From ed23b2bdf047748cae2e0bbeacd82afd90352a3a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 14:57:32 +0900 Subject: [PATCH] Improve White Horse relief battle pacing --- data/scenarios/010_white_horse_relief.json | 176 +++++++++++++++++++-- scripts/core/battle_state.gd | 7 +- tools/smoke_objective_progress.gd | 127 +++++++++++++++ 3 files changed, 297 insertions(+), 13 deletions(-) diff --git a/data/scenarios/010_white_horse_relief.json b/data/scenarios/010_white_horse_relief.json index a73f008..1a49e20 100644 --- a/data/scenarios/010_white_horse_relief.json +++ b/data/scenarios/010_white_horse_relief.json @@ -2,14 +2,27 @@ "id": "010_white_horse_relief", "name": "백마 구원전", "objectives": { - "victory": "유인 진형이 펼쳐진 뒤 안량 선봉을 격파하라.", - "defeat": "조조가 퇴각하거나 제12군령에 이르면 패한다." + "victory": "백마 유인선을 붙들고 원소군의 최종 압박을 격파하라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." }, "conditions": { - "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_decoy_line" }, + "victory": { + "type": "all", + "conditions": [ + { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_9_yuan_main_pressure" }, + { + "type": "unit_reaches_tile", + "label": "백마 유인선", + "team": "player", + "unit_ids": ["cao_cao_ch10", "guo_jia_ch10", "xiahou_dun_ch10", "dian_wei_ch10", "xiahou_yuan_ch10", "cao_ren_ch10"], + "officer_ids": ["cao_cao", "guo_jia", "xiahou_dun", "dian_wei", "xiahou_yuan", "cao_ren"], + "cells": [[8, 4], [9, 4], [8, 5], [9, 5]] + } + ] + }, "defeat": [ { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, - { "type": "turn_reached", "turn": 12, "team": "player" } + { "type": "turn_reached", "turn": 18, "team": "player" } ] }, "briefing": { @@ -17,7 +30,8 @@ "location": "백마, 건안 오년", "lines": [ "원소의 전군이 안량을 앞세워 백마 나루를 짓누르고 있다.", - "곽가는 거짓 퇴각을 청하였다. 선봉을 길 한복판으로 끌어낸 뒤 양익이 길게 벌어진 틈을 칠 계책이다." + "곽가는 거짓 퇴각을 청하였다. 선봉을 길 한복판으로 끌어낸 뒤 양익이 길게 벌어진 틈을 칠 계책이다.", + "제9군령에 원소의 본대 압박이 닿을 때까지 백마 유인선을 무너뜨리지 말고 안량의 선봉을 끊어라." ], "conditional_lines": [ { @@ -162,7 +176,7 @@ }, "roster": { "max_units": 6, - "required_officers": ["cao_cao", "guo_jia"] + "required_officers": ["cao_cao", "guo_jia", "xiahou_dun"] }, "formation": { "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 3], [3, 4]] @@ -175,11 +189,11 @@ "GGGGGGHHGGGGGGG", "GGFFGGHHGGGFFGG", "GGFFRRRRRRGFFGG", - "GGGGGRGGGRGGGGG", + "GGGGGRGGGRCCGGG", "RRRRRRRRRRRRRRR", - "GGGGGRGGGRGGGGG", - "GGHHGRRRGGFFFGG", - "GGHHGFFFGGFFFGG", + "GGGGGRGGGRCCGGG", + "GGHHTTRRGGFFFGG", + "GGHHTTFFGGFFFGG", "GGGGGFFFGGWWGGG", "GGGGGGGGGGWWGGG" ] @@ -279,6 +293,33 @@ "pos": [12, 5], "skills": ["blaze"], "base": { "hp": 38, "mp": 22, "atk": 6, "def": 6, "int": 14 } + }, + { + "unit_id": "white_horse_spear_support", + "name": "백마 창병", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [8, 3], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "white_horse_rider_screen", + "name": "백마 기병", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [9, 6], + "base": { "hp": 42, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "yuan_scout_archer", + "name": "원소 척후궁병", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [11, 7], + "base": { "hp": 34, "atk": 14, "def": 6 } } ], "events": [ @@ -356,8 +397,8 @@ }, { "type": "set_objective", - "victory": "안량과 고립된 선봉 전부를 격파하라.", - "defeat": "조조가 퇴각하거나 제12군령에 이르면 패한다." + "victory": "백마 유인선을 붙들고 원소군의 본대 압박을 기다려라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." }, { "type": "spawn_deployments", @@ -451,6 +492,117 @@ ] } ] + }, + { + "id": "turn_6_yuan_wings_close", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 6 }, + "actions": [ + { "type": "log", "text": "원소군 양익이 백마 유인선의 가장자리로 밀려든다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Scout", "display_speaker": "원소 척후", "text": "안량 장군의 선봉이 너무 깊습니다. 양익을 좁혀 조조의 중군을 물어라!" }, + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "text": "거짓 퇴각이 참패로 보일 만큼만 흔들린다. 더는 내주지 마라." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_wing_rider", + "name": "원소 양익기병", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [6, 1], + "base": { "hp": 42, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "yuan_wing_guard", + "name": "원소 양익창병", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [6, 8], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "yuan_wing_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_ch10", + "priority": 10, + "text": "원소 양익이 하후돈의 거짓 퇴각선을 노린다." + } + ] + }, + { + "id": "turn_9_yuan_main_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, + "actions": [ + { + "type": "set_objective", + "victory": "백마 유인선을 붙들고 원소군의 최종 압박을 격파하라.", + "defeat": "조조가 퇴각하거나 제18군령이 시작되면 패한다." + }, + { "type": "log", "text": "원소군 본대의 압박이 백마 나루로 밀려든다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Yan Liang", "display_speaker": "안량", "text": "조조의 물러섬은 겁이 아니었나. 그렇다면 힘으로 그 꾀까지 부숴 주겠다!" }, + { "speaker": "Guo Jia", "display_speaker": "곽가", "text": "이제 안량은 돌아갈 엄호보다 앞에 있습니다. 유인선이 닫히는 순간입니다." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_main_rider", + "name": "원소 본대기병", + "class_id": "cavalry", + "team": "enemy", + "level": 10, + "pos": [13, 3], + "base": { "hp": 48, "atk": 17, "def": 8, "agi": 12 } + }, + { + "unit_id": "yuan_main_guard", + "name": "원소 본대창병", + "class_id": "infantry", + "team": "enemy", + "level": 10, + "pos": [13, 5], + "base": { "hp": 48, "atk": 16, "def": 10 } + }, + { + "unit_id": "yuan_main_archer", + "name": "원소 본대궁병", + "class_id": "archer", + "team": "enemy", + "level": 10, + "pos": [13, 7], + "base": { "hp": 36, "atk": 14, "def": 6 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_ids": ["cao_cao_ch10", "xiahou_dun_ch10"], + "priority": 13, + "text": "원소군 본대가 조조와 하후돈을 함께 노린다." + } + ] } ], "rewards": { diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index b3a83e1..cafd286 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -510,9 +510,14 @@ func _joined_officer_lookup(source) -> Dictionary: func _is_deployment_available(unit: Dictionary) -> bool: if unit.get("team", "") != TEAM_PLAYER: return true + var required_officers: Array = deployment_rules.get("required_officers", []) + var required_units: Array = deployment_rules.get("required_units", []) + var officer_id := str(unit.get("officer_id", "")) + var unit_id := str(unit.get("id", "")) + if required_units.has(unit_id) or required_officers.has(officer_id): + return true if not bool(unit.get("requires_joined", false)): return true - var officer_id := str(unit.get("officer_id", "")) return not officer_id.is_empty() and campaign_joined_officers.has(officer_id) diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index 190eec4..b171024 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -151,6 +151,18 @@ func _init() -> void: _progress_text("res://data/scenarios/009_xiapi_siege.json", false), "하비 포위선" ) + _check_contains( + failures, + "010 gated victory progress", + _progress_text("res://data/scenarios/010_white_horse_relief.json", false), + "제9군령에 전공 갱신" + ) + _check_contains( + failures, + "010 decoy line progress", + _progress_text("res://data/scenarios/010_white_horse_relief.json", false), + "백마 유인선" + ) _check_contains( failures, "045 gated destination progress", @@ -169,6 +181,7 @@ func _init() -> void: _check_xian_emperor_escort_extended_pressure(failures) _check_wan_castle_escape_extended_pressure(failures) _check_xiapi_siege_extended_pressure(failures) + _check_white_horse_relief_extended_pressure(failures) _check_objective_update(failures) _check_objective_notice(failures) _check_terrain_recovery(failures) @@ -1016,6 +1029,111 @@ func _check_xiapi_siege_extended_pressure(failures: Array[String]) -> void: scene.free() +func _check_white_horse_relief_extended_pressure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/010_white_horse_relief.json"): + failures.append("White Horse Relief pressure smoke could not load scenario") + return + if state.get_turn_limit() != 17: + failures.append("White Horse Relief should allow play through turn 17, got limit %d" % state.get_turn_limit()) + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 9: + failures.append("White Horse Relief should start with 9 enemies, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + _assert_required_unit(failures, state, "guo_jia_ch10", "White Horse Relief Guo Jia") + _assert_required_unit(failures, state, "xiahou_dun_ch10", "White Horse Relief Xiahou Dun") + if state._condition_gate_open({"after_event": "turn_9_yuan_main_pressure"}): + failures.append("White Horse Relief final defeat-all condition should be gated until turn 9 Yuan main pressure") + var early_state = BattleStateScript.new() + if not early_state.load_battle("res://data/scenarios/010_white_horse_relief.json"): + failures.append("White Horse Relief early victory smoke could not load scenario") + return + var early_cao_cao: Dictionary = early_state.get_unit("cao_cao_ch10") + 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("White Horse Relief should not end before turn 9 Yuan main pressure, got %s" % str(early_state.battle_status)) + var marker_cells := state.get_objective_cells() + for decoy_cell in [Vector2i(8, 4), Vector2i(9, 4), Vector2i(8, 5), Vector2i(9, 5)]: + if not marker_cells.has(decoy_cell): + failures.append("White Horse Relief should mark decoy line cell %s: %s" % [str(decoy_cell), str(marker_cells)]) + if state.get_objective_cell_label(decoy_cell) != "백마 유인선": + failures.append("White Horse Relief marker should expose decoy line label at %s" % str(decoy_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("White Horse Relief should expose ferry recovery terrain: %s" % str(recovery_summaries)) + if not has_village_recovery: + failures.append("White Horse Relief should expose reserve camp recovery terrain: %s" % str(recovery_summaries)) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + _assert_unit_at(failures, state, "yan_liang_rider_north", Vector2i(8, 2), "White Horse Relief turn 2 north rider") + _assert_unit_at(failures, state, "yan_liang_rider_south", Vector2i(8, 6), "White Horse Relief turn 2 south rider") + state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + if not state._condition_gate_open({"after_event": "turn_3_decoy_line"}): + failures.append("White Horse Relief decoy line event should fire on turn 3") + _assert_unit_at(failures, state, "decoy_cutoff_guard", Vector2i(7, 4), "White Horse Relief turn 3 cutoff guard") + _assert_unit_at(failures, state, "decoy_cutoff_archer", Vector2i(7, 5), "White Horse Relief turn 3 cutoff archer") + if state._condition_gate_open({"after_event": "turn_9_yuan_main_pressure"}): + failures.append("White Horse Relief final gate should still be closed after decoy line event") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + if int(state.get_unit("xiahou_dun_ch10").get("ai_target_priority", 0)) != 10: + failures.append("White Horse Relief turn 6 pressure should target Xiahou Dun") + _assert_unit_at(failures, state, "yuan_wing_rider", Vector2i(6, 1), "White Horse Relief turn 6 rider") + _assert_unit_at(failures, state, "yuan_wing_guard", Vector2i(6, 8), "White Horse Relief turn 6 guard") + _assert_unit_at(failures, state, "yuan_wing_archer", Vector2i(9, 8), "White Horse Relief turn 6 archer") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if not state._condition_gate_open({"after_event": "turn_9_yuan_main_pressure"}): + failures.append("White Horse Relief final gate should open after turn 9 Yuan main pressure") + _assert_unit_at(failures, state, "yuan_main_rider", Vector2i(13, 3), "White Horse Relief turn 9 rider") + _assert_unit_at(failures, state, "yuan_main_guard", Vector2i(13, 5), "White Horse Relief turn 9 guard") + _assert_unit_at(failures, state, "yuan_main_archer", Vector2i(13, 7), "White Horse Relief turn 9 archer") + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 19: + failures.append("White Horse Relief should field 19 enemies after Yuan main pressure, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if int(state.get_unit("cao_cao_ch10").get("ai_target_priority", 0)) != 13: + failures.append("White Horse Relief turn 9 Yuan main pressure should target Cao Cao") + if int(state.get_unit("xiahou_dun_ch10").get("ai_target_priority", 0)) != 13: + failures.append("White Horse Relief turn 9 Yuan main pressure should target Xiahou Dun") + for enemy in state.get_living_units(BattleStateScript.TEAM_ENEMY): + enemy["alive"] = false + var cao_cao: Dictionary = state.get_unit("cao_cao_ch10") + cao_cao["pos"] = Vector2i(7, 4) + state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("White Horse Relief should not end before the decoy 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("White Horse Relief should end after Yuan pressure falls and decoy line is held, got %s" % str(state.battle_status)) + var pressed_state = BattleStateScript.new() + if not pressed_state.load_battle("res://data/scenarios/010_white_horse_relief.json", {}, {}, {"pressed_northern_campaign": true}): + failures.append("White Horse Relief pressed branch smoke could not load scenario") + return + pressed_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + pressed_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) + pressed_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4) + pressed_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) + pressed_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if pressed_state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 20: + failures.append("White Horse Relief pressed branch should field 20 enemies after Yuan main pressure, got %d" % pressed_state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/010_white_horse_relief.json"): + failures.append("White Horse Relief briefing smoke could not load scenario") + scene.free() + return + var overview := scene._format_briefing_battlefield_overview_text() + _check_contains(failures, "010 briefing map size", overview, "15칸 x 10칸") + _check_contains(failures, "010 briefing enemy count", overview, "적세 9명") + _check_contains(failures, "010 briefing village recovery", overview, "마을 4칸 +6") + _check_contains(failures, "010 briefing ferry recovery", overview, "성채 4칸 +8") + scene.free() + + 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) if unit.is_empty() or not bool(unit.get("alive", false)) or not bool(unit.get("deployed", true)): @@ -1025,6 +1143,15 @@ func _assert_unit_at(failures: Array[String], state, unit_id: String, expected_c failures.append("%s should be at %s, got %s" % [label, str(expected_cell), str(unit.get("pos", Vector2i(-999, -999)))]) +func _assert_required_unit(failures: Array[String], state, unit_id: String, label: String) -> void: + 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)): + failures.append("%s should deploy %s" % [label, unit_id]) + return + if not bool(unit.get("required_deployment", false)): + failures.append("%s should be required deployment: %s" % [label, unit_id]) + + 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"):