From 806b375313ee99790c5d5e33e150e12c700c311d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 15:53:05 +0900 Subject: [PATCH] Add story beats to Xingyang Ambush --- data/scenarios/003_xingyang_ambush.json | 34 +++++++++ tools/smoke_chapter_one_polish.gd | 98 ++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/data/scenarios/003_xingyang_ambush.json b/data/scenarios/003_xingyang_ambush.json index 6f5d680..1908fb3 100644 --- a/data/scenarios/003_xingyang_ambush.json +++ b/data/scenarios/003_xingyang_ambush.json @@ -420,6 +420,14 @@ "when": { "type": "turn_start", "team": "enemy", "turn": 6 }, "actions": [ { "type": "log", "text": "숲 뒤 비탈에서 서영의 궁병대가 퇴로를 끊으려 내려온다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Xu Rong", "display_speaker": "서영", "side": "right", "text": "선봉은 이미 숲 안으로 들어왔다. 비탈 궁병은 하후돈을 묶고, 창병은 길목을 좁혀라." }, + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "left", "text": "저들이 제 선봉을 노립니다. 제가 버티는 동안 본대는 동쪽 길을 잃지 마십시오." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "하후돈을 홀로 두지 마라. 비탈의 궁병을 꺾어야 이 숲을 빠져나갈 수 있다." } + ] + }, { "type": "spawn_deployments", "deployments": [ @@ -466,6 +474,14 @@ "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, "actions": [ { "type": "log", "text": "동쪽 길목에서 서영의 마지막 봉쇄병이 나타난다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Xu Rong", "display_speaker": "서영", "side": "right", "text": "여기서 더 물러서면 낙양의 불길이 헛것이 된다. 동쪽 길목을 닫고 조조의 깃발을 끊어라." }, + { "speaker": "Cao Ren", "display_speaker": "조인", "side": "left", "text": "길목의 끝에 봉쇄병이 버티고 있습니다. 저들을 남겨 두면 추격로가 다시 막힙니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "마지막 길을 연다. 길목을 장악하고 남은 봉쇄병까지 모두 쓸어내라." } + ] + }, { "type": "set_objective", "victory": "마지막 봉쇄병을 격파하고 동쪽 길목을 장악하라.", @@ -511,6 +527,24 @@ } ] }, + { + "id": "xu_rong_rearguard_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["xu_rong_rearguard"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xu Rong", "display_speaker": "서영", "side": "right", "text": "후군이 무너졌다고 끝난 것이 아니다. 숲 뒤 봉쇄병이 조조의 발을 붙잡을 것이다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "서영의 중심은 꺾였다. 방심하지 말고 동쪽 길목까지 진형을 밀어 붙여라." } + ] + }, + { + "type": "set_objective", + "victory": "서영의 후군을 꺾었다. 동쪽 길목을 장악하고 뒤따르는 봉쇄병까지 정리하라." + } + ] + }, { "id": "turn_5_regroup_pressure", "once": true, diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index 614fd02..c2bef0d 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -25,8 +25,10 @@ func _init() -> void: _check_event_dialogue_side_passthrough(failures) _check_opening_battle_event_dialogue_structure(failures) _check_sishui_gate_event_dialogue_structure(failures) + _check_xingyang_ambush_event_dialogue_structure(failures) _check_opening_battle_story_beats(failures) _check_sishui_gate_story_beats(failures) + _check_xingyang_ambush_story_beats(failures) _check_pixel_unit_helpers(failures) if failures.is_empty(): @@ -112,9 +114,10 @@ func _check_dialogue_localization_and_side(failures: Array[String]) -> void: {"speaker": "Lu Bu", "text": "조조를 짓밟아라."}, {"speaker": "Qingzhou Chief", "text": "마을 곡식을 내놓아라."}, {"speaker": "Camp Merchant", "text": "군막에 물자가 남았습니다."}, - {"speaker": "Hua Xiong", "text": "사수관을 지켜라."} + {"speaker": "Hua Xiong", "text": "사수관을 지켜라."}, + {"speaker": "Xu Rong", "text": "동쪽 길목을 닫아라."} ]) - if lines.size() != 5: + if lines.size() != 6: failures.append("dialogue normalization should keep all valid chapter one lines") scene.free() return @@ -128,6 +131,8 @@ func _check_dialogue_localization_and_side(failures: Array[String]) -> void: failures.append("camp merchant dialogue should be localized and right-sided by default") if lines[4].get("speaker", "") != "화웅" or lines[4].get("side", "") != "right": failures.append("Sishui Gate enemy commander dialogue should localize to Korean on the right") + if lines[5].get("speaker", "") != "서영" or lines[5].get("side", "") != "right": + failures.append("Xingyang Ambush enemy commander dialogue should localize to Korean on the right") scene.free() @@ -211,6 +216,41 @@ func _check_sishui_gate_event_dialogue_structure(failures: Array[String]) -> voi failures.append("Hua Xiong vanguard defeat event should clarify the next objective") +func _check_xingyang_ambush_event_dialogue_structure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}): + failures.append("could not load Xingyang Ambush for event dialogue structure") + return + for event_id in [ + "opening_dialogue_pursuit", + "opening_dialogue_regroup", + "turn_3_ambush_pursuit", + "turn_3_ambush_regroup", + "turn_5_pressure", + "turn_5_regroup_pressure", + "turn_6_slope_archers", + "turn_9_forest_blockade", + "xu_rong_rearguard_falls" + ]: + var event := _event_by_id(state, event_id) + if event.is_empty(): + failures.append("Xingyang Ambush missing event: %s" % event_id) + continue + if not _event_has_dialogue_action(event): + failures.append("Xingyang Ambush event should include contextual dialogue: %s" % event_id) + var rearguard_event := _event_by_id(state, "xu_rong_rearguard_falls") + if not rearguard_event.is_empty(): + var when: Dictionary = rearguard_event.get("when", {}) + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("xu_rong_rearguard"): + failures.append("Xu Rong rearguard defeat event should watch xu_rong_rearguard") + if not _event_has_set_objective_action(rearguard_event): + failures.append("Xu Rong rearguard defeat event should clarify the next objective") + var blockade_event := _event_by_id(state, "turn_9_forest_blockade") + if not blockade_event.is_empty() and not _event_has_set_objective_action(blockade_event): + failures.append("Xingyang Ambush blockade event should clarify the final objective") + + 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"): @@ -260,6 +300,60 @@ func _check_sishui_gate_story_beats(failures: Array[String]) -> void: failures.append("Sishui Gate final objective gate should open after the turn 8 story beat") +func _check_xingyang_ambush_story_beats(failures: Array[String]) -> void: + var pursuit_state = BattleStateScript.new() + if not pursuit_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}): + failures.append("could not load Xingyang Ambush pursuit branch for story beat checks") + return + + var pursuit_dialogue_batches := [] + pursuit_state.dialogue_requested.connect(func(lines: Array) -> void: + pursuit_dialogue_batches.append(lines) + ) + + pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) + pursuit_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) + pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + + if pursuit_dialogue_batches.size() < 4: + failures.append("Xingyang Ambush pursuit branch should add dialogue beats for each major ambush phase") + var pursuit_victory_text := str(pursuit_state.objectives.get("victory", "")) + if not pursuit_victory_text.contains("마지막 봉쇄병"): + failures.append("Xingyang Ambush turn 9 event should update the victory objective after the blockade arrives") + if not pursuit_state._condition_gate_open({"after_event": "turn_9_forest_blockade"}): + failures.append("Xingyang Ambush final objective gate should open after the turn 9 blockade") + var xu_rong := pursuit_state.get_unit("xu_rong_rearguard") + if xu_rong.is_empty(): + failures.append("Xingyang Ambush should include Xu Rong rearguard unit for defeat story beat") + else: + xu_rong["alive"] = false + pursuit_state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, xu_rong) + if pursuit_dialogue_batches.size() < 5: + failures.append("Xingyang Ambush should show dialogue when Xu Rong rearguard falls") + var rearguard_objective_text := str(pursuit_state.objectives.get("victory", "")) + if not rearguard_objective_text.contains("동쪽 길목") or not rearguard_objective_text.contains("봉쇄병"): + failures.append("Xingyang Ambush Xu Rong defeat objective should still point to the road and blockade") + + var regroup_state = BattleStateScript.new() + if not regroup_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"regrouped_after_sishui": true}): + failures.append("could not load Xingyang Ambush regroup branch for story beat checks") + return + + var regroup_dialogue_batches := [] + regroup_state.dialogue_requested.connect(func(lines: Array) -> void: + regroup_dialogue_batches.append(lines) + ) + + regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) + regroup_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) + regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + + if regroup_dialogue_batches.size() < 4: + failures.append("Xingyang Ambush regroup branch should keep dialogue beats for scouted ambush pressure") + + 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: