From e33201ce5c67b0d74dc000620a7917e7eee80d90 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 14:35:03 +0900 Subject: [PATCH] Improve Wan castle escape battle pacing --- data/scenarios/008_wan_castle_escape.json | 177 ++++++++++++++++++++-- tools/smoke_objective_progress.gd | 154 +++++++++++++++++++ 2 files changed, 316 insertions(+), 15 deletions(-) diff --git a/data/scenarios/008_wan_castle_escape.json b/data/scenarios/008_wan_castle_escape.json index cd66304..fe636d5 100644 --- a/data/scenarios/008_wan_castle_escape.json +++ b/data/scenarios/008_wan_castle_escape.json @@ -2,20 +2,28 @@ "id": "008_wan_castle_escape", "name": "완성 탈출전", "objectives": { - "victory": "조조를 서쪽 행로 표식까지 이동시켜라.", - "defeat": "조조 또는 조앙이 퇴각하거나 제12군령에 이르면 패한다." + "victory": "서쪽 탈출로를 열고 완성 추격대를 격파하라.", + "defeat": "조조나 조앙이 퇴각하거나 제18군령이 시작되면 패한다." }, "conditions": { "victory": { - "type": "unit_reaches_tile", - "team": "player", - "officer_ids": ["cao_cao"], - "cells": [[1, 4], [0, 4]] + "type": "all", + "conditions": [ + { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_9_west_gate_hunt" }, + { + "type": "unit_reaches_tile", + "label": "서쪽 탈출로", + "team": "player", + "unit_ids": ["cao_cao_ch8"], + "officer_ids": ["cao_cao"], + "cells": [[1, 4], [0, 4]] + } + ] }, "defeat": [ { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, { "type": "any_unit_defeated", "unit_ids": ["cao_ang_ch8"] }, - { "type": "turn_reached", "turn": 12, "team": "player" } + { "type": "turn_reached", "turn": 18, "team": "player" } ] }, "briefing": { @@ -23,7 +31,8 @@ "location": "완성, 건안 이년", "lines": [ "장수가 조조를 완성으로 맞아들였으나, 가후의 계책은 잔치를 함정으로 바꾸었다.", - "동문이 등 뒤에서 닫히고 있다. 매복이 조여 오기 전에 조앙을 지키며 조조를 서쪽 행로 표식까지 이끌어라." + "동문이 등 뒤에서 닫히고 있다. 매복이 조여 오기 전에 조앙을 지키며 조조를 서쪽 행로 표식까지 이끌어라.", + "제9군령의 서문 추격대까지 꺾어야 탈출로가 완전히 열린다." ], "conditional_lines": [ { @@ -182,11 +191,11 @@ "GGGGGHHGGGFFFFG", "GGFFGHHGGGFFFFG", "GGFFRRRRRRGGGGG", - "GGGGRGGGGGRRRGG", + "GCCGRGGGGGRRRGG", "RRRRRRRRRRRRRRR", - "GGGGRGGGGGRRRGG", - "GGFFRRRGGGFFFFG", - "GGFFGHHGGGFFFFG", + "GCCGRGGGGGRRRGG", + "GGTTRRRGGGFFFFG", + "GGTTGHHGGGFFFFG", "GGGGGHHGGGWWGGG", "GGGGGGGGGGWWGGG" ] @@ -291,6 +300,33 @@ "level": 8, "pos": [7, 7], "base": { "hp": 42, "atk": 15, "def": 7 } + }, + { + "unit_id": "wan_inner_spearman", + "name": "완성 창병", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [7, 3], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "wan_inner_guard", + "name": "완성 방패병", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [7, 5], + "base": { "hp": 46, "atk": 14, "def": 10 } + }, + { + "unit_id": "wan_west_archer", + "name": "서쪽 궁병", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [3, 2], + "base": { "hp": 34, "atk": 13, "def": 6 } } ], "events": [ @@ -331,8 +367,8 @@ }, { "type": "set_objective", - "victory": "서쪽 길을 뚫고 조조를 탈출 표식까지 이동시켜라.", - "defeat": "조조 또는 조앙이 퇴각하거나 제12군령에 이르면 패한다." + "victory": "서쪽 탈출로를 열고 완성 추격대를 격파하라.", + "defeat": "조조나 조앙이 퇴각하거나 제18군령이 시작되면 패한다." }, { "type": "set_ai_target_priority", @@ -433,7 +469,7 @@ { "id": "midroad_trap", "once": true, - "when": { "type": "unit_reaches_tile", "team": "player", "pos": [8, 4] }, + "when": { "type": "unit_reaches_tile", "team": "player", "unit_ids": ["cao_cao_ch8"], "officer_ids": ["cao_cao"], "pos": [8, 4] }, "actions": [ { "type": "dialogue", @@ -480,6 +516,117 @@ ] } ] + }, + { + "id": "turn_6_wan_crossfire", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 6 }, + "actions": [ + { "type": "log", "text": "완성 복병이 조앙의 후미를 끊으려 성 안쪽 길을 가른다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Jia Xu", "display_speaker": "가후", "text": "조조가 빠져나가려면 아들을 돌아보게 하십시오. 걸음을 늦추는 것은 창보다 두려운 미련입니다." }, + { "speaker": "Cao Ang", "display_speaker": "조앙", "text": "제 걱정으로 발을 늦추지 마십시오. 서쪽 길을 먼저 여십시오." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "wan_crossfire_archer", + "name": "협로 궁병", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [9, 2], + "base": { "hp": 34, "atk": 14, "def": 6 } + }, + { + "unit_id": "wan_crossfire_rider", + "name": "협로 기병", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [9, 6], + "base": { "hp": 42, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "wan_crossfire_bandit", + "name": "성안 도적", + "class_id": "bandit", + "team": "enemy", + "level": 8, + "pos": [4, 8], + "base": { "hp": 42, "atk": 15, "def": 7 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_id": "cao_ang_ch8", + "priority": 12, + "text": "완성 복병이 조앙의 퇴로를 노린다." + } + ] + }, + { + "id": "turn_9_west_gate_hunt", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, + "actions": [ + { + "type": "set_objective", + "victory": "서쪽 탈출로를 지키며 마지막 추격대를 격파하라.", + "defeat": "조조나 조앙이 퇴각하거나 제18군령이 시작되면 패한다." + }, + { "type": "log", "text": "서쪽 문가에 마지막 추격대가 들이닥친다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang Xiu", "display_speaker": "장수", "text": "서문을 열어 두었다고 길까지 내준 것은 아니다. 조조의 깃발을 문 앞에서 꺾어라." }, + { "speaker": "Dian Wei", "display_speaker": "전위", "text": "문 앞에 남은 자들은 제가 치우겠습니다. 주공은 조앙과 함께 빠져나가십시오." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "west_gate_hunt_rider", + "name": "서문 추격기", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [1, 2], + "base": { "hp": 48, "atk": 17, "def": 8, "agi": 12 } + }, + { + "unit_id": "west_gate_hunt_guard", + "name": "서문 방패병", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [3, 4], + "base": { "hp": 48, "atk": 15, "def": 10 } + }, + { + "unit_id": "west_gate_hunt_archer", + "name": "서문 궁병", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [1, 6], + "base": { "hp": 36, "atk": 14, "def": 6 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_ids": ["cao_cao_ch8", "cao_ang_ch8"], + "priority": 14, + "text": "마지막 추격대가 조조와 조앙을 함께 노린다." + } + ] } ], "rewards": { diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index ccaacc9..392adc0 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -121,6 +121,24 @@ func _init() -> void: _defeat_text("res://data/scenarios/007_xian_emperor_escort.json"), "천자 사자 건재" ) + _check_contains( + failures, + "008 gated victory progress", + _progress_text("res://data/scenarios/008_wan_castle_escape.json", false), + "제9군령에 전공 갱신" + ) + _check_contains( + failures, + "008 destination progress", + _progress_text("res://data/scenarios/008_wan_castle_escape.json", false), + "서쪽 탈출로 (2, 5), (1, 5) · 미도달" + ) + _check_contains( + failures, + "008 protected-unit risk progress", + _defeat_text("res://data/scenarios/008_wan_castle_escape.json"), + "조앙 건재" + ) _check_contains( failures, "045 gated destination progress", @@ -137,6 +155,7 @@ func _init() -> void: _check_puyang_raid_extended_pressure(failures) _check_dingtao_counterattack_extended_pressure(failures) _check_xian_emperor_escort_extended_pressure(failures) + _check_wan_castle_escape_extended_pressure(failures) _check_objective_update(failures) _check_objective_notice(failures) _check_terrain_recovery(failures) @@ -751,6 +770,141 @@ func _check_xian_emperor_escort_extended_pressure(failures: Array[String]) -> vo scene.free() +func _check_wan_castle_escape_extended_pressure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): + failures.append("Wan Castle Escape pressure smoke could not load scenario") + return + if state.get_turn_limit() != 17: + failures.append("Wan Castle Escape should allow play through turn 17, got limit %d" % state.get_turn_limit()) + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 9: + failures.append("Wan Castle Escape should start with 9 enemies, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if state._condition_gate_open({"after_event": "turn_9_west_gate_hunt"}): + failures.append("Wan Castle Escape final defeat-all condition should be gated until turn 9 west gate hunt") + var early_cao_cao: Dictionary = state.get_unit("cao_cao_ch8") + early_cao_cao["pos"] = Vector2i(1, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("Wan Castle Escape should not end before turn 9 west gate hunt, got %s" % str(state.battle_status)) + early_cao_cao["pos"] = Vector2i(12, 4) + var marker_cells := state.get_objective_cells() + for escape_cell in [Vector2i(1, 4), Vector2i(0, 4)]: + if not marker_cells.has(escape_cell): + failures.append("Wan Castle Escape should mark escape road cell %s: %s" % [str(escape_cell), str(marker_cells)]) + if state.get_objective_cell_label(escape_cell) != "서쪽 탈출로": + failures.append("Wan Castle Escape escape marker should expose label at %s" % str(escape_cell)) + var recovery_summaries := state.get_recovery_terrain_summaries() + var has_gate_recovery := false + var has_village_recovery := false + for summary in recovery_summaries: + if str(summary.get("name", "")) == "성채" and int(summary.get("count", 0)) == 4 and int(summary.get("heal", 0)) == 8: + has_gate_recovery = true + if str(summary.get("name", "")) == "마을" and int(summary.get("count", 0)) == 4 and int(summary.get("heal", 0)) == 6: + has_village_recovery = true + if not has_gate_recovery: + failures.append("Wan Castle Escape should expose west gate recovery terrain: %s" % str(recovery_summaries)) + if not has_village_recovery: + failures.append("Wan Castle Escape should expose inner village recovery terrain: %s" % str(recovery_summaries)) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + if int(state.get_unit("cao_ang_ch8").get("ai_target_priority", 0)) != 10: + failures.append("Wan Castle Escape turn 2 gate close should target Cao Ang") + _assert_unit_at(failures, state, "east_gate_rider_north", Vector2i(13, 2), "Wan Castle Escape turn 2 north rider") + _assert_unit_at(failures, state, "east_gate_rider_south", Vector2i(13, 6), "Wan Castle Escape turn 2 south rider") + var cao_cao: Dictionary = state.get_unit("cao_cao_ch8") + cao_cao["pos"] = Vector2i(8, 4) + state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 1, cao_cao) + _assert_unit_at(failures, state, "midroad_wan_archer_north", Vector2i(8, 2), "Wan Castle Escape midroad north archer") + _assert_unit_at(failures, state, "midroad_wan_archer_south", Vector2i(8, 6), "Wan Castle Escape midroad south archer") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + if int(state.get_unit("cao_ang_ch8").get("ai_target_priority", 0)) != 12: + failures.append("Wan Castle Escape turn 6 crossfire should target Cao Ang") + _assert_unit_at(failures, state, "wan_crossfire_archer", Vector2i(9, 2), "Wan Castle Escape turn 6 archer") + _assert_unit_at(failures, state, "wan_crossfire_rider", Vector2i(9, 6), "Wan Castle Escape turn 6 rider") + _assert_unit_at(failures, state, "wan_crossfire_bandit", Vector2i(4, 8), "Wan Castle Escape turn 6 bandit") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if not state._condition_gate_open({"after_event": "turn_9_west_gate_hunt"}): + failures.append("Wan Castle Escape final gate should open after turn 9 west gate hunt") + _assert_unit_at(failures, state, "west_gate_hunt_rider", Vector2i(1, 2), "Wan Castle Escape turn 9 rider") + _assert_unit_at(failures, state, "west_gate_hunt_guard", Vector2i(3, 4), "Wan Castle Escape turn 9 guard") + _assert_unit_at(failures, state, "west_gate_hunt_archer", Vector2i(1, 6), "Wan Castle Escape turn 9 archer") + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 19: + failures.append("Wan Castle Escape should field 19 enemies after midroad trap and west hunt, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if int(state.get_unit("cao_cao_ch8").get("ai_target_priority", 0)) != 14: + failures.append("Wan Castle Escape turn 9 west gate hunt should target Cao Cao") + if int(state.get_unit("cao_ang_ch8").get("ai_target_priority", 0)) != 14: + failures.append("Wan Castle Escape turn 9 west gate hunt should keep targeting Cao Ang") + for enemy in state.get_living_units(BattleStateScript.TEAM_ENEMY): + enemy["alive"] = false + cao_cao["pos"] = Vector2i(2, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("Wan Castle Escape should not end before the escape road is reached, got %s" % str(state.battle_status)) + cao_cao["pos"] = Vector2i(1, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_VICTORY: + failures.append("Wan Castle Escape should end after west hunt falls and escape road is reached, got %s" % str(state.battle_status)) + var no_trap_state = BattleStateScript.new() + if not no_trap_state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): + failures.append("Wan Castle Escape no-trap smoke could not load scenario") + return + no_trap_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + no_trap_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + no_trap_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if no_trap_state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 17: + failures.append("Wan Castle Escape should field 17 enemies without the midroad trap, got %d" % no_trap_state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + var pursued_state = BattleStateScript.new() + if not pursued_state.load_battle("res://data/scenarios/008_wan_castle_escape.json", {}, {}, {"pursued_li_jue_remnants": true}): + failures.append("Wan Castle Escape pursued branch smoke could not load scenario") + return + pursued_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + pursued_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) + var pursued_cao_cao: Dictionary = pursued_state.get_unit("cao_cao_ch8") + pursued_cao_cao["pos"] = Vector2i(8, 4) + pursued_state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 1, pursued_cao_cao) + pursued_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + pursued_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if pursued_state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 21: + failures.append("Wan Castle Escape pursued branch should field 21 enemies after west hunt, got %d" % pursued_state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + var court_state = BattleStateScript.new() + if not court_state.load_battle("res://data/scenarios/008_wan_castle_escape.json", {}, {}, {"secured_imperial_court": true}): + failures.append("Wan Castle Escape court branch smoke could not load scenario") + return + court_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + if court_state.get_unit("imperial_guard_support_ch8").is_empty(): + failures.append("Wan Castle Escape court branch should add imperial guard support") + else: + _assert_unit_at(failures, court_state, "imperial_guard_support_ch8", Vector2i(9, 4), "Wan Castle Escape court support") + var defeat_state = BattleStateScript.new() + if not defeat_state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): + failures.append("Wan Castle Escape defeat smoke could not load scenario") + return + var cao_ang: Dictionary = defeat_state.get_unit("cao_ang_ch8") + cao_ang["alive"] = false + defeat_state._check_battle_status() + if str(defeat_state.battle_status) != BattleStateScript.STATUS_DEFEAT: + failures.append("Wan Castle Escape should fail when Cao Ang is defeated, got %s" % str(defeat_state.battle_status)) + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): + failures.append("Wan Castle Escape briefing smoke could not load scenario") + scene.free() + return + var overview := scene._format_briefing_battlefield_overview_text() + _check_contains(failures, "008 briefing map size", overview, "15칸 x 10칸") + _check_contains(failures, "008 briefing enemy count", overview, "적세 9명") + _check_contains(failures, "008 briefing village recovery", overview, "마을 4칸 +6") + _check_contains(failures, "008 briefing gate recovery", overview, "성채 4칸 +8") + scene.free() + + +func _assert_unit_at(failures: Array[String], state, unit_id: String, expected_cell: Vector2i, label: String) -> void: + var unit: Dictionary = state.get_unit(unit_id) + if unit.is_empty() or not bool(unit.get("alive", false)) or not bool(unit.get("deployed", true)): + failures.append("%s should deploy %s" % [label, unit_id]) + return + if unit.get("pos", Vector2i(-999, -999)) != expected_cell: + failures.append("%s should be at %s, got %s" % [label, str(expected_cell), str(unit.get("pos", Vector2i(-999, -999)))]) + + func _check_opening_board_avoids_side_panel(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):