// Shared enums & constants (server + browser). Pure ESM, no Node APIs. import { N_ } from './i18n.js'; export const PROTOCOL_VERSION = 1; /** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version" * (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */ export const APP_VERSION = '0.2.4'; /** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and * tools/package.mjs refuses to build a release zip from it without --allow-dev. */ export const DEV_BUILD = /-dev$/.test(APP_VERSION); export const MAX_SEATS = 4; /** * Spectator seats of a co-op room (community report #26, owner's decision 2026-10-04) — a remake feature: the official * room has 1–4 players and no spectator seat (there only eliminated players and 联防 bystanders watch, research 09 §3.1). * A spectator never counts as a player, may not act, and watches like an eliminated player (server/lobby.js spectate, * server/match/match/watch.js addSpectator). */ export const MAX_SPECTATORS = 2; export const ROOM_CODE_LEN = 4; export const NAME_MAX_LEN = 12; export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']; export const DIFFICULTY_NAMES = { FUNNY: N_('标准模拟'), NORMAL: N_('险境模拟'), HARD: N_('绝境模拟'), ABYSS: N_('终极模拟') }; export const DIFFICULTY_COLORS = { FUNNY: '#f6a329', NORMAL: '#e85a1a', HARD: '#e73118', ABYSS: '#ff0024' }; // modeId in data/config.json = `mode_${type}_${difficulty.toLowerCase()}` with type single|multi export const modeIdFor = (roomMode, difficulty) => `mode_${roomMode === 'solo' ? 'single' : 'multi'}_${difficulty.toLowerCase()}`; export const PHASE = Object.freeze({ LOBBY: 'LOBBY', INFO_CHECK: 'INFO_CHECK', BAND_DRAFT: 'BAND_DRAFT', BATTLE_CHECK: 'BATTLE_CHECK', ROUND_START: 'ROUND_START', SP_DRAFT: 'SP_DRAFT', PREP: 'PREP', COMBAT: 'COMBAT', UNITE: 'UNITE', SETTLE: 'SETTLE', FINAL_ASSAULT: 'FINAL_ASSAULT', HIDDEN_CORE: 'HIDDEN_CORE', RESULT: 'RESULT', }); export const PHASE_NAMES = { LOBBY: N_('等待中'), INFO_CHECK: N_('确认本局信息'), BAND_DRAFT: N_('选择策略'), BATTLE_CHECK: N_('协议启动'), ROUND_START: N_('回合开始'), SP_DRAFT: N_('机变阶段'), PREP: N_('休整期'), COMBAT: N_('作战中'), UNITE: N_('联防阶段'), SETTLE: N_('结算'), FINAL_ASSAULT: N_('最终攻势'), HIDDEN_CORE: N_('隐秘核心'), RESULT: N_('模拟结束'), }; // Board geometry on the 19x21 stage grid (row 0 = bottom). See DESIGN §3. export const GEO = Object.freeze({ ROWS: 19, COLS: 21, FIELD: { r0: 9, r1: 12, c0: 2, c1: 10 }, // own deployable board region NORMAL_RECT: { r0: 9, r1: 12, c0: 0, c1: 10 }, // simulation rect for a normal battle UNITE_RECT: { r0: 9, r1: 12, c0: 0, c1: 20 }, BOSS_RECT: { r0: 0, r1: 5, c0: 0, c1: 20 }, HAND_ROW: 7, HAND_SIZE: 10, // hand slot idx = col 0..9 TEMP_ROW: 8, TEMP_C0: 4, TEMP_SIZE: 5, // temp slot idx 0..4 = cols 4..8 PARTNER_COL_OFFSET: 8, }); export const AREA = Object.freeze({ BOARD: 'board', HAND: 'hand', TEMP: 'temp', OUTSIDE: 'outside' }); export const PIECE_KIND = Object.freeze({ CHESS: 'chess', ITEM: 'item', TOKEN: 'token' }); /** * Whether the placed piece of a skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃) also deploys once, for free, at the * battle start. true = the PRTS reading (卫戍协议/帮助 §作战阶段: "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时 * 立即部署一次", example 赫默's drone), settled by the user on 2026-10-01 after playtest #6 (DESIGN §20); false = the * playtest #4 reading ("是赫默开技能释放一次,不是开局直接就部署了"): it takes its tile only when the owner's skill gives one. * Either way the piece re-appears on its tile each time the owner's skill gives one. * One switch for everything that depends on it: the sim (server/sim/content/tokens.js dockSkillSummons) and the summon * card's hint (public/js/ui/detailPanel.js summonDeployHint). docs/PLAYING.md §4 and docs/SIM.md (token pieces) state * the rule in prose — test/ui/playtest6_summons.test.js fails until they match the value. */ export const SKILL_SUMMON_START_DEPLOY = true; /** * Official per-bond layer cap (docs/research/11-limits-official.md §1): the client's * `Torappu.Battle.AutoChessBattleConst.MAX_GARRISON_STACK = 999`, and its bond counter (`AddBondCount`) stores * `min(L + n, 999)` — each bond stops at 999 on its own; the community reports bonds sitting at 999 while fed (巴哈姆特 * 12534 "每把都能999层", 12316 "999謝"; research 02 §layers). The only implementation of the cap (DESIGN §20.12). Every * writer of a bond's layers goes through `layerGainRoom`: the prep-side gains (server/match/player/economy.js addLayers — * 特质, items, bands, 机变 cards, bonds), the settle of the in-battle gains (server/match/match/settle.js) and the live in-battle * copy (server/sim/battle/economy.js addLayers, as the client's AddBondCount) — and the dev tools' direct writes (tools/matchrun.mjs * --layers, tools/balance.mjs applyBoard); a gain at the cap adds 0 (no onLayers, no 'layer' * event), and the client-result check (server/match/fields.js) bounds a reported gain by the room left. Milestones paid * per N layers (远见, 奇迹, 维多利亚 …) stop with the count. 0 / Infinity = no cap. */ export const BOND_LAYER_CAP = 999; /** * The layers a gain of `n` actually adds to a bond holding `before` under BOND_LAYER_CAP: min(n, cap − before), never * negative (a count already at or over the cap gains 0 and is never lowered); 0 for a non-positive / non-finite `n` * except +Infinity (= "the room left"). */ export function layerGainRoom(before, n) { if (!(n > 0)) return 0; const cap = BOND_LAYER_CAP > 0 ? BOND_LAYER_CAP : Infinity; const b = Number.isFinite(before) && before > 0 ? before : 0; return Math.max(0, Math.min(n, cap - b)); } /** * Official boss-hit limit "限伤" (docs/research/11-limits-official.md §2): `AutoChessBattleConst.MAX_BATTLE_DAMAGE = * 300000`. In a boss battle outside training — our battle kinds 'boss' (Final Assault) and 'hidden' (Hidden Core) — a * single hit on a leader (`AutoChessBattleUtil.IsBossEnemy`: an enemyId of activity_table autoChessData.bossInfoDict * = data/bosses.json `enemyKey`; in the sim the tag-'boss' units: those leaders and their mirrored copies, never parts, * escorts or drones) whose `ceil(final damage)` ≥ this is CANCELLED: 0 damage, nothing credited to the shared pool * (`AutoChessStepModeManager._OnBossEnemyTakeDamage` → `modifier.Cancel()`). It is not a clamp: a hit of 299999 lands. * Checked in server/sim/damage.js (dealDamage after DEF / RES and every multiplier, before shields; Battle.loseHp — not * the 胄 drone link's pool share, `noHitLimit`, DESIGN §25.13.4). Minions, normal rounds and 联防 are unaffected. * 0 / Infinity = off. */ export const BOSS_HIT_LIMIT = 300000; // Snapshot unit flag bits (DESIGN §8.2) export const UF = Object.freeze({ BLOCKED: 1, STUNNED: 2, FROZEN: 4, STEALTH: 8, SKILL: 16, SHIELD: 32, INVULN: 64, COLD: 128, SLEEP: 256, FLYING: 512, }); export const ANIM = Object.freeze({ IDLE: 0, MOVE: 1, ATTACK: 2, SKILL: 3, DIE: 4, STUN: 5, DEPLOY: 6 }); export const ERR = Object.freeze({ BAD_MSG: 'BAD_MSG', // malformed / unknown message SESSION_IN_USE: 'SESSION_IN_USE', // a local recovery candidate cannot replace the connected holder RATE: 'RATE', // rate limited NOT_IN_ROOM: 'NOT_IN_ROOM', ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', ROOM_FULL: 'ROOM_FULL', ROOM_STARTED: 'ROOM_STARTED', NOT_HOST: 'NOT_HOST', NOT_READY: 'NOT_READY', WRONG_PHASE: 'WRONG_PHASE', NO_FUNDS: 'NO_FUNDS', HAND_FULL: 'HAND_FULL', BOARD_FULL: 'BOARD_FULL', BAD_TILE: 'BAD_TILE', BAD_TARGET: 'BAD_TARGET', SOLD_OUT: 'SOLD_OUT', MAX_LEVEL: 'MAX_LEVEL', NOT_YOUR_TURN: 'NOT_YOUR_TURN', ALREADY: 'ALREADY', TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY', ELIMINATED: 'ELIMINATED', SPECTATOR: 'SPECTATOR', // a spectator seat only watches (MAX_SPECTATORS) INTERNAL: 'INTERNAL', }); export const ERR_TEXT = { SESSION_IN_USE: N_('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'), ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'), WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'), BAD_TILE: N_('无法部署在该位置'), BAD_TARGET: N_('无效的目标'), SOLD_OUT: N_('已售出'), MAX_LEVEL: N_('调度中心已达最高等级'), NOT_YOUR_TURN: N_('尚未轮到你'), ALREADY: N_('已完成该操作'), TEMP_NOT_EMPTY: N_('临时整备区不为空'), ELIMINATED: N_('你已被淘汰'), SPECTATOR: N_('观战中无法进行该操作'), INTERNAL: N_('服务器内部错误'), }; // ---- Emotes (交流, research 09 §4) ----------------------------------------------------------------------------- // The 36 official in-match emotes: display_meta_table emoticonData, scene AUTOCHESS_BATTLE, 6 themes × 6, one wheel // page per theme in activity_table autoChessData.enabledEmoticonThemeIdList order, emotes by sortId. `g.emote { id }` // / `m.emote { playerId, id }` carry the official emoji id. Generated reference: data/emotes.json // (tools/build-emotes.mjs); test/ui/emotes.test.js keeps this table identical to it. // Official emotes have no text (desc is null): `label` is ours and only ever an aria-label, never displayed. // Art: extracted from a local client (tools/local-extract) to /assets/local/emoticon/