feat(prototype): rebuild final landscape review

This commit is contained in:
2026-08-11 15:05:55 +08:00
parent bf22b07954
commit 802aad49ab
8 changed files with 7433 additions and 6080 deletions
+37 -28
View File
@@ -2,22 +2,21 @@
An original landscape 2D Japanese hand-drawn guild-expedition strategy game.
This repository is currently in **complete pre-production design**. Formal game
This repository is currently in **pre-development design review**. Formal game
implementation, bulk final-asset production, packaging, and publishing are
forbidden until the owner approves the complete product, content, presentation,
balance, and interactive-design package.
## Current Product Boundary
- 30 named recruitable characters
- 12 base professions and 24 advanced professions
- 100 enemy combat identities across 8 chapters
- 320 equipment and item entries
- 25-35 hour first campaign, 60-80 hour build completion, roughly 100 hour
collection and high-difficulty completion target
- Generated run-bound Recruits with no fixed story protagonists
- 12 base, 24 advanced, and 6 hidden Professions; 36 mechanical Traits
- 320 equipment identities and 60 run-wide Artifacts
- 100 ordinary/elite enemies and 16 bosses across 8 Regions
- 15-25 hour first clear and 60+ hour mastery breadth
- Landscape 16:9, premium cross-platform target, English and Simplified Chinese
- 2D Japanese hand-drawn presentation using layered rigs, equipment layers,
authored key poses, and unique portraits/illustrations
- 2D Japanese hand-drawn presentation using modular Recruit assemblies,
profession/equipment overlays, authored key poses, and readable silhouettes
The game may learn structural lessons from authorized private reference
research, but its setting, characters, enemies, names, art, audio, UI,
@@ -25,22 +24,24 @@ progression, formulas, and expression must be original.
## Review The Design
The current owner-facing HTML in [`prototype/site/`](prototype/site/) is a
**coverage audit**, not a completed UI prototype. Owner review on 2026-08-10
proved that generic specification templates were not understandable as game
screens. Its page browser now distinguishes one dedicated page, 40 shared
family mockups, and 41 pages whose UI is still missing.
The owner-facing HTML in [`prototype/site/`](prototype/site/) is the current
responsive landscape design prototype. It replaces the superseded coverage
audit and reads the current authorities directly when the Gallery bundle is
built.
1. **Flow specification:** inspect the required 17-step title-to-restoration
order; do not treat it as a validated clickable experience.
2. **Eight chapters:** inspect route, battle, and restored-world direction for
every chapter.
3. **Page coverage:** inspect all 82 requirements and identify which screens
still need a dedicated composition.
4. **Content:** search all 30 characters, 36 professions, 100 enemies, 320
items, 48 achievements, and 20 collections.
5. **Production and readiness:** inspect the art/audio scope and the gates that
remain open before runtime development.
1. **First play:** inspect 23 states from Contract setup through two-of-four
run creation, two separate Market hires, party ordering, risk, automatic
battle, diagnosis, reward, equipment adaptation, casualty, and recovery.
2. **Screen atlas:** inspect exactly 50 current page/state IDs. Every ID has a
page-specific composition plus loading/empty, error/confirmation, and
interruption/return variants; `SYS-005` exposes its five staged states.
3. **Regions:** inspect all eight Region directions, encounter pressure, enemy
identities, and two bosses per Region.
4. **Catalogs:** search 42 Professions, 36 Traits, 320 equipment items, 60
Artifacts, 100 ordinary/elite enemies, and 16 bosses.
5. **Review gates:** open the six critical Owner review groups and keep human
comprehension, visual, fun, listening, and device approval separate from
deterministic HTML checks.
Run it locally from the repository root:
@@ -59,12 +60,20 @@ relative CSS, JavaScript, JSON, or image requests:
python3 tools/build_gallery_bundle.py
```
The build command regenerates `prototype/site/data/catalog.json`, verifies the
current exact counts and unique IDs, and writes
`prototype/gallery/aetherbound-guild-prototype.html`. Browser verification is
in `prototype/site/verify-r6.js`; it requires an already-provisioned Playwright
Node module and Chrome, starts no external service, and writes screenshots to
`prototype/gallery/evidence/r6/`.
## Evidence Boundary
The HTML is an interactive **design prototype**, not a playable build. Its four
AI-generated boards are concept direction only. The UI chrome and readable text
are authored in HTML; no concept board is a final background, sprite, item icon,
animation, integrated asset, runtime screenshot, or human-approved result.
The HTML is an interactive **design prototype**, not a playable build. Existing
image boards are labeled concept direction only. The current one-line party,
transactions, readable UI, stable IDs, and text are independently authored in
HTML; no board is a final background, sprite, item icon, animation, integrated
asset, runtime screenshot, or human-approved result.
See [`docs/README.md`](docs/README.md) for the complete authority map and
[`docs/reviews/PRE_DEVELOPMENT_READINESS_AUDIT.md`](docs/reviews/PRE_DEVELOPMENT_READINESS_AUDIT.md)
+188 -112
View File
@@ -1,141 +1,217 @@
# Aetherbound Guild: Interactive Design Prototype
> Scope: reviewable pre-production HTML. It visualizes the approved product,
> content, screen, art, and audio specifications without implementing game
> simulation, save behavior, packaging, or final runtime assets.
> Scope: owner-reviewable, pre-development landscape HTML.
>
> Authority: current product contract, `SCREEN_AND_STATE_MAP`,
> `UI_AND_VISUAL_SYSTEM`, content catalogs, and art/audio production catalogs.
>
> Boundary: design prototype only. It contains no battle simulation, save
> implementation, engine runtime, final media, packaging, store, or release
> work.
## 1. Review Decisions
## 1. Owner Decisions
The prototype must let the owner decide whether:
The prototype gives the Owner one visible artifact for deciding whether:
1. the landscape 2D hand-drawn direction fits the intended game;
2. the player can recognize Guild, company, route, battle, diagnosis, reward,
restoration, records, and settings without a card-wall interface;
3. the 82-screen requirement inventory and eight chapters are present, while
unfinished UI is labeled as unfinished rather than presented as complete;
4. formation, telegraph, Directive, Cache, Anchor, Strain, profession, and item
concepts are sufficiently visible to proceed to a dynamic vertical slice;
5. any page family or chapter direction should be redesigned before runtime
work begins.
1. the opening Company choice is understandable and does not recommend a build;
2. Market valuation, formation order, equipment deltas, risk, and automatic
battle causality read as one complete game loop;
3. death, equipment return, Replacement Credit, recovery, dismissal, and run
abandonment remain distinct;
4. the 50 current player-facing page/state IDs look like game screens rather
than a requirements-card audit;
5. the eight Region directions and complete authored catalogs support the
promised breadth; and
6. any owner-visible boundary requires redesign before formal development.
Owner approval of the HTML is design approval only. It does not approve engine
implementation, paid bulk art/audio, packaging, publishing, or release.
HTML approval remains design approval only. Comprehension, visual quality, fun,
listening, device ergonomics, packaging, store, and release stay separate human
gates.
## 2. Information Architecture
## 2. Gallery Information Architecture
The gallery uses a persistent left navigation on desktop and a compact drawer
on narrow screens. The right side presents one review subject at a time with
ample margin and no tiled wall of tiny screenshots.
The gallery exposes one primary subject at a time:
```text
Overview
|- Product thesis and complete loop
|- Quantitative scope
`- Production/evidence boundary
Game Screens
|- System / Guild / Workshop / Records
|- World / Expedition / Battle / Results / Settings
`- 82 individually selectable screen specifications
Chapters
|- CH-01..CH-08
`- route, battlefield, and restoration states
Content
|- 30 Characters
|- 36 Professions
|- 100 Enemies
|- 320 Items
`- 48 Achievements / 20 Collections
Production
|- Art and animation
|- Music, ambience, voice and SFX
|- CH-01 dynamic slice
`- Development-readiness gates
First play (23 states)
Screen atlas (50 IDs plus required state variants)
Regions (RG-01..RG-08)
Catalogs (Profession / Trait / Equipment / Artifact / Enemy / Boss)
Review gates (six critical groups and evidence boundary)
```
## 3. Game Screen Presentation
Desktop and tablet use a persistent left rail. Phone layouts use an off-canvas
drawer. The game canvas remains a stable `16:9` composition and the viewer
provides 75-200% zoom without altering the source state.
Every screen entry shows:
## 3. Current Screen Inventory
- one landscape `16:9` game-stage mockup at a stable aspect ratio;
- page ID/name and the three-second focal order;
- the main action, required variants, and safe exit;
- status chips declaring `design spec`, `concept art`, and open evidence gates;
- previous/next page actions plus direct selection from the left list;
- a full-size viewing mode without cropping the game canvas.
The prototype contains exactly the 50 IDs in `SCREEN_AND_STATE_MAP` Section 5:
Owner review on 2026-08-10 rejected the shared-template result as
incomprehensible. The coverage audit found `1` dedicated page, `40` shared
family mockups, and `41` pages without an individual UI composition. A shared
template is not a completed page. Until this gap is closed, the HTML may expose
requirements and coverage but must not call itself a complete UI prototype.
| Family | IDs | Count |
|---|---|---:|
| System and run entry | `SYS-001..SYS-008` | 8 |
| Guild and forecast | `HUB-001..HUB-002` | 2 |
| Market | `SHP-001..SHP-003` | 3 |
| Roster and dismissal | `REC-001..REC-003` | 3 |
| Equipment and Artifacts | `EQP-001..EQP-002`, `ART-001` | 3 |
| Party and risk | `PTY-001..PTY-002`, `RSK-001..RSK-002` | 4 |
| Automatic battle | `BAT-001..BAT-006` | 6 |
| Result and recovery | `OUT-001..OUT-006` | 6 |
| Progression and postgame | `PRO-001..PRO-003`, `PST-001..PST-003` | 6 |
| Settings and data | `SET-001..SET-009` | 9 |
| **Total** | | **50** |
## 4. Compressed Click Path
Every ID has a canonical composition and inspectable loading/empty,
error/confirmation, and interruption/return overlays sourced from its authority
row. `SYS-005` remains one page ID while also exposing Contract, four-offer
choice, creation review, pending creation, and receipt/handoff states.
The clickable experience uses these states:
## 4. Critical Review Groups
### 4.1 Opening Two Of Four
`SYS-005` shows four equal-size stable generated Recruit offers. Each offer
names its stable ID, base Profession, Trait benefit/cost read, and no-Coin
status. Select and Inspect are separate. The Company begins with no selection;
Review Company enables only at `2/2`; creation review keeps the unchosen IDs
visible as `Not retained`; pending input is fixed-size; the receipt separates
`Company 2` from `Line capacity 4`.
### 4.2 Market Hire
`SHP-001/002` expose three Recruit offers, four equipment offers, and three
battle offers through separate tabs. The first Market shows affordable base
offers without a recommendation treatment. Two distinct hire reviews and
receipts advance `Company 2 -> 3 -> 4` while Line capacity remains `4`.
### 4.3 Formation And Equipment
`EQP-001/002` lead with the behavior-changing before/after comparison.
`PTY-001/002` stage one horizontal cord from protected rear at left to exposed
front at right; position `0` is the rightmost occupied marker. Reorder works by
select-then-destination semantics; drag is never required.
### 4.4 Horizontal Automatic Battle
`BAT-001..006` show the allied rear-left/front-right order, enemy side, action
gap, urgent forecast, objective, pause, `1x/2x/4x`, inspect, event log, captions,
retreat review, and static resume review. No control inserts an attack or skill
into the deterministic timeline.
### 4.5 Result, Reward, And Recovery
`OUT-001` leads with the first meaningful divergence on an expected/actual
timeline. `OUT-002` keeps the generated Recruit readable while naming permanent
loss, equipment return, party gap, and Credit. `OUT-003` presents equal reward
regions. `OUT-004` presents complete recovery transactions. Run end and
voluntary abandonment remain separate in `OUT-005/006`.
### 4.6 Catalog And Settings
`PRO-003` and the Gallery Catalogs expose stable IDs, names, mechanics,
tradeoffs, counterplay, and exact counts. `SET-001..009` use segmented controls,
switches, menus, remap rows, and destructive review. English and Simplified
Chinese, 100/115/130% text state, high contrast, reduced motion, captions, and
low-power state are interactive.
## 5. Complete First-Play Chain
The 23 review states cover the real first-play handoffs:
```text
Title SYS-004
-> Guild GLD-001
-> Formation GLD-005
-> World WLD-001
-> Route EXP-002
-> Encounter Preview EXP-004
-> Battle BAT-002
-> Directive BAT-005 / target BAT-006 / review BAT-007
-> Victory RES-001 / Diagnosis RES-002
-> Reward EXP-008 / Cache EXP-009
-> Anchor EXP-010
-> Restoration RES-005
-> Guild transformed GLD-001
SYS-005 Contract
-> four stable offers 0/2 -> 1/2 -> 2/2
-> creation review -> pending -> receipt
-> SHP-001 Company 2/4
-> SHP-002 hire 1 -> SHP-001 Company 3/4
-> SHP-002 hire 2 -> SHP-001 Company 4/4
-> PTY-001 reorder
-> RSK-001 compare -> RSK-002 commit
-> BAT-001 opening -> BAT-002 live -> BAT-003 inspect
-> OUT-001 diagnosis -> OUT-003 reward
-> EQP-002 adaptation
-> OUT-002 casualty -> OUT-004 recovery
```
This currently proves only the required node order. It does not yet prove
navigation comprehension or information hierarchy because several core nodes
still lack their own page design. It also does not simulate combat formulas,
persistence, pacing, audio, touch feel, or fun.
The chain is a design interaction, not a gameplay simulation. It makes the
intended decision and transaction boundaries visible without inventing prices,
combat formulas, save behavior, or random results.
## 5. Visual Assets In This Prototype
## 6. Content And Region Scope
Four paid image calls are the fixed task ceiling and have been used:
`tools/build_gallery_bundle.py` parses the current Markdown tables and fails on
any missing or duplicate identity. The generated payload contains:
- CH-01 battle concept;
- CH-01..04 environment grid;
- CH-05..08 environment grid;
- character/profession/enemy/item element grid.
| Catalog | Exact total |
|---|---:|
| Professions | 42 (`12` base, `24` advanced, `6` hidden) |
| Traits | 36 |
| Equipment | 320 |
| Artifacts | 60 |
| Ordinary/elite enemies | 100 |
| Bosses | 16 |
| Regions | 8 |
All outputs are `concept` evidence. The gallery may crop/slice them for review,
but cannot call them final backgrounds, sprites, icons, integrated assets, or
runtime screenshots. UI text and controls are authored in HTML so they remain
legible and do not depend on generated text.
Region pages expose `RG-01..RG-08`, current pressure, encounter grammar,
ordinary/elite assignments, and two bosses each. Existing image boards remain
labeled visual direction and never override current Region names or mechanics.
## 6. Responsive Contract
## 7. Responsive And Interaction Contract
- Desktop: 280 px navigation, centered content, game canvas max 1440 px.
- Tablet: 230 px navigation, canvas remains fully visible.
- Phone portrait: navigation becomes a drawer; the canvas fits width and opens
in a full-screen viewer for close inspection.
- Phone landscape: content uses the full safe width and hides nonessential
gallery chrome while the viewer is active.
- No design image uses `cover` where cropping would hide the intended screen.
- Long specifications wrap below the canvas rather than overlap it.
- Desktop: `1440x1000` evidence with persistent navigation and full game stage.
- Tablet: `1024x768` evidence with persistent navigation and a fully visible
battle composition.
- Phone portrait: `390x844` evidence; navigation is a drawer and the complete
four-offer canvas fits the content width.
- Phone landscape: `844x390` evidence; gallery chrome compresses and the entire
battle canvas fits the first viewport.
- Page switching, group filters, previous/next, first-play states, catalog
search/filter, Region tabs, EN/zh_CN, settings states, viewer open/close, and
viewer zoom are keyboard/pointer/touch-compatible HTML controls.
- No action depends on hover, precision drag, audio, color, rapid input, or an
uncapturable animation state.
## 7. Prototype Acceptance
## 8. Build And Evidence
- [x] All 82 screen IDs appear exactly once in the screen selector.
- [x] Coverage is reported truthfully as 1 dedicated / 40 family / 41 missing.
- [ ] All 82 requirements have page-specific, player-readable UI compositions.
- [ ] All 17 core-flow nodes have dedicated UI and pass owner comprehension.
- [x] All eight chapters and 24 chapter screen families are represented.
- [x] Content indexes show 30/36/100/320/48/20 exact counts.
- [x] Core click path reaches restoration without a dead control.
- [x] Desktop, tablet, phone portrait, and phone landscape do not overlap or
crop functional content.
- [x] Generated concepts remain labeled honestly and carry task provenance.
- [x] Local links, image paths, JSON, browser verification, Gallery publication,
and Gallery health/share checks pass.
- [x] Owner comprehension failed on the generic screen presentation; visual,
fun, audio, device, and release gates remain explicitly open.
```sh
python3 tools/build_gallery_bundle.py
```
This regenerates `prototype/site/data/catalog.json`, validates exact counts,
embeds CSS, JavaScript, JSON, favicon, and concept images, and writes:
```text
prototype/gallery/aetherbound-guild-prototype.html
```
Browser verification is implemented in `prototype/site/verify-r6.js`. With an
already-provisioned Playwright Node module and Chrome:
```sh
NODE_PATH=<existing-node_modules> node prototype/site/verify-r6.js
```
It checks all 50 IDs, critical compositions, first-play interactions, catalog
search, Region images, locale/settings state, viewer zoom, desktop/tablet/phone
layout, browser errors, and offline Gallery loading. Evidence is written under:
```text
prototype/gallery/evidence/r6/
```
## 9. Acceptance State
- [x] Exactly 50 current IDs with generated-Recruit and current catalog scope;
no superseded screen-count or authored-cast claim.
- [x] Six critical review groups use player-readable landscape compositions.
- [x] Every remaining page has a family-appropriate, page-specific composition
and explicit global state variants.
- [x] The 23-state first-play chain reaches recovery without a dead control.
- [x] All eight current Regions and all current catalog counts are searchable or
directly inspectable.
- [x] Desktop, tablet, phone portrait, and phone landscape browser checks pass.
- [x] The self-contained Gallery bundle opens offline with no external request.
- [x] Existing concept boards remain labeled and no paid/final media was added.
- [ ] Owner comprehension, visual acceptance, fun, listening, and device gates.
- [ ] Formal development, runtime media, packaging, store, and release approval.
+714 -538
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35 -28
View File
@@ -1,64 +1,71 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#171c22" />
<title>Aetherbound Guild 完整游戏设计</title>
<meta name="theme-color" content="#182127" />
<title>Aetherbound Guild - Final Landscape Prototype</title>
<link rel="icon" type="image/webp" href="favicon.webp" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<a class="skip-link" href="#content">跳到内容</a>
<a class="skip-link" href="#content">Skip to prototype</a>
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="设计导航">
<aside class="sidebar" id="sidebar" aria-label="Prototype navigation">
<div class="brand-block">
<button class="icon-button mobile-only" id="close-nav" type="button" aria-label="关闭导航">×</button>
<button class="icon-button mobile-only" id="close-nav" type="button" aria-label="Close navigation">&times;</button>
<span class="brand-mark" aria-hidden="true">AG</span>
<div>
<strong>Aetherbound Guild</strong>
<span>裂脉远征团 · 开发前设计</span>
<span data-copy="brandSubtitle">Final design prototype</span>
</div>
</div>
<nav class="primary-nav" id="primary-nav">
<button class="nav-item is-active" data-view="overview" type="button"><span>01</span> 总览</button>
<button class="nav-item" data-view="screens" type="button"><span>02</span> 页面规格与缺口 <b>82</b></button>
<button class="nav-item" data-view="flow" type="button"><span>03</span> 流程规格链</button>
<button class="nav-item" data-view="chapters" type="button"><span>04</span> 八章画面 <b>24</b></button>
<button class="nav-item" data-view="characters" type="button"><span>05</span> 角色 <b>30</b></button>
<button class="nav-item" data-view="professions" type="button"><span>06</span> 职业 <b>36</b></button>
<button class="nav-item" data-view="enemies" type="button"><span>07</span> 敌人 <b>100</b></button>
<button class="nav-item" data-view="items" type="button"><span>08</span> 装备 <b>320</b></button>
<button class="nav-item" data-view="records" type="button"><span>09</span> 成就与收藏</button>
<button class="nav-item" data-view="production" type="button"><span>10</span> 美术与音频</button>
<button class="nav-item" data-view="readiness" type="button"><span>11</span> 开发准备度</button>
<button class="nav-item is-active" data-view="firstplay" type="button"><span>01</span><b data-copy="navFirst">First play</b><em>23</em></button>
<button class="nav-item" data-view="screens" type="button"><span>02</span><b data-copy="navScreens">Screen atlas</b><em>50</em></button>
<button class="nav-item" data-view="regions" type="button"><span>03</span><b data-copy="navRegions">Regions</b><em>8</em></button>
<button class="nav-item" data-view="catalogs" type="button"><span>04</span><b data-copy="navCatalogs">Catalogs</b><em>574</em></button>
<button class="nav-item" data-view="review" type="button"><span>05</span><b data-copy="navReview">Review gates</b><em>6</em></button>
</nav>
<div class="sidebar-footer">
<span class="status-dot"></span>
<div><strong>DESIGN SPEC</strong><span>运行时开发仍冻结</span></div>
<span class="status-dot" aria-hidden="true"></span>
<div><strong>DESIGN PROTOTYPE</strong><span data-copy="runtimeFrozen">Runtime development frozen</span></div>
</div>
</aside>
<div class="page-shell">
<header class="topbar">
<button class="icon-button mobile-only" id="open-nav" type="button" aria-label="打开导航">☰</button>
<div class="breadcrumbs"><span>完整策划</span><b>/</b><strong id="view-title">总览</strong></div>
<button class="icon-button mobile-only" id="open-nav" type="button" aria-label="Open navigation">&#9776;</button>
<div class="breadcrumbs"><span>Aetherbound Guild</span><b>/</b><strong id="view-title">First play</strong></div>
<div class="top-actions">
<span class="evidence-badge">概念图 · 非运行时</span>
<button class="icon-button" id="fullscreen-current" type="button" title="全屏查看当前游戏画面" aria-label="全屏查看当前游戏画面">⛶</button>
<div class="locale-control" aria-label="Language">
<button class="is-active" type="button" data-locale="en">EN</button>
<button type="button" data-locale="zh_CN">简中</button>
</div>
<span class="evidence-badge" data-copy="conceptBadge">Concept direction</span>
<button class="icon-button" id="fullscreen-current" type="button" title="Open stage viewer" aria-label="Open stage viewer">&#x26F6;</button>
</div>
</header>
<main id="content" tabindex="-1">
<div class="loading-state" id="loading">正在载入完整设计索引…</div>
<div class="loading-state" id="loading">Loading current design authority...</div>
</main>
</div>
</div>
<div class="nav-scrim" id="nav-scrim" hidden></div>
<dialog class="stage-dialog" id="stage-dialog">
<button class="dialog-close" id="close-dialog" type="button" aria-label="关闭全屏画面">×</button>
<div id="dialog-stage"></div>
<dialog class="stage-dialog" id="stage-dialog" aria-label="Game stage viewer">
<div class="viewer-toolbar">
<span id="viewer-label">Game stage</span>
<div>
<button class="icon-button" id="zoom-out" type="button" aria-label="Zoom out" title="Zoom out">&minus;</button>
<button class="viewer-zoom" id="zoom-reset" type="button">100%</button>
<button class="icon-button" id="zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button class="icon-button" id="close-dialog" type="button" aria-label="Close viewer" title="Close">&times;</button>
</div>
</div>
<div class="viewer-viewport"><div id="dialog-stage"></div></div>
</dialog>
<div class="sr-only" id="live-region" aria-live="polite"></div>
<script src="app.js" defer></script>
</body>
</html>
+894 -309
View File
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const root = path.resolve(__dirname, "../..");
const baseURL = process.env.ABG_PROTOTYPE_URL || "http://127.0.0.1:8766";
const outputDir = process.env.ABG_PROTOTYPE_EVIDENCE || path.join(root, "prototype/gallery/evidence/r6");
const browserPath = process.env.ABG_BROWSER_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const criticalScreens = {
"SYS-005": ".opening-composition",
"SHP-001": ".market-composition",
"SHP-002": ".market-review",
"EQP-002": ".equipment-composition",
"PTY-001": ".party-composition",
"BAT-002": ".battle-composition",
"OUT-001": ".result-composition",
"OUT-003": ".reward-composition",
"OUT-004": ".recovery-composition",
"PRO-003": ".records-composition",
"SET-002": ".settings-composition",
};
async function noHorizontalOverflow(page, label) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
assert.ok(dimensions.scrollWidth <= dimensions.clientWidth + 1, `${label}: ${JSON.stringify(dimensions)}`);
}
async function stageFitsViewport(page, label, requireVertical = false) {
const metrics = await page.locator(".game-stage").first().evaluate((stage) => {
const box = stage.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height, viewportWidth: innerWidth, viewportHeight: innerHeight };
});
assert.ok(metrics.left >= -0.5 && metrics.right <= metrics.viewportWidth + 0.5, `${label} horizontal stage: ${JSON.stringify(metrics)}`);
if (requireVertical) assert.ok(metrics.top >= -0.5 && metrics.bottom <= metrics.viewportHeight + 0.5, `${label} vertical stage: ${JSON.stringify(metrics)}`);
assert.ok(Math.abs(metrics.width / metrics.height - 16 / 9) < 0.02, `${label} aspect ratio: ${JSON.stringify(metrics)}`);
}
async function openView(page, view) {
const desktopButton = page.locator(`[data-view="${view}"]`);
if (!(await desktopButton.isVisible())) {
await page.locator("#open-nav").click();
}
await desktopButton.click();
await page.locator("main h1").waitFor({ state: "visible" });
}
async function waitForMobileNavClosed(page) {
await page.waitForFunction(() => {
const sidebar = document.querySelector("#sidebar");
return sidebar && !sidebar.classList.contains("is-open") && sidebar.getBoundingClientRect().right <= 0.5;
});
}
async function selectScreen(page, id) {
await openView(page, "screens");
const button = page.locator(`.screen-index [data-screen-id="${id}"]`);
await button.evaluate((element) => element.click());
await page.locator(`[data-stage-id="${id}"]`).waitFor({ state: "visible" });
}
async function screenshot(page, name) {
const destination = path.join(outputDir, name);
await page.screenshot({ path: destination, fullPage: true });
return destination;
}
async function desktopAudit(browser, evidence) {
const errors = [];
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); });
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
await page.goto(baseURL, { waitUntil: "networkidle" });
assert.equal(await page.locator("#loading").count(), 0, "loading state clears");
assert.equal(await page.locator(".nav-item").count(), 5, "gallery view count");
assert.equal(await page.locator(".flow-progress button").count(), 23, "complete first-play state count");
assert.equal(await page.locator(".recruit-offer").count(), 4, "four provisional offers");
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 0, "opening build is not preselected");
await noHorizontalOverflow(page, "desktop opening");
await stageFitsViewport(page, "desktop opening");
evidence.push(await screenshot(page, "desktop-opening.png"));
await page.locator('[data-offer-id="OFF-01"]').click();
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 1, "first provisional selection");
await page.locator('[data-offer-id="OFF-03"]').click();
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 2, "second provisional selection");
assert.ok(await page.locator(".opening-footer .stage-primary").isEnabled(), "Review Company enabled at 2/2");
await page.locator('[data-flow-index="7"]').click();
assert.equal(await page.locator('[data-stage-id="SHP-001"] .company-meter strong').first().textContent(), "2", "first Market Company count");
evidence.push(await screenshot(page, "desktop-market.png"));
await page.locator('[data-flow-index="12"]').click();
assert.equal(await page.locator('[data-stage-id="PTY-001"] .party-actor').count(), 4, "party line actor count");
assert.equal(await page.locator('[data-stage-id="PTY-001"] .party-actor').last().locator("> b").textContent(), "0", "rightmost position is 0");
evidence.push(await screenshot(page, "desktop-formation.png"));
await page.locator('[data-flow-index="16"]').click();
assert.ok(await page.locator('[data-stage-id="BAT-002"] .forecast-arrow').isVisible(), "battle forecast visible");
assert.equal(await page.locator('[data-stage-id="BAT-002"] .allied-line .party-actor').count(), 4, "battle party line count");
evidence.push(await screenshot(page, "desktop-battle.png"));
await page.locator('[data-flow-index="18"]').click();
assert.ok(await page.locator('[data-stage-id="OUT-001"] .is-divergence').isVisible(), "result divergence visible");
evidence.push(await screenshot(page, "desktop-result.png"));
await page.locator('[data-flow-index="22"]').click();
assert.equal(await page.locator('[data-stage-id="OUT-004"] .recovery-path').count(), 3, "recovery paths visible");
evidence.push(await screenshot(page, "desktop-recovery.png"));
await openView(page, "screens");
assert.equal(await page.locator(".screen-index button").count(), 50, "screen inventory count");
const ids = await page.locator(".screen-index button span").allTextContents();
assert.equal(new Set(ids).size, 50, "screen IDs unique");
for (const id of ids) {
const button = page.locator(`.screen-index [data-screen-id="${id}"]`);
await button.evaluate((element) => element.click());
assert.equal(await page.locator(".game-stage").first().getAttribute("data-stage-id"), id, `${id} owns its composition`);
assert.equal(await page.locator(".prototype-missing").count(), 0, `${id} has no missing placeholder`);
}
for (const [id, selector] of Object.entries(criticalScreens)) {
await selectScreen(page, id);
assert.ok(await page.locator(`[data-stage-id="${id}"] ${selector}`).isVisible(), `${id} critical composition`);
}
await page.locator('[data-screen-variant="error"]').click();
assert.ok(await page.locator(".state-variant-overlay.error").isVisible(), "screen error variant inspectable");
await openView(page, "regions");
assert.equal(await page.locator(".region-tabs button").count(), 8, "region count");
for (let index = 0; index < 8; index += 1) {
await page.locator(`[data-region-index="${index}"]`).click();
await page.waitForFunction(() => {
const image = document.querySelector(".region-stage img");
return image && image.complete && image.naturalWidth > 0;
});
}
evidence.push(await screenshot(page, "desktop-regions.png"));
await openView(page, "catalogs");
assert.equal(await page.locator(".catalog-tabs button").count(), 6, "searchable catalog family count");
await page.locator('[data-catalog-type="professions"]').click();
await page.locator('[data-catalog-filter="base"]').click();
assert.equal(await page.locator(".catalog-entry").count(), 12, "base profession count");
await page.locator('[data-catalog-type="equipment"]').click();
await page.locator("[data-catalog-search]").fill("Stepback Soles");
assert.equal(await page.locator(".catalog-entry").count(), 1, "equipment search result");
assert.equal(await page.locator(".catalog-entry strong").textContent(), "Stepback Soles", "equipment search identity");
evidence.push(await screenshot(page, "desktop-catalog.png"));
await selectScreen(page, "SET-002");
await page.locator('[data-text-scale="130"]').click();
assert.equal(await page.locator("body").getAttribute("data-text-scale"), "130", "130% state applies");
await page.locator('[data-setting-key="contrast"]').click();
assert.match(await page.locator("body").getAttribute("class"), /high-contrast/, "high-contrast state applies");
await page.locator('[data-locale="zh_CN"]').first().click();
assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN", "Simplified Chinese state applies");
assert.match(await page.locator("main h1").textContent(), /显示与文字/, "selected screen localizes");
evidence.push(await screenshot(page, "desktop-settings-zh-130.png"));
await page.locator('[data-text-scale="100"]').click();
await page.locator("#fullscreen-current").click();
assert.ok(await page.locator("#stage-dialog").evaluate((dialog) => dialog.open), "stage viewer opens");
await page.locator("#zoom-in").click();
assert.equal(await page.locator("#zoom-reset").textContent(), "125%", "stage viewer zooms");
await page.locator("#close-dialog").click();
assert.deepEqual(errors, [], `desktop browser errors: ${errors.join(" | ")}`);
await page.close();
}
async function responsiveAudit(browser, evidence) {
const fixtures = [
{ name: "tablet-battle.png", viewport: { width: 1024, height: 768 }, view: "screens", screen: "BAT-002", vertical: false },
{ name: "phone-portrait-opening.png", viewport: { width: 390, height: 844 }, view: "firstplay", vertical: true },
{ name: "phone-landscape-battle.png", viewport: { width: 844, height: 390 }, view: "firstplay", flow: 16, vertical: true },
];
for (const fixture of fixtures) {
const page = await browser.newPage({ viewport: fixture.viewport, deviceScaleFactor: 1 });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${baseURL}#${fixture.view}`, { waitUntil: "networkidle" });
if (fixture.screen) await selectScreen(page, fixture.screen);
if (Number.isInteger(fixture.flow)) await page.locator(`[data-flow-index="${fixture.flow}"]`).click();
if (fixture.viewport.width < 880) {
await page.locator("#open-nav").click();
assert.match(await page.locator("#sidebar").getAttribute("class"), /is-open/, `${fixture.name} drawer opens`);
await page.locator("#close-nav").click();
assert.doesNotMatch(await page.locator("#sidebar").getAttribute("class"), /is-open/, `${fixture.name} drawer closes`);
await waitForMobileNavClosed(page);
}
await noHorizontalOverflow(page, fixture.name);
await stageFitsViewport(page, fixture.name, fixture.vertical);
evidence.push(await screenshot(page, fixture.name));
assert.deepEqual(errors, [], `${fixture.name} browser errors: ${errors.join(" | ")}`);
await page.close();
}
}
async function bundleAudit(browser) {
const bundle = path.join(root, "prototype/gallery/aetherbound-guild-prototype.html");
assert.ok(fs.statSync(bundle).size > 1_000_000, "Gallery bundle contains embedded media");
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const external = [];
page.on("request", (request) => {
if (!request.url().startsWith("file:") && !request.url().startsWith("data:")) external.push(request.url());
});
await page.goto(`file://${bundle}`, { waitUntil: "load" });
assert.equal(await page.locator(".recruit-offer").count(), 4, "Gallery bundle initializes offline");
assert.deepEqual(external, [], `Gallery external requests: ${external.join(" | ")}`);
await page.close();
}
async function main() {
fs.mkdirSync(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: browserPath });
const evidence = [];
try {
await desktopAudit(browser, evidence);
await responsiveAudit(browser, evidence);
await bundleAudit(browser);
} finally {
await browser.close();
}
console.log("ABG_R6_BROWSER_OK screens=50 flow_states=23 regions=8 catalogs=42,36,320,60,100,16 responsive=desktop,tablet,phone_portrait,phone_landscape bundle=offline");
console.log(`evidence=${evidence.join(",")}`);
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});
+193 -15
View File
@@ -1,18 +1,200 @@
#!/usr/bin/env python3
"""Build a self-contained HTML artifact for sandboxed Gallery viewers."""
"""Build current prototype data and a self-contained Gallery artifact."""
from __future__ import annotations
import base64
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "prototype" / "site"
DATA_PATH = SITE / "data" / "catalog.json"
DEFAULT_OUTPUT = ROOT / "prototype" / "gallery" / "aetherbound-guild-prototype.html"
def clean(value: str) -> str:
value = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", value)
value = value.replace("**", "").replace("`", "")
return re.sub(r"\s+", " ", value).strip()
def table_rows(path: Path, id_pattern: str) -> list[list[str]]:
matcher = re.compile(id_pattern)
rows: list[list[str]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.startswith("|"):
continue
cells = [clean(cell) for cell in line.strip().strip("|").split("|")]
if cells and matcher.match(cells[0]):
rows.append(cells)
return rows
def split_identity(value: str, pattern: str) -> tuple[str, str]:
match = re.match(pattern, value)
if not match:
raise ValueError(f"invalid catalog identity: {value}")
return match.group(1), match.group(2)
def require_unique(entries: list[dict[str, str]], expected: int, label: str) -> None:
ids = [entry["id"] for entry in entries]
if len(entries) != expected or len(set(ids)) != expected:
raise ValueError(f"{label}: expected {expected} unique rows, received {len(entries)} / {len(set(ids))}")
def build_catalog() -> dict[str, object]:
screen_rows = table_rows(
ROOT / "docs/presentation/SCREEN_AND_STATE_MAP.md",
r"^(?:SYS|HUB|SHP|REC|EQP|ART|PTY|RSK|BAT|OUT|PRO|PST|SET)-\d{3}\b",
)
screens = []
for row in screen_rows:
match = re.match(r"^([A-Z]+-\d{3}) (.+) / ([A-Z]+)$", row[0])
if not match or len(row) != 6:
raise ValueError(f"invalid screen row: {row[0]}")
screens.append(
{
"id": match.group(1),
"name": match.group(2),
"input": match.group(3),
"purpose": row[1],
"action": row[2],
"emptyLoading": row[3],
"errorConfirmation": row[4],
"interruptionReturn": row[5],
}
)
profession_rows = table_rows(ROOT / "docs/content/PROFESSION_CATALOG.md", r"^PF-[BAH]\d{2}$")
professions = []
for row in profession_rows:
if len(row) < 6:
continue
family = {"B": "base", "A": "advanced", "H": "hidden"}[row[0][3]]
identity = row[1].split(";", 1)
cadence_index = 4 if len(row) == 7 else 3
strategy_index = 5 if len(row) == 7 else 4
professions.append(
{
"id": row[0],
"name": identity[0].strip(),
"family": family,
"role": identity[1].strip() if len(identity) > 1 else family,
"rules": row[2],
"cadence": row[cadence_index],
"strategy": row[strategy_index],
"production": row[-1],
}
)
traits = [
{
"id": row[0],
"name": row[1],
"benefit": row[2],
"cost": row[3],
"evidence": row[4],
"prohibited": row[5],
}
for row in table_rows(ROOT / "docs/content/TRAIT_CATALOG.md", r"^TR-\d{3}$")
]
equipment = []
for row in table_rows(ROOT / "docs/content/EQUIPMENT_CATALOG.md", r"^EQ-\d{3}\b"):
item_id, name = split_identity(row[0], r"^(EQ-\d{3}) (.+)$")
equipment.append(
{
"id": item_id,
"name": name,
"fit": row[1],
"class": row[2],
"effect": row[3],
"tradeoff": row[4],
"production": row[5],
}
)
artifacts = []
for row in table_rows(ROOT / "docs/content/ARTIFACT_CATALOG.md", r"^AR-\d{3}\b"):
artifact_id, name = split_identity(row[0], r"^(AR-\d{3}) (.+)$")
artifacts.append(
{
"id": artifact_id,
"name": name,
"rule": row[1],
"tradeoff": row[2],
"production": row[3],
}
)
enemies = []
bosses = []
for row in table_rows(ROOT / "docs/content/ENEMY_CATALOG.md", r"^(?:EN-R\d-\d{2}|BO-R\d-\d{2})$"):
enemy_class, name = (part.strip() for part in row[1].split(";", 1))
entry = {
"id": row[0],
"name": name,
"class": enemy_class,
"rule": row[2],
"responses": row[3],
"production": row[4],
}
(bosses if row[0].startswith("BO-") else enemies).append(entry)
regions = []
for row in table_rows(ROOT / "docs/content/REGION_CATALOG.md", r"^RG-\d{2}$"):
identity = row[1].split(";", 1)
regions.append(
{
"id": row[0],
"name": identity[0].strip(),
"framing": identity[1].strip() if len(identity) > 1 else "",
"pressure": row[2],
"progression": row[3],
"assignment": row[4],
"production": row[5],
}
)
expected = {
"screens": 50,
"professions": 42,
"traits": 36,
"equipment": 320,
"artifacts": 60,
"enemies": 100,
"bosses": 16,
"regions": 8,
}
entries = {
"screens": screens,
"professions": professions,
"traits": traits,
"equipment": equipment,
"artifacts": artifacts,
"enemies": enemies,
"bosses": bosses,
"regions": regions,
}
for label, count in expected.items():
require_unique(entries[label], count, label)
payload: dict[str, object] = {
"version": 2,
"status": "design-prototype-current-authority",
"authorityRevision": "recruit-line-design-r3",
"counts": expected,
**entries,
}
DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
DATA_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return payload
def data_uri(path: Path) -> str:
mime = "image/webp" if path.suffix == ".webp" else "application/octet-stream"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
@@ -20,10 +202,10 @@ def data_uri(path: Path) -> str:
def main() -> None:
catalog = build_catalog()
index = (SITE / "index.html").read_text(encoding="utf-8")
styles = (SITE / "styles.css").read_text(encoding="utf-8")
app = (SITE / "app.js").read_text(encoding="utf-8")
catalog = json.loads((SITE / "data" / "catalog.json").read_text(encoding="utf-8"))
asset_paths = sorted((SITE / "assets" / "web").glob("*.webp"))
asset_paths.append(SITE / "favicon.webp")
@@ -33,28 +215,24 @@ def main() -> None:
index = index.replace(relative, uri)
app = app.replace(relative, uri)
catalog_json = json.dumps(catalog, ensure_ascii=False, separators=(",", ":"))
catalog_json = catalog_json.replace("<", "\\u003c")
catalog_json = json.dumps(catalog, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c")
app = app.replace("</script", "<\\/script")
styles = styles.replace("</style", "<\\/style")
index = index.replace(
'<link rel="stylesheet" href="styles.css" />',
f"<style>\n{styles}\n</style>",
)
index = index.replace('<link rel="stylesheet" href="styles.css" />', f"<style>\n{styles}\n</style>")
index = index.replace(
'<script src="app.js" defer></script>',
f'<script id="prototype-data" type="application/json">{catalog_json}</script>\n'
f"<script>\n{app}\n</script>",
f'<script id="prototype-data" type="application/json">{catalog_json}</script>\n<script>\n{app}\n</script>',
)
if 'href="styles.css"' in index or 'src="app.js"' in index:
raise RuntimeError("external stylesheet or script remains in bundle")
if "assets/web/" in index:
raise RuntimeError("external prototype asset remains in bundle")
forbidden = ('href="styles.css"', 'src="app.js"', "assets/web/")
remaining = [token for token in forbidden if token in index]
if remaining:
raise RuntimeError(f"external prototype dependency remains: {remaining}")
DEFAULT_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
DEFAULT_OUTPUT.write_text(index, encoding="utf-8")
counts = catalog["counts"]
print(f"ABG_PROTOTYPE_DATA_OK counts={json.dumps(counts, separators=(',', ':'))}")
print(f"ABG_GALLERY_BUNDLE_OK bytes={DEFAULT_OUTPUT.stat().st_size} output={DEFAULT_OUTPUT}")