Files
ark-stronghold-protocol/public/dev/game-mock.js
T
sganggs d1f8a49829 feedback1 WB: bounty drafts and the 机密商店 from 66 official screenshots (22 matches)
Bounty drafts: every 悬赏决策 event is a fixed card list and the draft shows
6 different cards of it, drawn with weight 1 + the official drafts of its
group the card showed in.
- R3: 9 of the 10 fixed sets seen (two new: 20_1 + 20_5, and one with 17_6);
  3 x I + 2 x II + 1 x III in 22 of 22. The unseen 10th set is built by a
  rule whose III is 10_6 or 20_6, the only two-battle cards no draft showed.
- R9: 6 groups of 9 cards, one per bossInitial event. The 鼠王 group came in
  14 of 22 matches (every grey-board match among them), so groups are picked
  by the matches they came in. Named bosses no longer always come together.
- R11: lists of 7 cards, the draft leaves out one (matches 1 / 6 / 21 / 22
  leave out four different cards of one list); 7 of 15 lists seen, the rest
  and the 4 partial lists' 7th cards built by rule. Families 14 / 4 / 4.

机密商店 (the user: "机密商店按官方改成可以重复吧"): choices.json shopDraft,
six slots drawn with replacement - VI, VI, V, 盟约之币 and twice
V / IV / III / 盟约之币, items weighted by the official cards they showed on.
A repeated item now comes in about 61 % of shops (official 3 of 4). Two
identical cards stay two cards (picks by index): E2E, overlay and a
headless-Chrome test (game-mock ?variant=shop, the official match 8 shop).
2026-10-03 07:42:20 +08:00

