Add story beats to Xiapi Siege

This commit is contained in:
2026-06-19 16:51:52 +09:00
parent fd9eceee0e
commit a851eef72b
2 changed files with 241 additions and 0 deletions

View File

@@ -360,6 +360,11 @@
{ "speaker": "Chen Gong", "text": "전열이 휘면 장수를 치십시오. 수공의 계책도 그와 함께 죽습니다." } { "speaker": "Chen Gong", "text": "전열이 휘면 장수를 치십시오. 수공의 계책도 그와 함께 죽습니다." }
] ]
}, },
{
"type": "set_objective",
"victory": "첫 돌격을 버티고 수문 신호가 오를 때까지 하비 포위선을 유지하라.",
"defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다."
},
{ {
"type": "spawn_deployments", "type": "spawn_deployments",
"deployments": [ "deployments": [
@@ -499,6 +504,10 @@
{ "speaker": "Xiahou Dun", "display_speaker": "하후돈", "text": "남은 길이 좁을수록 좋습니다. 좁은 곳에서는 겁먹은 말보다 사람이 먼저 섭니다." } { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "text": "남은 길이 좁을수록 좋습니다. 좁은 곳에서는 겁먹은 말보다 사람이 먼저 섭니다." }
] ]
}, },
{
"type": "set_objective",
"victory": "포위선 역습을 막고 여포의 마지막 돌파가 드러날 때까지 하비 포위선을 조여라."
},
{ {
"type": "spawn_deployments", "type": "spawn_deployments",
"deployments": [ "deployments": [
@@ -596,6 +605,82 @@
"text": "여포의 마지막 돌파가 조조와 하후돈을 노린다." "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": { "rewards": {

View File

@@ -31,6 +31,7 @@ func _init() -> void:
_check_dingtao_counterattack_event_dialogue_structure(failures) _check_dingtao_counterattack_event_dialogue_structure(failures)
_check_xian_emperor_escort_event_dialogue_structure(failures) _check_xian_emperor_escort_event_dialogue_structure(failures)
_check_wan_castle_escape_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_opening_battle_story_beats(failures)
_check_sishui_gate_story_beats(failures) _check_sishui_gate_story_beats(failures)
_check_xingyang_ambush_story_beats(failures) _check_xingyang_ambush_story_beats(failures)
@@ -39,6 +40,7 @@ func _init() -> void:
_check_dingtao_counterattack_story_beats(failures) _check_dingtao_counterattack_story_beats(failures)
_check_xian_emperor_escort_story_beats(failures) _check_xian_emperor_escort_story_beats(failures)
_check_wan_castle_escape_story_beats(failures) _check_wan_castle_escape_story_beats(failures)
_check_xiapi_siege_story_beats(failures)
_check_pixel_unit_helpers(failures) _check_pixel_unit_helpers(failures)
if failures.is_empty(): 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) 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: func _check_opening_battle_story_beats(failures: Array[String]) -> void:
var state = BattleStateScript.new() var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): 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") 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: func _event_by_id(state, event_id: String) -> Dictionary:
for event in state.battle_events: for event in state.battle_events:
if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id: if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id: