23 KiB
DESIGN §6, §16 — The match and meta engine (server/match), operator loadouts
Part of DESIGN.md (the index; section numbers are global).
6. Match & meta (server/match)
6.1 State machine (Match.js)
LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this
prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed;
a card is picked with two taps, §18.2)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助:
most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond >
Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains — with
the layers their own combat reached (the round's pending gains, PRTS "以其阵地当前的状态" [ASSUMED]);
a helper's operator knocked out at the end of its own combat enters down — carryState { down: true },
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
→ RESULT (per-player stats, titles, rounds passed, victory/defeat)
Solo FUNNY has 9 rounds (boss at R9); everything is read from data/config.json → modes[modeId].
6.2 Player state (PlayerState.js) — authoritative per player
{ playerId, seat, name, isBot, connected,
alive, lp, bandId,
funds, shop: { level, upgradePrice, slots: [ Slot ], frozen: bool, freeRefreshes: n, rewardOffer: null | { tier, source:'merge'|'special', label, queued, slots:[Slot] } }, // a special offer (凯瑟琳's 定向投放 …) by its source's label; queued = offers behind the shown one (§21.2)
hand: [ Piece|null ×10 ], temp: [ Piece|null ×5 ],
board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters
counters: { refreshes, buys, sells, … },
stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen }
Piece = { uid, kind:'chess'|'item'|'token', id /* chessId (golden id if elite) | itemId | tokenId */,
items:[itemPiece...] (chess only, max 2), count (token stacks), ownerUid (tokens), boughtRound,
meta: { round?: { r, n: { key: count } } } } // per-piece counters of the current round, module-prefixed keys (拉普兰德's first refresh, §21.1)
Intent handlers (validate → mutate → recompute bonds → push private view): buy(slot), refresh(), freeze(), levelUp(), sell(uid), move(uid, to), equip(itemUid, targetUid, replaceUid?), useArt(itemUid, row, col), destroy(uid), pickReward(idx), pickChoice(idx), setReady(bool). Every handler rejects when the phase forbids it (only PREP allows board/shop mutations; SP_DRAFT allows pickChoice only; reward pick allowed in PREP). Invalid intents return error with a code from shared/constants.js → ERR — the server never throws on bad input.
Summons (§20.1): an operator on the board sends its manually deployable summons to the hand — deploy-limit copies in one stack, no deploy slot (tokens.json placeable, gamedata.placeableTokens); moving or swapping it sends its placed summons back to their stack, withdrawing or selling it removes them. A range-bound summon (tokens.json ownerRange: 狼群, 流形) goes only on a tile of its owner's attack range (PlayerState._legal / summonRange); in a swap each piece is checked from the tile it ends on; an in-place re-orientation or promotion that leaves it outside sends it back to its stack with a toast (one still inside stays [ASSUMED], user-confirmed 2026-10-03), and a re-orientation whose lifted summon would have no room is refused with HAND_FULL; on the other paths it leaves the board and returns at the next round start (§21.3).
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item replaceUid names (the replace dialog's pick, official UseEquipUp.unloadInstId; absent ⇒ the oldest; a uid not equipped on the target ⇒ BAD_TARGET, nothing changes) and the replaced item is destroyed. Equipped items are otherwise locked: destroy (销毁) accepts hand / temp items only (BAD_TARGET 'equipped items are locked'); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with HAND_FULL when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income min(3+r,12); prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 different free chess of tier min(level+1, 6) (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (PlayerState._fillHandFromTemp); temp blocks ready).
Temp overflow (临时整备区, row 8; user playtest #3 item 3). Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the first prep in which its player could act on it (PlayerState.tempDue(piece) vs prepsEnded; every write into a temp slot goes through _putTemp, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the <休整期结束时> effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the next prep. Un-readying (setReady(false)) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). server/match/audit.js (tools/matchrun.mjs --check) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
6.3 Bonds & layers (bondsMeta.js)
count= number of distinct base chess on the board carrying the bond (BOARD);BOARD_AND_DECKbonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count that has a real member on the board (the views mark such an entryharmony: 1, §21.26), etc. (research 02).- 变形同构体: an operator wearing it together with an item that has a
giveBondIdis a member of that bond — counted like any member (BOARD: on the board only; once per operator), in the battle, and in the client's member list (tagged 同构) and bond chips (§21.11). tierfrom thresholds;active = tier ≥ 1.layers[bondId]persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected fromBattleResult.layerGainsat SETTLE (disabled in unite/boss/hidden).- A bond holds at most
BOND_LAYER_CAP= 999 layers (the client'sMAX_GARRISON_STACK, §20.12): every writer —PlayerState.addLayers, the SETTLE merge,Battle.addLayers, the dev tools — addslayerGainRoom(before, n)= min(n, 999 − before); a gain at the cap adds 0 and dispatches nothing (no onLayers, nolayerGainhook, no 'layer' event), so the per-N milestones stop with the count. - Σ activated layers (hidden-core check) = Σ layers over bonds that are active at the moment of the check (end of R14 prep, per research).
6.4 Prep-phase effect dispatcher (effectsMeta.js)
A registry mapping effect keys → handler objects { onRoundStart, onPrepEnd, onGain(piece), onSold(piece), onRefresh, onPrice(slot), onBuy, onMerge, onLevelUp, onBattleResult }. Sources: garrison SERVER_* keys (per chess on board or hand as the key requires), band effects, item meta effects, bond meta effects (助力, 远见, 奇迹, 投资人…), choice/team cards. Content owners register handlers; the dispatcher owns ordering and the 投资人 "获得时 ×2/×3" multiplier. onBuy = a shop purchase (g.buy) only: a free pick or a grant never counts as 购买 (休露丝, 玛恩纳, 升华, 整备; §21.21). Equipped items run from a snapshot — the owned chess and each holder's items as that step begins (every [holder, item] pair, taken before the first item runs) — each only while it is still equipped on its still-owned holder (handlers move and destroy pieces: 突变细胞, 博士投影; §21.25).
6.5 Waves (waves.js)
Per match: pick stage (weighted among the mode's config.modes[m].stages, weight 50 each: 标准 FUNNY = 战场#01 act1autochess_m01 only, 险境 NORMAL = 8 stages, 绝境 / 终极 = 7 without 战场#01 — waves.js setupMatchWaves, test/match/stagepick.test.js), 3 of 6 typed enemy factions, disabled bond set (3 core + 4 add-on for NORMAL+, per research), boss (weighted), hidden boss. Per round: wave template (by mode & round) → replace placeholders per research 05 §3.2 algorithm → apply stat multipliers (research 00-INDEX §5 atk ×base×1.1^k, hp ×base×1.2^k, ABYSS speed ×1.15 from R3) → SpawnSpec[] + routes resolved on the stage. (Superseded by the official generator — §14 "Corrections from research 08/09" and the waves.js header.) A player's normal battle (and the bot rehearsal) runs withBounties(gd, round, wave, bounties, playerId): the bounty units inserted among their host action's own units, which are re-timed around them (client order); previewOf keeps one entry per action (wave.spawns + bountySpawns).
6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — gd.bandBondIds, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within PlayerState.summonRange; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; g.move judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (botPrepBeginSteps, 25–70 ms late in a 4-bot match), the layout rehearsal and botPrepEndSteps — runs in wall-clock slices of botSliceMs (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score DIY_PIECE_BONUS (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (levelUp, diyOpensAt; 0.2.0 — bots field no 自选 piece).
16. Operator loadout: skill & module selection (v2.1, user playtest #1)
Official rule (bwiki 盟约, 更新公告 5114): before a match the player cannot change an operator's tier, but can change the equipped skill and module of each chess; some non-default choices are stronger. Scope in the remake (no accounts ⇒ everything the chess's status unlocks is selectable):
- Skills: any skill unlocked at the chess's status (
evolvePhase: E1 ⇒ S1–S2, E2 ⇒ S1–S3) at the chess's skill level (normal Lv4, elite Lv7). Default =defaultSkillIndex. - Modules: only matter for the elite (golden) chess (
equipLevel1, or 3 at T6): any module of that character (or none); default =defaultUniEquipId. Normal chess have no module (equipLevel 0).
Data (data/chess.json, built by tools/build-data.mjs): every chess keeps skill (the default, unchanged shape) and gains skills: [SkillRecord…] (index, skillId, iconId, name, desc/descRaw, skillType, durationType, duration, spType, spCost, initSp, maxChargeTime, bb (at the chess's skill level), bbStr, rangeGrid, trigger {rule, customRangeGrid} resolved for THAT skill index, isDefault). Golden chess gain modules: [ModuleRecord…] = { uniEquipId, name, typeName, icon, isDefault, attr: {maxHp, atk, def, res, aspd, cost, blockCnt, respawnTime…} (flat adds at the chess's equipLevel), traitOverride: {desc, descRaw, bb} | null, talentChanges: [{talentIndex, name, desc, bb}] } plus statsBase (stats WITHOUT any module); stats stays = stats with the default module (back-compat).
Loadout wire & state: the client keeps a per-browser loadout { [baseChessId]: { skill: index, module: uniEquipId | 'none' } } (localStorage) and sends it with C2S room.loadout { entries } (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start Match receives seats[].loadout; PlayerState keeps it immutable for the match; board pieces are resolved to {chessId, skillIndex, moduleId} when building PlayerBattleInput.units[] (fields skillIndex, moduleId; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
Sim: simdata resolves a unit def for (chessId, skillIndex, moduleId) (cached): skill = the selected SkillRecord; stats = statsBase + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function (bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait } receives chess.skill = the selected skill and bb = its blackboard; the loader uses skills[selectedSkillId] if present, else skill only when the selected skill is the chess's default, else the generic skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
UI: a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows its owner's skill/module from its UnitInfo skillIndex / moduleId, gameLogic.js unitLoadout) — which also shows the stats, 特性, talents and attack range the unit fights with (shared/loadoutRecord.js loadoutRecord / attackRangeGrid, the same composition the sim's getChess(id, loadout) uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live range (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (UnitInfo.skillIndex; data/assets.json lists icons, clips and sounds for every skill index).
导出 / 导入 a loadout: 导出 hands the current loadout out as a versioned envelope { kind: 'stronghold.loadout', v, exportedAt, count, entries } — copied through ui/clipboard.js copyText or downloaded as a file. 导入 accepts that envelope, the stored { v, entries } form and a bare { [chessId]: { skill, module } } map (serialised text too), and refuses another tool's kind, junk, an empty result and a newer v (parseImport — a newer payload is never mis-read). Both go through loadoutSync.applyLoadoutEntries → sanitizeEntries, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (ui/loadoutModel.js, screens/loadout.js).
干员持有 (0.2.0 补位): the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser sp.pref.ownership = { v, notOwned }, synced as room.ownership { notOwned } by the same sync engine as the loadout, ui/loadoutSync.js installPrefSync; 导出 / 导入 as { kind: 'stronghold.ownership', v, exportedAt, count, notOwned }). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
自选编队 (0.2.0): the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (welcome.diyKitted). Per-browser sp.pref.diy = { v, picks }, synced as room.diy { picks } by the same sync engine; 导出 / 导入 as { kind: 'stronghold.diy', v, exportedAt, count, picks } (ui/diyModel.js, screens/diy.js). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (ui/gameLogic/diy.js); the 0.2.0 自选编队 design subsection has the rules and sources.