745 lines
44 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.
// Mock harness for the in-match UI (see game-mock.html). Fabricates a 4-player co-op match from the real
// data files and answers `g.*` intents with a small in-browser mock server (buy / sell / move / equip /
// refresh / freeze / level / ready / reward / choice / band …), so drag & drop and every button can be
// exercised. Combat phases stream b.snap / b.ev at 20 Hz through net._emit like the real socket.
import '/js/ui/compat.js';
import { render } from '/vendor/preact.module.js';
import { installDeviceSupport } from '/js/ui/device.js';
import { html, UiHosts } from '/js/ui/components.js';
import { ToastHost, toast } from '/js/ui/toasts.js';
import { ConnectionBanner } from '/js/ui/connBanner.js';
import { GuideHost } from '/js/ui/guide.js';
import { GameScreen } from '/js/screens/game.js';
import { store, emptyMatch, selectRoute } from '/js/store.js';
import { net, NetError } from '/js/net.js';
import { data } from '/js/data.js';
import { installAudio } from '/js/audio.js';
import { settingsStore } from '/js/ui/settings.js';
import { awayStore } from '/js/ui/matchChrome.js';
import { GAME_FILES } from '/js/ui/gameComponents.js';
import { PHASE, GEO } from '/shared/constants.js';
const params = new URLSearchParams(location.search);
const SHOT = params.get('shot') === '1';
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
// ---- deterministic rng ----------------------------------------------------------------------------------
let seed = 20260927;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const ME = 'p1';
const PLAYERS = [
{ playerId: 'p1', seat: 0, name: '凯尔希', isBot: false, bandId: 'band_bldsk' },
{ playerId: 'ai_2', seat: 1, name: 'AI·华法琳', isBot: true, bandId: 'band_sarkazb' },
{ playerId: 'p3', seat: 2, name: 'Doctor·B', isBot: false, bandId: 'band_amiya' },
{ playerId: 'p4', seat: 3, name: '灰烬', isBot: false, bandId: 'band_amedic' },
];
let uidSeq = 100;
const nextUid = () => ++uidSeq;
let S = null; // mock server state
// ---- builders ------------------------------------------------------------------------------------------------
function visibleChess(tier) {
return data.list('chess').filter((c) => c.visible && !c.isGolden && (!tier || c.tier === tier));
}
function shopItems() { return data.list('items').filter((i) => !i.isGolden && i.itemType === 'EQUIP' && !i.hideInShop); }
function makeSlot(level) {
const tier = Math.max(1, Math.min(level, 1 + Math.floor(rnd() * level)));
const c = pick(visibleChess(tier));
const base = c.price;
const r = rnd();
const price = r < 0.15 ? Math.max(1, base - 1) : r > 0.9 ? base + 1 : base;
return { kind: 'chess', id: c.chessId, price, basePrice: base, sold: false };
}
function makeItemSlot() {
const it = pick(shopItems());
return { kind: 'item', id: it.id, price: it.price, basePrice: it.price, sold: false };
}
function chessPiece(c, golden = false) {
const id = golden ? (c.goldenId || c.chessId) : c.chessId;
return { uid: nextUid(), kind: 'chess', id, golden, tier: c.tier, items: [] };
}
function itemPiece(it) { return { uid: nextUid(), kind: 'item', id: it.id, golden: !!it.isGolden, tier: it.tier }; }
function computeBonds(priv) {
const counts = new Map();
const seen = new Set();
for (const p of priv.board) {
if (p.kind !== 'chess') continue;
const c = data.lookup('chess', p.id);
const base = c?.baseId || p.id;
if (seen.has(base)) continue;
seen.add(base);
for (const b of c?.bonds || []) counts.set(b, (counts.get(b) || 0) + 1);
}
const out = [];
for (const [bondId, count] of counts) {
const b = data.lookup('bonds', bondId);
const thresholds = b?.thresholds || [2];
let tier = 0;
for (const t of thresholds) if (count >= t) tier++;
const layers = S?.layers?.[bondId] ?? (tier ? Math.floor(rnd() * 40) + 4 : Math.floor(rnd() * 6));
if (S) S.layers[bondId] = layers;
out.push({ bondId, count, active: tier > 0, tier, layers, thresholds, countsHand: !!b?.countsHand });
}
return out.sort((a, b) => (b.active - a.active) || (b.layers - a.layers));
}
function refreshPrivate() {
const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p);
p.canReady = !p.temp.some(Boolean);
const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
p.shop.refreshPrice = p.shop.freeRefreshes > 0 ? 0 : 1;
store.patch('match', { private: JSON.parse(JSON.stringify(p)) });
const me = S.pub.players.find((x) => x.playerId === ME);
if (me) { me.bonds = p.bonds; me.boardCount = p.deployCount; me.lp = p.lp; me.ready = p.ready; if (S.pub.phase === PHASE.PREP) me.status = p.ready ? 'ready' : 'acting'; }
pushPublic();
}
function pushPublic() {
S.pub.serverNow = Date.now();
store.patch('match', { public: JSON.parse(JSON.stringify(S.pub)) });
}
function buildState() {
uidSeq = 100;
seed = 20260927;
const stage = data.lookup('stages', 'act2autochess_m01');
const melee = stage.deployTiles.normal.melee.slice();
const ranged = stage.deployTiles.normal.rangedOnly.slice();
const bonds = data.list('bonds');
const core = shuffle(bonds.filter((b) => b.isCore && b.weight > 0)).slice(0, 3).map((b) => b.bondId);
const addon = shuffle(bonds.filter((b) => !b.isCore && b.weight > 0)).slice(0, 4).map((b) => b.bondId);
const disabled = new Set([...core, ...addon]);
const banned = visibleChess().filter((c) => c.bonds.length && c.bonds.every((b) => disabled.has(b))).map((c) => c.chessId);
const pool = visibleChess().filter((c) => !banned.includes(c.chessId));
// board: 7 units on legal tiles
const board = [];
const used = new Set();
const preferBond = pool.filter((c) => c.bonds.includes('yanShip') || c.bonds.includes('preciShip'));
const lineup = shuffle(preferBond).slice(0, 5).concat(shuffle(pool).slice(0, 4));
for (const c of lineup) {
if (board.length >= 7) break;
const tiles = c.position === 'MELEE' ? melee : [...ranged, ...melee];
const t = tiles.find((x) => !used.has(`${x[0]},${x[1]}`));
if (!t) continue;
used.add(`${t[0]},${t[1]}`);
const golden = board.length === 1;
const piece = { ...chessPiece(c, golden), row: t[0], col: t[1] };
if (board.length === 0) piece.items = [{ uid: nextUid(), id: shopItems()[3].id }, { uid: nextUid(), id: shopItems()[9].id }];
if (board.length === 2) piece.items = [{ uid: nextUid(), id: shopItems()[5].id }];
board.push(piece);
}
const hand = new Array(GEO.HAND_SIZE).fill(null);
const hc = shuffle(pool).slice(0, 5);
hand[9] = chessPiece(hc[0]);
hand[8] = chessPiece(hc[1]);
hand[7] = chessPiece(board[3] ? data.lookup('chess', board[3].id) : hc[2]); // a merge pair
hand[6] = chessPiece(hc[3], true);
hand[5] = itemPiece(shopItems()[12]);
hand[4] = itemPiece(shopItems()[20]);
const tokenOwner = data.list('tokens').find((t) => t.tokenId === 'token_10028_vigil_wolf');
if (tokenOwner) hand[3] = { uid: nextUid(), kind: 'token', id: tokenOwner.tokenId, golden: false, count: 2 };
const temp = new Array(GEO.TEMP_SIZE).fill(null);
if (VARIANTS.has('temp')) { temp[0] = chessPiece(hc[4]); temp[1] = itemPiece(shopItems()[2]); }
const level = 4;
const slots = [];
for (let i = 0; i < 5; i++) slots.push(makeSlot(level));
slots[2].sold = true;
// make one card complete a merge
if (board[4]) { const c4 = data.lookup('chess', board[4].id); slots[0] = { kind: 'chess', id: c4.chessId, price: c4.price, basePrice: c4.price, sold: false }; hand[2] = chessPiece(c4); }
slots.push(makeItemSlot());
const enemies = data.list('enemies');
const byRank = (r) => enemies.filter((e) => e.rank === r && !e.key.includes('template'));
// m.private.nextEnemies (server/match/waves.js previewOf): one entry per spawn action, in spawn order
const flyOf = (e) => e.stats?.motion === 'FLY' || !!e.isFlyEnemy;
const nextEnemies = [
...shuffle(byRank('NORMAL')).slice(0, 4).map((e, i) => ({ enemyKey: e.key, count: [8, 6, 4, 3][i], gate: i % 2 ? 'upper' : 'lower', t: [3, 5, 12, 15][i], fly: flyOf(e), elite: false, boss: false, source: 'wave', tag: null })),
...shuffle(byRank('ELITE')).slice(0, 2).map((e, i) => ({ enemyKey: e.key, count: 1, gate: i ? 'upper' : 'lower', t: 18 + i * 6, fly: flyOf(e), elite: true, boss: false, source: 'wave', tag: null })),
(() => { const e = pick(byRank('ELITE')); return { enemyKey: e.key, count: 1, gate: 'lower', t: 20, fly: flyOf(e), elite: true, boss: false, source: 'bounty', tag: 'bounty' }; })(),
].sort((a, b) => a.t - b.t);
const priv = {
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true,
shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null },
hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [
{ id: 'aceffect_band_21', name: '重点监护', desc: data.lookup('bands', 'band_bldsk')?.descRaw || '', iconKind: 'band', iconId: 'band_bldsk' },
{ id: 'allybuff_select_5', name: '补给', desc: '获得<@ba.vup>2</>次免费刷新机会,若存在其他队友则他们也获得', iconKind: 'team', iconId: 'icon_team_buff', counter: 2 },
{ id: 'enemyeffect_3', name: '悬赏·飞行I', desc: '为自身<@ba.vup>下场作战</>添加1只悬赏敌人,将其击倒者获得<@ba.vup>1</>资金', iconKind: 'choice', iconId: 'icon_player_buff', counter: 1 },
],
nextEnemies,
stats: { dmgDealt: 184230, kills: 96, leaks: 3, gold: 58, refreshes: 11, merges: 3 },
};
if (VARIANTS.has('loadout')) priv.loadout = mockLoadout(board, slots);
if (VARIANTS.has('reward')) {
priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
}
const players = PLAYERS.map((p, i) => ({
...p, connected: i !== 3 || true, alive: true, lp: [24, 31, 18, 5][i], shopLevel: [4, 5, 3, 4][i], boardCount: [7, 8, 6, 7][i],
ready: [false, true, false, false][i], bonds: [], fieldId: `n:${p.playerId}`, status: ['acting', 'ready', 'acting', 'acting'][i],
}));
players[3].connected = false;
// boss-round prep (最终攻势 prep on the own half of the boss field): `boss` = the left half; `bossR` = seats swapped so
// I am the second of the first pair → the mirrored right half (server pairPlayers: alive players by seat, in pairs)
if (VARIANTS.has('bossR')) { players[0].seat = 1; players[1].seat = 0; }
if (VARIANTS.has('dead')) { players[0].alive = false; players[0].status = 'dead'; players[0].lp = 0; priv.lp = 0; }
const pub = {
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
};
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
S = { stage, pub, priv, layers: {}, battle: null, pool, requests: [] };
}
/**
* `loadout` variant (DESIGN §16): the first board operator and the first shop card fight with a non-default skill, the
* elite board operator with its module unequipped (m.private.loadout). Data without `skills[]` / `modules[]` (before the
* loadout data build) gets a stand-in second skill / module list on those records — mock only.
*/
function mockLoadout(board, slots) {
const out = {};
const withSkills = (c) => {
if (!c) return null;
const recs = [c, c.goldenId ? data.lookup('chess', c.goldenId) : null, c.baseId && c.baseId !== c.chessId ? data.lookup('chess', c.baseId) : null].filter(Boolean);
for (const r of recs) {
if (Array.isArray(r.skills) && r.skills.length > 1) continue;
const d = r.skill || {};
const alt = { ...d, index: (d.index ?? 0) + 1, skillId: `${d.skillId || 'sk'}_mockalt`, iconId: d.iconId || r.assets?.skillIcon || d.skillId, name: `${d.name || '技能'}·改`, isDefault: false,
desc: '(模拟)调配的第二技能:攻击力+60%,持续 20 秒', descRaw: '(模拟)调配的第二技能:攻击力<@ba.vup>+60%</>,持续 20 秒', spCost: 30, initSp: 10, duration: 20 };
r.skills = [{ ...d, isDefault: true }, alt];
}
const base = c.isGolden ? data.lookup('chess', c.baseId) || c : c;
const alt = (base.skills || []).find((x) => x && !x.isDefault);
return alt ? { base, alt } : null;
};
const b0 = board[0] ? data.lookup('chess', board[0].id) : null;
const s0 = slots.find((x) => x && x.kind === 'chess' && !x.sold);
for (const c of [b0, s0 ? data.lookup('chess', s0.id) : null]) {
const r = withSkills(c);
if (r) out[r.base.chessId] = { skill: r.alt.index, module: r.base.goldenId ? 'none' : null };
}
const gold = board.find((p) => p.golden);
const g = gold ? data.lookup('chess', gold.id) : null;
if (g && g.isGolden) {
const r = withSkills(g);
if (g.module && !Array.isArray(g.modules)) g.modules = [{ uniEquipId: g.module.id, name: g.module.name, typeName: g.module.type, isDefault: true }];
if (r) out[r.base.chessId] = { skill: r.alt.index, module: 'none' };
}
return out;
}
// ---- phases ---------------------------------------------------------------------------------------------------------
function setPhase(phase, variant) {
stopBattle();
buildState();
for (const v of (variant || '').split(',').filter(Boolean)) VARIANTS.add(v);
if (VARIANTS.has('reward') && !S.priv.shop.rewardOffer) S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
if (VARIANTS.has('temp') && !S.priv.temp.some(Boolean)) { S.priv.temp[0] = chessPiece(pick(S.pool)); S.priv.temp[1] = itemPiece(shopItems()[2]); }
const pub = S.pub;
pub.phase = phase;
store.set({ match: emptyMatch(), ticker: [], emotes: [] });
awayStore.set({ away: false });
const solo = VARIANTS.has('solo');
if (solo) {
pub.players = pub.players.slice(0, 1);
pub.modeId = 'mode_single_hard';
store.set({ room: { ...store.get().room, mode: 'solo' } });
} else {
store.set({ room: { ...store.get().room, mode: 'coop' } });
}
switch (phase) {
case PHASE.INFO_CHECK:
pub.round = 0; pub.deadline = Date.now() + 21000;
pub.players.forEach((p, i) => { p.ready = i === 1 || i === 2; p.status = p.ready ? 'ready' : 'deciding'; p.bandId = null; p.lp = 0; });
S.priv.bandId = null;
break;
case PHASE.BAND_DRAFT:
pub.round = 0; pub.deadline = Date.now() + 9000;
pub.players.forEach((p) => { p.bandId = null; p.status = 'deciding'; p.ready = false; });
S.priv.bandId = null;
if (!solo) {
pub.draft = { order: ['p3', 'p1', 'ai_2', 'p4'], turn: 'p1', picks: { p3: 'band_sarkazb' }, skipsLeft: { p1: 1, p3: 1, ai_2: 1, p4: 1 }, turnDeadline: Date.now() + 9000 };
pub.players.find((p) => p.playerId === 'p3').bandId = 'band_sarkazb';
} else pub.deadline = 0;
break;
case PHASE.BATTLE_CHECK:
pub.round = 0; pub.deadline = Date.now() + 3000;
break;
case PHASE.SP_DRAFT: {
pub.round = 9; pub.deadline = Date.now() + 14000;
const fam = VARIANTS.has('supply') ? 'supply' : VARIANTS.has('shop') ? 'shop' : VARIANTS.has('tactic') ? 'tactic' : 'bounty';
const ch = data.get('choices');
let cards;
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
// the official 机密商店 of match 8 R11 (test/fixtures/official-bounty-drafts.json): the same item twice
else if (fam === 'shop') cards = ['变形同构体', '盟约之币', '商业包装方案', '变形同构体', '天马之盔', '双模机械臂'].map((n) => ({ itemId: shopItems().find((i) => i.name === n).id }));
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => b.draft !== false)).slice(0, 6).map((b) => ({ effectId: b.effectId }));
if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
pub.sp = { family: fam, cards, order: solo ? ['p1'] : ['p4', 'p1', 'ai_2', 'p3'], turn: solo ? 'p1' : 'p1', picks: solo ? {} : { p4: 2 }, untimed: solo };
pub.players.forEach((p) => { p.status = p.playerId === 'p4' ? 'ready' : 'deciding'; });
break;
}
case PHASE.COMBAT: case PHASE.UNITE: case PHASE.SETTLE: case PHASE.FINAL_ASSAULT: case PHASE.HIDDEN_CORE:
startCombat(phase);
break;
case PHASE.RESULT:
pub.round = 14;
store.patch('match', { result: buildResult(!VARIANTS.has('defeat')) });
break;
default:
break;
}
refreshPrivate();
applyUiVariants();
}
// m.result in the exact shape of server/match/results.js buildResult (title = config.titles record, stats.gold = spent)
function buildResult(victory) {
const titles = (data.get('config')?.titles || []).filter((t) => victory || !t.onlyOnWin);
const pickTitle = (i) => { const t = titles[[0, 3, 5, 2][i] % Math.max(1, titles.length)]; return t ? { id: t.id, name: t.name, picId: t.picId, text: t.text } : null; };
return {
victory, roundsPassed: victory ? 14 : 11, hiddenReached: false, hiddenCleared: false, reason: victory ? 'victory' : 'defeat',
bossId: 'boss_5', hiddenBossId: 'boss_8', difficulty: 'HARD', modeId: 'mode_multi_hard', stageId: S.pub.stageId, durationMs: 52 * 60000,
players: S.pub.players.map((p, i) => ({
playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, left: false, alive: i !== 3, victory, lp: [12, 12, 12, 0][i], bandId: p.bandId,
roundsPassed: i === 3 ? 9 : victory ? 14 : 11, eliminatedRound: i === 3 ? 10 : null, title: pickTitle(i),
lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k, items: [] })),
bonds: shuffle(data.list('bonds')).slice(0, 4).map((b, k) => ({ bondId: b.bondId, count: 3 - (k % 3), layers: [359, 136, 34, 12][k], active: k < 3, tier: k < 3 ? 1 : 0 })),
stats: {
dmgDealt: [2310000, 1720000, 980000, 402000][i], kills: [412, 388, 301, 150][i], leaks: [3, 6, 9, 31][i], gold: [188, 164, 231, 90][i],
refreshes: [22, 18, 30, 8][i], merges: [7, 5, 9, 2][i], itemsEquipped: [9, 6, 11, 3][i], bossDamage: victory ? [912000, 610000, 240000, 0][i] : 0,
activatedLayers: [541, 402, 377, 120][i], lpLost: [16, 19, 12, 24][i], perfectRounds: [11, 12, 9, 4][i],
},
trophies: i === 3 ? 1 : 5, reward: i === 3 ? 170 : 425,
})),
};
}
// ---- combat simulation ------------------------------------------------------------------------------------------------
function stopBattle() { if (S?.battle?.timer) clearInterval(S.battle.timer); if (S) S.battle = null; }
function startCombat(phase) {
const pub = S.pub;
const boss = phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE;
pub.round = boss ? (phase === PHASE.HIDDEN_CORE ? 15 : 14) : 6;
pub.deadline = boss ? Date.now() + 95000 : Date.now() + 40000;
// boss rounds (server Match: deadline = the level's 120 s countdown, overtimeAt = the drain start at 150 s):
// `overtime` = the level time ran out, the drain starts in 18 s; `drain` = draining for 7 s
if (boss) {
const now = Date.now();
if (VARIANTS.has('drain')) { pub.deadline = now - 37000; pub.overtimeAt = now - 7000; }
else if (VARIANTS.has('overtime')) { pub.deadline = now - 12000; pub.overtimeAt = now + 18000; }
else pub.overtimeAt = pub.deadline + 30000;
}
// solo pause (g.pause → m.public.paused)
if (VARIANTS.has('paused')) pub.paused = true;
pub.players.forEach((p, i) => { p.status = phase === PHASE.SETTLE ? 'done' : i === 1 ? 'done' : 'combat'; });
if (phase === PHASE.UNITE) { pub.players[1].status = 'helping'; pub.players[0].status = 'helping'; }
// 联防 live counter (user playtest #6 item 7; server m.public unite / players[].uniteLeft / pendingLp): ?variant=leaker
// — you and 灰烬 let enemies through, the AI and Doctor·B help; otherwise Doctor·B leaked. The first leaker's count of
// enemies still standing (uncapped: the ×N tag) falls with every kill below.
const leakMock = phase === PHASE.UNITE && VARIANTS.has('leaker');
const leftOf = (pid, n) => { const p = pub.players.find((x) => x.playerId === pid); if (p) { p.uniteLeft = n; p.pendingLp = Math.min(10, n) || undefined; } };
if (phase === PHASE.UNITE) {
pub.unite = leakMock ? { helpers: ['ai_2', 'p3'], leakers: ['p1', 'p4'] } : { helpers: ['p1', 'ai_2'], leakers: ['p3'] };
if (leakMock) { leftOf('p1', 13); leftOf('p4', 3); pub.players[0].status = 'done'; pub.players[2].status = 'helping'; } else leftOf('p3', 12);
}
const countKill = () => {
if (phase !== PHASE.UNITE) return;
const pid = leakMock ? 'p1' : 'p3';
const p = S.pub.players.find((x) => x.playerId === pid);
if (p && p.uniteLeft > 0) { leftOf(pid, p.uniteLeft - 1); pushPublic(); }
};
if (VARIANTS.has('done')) pub.players[0].status = 'done';
let fields;
let rect;
let kind;
if (boss) {
kind = phase === PHASE.HIDDEN_CORE ? 'hidden' : 'boss';
fields = pub.players.length === 1 ? [{ fieldId: 'b1', kind, players: ['p1'], live: true }]
: [{ fieldId: 'b1', kind, players: ['p1', 'ai_2'], live: true }, { fieldId: 'b2', kind, players: ['p3', 'p4'], live: true }];
rect = { ...GEO.BOSS_RECT };
pub.teamLp = 61;
pub.bossHp = { hp: 1_240_000, max: 1_990_000 };
} else if (phase === PHASE.UNITE) {
kind = 'unite';
fields = [{ fieldId: 'u', kind: 'unite', players: ['p1', 'ai_2'], live: true }];
rect = { ...GEO.UNITE_RECT };
} else {
kind = 'normal';
fields = pub.players.map((p) => ({ fieldId: `n:${p.playerId}`, kind: 'normal', players: [p.playerId], live: true }));
rect = { ...GEO.NORMAL_RECT };
}
pub.fields = fields;
// like the server's m.public players[].fieldId (Match.fieldOf): the field each player fights on, null without one
for (const p of pub.players) p.fieldId = fields.find((f) => f.players.includes(p.playerId))?.fieldId ?? null;
const fieldRef = { id: fields[0].fieldId };
const fieldId = fieldRef.id;
const units = [];
let id = 1;
const board = S.priv.board;
for (const p of board) {
const c = data.lookup('chess', p.id);
const colOff = 0;
const r = boss ? p.row - 7 : p.row;
units.push({ id: id++, kind: 'op', side: 'ally', ownerId: ME, defId: p.id, name: c?.name, tier: c?.tier, golden: !!p.golden, spine: c?.charId, avatar: c?.assets?.avatar, x: p.col + colOff, y: r, facing: 1, maxHp: c?.stats?.maxHp || 1000, uid: p.uid });
}
if (kind !== 'normal') {
for (const p of board.slice(0, 5)) {
const c = data.lookup('chess', pick(S.pool).chessId);
const r = boss ? p.row - 7 : p.row;
units.push({ id: id++, kind: 'op', side: 'ally', ownerId: 'ai_2', defId: c.chessId, name: c.name, tier: c.tier, golden: false, spine: c.charId, avatar: c.assets.avatar, x: 20 - p.col, y: r, facing: -1, maxHp: c.stats.maxHp });
}
}
const enemyKeys = S.priv.nextEnemies.map((e) => e.enemyKey);
const enemies = [];
const nEnemy = boss ? 10 : 12;
for (let i = 0; i < nEnemy; i++) {
const key = enemyKeys[i % enemyKeys.length];
const e = data.lookup('enemies', key);
const y = boss ? [2, 5, 3][i % 3] : [9, 12][i % 2];
const startX = boss ? (i % 2 ? 17 : 3) : 10;
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: key, name: e?.name, spine: key, avatar: key, x: startX + rnd() * 0.3, y, facing: -1, maxHp: e?.stats?.maxHp || 3000, hp: e?.stats?.maxHp || 3000, spawnAt: i * 0.9, dir: boss ? (i % 2 ? -1 : 1) : -1, dead: false });
}
if (boss) {
const bossRec = data.lookup('bosses', phase === PHASE.HIDDEN_CORE ? 'boss_8' : 'boss_5');
const e = data.lookup('enemies', bossRec.enemyKey);
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
}
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
store.patch('match', { field });
const allyState = units.map((u) => ({ ...u, hp: u.maxHp * (0.55 + rnd() * 0.45), sp: rnd() * 20, spMax: 20 }));
const t0 = performance.now();
const total = enemies.length;
let killed = phase === PHASE.SETTLE ? total : 0;
if (phase === PHASE.SETTLE) enemies.forEach((e) => { e.dead = true; });
const spawned = new Set();
let pausedFor = 0;
let pausedSince = null;
const tick = () => {
// a paused solo battle stands still (the server's runner stops the clock too)
if (S.pub.paused) { if (pausedSince == null) pausedSince = performance.now(); return; }
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
const ev = [];
for (const e of enemies) {
if (e.dead || t < e.spawnAt) continue;
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
if (e.dir) e.x = Math.max(rect.c0 + 2.2, Math.min(rect.c1 - 2, e.x + e.dir * 0.035));
}
// attacks
const liveEnemies = enemies.filter((e) => !e.dead && t >= e.spawnAt);
for (const a of allyState) {
if (!liveEnemies.length || rnd() > 0.2) continue;
const tgt = liveEnemies[Math.floor(rnd() * liveEnemies.length)];
const dmg = Math.round(200 + rnd() * 700);
tgt.hp -= e2boss(tgt) ? dmg * 3 : dmg;
ev.push(['atk', a.id, tgt.id, 'arrow'], ['dmg', tgt.id, dmg, rnd() < 0.3 ? 'arts' : 'phys']);
a.sp = (a.sp + 1) % a.spMax;
if (tgt.hp <= 0 && !tgt.boss && !tgt.dead) { tgt.dead = true; killed++; ev.push(['die', tgt.id]); countKill(); }
}
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
];
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
const snap = { t: 'b.snap', fieldId: fieldRef.id, gt: t, units: snapUnits, dp: Math.min(99, 10 + Math.floor(t)), killed, total };
if (boss) snap.boss = { hp: S.pub.bossHp.hp, max: S.pub.bossHp.max };
if (ev.length) net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: t, ev });
net._emit('b.snap', snap);
};
const e2boss = (e) => !!e.boss;
S.battle = { timer: setInterval(tick, 50), fieldRef };
setTimeout(tick, 60);
}
// ---- mock server --------------------------------------------------------------------------------------------------------
const fail = (code) => { throw new NetError(code); };
function findPiece(uid) {
const p = S.priv;
let i = p.board.findIndex((x) => x.uid === uid);
if (i >= 0) return { area: 'board', i, piece: p.board[i] };
i = p.hand.findIndex((x) => x && x.uid === uid);
if (i >= 0) return { area: 'hand', i, piece: p.hand[i] };
i = p.temp.findIndex((x) => x && x.uid === uid);
if (i >= 0) return { area: 'temp', i, piece: p.temp[i] };
return null;
}
function removeAt(loc) {
const p = S.priv;
if (loc.area === 'board') p.board.splice(loc.i, 1);
else if (loc.area === 'hand') p.hand[loc.i] = null;
else p.temp[loc.i] = null;
}
function freeHandIdx() { for (let i = GEO.HAND_SIZE - 1; i >= 0; i--) if (!S.priv.hand[i]) return i; return -1; }
async function mockRequest(t, f = {}) {
await new Promise((r) => setTimeout(r, 40 + Math.random() * 60));
if (!S) fail('WRONG_PHASE');
S.requests.push([t, JSON.parse(JSON.stringify(f ?? {}))]); // E2E: what the UI sent
const p = S.priv;
const pub = S.pub;
const prepOnly = () => { if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE'); if (p.ready && t !== 'g.ready') fail('ALREADY'); };
switch (t) {
case 'g.infoReady': { const me = pub.players.find((x) => x.playerId === ME); me.ready = true; me.status = 'ready'; pushPublic(); return {}; }
case 'g.band': {
p.bandId = f.bandId; const me = pub.players.find((x) => x.playerId === ME); me.bandId = f.bandId;
if (pub.draft) { pub.draft.picks[ME] = f.bandId; pub.draft.turn = 'ai_2'; pub.draft.turnDeadline = Date.now() + 12000; }
refreshPrivate(); return {};
}
case 'g.bandSkip': {
if (!pub.draft) fail('WRONG_PHASE');
if (pub.draft.skipsLeft?.[ME] === 0) fail('ALREADY');
pub.draft.order = pub.draft.order.filter((x) => x !== ME).concat(ME); pub.draft.skipsLeft = { ...pub.draft.skipsLeft, [ME]: 0 }; pub.draft.turn = 'ai_2'; pushPublic(); return {};
}
case 'g.buy': {
prepOnly();
const s = p.shop.slots[f.slot];
if (!s || s.sold) fail('SOLD_OUT');
if (s.price > p.funds) fail('NO_FUNDS');
const idx = freeHandIdx();
if (idx < 0) fail('HAND_FULL');
p.funds -= s.price; s.sold = true;
p.hand[idx] = s.kind === 'item' ? itemPiece(data.lookup('items', s.id)) : chessPiece(data.lookup('chess', s.id));
refreshPrivate(); return {};
}
case 'g.refresh': {
prepOnly();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
const n = p.shop.slots.filter((s) => s.kind !== 'item').length;
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
refreshPrivate(); return {};
}
case 'g.freeze': prepOnly(); p.shop.frozen = !p.shop.frozen; refreshPrivate(); return {};
case 'g.levelUp': {
prepOnly();
if (p.shop.level >= 6) fail('MAX_LEVEL');
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
const n = p.shop.level >= 4 ? 5 : 4;
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level));
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
refreshPrivate(); return {};
}
case 'g.sell': {
prepOnly();
const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET');
if (loc.piece.kind === 'item') fail('BAD_TARGET');
removeAt(loc); p.funds += 1; refreshPrivate(); return {};
}
case 'g.destroy': { prepOnly(); const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET'); removeAt(loc); refreshPrivate(); return {}; }
case 'g.move': {
prepOnly();
const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET');
const to = f.to;
if (to.area === 'hand') {
const occ = p.hand[to.idx];
removeAt(loc);
if (occ) {
if (loc.area === 'board') { p.board.push({ ...occ, row: loc.piece.row, col: loc.piece.col }); }
else if (loc.area === 'hand') p.hand[loc.i] = occ;
else p.temp[loc.i] = occ;
}
const { row, col, dir, ...rest } = loc.piece; // eslint-disable-line no-unused-vars
p.hand[to.idx] = rest;
} else {
// the deploy wheel's facing, like PlayerState.move: top-level dir, else to.dir, else RIGHT (research 09 §1.2)
const d = ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(f.dir) ? f.dir : ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(to.dir) ? to.dir : 'RIGHT';
const oi = p.board.findIndex((x) => x.row === to.row && x.col === to.col);
const occ = oi >= 0 ? p.board[oi] : null;
if (occ && occ.uid === f.uid) { occ.dir = d; refreshPrivate(); return {}; } // re-orient in place
if (!occ && loc.area !== 'board' && loc.piece.kind === 'chess' && p.board.filter((x) => x.kind === 'chess').length >= p.deployCap) fail('BOARD_FULL');
if (occ) p.board.splice(oi, 1);
const locNow = findPiece(f.uid);
removeAt(locNow);
if (occ) {
if (loc.area === 'board') p.board.push({ ...occ, row: loc.piece.row, col: loc.piece.col });
else { const { row, col, dir, ...rest } = occ; if (loc.area === 'hand') p.hand[loc.i] = rest; else p.temp[loc.i] = rest; } // eslint-disable-line no-unused-vars
}
p.board.push({ ...loc.piece, row: to.row, col: to.col, dir: d });
}
refreshPrivate(); return {};
}
case 'g.equip': {
// like server PlayerState.equip: a third item replaces the equipped item the dialog picked (replaceUid, which
// must be one of the target's items), else the oldest; the replaced item is destroyed
prepOnly();
const it = findPiece(f.itemUid); const tg = findPiece(f.targetUid);
if (!it || !tg || tg.piece.kind !== 'chess') fail('BAD_TARGET');
if (f.replaceUid != null && !(tg.piece.items || []).some((x) => x.uid === f.replaceUid)) fail('BAD_TARGET');
removeAt(it);
const target = findPiece(f.targetUid).piece;
const items = [...(target.items || [])];
if (items.length >= 2) {
const i = f.replaceUid != null ? items.findIndex((x) => x.uid === f.replaceUid) : 0;
items.splice(i >= 0 ? i : 0, 1);
}
target.items = [...items, { uid: it.piece.uid, id: it.piece.id }];
refreshPrivate(); return {};
}
case 'g.pause': {
// solo battles only (server: g.pause {on} → m.public.paused)
if (S.pub.players.length > 1) fail('WRONG_PHASE');
if (![PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub.phase)) fail('WRONG_PHASE');
const now = Date.now();
if (f.on && !pub.paused) { pub.paused = true; pub.pausedAt = now; }
else if (!f.on && pub.paused) {
// the clocks resume where they stopped
const d = now - (pub.pausedAt || now);
if (pub.deadline) pub.deadline += d;
if (pub.overtimeAt) pub.overtimeAt += d;
pub.paused = false; delete pub.pausedAt;
}
pushPublic(); return {};
}
case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; }
case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
const idx = freeHandIdx(); if (idx < 0) fail('HAND_FULL');
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
}
case 'g.choice': {
if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET');
pub.sp.picks[ME] = f.idx; pub.sp.turn = 'ai_2'; pushPublic(); return {};
}
case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE');
if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {};
}
case 'g.emote': {
store.set((s) => ({ emotes: [...s.emotes.slice(-19), { seq: Date.now(), playerId: ME, id: f.id, at: Date.now() }] }));
return {};
}
case 'g.watch': {
// server Match.watch rules: the other pair's boss field is hidden from a fighting player; no dead targets
const target = (pub.fields || []).find((x) => x.fieldId === f.fieldId);
const mine = (pub.fields || []).find((x) => x.players.includes(ME));
const meAlive = pub.players.find((x) => x.playerId === ME)?.alive !== false;
if (target && (target.kind === 'boss' || target.kind === 'hidden') && meAlive && mine && mine.fieldId !== target.fieldId) fail('BAD_TARGET');
if (!target && String(f.fieldId).startsWith('n:') && pub.players.find((x) => x.playerId === f.fieldId.slice(2))?.alive === false) fail('BAD_TARGET');
if ([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.SETTLE].includes(pub.phase)) {
const cur = store.get().match.field;
if (S.battle?.fieldRef) S.battle.fieldRef.id = f.fieldId;
if (cur) store.patch('match', { field: { ...cur, fieldId: f.fieldId } });
} else {
const who = f.fieldId.replace(/^n:/, '');
if (who !== ME) {
const units = shuffle(S.pool).slice(0, 6).map((c, i) => ({ id: 900 + i, kind: 'op', side: 'ally', ownerId: who, defId: c.chessId, name: c.name, tier: c.tier, golden: i === 0, spine: c.charId, avatar: c.assets.avatar, x: [3, 4, 5, 7, 8, 5][i], y: [9, 9, 10, 11, 12, 12][i], facing: 1, maxHp: c.stats.maxHp }));
// like server/match/Match.js prepFieldMeta: a read-only prep board with THEIR coming enemies (the pen shows them)
const theirs = S.priv.nextEnemies.filter((e) => e.source !== 'bounty').slice(0, 3).map((e) => ({ ...e, gate: 'upper', count: e.count + 1 }));
store.patch('match', { field: { fieldId: f.fieldId, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: pub.stageId, units, prep: true, nextEnemies: theirs } });
}
}
return {};
}
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
default: return {};
}
}
// ---- UI variants (open panels for screenshots) ------------------------------------------------------------------------------
function applyUiVariants() {
const clickSel = (sel, delay = 250) => setTimeout(() => document.querySelector(sel)?.click(), delay);
// the enemy list is the 敌方情报 tab of the 本局信息 dialog (left 🔍); the right 🔍▶▶ pans to the pen
if (VARIANTS.has('drawer')) { clickSel('.gtop__iconbtn', 400); clickSel('.edrawer .tabs__tab:nth-child(2)', 700); }
if (VARIANTS.has('info')) clickSel('.gtop__iconbtn', 400);
if (VARIANTS.has('pen')) {
// the pen needs the field view: wait for it to mount
const t0 = Date.now();
const tryPen = () => {
if (globalThis.__SP_VIEW__ && document.querySelector('.enemybtn')) { setTimeout(() => document.querySelector('.enemybtn')?.click(), 300); return; }
if (Date.now() - t0 < 15000) setTimeout(tryPen, 100);
};
tryPen();
}
if (VARIANTS.has('bond')) clickSel('.bslot .bond', 500);
if (VARIANTS.has('emote')) {
clickSel('.ewheel__btn', 400);
setTimeout(() => {
const now = Date.now();
store.set({ emotes: [{ seq: now, playerId: 'p3', id: 'autochess_battle_nice_cooperate', at: now }, { seq: now + 1, playerId: 'ai_2', id: 'slug_autochess_battle_thanks', at: now }] });
}, 300);
}
if (VARIANTS.has('settings')) clickSel('.gm__gear', 400);
if (VARIANTS.has('collapsed')) clickSel('.funds__collapse', 400);
// the first tap on a shop card selects it and opens its detail (there is no ⓘ corner)
if (VARIANTS.has('detail')) clickSel('.shopbar__cards .scard:not(.scard--sold)', 500);
if (VARIANTS.has('detailpiece')) setTimeout(() => {
const el = document.querySelector('.ff-piece.is-golden') || document.querySelector('.ff-piece');
el?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 }));
}, 600);
if (VARIANTS.has('ticker')) setTimeout(() => store.set((s) => ({ ticker: [...s.ticker, { id: Date.now(), text: '<@ba.vup>Doctor·B博士</>对敌方领袖造成的伤害超过20%!', at: Date.now() }] })), 900);
if (VARIANTS.has('toast')) setTimeout(() => toast('资金不足', 'error'), 500);
}
// ---- switcher --------------------------------------------------------------------------------------------------------------
const SWITCH = [
['INFO_CHECK', PHASE.INFO_CHECK, ''], ['BAND_DRAFT', PHASE.BAND_DRAFT, ''], ['BAND_DRAFT solo', PHASE.BAND_DRAFT, 'solo'],
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'],
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP shop', PHASE.SP_DRAFT, 'shop'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['UNITE leaker', PHASE.UNITE, 'leaker'], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
['HIDDEN_CORE', PHASE.HIDDEN_CORE, ''], ['COMBAT solo (pause)', PHASE.COMBAT, 'solo'], ['FA solo paused', PHASE.FINAL_ASSAULT, 'solo,paused'],
['RESULT win', PHASE.RESULT, ''], ['RESULT lose', PHASE.RESULT, 'defeat'],
];
function Switcher() {
const cur = store.get().match.public?.phase;
return html`<h4>PHASE</h4>${SWITCH.map(([label, ph, v]) => html`<button class=${cur === ph ? 'on' : ''} onClick=${() => { VARIANTS.clear(); setPhase(ph, v); renderBar(); }}>${label}</button>`)}
<h4>EVENTS</h4>
<button onClick=${() => { const now = Date.now(); store.set((s) => ({ emotes: [...s.emotes, { seq: now, playerId: pick(['p3', 'ai_2', 'p4']), id: pick(['autochess_battle_happy', 'slug_autochess_battle_thanks', 'autochess_battle_call', 'autochess_battle_fooldoctor_05', 'autochess_battle_foolamiya_03', 'autochess_battle_foolwisdel_01']), at: now }] })); }}>teammate emote</button>
<button onClick=${() => store.set((s) => ({ ticker: [...s.ticker, { id: Date.now(), text: `<@ba.vup>${pick(['Doctor·B', '灰烬'])}博士</>将调度中心等级提升为5级`, at: Date.now() }] }))}>ticker</button>
<button onClick=${() => toast('资金不足', 'error')}>error toast</button>
<button onClick=${() => { if (S.priv) { S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) }; refreshPrivate(); } }}>merge reward</button>
<button onClick=${() => { S.priv.funds += 10; refreshPrivate(); }}>+10 funds</button>`;
}
const bar = document.getElementById('mockbar');
function renderBar() { render(html`<${Switcher} />`, bar); }
window.addEventListener('keydown', (e) => { if (e.key === '`') bar.classList.toggle('hidden'); });
if (SHOT) bar.classList.add('hidden');
// ---- boot ---------------------------------------------------------------------------------------------------------------------
async function boot() {
net.request = mockRequest;
store.set({
session: { entered: true },
me: { playerId: ME, name: '凯尔希', token: null },
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
clock: { offset: 0, rtt: 20, synced: true },
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
});
await data.loadAll(GAME_FILES);
installDeviceSupport();
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
const phase = params.get('phase') || 'PREP';
setPhase(PHASE[phase] || PHASE.PREP, params.get('variant') || '');
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
renderBar();
store.subscribe(() => renderBar());
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
}
boot().catch((err) => console.error('[mock] boot failed', err));