feat: rebuild first-play opening as game flow

This commit is contained in:
2026-08-12 20:50:57 +08:00
parent 00b019543e
commit a534af9f7c
30 changed files with 1839 additions and 1031 deletions
+13 -3
View File
@@ -1,9 +1,19 @@
# Current Checkpoint
Active Goal: `docs/goals/PHASE9_0_BOOT_TITLE_GOAL.md`
Active contract: `docs/runtime/PHASE9_0_BOOT_TITLE_CONTRACT.md`
Active Goal: `docs/goals/PHASE9_0R1_GAME_OPENING_REDESIGN_GOAL.md`
Active contract: `docs/runtime/PHASE9_0R1_GAME_OPENING_CONTRACT.md`
Active ledger: `docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md`
Baseline: `b3968b0f41f841efb0f699d8fb04a9bcb958182a`
Redesign baseline: `00b019543e571c89c20adaa97b4e805ecbd3e6f4`
Stage: `P9_0R1_GAME_OPENING_ACTIVE`
Current P9.0R1 replaces the Owner-rejected form-like Web opening with one
scene-based flow: Title -> Guild Yard front/rear choice -> deterministic first
battle -> explicit first-expedition victory. Contract/difficulty, paid Market,
equipment/order configuration, persistence, chapter expansion, authoritative
combat, device packaging, TestFlight, stores and release remain frozen. The
older Phase 8/P9 history below is retained as provenance, not current scope.
Historical portfolio baseline: `b3968b0f41f841efb0f699d8fb04a9bcb958182a`
Accepted P8.1 source: `0b4a64fe549af0ff965e654aa4f5fb7f2ff054b4`
P8.2 candidate source: `86a8a1adec39de40b4adfee6b01033407e3e3421`
Accepted P8.2 closure: `d308f8bc14b043ed133ce2d99cbce461aecec81f`
@@ -0,0 +1,125 @@
# Phase 9.0R1 Game Opening Redesign Goal
## Owner Decision
The Owner rejected remote Web source `f0aa84738041cebf4968d91c6bf170668804e203`.
The opening looked like a form rather than a game, exposed development labels,
split recruitment into two unexplained systems, hid revision/reset affordances,
and stopped before any playable encounter.
This Goal replaces that first-play structure. It does not preserve the rejected
Contract/First Market presentation merely because its domain tests were green.
## Decision Enabled
Decide whether a fresh player can enter a recognizable Japanese hand-painted
fantasy game, build one understandable two-person Company, see those characters
act in a short battle, understand the result, and restart without coaching.
## Player Chain
```text
title -> guild_yard_team_build -> departure -> tutorial_battle -> first_victory
^ | |
+-- replace+---------------- home / restart -------+
```
## Owned Scope
- Keep the verified cold boot and Title technical behavior but remove internal
milestone language from all player-facing screens.
- Replace Contract, difficulty, four-provisional-plus-two-paid recruitment,
equipment purchase/equip, order optimization and receipt screens with one
scene-based opening Company choice.
- Present two explicit positions:
- front: Hearthguard or Wayrunner;
- rear: Farstring or Kindhand.
- Show the selected characters standing in the yard before departure. Choosing
the other candidate replaces the current occupant; it never dead-ends.
- Run one 30-60 second authored tutorial battle with visible advance, front-line
contact, enemy attack, front role response, rear ranged attack or healing,
enemy defeat and victory.
- Always expose Home and a confirmed Restart Experience action after Title.
- End with `First expedition complete`, the chosen Company and one explicit next
goal. No chapter-complete claim is legal.
## Experience Contract
- Fantasy: assemble a small guild squad and watch the formation prove itself on
a dangerous route.
- First focal read: characters acting in the world.
- Second focal read: the current choice or threat.
- Third focal read: compact health/role feedback and one primary action.
- First action by 20 seconds: place or replace one character.
- First complete Company by 60 seconds without coaching.
- First encounter begins by 90 seconds and resolves by 150 seconds.
- The player can explain front versus rear contribution after the result.
## Presentation Contract
- Japanese 2D hand-painted fantasy environment and full-body character art are
the visual authority already accepted in Phase 6.
- World/actors occupy at least 70% of the landscape play field. HUD and commands
overlay the world; they do not replace it with a dashboard.
- UI materials use dark indigo lacquer, warm parchment, brushed gold edge,
teal magical glass and crimson danger. Rectangular controls need layered
border, highlight, shadow and pressed states; generic flat gray/gold buttons
are prohibited.
- Cards may frame one candidate choice, but a card wall or page-sized panel is
prohibited. Each candidate owns one clear role, consequence and Join/Replace
action.
- No `P9`, `CTR`, `OFF`, `MKT`, `EQ`, receipt, vertical-slice, locked-future or
implementation terminology appears in player copy.
## Dynamic Slice
Representative screen: tutorial battle at `844x390` landscape.
Focal order:
1. selected front and rear characters versus the Riftblade on one ground line;
2. the current attack/guard/heal/projectile consequence;
3. compact health and one pause/restart surface.
Motion-state table:
| Event | Actor | Anticipation | Impact | Recovery/final | Reduced motion |
|---|---|---|---|---|---|
| departure | Company | 250 ms lean | advance across ground | contact stance | snap to contact |
| enemy attack | Riftblade | telegraph | front HP loss | idle | telegraph + final |
| guard/dodge | front | brace/lean | reduced damage | ready | final pose |
| arrow | Farstring | draw | projectile + enemy HP loss | ready | projectile final |
| heal | Kindhand | lantern raise | front HP restoration | ready | value + final |
| victory | Company/enemy | final attack | enemy falls | victory banner | final poses |
## Acceptance
1. Fresh launch and in-game Restart both reach the same Title/yard state.
2. Home and Restart remain visible and touchable on yard, battle and victory.
3. Exactly one front and one rear choice are required; every candidate can be
selected, replaced and selected again with no stale state.
4. No pre-battle Contract, difficulty, paid hire, equipment or order step exists.
5. Parsed ScreenTouch drives the complete chain with fallback/direct zero.
6. Normal rendering covers Title, empty yard, one selection, ready Company,
battle pressure and victory at `1280x720`, `844x390` and `390x844` in
zh_CN/English and 130% text without clipping.
7. A 15-30 second normal-render recording proves visible actor motion and state
change; screenshots alone cannot pass the dynamic slice.
8. Strict script/parse/resource/import/ObjectDB/RID diagnostics are clean and
no owned process remains.
## Frozen
- Contract selection, difficulty, paid Market recruitment, equipment and manual
formation optimization.
- Persistent save/migration, chapters, expanded battles, progression, economy,
final audio, bulk assets, device packaging, stores and release.
- Changes to complete content counts or later-game authority.
## Legal Terminal
`Aetherbound Guild P9.0R1 game opening candidate ready`
or
`Aetherbound Guild P9.0R1 redesign required: <earliest failed player boundary>`
@@ -12,3 +12,4 @@ separate.
| 2026-08-12 | Owner Web preview request | `P9_0_WEB_PREVIEW_AUTHORIZED` | Owner requested a dedicated remote H5 page for phone review. Only a non-threaded Godot Web export, browser verification and private Artifact Gallery deployment of fixed P9.0 content are authorized. Product behavior, later pages, native packages, stores and release remain frozen. |
| 2026-08-12 | Web source `f0aa847`; Gallery item `PRji-yhJo0nQ`; `p9-0-web-f0aa847-attempt01` | `P9_0_REMOTE_WEB_READY` | Private signed item redirects to the full-screen runtime entry with an artifact-scoped HttpOnly cookie. Remote HTML/JS/WASM/PCK response types and executable CSP pass; unauthorized entry remains gated. Chrome phone-viewport verification reaches the Chinese Title and Contract by real canvas input, portrait shows the rotate overlay, and browser warnings/errors are zero. Final evidence inventory verifies 34/34 with SHA-256 `cb3111f86012d313c85d1eebed6574b8b5936531bf30821421f89f1442aeb945`; the signed token is not recorded in Git. Owner phone comprehension, fun, listening, physical device, packaging, store and release remain open. |
| 2026-08-12 | Owner remote H5 playtest of `f0aa847` | `P9_0_OWNER_REJECTED_REDESIGN_REQUIRED` | Earliest product failure is the first post-Title action: the runtime reads as a configuration form rather than a game. There is no visible in-game restart/home path; Contract, First Market, provisional two-of-four selection, two later forced hires, equipment/order revision and the stopping point are not understandable without coaching. Engineering labels and receipt language are exposed as if they were chapter content. The current H5 is retained only as a problem reference. P9.0/P9.1 are not Owner-accepted; P9.2, content expansion, packaging and release remain frozen. A successor must compare structural onboarding options and reach a short encounter before advanced preparation systems. |
| 2026-08-12 | Owner authorization for opening redesign | `P9_0R1_GAME_OPENING_ACTIVE` | Owner authorized a full first-play and UI redesign. The active slice is Title -> scene-based two-position Company choice -> deterministic short battle -> explicit first-expedition victory, with always-visible Home/Restart. Contract, difficulty, paid Market hire, equipment/order configuration, persistence, chapters, later combat, packaging and release remain frozen. Existing accepted Japanese hand-painted environment, character, Riftblade and VFX assets are reused before any new generation. |
@@ -0,0 +1,72 @@
# Phase 9.0R1 Game Opening Runtime Contract
## State
The successor runtime owns only:
```text
BOOT -> TITLE -> YARD -> BATTLE -> VICTORY
```
`home()` returns to Title and clears transient team/battle state. `restart()`
performs the same reset and returns to Title after confirmation. Browser reload
also starts from Boot because this slice has no persistence.
## Team
Front choices:
- Hearthguard: takes the enemy contact and visibly guards.
- Wayrunner: takes the enemy contact and visibly evades/counterattacks.
Rear choices:
- Farstring: attacks safely with a visible projectile.
- Kindhand: restores the injured front character.
One member per position. Selecting another candidate replaces that position.
No currency, offer ID, purchase, dismissal or capacity system is involved.
## Battle
The battle is a deterministic tutorial presentation, not the later authoritative
combat system. It must bind visible values and motion to one fixed sequence:
1. Company and one Riftblade advance to contact.
2. Riftblade damages the selected front.
3. Front guard/dodge response reduces or answers pressure.
4. Farstring damages the Riftblade, or Kindhand heals the front.
5. The Company performs a final combined action.
6. Riftblade reaches zero and falls; victory appears once.
The two rear choices must visibly change the sequence and final explanation.
Home/Restart cannot grant a victory or retain transient HP.
## Reused Runtime Art
No new provider generation is owned by this slice. Runtime copies are
byte-identical to accepted Phase 6 authority:
- `runtime/assets/backgrounds/first_expedition_field.png` <-
`phase6_battle_scale_04/outputs/01_empty_battlefield.png`;
- Riftblade idle/attack/hit/death <- accepted Phase 6 motion frames;
- `protect_absorb`, `protect_gain` and `damage_heavy` <- accepted Phase 6 VFX
`@2x` files.
The existing four full-body Recruit anchors remain the Company identity source.
Godot `.import` sidecars are import metadata, not new art sources.
## Player Copy
Allowed player labels describe place, role, action, threat and result. Internal
IDs and delivery milestones are forbidden. The victory state says the first
expedition is complete and that the guild can next learn hiring and equipment;
it does not claim a chapter is complete.
## Evidence Markers
- `ABG_P9_0R1_DOMAIN_OK`
- `ABG_P9_0R1_TOUCH_OK`
- `ABG_P9_0R1_LAYOUT_OK`
- `ABG_P9_0R1_RUNTIME_CAPTURE_OK`
- `ABG_P9_0R1_DYNAMIC_SLICE_OK`
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d0t6j8kk3l478"
path="res://.godot/imported/first_expedition_field.png-512185aef842432d998b716fbdbda726.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/backgrounds/first_expedition_field.png"
dest_files=["res://.godot/imported/first_expedition_field.png-512185aef842432d998b716fbdbda726.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dp0vd7ysqlxhq"
path="res://.godot/imported/attack.png-f368cb5dce3612ada5cc3dc0654ef93d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemies/riftblade/attack.png"
dest_files=["res://.godot/imported/attack.png-f368cb5dce3612ada5cc3dc0654ef93d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://3koj8sncknch"
path="res://.godot/imported/death.png-e68dfbb00e5ad6224a0d831754a35124.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemies/riftblade/death.png"
dest_files=["res://.godot/imported/death.png-e68dfbb00e5ad6224a0d831754a35124.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://m5i4rlks6f5b"
path="res://.godot/imported/hit.png-48d85e065b0cdc6acc16f8869b8964d4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemies/riftblade/hit.png"
dest_files=["res://.godot/imported/hit.png-48d85e065b0cdc6acc16f8869b8964d4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmvrgg6a8gtcd"
path="res://.godot/imported/idle.png-b4995d90d56d1cbedcb6819a1c5eddc9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemies/riftblade/idle.png"
dest_files=["res://.godot/imported/idle.png-b4995d90d56d1cbedcb6819a1c5eddc9.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://h3dvkx4rq4ok"
path="res://.godot/imported/damage_heavy.png-59e6de32464699ffe87188d4361ab7d2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/vfx/damage_heavy.png"
dest_files=["res://.godot/imported/damage_heavy.png-59e6de32464699ffe87188d4361ab7d2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://iwlvuwmmlice"
path="res://.godot/imported/protect_absorb.png-33f11958ef887c357bc8746b262471ac.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/vfx/protect_absorb.png"
dest_files=["res://.godot/imported/protect_absorb.png-33f11958ef887c357bc8746b262471ac.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btnelcc7vr06g"
path="res://.godot/imported/protect_gain.png-d3bfbe940288931ab6022856af268d59.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/vfx/protect_gain.png"
dest_files=["res://.godot/imported/protect_gain.png-d3bfbe940288931ab6022856af268d59.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+777 -1028
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const STATES := [
"title",
"yard_empty",
"yard_front",
"yard_ready",
"battle_pressure",
"victory",
]
var output_root := ""
var viewport_size := Vector2i(1280, 720)
var locale := "zh_CN"
var text_scale := 1.0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
output_root = OS.get_environment("ABG_P9_0R1_CAPTURE_ROOT")
if output_root.is_empty():
output_root = "/tmp/abg-p9-0r1-captures"
viewport_size = Vector2i(
read_int("ABG_P9_0R1_VIEWPORT_WIDTH", 1280),
read_int("ABG_P9_0R1_VIEWPORT_HEIGHT", 720)
)
locale = read_string("ABG_P9_0R1_LOCALE", "zh_CN")
text_scale = read_float("ABG_P9_0R1_TEXT_SCALE", 1.0)
DirAccess.make_dir_recursive_absolute(output_root)
var viewport := SubViewport.new()
viewport.size = viewport_size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var game := MainScene.instantiate()
game.auto_boot = false
viewport.add_child(game)
await settle()
game.locale = locale
game.text_scale = text_scale
game.reduced_motion = true
game.muted = true
game.boot_verified = true
for index in range(STATES.size()):
await capture_state(viewport, game, index + 1, STATES[index])
game.queue_free()
viewport.queue_free()
await drain_cleanup()
if failures.is_empty():
print("ABG_P9_0R1_RUNTIME_CAPTURE_OK frames=6 viewport=%sx%s locale=%s text_scale=%.1f renderer=normal input_claim=none root=%s" % [viewport_size.x, viewport_size.y, locale, text_scale, output_root])
call_deferred("finish", 0)
else:
for failure in failures:
push_error(failure)
call_deferred("finish", 1)
func finish(exit_code: int) -> void:
quit(exit_code)
func capture_state(viewport: SubViewport, game: Control, index: int, state_name: String) -> void:
game.clear_experience()
apply_state(game, state_name)
game.render()
await settle()
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var path := output_root.path_join("%02d-%s.png" % [index, state_name])
var error := image.save_png(path)
if error != OK:
failures.append("capture %s failed with %s" % [state_name, error])
func apply_state(game: Control, state_name: String) -> void:
match state_name:
"title":
game.page = game.AppPage.TITLE
"yard_empty":
game.page = game.AppPage.YARD
game.notice = game.tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.")
"yard_front":
game.page = game.AppPage.YARD
game.front_choice = "Hearthguard"
game.notice = game.tr_text("艾连进入前排。现在选择一名后排。", "Elian joined the front. Now choose a rear role.")
"yard_ready":
game.page = game.AppPage.YARD
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.notice = game.tr_text("妮娅进入后排。队伍已准备好。", "Nia joined the rear. The squad is ready.")
"battle_pressure":
game.page = game.AppPage.BATTLE
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.battle_playing = false
game.battle_phase = 2
game.front_hp = 82
game.enemy_hp = 100
"victory":
game.page = game.AppPage.VICTORY
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.front_hp = 82
game.enemy_hp = 0
func read_string(key: String, fallback: String) -> String:
var value := OS.get_environment(key)
return value if not value.is_empty() else fallback
func read_int(key: String, fallback: int) -> int:
var value := OS.get_environment(key)
return int(value) if not value.is_empty() else fallback
func read_float(key: String, fallback: float) -> float:
var value := OS.get_environment(key)
return float(value) if not value.is_empty() else fallback
func settle() -> void:
for frame in range(10):
await process_frame
func drain_cleanup() -> void:
for frame in range(120):
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://bdsp4a43800ei
+107
View File
@@ -0,0 +1,107 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var game := MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle()
expect(game.page == game.AppPage.BOOT, "fresh instance starts at Boot")
expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "fresh team is empty")
game.boot_paths_override.assign(["res://missing/p9_0r1_required_resource.tres"])
await game.run_boot_verification()
expect(game.page == game.AppPage.BOOT, "failed boot stays at Boot")
expect(game.boot_failed_path == "res://missing/p9_0r1_required_resource.tres", "failed boot retains path")
expect(find_control(game, "BootRetry") != null, "failed boot exposes Retry")
game.boot_paths_override.clear()
await game.run_boot_verification()
expect(game.boot_verified and game.page == game.AppPage.TITLE, "verified boot reaches Title")
expect(find_control(game, "StartAdventure") != null, "Title exposes one game entry")
game.start_adventure()
expect(game.page == game.AppPage.YARD, "entry reaches Guild Yard")
game.choose_candidate("Hearthguard")
game.choose_candidate("Wayrunner")
expect(game.front_choice == "Wayrunner", "front candidate replaces same position")
game.choose_candidate("Farstring")
game.choose_candidate("Kindhand")
expect(game.rear_choice == "Kindhand", "rear candidate replaces same position")
game.choose_candidate("Farstring")
expect(game.rear_choice == "Farstring", "rear candidate can be selected again")
game.depart()
expect(game.page == game.AppPage.BATTLE and game.battle_playing, "ready team begins battle")
for phase in range(6):
game.advance_battle_phase()
expect(game.page == game.AppPage.VICTORY, "fixed tutorial sequence reaches victory")
expect(game.enemy_hp == 0 and game.victory_events == 1, "victory commits once")
expect(game.guard_events == 1 and game.rear_events == 1, "front and rear roles both act")
game.advance_battle_phase()
expect(game.victory_events == 1, "victory cannot duplicate")
game.request_restart()
expect(game.restart_confirmation, "restart requires confirmation")
game.cancel_restart()
expect(not game.restart_confirmation and game.page == game.AppPage.VICTORY, "cancel preserves current state")
game.request_restart()
game.confirm_restart()
expect(game.page == game.AppPage.TITLE, "confirmed restart returns Title")
expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "restart clears team")
expect(game.front_hp == 100 and game.enemy_hp == 100 and game.victory_events == 0, "restart clears battle state")
game.start_adventure()
game.choose_candidate("Hearthguard")
game.choose_candidate("Kindhand")
game.depart()
for phase in range(6):
game.advance_battle_phase()
expect(game.page == game.AppPage.VICTORY, "healer route reaches victory")
expect(game.front_hp == 94, "healer visibly restores front after guarded hit")
expect(game.rear_events == 1, "healer contribution recorded")
game.go_home()
expect(game.page == game.AppPage.TITLE and game.front_choice.is_empty(), "Home clears transient experience")
game.queue_free()
await drain_cleanup()
if failures.is_empty():
print("ABG_P9_0R1_DOMAIN_OK routes=2 replace=front,rear restart=confirmed home=1 battle_phases=6 victory_once=1")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
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() -> void:
for frame in range(8):
await process_frame
func drain_cleanup() -> void:
for frame in range(40):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R1 domain: %s" % message)
+1
View File
@@ -0,0 +1 @@
uid://bixsojbce2foq
@@ -0,0 +1,53 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var game: Control
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
game = MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(12)
game.locale = "zh_CN"
game.text_scale = 1.0
game.reduced_motion = false
game.muted = true
game.boot_verified = true
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.depart()
var elapsed := 0.0
while game.page != game.AppPage.VICTORY and elapsed < 22.0:
await process_frame
elapsed += 1.0 / 30.0
if game.page != game.AppPage.VICTORY:
failures.append("dynamic slice did not reach victory")
if game.guard_events != 1 or game.rear_events != 1 or game.victory_events != 1:
failures.append("dynamic slice role events were not committed exactly once")
await create_timer(1.6).timeout
game.queue_free()
game = null
await settle(120)
if failures.is_empty():
print("ABG_P9_0R1_DYNAMIC_SLICE_OK duration=%.2f phases=6 advance=1 front_response=1 rear_action=1 enemy_defeat=1 victory=1 renderer=normal" % elapsed)
call_deferred("finish", 0)
else:
for failure in failures:
push_error(failure)
call_deferred("finish", 1)
func finish(exit_code: int) -> void:
quit(exit_code)
func settle(frames: int) -> void:
for frame in range(frames):
await process_frame
+128
View File
@@ -0,0 +1,128 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const LANDSCAPE_FIXTURES := [
{"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.0},
{"size": Vector2i(844, 390), "locale": "en", "scale": 1.0},
{"size": Vector2i(844, 390), "locale": "zh_CN", "scale": 1.3},
]
const PAGES := ["title", "yard", "battle", "victory"]
var failures: Array[String] = []
var fixture_count := 0
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
for fixture in LANDSCAPE_FIXTURES:
for page_name in PAGES:
await verify_landscape(fixture, page_name)
await verify_portrait()
if failures.is_empty():
print("ABG_P9_0R1_LAYOUT_OK fixtures=%d landscape=12 portrait=1 pages=4 locales=2 text_130=true min_touch=44" % fixture_count)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func verify_landscape(fixture: Dictionary, page_name: String) -> void:
var viewport := SubViewport.new()
viewport.size = fixture.size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var game := MainScene.instantiate()
game.auto_boot = false
viewport.add_child(game)
await settle()
game.locale = fixture.locale
game.text_scale = fixture.scale
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.boot_verified = true
match page_name:
"title": game.page = game.AppPage.TITLE
"yard": game.page = game.AppPage.YARD
"battle":
game.page = game.AppPage.BATTLE
game.battle_playing = false
game.battle_phase = 2
game.front_hp = 82
"victory":
game.page = game.AppPage.VICTORY
game.enemy_hp = 0
game.render()
await settle()
fixture_count += 1
var label := "%sx%s/%s/%s/%s" % [fixture.size.x, fixture.size.y, fixture.locale, str(fixture.scale), page_name]
verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(fixture.size)), label)
expect(find_control(game, "RotateNotice") == null, "%s unexpectedly shows portrait notice" % label)
if page_name != "title":
expect(find_control(game, "HomeButton") != null, "%s missing Home" % label)
expect(find_control(game, "RestartButton") != null, "%s missing Restart" % label)
game.queue_free()
viewport.queue_free()
await drain_cleanup()
func verify_portrait() -> void:
var viewport := SubViewport.new()
viewport.size = Vector2i(390, 844)
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var game := MainScene.instantiate()
game.auto_boot = false
viewport.add_child(game)
await settle()
game.page = game.AppPage.YARD
game.render()
await settle()
fixture_count += 1
expect(find_control(game, "RotateNotice") != null, "portrait missing rotate guidance")
verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(390, 844)), "390x844/portrait")
game.queue_free()
viewport.queue_free()
await drain_cleanup()
func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void:
if node is BaseButton and node.is_visible_in_tree():
var rect: Rect2 = node.get_global_rect()
expect(contains_rect(bounds, rect), "%s button %s outside viewport: %s" % [label, node.name, rect])
expect(rect.size.x >= 44.0 and rect.size.y >= 44.0, "%s button %s below 44pt: %s" % [label, node.name, rect.size])
for child in node.get_children():
verify_interactive_bounds(child, bounds, label)
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 contains_rect(outer: Rect2, inner: Rect2) -> bool:
return inner.position.x >= -0.5 and inner.position.y >= -0.5 and inner.end.x <= outer.end.x + 0.5 and inner.end.y <= outer.end.y + 0.5
func settle() -> void:
for frame in range(8):
await process_frame
func drain_cleanup() -> void:
for frame in range(30):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R1 layout: %s" % message)
+1
View File
@@ -0,0 +1 @@
uid://be0d7430yxb01
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var game: Control
var touch_count := 0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
game = MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle()
game.boot_verified = true
game.page = game.AppPage.TITLE
game.render()
await settle()
await tap("StartAdventure")
expect(game.page == game.AppPage.YARD, "Title touch reaches yard")
await tap("Candidate_Hearthguard")
await tap("Candidate_Wayrunner")
expect(game.front_choice == "Wayrunner", "touch replaces front")
await tap("Candidate_Farstring")
await tap("Candidate_Kindhand")
expect(game.rear_choice == "Kindhand", "touch replaces rear")
await tap("DepartButton")
expect(game.page == game.AppPage.BATTLE, "touch starts battle")
await tap("RestartButton")
expect(game.restart_confirmation, "restart touch opens confirmation")
await tap("RestartCancelButton")
expect(game.page == game.AppPage.BATTLE and not game.restart_confirmation, "cancel touch preserves battle")
await tap("HomeButton")
expect(game.page == game.AppPage.TITLE, "Home touch reaches Title")
expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "Home touch clears team")
await tap("StartAdventure")
await tap("Candidate_Hearthguard")
await tap("Candidate_Farstring")
await tap("DepartButton")
await tap("RestartButton")
await tap("RestartConfirmButton")
expect(game.page == game.AppPage.TITLE, "confirmed restart touch reaches Title")
expect(game.front_hp == 100 and game.enemy_hp == 100, "confirmed restart clears HP")
game.queue_free()
game = null
await drain_cleanup()
if failures.is_empty():
print("ABG_P9_0R1_TOUCH_OK touch=%d fallback=0 direct=0 replace=2 battle=1 home=1 restart=confirmed" % touch_count)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func tap(node_name: String) -> void:
var control := find_control(game, node_name)
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
failures.append("unavailable 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()
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() -> void:
for frame in range(8):
await process_frame
func drain_cleanup() -> void:
for frame in range(50):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R1 touch: %s" % message)
+1
View File
@@ -0,0 +1 @@
uid://b7i5n0vbinkxx