diff --git a/data/scenarios/009_xiapi_siege.json b/data/scenarios/009_xiapi_siege.json index e6b251c..400059d 100644 --- a/data/scenarios/009_xiapi_siege.json +++ b/data/scenarios/009_xiapi_siege.json @@ -2,14 +2,27 @@ "id": "009_xiapi_siege", "name": "하비 수공전", "objectives": { - "victory": "수문 작전이 끝날 때까지 버틴 뒤 여포군을 격파하라.", - "defeat": "조조가 퇴각하거나 제13군령에 이르면 패한다." + "victory": "수문 포위선을 조여 여포의 최종 돌파를 격파하라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." }, "conditions": { - "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_flood_gates" }, + "victory": { + "type": "all", + "conditions": [ + { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_9_lu_bu_final_breakout" }, + { + "type": "unit_reaches_tile", + "label": "하비 포위선", + "team": "player", + "unit_ids": ["cao_cao_ch9", "dian_wei_ch9", "xiahou_dun_ch9", "xiahou_yuan_ch9", "cao_ren_ch9"], + "officer_ids": ["cao_cao", "dian_wei", "xiahou_dun", "xiahou_yuan", "cao_ren"], + "cells": [[8, 4], [9, 4], [8, 5], [9, 5]] + } + ] + }, "defeat": [ { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, - { "type": "turn_reached", "turn": 13, "team": "player" } + { "type": "turn_reached", "turn": 18, "team": "player" } ] }, "briefing": { @@ -17,7 +30,8 @@ "location": "하비, 건안 삼년", "lines": [ "여포는 하비의 젖은 성벽 뒤로 물러나 기병과 진궁의 계책으로 포위를 깨려 한다.", - "조조는 물길을 열라 명한다. 수문 신호가 올라 여포군을 낮은 길로 끌어낼 때까지 첫 반격을 버텨라." + "조조는 물길을 열라 명한다. 수문 신호가 올라 여포군을 낮은 길로 끌어낼 때까지 첫 반격을 버텨라.", + "제9군령에 여포가 마지막 돌파를 걸어오면 하비 포위선을 조이고 남은 수비대를 격파하라." ], "conditional_lines": [ { @@ -175,11 +189,11 @@ "GGGGGHHGGGWWGGG", "GGGFFHHGGGWWGGG", "GGGFFRRRRGGGGGG", - "GGGGGRRRGGFFFGG", + "GGGGGRRRGGCCFGG", "RRRRRRRRRRRRRRR", - "GGGGGRRRGGFFFGG", - "GGHHGFFFGGGRRGG", - "GGHHGFFFGGGWWGG", + "GGGGGRRRGGCCFGG", + "GGHHTTFFGGGRRGG", + "GGHHTTFFGGGWWGG", "GGGGGGFFGGGWWGG", "GGGGGGGGGGGGGGG" ] @@ -281,6 +295,33 @@ "level": 8, "pos": [12, 6], "base": { "hp": 46, "atk": 15, "def": 10 } + }, + { + "unit_id": "xiapi_mud_spearman", + "name": "진흙길 창병", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [8, 3], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "xiapi_lowroad_archer", + "name": "낮은 길 궁병", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [8, 5], + "base": { "hp": 34, "atk": 13, "def": 6 } + }, + { + "unit_id": "xiapi_gate_rider", + "name": "성문 기병", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [13, 4], + "base": { "hp": 42, "atk": 16, "def": 8, "agi": 11 } } ], "events": [ @@ -359,8 +400,8 @@ }, { "type": "set_objective", - "victory": "여포, 진궁, 남은 하비 수비대를 격파하라.", - "defeat": "조조가 퇴각하거나 제13군령에 이르면 패한다." + "victory": "하비 포위선을 조이고 여포의 최종 돌파를 기다려라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." }, { "type": "withdraw_units", @@ -375,7 +416,7 @@ "class_id": "infantry", "team": "enemy", "level": 8, - "pos": [8, 4], + "pos": [7, 4], "base": { "hp": 42, "atk": 14, "def": 9 } }, { @@ -444,6 +485,117 @@ ] } ] + }, + { + "id": "turn_6_siege_line_counter", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 6 }, + "actions": [ + { "type": "log", "text": "진궁이 물길 사이로 남은 기병을 보내 포위선의 빈틈을 찌른다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Chen Gong", "display_speaker": "진궁", "text": "물이 길을 막는다면, 아직 젖지 않은 길로 조조의 장수를 흔드십시오." }, + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "text": "남은 길이 좁을수록 좋습니다. 좁은 곳에서는 겁먹은 말보다 사람이 먼저 섭니다." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "siege_line_counter_rider", + "name": "포위선 기병", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [6, 1], + "base": { "hp": 42, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "siege_line_counter_guard", + "name": "포위선 창병", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [6, 8], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "siege_line_counter_archer", + "name": "포위선 궁병", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [9, 8], + "base": { "hp": 34, "atk": 14, "def": 6 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_id": "xiahou_dun_ch9", + "priority": 10, + "text": "하비 역습대가 하후돈의 포위선을 노린다." + } + ] + }, + { + "id": "turn_9_lu_bu_final_breakout", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, + "actions": [ + { + "type": "set_objective", + "victory": "하비 포위선을 붙들고 여포의 마지막 돌파를 격파하라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." + }, + { "type": "log", "text": "여포가 마지막 마른 길을 찾아 포위선에 돌격한다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "display_speaker": "여포", "text": "물도 성도 나를 가두지 못한다. 마지막 길을 열어라!" }, + { "speaker": "Cao Cao", "display_speaker": "조조", "text": "그 길은 이미 우리 포위선 안에 있다. 여포의 돌파를 여기서 끝내라." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "lu_bu_final_breakout_rider", + "name": "최종 돌파 기병", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 10, + "pos": [13, 3], + "base": { "hp": 54, "atk": 19, "def": 9, "agi": 12 } + }, + { + "unit_id": "lu_bu_final_breakout_guard", + "name": "최종 돌파 창병", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [13, 5], + "base": { "hp": 46, "atk": 16, "def": 10 } + }, + { + "unit_id": "lu_bu_final_breakout_archer", + "name": "최종 돌파 궁병", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [13, 7], + "base": { "hp": 36, "atk": 14, "def": 6 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_ids": ["cao_cao_ch9", "xiahou_dun_ch9"], + "priority": 13, + "text": "여포의 마지막 돌파가 조조와 하후돈을 노린다." + } + ] } ], "rewards": { diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index 392adc0..190eec4 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -139,6 +139,18 @@ func _init() -> void: _defeat_text("res://data/scenarios/008_wan_castle_escape.json"), "조앙 건재" ) + _check_contains( + failures, + "009 gated victory progress", + _progress_text("res://data/scenarios/009_xiapi_siege.json", false), + "제9군령에 전공 갱신" + ) + _check_contains( + failures, + "009 siege line progress", + _progress_text("res://data/scenarios/009_xiapi_siege.json", false), + "하비 포위선" + ) _check_contains( failures, "045 gated destination progress", @@ -156,6 +168,7 @@ func _init() -> void: _check_dingtao_counterattack_extended_pressure(failures) _check_xian_emperor_escort_extended_pressure(failures) _check_wan_castle_escape_extended_pressure(failures) + _check_xiapi_siege_extended_pressure(failures) _check_objective_update(failures) _check_objective_notice(failures) _check_terrain_recovery(failures) @@ -896,6 +909,113 @@ func _check_wan_castle_escape_extended_pressure(failures: Array[String]) -> void scene.free() +func _check_xiapi_siege_extended_pressure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("Xiapi Siege pressure smoke could not load scenario") + return + if state.get_turn_limit() != 17: + failures.append("Xiapi Siege should allow play through turn 17, got limit %d" % state.get_turn_limit()) + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 10: + failures.append("Xiapi Siege should start with 10 enemies, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if state._condition_gate_open({"after_event": "turn_9_lu_bu_final_breakout"}): + failures.append("Xiapi Siege final defeat-all condition should be gated until turn 9 final breakout") + var early_state = BattleStateScript.new() + if not early_state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("Xiapi Siege early victory smoke could not load scenario") + return + var early_cao_cao: Dictionary = early_state.get_unit("cao_cao_ch9") + early_cao_cao["pos"] = Vector2i(8, 4) + for enemy in early_state.get_living_units(BattleStateScript.TEAM_ENEMY): + enemy["alive"] = false + early_state._check_battle_status() + if str(early_state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("Xiapi Siege should not end before turn 9 final breakout, got %s" % str(early_state.battle_status)) + var cao_cao: Dictionary = state.get_unit("cao_cao_ch9") + var marker_cells := state.get_objective_cells() + for siege_cell in [Vector2i(8, 4), Vector2i(9, 4), Vector2i(8, 5), Vector2i(9, 5)]: + if not marker_cells.has(siege_cell): + failures.append("Xiapi Siege should mark siege line cell %s: %s" % [str(siege_cell), str(marker_cells)]) + if state.get_objective_cell_label(siege_cell) != "하비 포위선": + failures.append("Xiapi Siege marker should expose siege line label at %s" % str(siege_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("Xiapi Siege should expose flooded gate recovery terrain: %s" % str(recovery_summaries)) + if not has_village_recovery: + failures.append("Xiapi Siege should expose siege camp recovery terrain: %s" % str(recovery_summaries)) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + _assert_unit_at(failures, state, "lu_bu_flank_rider_north", Vector2i(6, 1), "Xiapi Siege turn 2 north rider") + _assert_unit_at(failures, state, "lu_bu_flank_rider_south", Vector2i(6, 8), "Xiapi Siege turn 2 south rider") + state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + if not state._condition_gate_open({"after_event": "turn_3_flood_gates"}): + failures.append("Xiapi Siege flood gate event should fire on turn 3") + if bool(state.get_unit("lu_bu_flank_rider_north").get("deployed", true)): + failures.append("Xiapi Siege turn 3 flood should withdraw north rider") + if bool(state.get_unit("lu_bu_flank_rider_south").get("deployed", true)): + failures.append("Xiapi Siege turn 3 flood should withdraw south rider") + _assert_unit_at(failures, state, "flooded_gate_guard", Vector2i(7, 4), "Xiapi Siege turn 3 gate guard") + _assert_unit_at(failures, state, "flooded_wall_archer", Vector2i(8, 6), "Xiapi Siege turn 3 wall archer") + if state._condition_gate_open({"after_event": "turn_9_lu_bu_final_breakout"}): + failures.append("Xiapi Siege final gate should still be closed after flood gate event") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + if int(state.get_unit("xiahou_dun_ch9").get("ai_target_priority", 0)) != 10: + failures.append("Xiapi Siege turn 6 counter should target Xiahou Dun") + _assert_unit_at(failures, state, "siege_line_counter_rider", Vector2i(6, 1), "Xiapi Siege turn 6 rider") + _assert_unit_at(failures, state, "siege_line_counter_guard", Vector2i(6, 8), "Xiapi Siege turn 6 guard") + _assert_unit_at(failures, state, "siege_line_counter_archer", Vector2i(9, 8), "Xiapi Siege turn 6 archer") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if not state._condition_gate_open({"after_event": "turn_9_lu_bu_final_breakout"}): + failures.append("Xiapi Siege final gate should open after turn 9 final breakout") + _assert_unit_at(failures, state, "lu_bu_final_breakout_rider", Vector2i(13, 3), "Xiapi Siege turn 9 rider") + _assert_unit_at(failures, state, "lu_bu_final_breakout_guard", Vector2i(13, 5), "Xiapi Siege turn 9 guard") + _assert_unit_at(failures, state, "lu_bu_final_breakout_archer", Vector2i(13, 7), "Xiapi Siege turn 9 archer") + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 18: + failures.append("Xiapi Siege should field 18 enemies after final breakout, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if int(state.get_unit("cao_cao_ch9").get("ai_target_priority", 0)) != 13: + failures.append("Xiapi Siege turn 9 final breakout should target Cao Cao") + if int(state.get_unit("xiahou_dun_ch9").get("ai_target_priority", 0)) != 13: + failures.append("Xiapi Siege turn 9 final breakout should target Xiahou Dun") + for enemy in state.get_living_units(BattleStateScript.TEAM_ENEMY): + enemy["alive"] = false + cao_cao["pos"] = Vector2i(7, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("Xiapi Siege should not end before the siege line is held, got %s" % str(state.battle_status)) + cao_cao["pos"] = Vector2i(8, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_VICTORY: + failures.append("Xiapi Siege should end after final breakout falls and the siege line is held, got %s" % str(state.battle_status)) + var swift_state = BattleStateScript.new() + if not swift_state.load_battle("res://data/scenarios/009_xiapi_siege.json", {}, {}, {"swift_wan_escape": true}): + failures.append("Xiapi Siege swift branch smoke could not load scenario") + return + swift_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + swift_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + swift_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4) + swift_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + swift_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if swift_state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 19: + failures.append("Xiapi Siege swift branch should field 19 enemies after final breakout, got %d" % swift_state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("Xiapi Siege briefing smoke could not load scenario") + scene.free() + return + var overview := scene._format_briefing_battlefield_overview_text() + _check_contains(failures, "009 briefing map size", overview, "15칸 x 10칸") + _check_contains(failures, "009 briefing enemy count", overview, "적세 10명") + _check_contains(failures, "009 briefing village recovery", overview, "마을 4칸 +6") + _check_contains(failures, "009 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)):