From c26b052c90c3ceb662811322b16b337aa7bf0fd4 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 20 Jun 2026 07:06:47 +0900 Subject: [PATCH] Add sentry pacing to opening battle --- data/scenarios/001_yellow_turbans.json | 81 +++++++++++++++++++++++++- scripts/core/battle_state.gd | 70 +++++++++++++++++++++- scripts/core/data_catalog.gd | 12 ++++ scripts/scenes/battle_scene.gd | 22 ++++--- tools/smoke_chapter_one_polish.gd | 8 +++ tools/smoke_event_ai_priority.gd | 44 ++++++++++++++ tools/validate_data.ps1 | 52 ++++++++++++++++- 7 files changed, 277 insertions(+), 12 deletions(-) diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index cda70e0..cde5b09 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -317,6 +317,13 @@ 12, 5 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 12, + 5 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 5, "base": { "hp": 36, "atk": 10, @@ -333,6 +340,13 @@ 17, 3 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 17, + 3 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 6, "base": { "hp": 28, "atk": 9, @@ -354,7 +368,7 @@ 7, 5 ], - "ai_guard_radius": 2, + "ai_guard_radius": 3, "base": { "hp": 38, "atk": 9, @@ -371,6 +385,13 @@ 10, 6 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 10, + 6 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 4, "base": { "hp": 34, "atk": 11, @@ -387,6 +408,13 @@ 15, 7 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 15, + 7 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 6, "base": { "hp": 28, "atk": 9, @@ -425,6 +453,13 @@ 14, 8 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 14, + 8 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 6, "base": { "hp": 34, "atk": 11, @@ -441,6 +476,13 @@ 9, 7 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 9, + 7 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 4, "base": { "hp": 34, "atk": 9, @@ -457,6 +499,13 @@ 11, 4 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 11, + 4 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 5, "base": { "hp": 28, "atk": 9, @@ -473,6 +522,13 @@ 13, 7 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 13, + 7 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 5, "base": { "hp": 34, "atk": 11, @@ -489,6 +545,13 @@ 7, 2 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 7, + 2 + ], + "ai_sentry_radius": 3, + "ai_sentry_activation_turn": 6, "base": { "hp": 34, "atk": 9, @@ -510,7 +573,7 @@ 8, 6 ], - "ai_guard_radius": 2, + "ai_guard_radius": 3, "base": { "hp": 36, "atk": 10, @@ -527,6 +590,13 @@ 18, 5 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 18, + 5 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 7, "base": { "hp": 28, "atk": 9, @@ -543,6 +613,13 @@ 16, 4 ], + "ai_behavior": "sentry", + "ai_sentry_anchor": [ + 16, + 4 + ], + "ai_sentry_radius": 5, + "ai_sentry_activation_turn": 7, "base": { "hp": 34, "atk": 11, diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 4d29b0e..d2d4392 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -607,7 +607,7 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary: unit["persist_progression"] = bool(unit.get("persist_progression", true)) unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0))) unit["ai_behavior"] = str(unit.get("ai_behavior", "")).strip_edges().to_lower() - if unit["ai_behavior"] != "guard": + if not ["guard", "sentry"].has(unit["ai_behavior"]): unit["ai_behavior"] = "" if unit["ai_behavior"] == "guard" and not unit.has("ai_guard_radius"): unit["ai_guard_radius"] = 2 @@ -616,6 +616,15 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary: if not is_inside(guard_anchor): guard_anchor = unit["pos"] unit["ai_guard_anchor"] = guard_anchor + if unit["ai_behavior"] == "sentry" and not unit.has("ai_sentry_radius"): + unit["ai_sentry_radius"] = 4 + unit["ai_sentry_radius"] = max(0, int(unit.get("ai_sentry_radius", 0))) + unit["ai_sentry_activation_turn"] = max(0, int(unit.get("ai_sentry_activation_turn", 0))) + var sentry_anchor := _condition_cell(unit.get("ai_sentry_anchor", [unit["pos"].x, unit["pos"].y])) + if not is_inside(sentry_anchor): + sentry_anchor = unit["pos"] + unit["ai_sentry_anchor"] = sentry_anchor + unit["ai_awake"] = bool(unit.get("ai_awake", false)) unit["status_effects"] = [] unit["acted"] = false unit["moved"] = false @@ -3117,6 +3126,11 @@ func _run_enemy_turn() -> void: func _enemy_take_action(enemy: Dictionary) -> void: var movement_cells := _movement_range_for_unit(enemy) + if _is_ai_sentry(enemy) and not _ai_sentry_is_awake(enemy, movement_cells): + var sentry_return_cell := _best_ai_sentry_return_cell(enemy, movement_cells) + if sentry_return_cell != enemy["pos"]: + _move_ai_unit(enemy, sentry_return_cell) + return if _is_ai_guard(enemy): movement_cells = _ai_guard_movement_cells(enemy, movement_cells) if not _ai_guard_is_engaged(enemy, movement_cells): @@ -3185,6 +3199,60 @@ func _is_ai_guard(unit: Dictionary) -> bool: return str(unit.get("ai_behavior", "")).strip_edges().to_lower() == "guard" +func _is_ai_sentry(unit: Dictionary) -> bool: + return str(unit.get("ai_behavior", "")).strip_edges().to_lower() == "sentry" + + +func _ai_sentry_anchor(unit: Dictionary) -> Vector2i: + var anchor: Vector2i = unit.get("ai_sentry_anchor", unit.get("pos", Vector2i(-1, -1))) + if not is_inside(anchor): + return unit.get("pos", Vector2i(-1, -1)) + return anchor + + +func _ai_sentry_radius(unit: Dictionary) -> int: + return max(0, int(unit.get("ai_sentry_radius", 0))) + + +func _ai_sentry_contains_cell(unit: Dictionary, cell: Vector2i) -> bool: + return _manhattan(_ai_sentry_anchor(unit), cell) <= _ai_sentry_radius(unit) + + +func _ai_sentry_is_awake(unit: Dictionary, movement_cells: Array[Vector2i]) -> bool: + if bool(unit.get("ai_awake", false)): + return true + var activation_turn := int(unit.get("ai_sentry_activation_turn", 0)) + if activation_turn > 0 and turn_number >= activation_turn: + unit["ai_awake"] = true + return true + var target_team := _opposing_team(str(unit.get("team", ""))) + if target_team.is_empty(): + return false + for target in get_living_units(target_team): + if _ai_sentry_contains_cell(unit, target.get("pos", Vector2i(-1, -1))): + unit["ai_awake"] = true + return true + if not _best_ai_physical_attack_action(unit, [unit.get("pos", Vector2i(-1, -1))]).is_empty(): + unit["ai_awake"] = true + return true + if not movement_cells.is_empty() and not _best_ai_physical_attack_action(unit, movement_cells).is_empty(): + unit["ai_awake"] = true + return true + return false + + +func _best_ai_sentry_return_cell(unit: Dictionary, movement_cells: Array[Vector2i]) -> Vector2i: + var best_cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) + var anchor := _ai_sentry_anchor(unit) + var best_distance := _manhattan(best_cell, anchor) + for cell in movement_cells: + var distance := _manhattan(cell, anchor) + if distance < best_distance: + best_distance = distance + best_cell = cell + return best_cell + + func _ai_guard_anchor(unit: Dictionary) -> Vector2i: var anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1))) if not is_inside(anchor): diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index e730340..4007bc4 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -63,6 +63,18 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: unit["ai_guard_radius"] = int(deployment.get("ai_guard_radius", 0)) elif unit["ai_behavior"] == "guard": unit["ai_guard_radius"] = 2 + if deployment.has("ai_sentry_anchor"): + unit["ai_sentry_anchor"] = deployment["ai_sentry_anchor"] + elif unit["ai_behavior"] == "sentry": + unit["ai_sentry_anchor"] = deployment.get("pos", [0, 0]) + if deployment.has("ai_sentry_radius"): + unit["ai_sentry_radius"] = int(deployment.get("ai_sentry_radius", 0)) + elif unit["ai_behavior"] == "sentry": + unit["ai_sentry_radius"] = 4 + if deployment.has("ai_sentry_activation_turn"): + unit["ai_sentry_activation_turn"] = int(deployment.get("ai_sentry_activation_turn", 0)) + if deployment.has("ai_awake"): + unit["ai_awake"] = bool(deployment.get("ai_awake", false)) unit["level"] = int(deployment.get("level", officer.get("level", 1))) unit["exp"] = int(deployment.get("exp", officer.get("exp", 0))) unit["max_hp"] = int(stats.get("hp", stats.get("max_hp", 1))) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 226090d..d0e282f 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -9083,13 +9083,21 @@ func _unit_class_display_name(unit: Dictionary) -> String: func _unit_ai_behavior_text(unit: Dictionary) -> String: if str(unit.get("team", "")) != BattleState.TEAM_ENEMY: return "" - if str(unit.get("ai_behavior", "")).strip_edges().to_lower() != "guard": - return "" - var anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1))) - var radius := int(unit.get("ai_guard_radius", 0)) - if not state.is_inside(anchor): - return "수비: 거점 경계" - return "수비: 거점 %d,%d 반경 %d" % [anchor.x + 1, anchor.y + 1, radius] + var behavior := str(unit.get("ai_behavior", "")).strip_edges().to_lower() + if behavior == "guard": + var guard_anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1))) + var guard_radius := int(unit.get("ai_guard_radius", 0)) + if not state.is_inside(guard_anchor): + return "수비: 거점 경계" + return "수비: 거점 %d,%d 반경 %d" % [guard_anchor.x + 1, guard_anchor.y + 1, guard_radius] + if behavior == "sentry": + var sentry_anchor: Vector2i = unit.get("ai_sentry_anchor", unit.get("pos", Vector2i(-1, -1))) + var sentry_radius := int(unit.get("ai_sentry_radius", 0)) + var wake_text := "기상" if bool(unit.get("ai_awake", false)) else "대기" + if not state.is_inside(sentry_anchor): + return "초병: %s" % wake_text + return "초병: %s %d,%d 반경 %d" % [wake_text, sentry_anchor.x + 1, sentry_anchor.y + 1, sentry_radius] + return "" func _unit_current_terrain_text(unit: Dictionary) -> String: diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index e7d70f6..7841cfe 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -698,6 +698,14 @@ func _check_opening_battle_tactical_spacing(failures: Array[String]) -> void: var origin: Vector2i = scene._best_auto_attack_origin(player, enemy) if origin != Vector2i(-1, -1): failures.append("opening battle should not allow immediate move-attack from %s to %s via %s" % [player_id, str(enemy.get("id", "")), str(origin)]) + var front_guard: Dictionary = scene.state.get_unit("yellow_turban_4") + if str(front_guard.get("ai_behavior", "")) != "guard" or int(front_guard.get("ai_guard_radius", 0)) < 3: + failures.append("opening front guard should react to the lure line without charging from turn one") + var central_sentry: Dictionary = scene.state.get_unit("yellow_turban_5") + if str(central_sentry.get("ai_behavior", "")) != "sentry" or int(central_sentry.get("ai_sentry_radius", 0)) < 5: + failures.append("opening central enemy should use sentry AI for lure-line pacing") + if bool(central_sentry.get("ai_awake", false)): + failures.append("opening central sentry should start asleep") scene.free() diff --git a/tools/smoke_event_ai_priority.gd b/tools/smoke_event_ai_priority.gd index 46caba2..e8662c7 100644 --- a/tools/smoke_event_ai_priority.gd +++ b/tools/smoke_event_ai_priority.gd @@ -15,6 +15,7 @@ func _init() -> void: _check_priority_changes_enemy_targeting(state, failures) _check_reinforcement_priority_events(failures) _check_guard_ai_behavior(failures) + _check_sentry_ai_behavior(failures) if failures.is_empty(): print("event ai priority smoke ok") @@ -130,6 +131,49 @@ func _check_guard_ai_behavior(failures: Array[String]) -> void: failures.append("guard target search should prefer targets inside the guard zone: %s" % str(target.get("id", ""))) +func _check_sentry_ai_behavior(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("Could not load stage 1 sentry AI scenario") + return + + var sentry: Dictionary = state.get_unit("yellow_turban_5") + if str(sentry.get("ai_behavior", "")) != "sentry": + failures.append("central raider should load sentry AI behavior") + if sentry.get("ai_sentry_anchor", Vector2i(-1, -1)) != Vector2i(10, 6): + failures.append("central raider should keep its sentry anchor: %s" % str(sentry.get("ai_sentry_anchor", null))) + if int(sentry.get("ai_sentry_radius", 0)) != 5: + failures.append("central raider should keep its sentry radius") + if int(sentry.get("ai_sentry_activation_turn", 0)) != 4: + failures.append("central raider should keep delayed activation turn") + + state._enemy_take_action(sentry) + if bool(sentry.get("ai_awake", false)): + failures.append("sentry should not wake while opening player positions are distant") + if sentry.get("pos", Vector2i.ZERO) != Vector2i(10, 6): + failures.append("sleeping sentry should hold its watch post: %s" % str(sentry.get("pos", null))) + + state.get_unit("cao_cao")["pos"] = Vector2i(5, 6) + state._enemy_take_action(sentry) + if not bool(sentry.get("ai_awake", false)): + failures.append("sentry should wake when a player enters the lure line") + if sentry.get("pos", Vector2i.ZERO) == Vector2i(10, 6): + failures.append("woken sentry should start pressing the lure-line target") + + var delayed_state = BattleStateScript.new() + if not delayed_state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("Could not load delayed sentry AI scenario") + return + var delayed_sentry: Dictionary = delayed_state.get_unit("yellow_turban_2") + delayed_state._enemy_take_action(delayed_sentry) + if bool(delayed_sentry.get("ai_awake", false)): + failures.append("delayed sentry should not wake before its activation turn") + delayed_state.turn_number = 5 + delayed_state._enemy_take_action(delayed_sentry) + if not bool(delayed_sentry.get("ai_awake", false)): + failures.append("delayed sentry should wake on its activation turn") + + func _feedback_has(feedback: Array[Dictionary], expected_unit_id: String, expected_text: String, expected_kind: String) -> bool: for entry in feedback: if ( diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 7e1330d..c519c6f 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -536,11 +536,11 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H } $aiBehavior = [string](Get-Prop $Deployment "ai_behavior" "") if (-not [string]::IsNullOrWhiteSpace($aiBehavior)) { - if ($aiBehavior -ne "guard") { + if ($aiBehavior -notin @("guard", "sentry")) { Fail "Scenario $ScenarioId deployment $unitId has unknown ai_behavior: $aiBehavior" } if ($team -ne "enemy") { - Fail "Scenario $ScenarioId deployment $unitId ai_behavior guard is only valid for enemy deployments." + Fail "Scenario $ScenarioId deployment $unitId ai_behavior $aiBehavior is only valid for enemy deployments." } } if ((Has-Prop $Deployment "ai_guard_anchor") -and $aiBehavior -ne "guard") { @@ -549,6 +549,15 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H if ((Has-Prop $Deployment "ai_guard_radius") -and $aiBehavior -ne "guard") { Fail "Scenario $ScenarioId deployment $unitId ai_guard_radius needs ai_behavior guard." } + if ((Has-Prop $Deployment "ai_sentry_anchor") -and $aiBehavior -ne "sentry") { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_anchor needs ai_behavior sentry." + } + if ((Has-Prop $Deployment "ai_sentry_radius") -and $aiBehavior -ne "sentry") { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_radius needs ai_behavior sentry." + } + if ((Has-Prop $Deployment "ai_sentry_activation_turn") -and $aiBehavior -ne "sentry") { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_activation_turn needs ai_behavior sentry." + } if (Has-Prop $Deployment "ai_guard_radius") { $guardRadiusValue = Get-Prop $Deployment "ai_guard_radius" 0 if (-not ($guardRadiusValue -is [int] -or $guardRadiusValue -is [long])) { @@ -559,6 +568,26 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H Fail "Scenario $ScenarioId deployment $unitId ai_guard_radius must be between 0 and 12." } } + if (Has-Prop $Deployment "ai_sentry_radius") { + $sentryRadiusValue = Get-Prop $Deployment "ai_sentry_radius" 0 + if (-not ($sentryRadiusValue -is [int] -or $sentryRadiusValue -is [long])) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_radius must be an integer." + } + $sentryRadius = [int]$sentryRadiusValue + if ($sentryRadius -lt 0 -or $sentryRadius -gt 12) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_radius must be between 0 and 12." + } + } + if (Has-Prop $Deployment "ai_sentry_activation_turn") { + $sentryActivationTurnValue = Get-Prop $Deployment "ai_sentry_activation_turn" 0 + if (-not ($sentryActivationTurnValue -is [int] -or $sentryActivationTurnValue -is [long])) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_activation_turn must be an integer." + } + $sentryActivationTurn = [int]$sentryActivationTurnValue + if ($sentryActivationTurn -lt 0 -or $sentryActivationTurn -gt 99) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_activation_turn must be between 0 and 99." + } + } $classId = Resolve-Class-Id $Deployment $ScenarioId if (-not $classIds.Contains($classId)) { @@ -606,6 +635,25 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H Fail "Scenario $ScenarioId deployment $unitId ai_guard_anchor is on impassable terrain." } } + if (Has-Prop $Deployment "ai_sentry_anchor") { + $sentryAnchor = Get-Prop $Deployment "ai_sentry_anchor" @() + if ($sentryAnchor.Count -lt 2) { + Fail "Scenario $ScenarioId deployment $unitId has malformed ai_sentry_anchor." + } + $sentryAnchorX = [int]$sentryAnchor[0] + $sentryAnchorY = [int]$sentryAnchor[1] + if ($sentryAnchorX -lt 0 -or $sentryAnchorY -lt 0 -or $sentryAnchorX -ge $Width -or $sentryAnchorY -ge $Height) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_anchor is outside map at [$sentryAnchorX,$sentryAnchorY]." + } + $sentryAnchorTerrainKey = Terrain-At $Rows $sentryAnchorX $sentryAnchorY + $sentryAnchorMoveCost = $terrain.$sentryAnchorTerrainKey.move_cost.$moveType + if ($null -eq $sentryAnchorMoveCost) { + $sentryAnchorMoveCost = $terrain.$sentryAnchorTerrainKey.move_cost.foot + } + if ([int]$sentryAnchorMoveCost -ge 99) { + Fail "Scenario $ScenarioId deployment $unitId ai_sentry_anchor is on impassable terrain." + } + } } function Add-Deployment-Unit-Id($Deployment, $Result) {