Files
ark-stronghold-protocol/public/js/screens/lobby.js
T
sganggs 51e0e60b6e feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left
right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟)
counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js
_quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is
history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win
rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out.
- A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an
  `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page
  first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its
  settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded.

Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed:
- the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the
  host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest
  when opened (the title screen loads the same 3 files as before);
- importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the
  newest); the list is newest first everywhere, also when loaded from storage;
- recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as
  saveStats does;
- 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character
  quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records
  keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own
  summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are
  dropped (a strict schema), import files are bounded (8 MB);
- the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into
  ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match
  itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain
  operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a
  co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog.
- Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and
  counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled.

UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 /
导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes
with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty
table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu
(the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the
icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI
strings in en / ja / ko / zh-TW (the PR's four unused ones removed).

Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage,
import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome:
seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real
flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is
recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean,
check:imports, typecheck, docs and version tests, golden unchanged.
2026-10-08 20:36:41 +08:00

376 lines
20 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.
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room,
// or join one with a 同盟密钥 (recent codes remembered) — as a player (加入同盟) or in one of its MAX_SPECTATORS
// spectator seats (观战: room.spectate, also while its match runs; community report #26, a remake feature — the
// official room has none). Shows connection status + ping.
//
// Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
import { getConfig, getMode, getStage, useData } from '../data.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
export const MODE_TEXT = {
single: {
FUNNY: { code: 'AC-1', desc: N_('时长较短的模拟训练'), effects: [N_('可以快速完成作战'), N_('常规奖励')] },
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
multi: {
FUNNY: { code: 'AC-1', desc: N_('敌方攻击强度较低的模拟训练'), effects: [N_('作战环境较为温和'), N_('常规奖励')] },
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
};
/** Battlefield pool per difficulty when config.json is absent (the modes' `stages` lists; same for solo and co-op). */
export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, ABYSS: 7 };
/**
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
* From the data name only its 战场#NN part (with a (上半) / (下半) mark) — 'Battlefield #NN (First Half)' in English.
* @param {string} id e.g. 'act1autochess_m01'
*/
export function stageLabel(id) {
const rec = getStage(id);
if (rec && typeof rec.name === 'string' && rec.name) {
const m = rec.name.match(/^(.*?#\d+(?:\s*[((][^))]*[))])?)/);
return m ? m[1] : rec.name.split(/\s+/)[0];
}
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
return m ? t('战场#{no}', { no: String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0') }) : '';
}
/**
* The battlefield note of a difficulty (official wording): a single-stage pool is fixed ("战场固定为 战场#01"), a larger
* one is drawn at random ("战场随机(共8张)").
* @param {string[] | number | null | undefined} stages the mode's `stages` list (or a count)
* @returns {string} '' when unknown
*/
export function stageNote(stages) {
if (Array.isArray(stages)) {
const ids = stages.filter((s) => typeof s === 'string' && s);
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? t('战场固定为 {name}', { name }) : t('战场固定'); }
return ids.length > 1 ? t('战场随机(共{n}张)', { n: ids.length }) : '';
}
return Number.isInteger(stages) && stages > 1 ? t('战场随机(共{stages}张)', { stages }) : '';
}
const MODE_CARDS = [
{
id: 'solo', name: N_('独立模拟'), en: 'SOLO SIMULATION', icon: 'user',
desc: N_('独自调配资金与干员,以自己的节奏完成整场模拟。'),
points: [N_('1 名博士'), N_('休整期与机变阶段不限时')],
},
{
id: 'coop', name: N_('同盟模拟'), en: 'ALLIANCE SIMULATION', icon: 'users',
desc: N_('与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。'), params: { n: MAX_SEATS - 1 },
points: [N_('1–{n} 名博士 · 可由 AI 队友补位'), N_('联防阶段 · 最终攻势合并生命值')], pointParams: { n: MAX_SEATS },
},
];
/**
* Text for a difficulty card, preferring data/config.json.
* @param {'solo'|'coop'} roomMode
* @param {string} difficulty
* @returns {{ code: string, desc: string, effects: string[], rounds: number, hidden: boolean, stageNote: string }}
*/
export function difficultyInfo(roomMode, difficulty) {
const fallback = MODE_TEXT[roomMode === 'solo' ? 'single' : 'multi'][difficulty] || { code: '', desc: '', effects: [] };
// modeIdFor() lower-cases the difficulty: never call it with a value the server did not validate.
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
const effects = Array.isArray(m?.effectDescList)
? m.effectDescList.map((e) => String(e).replace(/^[·•・・\s]+/, '')).filter(Boolean)
: fallback.effects.map((e) => t(e));
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
return {
code: typeof m?.code === 'string' ? m.code : fallback.code,
desc: typeof m?.desc === 'string' ? m.desc : t(fallback.desc),
effects,
rounds,
hidden: difficulty !== 'FUNNY',
stageNote: stageNote(Array.isArray(m?.stages) && m.stages.length ? m.stages : STAGE_POOL[difficulty]),
};
}
const CODE_RE = new RegExp(`^[A-Z0-9]{${ROOM_CODE_LEN}}$`);
/**
* Normalise user input into a room code: accepts a pasted invite link (`…?room=ABCD`), keeps
* upper-cased alphanumerics and clamps to the code length.
* @param {string} v
* @returns {string}
*/
export function normalizeCode(v) {
let s = String(v ?? '');
const m = s.match(/[?&]room=([A-Za-z0-9]+)/);
if (m) s = m[1];
return s.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, ROOM_CODE_LEN);
}
/**
* A handler that Preact binds as `onClick=${fn}` receives the click EVENT as its first argument, and a default
* parameter only applies to `undefined` — so `fn(c = code)` would normalise the event target into a nonsense code
* (`String(el)` → `"[object HTMLElement]"` → "OBJE"). Only a string is ever a code; anything else falls back to the
* input field. Returns null when neither yields a well-formed code.
* @param {unknown} arg the argument a handler was called with
* @param {string} field the current input-field value
* @returns {string|null}
*/
export function codeArg(arg, field) {
const k = normalizeCode(typeof arg === 'string' ? arg : field);
return CODE_RE.test(k) ? k : null;
}
/**
* Room code from a deep link query string (`?room=CODE`), or null when absent/malformed.
* Accepts ROOM_CODE_LEN..ROOM_CODE_LEN+2 alphanumerics (the protocol's join limit).
* @param {string} search e.g. location.search
* @returns {string|null}
*/
export function parseRoomParam(search) {
try {
const raw = new URLSearchParams(search || '').get('room');
if (!raw) return null;
const code = raw.trim().toUpperCase();
if (!/^[A-Z0-9]+$/.test(code)) return null;
return code.length >= ROOM_CODE_LEN && code.length <= ROOM_CODE_LEN + 2 ? code : null;
} catch {
return null;
}
}
/** Recently joined/created co-op room codes (most recent first). */
export function recentRooms() {
const list = loadPref('recentRooms', []);
return Array.isArray(list) ? list.filter((c) => typeof c === 'string' && CODE_RE.test(c)).slice(0, 4) : [];
}
/** @param {string} code */
export function rememberRoom(code) {
if (!CODE_RE.test(code)) return;
savePref('recentRooms', [code, ...recentRooms().filter((c) => c !== code)].slice(0, 4));
}
const FALLBACK_TIPS = [
N_('联合模拟在选择策略时可以进行一次跳过'),
N_('调度中心即使冻结,依然可以主动刷新'),
N_('两件同名装备可以合成一件更强力的装备'),
N_('只有达成完美作战的队友可以进行联防'),
];
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
function TipsPanel() {
const cfg = getConfig();
const fallback = FALLBACK_TIPS.map((x) => t(x));
const tips = Array.isArray(cfg?.tips)
? cfg.tips.map((x) => (typeof x === 'string' ? x : x?.tip)).filter((x) => typeof x === 'string' && x)
: fallback;
const list = tips.length ? tips : fallback;
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
useEffect(() => {
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
return () => clearInterval(id);
}, []);
const i = ((idx % list.length) + list.length) % list.length;
return html`<div class="tips brackets">
<div class="tips__head">
<${Icon} name="info" />
<span>${t('作战提示')}</span>
<${MicroLabel}>TACTICAL TIPS<//>
<span class="tips__idx num">${String(i + 1).padStart(2, '0')}<span class="t-dim">/${String(list.length).padStart(2, '0')}</span></span>
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label=${t('上一条')}><${Icon} name="chevronLeft" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label=${t('下一条')}><${Icon} name="chevronRight" /></button>
</div>
<p key=${i} class="tips__text">${list[i]}</p>
</div>`;
}
function ModeCard({ card, selected, onSelect }) {
return html`<button type="button" class=${`mode-card brackets${selected ? ' is-selected' : ''}`} onClick=${() => onSelect(card.id)}
aria-pressed=${selected ? 'true' : 'false'}>
<span class="mode-card__bg" aria-hidden="true"></span>
<span class="mode-card__icon"><${Icon} name=${card.icon} /></span>
<span class="mode-card__text">
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}<//>
<span class="mode-card__name">${t(card.name)}</span>
<span class="mode-card__desc">${t(card.desc, card.params)}</span>
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${t(p, card.pointParams)}</span>`)}</span>
</span>
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />${t('已选定')}</span>
</button>`;
}
function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
const info = difficultyInfo(roomMode, difficulty);
return html`<button type="button" class=${`diff-card${selected ? ' is-selected' : ''}`}
style=${`--d-color:${DIFFICULTY_COLORS[difficulty]}`} onClick=${() => onSelect(difficulty)} aria-pressed=${selected ? 'true' : 'false'}>
<span class="diff-card__bar" aria-hidden="true"></span>
<span class="diff-card__head">
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
<span class="diff-card__name">${t(DIFFICULTY_NAMES[difficulty])}</span>
<span class="diff-card__code num">${info.code}</span>
<span class="diff-card__meta">
<span class="num">${info.rounds}</span> ${tc('rounds', '回合')}${info.hidden ? html`<span class="diff-card__hidden">${t('+ 隐秘核心')}</span>` : null}
</span>
</span>
<span class="diff-card__desc">${info.desc}</span>
<span class="diff-card__effects">${info.effects.map((e) => html`<span key=${e}>${e}</span>`)}${info.stageNote ? html`<span key="stage" class="diff-card__stage"><${Icon} name="rook" />${info.stageNote}</span>` : null}</span>
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>${t('已选定')}</span></span>
</button>`;
}
/** Lobby screen component. */
export function LobbyScreen() {
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
useData('config');
const [roomMode, setRoomMode] = useState(() => (loadPref('lobby.mode', 'coop') === 'solo' ? 'solo' : 'coop'));
const [difficulty, setDifficulty] = useState(() => {
const d = loadPref('lobby.difficulty', 'FUNNY');
return DIFFICULTIES.includes(d) ? d : 'FUNNY';
});
const [code, setCode] = useState('');
const [busy, setBusy] = useState(null);
const [recent] = useState(recentRooms);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
const online = conn.status === 'online';
const codeOk = CODE_RE.test(code);
const pickMode = (m) => { setRoomMode(m); savePref('lobby.mode', m); };
const pickDifficulty = (d) => { setDifficulty(d); savePref('lobby.difficulty', d); };
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast(t('尚未连接到服务器,请稍候'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const create = () => run('create', () => net.request('room.create', { mode: roomMode, difficulty }));
const join = (c = code) => {
// `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to
// `undefined` — codeArg keeps an event target out of the key and falls back to the input field
const k = codeArg(c, code);
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('join', () => net.request('room.join', { code: k }));
};
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
const spectate = (c = code) => {
// same guard as join: `onClick=${spectate}` passes the click event, not a code
const k = codeArg(c, code);
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => {
// Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an
// earlier room, or another machine's) — the server can only answer "no such room".
if (err?.code === ERR.ROOM_NOT_FOUND) {
toast(t('没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)', { k }), 'warn');
return;
}
if (err?.code === ERR.ALREADY) {
toast(t('你已经是该同盟的博士:先离开同盟,才能以观战身份进入'), 'warn');
return;
}
throw err;
}));
};
const backToTitle = () => {
identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } }));
};
return html`<div class="screen lobby-screen">
<header class="topbar">
<div class="topbar__left">
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
<${PingPill} ms=${conn.ping} online=${online} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
<div class="me-chip__text">
<span class="me-chip__name">${me.name || t('博士')}</span>
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
</div>
</div>
</div>
</header>
<div class="lobby-body screen__scroll">
<section class="lobby-left">
<div class="section-label"><span class="section-label__idx num">01</span>${t('模拟方式')}<${MicroLabel}>MODE<//></div>
<div class="mode-cards">
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
</div>
<div class="section-label"><span class="section-label__idx num">03</span>${t('加入同盟')}<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
<${Panel} class="join-panel" tone="amber">
<div class="join-row">
<${TextField} size="code" icon="key" value=${code} placeholder=${t('输入同盟密钥 / 粘贴邀请链接')}
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>${t('加入同盟')}<//>
<${Tooltip} text=${t('以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)', { MAX_SPECTATORS })}>
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>${t('观战')}<//>
<//>
</div>
<div class="join-foot">
${recent.length ? html`<span class="t-lo">${t('最近的同盟')}</span>
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title=${t('填入密钥(不会直接加入)')}
onClick=${() => setCode(c)}>${c}</button>`)}`
: html`<span class="t-dim">${t('向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接', { ROOM_CODE_LEN })}</span>`}
</div>
<//>
<${TipsPanel} />
</section>
<section class="lobby-right">
<div class="section-label"><span class="section-label__idx num">02</span>${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></div>
<div class="diff-list">
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
</div>
<div class="create-box">
<${Tooltip} block=${true} text=${online ? null : t('正在连接服务器…')}>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
${roomMode === 'solo' ? t('开始独立模拟') : t('创建同盟')}
<//>
<//>
<div class="create-box__hint">
${online
? html`<span>${roomMode === 'solo' ? t('创建后即可开始模拟') : t('创建后可邀请好友或添加 AI 队友')}</span>`
: html`<${Spinner} size="sm" label="CONNECTING" />`}
</div>
</div>
</section>
</div>
</div>`;
}