Files
aetherbound-guild/prototype/site/app.js
T

837 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
data: null,
view: "firstplay",
locale: "en",
screenId: "SYS-005",
screenGroup: "ALL",
screenVariant: "primary",
flowIndex: 1,
regionIndex: 0,
catalogType: "professions",
catalogFilter: "all",
catalogQuery: "",
openingSelected: [],
partySwapped: false,
battlePaused: false,
battleSpeed: 1,
settings: {
contrast: false,
reducedMotion: false,
captions: true,
lowPower: false,
textScale: "100",
},
};
const copy = {
en: {
brandSubtitle: "Final design prototype",
navFirst: "First play",
navScreens: "Screen atlas",
navRegions: "Regions",
navCatalogs: "Catalogs",
navReview: "Review gates",
runtimeFrozen: "Runtime development frozen",
conceptBadge: "Concept direction",
firstplay: "First play",
screens: "Screen atlas",
regions: "Regions",
catalogs: "Catalogs",
review: "Review gates",
prev: "Previous",
next: "Next decision",
back: "Back",
inspect: "Inspect",
compare: "Compare",
concept: "Concept direction - interface authored in HTML",
saved: "Saved",
company: "Company",
capacity: "Line capacity",
protected: "Protected rear",
exposed: "Exposed front",
selected: "Selected",
select: "Select",
reviewCompany: "Review Company",
createRun: "Create Run",
openMarket: "Open First Market",
noCoin: "No Coin cost",
notRetained: "Not retained",
loading: "Loading",
retry: "Retry same state",
returnSafe: "Return safely",
},
zh_CN: {
brandSubtitle: "最终设计原型",
navFirst: "首次游玩",
navScreens: "页面图谱",
navRegions: "八大区域",
navCatalogs: "完整图鉴",
navReview: "审核闸门",
runtimeFrozen: "运行时开发仍冻结",
conceptBadge: "概念方向",
firstplay: "首次游玩",
screens: "页面图谱",
regions: "八大区域",
catalogs: "完整图鉴",
review: "审核闸门",
prev: "上一步",
next: "下一决策",
back: "返回",
inspect: "查看",
compare: "比较",
concept: "概念方向 - 界面由 HTML 独立绘制",
saved: "已保存",
company: "队伍",
capacity: "出战容量",
protected: "受保护后排",
exposed: "暴露前排",
selected: "已选择",
select: "选择",
reviewCompany: "审核队伍",
createRun: "创建远征",
openMarket: "进入首个集市",
noCoin: "不消耗金币",
notRetained: "不会保留",
loading: "正在载入",
retry: "重试同一状态",
returnSafe: "安全返回",
},
};
const screenNamesZh = {
"SYS-001": "启动与校验", "SYS-002": "首次启动设置", "SYS-003": "标题", "SYS-004": "远征档案",
"SYS-005": "新远征设置", "SYS-006": "继续前审核", "SYS-007": "存档恢复", "SYS-008": "制作与数据说明",
"HUB-001": "公会桌面", "HUB-002": "威胁预报", "SHP-001": "集市", "SHP-002": "招募审核",
"SHP-003": "装备报价审核", "REC-001": "成员名册", "REC-002": "成员详情", "REC-003": "遣散审核",
"EQP-001": "装备工台", "EQP-002": "装备比较", "ART-001": "神器账册", "PTY-001": "队伍横列",
"PTY-002": "出战准备审核", "RSK-001": "风险看板", "RSK-002": "风险承诺", "BAT-001": "战斗开场",
"BAT-002": "自动战斗", "BAT-003": "角色与事件查看", "BAT-004": "暂停与速度", "BAT-005": "撤退审核",
"BAT-006": "战斗恢复审核", "OUT-001": "战果与诊断", "OUT-002": "死亡与损失", "OUT-003": "奖励选择",
"OUT-004": "恢复选择", "OUT-005": "远征结束总结", "OUT-006": "放弃远征审核", "PRO-001": "本轮成长",
"PRO-002": "永久成长", "PRO-003": "记录与图鉴", "PST-001": "终局挑战", "PST-002": "终局远征审核",
"PST-003": "终局结果", "SET-001": "设置", "SET-002": "显示与文字", "SET-003": "动效与闪烁",
"SET-004": "音频与字幕", "SET-005": "控制方式", "SET-006": "语言与地区", "SET-007": "玩法信息",
"SET-008": "存档与数据", "SET-009": "重置与删除",
};
const flowSteps = [
["SYS-005", "contract", "Contract and difficulty", "契约与难度"],
["SYS-005", "offers0", "Four stable offers - 0/2", "四个固定候选 - 0/2"],
["SYS-005", "offers1", "Read one tradeoff - 1/2", "读懂一个取舍 - 1/2"],
["SYS-005", "offers2", "Choose exactly two - 2/2", "恰好选择两人 - 2/2"],
["SYS-005", "review", "Creation review", "创建前审核"],
["SYS-005", "pending", "Atomic creation", "原子化创建"],
["SYS-005", "receipt", "Run receipt", "远征回执"],
["SHP-001", "company2", "First Market - Company 2/4", "首个集市 - 队伍 2/4"],
["SHP-002", "hire1", "First hire review", "第一次招募审核"],
["SHP-001", "company3", "Company 3/4", "队伍 3/4"],
["SHP-002", "hire2", "Second hire review", "第二次招募审核"],
["SHP-001", "company4", "Capacity filled - 4/4", "容量填满 - 4/4"],
["PTY-001", "reorder", "Order the horizontal line", "调整横向队列"],
["RSK-001", "compare", "Compare three risks", "比较三种风险"],
["RSK-002", "commit", "Commit party and exposure", "确认队伍与风险"],
["BAT-001", "opening", "Battle opening", "战斗开场"],
["BAT-002", "live", "Automatic resolution", "自动执行"],
["BAT-003", "inspect", "Inspect first cause", "查看首个因果"],
["OUT-001", "diagnosis", "Read the divergence", "阅读分歧点"],
["OUT-003", "reward", "Choose a fixed reward", "选择固定奖励"],
["EQP-002", "adapt", "Compare one adaptation", "比较一次调整"],
["OUT-002", "casualty", "Understand permanent loss", "理解永久损失"],
["OUT-004", "recovery", "Purchase recovery", "购买恢复方案"],
];
const screenGroups = {
ALL: "All 50", SYS: "System", HUB: "Guild", SHP: "Market", REC: "Roster",
EQP: "Equipment", ART: "Artifacts", PTY: "Party", RSK: "Risk", BAT: "Battle",
OUT: "Outcome", PRO: "Progress", PST: "Postgame", SET: "Settings",
};
const sceneImages = [
"assets/web/ch_ch01.webp", "assets/web/ch_ch02.webp", "assets/web/ch_ch03.webp", "assets/web/ch_ch04.webp",
"assets/web/ch_ch05.webp", "assets/web/ch_ch06.webp", "assets/web/ch_ch07.webp", "assets/web/ch_ch08.webp",
];
const generatedOffers = [
{ id: "OFF-01", name: "Elian Rook", profession: "Hearthguard", trait: "Narrow Guard", read: "Intercepts a neighbor / slower steps", mark: "HG" },
{ id: "OFF-02", name: "Nia Orrow", profession: "Farstring", trait: "Patient Start", read: "Rear pressure / delayed first basic", mark: "FS" },
{ id: "OFF-03", name: "Tomas Vale", profession: "Wayrunner", trait: "Open Stance", read: "Reorders safely / briefly Exposed", mark: "WR" },
{ id: "OFF-04", name: "Sable Kest", profession: "Kindhand", trait: "Shared Cup", read: "Restores a neighbor / weaker self-heal", mark: "KH" },
];
const stageDetails = {
"SYS-001": ["Verifying local rules", "Content revision 69f671", "Save lineage healthy"],
"SYS-002": ["Language: English", "Text: 115%", "Captions: On"],
"SYS-003": ["Continue safely", "New Run", "Records"],
"SYS-004": ["Slot 01 - Market, Round 3", "Slot 02 - No run", "Local save verified"],
"SYS-006": ["Last receipt: equipment return", "Next: Recovery", "Snapshot: 00:42.6"],
"SYS-007": ["Local lineage - healthy", "Backup - 12 min older", "Conflict - review required"],
"SYS-008": ["Original design and media", "Offline core play", "No third-party commercial assets"],
"HUB-001": ["Next pressure: projectile screen", "Company ready 4/4", "Last receipt: reward claimed"],
"HUB-002": ["Pane Nibbler - Guard pressure", "Gust Finch - rear projectile", "Confidence: exact"],
"SHP-003": ["Stepback Soles", "After first direct hit: step rear", "Tradeoff: next basic waits"],
"REC-001": ["Deployed 4", "Reserve 1", "Cumulative hires 6"],
"REC-002": ["Nia Orrow - Farstring", "Trait: Patient Start", "Survived 4 battles"],
"REC-003": ["Equipment returns", "No Replacement Credit", "Leaves 3 living recruits"],
"ART-001": ["Open Ledger - active", "Narrow Vanguard - conflict", "2 shared rules inspected"],
"PTY-002": ["Order frozen: 3 > 2 > 1 > 0", "Rear protected", "Known pressure covered"],
"RSK-002": ["Threat: projectile screen", "Reward: equipment choice", "Retreat: one Coin tier"],
"BAT-004": ["Paused at 00:08.4", "Speed 1x / 2x / 4x", "Event log available"],
"BAT-005": ["Retreat resolves after current event", "No fallen recruits", "Return to Market"],
"BAT-006": ["Verified time 00:08.4", "Next event: Pane Bite", "Resume static at 1x"],
"OUT-005": ["Contract clear", "Renown +4", "Discoveries 7"],
"OUT-006": ["Closes this run", "Coin and Credit cleared", "History retained"],
"PRO-001": ["Capacity milestone 4 > 5", "Hearthguard branch visible", "One choice pending"],
"PRO-002": ["Profession breadth 18/42", "Region routes 3/8", "No permanent power"],
"PST-001": ["Profession Mastery", "Catalog Completion", "Oath Contracts"],
"PST-002": ["Oath: Narrow Company", "Difficulty: Standard", "Ordinary New Run remains"],
"PST-003": ["Challenge cleared", "Rules that mattered: 2", "Record receipt applied"],
};
const settingRows = {
"SET-001": [["Display and text", "115%"], ["Motion and power", "Reduced"], ["Audio and captions", "Captions on"], ["Controls", "Touch + keyboard"]],
"SET-002": [["UI and text scale", "100 / 115 / 130"], ["Safe area", "Reference"], ["High contrast", "Off"], ["Color-independent cues", "On"]],
"SET-003": [["Reduced motion", "On"], ["Reduced flashes", "On"], ["Camera shake", "25%"], ["Low-power mode", "Off"]],
"SET-004": [["Master", "80%"], ["Music", "65%"], ["Effects", "80%"], ["Captions", "Critical + full log"]],
"SET-005": [["Inspect", "I / South"], ["Pause", "Space / Menu"], ["Reorder", "Select then destination"], ["Hold timing", "600 ms"]],
"SET-006": [["Language", "English / 简体中文"], ["Numbers", "Locale format"], ["Text preview", "130%"], ["Reload", "No run change"]],
"SET-007": [["Forecast detail", "Full"], ["Tutorial reminders", "On"], ["Battle log density", "Detailed"], ["Default speed", "1x"]],
"SET-008": [["Local save", "Healthy"], ["Backup", "12 min ago"], ["Offline", "Core play available"], ["Diagnostics", "Export ready"]],
"SET-009": [["Delete current run", "Review"], ["Reset settings", "Review"], ["Reset profile", "Locked"], ["Diagnostics", "Clear"]],
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const t = (key) => copy[state.locale][key] || copy.en[key] || key;
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function truncate(value, length = 150) {
const text = String(value || "");
return text.length > length ? `${text.slice(0, length - 1)}...` : text;
}
function screenName(screen) {
return state.locale === "zh_CN" ? screenNamesZh[screen.id] || screen.name : screen.name;
}
function getScreen(id) {
return state.data.screens.find((screen) => screen.id === id);
}
function flowLabel(step) {
return step[state.locale === "zh_CN" ? 3 : 2];
}
function iconButton(symbol, label, attrs = "") {
return `<button class="stage-icon" type="button" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}" ${attrs}>${symbol}</button>`;
}
function stageArt(index = 0) {
return `<img class="stage-art" src="${sceneImages[index % sceneImages.length]}" alt="" />`;
}
function conceptLabel() {
return `<span class="concept-label">${escapeHtml(t("concept"))}</span>`;
}
function stageHeader(screen, context = "Windglass Verge", resources = true) {
return `<div class="game-hud">
<div class="hud-left">${iconButton("‹", t("back"))}<span class="screen-stamp">${escapeHtml(screen.id)}</span><strong>${escapeHtml(context)}</strong></div>
<div class="hud-right">${resources ? `<span class="resource"><b>214</b> Coin</span><span class="resource"><b>0</b> Credit</span>` : ""}<span class="save-state">◆ ${escapeHtml(t("saved"))}</span></div>
</div>`;
}
function actionDock(primary, secondary = t("inspect"), attrs = "") {
return `<div class="action-dock"><button class="stage-secondary" type="button">${escapeHtml(secondary)}</button><button class="stage-primary" type="button" ${attrs}>${escapeHtml(primary)}</button></div>`;
}
function frame(screen, body, options = {}) {
const classes = ["game-stage", `family-${screen.id.split("-")[0].toLowerCase()}`, options.className || ""].filter(Boolean).join(" ");
const art = options.art === false ? "" : stageArt(options.region || 0);
const header = options.header === false ? "" : stageHeader(screen, options.context, options.resources !== false);
return `<section class="${classes}" data-stage-id="${escapeHtml(screen.id)}" data-variant="${escapeHtml(options.variant || "primary")}" aria-label="${escapeHtml(screenName(screen))}">
${art}<div class="stage-wash"></div>${header}<div class="stage-body">${body}</div>${conceptLabel()}
</section>`;
}
function renderOpening(screen, variant = "offers2") {
const oneSelected = state.openingSelected.length === 1 ? state.openingSelected : ["OFF-02"];
const twoSelected = state.openingSelected.length === 2 ? state.openingSelected : ["OFF-02", "OFF-04"];
const selectionByVariant = { offers0: state.openingSelected, offers1: oneSelected, offers2: twoSelected, review: twoSelected, pending: twoSelected, receipt: twoSelected };
const selected = selectionByVariant[variant] || state.openingSelected;
if (variant === "contract") {
const body = `<div class="contract-composition">
<div class="region-signal"><span>RG-01</span><h2>Windglass Verge</h2><p>Contract 01 - chart the suspended pane crossing.</p></div>
<div class="contract-sheet"><span class="stage-kicker">NEW CONTRACT</span><h3>Trade Route: First Crossing</h3>
<dl><div><dt>Known pressure</dt><dd>Push + projectile screen</dd></div><div><dt>Boss rule</dt><dd>Rotating damage-family panes</dd></div><div><dt>Reward breadth</dt><dd>Base professions + opening tools</dd></div><div><dt>Difficulty</dt><dd>Standard - full systems</dd></div></dl>
<button class="stage-primary" type="button" data-flow-next>Load Provisional Offers</button></div>
</div>`;
return frame(screen, body, { variant, resources: false });
}
if (variant === "review" || variant === "pending" || variant === "receipt") {
const chosen = generatedOffers.filter((offer) => selected.includes(offer.id));
const unchosen = generatedOffers.filter((offer) => !selected.includes(offer.id));
const stateBlock = variant === "pending"
? `<div class="operation-state"><span class="operation-spinner"></span><strong>Creating run</strong><small>Operation RUN-CREATE-014</small></div>`
: variant === "receipt"
? `<div class="receipt-state"><b>✓ RUN-CREATE-014</b><strong>${t("company")} 2 <i>/</i> ${t("capacity")} 4</strong><small>The two unchosen offers were not retained.</small></div>`
: `<div class="creation-ledger"><span>No Coin transaction</span><b>Slot 01 · Standard</b><small>Two unchosen stable IDs will not be retained.</small></div>`;
const body = `<div class="creation-review"><span class="stage-kicker">${variant === "receipt" ? "RUN RECEIPT" : "COMPANY REVIEW"}</span>
<div class="chosen-pair">${chosen.map((offer) => recruitMarker(offer, true)).join("")}</div>
<div class="unchosen-strip">${unchosen.map((offer) => `<span><b>${offer.id}</b> ${offer.name}<em>${t("notRetained")}</em></span>`).join("")}</div>
${stateBlock}
<div class="review-actions"><button class="stage-secondary" type="button" data-flow-prev>${variant === "review" ? "Revise Recruits" : t("back")}</button>${variant !== "pending" ? `<button class="stage-primary" type="button" data-flow-next>${variant === "receipt" ? t("openMarket") : t("createRun")}</button>` : ""}</div>
</div>`;
return frame(screen, body, { variant, resources: false });
}
const body = `<div class="opening-composition">
<div class="opening-title"><span class="stage-kicker">PROVISIONAL COMPANY</span><h2>Choose two generated recruits</h2><p>Offer set WIND-01-A remains stable until run creation.</p></div>
<div class="offer-grid">${generatedOffers.map((offer) => recruitOffer(offer, selected.includes(offer.id))).join("")}</div>
<div class="opening-footer"><div class="selection-count"><span>${selected.length === 2 ? t("selected") : "Choose two"}</span><strong>${selected.length}/2</strong></div><button class="stage-secondary" type="button">${t("compare")}</button><button class="stage-primary" type="button" data-flow-next ${selected.length === 2 ? "" : "disabled"}>${t("reviewCompany")}</button></div>
</div>`;
return frame(screen, body, { variant, resources: false });
}
function recruitMarker(offer, selected = false) {
return `<article class="recruit-marker ${selected ? "is-selected" : ""}"><span class="recruit-silhouette">${offer.mark}</span><div><small>${offer.id}</small><strong>${offer.name}</strong><em>${offer.profession}</em></div></article>`;
}
function recruitOffer(offer, selected) {
return `<article class="recruit-offer ${selected ? "is-selected" : ""}">
<div class="offer-figure"><span>${offer.mark}</span><small>${offer.id}</small></div>
<div class="offer-copy"><strong>${offer.name}</strong><span class="profession-seal">${offer.profession}</span><p><b>${offer.trait}</b> - ${offer.read}</p></div>
<div class="offer-actions"><span>${t("noCoin")}</span><button type="button" data-offer-id="${offer.id}" aria-pressed="${selected}">${selected ? `✓ ${t("selected")}` : t("select")}</button>${iconButton("i", `${t("inspect")} ${offer.name}`)}</div>
</article>`;
}
function companyCountForVariant(variant) {
if (variant === "company2") return 2;
if (variant === "company3") return 3;
return 4;
}
function renderMarket(screen, variant) {
const count = companyCountForVariant(variant);
const isRecruit = screen.id === "SHP-002";
const isItem = screen.id === "SHP-003";
if (isRecruit) {
const offer = variant === "hire2" ? generatedOffers[2] : generatedOffers[0];
const body = `<div class="market-review">
<div class="review-figure">${recruitMarker(offer, true)}<span class="trait-glyph">⬡</span></div>
<div class="review-facts"><span class="stage-kicker">RECRUIT REVIEW</span><h2>${offer.name}</h2><h3>${offer.profession} · ${offer.trait}</h3>
<div class="stat-radar"><span>VIT <b>A</b></span><span>PACE <b>C</b></span><span>REACH <b>${offer.profession === "Hearthguard" ? "1" : "2"}</b></span><span>GUARD <b>B</b></span></div>
<p>${offer.read}. Generated identity, exactly one Trait, no authored personal arc.</p></div>
<div class="party-impact"><span>PARTY IMPACT</span><strong>${t("company")} ${count} → ${Math.min(4, count + 1)}</strong><p>${offer.profession === "Hearthguard" ? "Adds a visible front intercept." : "Adds position movement and target change."}</p><small>Price 46 Coin · Operation HIRE-${variant === "hire2" ? "002" : "001"}</small></div>
${actionDock("Recruit - 46 Coin", t("compare"), "data-flow-next")}
</div>`;
return frame(screen, body, { variant });
}
if (isItem) {
const body = `<div class="item-review"><div class="item-object">⇣<small>EQ-001</small></div><div><span class="stage-kicker">ITEM OFFER</span><h2>Stepback Soles</h2><p>After the first direct hit, step one position rearward if legal.</p><div class="delta-row"><span>Target exposure</span><b>Front → Center</b></div><div class="delta-row danger"><span>Next basic</span><b>Wait +1 tick</b></div></div><aside><b>Eligible</b><span>Nia · Tomas · Sable</span><b>Conflict</b><span>Full rear makes trigger dormant</span></aside>${actionDock("Buy - 32 Coin", t("inspect"))}</div>`;
return frame(screen, body, { variant });
}
const offers = [generatedOffers[0], generatedOffers[2], generatedOffers[3]];
const body = `<div class="market-composition">
<div class="market-heading"><div><span class="stage-kicker">FIRST MARKET</span><h2>Build capability before choosing risk</h2></div><div class="company-meter"><span>${t("company")}</span><strong>${count}</strong><i>/</i><span>${t("capacity")}</span><strong>4</strong></div></div>
<div class="market-tabs"><button class="is-active">Recruits · 3</button><button>Equipment · 4</button><button>Risks · 3</button></div>
<div class="market-surface"><div class="market-list">${offers.map((offer, index) => `<button class="market-row ${index === 0 ? "is-focused" : ""}" type="button"><span class="row-avatar">${offer.mark}</span><div><small>${offer.id}</small><strong>${offer.name}</strong><em>${offer.profession} · ${offer.trait}</em></div><b>${index < 2 ? "46" : "58"}</b></button>`).join("")}</div>
<aside class="market-inspect"><span class="stage-kicker">AFFORDABLE BASE OFFER</span><h3>${offers[0].name}</h3><p>${offers[0].read}</p><div class="fit-read"><span>Front hold</span><b>Needed</b></div><div class="fit-read"><span>Trait cost</span><b>Visible</b></div><button class="stage-primary" type="button" data-flow-next>${count < 4 ? "Review Recruit" : "Open Party Line"}</button></aside></div>
<div class="market-footer"><span>Refresh 18 Coin · next 24</span><button class="stage-secondary">Lock 0/2</button></div>
</div>`;
return frame(screen, body, { variant });
}
function partyActors() {
const original = [
["3", "Nia", "FS", "Rear"], ["2", "Sable", "KH", "Protected"], ["1", "Tomas", "WR", "Center"], ["0", "Elian", "HG", "Exposed"],
];
const swapped = [original[0], original[2], original[1], original[3]];
return (state.partySwapped ? swapped : original).map(([position, name, mark, status], index) => `<button class="party-actor ${position === "0" ? "is-front" : ""}" type="button" data-party-index="${index}"><span class="actor-figure">${mark}</span><b>${position}</b><strong>${name}</strong><em>${status}</em></button>`).join("");
}
function renderPreparation(screen, variant) {
if (screen.id.startsWith("EQP")) {
const comparison = screen.id === "EQP-002";
const body = `<div class="equipment-composition ${comparison ? "is-comparison" : ""}">
<div class="equipment-list"><span class="stage-kicker">ELIGIBLE INVENTORY</span>${["Stepback Soles", "Frontpin Greaves", "Rescue Loop", "Open-Line Cord"].map((name, index) => `<button class="equipment-row ${index === 0 ? "is-focused" : ""}"><span>${["⇣", "⊓", "⌁", "—"][index]}</span><div><strong>${name}</strong><small>Behavior class B</small></div></button>`).join("")}</div>
<div class="equipment-wearer"><span class="large-figure">FS</span><strong>Nia Orrow</strong><small>Farstring · position 3</small><div class="equipped-slot">Current: Quiet Rearplate</div></div>
<div class="equipment-delta"><span class="stage-kicker">BEHAVIOR DELTA</span><h2>${comparison ? "Quiet Rearplate → Stepback Soles" : "Stepback Soles"}</h2><div class="delta-row"><span>After first hit</span><b>Step rear</b></div><div class="delta-row"><span>Target relation</span><b>Front → Center</b></div><div class="delta-row danger"><span>Next basic</span><b>Wait +1</b></div><p>At full rear the trigger is dormant. Apply remains reversible until readiness commit.</p><button class="stage-primary" type="button" ${variant === "adapt" ? "data-flow-next" : ""}>Apply candidate</button></div>
</div>`;
return frame(screen, body, { variant });
}
const ready = screen.id === "PTY-002";
const body = `<div class="party-composition ${ready ? "is-ready" : ""}">
<div class="party-title"><span class="stage-kicker">${ready ? "FROZEN PARTY SNAPSHOT" : "ORDERED PARTY LINE"}</span><h2>${t("protected")} <i>→</i> ${t("exposed")}</h2><p>Position 0 is always the rightmost front. The line is never mirrored by locale.</p></div>
<div class="relation-layer"><span class="relation protection">Sable protects 0</span><span class="relation reach">Nia reaches enemy rear</span></div>
<div class="party-cord"><span class="cord-label rear">${t("protected")}</span>${partyActors()}<span class="cord-label front">${t("exposed")}</span></div>
<aside class="party-read"><strong>${ready ? "Plan accepted" : "Changed relations"}</strong><span>Elian · first legal target</span><span>Nia · projectile screen risk</span><span>Reserve · 1 living recruit</span></aside>
<div class="party-tools">${iconButton("↶", "Undo")}${iconButton("↺", "Reset")}<button class="stage-secondary" type="button" data-party-swap>${ready ? "Inspect order" : "Swap positions 1 / 2"}</button><button class="stage-primary" type="button" data-flow-next>${ready ? "Accept preparation" : "Save Order"}</button></div>
</div>`;
return frame(screen, body, { variant });
}
function renderRisk(screen, variant) {
const review = screen.id === "RSK-002";
const risks = [
["Steady Crossing", "Pane Nibbler + Gust Finch", "Equipment", "Low"],
["Shattered Terrace", "Prism Ox + rear pressure", "Artifact", "Medium"],
["Bellward Detour", "Unknown third identity", "Profession", "High"],
];
const body = review ? `<div class="risk-review"><span class="stage-kicker">RISK COMMITMENT</span><h2>Steady Crossing</h2><div class="threat-band"><span>Known threat<b>Projectile screen</b></span><span>Entry cost<b>0 Coin</b></span><span>Reward class<b>Equipment</b></span><span>Retreat<b>One Coin tier</b></span></div><div class="mini-party-line">${partyActors()}</div><p>The accepted order is frozen with the risk receipt. Selection alone changed nothing.</p>${actionDock("Begin Battle", "Return to Risk Board", "data-flow-next")}</div>` : `<div class="risk-board"><div class="risk-heading"><span class="stage-kicker">THREE BOUNDED OFFERS</span><h2>Choose pressure, not a hidden result</h2></div><div class="risk-options">${risks.map((risk, index) => `<button class="risk-option ${index === 0 ? "is-selected" : ""}" type="button"><span>0${index + 1}</span><strong>${risk[0]}</strong><small>${risk[1]}</small><div><em>${risk[2]}</em><b>${risk[3]} exposure</b></div></button>`).join("")}</div><div class="risk-compare"><span>Coverage</span><b>Front hold ✓</b><b>Rear screen △</b><b>Recovery 214 Coin</b><button class="stage-primary" type="button" data-flow-next>Review Steady Crossing</button></div></div>`;
return frame(screen, body, { variant });
}
function battleLine() {
return `<div class="battle-line"><div class="allied-line"><span class="edge-label">${t("protected")}</span>${partyActors()}<span class="edge-label">${t("exposed")}</span></div><div class="action-gap"><span class="forecast-arrow">Pane Bite · 0.8s → Elian</span></div><div class="enemy-line"><span class="enemy-actor elite">OX<small>Prism screen</small></span><span class="enemy-actor">PN<small>Guard chip</small></span><span class="enemy-actor">GF<small>Rear shot</small></span></div></div>`;
}
function renderBattle(screen, variant) {
const id = screen.id;
if (["BAT-004", "BAT-005", "BAT-006"].includes(id)) {
const title = id === "BAT-004" ? "Observation paused" : id === "BAT-005" ? "Retreat consequence" : "Resume verified snapshot";
const primary = id === "BAT-004" ? "Resume battle" : id === "BAT-005" ? "Begin retreat" : "Resume at 1x";
const rows = stageDetails[id];
const body = `<div class="battle-static">${battleLine()}<div class="battle-sheet"><span class="stage-kicker">${id}</span><h2>${title}</h2>${rows.map((row) => `<div class="sheet-row">${row}</div>`).join("")}<div class="speed-control"><button class="is-active">1x</button><button>2x</button><button>4x</button></div>${actionDock(primary, id === "BAT-005" ? "Cancel" : "Event log")}</div></div>`;
return frame(screen, body, { variant });
}
const inspect = id === "BAT-003";
const opening = id === "BAT-001";
const body = `<div class="battle-composition ${state.battlePaused ? "is-paused" : ""}">
<div class="battle-objective"><span class="stage-kicker">${opening ? "BATTLE OPENING" : "AUTOMATIC BATTLE"}</span><h2>${opening ? "Break the rotating pane screen" : "Urgent: Guard chip reaches Sable"}</h2><p>${opening ? "Player line faces right. Position 0 receives the first legal bite." : "The second Pane Bite will remove Elian's Guard and chip the neighbor."}</p></div>
<div class="playback-tools">${iconButton(state.battlePaused ? "▶" : "Ⅱ", state.battlePaused ? "Resume" : "Pause", "data-battle-pause")}
<div class="speed-control">${[1, 2, 4].map((speed) => `<button class="${state.battleSpeed === speed ? "is-active" : ""}" data-battle-speed="${speed}">${speed}x</button>`).join("")}</div>${iconButton("i", "Inspect event", "data-open-screen=\"BAT-003\"")}</div>
${battleLine()}
<div class="battle-event-log"><span>00:07.2 · Gust Finch targets Nia</span><b>00:08.0 · Elian intercepts Pane Bite</b><span>00:08.8 · Sable gains Remedy</span></div>
${inspect ? `<aside class="inspect-sheet"><span class="stage-kicker">PINNED CAUSE</span><h3>Elian intercepts Pane Bite</h3><dl><div><dt>Source</dt><dd>Hearthguard · Guard 1</dd></div><div><dt>Target</dt><dd>Sable · position 2</dd></div><div><dt>Order effect</dt><dd>Neighbor protection</dd></div><div><dt>Next cause</dt><dd>Guard reaches zero</dd></div></dl><button class="stage-primary" data-flow-next>Pin and continue</button></aside>` : ""}
<div class="caption-line">[Front recruit: Guard consumed] [Enemy forecast: rear projectile]</div>
${opening ? actionDock("Start observation", "Inspect objective", "data-flow-next") : ""}
</div>`;
return frame(screen, body, { variant });
}
function renderOutcome(screen, variant) {
if (screen.id === "OUT-001") {
const body = `<div class="result-composition"><div class="result-title"><span class="outcome-seal">VICTORY</span><div><span class="stage-kicker">FIRST MEANINGFUL DIVERGENCE</span><h2>Guard broke before the second rear shot</h2><p>Expected protection ended at 00:18.4; actual pressure reached Nia at 00:19.1.</p></div></div><div class="causal-timeline"><div class="expected-track"><b>EXPECTED</b><span>00:17 Guard 1</span><span>00:19 Rear shot blocked</span><span>00:22 Stable line</span></div><div class="actual-track"><b>ACTUAL</b><span>00:17 Pane chip</span><span class="is-divergence">00:18 Guard 0</span><span>00:19 Nia hit</span></div><svg aria-hidden="true" viewBox="0 0 100 20" preserveAspectRatio="none"><path d="M34 3 C42 3 42 17 51 17" /></svg></div><div class="result-effects"><span><b>Cause</b>Second uninterrupted Pane Bite</span><span><b>Order</b>Sable remained adjacent</span><span><b>Adapt</b>Move or interrupt before bite 2</span></div>${actionDock("Continue to Reward", "Full event log", "data-flow-next")}</div>`;
return frame(screen, body, { variant });
}
if (screen.id === "OUT-002") {
const body = `<div class="loss-composition"><div class="fallen-figure"><span>FS</span><i>FALLEN</i></div><div class="loss-record"><span class="stage-kicker">COMMITTED LOSS</span><h2>Nia Orrow</h2><p>Farstring · Patient Start · Level 3 · survived 4 battles</p><div class="loss-row"><span>Equipment</span><b>Returned to inventory</b></div><div class="loss-row"><span>Party gap</span><b>Rear pressure lost</b></div><div class="loss-row"><span>Replacement Credit</span><b>+1 tier</b></div><small>Death is permanent for this run. No relationship or personal-arc system is implied.</small></div>${actionDock("Acknowledge loss", "Inspect receipt", "data-flow-next")}</div>`;
return frame(screen, body, { variant });
}
if (screen.id === "OUT-003") {
const rewards = [["EQ-014", "Quiet Rearplate", "Converts first area hit to direct"], ["AR-011", "Narrow Vanguard", "One fewer deploy; front starts Guarded"], ["PF-B05", "Farstring insight", "Opens a profession trial"]];
const body = `<div class="reward-composition"><div class="reward-heading"><span class="stage-kicker">FIXED REWARD DRAFT</span><h2>Choose the next decision you want to change</h2></div><div class="reward-options">${rewards.map((reward, index) => `<button class="reward-option ${index === 0 ? "is-selected" : ""}"><span class="reward-object">${["▰", "◇", "◎"][index]}</span><small>${reward[0]}</small><strong>${reward[1]}</strong><p>${reward[2]}</p><em>${index === 0 ? "Selected" : "Compare"}</em></button>`).join("")}</div>${actionDock("Claim Quiet Rearplate", "Compare build", "data-flow-next")}</div>`;
return frame(screen, body, { variant });
}
if (screen.id === "OUT-004") {
const body = `<div class="recovery-composition"><div class="recovery-heading"><span class="stage-kicker">RECOVERY</span><h2>Company remains viable</h2><p>Replacement Credit reduces only a later hire. Lost level, Trait, profession and history stay lost.</p></div><div class="recovery-paths"><button class="recovery-path is-selected"><span>01</span><strong>Protected base hire</strong><small>46 → 28 Coin · consumes 1 Credit</small><p>Return with a new base profession at Market.</p></button><button class="recovery-path"><span>02</span><strong>Continue short-handed</strong><small>0 Coin · Company 3 / capacity 4</small><p>Keep resources and accept reduced coverage.</p></button><button class="recovery-path"><span>03</span><strong>Abandon run</strong><small>Run-close transaction</small><p>Retain history and committed progression only.</p></button></div>${actionDock("Commit protected hire", "Review consequences", "data-flow-next")}</div>`;
return frame(screen, body, { variant });
}
const rows = stageDetails[screen.id] || ["Outcome receipt verified", "No unresolved casualty", "Safe destination named"];
const body = `<div class="outcome-sheet"><span class="stage-kicker">${screen.id}</span><h2>${escapeHtml(screenName(screen))}</h2>${rows.map((row) => `<div class="sheet-row">${escapeHtml(row)}</div>`).join("")}<p>${truncate(screen.purpose, 180)}</p>${actionDock(screen.id === "OUT-006" ? "Hold to abandon run" : "Finish result", "Cancel")}</div>`;
return frame(screen, body, { variant });
}
function renderRoster(screen, variant) {
if (screen.id === "REC-003") {
const body = `<div class="dismissal-review"><div class="review-figure">${recruitMarker(generatedOffers[2], true)}</div><div><span class="stage-kicker">DESTRUCTIVE REVIEW</span><h2>Dismiss Tomas Vale?</h2><div class="sheet-row">Equipment returns to run inventory</div><div class="sheet-row">No Replacement Credit</div><div class="sheet-row">Living Company 4 → 3</div><p>Eligibility is revalidated at commit. The last living recruit can never be dismissed.</p></div>${actionDock("Dismiss Recruit", "Cancel")}</div>`;
return frame(screen, body, { variant });
}
const roster = generatedOffers.concat([{ id: "RC-017", name: "Miro Sen", profession: "Coilsmith", trait: "Deep Pocket", mark: "CS" }]);
const body = `<div class="roster-composition"><div class="roster-list"><span class="stage-kicker">LIVING COMPANY · 5</span>${roster.map((offer, index) => `<button class="roster-row ${index === 1 ? "is-focused" : ""}">${recruitMarker(offer, index < 4)}<span>${index < 4 ? `Position ${3 - index}` : "Reserve"}</span></button>`).join("")}</div><aside class="recruit-detail"><span class="large-figure">${screen.id === "REC-002" ? "FS" : "KH"}</span><h2>${screen.id === "REC-002" ? "Nia Orrow" : "Sable Kest"}</h2><p>${screen.id === "REC-002" ? "Farstring · Patient Start" : "Kindhand · Shared Cup"}</p><div class="detail-grid"><span>Readiness<b>Ready</b></span><span>Position<b>3 · Rear</b></span><span>Equipment<b>Quiet Rearplate</b></span><span>History<b>4 survived</b></span></div><button class="stage-secondary">Open Equipment</button><button class="stage-primary">Open Party Line</button></aside></div>`;
return frame(screen, body, { variant });
}
function renderGuild(screen, variant) {
const body = screen.id === "HUB-001" ? `<div class="guild-composition"><div class="guild-pressure"><span class="stage-kicker">CURRENT PRESSURE</span><h2>Rear projectile at the next crossing</h2><p>Gust Finch enters behind a visible Pane screen.</p></div><div class="guild-destinations"><button>Market <b>3 / 4 / 3</b></button><button>Roster <b>5 living</b></button><button>Equipment <b>1 new</b></button><button>Party Line <b>Ready</b></button><button class="is-primary">Risk Board <b>3 offers</b></button></div><div class="muster-strip">${partyActors()}</div><div class="receipt-strip"><b>Latest receipt</b><span>Quiet Rearplate claimed · no unresolved transaction</span></div></div>` : `<div class="forecast-composition"><div class="forecast-horizon"><span>NOW</span><i></i><span>ROUND 4</span><i></i><span>BOSS MUSTER</span></div><div class="forecast-sheets"><article><span>KNOWN</span><h3>Gust Finch</h3><p>Targets rearmost ally, then steps forward.</p><b>Counter: screen or pull</b></article><article><span>KNOWN</span><h3>Pane Nibbler</h3><p>Second uninterrupted bite chips neighbor Guard.</p><b>Counter: reorder or interrupt</b></article><article class="uncertain"><span>PARTIAL</span><h3>Third identity</h3><p>Family known: forced movement.</p><b>Confidence 68%</b></article></div></div>`;
return frame(screen, body, { variant });
}
function renderSystem(screen, variant) {
if (screen.id === "SYS-005") return renderOpening(screen, variant === "primary" ? "offers2" : variant);
if (screen.id === "SYS-003") {
const body = `<div class="title-composition"><div class="title-brand"><span>AETHERBOUND</span><strong>GUILD</strong><small>Build the company. Order the line. Read the result.</small></div><div class="run-summary"><b>Safe resume</b><strong>Windglass Verge · Market</strong><span>Company 5 · Round 3 · save healthy</span><button class="stage-primary">Continue to Resume Review</button><button class="stage-secondary">New Run</button></div><div class="title-tools">${iconButton("⚙", "Settings")}${iconButton("Aa", "Accessibility")}</div></div>`;
return frame(screen, body, { variant, resources: false, context: "Aetherbound Guild" });
}
if (screen.id === "SYS-001") {
const body = `<div class="boot-composition"><div class="boot-mark"><i></i><span>AG</span></div><h2>Aetherbound Guild</h2><div class="verification-track"><i style="width:76%"></i></div><strong>Verifying local content · 76%</strong><small>Rules · Content · Settings · Save lineage</small></div>`;
return frame(screen, body, { variant, art: false, header: false });
}
if (screen.id === "SYS-002") return renderSettings(screen, variant, "FIRST-LAUNCH SETUP");
const details = stageDetails[screen.id] || [screen.purpose, screen.action];
const body = `<div class="system-workspace"><span class="stage-kicker">${screen.id}</span><h2>${escapeHtml(screenName(screen))}</h2><div class="system-list">${details.map((detail, index) => `<button class="system-row ${index === 0 ? "is-focused" : ""}"><span>0${index + 1}</span><strong>${escapeHtml(detail)}</strong><em>${index === 0 ? "Available" : "Inspect"}</em></button>`).join("")}</div><aside><b>Safe action</b><p>${truncate(screen.action, 160)}</p><button class="stage-primary">${screen.id === "SYS-007" ? "Load selected lineage" : "Open selected state"}</button></aside></div>`;
return frame(screen, body, { variant, resources: false });
}
function renderArtifact(screen, variant) {
const entries = state.data.artifacts.slice(0, 3);
const body = `<div class="artifact-composition"><div class="artifact-heading"><span class="stage-kicker">RUN-WIDE RULES · NOT EQUIPMENT</span><h2>Active Artifact Ledger</h2></div><div class="artifact-ledger">${entries.map((entry, index) => `<button class="artifact-row ${index === 0 ? "is-focused" : ""}"><span class="artifact-emblem">${["◇", "⌑", "⬡"][index]}</span><div><small>${entry.id}</small><strong>${entry.name}</strong><p>${truncate(entry.rule, 90)}</p></div><em>${index < 2 ? "Active" : "Conflict"}</em></button>`).join("")}</div><aside class="artifact-inspect"><h3>${entries[0].name}</h3><p>${entries[0].rule}</p><b>Conflict</b><span>${entries[0].tradeoff}</span><small>Lifecycle: current run only</small></aside></div>`;
return frame(screen, body, { variant });
}
function renderProgress(screen, variant) {
if (screen.id === "PRO-003") {
const body = `<div class="records-composition"><div class="records-categories"><strong>COMPENDIUM</strong><button class="is-active">Professions <b>18/42</b></button><button>Traits <b>24/36</b></button><button>Equipment <b>73/320</b></button><button>Artifacts <b>11/60</b></button><button>Enemies <b>31/100</b></button><button>Bosses <b>2/16</b></button><button>Regions <b>2/8</b></button></div><div class="record-grid">${state.data.professions.slice(0, 6).map((entry, index) => `<button class="record-tile ${index === 0 ? "is-focused" : ""}"><span>${entry.name.slice(0, 2).toUpperCase()}</span><small>${entry.id}</small><strong>${entry.name}</strong><em>${index < 3 ? "Known" : "Undiscovered"}</em></button>`).join("")}</div><aside class="record-inspect"><span class="large-figure">HG</span><h2>Hearthguard</h2><p>Front hold · Guard intercept · reach 1</p><b>Counterplay</b><span>Rear pressure and Guard stripping</span></aside></div>`;
return frame(screen, body, { variant, art: false });
}
const rows = stageDetails[screen.id] || ["Decision grammar unlocked", "Prerequisite verified", "Receipt available"];
const isPostgame = screen.id.startsWith("PST");
const body = `<div class="progress-composition"><div class="progress-heading"><span class="stage-kicker">${isPostgame ? "POSTGAME" : "PROGRESSION"}</span><h2>${escapeHtml(screenName(screen))}</h2><p>${truncate(screen.purpose, 170)}</p></div><div class="progress-path"><span class="path-node is-complete">Base</span><i></i><span class="path-node is-current">Choice</span><i></i><span class="path-node">Mastery</span></div><div class="progress-details">${rows.map((row) => `<div class="sheet-row">${escapeHtml(row)}</div>`).join("")}</div>${actionDock(isPostgame ? "Review active rules" : "Inspect choice", "Ordinary New Run")}</div>`;
return frame(screen, body, { variant, art: false });
}
function renderSettings(screen, variant, kicker = "SETTINGS") {
const rows = settingRows[screen.id] || settingRows["SET-002"];
const categoryNames = ["Display", "Motion", "Audio", "Controls", "Language", "Information", "Save / Data", "Reset"];
const settingKeys = {
"SET-002-2": "contrast",
"SET-003-0": "reducedMotion",
"SET-003-3": "lowPower",
"SET-004-3": "captions",
};
const controls = rows.map(([label, value], index) => {
if (screen.id === "SET-002" && index === 0) {
return `<div class="segmented-control">${["100", "115", "130"].map((scale) => `<button class="${state.settings.textScale === scale ? "is-active" : ""}" data-text-scale="${scale}">${scale}</button>`).join("")}</div>`;
}
if (screen.id === "SET-006" && index === 0) {
return `<div class="segmented-control"><button class="${state.locale === "en" ? "is-active" : ""}" data-locale="en">EN</button><button class="${state.locale === "zh_CN" ? "is-active" : ""}" data-locale="zh_CN">简中</button></div>`;
}
const key = settingKeys[`${screen.id}-${index}`];
if (key) {
const enabled = Boolean(state.settings[key]);
return `<button class="switch ${enabled ? "is-on" : ""}" type="button" data-setting-key="${key}" aria-pressed="${enabled}"><i></i></button>`;
}
if (index % 3 === 1) return `<button class="switch is-on" type="button" aria-pressed="true"><i></i></button>`;
if (index % 3 === 2) return `<button class="menu-control">${escapeHtml(value)}⌄</button>`;
return `<div class="segmented-control"><button>Low</button><button class="is-active">${escapeHtml(value)}</button><button>High</button></div>`;
});
const body = `<div class="settings-composition"><nav class="settings-rail"><strong>${kicker}</strong>${categoryNames.map((name, index) => `<button class="${screen.id === `SET-00${Math.min(9, index + 2)}` || (screen.id === "SET-001" && index === 0) ? "is-active" : ""}">${name}</button>`).join("")}</nav><div class="settings-workspace"><span class="stage-kicker">CALLER · ${variant === "pause" ? "PAUSED BATTLE" : "TITLE"}</span><h2>${escapeHtml(screenName(screen))}</h2>${rows.map(([label], index) => `<div class="setting-control"><div><strong>${escapeHtml(label)}</strong><small>${index === 0 ? "Live preview" : "Current value"}</small></div>${controls[index]}</div>`).join("")}<div class="settings-actions"><button class="stage-secondary">Discard</button><button class="stage-primary">Apply settings</button></div></div><aside class="settings-preview"><span>PREVIEW</span><div class="preview-target">TARGET <b>Pane Nibbler</b></div><p>Front recruit protected<br />[Enemy forecast: rear projectile]</p><strong>Readable at ${state.settings.textScale}%</strong></aside></div>`;
return frame(screen, body, { variant, art: false, header: false });
}
function renderGeneric(screen, variant) {
const details = stageDetails[screen.id] || [truncate(screen.purpose, 90), truncate(screen.action, 90), screen.input];
const body = `<div class="generic-composition"><div class="generic-subject"><span>${screen.id.split("-")[0]}</span><strong>${escapeHtml(screenName(screen))}</strong></div><div class="generic-decision"><span class="stage-kicker">CURRENT DECISION</span><h2>${escapeHtml(screenName(screen))}</h2><p>${truncate(screen.purpose, 180)}</p>${details.map((detail) => `<div class="sheet-row">${escapeHtml(detail)}</div>`).join("")}</div>${actionDock(truncate(screen.action.split(";")[0], 46), t("inspect"))}</div>`;
return frame(screen, body, { variant });
}
function renderPrimaryStage(screen, variant = "primary") {
const prefix = screen.id.split("-")[0];
if (prefix === "SYS") return renderSystem(screen, variant);
if (prefix === "HUB") return renderGuild(screen, variant);
if (prefix === "SHP") return renderMarket(screen, variant);
if (prefix === "REC") return renderRoster(screen, variant);
if (prefix === "EQP" || prefix === "PTY") return renderPreparation(screen, variant);
if (prefix === "ART") return renderArtifact(screen, variant);
if (prefix === "RSK") return renderRisk(screen, variant);
if (prefix === "BAT") return renderBattle(screen, variant);
if (prefix === "OUT") return renderOutcome(screen, variant);
if (prefix === "PRO" || prefix === "PST") return renderProgress(screen, variant);
if (prefix === "SET") return renderSettings(screen, variant);
return renderGeneric(screen, variant);
}
function renderStateVariant(screen, variant) {
if (variant === "primary" || (screen.id === "SYS-005" && !["loading", "error", "return"].includes(variant))) return renderPrimaryStage(screen, variant);
const source = variant === "loading" ? screen.emptyLoading : variant === "error" ? screen.errorConfirmation : screen.interruptionReturn;
const label = variant === "loading" ? "LOADING / EMPTY" : variant === "error" ? "ERROR / CONFIRMATION" : "INTERRUPTION / RETURN";
const base = renderPrimaryStage(screen, "primary");
const action = variant === "loading" ? t("retry") : variant === "error" ? "Review changed state" : t("returnSafe");
return base.replace(
"</section>",
`<div class="state-variant-overlay ${variant}"><div class="variant-sheet"><span class="stage-kicker">${label}</span><h2>${escapeHtml(screenName(screen))}</h2><p>${escapeHtml(truncate(source, 260))}</p><div><button class="stage-secondary">${t("back")}</button><button class="stage-primary">${escapeHtml(action)}</button></div></div></div></section>`,
);
}
function viewHeading(kicker, title, detail, badges = []) {
return `<header class="view-heading"><div><span class="eyebrow">${escapeHtml(kicker)}</span><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p></div><div class="heading-badges">${badges.map((badge) => `<span>${escapeHtml(badge)}</span>`).join("")}</div></header>`;
}
function renderFirstPlay() {
const step = flowSteps[state.flowIndex];
const screen = getScreen(step[0]);
return `<section class="view firstplay-view">
${viewHeading("OWNER REVIEW · FIRST 16 MINUTES", flowLabel(step), `${screen.id} · ${screenName(screen)}`, [`${state.flowIndex + 1} / ${flowSteps.length}`, "Real screen state"])}
<div class="stage-wrap">${renderPrimaryStage(screen, step[1])}</div>
<div class="firstplay-controls"><button class="page-command" type="button" data-flow-prev ${state.flowIndex === 0 ? "disabled" : ""}>‹ ${t("prev")}</button><div class="flow-progress" aria-label="First-play progress">${flowSteps.map((entry, index) => `<button class="${index === state.flowIndex ? "is-active" : ""} ${index < state.flowIndex ? "is-complete" : ""}" type="button" data-flow-index="${index}" title="${escapeHtml(flowLabel(entry))}"><span>${index + 1}</span></button>`).join("")}</div><button class="page-command is-primary" type="button" data-flow-next>${state.flowIndex === flowSteps.length - 1 ? "Return to first Market" : t("next")} ›</button></div>
</section>`;
}
function renderScreens() {
const filtered = state.data.screens.filter((screen) => state.screenGroup === "ALL" || screen.id.startsWith(`${state.screenGroup}-`));
if (!filtered.some((screen) => screen.id === state.screenId)) state.screenId = filtered[0].id;
const screen = getScreen(state.screenId);
const index = filtered.findIndex((entry) => entry.id === screen.id);
const previous = filtered[(index - 1 + filtered.length) % filtered.length];
const next = filtered[(index + 1) % filtered.length];
const variants = screen.id === "SYS-005" ? [["contract", "A"], ["offers2", "B"], ["review", "C"], ["pending", "D"], ["receipt", "E"], ["loading", "Loading"], ["error", "Error"], ["return", "Return"]] : [["primary", "Primary"], ["loading", "Loading / empty"], ["error", "Error / confirm"], ["return", "Return"]];
return `<section class="view screens-view">
${viewHeading("50 CURRENT PAGE / STATE IDS", screenName(screen), `${screen.id} · ${screen.input}`, [`${index + 1} / ${filtered.length}`, "Page-specific composition"])}
<div class="screen-tools"><div class="filter-scroll">${Object.entries(screenGroups).map(([key, label]) => `<button class="${state.screenGroup === key ? "is-active" : ""}" data-screen-group="${key}">${label}</button>`).join("")}</div><div class="variant-control">${variants.map(([key, label]) => `<button class="${state.screenVariant === key ? "is-active" : ""}" data-screen-variant="${key}">${label}</button>`).join("")}</div></div>
<div class="screen-browser"><aside class="screen-index">${filtered.map((entry) => `<button class="${entry.id === screen.id ? "is-active" : ""}" type="button" data-screen-id="${entry.id}"><span>${entry.id}</span><strong>${escapeHtml(screenName(entry))}</strong><em>COMPOSED</em></button>`).join("")}</aside><div class="screen-inspection"><div class="stage-wrap">${renderStateVariant(screen, state.screenVariant)}</div><div class="screen-pager"><button data-screen-id="${previous.id}">‹ ${t("prev")} · ${previous.id}</button><button data-screen-id="${next.id}">${next.id} · ${t("next")} ›</button></div><div class="authority-summary"><div><span>Purpose</span><p>${escapeHtml(screen.purpose)}</p></div><div><span>Primary action</span><p>${escapeHtml(screen.action)}</p></div><div><span>Safe state</span><p>${escapeHtml(screen.interruptionReturn)}</p></div></div></div></div>
</section>`;
}
function renderRegions() {
const region = state.data.regions[state.regionIndex];
const enemies = state.data.enemies.filter((entry) => entry.id.startsWith(`EN-R${state.regionIndex + 1}-`));
const bosses = state.data.bosses.filter((entry) => entry.id.startsWith(`BO-R${state.regionIndex + 1}-`));
return `<section class="view regions-view">
${viewHeading("EIGHT CAMPAIGN REGIONS", region.name, region.framing, [`${region.id}`, `${enemies.length} enemies · ${bosses.length} bosses`])}
<div class="region-tabs">${state.data.regions.map((entry, index) => `<button class="${index === state.regionIndex ? "is-active" : ""}" data-region-index="${index}"><span>${entry.id}</span><strong>${entry.name}</strong></button>`).join("")}</div>
<div class="region-stage"><img src="${sceneImages[state.regionIndex]}" alt="${escapeHtml(region.name)} concept direction" /><span class="concept-label">${t("concept")}</span><div class="region-title"><span>${region.id}</span><h2>${region.name}</h2><p>${region.framing}</p></div><div class="region-route"><i></i><button>Contract A</button><i></i><button>Boss 01</button><i></i><button>Contract B</button><i></i><button>Boss 02</button></div></div>
<div class="region-intel"><article><span>Build pressure</span><p>${region.pressure}</p></article><article><span>Encounter grammar</span><p>${region.progression}</p></article><article><span>Current identities</span><div class="identity-strip">${enemies.slice(0, 5).map((enemy) => `<b>${enemy.id}<small>${enemy.name}</small></b>`).join("")}${bosses.map((boss) => `<b class="boss">${boss.id}<small>${boss.name}</small></b>`).join("")}</div></article></div>
</section>`;
}
function catalogConfig() {
const configs = {
professions: { label: "Professions", image: "assets/web/el_profession_kits.webp", filters: [["all", "All 42"], ["base", "12 base"], ["advanced", "24 advanced"], ["hidden", "6 hidden"]], meta: (entry) => `${entry.family} · ${entry.role}`, body: (entry) => `${entry.rules} ${entry.strategy}` },
traits: { label: "Traits", image: "assets/web/el_heroes.webp", filters: [["all", "All 36"]], meta: () => "one per recruit", body: (entry) => `Benefit: ${entry.benefit} Cost: ${entry.cost}` },
equipment: { label: "Equipment", image: "assets/web/el_items.webp", filters: [["all", "All 320"], ["B", "Behavior B"], ["T", "Tradeoff T"]], meta: (entry) => `${entry.class} · ${entry.fit}`, body: (entry) => `${entry.effect} Tradeoff: ${entry.tradeoff}` },
artifacts: { label: "Artifacts", image: "assets/web/el_items.webp", filters: [["all", "All 60"]], meta: () => "run-wide rule", body: (entry) => `${entry.rule} ${entry.tradeoff}` },
enemies: { label: "Enemies", image: "assets/web/el_enemies.webp", filters: [["all", "All 100"], ["ordinary", "Ordinary"], ["elite", "Elite"]], meta: (entry) => entry.class, body: (entry) => `${entry.rule} Responses: ${entry.responses}` },
bosses: { label: "Bosses", image: "assets/web/el_enemies.webp", filters: [["all", "All 16"]], meta: (entry) => entry.class, body: (entry) => `${entry.rule} Responses: ${entry.responses}` },
};
return configs[state.catalogType];
}
function renderCatalogs() {
const config = catalogConfig();
const entries = state.data[state.catalogType];
const normalized = state.catalogQuery.trim().toLocaleLowerCase();
const filtered = entries.filter((entry) => {
const filterMatch = state.catalogFilter === "all" || entry.family === state.catalogFilter || entry.class === state.catalogFilter;
const queryMatch = !normalized || Object.values(entry).join(" ").toLocaleLowerCase().includes(normalized);
return filterMatch && queryMatch;
});
return `<section class="view catalog-view">
${viewHeading("CURRENT AUTHORED CATALOGS", config.label, "Search stable IDs, names, mechanics, tradeoffs and counterplay.", [`${filtered.length} / ${entries.length}`, "No filler rows"])}
<div class="catalog-tabs">${["professions", "traits", "equipment", "artifacts", "enemies", "bosses"].map((key) => `<button class="${state.catalogType === key ? "is-active" : ""}" data-catalog-type="${key}">${key}<b>${state.data.counts[key]}</b></button>`).join("")}</div>
<div class="catalog-toolbar"><div class="filter-scroll">${config.filters.map(([key, label]) => `<button class="${state.catalogFilter === key ? "is-active" : ""}" data-catalog-filter="${key}">${label}</button>`).join("")}</div><label class="search-control"><span>⌕</span><input type="search" data-catalog-search value="${escapeHtml(state.catalogQuery)}" placeholder="Search ID, name or mechanic" aria-label="Search catalog" /></label></div>
<div class="catalog-layout"><aside class="catalog-art"><img src="${config.image}" alt="Concept element direction" /><span class="concept-label">${t("concept")}</span><div><strong>${config.label}</strong><p>Concept board only. Stable IDs and mechanics come from the current authority.</p></div></aside><div class="catalog-list">${filtered.length ? filtered.slice(0, 100).map((entry) => `<article class="catalog-entry"><div><small>${entry.id}</small><strong>${entry.name}</strong><em>${escapeHtml(config.meta(entry))}</em></div><p>${escapeHtml(truncate(config.body(entry), 240))}</p><button class="stage-icon" aria-label="Inspect ${escapeHtml(entry.name)}" title="Inspect">i</button></article>`).join("") : `<div class="empty-state"><strong>No matching identities</strong><button data-clear-search>Clear search</button></div>`}${filtered.length > 100 ? `<div class="catalog-more">100 shown · ${filtered.length - 100} more remain searchable</div>` : ""}</div></div>
</section>`;
}
function renderReview() {
const groups = [
["01", "Opening company", "SYS-005 A-E", "Four stable offers, exactly-two gating, revision, atomic receipt and first-Market handoff.", "firstplay", "0"],
["02", "Market hire", "SHP-001 / 002", "Two separate affordable base hires move Company 2 → 3 → 4 while capacity remains 4.", "firstplay", "7"],
["03", "Formation and equip", "EQP-001 / 002 · PTY-001 / 002", "Behavior delta and one rear-left to front-right line with exposed position 0.", "screens", "EQP-002"],
["04", "Horizontal battle", "BAT-001..006", "Automatic execution, one ordered line, urgent cause, pause, speed, inspect and retreat review.", "screens", "BAT-002"],
["05", "Result and recovery", "OUT-001..006", "Earliest divergence precedes totals; loss, reward, recovery and abandonment remain distinct.", "screens", "OUT-001"],
["06", "Catalog and settings", "PRO-003 · SET-001..009", "Searchable current counts plus EN/zh_CN, 130%, contrast, captions, reduced motion and input states.", "catalogs", "professions"],
];
return `<section class="view review-view">
${viewHeading("OWNER DECISION SURFACE", "Six critical review groups", "Each group opens the real composition used in the 50-state atlas.", ["Design review only", "Human gate open"])}
<div class="review-groups">${groups.map((group) => `<article><span>${group[0]}</span><div><small>${group[2]}</small><h2>${group[1]}</h2><p>${group[3]}</p></div><button class="page-command" data-review-view="${group[4]}" data-review-target="${group[5]}">Open group ›</button></article>`).join("")}</div>
<div class="review-boundary"><div><span>Machine-verifiable</span><strong>50 unique states · current counts · responsive layout · interactions · self-contained bundle</strong></div><div><span>Owner remains responsible</span><strong>Comprehension · visual acceptance · fun · listening · device ergonomics · development approval</strong></div></div>
</section>`;
}
function applyLocale() {
document.documentElement.lang = state.locale === "zh_CN" ? "zh-CN" : "en";
$$('[data-copy]').forEach((element) => { element.textContent = t(element.dataset.copy); });
$$('[data-locale]').forEach((button) => button.classList.toggle("is-active", button.dataset.locale === state.locale));
$("#view-title").textContent = t(state.view);
}
function applySettings() {
document.body.classList.toggle("high-contrast", state.settings.contrast);
document.body.classList.toggle("reduced-motion", state.settings.reducedMotion);
document.body.classList.toggle("low-power", state.settings.lowPower);
document.body.dataset.textScale = state.settings.textScale;
}
function render() {
if (!state.data) return;
const renderers = { firstplay: renderFirstPlay, screens: renderScreens, regions: renderRegions, catalogs: renderCatalogs, review: renderReview };
$("#content").innerHTML = renderers[state.view]();
$$('.nav-item').forEach((button) => button.classList.toggle("is-active", button.dataset.view === state.view));
applyLocale();
applySettings();
}
function setView(view) {
state.view = view;
history.replaceState(null, "", `#${view}`);
render();
closeMobileNav();
$("#content").focus({ preventScroll: true });
}
function closeMobileNav() {
$("#sidebar").classList.remove("is-open");
$("#nav-scrim").hidden = true;
}
function openMobileNav() {
$("#sidebar").classList.add("is-open");
$("#nav-scrim").hidden = false;
}
function advanceFlow(delta) {
const next = state.flowIndex + delta;
state.flowIndex = next < 0 ? 0 : next >= flowSteps.length ? 7 : next;
if (state.view !== "firstplay") state.view = "firstplay";
if ($("#stage-dialog").open) $("#stage-dialog").close();
render();
}
function handleClick(event) {
const button = event.target.closest("button");
if (!button) return;
if (button.dataset.view) return setView(button.dataset.view);
if (button.dataset.locale) {
state.locale = button.dataset.locale;
return render();
}
if (button.hasAttribute("data-flow-next")) return advanceFlow(1);
if (button.hasAttribute("data-flow-prev")) return advanceFlow(-1);
if (button.dataset.flowIndex) {
state.flowIndex = Number(button.dataset.flowIndex);
return render();
}
if (button.dataset.screenGroup) {
state.screenGroup = button.dataset.screenGroup;
state.screenVariant = "primary";
return render();
}
if (button.dataset.screenId) {
state.screenId = button.dataset.screenId;
state.screenVariant = "primary";
if (state.view !== "screens") state.view = "screens";
return render();
}
if (button.dataset.screenVariant) {
state.screenVariant = button.dataset.screenVariant;
return render();
}
if (button.dataset.regionIndex) {
state.regionIndex = Number(button.dataset.regionIndex);
return render();
}
if (button.dataset.catalogType) {
state.catalogType = button.dataset.catalogType;
state.catalogFilter = "all";
state.catalogQuery = "";
return render();
}
if (button.dataset.catalogFilter) {
state.catalogFilter = button.dataset.catalogFilter;
return render();
}
if (button.dataset.offerId) {
const id = button.dataset.offerId;
const index = state.openingSelected.indexOf(id);
if (index >= 0) state.openingSelected.splice(index, 1);
else if (state.openingSelected.length < 2) state.openingSelected.push(id);
state.flowIndex = state.openingSelected.length === 0 ? 1 : state.openingSelected.length === 1 ? 2 : 3;
return render();
}
if (button.hasAttribute("data-party-swap")) {
state.partySwapped = !state.partySwapped;
return render();
}
if (button.hasAttribute("data-battle-pause")) {
state.battlePaused = !state.battlePaused;
return render();
}
if (button.dataset.battleSpeed) {
state.battleSpeed = Number(button.dataset.battleSpeed);
return render();
}
if (button.dataset.openScreen) {
state.screenId = button.dataset.openScreen;
state.screenVariant = "primary";
return setView("screens");
}
if (button.dataset.settingKey) {
const key = button.dataset.settingKey;
state.settings[key] = !state.settings[key];
return render();
}
if (button.dataset.textScale) {
state.settings.textScale = button.dataset.textScale;
return render();
}
if (button.dataset.reviewView) {
const target = button.dataset.reviewTarget;
if (button.dataset.reviewView === "firstplay") state.flowIndex = Number(target);
if (button.dataset.reviewView === "screens") state.screenId = target;
if (button.dataset.reviewView === "catalogs") state.catalogType = target;
return setView(button.dataset.reviewView);
}
if (button.hasAttribute("data-clear-search")) {
state.catalogQuery = "";
return render();
}
}
function handleInput(event) {
if (!event.target.matches("[data-catalog-search]")) return;
state.catalogQuery = event.target.value;
render();
const replacement = $("[data-catalog-search]");
replacement.focus();
replacement.setSelectionRange(replacement.value.length, replacement.value.length);
}
let viewerZoom = 1;
function openCurrentStage() {
const stage = $(".game-stage") || $(".region-stage");
if (!stage) return;
viewerZoom = 1;
$("#zoom-reset").textContent = "100%";
$("#dialog-stage").style.width = "100%";
$("#dialog-stage").replaceChildren(stage.cloneNode(true));
$("#viewer-label").textContent = stage.dataset.stageId || "Region composition";
$("#stage-dialog").showModal();
}
function setViewerZoom(delta) {
viewerZoom = Math.min(2, Math.max(0.75, Math.round((viewerZoom + delta) * 4) / 4));
$("#dialog-stage").style.width = `${viewerZoom * 100}%`;
$("#zoom-reset").textContent = `${Math.round(viewerZoom * 100)}%`;
}
async function init() {
try {
const embeddedData = $("#prototype-data");
if (embeddedData) state.data = JSON.parse(embeddedData.textContent);
else {
const response = await fetch("data/catalog.json");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.data = await response.json();
}
const hashView = location.hash.replace("#", "");
if (["firstplay", "screens", "regions", "catalogs", "review"].includes(hashView)) state.view = hashView;
render();
} catch (error) {
$("#content").innerHTML = `<div class="empty-state"><h1>Prototype data unavailable</h1><p>${escapeHtml(error.message)}</p></div>`;
}
}
document.addEventListener("click", handleClick);
document.addEventListener("input", handleInput);
$("#open-nav").addEventListener("click", openMobileNav);
$("#close-nav").addEventListener("click", closeMobileNav);
$("#nav-scrim").addEventListener("click", closeMobileNav);
$("#fullscreen-current").addEventListener("click", openCurrentStage);
$("#close-dialog").addEventListener("click", () => $("#stage-dialog").close());
$("#zoom-out").addEventListener("click", () => setViewerZoom(-0.25));
$("#zoom-in").addEventListener("click", () => setViewerZoom(0.25));
$("#zoom-reset").addEventListener("click", () => { viewerZoom = 1; setViewerZoom(0); });
$("#stage-dialog").addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.close(); });
window.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMobileNav(); });
init();