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

1637 lines
148 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: "playtest",
locale: "zh_CN",
screenId: "SYS-005",
screenGroup: "ALL",
screenVariant: "primary",
flowIndex: 1,
regionIndex: 0,
catalogType: "professions",
catalogFilter: "all",
catalogQuery: "",
referenceIndex: 2,
openingSelected: [],
experienceStep: "contract",
experienceContract: null,
experienceDifficulty: null,
experienceSelected: [],
experienceInspected: null,
experienceInspectedIds: [],
experienceNotice: "",
experienceCommitted: false,
partySwapped: false,
battlePaused: false,
battleSpeed: 1,
charterRegionIndex: 2,
charterPacketIndex: 0,
chronicleChoice: "chronicle",
oathLaws: ["measured-supply"],
settings: {
contrast: false,
reducedMotion: false,
captions: true,
lowPower: false,
textScale: "100",
},
};
const copy = {
en: {
brandSubtitle: "Phased experience prototype",
navPlaytest: "Play P8.1",
navFirst: "First-play map",
navScreens: "Screen atlas",
navRegions: "Regions",
navCatalogs: "Catalogs",
navReference: "Reference map",
navReview: "Review gates",
runtimeFrozen: "Godot development frozen",
conceptBadge: "P8.1 prototype",
playtest: "Play P8.1",
firstplay: "First play",
screens: "Screen atlas",
regions: "Regions",
catalogs: "Catalogs",
reference: "Reference map",
review: "Review gates",
prev: "Previous",
next: "Next decision",
back: "Back",
inspect: "Inspect",
compare: "Compare",
concept: "Production UI - native text + traceable candidate art",
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: "分阶段体验原型",
navPlaytest: "体验 P8.1",
navFirst: "首次流程图",
navScreens: "页面图谱",
navRegions: "八大区域",
navCatalogs: "完整图鉴",
navReference: "参考映射",
navReview: "审核闸门",
runtimeFrozen: "Godot 开发仍冻结",
conceptBadge: "P8.1 体验原型",
playtest: "体验 P8.1",
firstplay: "首次游玩",
screens: "页面图谱",
regions: "八大区域",
catalogs: "完整图鉴",
reference: "参考映射",
review: "审核闸门",
prev: "上一步",
next: "下一决策",
back: "返回",
inspect: "查看",
compare: "比较",
concept: "生产级 UI - 原生文字 + 可追踪候选素材",
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": "区域",
"Seven critical review groups": "七组关键体验审核",
"Open group ›": "打开该组 ›",
"Opening company": "开局队伍",
"Market hire": "集市招募",
"Formation and equip": "站位与装备",
"Horizontal battle": "横向自动战斗",
"Result and recovery": "结果与恢复",
"Catalog and settings": "图鉴与设置",
"Charter and final choice": "公会宪章与终章选择",
"Four stable offers, exactly-two gating, revision, atomic receipt and first-Market handoff.": "四名固定候选、恰好选两人、原子回执与首个集市交接。",
"Two separate affordable base hires move Company 2 → 3 → 4 while capacity remains 4.": "两次独立且可负担的基础招募让队伍从2人增至3人再增至4人,出战容量始终为4。",
"Behavior delta and one rear-left to front-right line with exposed position 0.": "展示装备行为变化,以及从左后排到右前排、0号位暴露的单横列。",
"Automatic execution, one ordered line, urgent cause, pause, speed, inspect and retreat review.": "自动执行、单横列、紧急原因、暂停、速度、查看与撤退审核。",
"Earliest divergence precedes totals; loss, reward, recovery and abandonment remain distinct.": "先解释最早分歧,再显示汇总;损失、奖励、恢复与放弃彼此独立。",
"Searchable current counts plus EN/zh_CN, 130%, contrast, captions, reduced motion and input states.": "可搜索完整数量,并检查中英文、130%文字、对比度、字幕、减少动效和输入状态。",
"Spend Renown only for breadth, then compare finite closure with a new generated Oath Season run.": "声望只购买玩法广度,并比较有限结局与生成全新队伍的誓约季。",
};
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 actorAssets = {
Hearthguard: "assets/game/actors/hearthguard.png",
Farstring: "assets/game/actors/farstring.png",
Wayrunner: "assets/game/actors/wayrunner.png",
Kindhand: "assets/game/actors/kindhand.png",
Riftblade: "assets/game/actors/riftblade.png",
TempestSeraph: "assets/game/actors/tempest-seraph.png",
};
const equipmentAssets = {
"Stepback Soles": "assets/game/equipment/eq_001_stepback_soles@1x.png",
"Frontpin Greaves": "assets/game/equipment/eq_002_frontpin_greaves@1x.png",
"Passing Sash": "assets/game/equipment/eq_003_passing_sash@1x.png",
"Gapfinder Sandals": "assets/game/equipment/eq_004_gapfinder_sandals@1x.png",
"Leadweight Belt": "assets/game/equipment/eq_005_leadweight_belt@1x.png",
"Rearhook Cloak": "assets/game/equipment/eq_006_rearhook_cloak@1x.png",
"Pivot Anklet": "assets/game/equipment/eq_007_pivot_anklet@1x.png",
"Stillroad Wrap": "assets/game/equipment/eq_008_stillroad_wrap@1x.png",
"Rescue Loop": "assets/game/equipment/eq_009_rescue_loop@1x.png",
"Forward Claim": "assets/game/equipment/eq_010_forward_claim@1x.png",
"Neighbor's Buckle": "assets/game/equipment/eq_011_neighbors_buckle@1x.png",
"Open-Line Cord": "assets/game/equipment/eq_012_open_line_cord@1x.png",
};
const vfxAssets = {
threat: "assets/game/vfx/threat_imminent@1x.png",
damage: "assets/game/vfx/damage_heavy@1x.png",
protect: "assets/game/vfx/protect_intercept@1x.png",
tempest: "assets/game/vfx/tempest_phase_imminent@1x.png",
};
const generatedOffers = [
{ id: "OFF-01", name: "Elian Rook", profession: "Hearthguard", trait: "Narrow Guard", read: "Intercepts a neighbor / slower steps", mark: "HG", art: actorAssets.Hearthguard, level: 1, xp: 0, xpNext: 30, itemId: "EQ-002", item: "Frontpin Greaves" },
{ id: "OFF-02", name: "Nia Orrow", profession: "Farstring", trait: "Patient Start", read: "Rear pressure / delayed first basic", mark: "FS", art: actorAssets.Farstring, level: 1, xp: 0, xpNext: 30, itemId: "EQ-014", item: "Quiet Rearplate" },
{ id: "OFF-03", name: "Tomas Vale", profession: "Wayrunner", trait: "Open Stance", read: "Reorders safely / briefly Exposed", mark: "WR", art: actorAssets.Wayrunner, level: 1, xp: 0, xpNext: 30, itemId: "EQ-001", item: "Stepback Soles" },
{ id: "OFF-04", name: "Sable Kest", profession: "Kindhand", trait: "Shared Cup", read: "Restores a neighbor / weaker self-heal", mark: "KH", art: actorAssets.Kindhand, level: 1, xp: 0, xpNext: 30, itemId: "EQ-009", item: "Rescue Loop" },
];
const openingContract = {
id: "CTR-R1-01",
name: ["First Crossing", "首次穿越"],
region: ["Windglass Verge", "风镜原野"],
objective: ["Chart the suspended pane crossing", "勘察悬浮晶板之间的安全通路"],
pressure: ["Push + projectile screen", "击退与远程屏障"],
boss: ["Rotating damage-family panes", "轮换不同伤害类型的晶板"],
reward: ["Base professions + opening tools", "基础职业与开局装备"],
};
const openingDifficulties = [
{ id: "wayfinder", name: ["Wayfinder", "向导"], stats: ["Enemy HP 90% · damage 90% · rewards 100%", "敌人生命90% · 伤害90% · 奖励100%"], audience: ["Learn every system with lower combat pressure", "保留全部系统,以较低战斗压力学习"] },
{ id: "standard", name: ["Standard", "标准"], stats: ["Enemy HP 100% · damage 100% · rewards 100%", "敌人生命100% · 伤害100% · 奖励100%"], audience: ["Authored first-clear baseline", "首次通关的标准平衡"] },
{ id: "oathbound", name: ["Oathbound", "誓约"], stats: ["Enemy HP 110% · damage 112% · rewards 115%", "敌人生命110% · 伤害112% · 奖励115%"], audience: ["Prepared high-risk play", "适合已有准备的高风险玩法"] },
];
const experienceSteps = [
["contract", "1", "Contract", "契约与难度"],
["offers", "2", "Company", "选择初始队伍"],
["review", "3", "Review", "创建前确认"],
["receipt", "4", "Receipt", "创建回执"],
];
const currentRecruitProfiles = {
"OFF-01": { level: 4, xp: 36, xpNext: 60, itemId: "EQ-002", item: "Frontpin Greaves" },
"OFF-02": { level: 3, xp: 44, xpNext: 50, itemId: "EQ-014", item: "Quiet Rearplate" },
"OFF-03": { level: 2, xp: 18, xpNext: 40, itemId: "EQ-001", item: "Stepback Soles" },
"OFF-04": { level: 2, xp: 32, xpNext: 40, itemId: "EQ-009", item: "Rescue Loop" },
};
function recruitProfile(offer, current = false) {
return currentRecruitProfiles[offer.id] && current
? { ...offer, ...currentRecruitProfiles[offer.id] }
: { level: 1, xp: 0, xpNext: 30, itemId: null, item: null, ...offer };
}
function compactRecruitBuild(profile) {
return `${bi("LV", "等级")} ${profile.level} · ${profile.itemId || bi("EMPTY ITEM", "无装备")}`;
}
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": ["Renown 34", "Eight Region seals", "Breadth only - no permanent power"],
"PST-001": ["Final boss settled", "Company archives on both choices", "Ordinary New Run remains"],
"PST-002": ["Optional Oath laws", "New generated Company", "Ordinary New Run remains"],
"PST-003": ["Oath challenge cleared", "Rules that mattered: 2", "Record and Renown receipts applied"],
};
const guildCharterRegions = [
["RG-01", "Windglass Verge", "风镜原野"],
["RG-02", "Ember Archive", "余烬典库"],
["RG-03", "Rainbell Reach", "雨钟长境"],
["RG-04", "Violet Causeway", "紫辉堤道"],
["RG-05", "Hollow Meridian", "空鸣子午线"],
["RG-06", "Sunken Loom", "沉没织庭"],
["RG-07", "Starwake Crown", "星醒王冠"],
["RG-08", "Aetherbound Spire", "以太盟誓塔"],
];
const charterPackets = [
{ id: "CH-R3-CATALOG", cost: 8, family: ["Catalog packet", "图鉴扩展包"], title: ["Rainline Implements", "雨线器具"], effect: ["Adds six sidegrade equipment rows to eligible future Markets.", "向未来符合条件的集市加入六件侧向选择装备。"], prerequisite: ["Region 03 first Contract clear", "完成第三区域首个契约"] },
{ id: "CH-R3-THREAT", cost: 10, family: ["Threat archive", "威胁档案"], title: ["Pressure Forecast II", "压力预报 II"], effect: ["Shows displacement order for encountered Rainbell enemy families.", "显示已遭遇雨钟敌人家族的位移顺序。"], prerequisite: ["Inspect three Rainbell enemy families", "查看三类雨钟敌人"] },
{ id: "CH-R3-OATH", cost: 16, family: ["Oath law", "誓约法则"], title: ["Measured Supply", "定量补给"], effect: ["Adds one optional postgame Market law with its completion Record.", "加入一条可选终局集市法则及其完成记录。"], prerequisite: ["Complete both Region 03 Contracts", "完成第三区域的两个契约"] },
];
const oathLawCatalog = [
{ id: "measured-supply", title: ["Measured Supply", "定量补给"], effect: ["Markets show fewer offers; each lock costs less.", "集市报价更少,但每次锁定费用降低。"], status: ["Eligible", "可用"] },
{ id: "broken-front", title: ["Broken Front", "破阵前线"], effect: ["Front position begins Exposed; rear control rewards increase.", "前排开局处于暴露状态,后排控制奖励提高。"], status: ["Eligible", "可用"] },
{ id: "named-pressure", title: ["Named Pressure", "具名压力"], effect: ["One elite family is revealed before each risk choice.", "每次风险选择前揭示一个精英敌人家族。"], status: ["Charter clue 1/1", "宪章线索 1/1"] },
];
const referenceMappings = [
{ id: "REF-01", title: ["Title and run entry", "标题与远征入口"], evidence: "PROVEN", target: "SYS-003", variant: "primary", template: "entry", zones: [["top", "Save / version", "存档与版本"], ["main", "New / Continue", "新远征 / 继续"], ["side", "Persistent systems", "永久系统入口"], ["actions", "One clear start", "一个明确开始操作"]], options: ["Continue · New Run · Records · Settings", "继续 · 新远征 · 记录 · 设置"], adopt: ["One first-viewport run decision with save health.", "首屏只突出一次远征决策,并显示存档健康状态。"], change: ["Original guild-threshold composition and hierarchy.", "使用原创公会门厅构图与层级。"], reject: ["Reference title ornament, logo and wording.", "参考游戏的标题装饰、标志和文案。"] },
{ id: "REF-02", title: ["Difficulty and run setup", "难度与远征设置"], evidence: "PROVEN", target: "SYS-005", variant: "contract", template: "choice", zones: [["top", "Run consequences", "远征后果"], ["main", "Few large choices", "少量大选项"], ["lower", "Opening setup", "开局设置"], ["actions", "Review then commit", "审核后确认"]], options: ["Difficulty · slot · opening offers", "难度 · 存档位 · 初始候选"], adopt: ["Failure rules and opening state are visible before commit.", "确认前显示失败规则和开局状态。"], change: ["Four stable generated Recruit offers follow Contract review.", "契约审核后提供四名固定生成队员候选。"], reject: ["Copied clan identities or difficulty prose.", "照搬氏族身份或难度文案。"] },
{ id: "REF-03", title: ["Integrated Market", "队伍与库存同屏集市"], evidence: "PROVEN", target: "SHP-001", variant: "company4", template: "market", zones: [["top", "Company bench", "队伍席位"], ["main", "Three offer cards", "三张报价卡"], ["lower", "Run inventory", "本轮库存"], ["side", "Shop modes", "商店模式"], ["actions", "Refresh / lock / next", "刷新 / 锁定 / 下一步"]], options: ["Recruit · inspect · buy · refresh · lock · sell", "招募 · 查看 · 购买 · 刷新 · 锁定 · 出售"], adopt: ["Company, offers, inventory and resources stay visible together.", "队伍、报价、库存与资源保持同屏。"], change: ["Generated-Recruit role/Tradeoff and Coin/Credit economy.", "采用生成队员的职业/代价信息与金币/补员凭证经济。"], reject: ["Copied frames, symbols, exact geometry and price values.", "照搬边框、符号、精确几何与价格数值。"] },
{ id: "REF-04", title: ["Recruit inspect and buy", "队员查看与招募"], evidence: "PROVEN", target: "SHP-002", variant: "hire1", template: "inspect", zones: [["top", "Offer identity", "候选身份"], ["main", "Role and stats", "职责与属性"], ["lower", "Company impact", "对队伍的影响"], ["actions", "Inspect / buy", "查看 / 招募"]], options: ["Profession · Trait · price · fit", "职业 · 特性 · 价格 · 适配度"], adopt: ["Inspect precedes purchase and shows Company delta.", "购买前先查看,并显示队伍变化。"], change: ["Exactly one visible Trait with benefit and cost.", "每名队员只有一个明确收益与代价的特性。"], reject: ["Fixed commercial identity or relationship content.", "固定商业角色身份或关系内容。"] },
{ id: "REF-05", title: ["Inventory, equipment and sell", "库存、装备与出售"], evidence: "PROVEN", target: "EQP-002", variant: "primary", template: "inventory", zones: [["top", "Current wearer", "当前装备者"], ["main", "Slot grid", "物品槽位网格"], ["lower", "Behavior delta", "行为变化"], ["side", "Selling mode", "出售模式"], ["actions", "Apply / cancel", "应用 / 取消"]], options: ["Inspect · equip · compare · sell review", "查看 · 装备 · 比较 · 出售审核"], adopt: ["Persistent capacity and focused tooltip reduce navigation.", "持续显示容量与聚焦详情,减少页面跳转。"], change: ["Equipment comparisons lead with behavior, target and tradeoff.", "装备比较优先显示行为、目标与代价变化。"], reject: ["Copied rarity palette, items or sell formula.", "照搬稀有度配色、物品或出售公式。"] },
{ id: "REF-06", title: ["Ordered Company line", "横向队伍顺序"], evidence: "PROVEN", target: "PTY-001", variant: "primary", template: "line", zones: [["top", "Stable positions", "固定位置"], ["main", "One horizontal line", "一条横向队列"], ["lower", "Reserve / equipment", "替补 / 装备"], ["actions", "Tap then destination", "点击后再点目标位"]], options: ["Select · inspect · swap · undo", "选择 · 查看 · 换位 · 撤销"], adopt: ["Formation is a direct, repeated build decision.", "站位是直接且反复发生的构筑决策。"], change: ["Protected rear is always left and exposed front right.", "受保护后排始终在左,暴露前排始终在右。"], reject: ["Locale mirroring or mandatory precision drag.", "语言镜像或必须精准拖动。"] },
{ id: "REF-07", title: ["Three-card encounter choice", "三卡遭遇选择"], evidence: "PROVEN", target: "RSK-001", variant: "primary", template: "choice", zones: [["top", "Current Company", "当前队伍"], ["main", "Three encounters", "三个遭遇"], ["lower", "Threat / reward compare", "威胁 / 奖励比较"], ["actions", "Review chosen risk", "审核所选风险"]], options: ["Enemies · reward · exposure · retreat", "敌人 · 奖励 · 暴露风险 · 撤退"], adopt: ["All three options remain adjacent and comparable.", "三个选项始终相邻并可比较。"], change: ["Current coverage and retreat consequence are explicit.", "明确显示当前应对能力和撤退后果。"], reject: ["Copied card backs, enemy names or bounty formula.", "照搬卡背、敌人名称或赏金公式。"] },
{ id: "REF-08", title: ["Automatic horizontal battle", "横向自动战斗"], evidence: "PROVEN", target: "BAT-002", variant: "primary", template: "battle", zones: [["top", "Objective / counts", "目标 / 数量"], ["main", "Company vs enemies", "队伍对抗敌人"], ["lower", "Unit state panels", "单位状态条"], ["side", "Observation tools", "观察工具"], ["actions", "Pause / speed / inspect", "暂停 / 速度 / 查看"]], options: ["Automatic actions · forecast · event log", "自动行动 · 预告 · 事件记录"], adopt: ["Battle is the central visual payoff of preparation.", "战斗是前期准备的核心视觉回报。"], change: ["Causal forecast and permanent-casualty rule stay readable.", "保持因果预告和永久阵亡规则可读。"], reject: ["Manual attack or ultimate buttons and copied HUD art.", "手动攻击/终极技能按钮及照搬HUD美术。"] },
{ id: "REF-09", title: ["Battle observation controls", "战斗观察控制"], evidence: "PROVEN", target: "BAT-003", variant: "primary", template: "battle", zones: [["top", "Urgent state", "紧急状态"], ["main", "Battle remains visible", "战斗保持可见"], ["side", "Inspect panel", "查看面板"], ["actions", "Pause / 1x / 2x / 4x / retreat", "暂停 / 1x / 2x / 4x / 撤退"]], options: ["Inspect cause · pin event · resume", "查看原因 · 固定事件 · 继续"], adopt: ["Observation controls never replace the arena.", "观察控制不会遮蔽战斗主体。"], change: ["Pinned causal event explains target and order effect.", "固定因果事件解释目标与站位影响。"], reject: ["Hidden targeting or pointer-only shortcuts.", "隐藏目标选择或仅鼠标可用的快捷操作。"] },
{ id: "REF-10", title: ["Victory report and reward", "胜利报告与奖励"], evidence: "PROVEN", target: "OUT-001", variant: "primary", template: "report", zones: [["top", "Outcome", "结果"], ["main", "Unit result rows", "单位结果行"], ["lower", "Reward / next decision", "奖励 / 下一决策"], ["actions", "Continue to build", "继续构筑"]], options: ["Diagnosis · losses · reward · next Market", "诊断 · 损失 · 奖励 · 下一集市"], adopt: ["Result immediately points to the next build decision.", "结果立即指向下一次构筑决策。"], change: ["First meaningful divergence appears before totals.", "汇总前先显示第一个关键分歧。"], reject: ["Copied report ornament, reward identities or formulas.", "照搬报告装饰、奖励身份或公式。"] },
{ id: "REF-11", title: ["Game Over and persistent handoff", "失败结算与永久成长交接"], evidence: "PROVEN", target: "OUT-005", variant: "primary", template: "terminal", zones: [["top", "Terminal reason", "终止原因"], ["main", "Company result", "队伍结果"], ["lower", "Persistent award", "永久奖励"], ["actions", "Progression / title / new run", "成长 / 标题 / 新远征"]], options: ["Finish · inspect Records · spend Renown", "结束 · 查看记录 · 使用声望"], adopt: ["Failure still advances knowledge and breadth.", "失败仍推进知识与玩法广度。"], change: ["Closure receipt separates retained and cleared state.", "结束回执分开说明保留与清除内容。"], reject: ["Mandatory victory farming or inferred reward formula.", "强制刷胜场或推测奖励公式。"] },
{ id: "REF-12", title: ["Persistent path spending", "永久路径消费"], evidence: "PROVEN", target: "PRO-002", variant: "primary", template: "graph", zones: [["top", "Persistent balance", "永久余额"], ["main", "Prerequisite paths", "前置路径"], ["lower", "Node preview", "节点预览"], ["actions", "Inspect cost / claim", "查看费用 / 领取"]], options: ["Path · prerequisite · cost · future effect", "路径 · 前置条件 · 费用 · 未来效果"], adopt: ["Spending visibly changes a persistent planning surface.", "消费会在永久规划界面产生可见变化。"], change: ["Eight horizontal Region seals and breadth-only packets.", "改为八个横向区域印章与仅扩展广度的内容包。"], reject: ["Radial graph, copied nodes, raw power and unknown refunds.", "放射图、照搬节点、永久数值和未知退款机制。"] },
{ id: "REF-13", title: ["Forge, storage and veteran operation", "锻造、仓储与资深操作"], evidence: "LOWER_BOUND", target: "EQP-001", variant: "primary", template: "operation", zones: [["top", "Owned inventory", "已拥有库存"], ["main", "Recipe / target review", "配方 / 目标审核"], ["lower", "Ingredients and result", "材料与结果"], ["actions", "Confirm operation", "确认操作"]], options: ["Source items · target · conflict · receipt", "来源物品 · 目标 · 冲突 · 回执"], adopt: ["Transactions keep source, target and result visible.", "事务始终显示来源、目标和结果。"], change: ["Equipment Workbench uses deterministic behavior deltas.", "装备工台使用确定性的行为变化。"], reject: ["New crafting economy, copied recipes or probabilities.", "新增锻造经济、照搬配方或概率。"] },
{ id: "REF-14", title: ["Tavern retirement surface", "酒馆退休界面"], evidence: "PROVEN_UI_ONLY", target: "PRO-003", variant: "primary", template: "operation", zones: [["top", "Between-run resource", "局外资源"], ["main", "Veteran slots", "资深成员槽位"], ["lower", "Retirement explanation", "退休说明"], ["actions", "Review irreversible action", "审核不可逆操作"]], options: ["Inspect veteran · preview value · review", "查看资深成员 · 预览价值 · 审核"], adopt: ["A completed Company needs a readable historical home.", "完成远征的队伍需要可读的历史归宿。"], change: ["Archive generated Company into Records only.", "仅把生成队伍存入历史记录。"], reject: ["Unproven XP conversion, equipment disposal or save behavior.", "未证明的经验转换、装备处置或存档行为。"] },
{ id: "REF-15", title: ["Raid, final boss and final choice", "大型首领、最终战与终章选择"], evidence: "PROVEN", target: "PST-001", variant: "primary", template: "terminal", zones: [["top", "Boss preview", "首领预览"], ["main", "Large automatic fight", "大型自动战斗"], ["lower", "Victory report", "胜利报告"], ["actions", "Finish / continue", "完成 / 继续"]], options: ["Boss rules · reward · completion · postgame", "首领规则 · 奖励 · 完成 · 终局后玩法"], adopt: ["Final victory leads to two explicit motivations.", "最终胜利后提供两个明确动机。"], change: ["Both paths archive Company; Oath Season generates a new run.", "两个路径都会存档队伍;誓约季生成全新远征。"], reject: ["Copied Raid page, boss, reward or endless current Company.", "照搬大型首领页、首领、奖励或无限延续当前队伍。"] },
];
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;
const bi = (en, zh) => state.locale === "zh_CN" ? zh : en;
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 actorImage(offer, className = "actor-art") {
const art = offer.art || actorAssets[offer.profession] || actorAssets.Hearthguard;
return `<img class="${className}" src="${art}" alt="${escapeHtml(localizedText(offer.profession))}" />`;
}
function equipmentImage(name, className = "equipment-art") {
const art = equipmentAssets[name] || equipmentAssets["Stepback Soles"];
return `<img class="${className}" src="${art}" alt="${escapeHtml(localizedText(name))}" />`;
}
function effectImage(kind, className = "battle-vfx") {
return `<img class="${className}" src="${vfxAssets[kind]}" alt="" aria-hidden="true" />`;
}
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, current = false) {
const profile = recruitProfile(offer, current);
return `<article class="recruit-marker ${selected ? "is-selected" : ""}"><span class="recruit-silhouette">${actorImage(offer, "recruit-marker-art")}</span><div><small>${offer.id}</small><strong>${localizedText(offer.name)}</strong><em>${localizedText(offer.profession)} · ${compactRecruitBuild(profile)}</em></div></article>`;
}
function recruitOffer(offer, selected) {
const profile = recruitProfile(offer);
return `<article class="recruit-offer ${selected ? "is-selected" : ""}">
<div class="offer-figure">${actorImage(offer, "offer-character")}<span class="offer-role-mark">${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><div class="offer-loadout"><span>${bi("LV", "等级")} ${profile.level}</span><span>${profile.itemId} · ${localizedText(profile.item)}</span></div><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 profile = recruitProfile(offer);
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="recruit-level-strip"><strong>${bi("INDIVIDUAL LEVEL", "角色独立等级")} · LV ${profile.level}</strong><span>XP ${profile.xp} / ${profile.xpNext}</span><span>${bi("ONE EQUIPMENT SLOT", "唯一装备槽")} · ${profile.itemId} ${localizedText(profile.item)}</span></div>
<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">${equipmentImage("Stepback Soles", "item-offer-art")}<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 company = generatedOffers.slice(0, count);
const inventory = [["EQ-001", "⇣"], ["EQ-014", "▰"], ["AR-011", "◇"], ["EQ-028", "⌁"]];
const body = `<div class="market-composition reference-informed-market">
<div class="market-heading"><div><span class="stage-kicker">${bi("INTEGRATED MARKET", "一体化集市")}</span><h2>${bi("Company, offers and inventory stay visible", "队伍、报价与库存保持同屏")}</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-board">
<section class="market-company-bench"><header><strong>${bi("COMPANY BENCH", "队伍席位")}</strong><span>${bi("Tap Recruit to inspect or reorder", "点击队员查看或调整顺序")}</span></header><div>${company.map((offer, index) => { const profile = recruitProfile(offer, true); return `<button type="button" class="bench-recruit"><span>${actorImage(offer, "bench-actor-art")}</span><strong>${localizedText(offer.name)}</strong><small>LV ${profile.level} · ${profile.itemId} · ${index === count - 1 ? bi("Front", "前排") : bi(`Position ${count - 1 - index}`, `${count - 1 - index}号位`)}</small></button>`; }).join("")}${Array.from({ length: 4 - count }, (_, index) => `<button type="button" class="bench-recruit is-empty"><span>+</span><strong>${bi("Open slot", "空席位")}</strong><small>${bi("Hire below", "从下方招募")}</small></button>`).join("")}</div></section>
<nav class="market-tool-rail" aria-label="${bi("Market modes", "集市模式")}"><button class="is-active" type="button">${bi("Recruit", "招募")}</button><button type="button">${bi("Equipment", "装备")}</button><button type="button">${bi("Risk", "风险")}</button><button type="button">${bi("Sell mode", "出售模式")}</button></nav>
<section class="market-offer-cards">${offers.map((offer, index) => { const profile = recruitProfile(offer); return `<button class="market-offer-card ${index === 0 ? "is-focused" : ""}" type="button" ${index === 0 ? "data-flow-next" : ""}><span class="row-avatar">${actorImage(offer, "market-offer-art")}</span><div><small>${offer.id}</small><strong>${localizedText(offer.name)}</strong><em>${localizedText(offer.profession)} · LV ${profile.level} · ${profile.itemId}</em><p>${localizedText(offer.read)}</p></div><b>${index < 2 ? "46" : "58"}<small>${bi("Coin", "金币")}</small></b></button>`; }).join("")}</section>
<section class="market-inventory"><header><strong>${bi("RUN INVENTORY", "本轮库存")}</strong><span>4 / 12 · ${bi("Tap item to compare", "点击物品进行比较")}</span></header><div>${Array.from({ length: 12 }, (_, index) => { const item = inventory[index]; const inventoryNames = ["Stepback Soles", "Frontpin Greaves", "Rescue Loop", "Open-Line Cord"]; return `<button class="inventory-slot ${item ? "is-filled" : ""}" type="button">${item ? equipmentImage(inventoryNames[index], "inventory-item-art") : ""}<small>${item ? item[0] : bi("Empty", "空")}</small></button>`; }).join("")}</div></section>
</div>
<div class="market-footer"><span>${bi("Refresh 18 Coin · next 24", "刷新18金币 · 下次24金币")}</span><button class="stage-secondary">${bi("Lock 0/2", "锁定 0/2")}</button><button class="stage-secondary">${bi("Selling mode off", "出售模式:关闭")}</button><button class="stage-primary" data-flow-next>${count < 4 ? bi("Review focused Recruit", "审核所选队员") : bi("Open Party Line", "打开队伍站位")}</button></div>
</div>`;
return frame(screen, body, { variant });
}
function partyActors() {
const original = [
["3", "Nia", generatedOffers[1], "Rear"], ["2", "Sable", generatedOffers[3], "Protected"], ["1", "Tomas", generatedOffers[2], "Center"], ["0", "Elian", generatedOffers[0], "Exposed"],
];
const swapped = [original[0], original[2], original[1], original[3]];
return (state.partySwapped ? swapped : original).map(([position, name, offer, status], index) => { const profile = recruitProfile(offer, true); return `<button class="party-actor ${position === "0" ? "is-front" : ""}" type="button" data-party-index="${index}" title="${localizedText(status)} · ${localizedText(profile.item)}"><span class="actor-figure">${actorImage(offer, "party-actor-art")}</span><b>${position}</b><strong>${name}<small>${localizedText(status)}</small></strong><em>LV ${profile.level} · ${profile.itemId}</em></button>`; }).join("");
}
function renderPreparation(screen, variant) {
if (screen.id.startsWith("EQP")) {
const comparison = screen.id === "EQP-002";
const equipmentRows = [["Stepback Soles", "EQ-001"], ["Frontpin Greaves", "EQ-002"], ["Rescue Loop", "EQ-009"], ["Open-Line Cord", "EQ-012"]];
const body = `<div class="equipment-composition ${comparison ? "is-comparison" : ""}">
<div class="equipment-list"><span class="stage-kicker">ELIGIBLE INVENTORY</span>${equipmentRows.map(([name, id], index) => `<button class="equipment-row ${index === 0 ? "is-focused" : ""}"><span>${equipmentImage(name, "equipment-row-art")}</span><div><small>${id}</small><strong>${name}</strong><em>Behavior class B</em></div></button>`).join("")}</div>
<div class="equipment-wearer"><span class="one-slot-badge">${bi("ONE EQUIPMENT SLOT", "唯一装备槽")}</span><span class="large-figure">${actorImage(generatedOffers[1], "equipment-wearer-art")}</span><strong>Nia Orrow</strong><small>Farstring · LV 3 · XP 44/50 · position 3</small><div class="equipped-slot"><b>${bi("CURRENT SLOT", "当前装备槽")}</b>${localizedText("Quiet Rearplate")} · EQ-014</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>${bi("Slot after commit", "确认后的装备槽")}</span><b>EQ-001 · ${localizedText("Stepback Soles")}</b></div><div class="delta-row"><span>${bi("Prior item", "原装备")}</span><b>${bi("EQ-014 returns to inventory", "EQ-014 退回本轮库存")}</b></div><div class="delta-row"><span>After first hit</span><b>Step rear</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() {
const enemies = Array.from({ length: 4 }, (_, index) => `<span class="enemy-actor ${index === 3 ? "elite" : ""}"><img src="${actorAssets.Riftblade}" alt="Riftblade" /><small>${index === 3 ? "Prism screen" : index % 2 ? "Guard chip" : "Rear step"}</small></span>`).join("");
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>${effectImage("threat")}</div><div class="enemy-line">${enemies}<span class="boss-actor"><img src="${actorAssets.TempestSeraph}" alt="Tempest Seraph" />${effectImage("tempest", "boss-vfx")}<small>Phase 1</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">${actorImage(generatedOffers[1], "fallen-actor-art")}<i>FALLEN</i></div><div class="loss-record"><span class="stage-kicker">COMMITTED LOSS</span><h2>Nia Orrow</h2><p>Farstring · Patient Start · LV 3 · XP 44/50 · survived 4 battles</p><div class="loss-row"><span>${bi("Individual progress lost", "失去的个人成长")}</span><b>LV 3 · XP 44/50</b></div><div class="loss-row"><span>${bi("One equipped item", "唯一装备")}</span><b>EQ-014 ${bi("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 rewardArt = [equipmentImage("Stillroad Wrap", "reward-item-art"), effectImage("protect", "reward-item-art"), actorImage(generatedOffers[1], "reward-actor-art")];
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">${rewardArt[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 });
}
if (screen.id === "OUT-005") {
const body = `<div class="run-end-composition"><header><span class="outcome-seal">${bi("RUN CLOSED", "远征已结束")}</span><div><small>${bi("COMPANY COLLAPSE · CONTRACT 05", "队伍覆灭 · 契约05")}</small><h2>${bi("The route ends; the Guild keeps what was learned", "本次路线结束,公会保留已获得的知识")}</h2></div></header><section class="run-end-company">${generatedOffers.map((offer) => `<span>${actorImage(offer, "run-end-actor")}<b>${localizedText(offer.profession)}</b></span>`).join("")}</section><div class="run-end-ledger"><article><span>${bi("RETAINED", "永久保留")}</span><strong>${bi("Renown", "声望")} +4</strong><strong>${bi("Discoveries", "发现")} +7</strong><strong>${bi("Records", "历史记录")} +1</strong></article><article class="is-cleared"><span>${bi("CLEARED", "本轮清除")}</span><strong>${bi("Coin", "金币")} 86 → 0</strong><strong>${bi("Credit", "补员凭证")} 1 → 0</strong><strong>${bi("Living Company", "存活队伍")} 0</strong></article><aside><b>${bi("Next meaningful action", "下一步有意义的操作")}</b><p>${bi("Spend Renown on future-run breadth or create an ordinary new run.", "使用声望扩展未来远征的选择,或创建普通新远征。")}</p></aside></div>${actionDock(bi("Open Guild Charter", "打开公会宪章"), bi("Return to Title", "返回标题"))}</div>`;
return frame(screen, body, { variant, resources: false });
}
const body = `<div class="abandon-composition"><section><span class="stage-kicker">${bi("RUN ABANDONMENT REVIEW", "放弃远征审核")}</span><h2>${bi("End this expedition?", "确定结束本轮远征?")}</h2><p>${bi("This is a run-close transaction, not a retreat. It cannot be undone after the receipt is committed.", "这是结束本轮远征的事务,不是撤退;回执提交后无法撤销。")}</p><div class="abandon-impact"><span><b>${bi("Cleared", "清除")}</b>${bi("Coin 214 · Credit 1 · current Company", "金币214 · 补员凭证1 · 当前队伍")}</span><span><b>${bi("Retained", "保留")}</b>${bi("Records · discoveries · committed Guild breadth", "历史记录 · 发现 · 已提交的公会广度")}</span><span><b>${bi("Not granted", "不会获得")}</b>${bi("Battle reward · casualty Credit · completion bonus", "战斗奖励 · 伤亡补员凭证 · 完成奖励")}</span></div></section><aside><span>OUT-006</span><strong>${bi("HOLD TO CONFIRM", "按住确认")}</strong><div class="hold-track"><i></i></div><button class="stage-secondary">${bi("Keep playing", "继续远征")}</button><button class="stage-danger">${bi("Hold to abandon run", "按住放弃远征")}</button></aside></div>`;
return frame(screen, body, { variant, resources: false });
}
function renderRoster(screen, variant) {
if (screen.id === "REC-003") {
const body = `<div class="dismissal-review"><div class="review-figure">${recruitMarker(generatedOffers[2], true, true)}</div><div><span class="stage-kicker">DESTRUCTIVE REVIEW</span><h2>Dismiss Tomas Vale?</h2><div class="sheet-row">LV 2 · XP 18/40 is lost</div><div class="sheet-row">One equipped item · EQ-001 returns</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", level: 2, xp: 6, xpNext: 40, itemId: null, item: null }]);
const focusedOffer = screen.id === "REC-002" ? generatedOffers[1] : generatedOffers[3];
const focusedProfile = recruitProfile(focusedOffer, true);
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, index < 4)}<span>${index < 4 ? `Position ${3 - index}` : "Reserve"}</span></button>`).join("")}</div><aside class="recruit-detail"><span class="large-figure">${actorImage(focusedOffer, "recruit-detail-art")}</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="level-progress"><span>${bi("INDIVIDUAL LEVEL", "角色独立等级")}</span><strong>LV ${focusedProfile.level}</strong><b>XP ${focusedProfile.xp} / ${focusedProfile.xpNext}</b><i style="--xp:${Math.round(focusedProfile.xp / focusedProfile.xpNext * 100)}%"></i></div><div class="detail-grid"><span>Readiness<b>Ready</b></span><span>Position<b>3 · Rear</b></span><span>${bi("One equipment slot", "唯一装备槽")}<b>${focusedProfile.itemId} · ${localizedText(focusedProfile.item)}</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 charterPrerequisite(index, region) {
const name = bi(region[1], region[2]);
if (index === 0) return bi(`Clear ${region[0]} Contract 1`, `完成${name}的首个契约`);
if (index === 1) return bi(`Inspect three ${name} enemy families`, `查看三类${name}敌人`);
return bi(`Complete both ${region[0]} Contracts`, `完成${name}的两个契约`);
}
function renderGuildCharter(screen, variant) {
const region = guildCharterRegions[state.charterRegionIndex];
const packet = charterPackets[state.charterPacketIndex];
const remaining = 34 - packet.cost;
const regionName = bi(region[1], region[2]);
const body = `<div class="charter-composition">
<header class="charter-heading"><div><span class="stage-kicker">${bi("GUILD CHARTER · BREADTH ONLY", "公会宪章 · 仅扩展玩法广度")}</span><h2>${bi("Shape future Companies", "规划未来队伍的选择空间")}</h2><p>${bi("Tap a Region, then Tap a packet. Every claim changes future-run options, never permanent combat power.", "点击区域,再点击扩展包。所有领取只改变未来远征的选择,不增加永久战斗数值。")}</p></div><div class="renown-balance"><span>${bi("RENOWN", "声望")}</span><strong>34</strong><small>${bi("Persistent breadth resource", "永久广度资源")}</small></div></header>
<div class="charter-track" aria-label="${bi("Eight Region seals", "八个区域印章")}">${guildCharterRegions.map((entry, index) => `<button class="charter-seal ${index < 2 ? "is-complete" : ""} ${index === state.charterRegionIndex ? "is-current" : ""}" type="button" data-charter-region="${index}"><span>${entry[0]}</span><strong>${bi(entry[1], entry[2])}</strong><em>${index < 2 ? bi("CLAIMED", "已完成") : index === state.charterRegionIndex ? bi("SELECTED", "已选中") : bi("INSPECT", "查看")}</em></button>`).join("<i></i>")}</div>
<div class="charter-workspace"><section class="charter-packets"><div class="charter-section-title"><span>${region[0]}</span><strong>${regionName}</strong><small>${bi("Tap one packet to preview", "点击一个扩展包查看详情")}</small></div>${charterPackets.map((entry, index) => `<button class="charter-packet ${index === state.charterPacketIndex ? "is-selected" : ""}" type="button" data-charter-packet="${index}"><span>${region[0]}-0${index + 1} · ${bi(entry.family[0], entry.family[1])}</span><strong>${state.charterRegionIndex === 2 ? bi(entry.title[0], entry.title[1]) : `${regionName} · ${bi(entry.family[0], entry.family[1])}`}</strong><small>${charterPrerequisite(index, region)}</small><b>${entry.cost} ${bi("Renown", "声望")}</b></button>`).join("")}</section>
<aside class="charter-review"><span class="stage-kicker">${bi("CLAIM PREVIEW", "领取预览")}</span><h3>${state.charterRegionIndex === 2 ? bi(packet.title[0], packet.title[1]) : `${regionName} · ${bi(packet.family[0], packet.family[1])}`}</h3><p>${bi(packet.effect[0], packet.effect[1])}</p><dl><div><dt>${bi("Prerequisite", "前置条件")}</dt><dd>✓ ${charterPrerequisite(state.charterPacketIndex, region)}</dd></div><div><dt>${bi("Renown", "声望")}</dt><dd>34 → ${remaining}</dd></div><div><dt>${bi("Applies", "生效范围")}</dt><dd>${bi("Future generated runs", "未来新生成的远征")}</dd></div></dl><strong class="no-power-boundary">${bi("NO PERMANENT COMBAT POWER", "不增加永久战斗数值")}</strong><div><button class="stage-secondary" type="button">${bi("Inspect prerequisite", "查看前置条件")}</button><button class="stage-primary" type="button">${bi(`Claim for ${packet.cost} Renown`, `花费${packet.cost}声望领取`)}</button></div></aside>
</div>
</div>`;
return frame(screen, body, { variant, art: false, context: bi("Guild Charter", "公会宪章"), resources: false });
}
function renderChronicleDecision(screen, variant) {
const choice = state.chronicleChoice;
const details = choice === "chronicle"
? [bi("Finite ending and Final Ledger", "观看有限结局与最终账册"), bi("Return to Title", "随后返回标题页")]
: [bi("Enter Oath Season hub", "进入誓约季大厅"), bi("Review laws, then generate a new Company", "审核法则后生成一支全新队伍")];
const body = `<div class="chronicle-composition"><header class="chronicle-heading"><span class="stage-kicker">${bi("FINAL BOSS SETTLED · FIRST CLEAR", "最终首领已结算 · 首次通关")}</span><h2>${bi("How will the Guild record this victory?", "公会将如何记录这场胜利?")}</h2><p>${bi("Both choices close this run and archive the surviving Company.", "两个选择都会结束本轮远征,并把幸存队伍存入历史记录。")}</p></header>
<div class="archive-contract"><span>✓</span><div><strong>${bi("SHARED RESULT", "共同结果")}</strong><p>${bi("Company archived as Records · Coin and Credit close · Renown and discoveries retained · no Recruit remains playable", "队伍存入记录 · 金币与补员凭证清零 · 保留声望与发现 · 不会保留任何可继续使用的队员")}</p></div></div>
<div class="chronicle-choices"><button class="chronicle-choice ${choice === "chronicle" ? "is-selected" : ""}" type="button" data-chronicle-choice="chronicle"><span>01</span><small>${bi("FINITE COMPLETION", "有限结局")}</small><strong>${bi("Complete the First Chronicle", "完成首部编年史")}</strong><p>${bi("Honor this Company in the Final Ledger, then return to Title.", "在最终账册中纪念这支队伍,然后返回标题页。")}</p></button><button class="chronicle-choice ${choice === "oath" ? "is-selected" : ""}" type="button" data-chronicle-choice="oath"><span>02</span><small>${bi("POSTGAME CONTINUATION", "终局后继续")}</small><strong>${bi("Open the Oath Season", "开启誓约季")}</strong><p>${bi("Archive this Company, review optional laws, and start a new generated run.", "存档这支队伍,审核可选法则,并开始一轮全新生成的远征。")}</p></button></div>
<aside class="chronicle-review"><div><span>${bi("SELECTED DESTINATION", "已选去向")}</span>${details.map((entry) => `<strong>✓ ${entry}</strong>`).join("")}</div><button class="stage-secondary" type="button">${bi("Ordinary New Run", "普通新远征")}</button><button class="stage-primary" type="button">${choice === "chronicle" ? bi("Confirm Chronicle", "确认完成编年史") : bi("Confirm Oath Season", "确认开启誓约季")}</button></aside>
</div>`;
return frame(screen, body, { variant, art: false, context: bi("Final Ledger", "最终账册"), resources: false });
}
function renderOathSeason(screen, variant) {
const selected = new Set(state.oathLaws);
const body = `<div class="oath-composition"><header class="oath-heading"><div><span class="stage-kicker">${bi("OATH SEASON · NEW GENERATED RUN", "誓约季 · 全新生成远征")}</span><h2>${bi("Choose laws that change valuation", "选择会改变决策价值的法则")}</h2><p>${bi("The cleared Company is archived and cannot be selected here.", "已通关队伍已存档,无法在这里继续选择。")}</p></div><button class="stage-secondary" type="button">${bi("Ordinary New Run", "普通新远征")}</button></header>
<div class="oath-workspace"><section class="oath-laws">${oathLawCatalog.map((law, index) => `<button class="oath-law ${selected.has(law.id) ? "is-selected" : ""}" type="button" data-oath-law="${law.id}"><span>OATH-0${index + 1}</span><strong>${bi(law.title[0], law.title[1])}</strong><p>${bi(law.effect[0], law.effect[1])}</p><em>${bi(law.status[0], law.status[1])}</em></button>`).join("")}</section><aside class="oath-review"><span class="stage-kicker">${bi("RUN REVIEW", "远征审核")}</span><h3>${selected.size} ${bi("active law(s)", "条启用法则")}</h3><div class="sheet-row">${bi("Difficulty", "难度")} <b>${bi("Standard", "标准")}</b></div><div class="sheet-row">${bi("Save slot", "存档位")} <b>02</b></div><div class="sheet-row">${bi("Opening Company", "初始队伍")} <b>${bi("Four new offers, choose two", "四名新候选中选择两人")}</b></div><div class="sheet-row">${bi("Carryover", "继承内容")} <b>${bi("Charter breadth only", "仅继承宪章广度")}</b></div><strong class="no-carryover">${bi("NO CLEARED RECRUIT CARRYOVER", "不继承已通关队员")}</strong><button class="stage-primary" type="button">${bi("Review and create new run", "审核并创建新远征")}</button></aside></div>
</div>`;
return frame(screen, body, { variant, art: false, context: bi("Oath Season", "誓约季"), resources: false });
}
function renderOathResult(screen, variant) {
const body = `<div class="oath-result-composition"><header><span class="stage-kicker">${bi("OATH SEASON RESULT", "誓约季结果")}</span><h2>${bi("The Company held under changed laws", "队伍在变更法则下完成了远征")}</h2><p>${bi("Result receipts are complete. This generated Company is now a Record.", "结果回执已完成。这支生成队伍现已存入记录。")}</p></header><div class="oath-result-grid"><article><span>01</span><strong>${bi("Measured Supply", "定量补给")}</strong><p>${bi("Fewer offers changed refresh and lock timing in five Markets.", "更少的报价改变了五个集市中的刷新与锁定时机。")}</p></article><article><span>02</span><strong>${bi("Broken Front", "破阵前线")}</strong><p>${bi("Front exposure made rear control the decisive counter twice.", "前排暴露让后排控制两次成为关键应对。")}</p></article><article class="oath-receipts"><strong>${bi("RECEIPTS", "结算回执")}</strong><p>${bi("Oath Record", "誓约记录")} +1</p><p>${bi("Renown", "声望")} +6</p><p>${bi("Archived Company", "存档队伍")} · 8 ${bi("survivors", "名幸存者")}</p></article></div><footer><strong>${bi("The closed Company cannot be resumed.", "已结束的队伍无法继续使用。")}</strong><button class="stage-secondary" type="button">${bi("Final Ledger", "最终账册")}</button><button class="stage-secondary" type="button">${bi("Ordinary New Run", "普通新远征")}</button><button class="stage-primary" type="button">${bi("Return to Oath Season", "返回誓约季")}</button></footer></div>`;
return frame(screen, body, { variant, art: false, context: bi("Oath Result", "誓约结果"), resources: false });
}
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");
if (screen.id === "SYS-004") {
const body = `<div class="run-ledger-composition"><header><span class="stage-kicker">RUN LEDGER</span><h2>${bi("Choose a verified expedition", "选择一个已校验的远征")}</h2><button class="stage-primary">${bi("New Run", "新建远征")}</button></header><div class="run-slots"><button class="run-slot is-focused"><img src="${sceneImages[0]}" alt="" /><span>01</span><div><small>${bi("SAFE RESUME", "可安全继续")}</small><strong>${bi("Windglass Verge · Market", "风镜原野 · 集市")}</strong><p>${bi("Company 5 · Round 3 · 01:42:18", "队伍5人 · 第3轮 · 01:42:18")}</p></div><em>✓ ${bi("Healthy", "健康")}</em></button><button class="run-slot"><img src="${sceneImages[2]}" alt="" /><span>02</span><div><small>${bi("OATH SEASON", "誓约季")}</small><strong>${bi("Rainbell Reach · Recovery", "雨钟长境 · 恢复")}</strong><p>${bi("Company 3 · Round 7 · 03:18:02", "队伍3人 · 第7轮 · 03:18:02")}</p></div><em>! ${bi("Review", "需审核")}</em></button><button class="run-slot is-empty"><span>03</span><div><small>${bi("EMPTY SLOT", "空存档位")}</small><strong>${bi("No expedition", "暂无远征")}</strong><p>${bi("Create a new generated Company", "创建一支新的生成队伍")}</p></div><em>+</em></button></div><aside><b>${bi("Selected destination", "所选去向")}</b><strong>${bi("Resume Review", "继续前审核")}</strong><p>${bi("The game will show the last committed receipt before simulation resumes.", "恢复模拟前会先显示上一笔已提交回执。")}</p><button class="stage-primary">${bi("Review Slot 01", "审核存档01")}</button></aside></div>`;
return frame(screen, body, { variant, resources: false });
}
if (screen.id === "SYS-006") {
const body = `<div class="resume-composition"><section><span class="stage-kicker">${bi("RESUME REVIEW", "继续前审核")}</span><h2>${bi("You stopped after equipment returned", "你在装备退回后离开")}</h2><p>${bi("No battle event or purchase is pending.", "没有待处理的战斗事件或购买操作。")}</p><div class="resume-timeline"><span class="is-done">✓ ${bi("Battle finalized", "战斗已结算")}</span><i></i><span class="is-done">✓ ${bi("Equipment returned", "装备已退回")}</span><i></i><span class="is-current">03 ${bi("Choose recovery", "选择恢复方案")}</span></div><div class="resume-party">${partyActors()}</div></section><aside><small>${bi("LAST RECEIPT", "上一笔回执")}</small><strong>LOSS-014</strong><dl><div><dt>${bi("Saved", "保存时间")}</dt><dd>00:42.6</dd></div><div><dt>${bi("Destination", "继续位置")}</dt><dd>${bi("Recovery", "恢复选择")}</dd></div><div><dt>${bi("Unresolved", "未完成事务")}</dt><dd>0</dd></div></dl><button class="stage-primary">${bi("Resume Recovery", "继续恢复选择")}</button><button class="stage-secondary">${bi("Return to Ledger", "返回远征档案")}</button></aside></div>`;
return frame(screen, body, { variant, resources: false });
}
if (screen.id === "SYS-007") {
const body = `<div class="recovery-ledger-composition"><header><span class="stage-kicker">${bi("SAVE RECOVERY", "存档恢复")}</span><h2>${bi("Compare verified lineage", "比较已校验的存档谱系")}</h2><p>${bi("Recovery never overwrites the last healthy source before a receipt exists.", "生成恢复回执前,不会覆盖最后一个健康来源。")}</p></header><div class="lineage-list"><button class="lineage-row is-focused"><span>LOCAL</span><div><strong>${bi("Current local state", "当前本地状态")}</strong><p>${bi("Healthy · Market · 12:41", "健康 · 集市 · 12:41")}</p></div><em>${bi("0 min lost", "无进度损失")}</em></button><button class="lineage-row"><span>BACKUP</span><div><strong>${bi("Automatic backup", "自动备份")}</strong><p>${bi("Healthy · Result · 12:29", "健康 · 结算 · 12:29")}</p></div><em>${bi("12 min lost", "损失12分钟")}</em></button><button class="lineage-row is-conflict"><span>CLOUD</span><div><strong>${bi("Conflict candidate", "冲突候选")}</strong><p>${bi("Ruleset mismatch · diagnostics only", "规则版本不匹配 · 仅可诊断")}</p></div><em>${bi("Blocked", "已阻止")}</em></button></div><aside><b>${bi("Recovery consequence", "恢复后果")}</b><p>${bi("Load LOCAL. Active lineage changes only after RECOVER-003 is written and read back.", "载入本地状态。只有写入并回读 RECOVER-003 后,活动谱系才会改变。")}</p><button class="stage-primary">${bi("Load LOCAL", "载入本地状态")}</button></aside></div>`;
return frame(screen, body, { variant, resources: false });
}
const body = `<div class="data-notice-composition"><header><span class="stage-kicker">${bi("CREDITS · LICENSES · DATA", "制作名单 · 许可 · 数据")}</span><h2>${bi("What belongs to the game and what leaves the device", "游戏包含什么,以及哪些数据会离开设备")}</h2></header><nav><button class="is-active">${bi("Authorship", "制作与著作")}</button><button>${bi("Media licenses", "媒体许可")}</button><button>${bi("Local data", "本地数据")}</button><button>${bi("External links", "外部链接")}</button></nav><section><article><span>01</span><strong>${bi("Original game design", "原创游戏设计")}</strong><p>${bi("Systems, names, rules and presentation are authored for Aetherbound Guild.", "系统、名称、规则与呈现均为 Aetherbound Guild 独立创作。")}</p></article><article><span>02</span><strong>${bi("Offline core play", "核心玩法离线可用")}</strong><p>${bi("Runs, settings and Records remain local unless a future platform feature states otherwise.", "远征、设置和记录保留在本地,除非未来平台功能另有说明。")}</p></article><article><span>03</span><strong>${bi("Traceable production media", "可追踪的制作素材")}</strong><p>${bi("Generated candidates retain provider task, prompt, cost and processing provenance.", "生成候选保留供应商任务、提示词、费用和处理记录。")}</p></article></section><aside><b>${bi("Current network boundary", "当前网络边界")}</b><strong>${bi("No account required", "无需账号")}</strong><strong>${bi("No gameplay telemetry", "不收集玩法遥测")}</strong><strong>${bi("External links require review", "打开外链前需要确认")}</strong></aside></div>`;
return frame(screen, body, { variant, resources: false, art: 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-002") return renderGuildCharter(screen, variant);
if (screen.id === "PST-001") return renderChronicleDecision(screen, variant);
if (screen.id === "PST-002") return renderOathSeason(screen, variant);
if (screen.id === "PST-003") return renderOathResult(screen, variant);
if (screen.id === "PRO-001") {
const body = `<div class="run-growth-composition"><header><div><span class="stage-kicker">${bi("RUN GROWTH · ONE DECISION PENDING", "本轮成长 · 一项决策待定")}</span><h2>${bi("Elian can change how the front is held", "Elian 可以改变前排的承伤方式")}</h2><p>${bi("Promotion preserves this Recruit's level, XP, Trait, one equipped item and current HP percentage.", "晋升会保留这名角色自己的等级、经验、特性、唯一装备与当前生命比例。")}</p></div><span class="growth-level">LV 6<small>XP 12/80</small></span></header><div class="growth-tree"><section class="growth-origin">${actorImage(generatedOffers[0], "growth-actor-art")}<small>PF-B01</small><strong>${bi("Hearthguard", "护心卫士")}</strong><p>${bi("Intercept adjacent direct pressure", "拦截相邻队员承受的直接压力")}</p></section><i></i><button class="growth-choice is-selected"><small>PF-A01</small><strong>${bi("Bell Bastion", "鸣钟壁垒")}</strong><p>${bi("Guard break delays the next enemy forecast.", "护卫崩溃会延后下一个敌方预告。")}</p><em>${bi("Changes timing", "改变行动时序")}</em></button><button class="growth-choice"><small>PF-A02</small><strong>${bi("Road Warden", "巡路守望")}</strong><p>${bi("First forced move becomes a protected swap.", "第一次强制移动改为受保护换位。")}</p><em>${bi("Changes position", "改变站位")}</em></button></div><aside><span>${bi("BUILD CONSEQUENCE", "构筑后果")}</span><div class="sheet-row">${bi("One equipment slot", "唯一装备槽")} <b>EQ-002 ${bi("remains equipped", "继续装备")}</b></div><div class="sheet-row">${bi("Party Line", "队伍站位")} <b>${bi("Position 0 stays first target", "0号位仍是首个目标")}</b></div><div class="sheet-row">${bi("Next risk", "下一风险")} <b>${bi("Pane Bite timing changes", "晶板啃咬时序改变")}</b></div><button class="stage-primary">${bi("Review Bell Bastion", "审核鸣钟壁垒")}</button></aside></div>`;
return frame(screen, body, { variant, art: false });
}
if (screen.id === "PRO-003") {
const professionPreviews = [generatedOffers[0], generatedOffers[1], generatedOffers[2], generatedOffers[3], generatedOffers[0], generatedOffers[1]];
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>${index < 4 ? actorImage(professionPreviews[index], "record-tile-art") : "?"}</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">${actorImage(generatedOffers[0], "record-inspect-art")}</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 experienceStepLabel(step) {
return step[state.locale === "zh_CN" ? 3 : 2];
}
function experienceDifficulty() {
return openingDifficulties.find((difficulty) => difficulty.id === state.experienceDifficulty) || null;
}
function experienceProgress() {
const currentIndex = experienceSteps.findIndex((step) => step[0] === state.experienceStep);
return `<ol class="experience-progress" aria-label="${bi("P8.1 progress", "P8.1 进度")}">${experienceSteps.map((step, index) => `<li class="${index === currentIndex ? "is-current" : ""} ${index < currentIndex ? "is-complete" : ""}" ${index === currentIndex ? 'aria-current="step"' : ""}><span>${index < currentIndex ? "✓" : step[1]}</span><strong>${escapeHtml(experienceStepLabel(step))}</strong></li>`).join("")}</ol>`;
}
function recruitTradeoff(offer) {
const reads = {
"OFF-01": [["Intercept one adjacent hit", "替相邻队员承受一次攻击"], ["Moves and acts more slowly", "移动与行动较慢"]],
"OFF-02": [["Pressures the enemy rear", "优先压制敌方后排"], ["First basic attack is delayed", "第一次普通攻击较晚"]],
"OFF-03": [["Can change order safely", "可以安全调整队伍顺序"], ["Becomes briefly Exposed", "会短暂进入暴露状态"]],
"OFF-04": [["Restores an adjacent ally", "治疗相邻队员"], ["Self-healing is weaker", "治疗自己时效果较弱"]],
};
return reads[offer.id];
}
function renderExperienceContract(screen) {
const contractSelected = state.experienceContract === openingContract.id;
const difficulty = experienceDifficulty();
const body = `<div class="experience-contract">
<header class="experience-stage-title"><span class="stage-kicker">P8.1 · ${bi("STEP 1 OF 4", "第 1 / 4 步")}</span><h2>${bi("Choose how this expedition begins", "选择这次远征如何开始")}</h2><p>${bi("One Contract is unlocked. You still choose its difficulty before seeing any Recruit offers.", "目前只解锁一个契约;在看到队员候选前,你仍需自己选择难度。")}</p></header>
<div class="experience-contract-grid">
<button class="experience-contract-card ${contractSelected ? "is-selected" : ""}" type="button" data-experience-contract="${openingContract.id}" aria-pressed="${contractSelected}">
<span>RG-01 · ${openingContract.id}</span><strong>${bi(openingContract.name[0], openingContract.name[1])}</strong><em>${bi(openingContract.region[0], openingContract.region[1])}</em><p>${bi(openingContract.objective[0], openingContract.objective[1])}</p><small>${contractSelected ? bi("SELECTED", "已选择") : bi("SELECT CONTRACT", "选择契约")}</small>
</button>
<section class="experience-contract-read"><span>${bi("WHAT YOU KNOW", "已知情报")}</span><dl><div><dt>${bi("Pressure", "主要威胁")}</dt><dd>${bi(openingContract.pressure[0], openingContract.pressure[1])}</dd></div><div><dt>${bi("Boss rule", "首领规则")}</dt><dd>${bi(openingContract.boss[0], openingContract.boss[1])}</dd></div><div><dt>${bi("Reward breadth", "奖励范围")}</dt><dd>${bi(openingContract.reward[0], openingContract.reward[1])}</dd></div></dl></section>
<section class="experience-difficulty"><span>${bi("CHOOSE DIFFICULTY", "选择难度")}</span><div>${openingDifficulties.map((entry) => { const selected = entry.id === state.experienceDifficulty; return `<button type="button" data-experience-difficulty="${entry.id}" class="${selected ? "is-selected" : ""}" aria-pressed="${selected}" ${contractSelected ? "" : "disabled"}><strong>${bi(entry.name[0], entry.name[1])}</strong><small>${bi(entry.stats[0], entry.stats[1])}</small><em>${bi(entry.audience[0], entry.audience[1])}</em></button>`; }).join("")}</div></section>
</div>
<footer class="experience-stage-actions"><span>${difficulty ? `${bi("Chosen", "已选择")} · ${bi(difficulty.name[0], difficulty.name[1])}` : bi("Choose the Contract and one difficulty", "请选择契约和一种难度")}</span><button class="stage-primary" type="button" data-experience-next ${contractSelected && difficulty ? "" : "disabled"}>${bi("Load four stable offers", "载入四名固定候选")} ›</button></footer>
</div>`;
return frame(screen, body, { variant: "p8-contract", resources: false });
}
function renderExperienceOffers(screen) {
const inspected = generatedOffers.find((offer) => offer.id === state.experienceInspected) || null;
const notice = state.experienceNotice ? `<div class="experience-notice" role="status">${escapeHtml(state.experienceNotice)}</div>` : "";
const body = `<div class="experience-offers">
<header class="experience-stage-title"><span class="stage-kicker">P8.1 · ${bi("STEP 2 OF 4", "第 2 / 4 步")}</span><h2>${bi("Inspect four Recruits, then choose exactly two", "查看四名队员,然后恰好选择两人")}</h2><p>${bi("Offer set WIND-01-A stays unchanged until creation. No pair is recommended.", "候选组 WIND-01-A 在创建前不会变化;界面不会推荐任何组合。")}</p></header>
<div class="experience-offer-grid">${generatedOffers.map((offer) => { const selected = state.experienceSelected.includes(offer.id); const seen = state.experienceInspectedIds.includes(offer.id); const tradeoff = recruitTradeoff(offer); return `<article class="experience-offer ${selected ? "is-selected" : ""} ${state.experienceInspected === offer.id ? "is-inspected" : ""}"><div class="experience-offer-art">${actorImage(offer, "offer-character")}<span>${offer.id}</span></div><div><strong>${localizedText(offer.name)}</strong><small>${localizedText(offer.profession)} · ${bi("LV", "等级")} 1</small><em>${offer.itemId} · ${localizedText(offer.item)}</em></div><p><b>+</b> ${bi(tradeoff[0][0], tradeoff[0][1])}</p><p><b>−</b> ${bi(tradeoff[1][0], tradeoff[1][1])}</p><footer><button type="button" data-experience-inspect="${offer.id}">${seen ? bi("Inspected", "已查看") : bi("Inspect", "查看")}</button><button type="button" data-experience-offer="${offer.id}" aria-pressed="${selected}">${selected ? `✓ ${bi("Selected", "已选择")}` : bi("Select", "选择")}</button></footer></article>`; }).join("")}</div>
<aside class="experience-inspect ${inspected ? "has-recruit" : ""}">${inspected ? (() => { const tradeoff = recruitTradeoff(inspected); return `<span>${bi("INSPECTED TRADEOFF", "当前查看的取舍")}</span><strong>${localizedText(inspected.name)} · ${localizedText(inspected.profession)}</strong><p>${bi(tradeoff[0][0], tradeoff[0][1])};${bi(tradeoff[1][0], tradeoff[1][1])}。</p><small>${bi("One active equipment slot", "一个有效装备槽")} · ${inspected.itemId} ${localizedText(inspected.item)}</small>`; })() : `<span>${bi("INSPECT BEFORE CHOOSING", "选择前先查看")}</span><strong>${bi("No Recruit inspected yet", "尚未查看任何队员")}</strong><p>${bi("Inspect shows each benefit, cost and starting item without selecting for you.", "查看会说明优势、代价与初始装备,但不会替你做选择。")}</p>`}</aside>
${notice}
<footer class="experience-stage-actions"><button class="stage-secondary" type="button" data-experience-back>${bi("Back to Contract", "返回契约")}</button><span>${bi("Selected", "已选择")} <b>${state.experienceSelected.length}/2</b> · ${bi("Inspected", "已查看")} ${state.experienceInspectedIds.length}/4</span><button class="stage-primary" type="button" data-experience-next ${state.experienceSelected.length === 2 ? "" : "disabled"}>${bi("Review Company", "确认初始队伍")} ›</button></footer>
</div>`;
return frame(screen, body, { variant: "p8-offers", resources: false });
}
function renderExperienceReview(screen) {
const chosen = generatedOffers.filter((offer) => state.experienceSelected.includes(offer.id));
const unchosen = generatedOffers.filter((offer) => !state.experienceSelected.includes(offer.id));
const difficulty = experienceDifficulty();
const body = `<div class="experience-review">
<header class="experience-stage-title"><span class="stage-kicker">P8.1 · ${bi("STEP 3 OF 4", "第 3 / 4 步")}</span><h2>${bi("Review before creating the expedition", "创建远征前请确认")}</h2><p>${bi("This is the only commit boundary. You can still revise the pair.", "这里是唯一确认边界;现在仍可返回修改队伍。")}</p></header>
<div class="experience-review-grid"><section><span>${bi("SELECTED COMPANY", "选择的初始队伍")}</span><div>${chosen.map((offer) => recruitMarker(offer, true)).join("")}</div></section><section><span>${bi("NOT RETAINED AFTER COMMIT", "确认后不会保留")}</span><div>${unchosen.map((offer) => recruitMarker(offer, false)).join("")}</div></section><aside><span>${bi("RUN TERMS", "远征条款")}</span><dl><div><dt>${bi("Contract", "契约")}</dt><dd>${bi(openingContract.name[0], openingContract.name[1])}</dd></div><div><dt>${bi("Difficulty", "难度")}</dt><dd>${difficulty ? bi(difficulty.name[0], difficulty.name[1]) : "-"}</dd></div><div><dt>${bi("Save slot", "存档位")}</dt><dd>01</dd></div><div><dt>${bi("Coin cost", "金币消耗")}</dt><dd>0</dd></div><div><dt>${bi("First Market", "首个集市")}</dt><dd>${bi("52 Coin · Company 2/4", "52金币 · 队伍2/4")}</dd></div></dl></aside></div>
<footer class="experience-stage-actions"><button class="stage-secondary" type="button" data-experience-revise>${bi("Revise Recruits", "重新选择队员")}</button><span>${bi("Creating discards the two unchosen stable offers.", "创建后,两名未选择的固定候选将离开。")}</span><button class="stage-primary" type="button" data-experience-commit>${bi("Create this expedition", "创建这次远征")}</button></footer>
</div>`;
return frame(screen, body, { variant: "p8-review", resources: false });
}
function renderExperienceReceipt(screen) {
const chosen = generatedOffers.filter((offer) => state.experienceSelected.includes(offer.id));
const unchosen = generatedOffers.filter((offer) => !state.experienceSelected.includes(offer.id));
const difficulty = experienceDifficulty();
const body = `<div class="experience-receipt">
<header><span class="receipt-check">✓</span><div><span class="stage-kicker">P8.1 · ${bi("STEP 4 OF 4", "第 4 / 4 步")}</span><h2>${bi("Expedition created", "远征已创建")}</h2><p>${bi("One receipt was committed. Repeated input cannot create another run.", "已生成唯一创建回执;重复点击不会再次创建远征。")}</p></div><b>P8-RUN-001</b></header>
<div class="experience-receipt-grid"><section><span>${bi("COMPANY 2 / CAPACITY 4", "队伍 2 / 容量 4")}</span>${chosen.map((offer) => recruitMarker(offer, true)).join("")}</section><section><span>${bi("UNCHOSEN OFFERS DISCARDED", "未选候选已离开")}</span>${unchosen.map((offer) => `<p><b>${offer.id}</b><strong>${localizedText(offer.name)}</strong></p>`).join("")}</section><aside><span>${bi("RECEIPT", "创建回执")}</span><dl><div><dt>${bi("Contract", "契约")}</dt><dd>${openingContract.id}</dd></div><div><dt>${bi("Difficulty", "难度")}</dt><dd>${difficulty ? bi(difficulty.name[0], difficulty.name[1]) : "-"}</dd></div><div><dt>${bi("Coin", "金币")}</dt><dd>+0 / −0</dd></div><div><dt>${bi("Next", "下一步")}</dt><dd>${bi("First Market", "首个集市")}</dd></div></dl></aside></div>
<div class="experience-next-package"><span>${bi("P8.2 LOCKED", "P8.2 尚未开启")}</span><strong>${bi("Market preparation begins only after your P8.1 review.", "你确认 P8.1 后,才会开发集市整备体验。")}</strong></div>
<footer class="experience-stage-actions"><button class="stage-secondary" type="button" data-experience-restart>${bi("Play P8.1 again", "重新体验 P8.1")}</button><span>${bi("End of this review package", "本次体验包到此结束")}</span><button class="stage-primary" type="button" data-view="screens">${bi("Open design atlas", "查看完整设计图册")}</button></footer>
</div>`;
return frame(screen, body, { variant: "p8-receipt", resources: false });
}
function renderExperiencePrototype() {
const screen = getScreen("SYS-005");
const stage = state.experienceStep === "contract" ? renderExperienceContract(screen)
: state.experienceStep === "offers" ? renderExperienceOffers(screen)
: state.experienceStep === "review" ? renderExperienceReview(screen)
: renderExperienceReceipt(screen);
return `<section class="view playtest-view">
${viewHeading("PHASE 8 · PACKAGE 1", bi("Play the opening Company experience", "体验开局建队"), bi("Make the real decisions in order. No step can be skipped, and P8.2 remains locked until your review.", "请按顺序完成真实选择;步骤不可跳过,你确认前不会开始 P8.2。"), ["P8.1", bi("Owner play gate", "等待你的体验结论")])}
<div class="experience-shell">${experienceProgress()}<div class="stage-wrap">${stage}</div><div class="package-roadmap"><strong>P8.1 ${bi("Opening Company", "开局建队")}</strong><span>P8.2 ${bi("Market preparation · locked", "集市整备 · 未开启")}</span><span>P8.3 ${bi("Risk and battle · locked", "风险与战斗 · 未开启")}</span><span>P8.4 ${bi("Result and recovery · locked", "结果与恢复 · 未开启")}</span><span>P8.5 ${bi("Integrated loop · locked", "完整循环 · 未开启")}</span></div></div>
</section>`;
}
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 renderReferenceWireframe(mapping) {
return `<div class="reference-wireframe template-${mapping.template}" data-reference-template="${mapping.template}">
<header><span>${mapping.id}</span><strong>${bi("OBSERVED FUNCTIONAL STRUCTURE", "观察到的功能结构")}</strong><em>${mapping.evidence}</em></header>
<div class="wireframe-canvas">${mapping.zones.map(([position, en, zh], index) => `<div class="wire-zone zone-${position}"><span>0${index + 1}</span><strong>${bi(en, zh)}</strong></div>`).join("")}</div>
<footer>${bi("Neutral reconstruction only - no commercial media", "仅为中性结构重绘 · 不含商业素材")}</footer>
</div>`;
}
function renderReferenceMap() {
const mapping = referenceMappings[state.referenceIndex];
const screen = getScreen(mapping.target);
return `<section class="view reference-view">
${viewHeading("REFERENCE EXPERIENCE MAP", bi(mapping.title[0], mapping.title[1]), `${mapping.id} · ${mapping.evidence} · ${mapping.target}`, [`${state.referenceIndex + 1} / ${referenceMappings.length}`, bi("Functional learning, original expression", "学习功能结构,保持原创表达")])}
<div class="reference-selector">${referenceMappings.map((entry, index) => `<button class="${index === state.referenceIndex ? "is-active" : ""}" type="button" data-reference-index="${index}"><span>${entry.id}</span><strong>${bi(entry.title[0], entry.title[1])}</strong><em>${entry.target}</em></button>`).join("")}</div>
<div class="fidelity-comparison"><article class="reference-panel"><header><span>${bi("REFERENCE LEARNING", "参考学习")}</span><h2>${bi("Observed structure", "观察到的结构")}</h2><p>${bi(mapping.options[0], mapping.options[1])}</p></header>${renderReferenceWireframe(mapping)}</article><article class="adaptation-panel"><header><span>${bi("AETHERBOUND ADAPTATION", "Aetherbound 原创改造")}</span><h2>${screenName(screen)}</h2><p>${screen.id} · ${bi("Current page composition", "当前页面构图")}</p></header><div class="stage-wrap">${renderPrimaryStage(screen, mapping.variant)}</div></article></div>
<div class="fidelity-decisions"><article><span>${bi("ADOPTED", "采用")}</span><p>${bi(mapping.adopt[0], mapping.adopt[1])}</p></article><article><span>${bi("CHANGED", "改造")}</span><p>${bi(mapping.change[0], mapping.change[1])}</p></article><article><span>${bi("REJECTED", "拒绝")}</span><p>${bi(mapping.reject[0], mapping.reject[1])}</p></article></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"],
["07", "Charter and final choice", "PRO-002 · PST-001..003", "Spend Renown only for breadth, then compare finite closure with a new generated Oath Season run.", "screens", "PRO-002"],
];
return `<section class="view review-view">
${viewHeading("OWNER DECISION SURFACE", "Seven 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 = { playtest: renderExperiencePrototype, firstplay: renderFirstPlay, screens: renderScreens, regions: renderRegions, catalogs: renderCatalogs, reference: renderReferenceMap, 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 resetExperience() {
state.experienceStep = "contract";
state.experienceContract = null;
state.experienceDifficulty = null;
state.experienceSelected = [];
state.experienceInspected = null;
state.experienceInspectedIds = [];
state.experienceNotice = "";
state.experienceCommitted = false;
}
function announceExperience(message) {
state.experienceNotice = message;
const liveRegion = $("#live-region");
if (liveRegion) liveRegion.textContent = message;
}
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.dataset.experienceContract) {
const selected = state.experienceContract === button.dataset.experienceContract;
state.experienceContract = selected ? null : button.dataset.experienceContract;
if (selected) state.experienceDifficulty = null;
return render();
}
if (button.dataset.experienceDifficulty) {
if (!state.experienceContract) return;
state.experienceDifficulty = button.dataset.experienceDifficulty;
return render();
}
if (button.dataset.experienceInspect) {
const id = button.dataset.experienceInspect;
state.experienceInspected = id;
if (!state.experienceInspectedIds.includes(id)) state.experienceInspectedIds.push(id);
state.experienceNotice = "";
return render();
}
if (button.dataset.experienceOffer) {
const id = button.dataset.experienceOffer;
const index = state.experienceSelected.indexOf(id);
if (index >= 0) {
state.experienceSelected.splice(index, 1);
state.experienceNotice = "";
} else if (state.experienceSelected.length < 2) {
state.experienceSelected.push(id);
state.experienceNotice = "";
} else {
announceExperience(bi("Two Recruits are already selected. Deselect one before choosing another.", "已经选择两名队员;请先取消一人,再选择其他候选。"));
}
return render();
}
if (button.hasAttribute("data-experience-next")) {
if (state.experienceStep === "contract" && state.experienceContract && state.experienceDifficulty) {
state.experienceStep = "offers";
} else if (state.experienceStep === "offers" && state.experienceSelected.length === 2) {
state.experienceStep = "review";
}
state.experienceNotice = "";
return render();
}
if (button.hasAttribute("data-experience-back")) {
state.experienceStep = "contract";
state.experienceNotice = "";
return render();
}
if (button.hasAttribute("data-experience-revise")) {
state.experienceStep = "offers";
state.experienceNotice = "";
return render();
}
if (button.hasAttribute("data-experience-commit")) {
if (!state.experienceCommitted && state.experienceSelected.length === 2 && state.experienceContract && state.experienceDifficulty) {
state.experienceCommitted = true;
state.experienceStep = "receipt";
}
return render();
}
if (button.hasAttribute("data-experience-restart")) {
resetExperience();
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.hasAttribute("data-reference-index")) {
state.referenceIndex = Number(button.dataset.referenceIndex);
return render();
}
if (button.hasAttribute("data-charter-region")) {
state.charterRegionIndex = Number(button.dataset.charterRegion);
state.charterPacketIndex = 0;
return render();
}
if (button.hasAttribute("data-charter-packet")) {
state.charterPacketIndex = Number(button.dataset.charterPacket);
return render();
}
if (button.dataset.chronicleChoice) {
state.chronicleChoice = button.dataset.chronicleChoice;
return render();
}
if (button.dataset.oathLaw) {
const id = button.dataset.oathLaw;
const index = state.oathLaws.indexOf(id);
if (index >= 0) state.oathLaws.splice(index, 1);
else state.oathLaws.push(id);
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 (["playtest", "firstplay", "screens", "regions", "catalogs", "reference", "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();