Files
ark-stronghold-protocol/public/dev/game-mock.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

743 lines
43 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('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 }));
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 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));