From 045155023419cf62a254d389f444644517654a39 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 13:50:18 +0900 Subject: [PATCH] Improve Xingyang ambush battle pacing --- data/scenarios/003_xingyang_ambush.json | 153 ++++++++++++++++++++++-- tools/smoke_objective_progress.gd | 75 ++++++++++++ tools/smoke_result_summary.gd | 2 +- 3 files changed, 217 insertions(+), 13 deletions(-) diff --git a/data/scenarios/003_xingyang_ambush.json b/data/scenarios/003_xingyang_ambush.json index 7a25db7..6f5d680 100644 --- a/data/scenarios/003_xingyang_ambush.json +++ b/data/scenarios/003_xingyang_ambush.json @@ -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, diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index 2cd3207..4a867f6 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -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"): diff --git a/tools/smoke_result_summary.gd b/tools/smoke_result_summary.gd index 2f85a0f..e112a1b 100644 --- a/tools/smoke_result_summary.gd +++ b/tools/smoke_result_summary.gd @@ -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 = {