Improve Xiapi siege battle pacing

This commit is contained in:
2026-06-19 14:44:37 +09:00
parent e33201ce5c
commit 4e84d83f3f
2 changed files with 284 additions and 12 deletions

View File

@@ -2,14 +2,27 @@
"id": "009_xiapi_siege", "id": "009_xiapi_siege",
"name": "하비 수공전", "name": "하비 수공전",
"objectives": { "objectives": {
"victory": "수문 작전이 끝날 때까지 버틴 뒤 여포군을 격파하라.", "victory": "수문 포위선을 조여 여포의 최종 돌파를 격파하라.",
"defeat": "조조가 퇴각하거나 제13군령에 이르면 패한다." "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다."
}, },
"conditions": { "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": [ "defeat": [
{ "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, { "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": { "briefing": {
@@ -17,7 +30,8 @@
"location": "하비, 건안 삼년", "location": "하비, 건안 삼년",
"lines": [ "lines": [
"여포는 하비의 젖은 성벽 뒤로 물러나 기병과 진궁의 계책으로 포위를 깨려 한다.", "여포는 하비의 젖은 성벽 뒤로 물러나 기병과 진궁의 계책으로 포위를 깨려 한다.",
"조조는 물길을 열라 명한다. 수문 신호가 올라 여포군을 낮은 길로 끌어낼 때까지 첫 반격을 버텨라." "조조는 물길을 열라 명한다. 수문 신호가 올라 여포군을 낮은 길로 끌어낼 때까지 첫 반격을 버텨라.",
"제9군령에 여포가 마지막 돌파를 걸어오면 하비 포위선을 조이고 남은 수비대를 격파하라."
], ],
"conditional_lines": [ "conditional_lines": [
{ {
@@ -175,11 +189,11 @@
"GGGGGHHGGGWWGGG", "GGGGGHHGGGWWGGG",
"GGGFFHHGGGWWGGG", "GGGFFHHGGGWWGGG",
"GGGFFRRRRGGGGGG", "GGGFFRRRRGGGGGG",
"GGGGGRRRGGFFFGG", "GGGGGRRRGGCCFGG",
"RRRRRRRRRRRRRRR", "RRRRRRRRRRRRRRR",
"GGGGGRRRGGFFFGG", "GGGGGRRRGGCCFGG",
"GGHHGFFFGGGRRGG", "GGHHTTFFGGGRRGG",
"GGHHGFFFGGGWWGG", "GGHHTTFFGGGWWGG",
"GGGGGGFFGGGWWGG", "GGGGGGFFGGGWWGG",
"GGGGGGGGGGGGGGG" "GGGGGGGGGGGGGGG"
] ]
@@ -281,6 +295,33 @@
"level": 8, "level": 8,
"pos": [12, 6], "pos": [12, 6],
"base": { "hp": 46, "atk": 15, "def": 10 } "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": [ "events": [
@@ -359,8 +400,8 @@
}, },
{ {
"type": "set_objective", "type": "set_objective",
"victory": "여포, 진궁, 남은 하비 수비대를 격파하라.", "victory": "하비 포위선을 조이고 여포의 최종 돌파를 기다려라.",
"defeat": "조조가 퇴각하거나 제13군령에 이르면 패한다." "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다."
}, },
{ {
"type": "withdraw_units", "type": "withdraw_units",
@@ -375,7 +416,7 @@
"class_id": "infantry", "class_id": "infantry",
"team": "enemy", "team": "enemy",
"level": 8, "level": 8,
"pos": [8, 4], "pos": [7, 4],
"base": { "hp": 42, "atk": 14, "def": 9 } "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": { "rewards": {

View File

@@ -139,6 +139,18 @@ func _init() -> void:
_defeat_text("res://data/scenarios/008_wan_castle_escape.json"), _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( _check_contains(
failures, failures,
"045 gated destination progress", "045 gated destination progress",
@@ -156,6 +168,7 @@ func _init() -> void:
_check_dingtao_counterattack_extended_pressure(failures) _check_dingtao_counterattack_extended_pressure(failures)
_check_xian_emperor_escort_extended_pressure(failures) _check_xian_emperor_escort_extended_pressure(failures)
_check_wan_castle_escape_extended_pressure(failures) _check_wan_castle_escape_extended_pressure(failures)
_check_xiapi_siege_extended_pressure(failures)
_check_objective_update(failures) _check_objective_update(failures)
_check_objective_notice(failures) _check_objective_notice(failures)
_check_terrain_recovery(failures) _check_terrain_recovery(failures)
@@ -896,6 +909,113 @@ func _check_wan_castle_escape_extended_pressure(failures: Array[String]) -> void
scene.free() 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: 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) 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)): if unit.is_empty() or not bool(unit.get("alive", false)) or not bool(unit.get("deployed", true)):