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

1177 lines
82 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: "zh_CN",
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 zhText = {
"OWNER REVIEW · FIRST 16 MINUTES": "玩家审核 · 首次16分钟",
"Real screen state": "真实游戏页面",
"50 CURRENT PAGE / STATE IDS": "50个游戏页面与状态",
"Page-specific composition": "当前页面完整构图",
"EIGHT CAMPAIGN REGIONS": "八大冒险区域",
"CURRENT AUTHORED CATALOGS": "完整内容图鉴",
"OWNER DECISION SURFACE": "玩家验收入口",
"Design review only": "设计稿审核",
"Human gate open": "等待玩家确认",
"Windglass Verge": "风镜原野",
"Coin": "金币",
"Credit": "补员凭证",
"PROVISIONAL COMPANY": "初始队伍候选",
"Choose two generated recruits": "从四名候选中选择两名队员",
"Offer set WIND-01-A remains stable until run creation.": "本组候选在创建远征前保持不变,可以放心比较后再选择。",
"Choose two": "请选择两人",
"Hearthguard": "护心卫士",
"Farstring": "远弦射手",
"Wayrunner": "逐风行者",
"Kindhand": "仁心医者",
"Narrow Guard": "窄阵护卫",
"Patient Start": "耐心起手",
"Open Stance": "开放步法",
"Shared Cup": "共饮药杯",
"Intercepts a neighbor / slower steps": "替相邻队员承受攻击,但行动速度较慢",
"Rear pressure / delayed first basic": "擅长攻击敌方后排,但第一次普通攻击较晚",
"Reorders safely / briefly Exposed": "可以安全调整站位,但会短暂暴露",
"Restores a neighbor / weaker self-heal": "治疗相邻队员,但治疗自己时效果较弱",
"Elian Rook": "Elian Rook(伊利安)",
"Nia Orrow": "Nia Orrow(妮娅)",
"Tomas Vale": "Tomas Vale(托马斯)",
"Sable Kest": "Sable Kest(塞布尔)",
"COMPANY REVIEW": "队伍确认",
"RUN RECEIPT": "远征创建回执",
"Creating run": "正在创建远征",
"Operation RUN-CREATE-014": "操作编号 RUN-CREATE-014",
"The two unchosen offers were not retained.": "未选择的两名候选不会保留。",
"No Coin transaction": "本次不消耗金币",
"Slot 01 · Standard": "存档01 · 标准难度",
"Two unchosen stable IDs will not be retained.": "创建远征后,未选择的两名候选将离开。",
"Revise Recruits": "重新选择队员",
"NEW CONTRACT": "新契约",
"Trade Route: First Crossing": "商路:首次穿越",
"Contract 01 - chart the suspended pane crossing.": "契约01:勘察悬浮晶板之间的安全通路。",
"Known pressure": "已知威胁",
"Push + projectile screen": "击退与远程屏障",
"Boss rule": "首领规则",
"Rotating damage-family panes": "轮换不同伤害类型的晶板",
"Reward breadth": "奖励范围",
"Base professions + opening tools": "基础职业与开局装备",
"Difficulty": "难度",
"Standard - full systems": "标准:启用完整系统",
"Load Provisional Offers": "查看初始队员候选",
"FIRST MARKET": "首个集市",
"Build capability before choosing risk": "先补齐队伍能力,再选择要承担的风险",
"Recruits · 3": "队员 · 3",
"Equipment · 4": "装备 · 4",
"Risks · 3": "风险 · 3",
"AFFORDABLE BASE OFFER": "当前可负担的基础队员",
"Front hold": "前排承伤",
"Needed": "当前需要",
"Trait cost": "特性代价",
"Visible": "已说明",
"Review Recruit": "查看招募详情",
"Open Party Line": "打开队伍站位",
"Refresh 18 Coin · next 24": "刷新花费18金币 · 下次24金币",
"Lock 0/2": "锁定报价 0/2",
"RECRUIT REVIEW": "招募审核",
"PARTY IMPACT": "对队伍的影响",
"Generated identity, exactly one Trait, no authored personal arc.": "随机生成身份;每人只有一个机械特性,不包含角色关系或个人剧情。",
"Adds a visible front intercept.": "增加一个明确的前排拦截能力。",
"Adds position movement and target change.": "增加站位移动与目标切换能力。",
"Recruit - 46 Coin": "花费46金币招募",
"Price 46 Coin · Operation HIRE-001": "价格46金币 · 招募操作 HIRE-001",
"Price 46 Coin · Operation HIRE-002": "价格46金币 · 招募操作 HIRE-002",
"VIT": "生存",
"PACE": "速度",
"REACH": "射程",
"GUARD": "护卫",
"ELIGIBLE INVENTORY": "可装备物品",
"BEHAVIOR DELTA": "装备后行为变化",
"Stepback Soles": "退步靴",
"Frontpin Greaves": "钉前护胫",
"Rescue Loop": "救援环扣",
"Open-Line Cord": "开阵系带",
"Behavior class B": "行为型装备",
"Farstring · position 3": "远弦射手 · 位置3(后排)",
"Current: Quiet Rearplate": "当前装备:静默后甲",
"Quiet Rearplate → Stepback Soles": "静默后甲 → 退步靴",
"After first hit": "首次受击后",
"Step rear": "向后移动一格",
"Target relation": "受击位置变化",
"Front → Center": "前排 → 中排",
"Next basic": "下一次普通攻击",
"Wait +1": "延后1拍",
"At full rear the trigger is dormant. Apply remains reversible until readiness commit.": "位于最后排时此效果不会触发;确认出战准备前都可以撤销。",
"Apply candidate": "应用候选装备",
"ORDERED PARTY LINE": "队伍站位顺序",
"FROZEN PARTY SNAPSHOT": "已确认的出战队伍",
"Position 0 is always the rightmost front. The line is never mirrored by locale.": "最右侧的0号位永远是前排;切换语言不会改变队伍方向。",
"Sable protects 0": "Sable保护0号位",
"Nia reaches enemy rear": "Nia可以攻击敌方后排",
"Rear": "后排",
"Center": "中排",
"Protected": "受保护",
"Exposed": "暴露",
"Plan accepted": "方案已确认",
"Changed relations": "站位关系已改变",
"Elian · first legal target": "Elian · 敌人首先可攻击的目标",
"Nia · projectile screen risk": "Nia · 需要防范敌方远程攻击",
"Reserve · 1 living recruit": "替补席 · 还有1名存活队员",
"Inspect order": "查看站位",
"Swap positions 1 / 2": "交换1号位和2号位",
"Accept preparation": "确认出战准备",
"Save Order": "保存站位",
"THREE BOUNDED OFFERS": "三项明确的风险选择",
"Choose pressure, not a hidden result": "选择要面对的威胁,而不是猜隐藏结果",
"Steady Crossing": "稳健渡口",
"Shattered Terrace": "破碎台地",
"Bellward Detour": "钟卫绕行路",
"Pane Nibbler + Gust Finch": "晶板啮兽 + 阵风雀",
"Prism Ox + rear pressure": "棱镜牛 + 后排压力",
"Unknown third identity": "第三名敌人身份未知",
"Equipment": "装备",
"Artifact": "神器",
"Profession": "职业",
"Low": "低",
"Medium": "中",
"High": "高",
"exposure": "暴露风险",
"Coverage": "当前应对能力",
"Front hold ✓": "前排承伤 ✓",
"Rear screen △": "后排防护 △",
"Recovery 214 Coin": "可用恢复资金 214金币",
"Review Steady Crossing": "审核“稳健渡口”",
"RISK COMMITMENT": "风险确认",
"Known threat": "已知威胁",
"Projectile screen": "远程屏障",
"Entry cost": "进入费用",
"0 Coin": "0金币",
"Reward class": "奖励类型",
"Retreat": "撤退代价",
"One Coin tier": "损失一个金币档位",
"The accepted order is frozen with the risk receipt. Selection alone changed nothing.": "确认后,当前站位会和风险回执一起锁定;只选中风险不会立即改变游戏状态。",
"Begin Battle": "开始战斗",
"Return to Risk Board": "返回风险看板",
"BATTLE OPENING": "战斗开场",
"AUTOMATIC BATTLE": "自动战斗",
"Break the rotating pane screen": "击破轮换的晶板屏障",
"Player line faces right. Position 0 receives the first legal bite.": "我方朝右作战,最右侧0号位会首先承受可命中的攻击。",
"NO COMBAT RESPAWN · NO RESERVE AUTO-ENTRY": "阵亡不返场 · 后备队不自动补位",
"Urgent: Guard chip reaches Sable": "紧急:护卫削减即将波及Sable",
"The second Pane Bite will remove Elian's Guard and chip the neighbor.": "第二次晶板啃咬会清空Elian的护卫,并伤害相邻队员。",
"Pane Bite · 0.8s → Elian": "晶板啃咬 · 0.8秒后 → Elian",
"Prism screen": "棱镜屏障",
"Guard chip": "削减护卫",
"Rear shot": "后排射击",
"Start observation": "开始观察战斗",
"Inspect objective": "查看战斗目标",
"Resume": "继续",
"Pause": "暂停",
"Inspect event": "查看事件",
"00:07.2 · Gust Finch targets Nia": "00:07.2 · 阵风雀锁定Nia",
"00:08.0 · Elian intercepts Pane Bite": "00:08.0 · Elian拦截晶板啃咬",
"00:08.8 · Sable gains Remedy": "00:08.8 · Sable获得治疗效果",
"PINNED CAUSE": "锁定的战斗原因",
"Elian intercepts Pane Bite": "Elian拦截晶板啃咬",
"Source": "来源",
"Target": "目标",
"Order effect": "站位影响",
"Next cause": "下一原因",
"Neighbor protection": "保护相邻队员",
"Guard reaches zero": "护卫值归零",
"Pin and continue": "记录原因并继续",
"Hearthguard · Guard 1": "护心卫士 · 护卫1",
"Sable · position 2": "Sable · 2号位",
"[Front recruit: Guard consumed] [Enemy forecast: rear projectile]": "[前排队员:护卫已消耗] [敌方预告:攻击后排]",
"VICTORY": "胜利",
"FIRST MEANINGFUL DIVERGENCE": "第一个关键分歧点",
"Guard broke before the second rear shot": "第二次后排射击前,护卫提前崩溃",
"Expected protection ended at 00:18.4; actual pressure reached Nia at 00:19.1.": "原本预计保护持续到00:18.4;实际在00:19.1时Nia已经受到攻击。",
"EXPECTED": "预计过程",
"ACTUAL": "实际过程",
"00:17 Guard 1": "00:17 护卫1",
"00:19 Rear shot blocked": "00:19 后排射击被挡",
"00:22 Stable line": "00:22 队形稳定",
"00:17 Pane chip": "00:17 晶板削减",
"00:18 Guard 0": "00:18 护卫0",
"00:19 Nia hit": "00:19 Nia受击",
"Cause": "原因",
"Second uninterrupted Pane Bite": "第二次晶板啃咬没有被打断",
"Order": "站位",
"Sable remained adjacent": "Sable仍与其相邻",
"Adapt": "下次调整",
"Move or interrupt before bite 2": "第二次啃咬前移动或打断",
"Continue to Reward": "继续领取奖励",
"Full event log": "完整战斗记录",
"FIXED REWARD DRAFT": "固定奖励三选一",
"Choose the next decision you want to change": "选择你希望下一步改变的战术决策",
"Quiet Rearplate": "静默后甲",
"Narrow Vanguard": "精简先锋",
"Farstring insight": "远弦职业领悟",
"Converts first area hit to direct": "将第一次范围伤害改为单体伤害",
"One fewer deploy; front starts Guarded": "少上一名队员;前排开局获得护卫",
"Opens a profession trial": "解锁一次职业试炼",
"Selected": "已选择",
"Compare": "比较",
"Claim Quiet Rearplate": "领取静默后甲",
"Compare build": "比较构筑",
"COMMITTED LOSS": "已确定的永久损失",
"FALLEN": "阵亡",
"Farstring · Patient Start · Level 3 · survived 4 battles": "远弦射手 · 耐心起手 · 3级 · 曾存活4场战斗",
"Returned to inventory": "退回仓库",
"Party gap": "队伍缺口",
"Rear pressure lost": "失去后排输出能力",
"Replacement Credit": "补员凭证",
"+1 tier": "+1档",
"Death is permanent for this run. No relationship or personal-arc system is implied.": "本轮远征中阵亡不可逆;游戏不包含关系或个人剧情系统。",
"Acknowledge loss": "确认损失",
"Inspect receipt": "查看回执",
"RECOVERY": "恢复方案",
"Company remains viable": "队伍仍可继续远征",
"Replacement Credit reduces only a later hire. Lost level, Trait, profession and history stay lost.": "补员凭证只能降低后续招募费用;失去的等级、特性、职业和战绩不会恢复。",
"Protected base hire": "优惠招募基础队员",
"46 → 28 Coin · consumes 1 Credit": "46 → 28金币 · 消耗1张补员凭证",
"Return with a new base profession at Market.": "回到集市并招募一名新的基础职业队员。",
"Continue short-handed": "缺员继续",
"0 Coin · Company 3 / capacity 4": "0金币 · 当前3人 / 上限4人",
"Keep resources and accept reduced coverage.": "保留资源,但接受战术覆盖不足。",
"Abandon run": "放弃本轮远征",
"Run-close transaction": "结束远征操作",
"Retain history and committed progression only.": "只保留战绩和已经确认的永久进度。",
"Commit protected hire": "确认优惠招募",
"Review consequences": "查看后果",
"Primary": "主要状态",
"Loading / empty": "加载或空状态",
"Error / confirm": "错误或确认状态",
"Return": "中断返回",
"Return to first Market": "返回首个集市",
"Loading": "加载",
"Error": "错误",
"COMPOSED": "已构图",
"Purpose": "页面目的",
"Primary action": "主要操作",
"Safe state": "中断后状态",
"System": "系统",
"Guild": "公会",
"Market": "集市",
"Roster": "成员",
"Artifacts": "神器",
"Party": "队伍",
"Risk": "风险",
"Battle": "战斗",
"Outcome": "结算",
"Progress": "成长",
"Postgame": "终局",
"Settings": "设置",
"All 50": "全部50页",
"All 42": "全部42个",
"12 base": "12个基础职业",
"24 advanced": "24个进阶职业",
"6 hidden": "6个隐藏职业",
"All 36": "全部36个",
"All 320": "全部320件",
"All 60": "全部60件",
"All 100": "全部100种",
"All 16": "全部16名",
"Ordinary": "普通敌人",
"Elite": "精英敌人",
"Search ID, name or mechanic": "搜索编号、名称或规则",
"No filler rows": "无占位内容",
"Professions": "职业",
"Traits": "特性",
"Enemies": "敌人",
"Bosses": "首领",
"Regions": "区域",
"Six critical review groups": "六组关键体验审核",
"Open group ›": "打开该组 ›",
};
const screenPurposeZh = {
SYS: "检查启动、设置、存档与远征创建是否清楚可靠。",
HUB: "查看当前威胁、队伍准备情况、资源与下一处可去地点。",
SHP: "比较队员或装备的费用、收益、代价以及对当前队伍的影响。",
REC: "查看队员状态、职业、特性、装备、位置与存活记录。",
EQP: "比较装备前后的具体行为变化,确认后才改变配置。",
ART: "查看影响整轮远征的神器规则、冲突和持续时间。",
PTY: "从左侧后排到右侧前排排列队伍,并确认保护、射程和承伤关系。",
RSK: "比较已知威胁、奖励、费用、撤退代价和当前队伍覆盖能力。",
BAT: "观察自动战斗的目标、因果、站位、事件顺序,并可暂停或查看详情。",
OUT: "理解战斗结果、关键失败原因、损失、奖励与恢复选择。",
PRO: "查看本轮成长、永久解锁以及新开放的策略选择。",
PST: "查看终局挑战规则、结果与下一次可尝试的方案。",
SET: "调整显示、文字、动效、声音、控制、语言和存档选项。",
};
function localizedText(value) {
const raw = String(value ?? "");
if (state.locale !== "zh_CN") return raw;
const leading = raw.match(/^\s*/)?.[0] || "";
const trailing = raw.match(/\s*$/)?.[0] || "";
const core = raw.trim();
if (!core) return raw;
if (zhText[core]) return `${leading}${zhText[core]}${trailing}`;
return raw;
}
function localizeRenderedContent(root = document) {
if (state.locale !== "zh_CN") return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach((node) => { node.nodeValue = localizedText(node.nodeValue); });
$$('[placeholder], [title], [aria-label]', root).forEach((element) => {
for (const attribute of ["placeholder", "title", "aria-label"]) {
if (element.hasAttribute(attribute)) element.setAttribute(attribute, localizedText(element.getAttribute(attribute)));
}
});
}
function screenPurpose(screen) {
if (state.locale !== "zh_CN") return screen.purpose;
return screenPurposeZh[screen.id.split("-")[0]] || `${screenName(screen)}页面的用途与主要决策。`;
}
function screenAction(screen) {
if (state.locale !== "zh_CN") return screen.action;
return `执行“${screenName(screen)}”页面中高亮的主要操作;确认前不会改变已保存状态。`;
}
function screenSafeState(screen) {
if (state.locale !== "zh_CN") return screen.interruptionReturn;
return "中断时保留最近一次已确认状态;返回后先复核,再继续执行。";
}
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(localizedText(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(localizedText(secondary))}</button><button class="stage-primary" type="button" ${attrs}>${escapeHtml(localizedText(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>${localizedText(offer.name)}</strong><em>${localizedText(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>${localizedText(offer.name)}</strong><span class="profession-seal">${localizedText(offer.profession)}</span><p><b>${localizedText(offer.trait)}</b> - ${localizedText(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>${localizedText(offer.name)}</h2><h3>${localizedText(offer.profession)} · ${localizedText(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>${localizedText(offer.read)}。${state.locale === "zh_CN" ? "随机生成身份;每人只有一个机械特性,不包含角色关系或个人剧情。" : "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>${localizedText(offer.name)}</strong><em>${localizedText(offer.profession)} · ${localizedText(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>${localizedText(offers[0].name)}</h3><p>${localizedText(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 ? localizedText("Review Recruit") : localizedText("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>${localizedText(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>${localizedText(risk[0])}</strong><small>${localizedText(risk[1])}</small><div><em>${localizedText(risk[2])}</em><b>${localizedText(risk[3])} ${localizedText("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><strong class="battle-life-rule">NO COMBAT RESPAWN · NO RESERVE AUTO-ENTRY</strong></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>${escapeHtml(truncate(screenPurpose(screen), 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(localizedText(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] || [screenPurpose(screen), screenAction(screen), screenSafeState(screen)];
const primary = state.locale === "zh_CN" ? `打开${screenName(screen)}` : truncate(screen.action.split(";")[0], 46);
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>${escapeHtml(truncate(screenPurpose(screen), 180))}</p>${details.map((detail) => `<div class="sheet-row">${escapeHtml(localizedText(detail))}</div>`).join("")}</div>${actionDock(primary, 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 = state.locale === "zh_CN"
? (variant === "loading" ? "正在加载当前页面;没有可用内容时会显示明确的空状态和返回路径。" : variant === "error" ? "操作未完成;请先核对发生变化的内容,再决定重试或返回。" : screenSafeState(screen))
: (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} · ${state.locale === "zh_CN" ? "页面操作" : 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(screenPurpose(screen))}</p></div><div><span>Primary action</span><p>${escapeHtml(screenAction(screen))}</p></div><div><span>Safe state</span><p>${escapeHtml(screenSafeState(screen))}</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);
localizeRenderedContent($("#content"));
}
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();