卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
// Shared enums & constants (server + browser). Pure ESM, no Node APIs.
export const PROTOCOL_VERSION = 1;
export const MAX_SEATS = 4;
export const ROOM_CODE_LEN = 4;
export const NAME_MAX_LEN = 12;
export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
export const DIFFICULTY_NAMES = { FUNNY: '标准模拟', NORMAL: '险境模拟', HARD: '绝境模拟', ABYSS: '终极模拟' };
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: '等待中', INFO_CHECK: '确认本局信息', BAND_DRAFT: '选择策略', BATTLE_CHECK: '协议启动',
ROUND_START: '回合开始', SP_DRAFT: '机变阶段', PREP: '休整期', COMBAT: '作战中', UNITE: '联防阶段',
SETTLE: '结算', FINAL_ASSAULT: '最终攻势', HIDDEN_CORE: '隐秘核心', RESULT: '模拟结束',
};
// 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' });
// 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
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',
INTERNAL: 'INTERNAL',
});
export const ERR_TEXT = {
BAD_MSG: '无效的请求', RATE: '操作过于频繁', NOT_IN_ROOM: '你不在房间中', ROOM_NOT_FOUND: '未找到该同盟密钥对应的房间',
ROOM_FULL: '房间已满', ROOM_STARTED: '模拟已开始', NOT_HOST: '只有房主可以操作', NOT_READY: '仍有玩家未就绪',
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限',
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级',
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰',
INTERNAL: '服务器内部错误',
};
// ---- 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/<dir>/<picId>.png and listed in
// data/local-assets.json group `emoticon/<dir>`; the UI shows a neutral glyph when it is absent. The picId is not
// derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle).
const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
export const EMOTE_THEMES = Object.freeze([
{ themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_happy', 1001, 'pic_happy_battle', '开心'),
emo('autochess_battle_scared', 1002, 'pic_scared_battle', '害怕'),
emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', '对不起'),
emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', '谢谢'),
emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', '思考'),
emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', '合作愉快'),
] },
{ themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: '表情套组:虫动', emotes: [
emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', '合作愉快!'),
emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', '谢谢!'),
emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', '对不起!'),
emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', '再见!'),
emo('slug_autochess_battle_distrust', 2005, 'pic_distrust_battle', '???'),
emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', '很快就好!'),
] },
{ themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', '没问题!'),
emo('autochess_battle_respect', 1008, 'pic_respect_battle', '敬礼!'),
emo('autochess_battle_call', 1009, 'pic_call_battle', '欢呼!'),
emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', '酷!'),
emo('autochess_battle_sad', 1011, 'pic_sad_battle', '伤心'),
emo('autochess_battle_dying', 1012, 'pic_dying_battle', '快死了'),
] },
{ themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: '表情套组:博士士', emotes: [
emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', '博士士 1'),
emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', '博士士 2'),
emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', '博士士 3'),
emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', '博士士 4'),
emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', '博士士 5'),
emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', '博士士 6'),
] },
{ themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: '表情套组:米米子', emotes: [
emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', '米米子 1'),
emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', '米米子 2'),
emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', '米米子 3'),
emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', '米米子 4'),
emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', '米米子 5'),
emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', '米米子 6'),
] },
{ themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: '表情套组:维维美', emotes: [
emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', '维维美 1'),
emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', '维维美 2'),
emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', '维维美 3'),
emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', '维维美 4'),
emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', '维维美 5'),
emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', '维维美 6'),
] },
].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) })));
/** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */
export const EMOTE_CATALOG = Object.freeze(EMOTE_THEMES.flatMap((t) => t.emotes.map((e) => Object.freeze({ ...e, themeId: t.themeId, dir: t.dir }))));
/** The 36 official emote ids (protocol whitelist: `EMOTES.includes(id)`). */
export const EMOTES = Object.freeze(EMOTE_CATALOG.map((e) => e.id));
const EMOTE_INDEX = new Map(EMOTE_CATALOG.map((e) => [e.id, e]));
/** Catalog record of an emote id, or null (safe for any input, including '__proto__'). */
export const emoteInfo = (id) => (typeof id === 'string' && EMOTE_INDEX.get(id)) || null;
// id-keyed maps without a prototype: a wire id like '__proto__' / 'toString' looks up undefined, never an Object method
const emoteMap = (pick) => Object.freeze(Object.assign(Object.create(null), Object.fromEntries(EMOTE_CATALOG.map((e) => [e.id, pick(e)]))));
/** Emote id → theme id. */
export const EMOTE_THEME = emoteMap((e) => e.themeId);
/** Emote id → our aria-label (never displayed). */
export const EMOTE_LABEL = emoteMap((e) => e.label);
/** data/local-assets.json group of an emote's art (`emoticon/<dir>`), or null. */
export const emoteArtGroup = (id) => { const e = emoteInfo(id); return e ? `emoticon/${e.dir}` : null; };
/** Canonical URL of an emote's extracted art (`/assets/local/emoticon/<dir>/<picId>.png`), or null for unknown ids. */
export const emoteArtPath = (id) => { const e = emoteInfo(id); return e ? `/assets/local/emoticon/${e.dir}/${e.picId}.png` : null; };
export const EMOTE_COOLDOWN_MS = 1000; // activity_table autoChessData.constData.chatCD (s)
export const EMOTE_BUBBLE_MS = 3000; // constData.chatTime (s): how long a bubble stays up