feat(runtime): add P9.6 risk recovery flow
This commit is contained in:
@@ -5,6 +5,7 @@ const Service = preload("res://p9_3/core_loop_service.gd")
|
||||
const State = preload("res://p9_3/core_loop_state.gd")
|
||||
const Rules = preload("res://domain/p9_2/battle_rules.gd")
|
||||
const AnimationAdapter = preload("res://presentation/animation_adapter.gd")
|
||||
const SaveService = preload("res://p9_6/save_service.gd")
|
||||
|
||||
const HALL_BACKGROUND = preload("res://assets/backgrounds/p9_3_guild_hall.png")
|
||||
const FIELD_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png")
|
||||
@@ -56,6 +57,7 @@ const CORAL := Color("#ff7478")
|
||||
const JADE := Color("#75e0a4")
|
||||
|
||||
var service := Service.new()
|
||||
var save_service := SaveService.new()
|
||||
var animation := AnimationAdapter.new()
|
||||
var display_root: Control
|
||||
var ambient_actors: Dictionary = {}
|
||||
@@ -78,6 +80,8 @@ var speed_multiplier := 1
|
||||
var paused := false
|
||||
var returned_from_battle := false
|
||||
var render_queued := false
|
||||
var continue_snapshot: Dictionary = {}
|
||||
@export var persistence_enabled := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -86,6 +90,10 @@ func _ready() -> void:
|
||||
ui_theme.default_font = UI_FONT
|
||||
theme = ui_theme
|
||||
animation.load_specs(ANIMATION_SPECS)
|
||||
if persistence_enabled:
|
||||
var loaded: Dictionary = save_service.load_snapshot()
|
||||
if loaded.ok:
|
||||
continue_snapshot = Dictionary(loaded.snapshot).duplicate(true)
|
||||
display_root = Control.new()
|
||||
display_root.name = "P93DisplayRoot"
|
||||
display_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -108,16 +116,30 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func enter_hall() -> void:
|
||||
if persistence_enabled:
|
||||
save_service.clear()
|
||||
continue_snapshot.clear()
|
||||
if service.enter_hall().ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func continue_saved_loop() -> void:
|
||||
if continue_snapshot.is_empty() or not service.state.restore_snapshot(continue_snapshot):
|
||||
continue_snapshot.clear()
|
||||
_queue_render_current()
|
||||
return
|
||||
returned_from_battle = service.state.reward_receipts.size() > 0
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func tap_offer(member_id: String) -> void:
|
||||
if service.state.phase != State.Phase.HALL:
|
||||
return
|
||||
if service.state.focused_offer == member_id:
|
||||
var result: Dictionary = service.hire(member_id)
|
||||
if result.ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
return
|
||||
if service.focus_offer(member_id).ok:
|
||||
@@ -131,6 +153,19 @@ func tap_equipment(item_id: String) -> void:
|
||||
|
||||
func tap_equipment_wearer(member_id: String) -> void:
|
||||
if service.equip_focused(member_id).ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func tap_rift_tide_plan(plan_id: String) -> void:
|
||||
if service.choose_rift_tide_plan(plan_id).ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func recover_injured_company() -> void:
|
||||
if service.recover_injured_company().ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
@@ -141,10 +176,12 @@ func tap_encounter(encounter_id: String) -> void:
|
||||
start_selected_battle()
|
||||
return
|
||||
if service.select_encounter(encounter_id).ok:
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func start_selected_battle() -> void:
|
||||
_save_stable()
|
||||
var result: Dictionary = service.start_battle()
|
||||
if not result.ok:
|
||||
return
|
||||
@@ -178,10 +215,14 @@ func claim_and_return() -> void:
|
||||
return
|
||||
battle_visual_active = false
|
||||
returned_from_battle = true
|
||||
_save_stable()
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func reset_loop() -> void:
|
||||
if persistence_enabled:
|
||||
save_service.clear()
|
||||
continue_snapshot.clear()
|
||||
service.state.reset()
|
||||
service.battle.state.reset()
|
||||
battle_visual_active = false
|
||||
@@ -189,6 +230,18 @@ func reset_loop() -> void:
|
||||
_queue_render_current()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what in [NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_WM_CLOSE_REQUEST]:
|
||||
_save_stable()
|
||||
|
||||
|
||||
func _save_stable() -> bool:
|
||||
if not persistence_enabled or service.state.phase not in [State.Phase.HALL, State.Phase.ENCOUNTER]:
|
||||
return false
|
||||
var result: Dictionary = save_service.save_snapshot(service.state.snapshot())
|
||||
return bool(result.ok)
|
||||
|
||||
|
||||
func _queue_render_current() -> void:
|
||||
if render_queued:
|
||||
return
|
||||
@@ -248,17 +301,29 @@ func _render_title() -> void:
|
||||
subtitle.position = Vector2(size.x * 0.058, size.y * 0.39)
|
||||
subtitle.size = Vector2(size.x * 0.48, 34)
|
||||
display_root.add_child(subtitle)
|
||||
var start := _action_button("P93EnterHallButton", _tr("进入公会", "ENTER THE GUILD"), enter_hall)
|
||||
start.position = Vector2(size.x * 0.058, size.y * 0.62)
|
||||
var has_continue := not continue_snapshot.is_empty()
|
||||
var start := _action_button("P93EnterHallButton", _tr("新远征", "NEW EXPEDITION") if has_continue else _tr("进入公会", "ENTER THE GUILD"), enter_hall)
|
||||
start.position = Vector2(size.x * 0.058, size.y * (0.55 if has_continue else 0.62))
|
||||
start.size = Vector2(minf(270.0, size.x * 0.34), 56)
|
||||
display_root.add_child(start)
|
||||
if has_continue:
|
||||
var continue_button := _action_button("P96ContinueButton", _tr("继续远征", "CONTINUE EXPEDITION"), continue_saved_loop)
|
||||
continue_button.position = Vector2(size.x * 0.058, size.y * 0.73)
|
||||
continue_button.size = Vector2(minf(270.0, size.x * 0.34), 52)
|
||||
display_root.add_child(continue_button)
|
||||
|
||||
|
||||
func _render_hall() -> void:
|
||||
if service.equipment_choice_available():
|
||||
_render_equipment_hall()
|
||||
return
|
||||
if service.state.reward_receipts.size() >= 3 and service.state.equipment_receipts.size() == 1:
|
||||
if service.state.recovery_required:
|
||||
_render_injury_recovery_hall()
|
||||
return
|
||||
if service.state.reward_receipts.size() >= 4:
|
||||
_render_rift_tide_return_hall()
|
||||
return
|
||||
if service.state.reward_receipts.size() == 3 and service.state.equipment_receipts.size() == 1:
|
||||
_render_growth_return_hall()
|
||||
return
|
||||
_add_background(HALL_BACKGROUND)
|
||||
@@ -432,51 +497,186 @@ func _render_equipment_hall() -> void:
|
||||
|
||||
func _render_growth_return_hall() -> void:
|
||||
_add_background(HALL_BACKGROUND)
|
||||
_add_compact_hud(_tr("钟门凯旋", "BELL BREACH CLEARED"), service.state.coin, service.state.company.size())
|
||||
var headline := _outlined_label(_tr("全员升级 · 第三战奖励 +%d 金币" % service.state.last_reward, "COMPANY LEVEL UP · THIRD REWARD +%d COIN" % service.state.last_reward), 18, GOLD_LIGHT)
|
||||
_add_compact_hud(_tr("裂潮逼近", "RIFT TIDE APPROACHES"), service.state.coin, service.state.company.size())
|
||||
var headline := _outlined_label(_tr("钟门凯旋 · 伤势将带入连续两波", "BELL CLEARED · CURRENT HP CARRIES INTO TWO WAVES"), 16, GOLD_LIGHT)
|
||||
headline.name = "P95GrowthHeadline"
|
||||
headline.position = Vector2(size.x * 0.20, 64)
|
||||
headline.size = Vector2(size.x * 0.60, 46)
|
||||
headline.position = Vector2(size.x * 0.13, 60)
|
||||
headline.size = Vector2(size.x * 0.52, 44)
|
||||
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(headline)
|
||||
var actor_height := clampf(size.y * 0.36, 122.0, 146.0)
|
||||
var spacing := size.x * 0.25
|
||||
var start_x := size.x * 0.16
|
||||
var actor_height := clampf(size.y * 0.31, 112.0, 126.0)
|
||||
var spacing := size.x * 0.18
|
||||
var start_x := size.x * 0.055
|
||||
for index in range(service.state.company.size()):
|
||||
var member_id: String = service.state.company[index]
|
||||
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
|
||||
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.80, GOLD if not String(service.state.equipped_items[member_id]).is_empty() else CYAN, "P95GrowthRing_%s" % member_id)
|
||||
_add_actor(member_id, actor_position, actor_height, "P93Company_%s" % member_id, true, "victory" if member_id == "Hearthguard" else "idle")
|
||||
_add_member_status(member_id, actor_position + Vector2(-10, -54), Vector2(actor_height + 20, 52), "P95GrowthStatus_%s" % member_id)
|
||||
var item_id := String(service.state.equipped_items[member_id])
|
||||
if not item_id.is_empty():
|
||||
var icon := TextureRect.new()
|
||||
icon.name = "P95EquippedIcon_%s" % member_id
|
||||
icon.custom_minimum_size = Vector2.ZERO
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.texture = _equipment_texture(item_id)
|
||||
icon.position = actor_position + Vector2(actor_height - 20, actor_height * 0.34)
|
||||
icon.size = Vector2(54, 54)
|
||||
icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
display_root.add_child(icon)
|
||||
_add_member_status(member_id, actor_position + Vector2(-8, -48), Vector2(actor_height + 16, 38), "P95GrowthStatus_%s" % member_id, true)
|
||||
_add_member_condition(member_id, actor_position + Vector2(-8, -16), Vector2(actor_height + 16, 30), "P96Condition_%s" % member_id)
|
||||
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.38))
|
||||
|
||||
var plan_caption := _outlined_label(_tr("裂潮计划 · 现在选择", "RIFT TIDE PLAN · CHOOSE NOW"), 13, WHITE)
|
||||
plan_caption.name = "P96PlanHeadline"
|
||||
plan_caption.position = Vector2(size.x * 0.63, 62)
|
||||
plan_caption.size = Vector2(size.x * 0.34, 36)
|
||||
plan_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(plan_caption)
|
||||
_render_plan_target(
|
||||
"press_on",
|
||||
Vector2(size.x * 0.70, size.y * 0.67),
|
||||
_tr("直接追击", "PRESS ON"),
|
||||
_tr("0 金币 · 保留当前 HP", "0 COIN · KEEP CURRENT HP"),
|
||||
CORAL
|
||||
)
|
||||
_render_plan_target(
|
||||
"recover",
|
||||
Vector2(size.x * 0.88, size.y * 0.67),
|
||||
_tr("整队恢复", "RECOVER FIRST"),
|
||||
_tr("6 金币 · 全员满 HP", "6 COIN · FULL HP"),
|
||||
JADE
|
||||
)
|
||||
|
||||
|
||||
func _render_plan_target(plan_id: String, center: Vector2, title_text: String, consequence: String, color: Color) -> void:
|
||||
_add_ground_ring(center + Vector2(0, 32), 112, color, "P96PlanRing_%s" % plan_id)
|
||||
if plan_id == "press_on":
|
||||
var lead := _add_actor("Riftblade", center + Vector2(-49, -56), 60, "P96PlanEnemy_0", true, "advance")
|
||||
lead.modulate = Color(1.0, 0.76, 0.82, 1.0)
|
||||
var rear := _add_actor("Riftblade", center + Vector2(4, -44), 48, "P96PlanEnemy_1", true, "advance")
|
||||
rear.modulate = Color(0.78, 0.86, 1.0, 1.0)
|
||||
else:
|
||||
var recovery_icon := TextureRect.new()
|
||||
recovery_icon.name = "P96PlanRecoveryIcon"
|
||||
recovery_icon.texture = HEAL_VFX
|
||||
recovery_icon.custom_minimum_size = Vector2.ZERO
|
||||
recovery_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
recovery_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
recovery_icon.position = center + Vector2(-42, -58)
|
||||
recovery_icon.size = Vector2(84, 84)
|
||||
recovery_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
recovery_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
display_root.add_child(recovery_icon)
|
||||
var caption := _outlined_label("%s\n%s" % [title_text, consequence], 10, color)
|
||||
caption.name = "P96PlanCaption_%s" % plan_id
|
||||
caption.position = center + Vector2(-72, 36)
|
||||
caption.size = Vector2(144, 48)
|
||||
caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(caption)
|
||||
var target := _actor_target("P96PlanTarget_%s" % plan_id, center + Vector2(-62, -68), Vector2(124, 156), tap_rift_tide_plan.bind(plan_id))
|
||||
target.tooltip_text = "%s · %s" % [title_text, consequence]
|
||||
display_root.add_child(target)
|
||||
|
||||
|
||||
func _render_injury_recovery_hall() -> void:
|
||||
_add_background(HALL_BACKGROUND)
|
||||
var defeated := service.state.last_outcome == "defeat"
|
||||
var cost := service.injury_recovery_cost()
|
||||
_add_compact_hud(_tr("裂潮败退", "RIFT TIDE DEFEAT") if defeated else _tr("裂潮凯旋", "RIFT TIDE CLEARED"), service.state.coin, service.state.company.size())
|
||||
var headline_text := _tr("两波击溃了队伍 · 无奖励、无人丢失", "TWO WAVES BROKE THE LINE · NO REWARD, NO ONE LOST") if defeated else _tr("胜利 +20 金币 · 倒下队员等待恢复", "VICTORY +20 COIN · FALLEN RECRUITS NEED RECOVERY")
|
||||
var headline := _outlined_label(headline_text, 16, CORAL if defeated else GOLD_LIGHT)
|
||||
headline.name = "P96RecoveryHeadline"
|
||||
headline.position = Vector2(size.x * 0.17, 62)
|
||||
headline.size = Vector2(size.x * 0.66, 44)
|
||||
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(headline)
|
||||
|
||||
var actor_height := clampf(size.y * 0.33, 118.0, 134.0)
|
||||
var spacing := size.x * 0.19
|
||||
var start_x := size.x * 0.10
|
||||
for index in range(service.state.company.size()):
|
||||
var member_id: String = service.state.company[index]
|
||||
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
|
||||
var injured := member_id in service.state.injured_members
|
||||
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.78, CORAL if injured else JADE, "P96RecoveryRing_%s" % member_id)
|
||||
var actor := _add_actor(member_id, actor_position, actor_height, "P96RecoveryActor_%s" % member_id, true)
|
||||
actor.modulate = Color(0.72, 0.72, 0.78, 1.0) if injured else Color.WHITE
|
||||
_add_member_condition(member_id, actor_position + Vector2(-8, -18), Vector2(actor_height + 16, 34), "P96RecoveryCondition_%s" % member_id)
|
||||
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.40))
|
||||
|
||||
var recovery_label := _outlined_label(
|
||||
_tr("支付 %d 金币,全员恢复;失败重试不会重复奖励" % cost, "PAY %d COIN TO RESTORE ALL; RETRY CANNOT DUPLICATE REWARDS" % cost),
|
||||
12,
|
||||
JADE
|
||||
)
|
||||
recovery_label.name = "P96RecoveryDetail"
|
||||
recovery_label.position = Vector2(size.x * 0.58, size.y * 0.43)
|
||||
recovery_label.size = Vector2(size.x * 0.38, 52)
|
||||
recovery_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(recovery_label)
|
||||
var recovery_button := _action_button("P96RecoveryButton", _tr("恢复队伍 ◆ %d" % cost, "RECOVER COMPANY ◆ %d" % cost), recover_injured_company)
|
||||
recovery_button.position = Vector2(size.x * 0.66, size.y * 0.68)
|
||||
recovery_button.size = Vector2(size.x * 0.25, 54)
|
||||
recovery_button.disabled = service.state.coin < cost
|
||||
display_root.add_child(recovery_button)
|
||||
|
||||
|
||||
func _render_rift_tide_return_hall() -> void:
|
||||
_add_background(HALL_BACKGROUND)
|
||||
_add_compact_hud(_tr("裂潮归来", "RIFT TIDE RETURN"), service.state.coin, service.state.company.size())
|
||||
var receipt: Dictionary = service.state.rift_tide_result_receipts[-1] if not service.state.rift_tide_result_receipts.is_empty() else {}
|
||||
var final_hp: Dictionary = Dictionary(receipt.get("member_hp", service.state.member_condition)).duplicate(true)
|
||||
var xp_members: Array = []
|
||||
if not service.state.xp_receipts.is_empty():
|
||||
xp_members = Array(service.state.xp_receipts[-1].get("members", []))
|
||||
var xp_names: Array[String] = []
|
||||
for row in xp_members:
|
||||
xp_names.append(_member_name(String(row.get("member_id", ""))))
|
||||
var headline := _outlined_label(_tr("裂潮已平息 · +20 金币", "RIFT TIDE BROKEN · +20 COIN"), 19, GOLD_LIGHT)
|
||||
headline.name = "P96FinalHeadline"
|
||||
headline.position = Vector2(size.x * 0.18, 60)
|
||||
headline.size = Vector2(size.x * 0.64, 44)
|
||||
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(headline)
|
||||
var xp_text := ", ".join(xp_names)
|
||||
var detail := _outlined_label(
|
||||
_tr("存活者 +18 XP:%s\n所有队员与装备均已保留" % xp_text, "SURVIVORS +18 XP: %s\nALL RECRUITS AND EQUIPMENT PRESERVED" % xp_text),
|
||||
11,
|
||||
JADE
|
||||
)
|
||||
detail.name = "P96FinalDetail"
|
||||
detail.position = Vector2(size.x * 0.24, 99)
|
||||
detail.size = Vector2(size.x * 0.52, 50)
|
||||
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(detail)
|
||||
var actor_height := clampf(size.y * 0.34, 120.0, 138.0)
|
||||
var spacing := size.x * 0.24
|
||||
var start_x := size.x * 0.14
|
||||
for index in range(service.state.company.size()):
|
||||
var member_id: String = service.state.company[index]
|
||||
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
|
||||
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.80, GOLD if not String(service.state.equipped_items[member_id]).is_empty() else JADE, "P96FinalRing_%s" % member_id)
|
||||
_add_actor(member_id, actor_position, actor_height, "P96FinalActor_%s" % member_id, true, "victory" if member_id == "Hearthguard" else "idle")
|
||||
var maximum := int(Rules.member_definition(member_id).get("max_hp", 0))
|
||||
var battle_hp := int(final_hp.get(member_id, 0))
|
||||
var condition := _outlined_label(_tr("战斗末 %d/%d HP" % [battle_hp, maximum], "BATTLE END %d/%d HP" % [battle_hp, maximum]), 9, CORAL if battle_hp <= 0 else JADE)
|
||||
condition.name = "P96FinalCondition_%s" % member_id
|
||||
condition.position = actor_position + Vector2(-12, -18)
|
||||
condition.size = Vector2(actor_height + 24, 32)
|
||||
condition.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(condition)
|
||||
_add_member_status(member_id, actor_position + Vector2(-12, actor_height - 30), Vector2(actor_height + 24, 42), "P96FinalStatus_%s" % member_id, true)
|
||||
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.38))
|
||||
|
||||
|
||||
func _render_encounters() -> void:
|
||||
_add_background(HALL_BACKGROUND)
|
||||
var p95_bell_only := service.state.reward_receipts.size() == 2 and service.state.equipment_receipts.size() == 1
|
||||
_add_compact_hud(_tr("检验新装备", "TEST THE NEW EQUIPMENT") if p95_bell_only else _tr("选择下一场战斗", "CHOOSE THE NEXT FIGHT"), service.state.coin, service.state.company.size())
|
||||
var actor_height := 100.0 if p95_bell_only else clampf(size.y * 0.30, 100.0, 126.0)
|
||||
var p96_rift_only := service.state.reward_receipts.size() == 3 and not service.state.rift_tide_plan.is_empty()
|
||||
var encounter_context := _tr("裂潮追击 · 连续两波", "RIFT TIDE · TWO WAVES") if p96_rift_only else (_tr("检验新装备", "TEST THE NEW EQUIPMENT") if p95_bell_only else _tr("选择下一场战斗", "CHOOSE THE NEXT FIGHT"))
|
||||
_add_compact_hud(encounter_context, service.state.coin, service.state.company.size())
|
||||
var actor_height := 100.0 if p95_bell_only or p96_rift_only else clampf(size.y * 0.30, 100.0, 126.0)
|
||||
for index in range(service.state.company.size()):
|
||||
var member_id: String = service.state.company[index]
|
||||
var actor_position := Vector2(16 + index * 100, size.y - actor_height - 16) if p95_bell_only else Vector2(36 + index * actor_height * 0.57, size.y - actor_height - 16)
|
||||
var actor_position := Vector2(16 + index * 100, size.y - actor_height - 16) if p95_bell_only or p96_rift_only else Vector2(36 + index * actor_height * 0.57, size.y - actor_height - 16)
|
||||
_add_actor(member_id, actor_position, actor_height, "P93Ready_%s" % member_id, true, "advance")
|
||||
if p95_bell_only:
|
||||
_add_member_status(member_id, actor_position + Vector2(0, -40), Vector2(96, 40), "P95ReadyStatus_%s" % member_id, true)
|
||||
elif p96_rift_only:
|
||||
_add_member_condition(member_id, actor_position + Vector2(0, -34), Vector2(96, 34), "P96ReadyCondition_%s" % member_id)
|
||||
|
||||
var encounter_ids := ["bell_breach"] if p95_bell_only else ["open_road", "crosswind", "bell_breach"]
|
||||
var start_x := size.x * (0.72 if p95_bell_only else 0.43)
|
||||
var encounter_ids := ["rift_tide"] if p96_rift_only else (["bell_breach"] if p95_bell_only else ["open_road", "crosswind", "bell_breach"])
|
||||
var start_x := size.x * (0.73 if p95_bell_only or p96_rift_only else 0.43)
|
||||
var spacing := size.x * 0.18
|
||||
for index in range(encounter_ids.size()):
|
||||
var encounter_id: String = encounter_ids[index]
|
||||
@@ -489,7 +689,7 @@ func _render_encounters() -> void:
|
||||
var enemy_size := actor_height * (0.58 if enemy_index == 0 else 0.48)
|
||||
var enemy_pos := Vector2(center.x - enemy_size * 0.58 + enemy_index * 34, size.y - enemy_size - 58 - enemy_index * 10)
|
||||
var enemy := _add_actor("Riftblade", enemy_pos, enemy_size, "P93EncounterEnemy_%s_%d" % [encounter_id, enemy_index], true)
|
||||
enemy.modulate = Color(1.0, 0.75, 0.82, 1.0) if encounter_id == "bell_breach" else Color.WHITE
|
||||
enemy.modulate = Color(1.0, 0.75, 0.82, 1.0) if encounter_id in ["bell_breach", "rift_tide"] else Color.WHITE
|
||||
var caption := _outlined_label("%s\n%s ◆ +%d" % [_encounter_name(encounter_id), _tr(String(data.pressure_zh), String(data.pressure_en)), int(data.reward)], 10, GOLD_LIGHT if selected else WHITE)
|
||||
caption.name = "P93EncounterCaption_%s" % encounter_id
|
||||
caption.position = Vector2(center.x - 70, 66)
|
||||
@@ -516,6 +716,17 @@ func _render_encounters() -> void:
|
||||
equipment_read.size = Vector2(size.x * 0.28, 62)
|
||||
equipment_read.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(equipment_read)
|
||||
elif p96_rift_only:
|
||||
var plan_read := _outlined_label(
|
||||
_tr("已恢复后重试\n敌人仅剩裂潮" if service.state.rift_tide_plan == "recovered_after_defeat" else ("已选择:%s\n敌人仅剩裂潮" % ("整队恢复" if service.state.rift_tide_plan == "recover" else "直接追击")), "RECOVERED RETRY\nONLY RIFT TIDE REMAINS" if service.state.rift_tide_plan == "recovered_after_defeat" else ("PLAN: %s\nONLY RIFT TIDE REMAINS" % ("RECOVER FIRST" if service.state.rift_tide_plan == "recover" else "PRESS ON"))),
|
||||
11,
|
||||
JADE if service.state.rift_tide_plan in ["recover", "recovered_after_defeat"] else CORAL
|
||||
)
|
||||
plan_read.name = "P96CommittedPlan"
|
||||
plan_read.position = Vector2(size.x * 0.42, 72)
|
||||
plan_read.size = Vector2(size.x * 0.26, 58)
|
||||
plan_read.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(plan_read)
|
||||
|
||||
|
||||
func _render_battle() -> void:
|
||||
@@ -561,6 +772,13 @@ func _process_battle(delta: float) -> void:
|
||||
|
||||
func _handle_battle_event(event: Dictionary) -> void:
|
||||
match String(event.get("kind", "")):
|
||||
"wave_started":
|
||||
_clear_battle_feedback()
|
||||
battle_actions.clear()
|
||||
var context := find_child("P93BattleContext", true, false) as Label
|
||||
if context != null:
|
||||
context.text = _battle_context_text()
|
||||
_spawn_word(_tr("第二波来袭!", "WAVE 2 INCOMING!"), Vector2(size.x * 0.42, size.y * 0.26), CORAL, "P96WaveCue")
|
||||
"contact":
|
||||
_spawn_word(_tr("接敌!", "CONTACT!"), Vector2(size.x * 0.49, size.y * 0.46), GOLD_LIGHT)
|
||||
"equipment_trigger":
|
||||
@@ -677,6 +895,7 @@ func _show_battle_result() -> void:
|
||||
if battle_result_shown:
|
||||
return
|
||||
battle_result_shown = true
|
||||
_clear_battle_feedback()
|
||||
var victory := service.state.last_outcome == "victory"
|
||||
var reward := int(service.encounter(service.state.selected_encounter).get("reward", 0)) if victory else 0
|
||||
var result := _outlined_label(_tr("胜利 ◆ +%d 金币" % reward, "VICTORY ◆ +%d COIN" % reward) if victory else _tr("队伍战败", "COMPANY DEFEATED"), 28, GOLD_LIGHT if victory else CORAL)
|
||||
@@ -698,10 +917,30 @@ func _show_battle_result() -> void:
|
||||
result_detail.size = Vector2(size.x * 0.64, 52)
|
||||
result_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(result_detail)
|
||||
elif service.state.selected_encounter == "rift_tide":
|
||||
var receipts: Array[Dictionary] = service.state.rift_tide_wave_receipts
|
||||
var wave_one_tick := int(receipts[-2].tick) if receipts.size() >= 2 else 0
|
||||
var wave_two_tick := int(receipts[-1].tick) if receipts.size() >= 1 else 0
|
||||
var fallen: Array[String] = []
|
||||
for member_id in service.state.company:
|
||||
if int(service.battle.state.member_hp.get(member_id, 0)) <= 0:
|
||||
fallen.append(_member_name(member_id))
|
||||
var detail_text := ""
|
||||
if victory:
|
||||
detail_text = _tr("第一波 %d tick · 第二波 %d tick\n存活者 +18 XP · 倒下 %d 人,返回后恢复 %d 金币" % [wave_one_tick, wave_two_tick, fallen.size(), fallen.size() * 2], "WAVE 1 %d TICKS · WAVE 2 %d TICKS\nSURVIVORS +18 XP · %d FALLEN, RECOVERY %d COIN" % [wave_one_tick, wave_two_tick, fallen.size(), fallen.size() * 2])
|
||||
else:
|
||||
detail_text = _tr("第一波 %d tick · 第二波 %d tick 后全队倒下\n无奖励、无人丢失 · 返回后恢复 8 金币" % [wave_one_tick, wave_two_tick], "WAVE 1 %d TICKS · COMPANY FELL IN WAVE 2 AT %d\nNO REWARD, NO ONE LOST · RECOVERY 8 COIN" % [wave_one_tick, wave_two_tick])
|
||||
var result_detail := _outlined_label(detail_text, 12, GOLD_LIGHT if victory else CORAL)
|
||||
result_detail.name = "P96ResultDetail"
|
||||
result_detail.position = Vector2(size.x * 0.17, size.y * 0.39)
|
||||
result_detail.size = Vector2(size.x * 0.66, 66)
|
||||
result_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(result_detail)
|
||||
for hp_bar in battle_hp_bars.values():
|
||||
if is_instance_valid(hp_bar):
|
||||
hp_bar.hide()
|
||||
var return_button := _action_button("P93ReturnHallButton", _tr("带着结果返回大厅", "RETURN TO THE HALL"), claim_and_return)
|
||||
var return_text := _tr("领取 +20 并返回", "CLAIM +20 AND RETURN") if service.state.selected_encounter == "rift_tide" and victory else (_tr("返回大厅恢复", "RETURN TO RECOVER") if service.state.selected_encounter == "rift_tide" else _tr("带着结果返回大厅", "RETURN TO THE HALL"))
|
||||
var return_button := _action_button("P93ReturnHallButton", return_text, claim_and_return)
|
||||
return_button.position = Vector2(size.x * 0.35, size.y - 68)
|
||||
return_button.size = Vector2(size.x * 0.30, 52)
|
||||
display_root.add_child(return_button)
|
||||
@@ -735,7 +974,7 @@ func _add_compact_hud(context: String, coin: int, company_size: int) -> void:
|
||||
|
||||
|
||||
func _add_battle_controls() -> void:
|
||||
var context := _outlined_label(_encounter_name(service.state.selected_encounter), 14, WHITE)
|
||||
var context := _outlined_label(_battle_context_text(), 14, WHITE)
|
||||
context.name = "P93BattleContext"
|
||||
context.position = Vector2(14, 8)
|
||||
context.size = Vector2(size.x * 0.44, 42)
|
||||
@@ -751,6 +990,12 @@ func _add_battle_controls() -> void:
|
||||
display_root.add_child(speed_button)
|
||||
|
||||
|
||||
func _battle_context_text() -> String:
|
||||
if service.state.selected_encounter == "rift_tide":
|
||||
return _tr("裂潮追击 · 第 %d/2 波" % service.state.rift_tide_wave, "RIFT TIDE · WAVE %d/2" % service.state.rift_tide_wave)
|
||||
return _encounter_name(service.state.selected_encounter)
|
||||
|
||||
|
||||
func _update_battle_control_labels() -> void:
|
||||
var pause_button := find_child("P93PauseButton", true, false) as Button
|
||||
var speed_button := find_child("P93SpeedButton", true, false) as Button
|
||||
@@ -786,6 +1031,7 @@ func _spawn_number(delta_value: int, actor_id: String, color: Color) -> void:
|
||||
number.position = actor.position + Vector2(actor.size.x * 0.36, -6)
|
||||
number.size = Vector2(92, 38)
|
||||
number.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
number.add_to_group("p93_transient_feedback")
|
||||
display_root.add_child(number)
|
||||
var tween := number.create_tween()
|
||||
tween.set_parallel(true)
|
||||
@@ -808,6 +1054,7 @@ func _spawn_word(value: String, at: Vector2, color: Color, node_name := "") -> v
|
||||
word.position = at
|
||||
word.size = Vector2(150, 36)
|
||||
word.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
word.add_to_group("p93_transient_feedback")
|
||||
display_root.add_child(word)
|
||||
var tween := word.create_tween()
|
||||
tween.tween_property(word, "modulate:a", 0.0, 0.72).set_delay(0.28)
|
||||
@@ -824,6 +1071,7 @@ func _spawn_projectile(source_id: String, target_id: String) -> void:
|
||||
projectile.position = source.position + Vector2(source.size.x * 0.72, source.size.y * 0.38)
|
||||
projectile.size = Vector2(52, 36)
|
||||
projectile.z_index = 20
|
||||
projectile.add_to_group("p93_transient_feedback")
|
||||
display_root.add_child(projectile)
|
||||
var target_position := target.position + Vector2(target.size.x * 0.28, target.size.y * 0.40)
|
||||
var tween := projectile.create_tween()
|
||||
@@ -848,6 +1096,7 @@ func _spawn_vfx(actor_id: String, texture: Texture2D) -> void:
|
||||
vfx.size = actor.size * 0.28
|
||||
vfx.z_index = 18
|
||||
vfx.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vfx.add_to_group("p93_transient_feedback")
|
||||
display_root.add_child(vfx)
|
||||
var tween := vfx.create_tween()
|
||||
tween.set_parallel(true)
|
||||
@@ -856,6 +1105,14 @@ func _spawn_vfx(actor_id: String, texture: Texture2D) -> void:
|
||||
tween.chain().tween_callback(vfx.queue_free)
|
||||
|
||||
|
||||
func _clear_battle_feedback() -> void:
|
||||
for transient in get_tree().get_nodes_in_group("p93_transient_feedback"):
|
||||
if not is_instance_valid(transient) or not display_root.is_ancestor_of(transient):
|
||||
continue
|
||||
transient.hide()
|
||||
transient.queue_free()
|
||||
|
||||
|
||||
func _add_background(texture: Texture2D) -> void:
|
||||
var background := TextureRect.new()
|
||||
background.name = "P93WorldBackground"
|
||||
@@ -1030,6 +1287,34 @@ func _add_member_status(member_id: String, at: Vector2, status_size: Vector2, no
|
||||
display_root.add_child(status)
|
||||
|
||||
|
||||
func _add_member_condition(member_id: String, at: Vector2, status_size: Vector2, node_name: String) -> void:
|
||||
var current := int(service.state.member_condition.get(member_id, 0))
|
||||
var maximum := int(Rules.member_definition(member_id).get("max_hp", 0))
|
||||
var status := _outlined_label(_tr("%d/%d HP" % [current, maximum], "%d/%d HP" % [current, maximum]), 10, CORAL if current * 2 < maximum else JADE)
|
||||
status.name = node_name
|
||||
status.position = at
|
||||
status.size = status_size
|
||||
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
display_root.add_child(status)
|
||||
|
||||
|
||||
func _add_equipped_icon(member_id: String, at: Vector2) -> void:
|
||||
var item_id := String(service.state.equipped_items.get(member_id, ""))
|
||||
if item_id.is_empty():
|
||||
return
|
||||
var icon := TextureRect.new()
|
||||
icon.name = "P95EquippedIcon_%s" % member_id
|
||||
icon.custom_minimum_size = Vector2.ZERO
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.texture = _equipment_texture(item_id)
|
||||
icon.position = at
|
||||
icon.size = Vector2(54, 54)
|
||||
icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
display_root.add_child(icon)
|
||||
|
||||
|
||||
func _equipment_texture(item_id: String) -> Texture2D:
|
||||
return FRONTPIN_TEXTURE if item_id == "EQ-002" else OPEN_LINE_TEXTURE
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
extends SceneTree
|
||||
|
||||
const Controller = preload("res://p9_3/core_loop_controller.gd")
|
||||
const Service = preload("res://p9_3/core_loop_service.gd")
|
||||
const SaveService = preload("res://p9_6/save_service.gd")
|
||||
const State = preload("res://p9_3/core_loop_state.gd")
|
||||
const Rules = preload("res://domain/p9_2/battle_rules.gd")
|
||||
const VIEWPORT_SIZE := Vector2i(844, 390)
|
||||
|
||||
var failures: Array[String] = []
|
||||
var experience
|
||||
var capture_root := ""
|
||||
var save_root := ""
|
||||
var touch_count := 0
|
||||
var wave_cue_count := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
|
||||
func run_test() -> void:
|
||||
capture_root = OS.get_environment("ABG_P9_6_CAPTURE_ROOT")
|
||||
save_root = OS.get_environment("ABG_P9_6_SAVE_ROOT")
|
||||
if save_root.is_empty():
|
||||
save_root = OS.get_user_data_dir().path_join("p9_6_runtime_test")
|
||||
DirAccess.make_dir_recursive_absolute(save_root)
|
||||
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
|
||||
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
|
||||
root.content_scale_size = VIEWPORT_SIZE
|
||||
root.size = VIEWPORT_SIZE
|
||||
|
||||
await verify_invalid_save_and_new_expedition()
|
||||
await run_wayrunner_failure_recovery()
|
||||
await run_kindhand_direct_victory()
|
||||
|
||||
if failures.is_empty():
|
||||
print("ABG_P9_6_RISK_RECOVERY_RUNTIME_OK paths=2 touch=%d fallback=0 direct=0 continue=valid_only reload=selected_rift new_run=clears_old waves=2 wave_cues=%d wayrunner=defeat_retry kindhand=direct_victory recovery=8,4 reward=20 xp=18 controls=44 cards=0" % [touch_count, wave_cue_count])
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func verify_invalid_save_and_new_expedition() -> void:
|
||||
var save_path := save_root.path_join("title.json")
|
||||
var saves = SaveService.new()
|
||||
saves.save_path = save_path
|
||||
saves.clear()
|
||||
var malformed := FileAccess.open(save_path, FileAccess.WRITE)
|
||||
malformed.store_string("{not-json")
|
||||
malformed = null
|
||||
await create_experience(save_path)
|
||||
expect(find_control(experience, "P96ContinueButton") == null, "invalid save does not expose Continue")
|
||||
expect(find_control(experience, "P93EnterHallButton") != null, "invalid save keeps the safe new-run action")
|
||||
await destroy_experience()
|
||||
saves.clear()
|
||||
|
||||
var old_service = complete_bell("Wayrunner", "EQ-002")
|
||||
old_service.choose_rift_tide_plan("press_on")
|
||||
old_service.select_encounter("rift_tide")
|
||||
expect(saves.save_snapshot(old_service.state.snapshot()).ok, "new-run fixture saves selected Rift Tide")
|
||||
await create_experience(save_path)
|
||||
expect(find_control(experience, "P96ContinueButton") != null, "valid save exposes Continue")
|
||||
await tap("P93EnterHallButton")
|
||||
expect(experience.service.state.phase == State.Phase.HALL and experience.service.state.reward_receipts.is_empty(), "New Expedition starts from a fresh Hall")
|
||||
var replacement: Dictionary = saves.load_snapshot()
|
||||
expect(replacement.ok and replacement.snapshot.phase == State.Phase.HALL and replacement.snapshot.reward_receipts.is_empty(), "New Expedition replaces the old selected-Rift save")
|
||||
await destroy_experience()
|
||||
saves.clear()
|
||||
|
||||
|
||||
func run_wayrunner_failure_recovery() -> void:
|
||||
var save_path := save_root.path_join("wayrunner.json")
|
||||
var saves = SaveService.new()
|
||||
saves.save_path = save_path
|
||||
saves.clear()
|
||||
await create_experience(save_path)
|
||||
experience.service = complete_bell("Wayrunner", "EQ-002")
|
||||
experience.returned_from_battle = true
|
||||
experience._render_current()
|
||||
await settle(6)
|
||||
|
||||
expect(label_text("P96Condition_Hearthguard").contains("22/130 HP"), "Wayrunner Hall shows carried Hearthguard HP")
|
||||
expect(label_text("P96Condition_Farstring").contains("85/85 HP"), "Wayrunner Hall shows carried Farstring HP")
|
||||
expect(label_text("P96Condition_Wayrunner").contains("100/100 HP"), "Wayrunner Hall shows carried Wayrunner HP")
|
||||
expect(find_control(experience, "P96PlanTarget_press_on") != null and find_control(experience, "P96PlanTarget_recover") != null, "post-Bell Hall exposes both direct plan targets without preselection")
|
||||
verify_no_forms_and_bounds()
|
||||
await capture("wayrunner", "01-plan")
|
||||
|
||||
await tap("P96PlanTarget_press_on")
|
||||
expect(experience.service.state.phase == State.Phase.ENCOUNTER and experience.service.state.rift_tide_plan == "press_on", "Wayrunner commits Press On")
|
||||
expect(find_control(experience, "P93EncounterTarget_rift_tide") != null, "Press On exposes Rift Tide")
|
||||
expect(find_control(experience, "P93EncounterTarget_bell_breach") == null, "Press On removes resolved encounters")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
expect(experience.service.state.selected_encounter == "rift_tide", "Wayrunner selects Rift Tide before interruption")
|
||||
expect(saves.load_snapshot().ok and saves.load_snapshot().snapshot.selected_encounter == "rift_tide", "selected Rift Tide is saved immediately")
|
||||
await destroy_experience()
|
||||
|
||||
await create_experience(save_path)
|
||||
expect(find_control(experience, "P96ContinueButton") != null, "selected Rift Tide produces a truthful Continue action")
|
||||
await tap("P96ContinueButton")
|
||||
expect(experience.service.state.phase == State.Phase.ENCOUNTER and experience.service.state.selected_encounter == "rift_tide", "Continue restores the selected pre-battle Encounter")
|
||||
expect(find_control(experience, "P93EncounterTarget_rift_tide") != null, "continued Encounter remains directly touchable")
|
||||
await capture("wayrunner", "02-continued-encounter")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
expect(experience.service.state.phase == State.Phase.BATTLE, "continued Wayrunner path starts Rift Tide")
|
||||
var first_battle := await run_battle_to_result("wayrunner", "03-wave-two")
|
||||
expect(first_battle.wave_two_seen, "Wayrunner defeat visibly enters wave two")
|
||||
expect(experience.service.state.last_outcome == "defeat", "wounded Wayrunner Press On loses")
|
||||
expect(find_control(experience, "P96ResultDetail") != null and label_text("P96ResultDetail").contains("8"), "defeat result names both waves and 8-Coin recovery")
|
||||
verify_no_forms_and_bounds()
|
||||
await capture("wayrunner", "04-defeat-result")
|
||||
|
||||
await tap("P93ReturnHallButton")
|
||||
expect(experience.service.state.phase == State.Phase.HALL and experience.service.state.recovery_required, "defeat returns the recoverable Company")
|
||||
expect(experience.service.injury_recovery_cost() == 8 and find_control(experience, "P96RecoveryButton") != null, "defeat Hall exposes exact 8-Coin recovery")
|
||||
expect(label_text("P96RecoveryHeadline").contains("无人丢失"), "defeat explains that no Recruit or item is lost")
|
||||
await capture("wayrunner", "05-defeat-recovery")
|
||||
await tap("P96RecoveryButton")
|
||||
expect(experience.service.state.phase == State.Phase.ENCOUNTER and experience.service.state.rift_tide_plan == "recovered_after_defeat", "8-Coin recovery opens a full-HP retry")
|
||||
expect(experience.service.state.coin == 30, "failure recovery spends exactly 8 Coin")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
expect(experience.service.state.phase == State.Phase.BATTLE, "recovered Wayrunner retry starts")
|
||||
var retry_battle := await run_battle_to_result("wayrunner", "06-retry-wave-two")
|
||||
expect(retry_battle.wave_two_seen and experience.service.state.last_outcome == "victory", "recovered retry visibly clears both waves")
|
||||
expect(find_control(experience, "P96ResultDetail") != null and label_text("P96ResultDetail").contains("18 XP"), "retry victory names living-member XP")
|
||||
await capture("wayrunner", "07-retry-victory")
|
||||
|
||||
await tap("P93ReturnHallButton")
|
||||
expect(experience.service.state.recovery_required and experience.service.injury_recovery_cost() == 4, "Wayrunner victory returns two fallen members for 4 Coin")
|
||||
expect(experience.service.state.coin == 50 and find_control(experience, "P96RecoveryButton") != null, "victory reward commits before injury recovery")
|
||||
await capture("wayrunner", "08-victory-injuries")
|
||||
await tap("P96RecoveryButton")
|
||||
expect(experience.service.state.coin == 46 and not experience.service.state.recovery_required, "victory recovery settles the failure route at 46 Coin")
|
||||
expect(find_control(experience, "P96FinalHeadline") != null, "recovered Wayrunner reaches the changed final Hall")
|
||||
expect(label_text("P96FinalCondition_Hearthguard").contains("0/130") and label_text("P96FinalCondition_Wayrunner").contains("36/100"), "final Hall preserves the battle-end HP consequence")
|
||||
expect(find_control(experience, "P95EquippedIcon_Hearthguard") != null, "failure and recovery preserve Frontpin equipment")
|
||||
expect(experience.service.state.member_xp == {"Hearthguard": 12, "Farstring": 12, "Wayrunner": 18}, "only the living Wayrunner receives fourth-battle XP")
|
||||
verify_no_forms_and_bounds()
|
||||
await capture("wayrunner", "09-final-hall")
|
||||
await destroy_experience()
|
||||
saves.clear()
|
||||
|
||||
|
||||
func run_kindhand_direct_victory() -> void:
|
||||
var save_path := save_root.path_join("kindhand.json")
|
||||
var saves = SaveService.new()
|
||||
saves.save_path = save_path
|
||||
saves.clear()
|
||||
await create_experience(save_path)
|
||||
experience.service = complete_bell("Kindhand", "EQ-012")
|
||||
experience.returned_from_battle = true
|
||||
experience._render_current()
|
||||
await settle(6)
|
||||
expect(label_text("P96Condition_Hearthguard").contains("108/130 HP") and label_text("P96Condition_Farstring").contains("51/85 HP"), "Kindhand Hall exposes its healthier carried state")
|
||||
await tap("P96PlanTarget_press_on")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
await tap("P93EncounterTarget_rift_tide")
|
||||
expect(experience.service.state.phase == State.Phase.BATTLE, "Kindhand Press On starts Rift Tide")
|
||||
var battle := await run_battle_to_result("kindhand", "01-wave-two")
|
||||
expect(battle.wave_two_seen and experience.service.state.last_outcome == "victory", "healthy Kindhand Press On clears both waves")
|
||||
expect(label_text("P96ResultDetail").contains("144") and label_text("P96ResultDetail").contains("18 XP"), "Kindhand result exposes both exact waves and XP")
|
||||
await capture("kindhand", "02-direct-victory")
|
||||
await tap("P93ReturnHallButton")
|
||||
expect(not experience.service.state.recovery_required and experience.service.state.coin == 57, "Kindhand victory needs no recovery and settles at 57 Coin")
|
||||
expect(find_control(experience, "P96FinalHeadline") != null and find_control(experience, "P96RecoveryButton") == null, "Kindhand returns directly to the final Hall")
|
||||
expect(experience.service.state.member_xp == {"Hearthguard": 30, "Farstring": 30, "Kindhand": 18}, "all living Kindhand members receive fourth-battle XP")
|
||||
expect(label_text("P96FinalCondition_Hearthguard").contains("54/130") and label_text("P96FinalCondition_Farstring").contains("65/85") and label_text("P96FinalCondition_Kindhand").contains("90/90"), "Kindhand final Hall exposes exact battle-end HP")
|
||||
expect(find_control(experience, "P95EquippedIcon_Farstring") != null, "Kindhand route preserves Open-Line equipment")
|
||||
verify_no_forms_and_bounds()
|
||||
await capture("kindhand", "03-final-hall")
|
||||
await destroy_experience()
|
||||
saves.clear()
|
||||
|
||||
|
||||
func run_battle_to_result(path_id: String, wave_capture_name: String) -> Dictionary:
|
||||
var wave_two_seen := false
|
||||
var wave_captured := false
|
||||
var guard := 0
|
||||
while experience.service.state.phase == State.Phase.BATTLE and guard < 3000:
|
||||
experience._process(Rules.TICK_SECONDS)
|
||||
if experience.service.state.rift_tide_wave == 2:
|
||||
wave_two_seen = true
|
||||
if find_control(experience, "P96WaveCue") != null and not wave_captured:
|
||||
wave_cue_count += 1
|
||||
await capture(path_id, wave_capture_name)
|
||||
wave_captured = true
|
||||
await process_frame
|
||||
guard += 1
|
||||
expect(guard < 3000 and experience.service.state.phase == State.Phase.RESULT, "%s reaches a bounded Rift Tide result" % path_id)
|
||||
experience._process(1.0)
|
||||
await settle(4)
|
||||
expect(find_control(experience, "P93BattleResult") != null, "%s holds the result on the battlefield" % path_id)
|
||||
return {"wave_two_seen": wave_two_seen, "wave_captured": wave_captured}
|
||||
|
||||
|
||||
func complete_bell(third_member: String, item_id: String):
|
||||
var service = Service.new()
|
||||
service.enter_hall()
|
||||
service.focus_offer("Farstring")
|
||||
service.hire("Farstring")
|
||||
service.select_encounter("open_road")
|
||||
service.start_battle()
|
||||
run_service_to_result(service)
|
||||
service.claim_and_return()
|
||||
service.focus_offer(third_member)
|
||||
service.hire(third_member)
|
||||
service.select_encounter("crosswind")
|
||||
service.start_battle()
|
||||
run_service_to_result(service)
|
||||
service.claim_and_return()
|
||||
service.focus_equipment(item_id)
|
||||
service.equip_focused("Hearthguard" if item_id == "EQ-002" else "Farstring")
|
||||
service.select_encounter("bell_breach")
|
||||
service.start_battle()
|
||||
run_service_to_result(service)
|
||||
service.claim_and_return()
|
||||
return service
|
||||
|
||||
|
||||
func run_service_to_result(service) -> void:
|
||||
var guard := 0
|
||||
while service.state.phase == State.Phase.BATTLE and guard < 3000:
|
||||
service.advance_battle_ticks(1)
|
||||
guard += 1
|
||||
expect(guard < 3000 and service.state.phase == State.Phase.RESULT, "fixture battle reaches a bounded result")
|
||||
|
||||
|
||||
func create_experience(save_path: String) -> void:
|
||||
experience = Controller.new()
|
||||
experience.persistence_enabled = true
|
||||
experience.save_service.save_path = save_path
|
||||
experience.size = Vector2(VIEWPORT_SIZE)
|
||||
root.add_child(experience)
|
||||
await settle(8)
|
||||
|
||||
|
||||
func destroy_experience() -> void:
|
||||
if is_instance_valid(experience):
|
||||
experience.queue_free()
|
||||
await settle(90)
|
||||
experience = null
|
||||
|
||||
|
||||
func tap(node_name: String) -> void:
|
||||
var control := find_control(experience, node_name)
|
||||
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
|
||||
failures.append("unavailable parsed-touch target %s" % node_name)
|
||||
return
|
||||
var position := root.get_final_transform() * control.get_global_rect().get_center()
|
||||
var down := InputEventScreenTouch.new()
|
||||
down.index = 0
|
||||
down.position = position
|
||||
down.pressed = true
|
||||
Input.parse_input_event(down)
|
||||
await process_frame
|
||||
var up := InputEventScreenTouch.new()
|
||||
up.index = 0
|
||||
up.position = position
|
||||
up.pressed = false
|
||||
Input.parse_input_event(up)
|
||||
touch_count += 1
|
||||
await settle(6)
|
||||
|
||||
|
||||
func capture(path_id: String, name: String) -> void:
|
||||
if capture_root.is_empty():
|
||||
return
|
||||
var output_dir := capture_root.path_join(path_id)
|
||||
DirAccess.make_dir_recursive_absolute(output_dir)
|
||||
await settle(2)
|
||||
await RenderingServer.frame_post_draw
|
||||
var error := root.get_texture().get_image().save_png(output_dir.path_join(name + ".png"))
|
||||
expect(error == OK, "capture %s/%s writes" % [path_id, name])
|
||||
|
||||
|
||||
func label_text(node_name: String) -> String:
|
||||
var control := find_control(experience, node_name)
|
||||
return control.text if control is Label else ""
|
||||
|
||||
|
||||
func verify_no_forms_and_bounds() -> void:
|
||||
expect(count_named_fragment(experience, "Card") == 0, "runtime adds no cards")
|
||||
expect(count_named_fragment(experience, "Panel") == 0, "runtime adds no panels")
|
||||
expect(count_named_fragment(experience, "Modal") == 0, "runtime adds no modals")
|
||||
verify_control_bounds(experience, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE)))
|
||||
|
||||
|
||||
func verify_control_bounds(node: Node, bounds: Rect2) -> void:
|
||||
if node is BaseButton and node.is_visible_in_tree():
|
||||
var rect: Rect2 = node.get_global_rect()
|
||||
if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5:
|
||||
failures.append("control %s outside viewport: %s" % [node.name, rect])
|
||||
if rect.size.x < 44.0 or rect.size.y < 44.0:
|
||||
failures.append("control %s below 44px: %s" % [node.name, rect.size])
|
||||
for child in node.get_children():
|
||||
verify_control_bounds(child, bounds)
|
||||
|
||||
|
||||
func count_named_fragment(node: Node, fragment: String) -> int:
|
||||
var result := 1 if fragment in String(node.name) else 0
|
||||
for child in node.get_children():
|
||||
result += count_named_fragment(child, fragment)
|
||||
return result
|
||||
|
||||
|
||||
func find_control(node: Node, node_name: String) -> Control:
|
||||
if node.name == node_name and node is Control and not node.is_queued_for_deletion():
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := find_control(child, node_name)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func settle(frames: int) -> void:
|
||||
for _frame in frames:
|
||||
await process_frame
|
||||
|
||||
|
||||
func expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append("P9.6 risk/recovery runtime: %s" % message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d5w4p96runtime
|
||||
Reference in New Issue
Block a user