diff --git a/data/scenarios/009_xiapi_siege.json b/data/scenarios/009_xiapi_siege.json index 400059d..eb38637 100644 --- a/data/scenarios/009_xiapi_siege.json +++ b/data/scenarios/009_xiapi_siege.json @@ -360,6 +360,11 @@ { "speaker": "Chen Gong", "text": "전열이 휘면 장수를 치십시오. 수공의 계책도 그와 함께 죽습니다." } ] }, + { + "type": "set_objective", + "victory": "첫 돌격을 버티고 수문 신호가 오를 때까지 하비 포위선을 유지하라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." + }, { "type": "spawn_deployments", "deployments": [ @@ -499,6 +504,10 @@ { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "text": "남은 길이 좁을수록 좋습니다. 좁은 곳에서는 겁먹은 말보다 사람이 먼저 섭니다." } ] }, + { + "type": "set_objective", + "victory": "포위선 역습을 막고 여포의 마지막 돌파가 드러날 때까지 하비 포위선을 조여라." + }, { "type": "spawn_deployments", "deployments": [ @@ -596,6 +605,82 @@ "text": "여포의 마지막 돌파가 조조와 하후돈을 노린다." } ] + }, + { + "id": "gao_shun_trap_line_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["gao_shun_trap_line"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Gao Shun", "display_speaker": "고순", "side": "right", "text": "함진의 방진이 물러서다니... 이 진흙 속에서도 조조군의 포위가 흔들리지 않는구나." }, + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "left", "text": "고순의 방진이 무너졌습니다. 성문 앞의 적도 더는 마음대로 돌파하지 못합니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "잘 버텼다. 하비 포위선을 그대로 조이고 진궁의 계책과 여포의 돌파를 차례로 꺾어라." } + ] + }, + { + "type": "set_objective", + "victory": "고순의 방진을 꺾었다. 하비 포위선을 유지하며 진궁의 계책과 여포의 최종 돌파를 격파하라." + } + ] + }, + { + "id": "chen_gong_strategist_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["chen_gong_strategist"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Chen Gong", "display_speaker": "진궁", "side": "right", "text": "물이 길을 삼켰고, 계책도 더는 말발굽을 붙들지 못하는군요." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "진궁의 전열이 흔들렸다. 하비 포위선을 조이고 여포의 마지막 돌파를 기다려라." }, + { "speaker": "Dian Wei", "display_speaker": "전위", "side": "right", "text": "계책이 멎으면 남는 것은 힘입니다. 그 힘도 여기서 받겠습니다." } + ] + }, + { + "type": "set_objective", + "victory": "진궁의 계책을 꺾었다. 하비 포위선을 유지하며 여포의 최종 돌파를 격파하라." + } + ] + }, + { + "id": "lu_bu_xiapi_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["lu_bu_xiapi"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "display_speaker": "여포", "side": "right", "text": "이 물과 진흙이... 내 길을 여기서 끊는단 말이냐." }, + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "right", "text": "여포의 본대가 무너졌습니다. 포위선 안의 남은 돌파병도 흔들립니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "여포를 꺾었다. 방심하지 말고 하비 포위선을 붙들어 남은 수비대를 정리하라." } + ] + }, + { + "type": "set_objective", + "victory": "여포를 꺾었다. 하비 포위선을 붙들고 남은 수비대를 격파하라." + } + ] + }, + { + "id": "lu_bu_final_breakout_rider_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["lu_bu_final_breakout_rider"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "display_speaker": "여포", "side": "right", "text": "마지막 돌파마저 막히다니... 아직 성문은 완전히 닫히지 않았다!" }, + { "speaker": "Cao Ren", "display_speaker": "조인", "side": "right", "text": "최종 돌파의 앞줄이 꺾였습니다. 남은 병력이 성벽 쪽으로 밀립니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "마지막 돌파를 끊었다. 하비 포위선을 지키며 남은 적을 완전히 거두어라." } + ] + }, + { + "type": "set_objective", + "victory": "여포의 최종 돌파 선봉을 꺾었다. 하비 포위선을 지키며 남은 적을 격파하라." + } + ] } ], "rewards": { diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index cd35520..7b3425e 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -31,6 +31,7 @@ func _init() -> void: _check_dingtao_counterattack_event_dialogue_structure(failures) _check_xian_emperor_escort_event_dialogue_structure(failures) _check_wan_castle_escape_event_dialogue_structure(failures) + _check_xiapi_siege_event_dialogue_structure(failures) _check_opening_battle_story_beats(failures) _check_sishui_gate_story_beats(failures) _check_xingyang_ambush_story_beats(failures) @@ -39,6 +40,7 @@ func _init() -> void: _check_dingtao_counterattack_story_beats(failures) _check_xian_emperor_escort_story_beats(failures) _check_wan_castle_escape_story_beats(failures) + _check_xiapi_siege_story_beats(failures) _check_pixel_unit_helpers(failures) if failures.is_empty(): @@ -627,6 +629,82 @@ func _check_wan_castle_escape_event_dialogue_structure(failures: Array[String]) failures.append("Wan Castle Escape event should update the active objective: %s" % objective_event_id) +func _check_xiapi_siege_event_dialogue_structure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("could not load Xiapi Siege for event dialogue structure") + return + for event_id in [ + "opening_dialogue", + "turn_2_lu_bu_charge", + "turn_3_flood_gates", + "wan_gate_baggage_support", + "swift_wan_counterraid", + "turn_6_lu_bu_cornered", + "turn_6_siege_line_counter", + "turn_9_lu_bu_final_breakout", + "gao_shun_trap_line_falls", + "chen_gong_strategist_falls", + "lu_bu_xiapi_falls", + "lu_bu_final_breakout_rider_falls" + ]: + var event := _event_by_id(state, event_id) + if event.is_empty(): + failures.append("Xiapi Siege missing event: %s" % event_id) + continue + if not _event_has_dialogue_action(event): + failures.append("Xiapi Siege event should include contextual dialogue: %s" % event_id) + var gao_shun_event := _event_by_id(state, "gao_shun_trap_line_falls") + if not gao_shun_event.is_empty(): + var when: Dictionary = gao_shun_event.get("when", {}) + if str(when.get("type", "")) != "unit_defeated": + failures.append("Gao Shun trap line defeat event should trigger when the unit is defeated") + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("gao_shun_trap_line"): + failures.append("Gao Shun trap line defeat event should watch gao_shun_trap_line") + if not _event_has_set_objective_action(gao_shun_event): + failures.append("Gao Shun trap line defeat event should clarify the siege objective") + var chen_gong_event := _event_by_id(state, "chen_gong_strategist_falls") + if not chen_gong_event.is_empty(): + var when: Dictionary = chen_gong_event.get("when", {}) + if str(when.get("type", "")) != "unit_defeated": + failures.append("Chen Gong defeat event should trigger when the unit is defeated") + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("chen_gong_strategist"): + failures.append("Chen Gong defeat event should watch chen_gong_strategist") + if not _event_has_set_objective_action(chen_gong_event): + failures.append("Chen Gong defeat event should clarify the siege objective") + var lu_bu_event := _event_by_id(state, "lu_bu_xiapi_falls") + if not lu_bu_event.is_empty(): + var when: Dictionary = lu_bu_event.get("when", {}) + if str(when.get("type", "")) != "unit_defeated": + failures.append("Lu Bu Xiapi defeat event should trigger when the unit is defeated") + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("lu_bu_xiapi"): + failures.append("Lu Bu Xiapi defeat event should watch lu_bu_xiapi") + if not _event_has_set_objective_action(lu_bu_event): + failures.append("Lu Bu Xiapi defeat event should clarify the siege objective") + var final_breakout_event := _event_by_id(state, "lu_bu_final_breakout_rider_falls") + if not final_breakout_event.is_empty(): + var when: Dictionary = final_breakout_event.get("when", {}) + if str(when.get("type", "")) != "unit_defeated": + failures.append("Lu Bu final breakout rider event should trigger when the unit is defeated") + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("lu_bu_final_breakout_rider"): + failures.append("Lu Bu final breakout rider event should watch lu_bu_final_breakout_rider") + if not _event_has_set_objective_action(final_breakout_event): + failures.append("Lu Bu final breakout rider event should clarify the final siege objective") + for objective_event_id in [ + "turn_2_lu_bu_charge", + "turn_3_flood_gates", + "turn_6_siege_line_counter", + "turn_9_lu_bu_final_breakout" + ]: + var objective_event := _event_by_id(state, objective_event_id) + if not objective_event.is_empty() and not _event_has_set_objective_action(objective_event): + failures.append("Xiapi Siege event should update the active objective: %s" % objective_event_id) + + func _check_opening_battle_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): @@ -998,6 +1076,84 @@ func _check_wan_castle_escape_story_beats(failures: Array[String]) -> void: failures.append("West gate hunt rider defeat objective should still point to the escape road and remaining pursuers") +func _check_xiapi_siege_story_beats(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("could not load Xiapi Siege for story beat checks") + return + + var dialogue_batches: Array = [] + state.dialogue_requested.connect(func(lines: Array) -> void: + dialogue_batches.append(lines) + ) + + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 6) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + + if dialogue_batches.size() < 5: + failures.append("Xiapi Siege should add dialogue beats for each major flood siege phase") + var victory_text := str(state.objectives.get("victory", "")) + if not victory_text.contains("하비 포위선") or not victory_text.contains("마지막 돌파"): + failures.append("Xiapi Siege turn 9 event should update the victory objective for Lu Bu final breakout") + if not state._condition_gate_open({"after_event": "turn_3_flood_gates"}): + failures.append("Xiapi Siege flood gate story beat should open the flood gate event") + if not state._condition_gate_open({"after_event": "turn_9_lu_bu_final_breakout"}): + failures.append("Xiapi Siege final objective gate should open after the turn 9 final breakout") + if bool(state.get_unit("lu_bu_flank_rider_north").get("deployed", true)): + failures.append("Xiapi Siege flood gate story beat should withdraw the north flank rider") + + var gao_shun: Dictionary = state.get_unit("gao_shun_trap_line") + if gao_shun.is_empty(): + failures.append("Xiapi Siege should include Gao Shun trap line for the siege line collapse story beat") + else: + gao_shun["alive"] = false + state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, gao_shun) + if dialogue_batches.size() < 6: + failures.append("Xiapi Siege should show dialogue when Gao Shun trap line falls") + var gao_shun_objective_text := str(state.objectives.get("victory", "")) + if not gao_shun_objective_text.contains("하비 포위선") or not gao_shun_objective_text.contains("진궁"): + failures.append("Gao Shun trap line defeat objective should still point to the siege line and Chen Gong") + + var chen_gong: Dictionary = state.get_unit("chen_gong_strategist") + if chen_gong.is_empty(): + failures.append("Xiapi Siege should include Chen Gong for the scheme collapse story beat") + else: + chen_gong["alive"] = false + state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, chen_gong) + if dialogue_batches.size() < 7: + failures.append("Xiapi Siege should show dialogue when Chen Gong falls") + var chen_gong_objective_text := str(state.objectives.get("victory", "")) + if not chen_gong_objective_text.contains("하비 포위선") or not chen_gong_objective_text.contains("최종 돌파"): + failures.append("Chen Gong defeat objective should still point to the siege line and final breakout") + + var lu_bu: Dictionary = state.get_unit("lu_bu_xiapi") + if lu_bu.is_empty(): + failures.append("Xiapi Siege should include Lu Bu for the chapter climax story beat") + else: + lu_bu["alive"] = false + state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, lu_bu) + if dialogue_batches.size() < 8: + failures.append("Xiapi Siege should show dialogue when Lu Bu falls") + var lu_bu_objective_text := str(state.objectives.get("victory", "")) + if not lu_bu_objective_text.contains("하비 포위선") or not lu_bu_objective_text.contains("남은"): + failures.append("Lu Bu defeat objective should still point to the siege line and remaining defenders") + + var final_breakout: Dictionary = state.get_unit("lu_bu_final_breakout_rider") + if final_breakout.is_empty(): + failures.append("Xiapi Siege should include Lu Bu final breakout rider for the final pressure story beat") + else: + final_breakout["alive"] = false + state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, final_breakout) + if dialogue_batches.size() < 9: + failures.append("Xiapi Siege should show dialogue when Lu Bu final breakout rider falls") + var final_objective_text := str(state.objectives.get("victory", "")) + if not final_objective_text.contains("하비 포위선") or not final_objective_text.contains("남은"): + failures.append("Lu Bu final breakout rider objective should still point to the siege line and remaining enemies") + + func _event_by_id(state, event_id: String) -> Dictionary: for event in state.battle_events: if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id: