Improve Xingyang ambush battle pacing

This commit is contained in:
2026-06-19 13:50:18 +09:00
parent 1cdb59aa1e
commit 0451550234
3 changed files with 217 additions and 13 deletions

View File

@@ -2,14 +2,27 @@
"id": "003_xingyang_ambush",
"name": "형양 매복전",
"objectives": {
"victory": "서영의 후군을 격파하라.",
"defeat": "조조가 퇴각하거나 제11턴에 이르면 패한다."
"victory": "서영의 매복진을 돌파하고 동쪽 길목을 장악하라.",
"defeat": "조조가 퇴각하거나 제17턴이 시작되면 패한다."
},
"conditions": {
"victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_ambush" },
"victory": {
"type": "all",
"conditions": [
{ "type": "all_units_defeated", "team": "enemy", "after_event": "turn_9_forest_blockade" },
{
"type": "unit_reaches_tile",
"label": "동쪽 길목",
"team": "player",
"unit_ids": ["cao_cao_ch3", "xiahou_dun_ch3", "xiahou_yuan_ch3", "cao_ren_ch3"],
"officer_ids": ["cao_cao", "xiahou_dun", "xiahou_yuan", "cao_ren"],
"cells": [[10, 3], [11, 3], [10, 4], [11, 4]]
}
]
},
"defeat": [
{ "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] },
{ "type": "turn_reached", "turn": 11, "team": "player" }
{ "type": "turn_reached", "turn": 17, "team": "player" }
]
},
"briefing": {
@@ -18,7 +31,8 @@
"lines": [
"동탁이 낙양을 버리고 천자를 끼고 서쪽으로 달아났다.",
"조조의 선봉은 형양 부근에서 적의 후군을 따라붙는다.",
"앞길은 좁고 숲은 깊다. 전열을 흩뜨리지 말고 수풀의 기척을 살펴라."
"앞길은 좁고 숲은 깊다. 전열을 흩뜨리지 말고 수풀의 기척을 살펴라.",
"동쪽 길목을 빼앗고 숲 뒤의 봉쇄병까지 무너뜨려야 추격로가 열린다."
],
"conditional_lines": [
{
@@ -133,7 +147,7 @@
]
},
"roster": {
"max_units": 3,
"max_units": 4,
"required_officers": ["cao_cao"]
},
"formation": {
@@ -148,8 +162,8 @@
"GGGFFRRGHHGG",
"GGGGGRRGHHGG",
"RRRRRRRRRRRR",
"GGGGFRRFGGGG",
"GGGHFRRFGGGG",
"GGGGFRRFTTGG",
"GGGHFRRFTTGG",
"GGGHHRRGFFGG",
"GGGGGRRGFFGG",
"GGWWGRRGGGGG"
@@ -217,6 +231,24 @@
"level": 3,
"pos": [8, 2],
"base": { "hp": 30, "atk": 11, "def": 5 }
},
{
"unit_id": "forest_spearman",
"name": "숲길 창병",
"class_id": "infantry",
"team": "enemy",
"level": 3,
"pos": [6, 1],
"base": { "hp": 38, "atk": 12, "def": 7 }
},
{
"unit_id": "rearguard_cavalry_screen",
"name": "후군 기병",
"class_id": "cavalry",
"team": "enemy",
"level": 3,
"pos": [10, 5],
"base": { "hp": 40, "atk": 14, "def": 7 }
}
],
"events": [
@@ -289,8 +321,8 @@
},
{
"type": "set_objective",
"victory": "서영과 모든 매복병을 격파하라.",
"defeat": "조조가 퇴각하거나 제11턴에 이르면 패한다."
"victory": "서영의 매복진을 밀어내고 동쪽 길목을 장악하라.",
"defeat": "조조가 퇴각하거나 제17턴이 시작되면 패한다."
},
{
"type": "spawn_deployments",
@@ -341,8 +373,8 @@
},
{
"type": "set_objective",
"victory": "서영과 드러난 매복병을 격파하라.",
"defeat": "조조가 퇴각하거나 제11턴에 이르면 패한다."
"victory": "드러난 매복병을 무너뜨리고 동쪽 길목을 장악하라.",
"defeat": "조조가 퇴각하거나 제17턴이 시작되면 패한다."
},
{
"type": "spawn_deployments",
@@ -382,6 +414,103 @@
}
]
},
{
"id": "turn_6_slope_archers",
"once": true,
"when": { "type": "turn_start", "team": "enemy", "turn": 6 },
"actions": [
{ "type": "log", "text": "숲 뒤 비탈에서 서영의 궁병대가 퇴로를 끊으려 내려온다." },
{
"type": "spawn_deployments",
"deployments": [
{
"unit_id": "slope_archer_north",
"name": "비탈 궁병",
"class_id": "archer",
"team": "enemy",
"level": 3,
"pos": [10, 1],
"base": { "hp": 28, "atk": 12, "def": 4 }
},
{
"unit_id": "slope_spearman",
"name": "비탈 창병",
"class_id": "infantry",
"team": "enemy",
"level": 3,
"pos": [9, 1],
"base": { "hp": 36, "atk": 12, "def": 7 }
},
{
"unit_id": "slope_archer_south",
"name": "비탈 궁병",
"class_id": "archer",
"team": "enemy",
"level": 3,
"pos": [10, 7],
"base": { "hp": 28, "atk": 12, "def": 4 }
}
]
},
{
"type": "set_ai_target_priority",
"unit_id": "xiahou_dun_ch3",
"priority": 8,
"text": "비탈 궁병대가 하후돈의 선봉을 노린다."
}
]
},
{
"id": "turn_9_forest_blockade",
"once": true,
"when": { "type": "turn_start", "team": "enemy", "turn": 9 },
"actions": [
{ "type": "log", "text": "동쪽 길목에서 서영의 마지막 봉쇄병이 나타난다." },
{
"type": "set_objective",
"victory": "마지막 봉쇄병을 격파하고 동쪽 길목을 장악하라.",
"defeat": "조조가 퇴각하거나 제17턴이 시작되면 패한다."
},
{
"type": "spawn_deployments",
"deployments": [
{
"unit_id": "forest_blockade_cavalry",
"name": "봉쇄 기병",
"class_id": "cavalry",
"team": "enemy",
"level": 3,
"pos": [11, 2],
"base": { "hp": 42, "atk": 15, "def": 7 }
},
{
"unit_id": "forest_blockade_guard",
"name": "봉쇄 창병",
"class_id": "infantry",
"team": "enemy",
"level": 3,
"pos": [11, 5],
"base": { "hp": 38, "atk": 12, "def": 8 }
},
{
"unit_id": "forest_blockade_archer",
"name": "봉쇄 궁병",
"class_id": "archer",
"team": "enemy",
"level": 3,
"pos": [11, 7],
"base": { "hp": 30, "atk": 12, "def": 4 }
}
]
},
{
"type": "set_ai_target_priority",
"unit_id": "cao_cao_ch3",
"priority": 11,
"text": "봉쇄병이 조조의 깃발을 노리고 길목을 닫는다."
}
]
},
{
"id": "turn_5_regroup_pressure",
"once": true,

View File

@@ -55,6 +55,18 @@ func _init() -> void:
_progress_text("res://data/scenarios/002_sishui_gate.json", false),
"관문 장악"
)
_check_contains(
failures,
"003 gated victory progress",
_progress_text("res://data/scenarios/003_xingyang_ambush.json", false),
"제9군령에 전공 갱신"
)
_check_contains(
failures,
"003 road capture progress",
_progress_text("res://data/scenarios/003_xingyang_ambush.json", false),
"동쪽 길목"
)
_check_contains(
failures,
"007 destination progress",
@@ -78,6 +90,7 @@ func _init() -> void:
_check_turn_gated_objective_does_not_create_marker(failures)
_check_opening_battle_requires_castle_capture(failures)
_check_sishui_gate_extended_pressure(failures)
_check_xingyang_ambush_extended_pressure(failures)
_check_objective_update(failures)
_check_objective_notice(failures)
_check_terrain_recovery(failures)
@@ -360,6 +373,68 @@ func _check_sishui_gate_extended_pressure(failures: Array[String]) -> void:
scene.free()
func _check_xingyang_ambush_extended_pressure(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("Xingyang Ambush pressure smoke could not load scenario")
return
if state.get_turn_limit() != 16:
failures.append("Xingyang Ambush should allow play through turn 16, got limit %d" % state.get_turn_limit())
if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 6:
failures.append("Xingyang Ambush should start with 6 enemies, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size())
if state._condition_gate_open({"after_event": "turn_9_forest_blockade"}):
failures.append("Xingyang Ambush final defeat-all condition should be gated until turn 9 blockade")
var marker_cells := state.get_objective_cells()
for road_cell in [Vector2i(10, 3), Vector2i(11, 3), Vector2i(10, 4), Vector2i(11, 4)]:
if not marker_cells.has(road_cell):
failures.append("Xingyang Ambush should mark road capture cell %s: %s" % [str(road_cell), str(marker_cells)])
if state.get_objective_cell_label(road_cell) != "동쪽 길목":
failures.append("Xingyang Ambush road marker should expose label at %s" % str(road_cell))
var recovery_summaries := state.get_recovery_terrain_summaries()
var has_waystation_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)) == 6:
has_waystation_recovery = true
if not has_waystation_recovery:
failures.append("Xingyang Ambush should expose roadside recovery terrain: %s" % str(recovery_summaries))
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9)
if not state._condition_gate_open({"after_event": "turn_9_forest_blockade"}):
failures.append("Xingyang Ambush final gate should open after turn 9 blockade event")
if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 15:
failures.append("Xingyang Ambush pursuit branch should field 15 enemies after blockade, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size())
for enemy in state.get_living_units(BattleStateScript.TEAM_ENEMY):
enemy["alive"] = false
state._check_battle_status()
if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE:
failures.append("Xingyang Ambush should not end before east road capture, got %s" % str(state.battle_status))
var cao_cao: Dictionary = state.get_unit("cao_cao_ch3")
cao_cao["pos"] = Vector2i(10, 3)
state._check_battle_status()
if str(state.battle_status) != BattleStateScript.STATUS_VICTORY:
failures.append("Xingyang Ambush should end after blockade falls and east road is captured, got %s" % str(state.battle_status))
var regroup_state = BattleStateScript.new()
if not regroup_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"regrouped_after_sishui": true}):
failures.append("Xingyang Ambush regroup pressure smoke could not load scenario")
return
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9)
if regroup_state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 14:
failures.append("Xingyang Ambush regroup branch should field 14 enemies after blockade, got %d" % regroup_state.get_living_units(BattleStateScript.TEAM_ENEMY).size())
var scene = BattleSceneScript.new()
if not scene.state.load_battle("res://data/scenarios/003_xingyang_ambush.json"):
failures.append("Xingyang Ambush briefing smoke could not load scenario")
scene.free()
return
var overview := scene._format_briefing_battlefield_overview_text()
_check_contains(failures, "003 briefing map size", overview, "12칸 x 9칸")
_check_contains(failures, "003 briefing enemy count", overview, "적세 6명")
_check_contains(failures, "003 briefing village recovery", overview, "마을 4칸 +6")
scene.free()
func _check_opening_board_avoids_side_panel(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):

View File

@@ -45,7 +45,7 @@ func _init() -> void:
_check_contains(failures, "next camp location", result_text, "사수관, 초평 원년")
_check_contains(failures, "next camp talk", result_text, "군막 회의 · 3건")
_check_contains(failures, "next camp shop", result_text, "군상 물품 · 6종")
_check_contains(failures, "next camp deploy", result_text, "출진 명부 · 2/3")
_check_contains(failures, "next camp deploy", result_text, "출진 명부 · 2/4")
_check_contains(failures, "next camp formation", result_text, "진형 배치 6칸")
scene.battle_result_summary = {