Files
ark-stronghold-protocol/docs/META.md
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

506 lines
42 KiB
Markdown
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.
# META.md — match & meta engine (server/match)
Audience: **content authors** writing prep-side ("SERVER_*") effects in `server/sim/content/*.js → registerMeta(registry)`,
the **UI owner** consuming `m.public` / `m.private` / `m.result`, and anyone driving matches in tests or tools.
Normative contracts stay in DESIGN.md §6 and §8; this file documents the implementation and every assumption it makes.
```
server/match/
Match.js state machine, timers, round loop, co-op orchestration, views (the lobby⇄match interface is at the top)
PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler
gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
(data/tuning.json, §3.1)
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls
board.js placement legality from the stage legend, slot helpers, deployment order
bondsMeta.js bond counting modes, tiers, 调和 / 独行 / 助力 / 绝技, layers
effectsMeta.js MetaRegistry + EffectDispatcher + the handler ctx (this document, §2)
builtinMeta.js engine built-ins (consume-on-equip items, Arts, EffectRefs used by 机变 defaults)
choices.js 机变 draft cards + family defaults
waves.js stage / factions / bosses per match, per-round spawns, bounties, 联防 wave, preview
unite.js 联防 planning and LP attribution
finalAssault.js pairing, boss pool, hidden-core condition
fields.js FieldRunner: battle pacing (2×), snapshots, per-field error isolation
results.js m.result rows, titles, trophies, rewards
bot.js AI player (AI teammates, departed humans, "AI 托管")
scheduler.js RealScheduler / VirtualScheduler
StubMatch.js the old platform stub (platform tests only)
```
---
## 1. Match flow
```
LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden):
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp wiped (reward offers earned after
the last prep — a SETTLE merge — are kept for this prep),
shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview),
onRoundStart dispatch
[SP_DRAFT] r ∈ modes[m].spRounds (机变)
PREP onPrepStart; co-op timer rounds[r].prepTime, solo untimed; ends when every alive seat is ready
(prep end) onPrepEnd, temp auto-resolved, reward offers expire, unfrozen shop cleared, funds lost
(band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check
COMBAT one Battle per alive player (FieldRunner, 2× game speed; limit = 2 × maxPlayTime game s, §3)
UNITE co-op, ≥ 1 leaker and ≥ 1 perfect player (§4)
SETTLE (3 s) LP, coins, layers, bounties, eliminations, onBattleResult
boss round → FINAL_ASSAULT (instead of COMBAT/UNITE/SETTLE) hidden round → HIDDEN_CORE
→ RESULT (m.result to every human, then onEnd once)
```
| Timer (real s, × `opts.timerScale`) | Value |
|---|---|
| INFO_CHECK | `config.timers.infoCheck` 25 |
| band draft turn / whole step | `bandTurn` 12 / `bandDraft` 50 (co-op; solo untimed) |
| BATTLE_CHECK | `battleCheck` 3 |
| 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo untimed) |
| PREP | `modes[m].rounds[r].prepTime` (co-op; solo untimed) |
| COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds |
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) |
| ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) |
| bot action delay | 0.9 s (+0.35 s per seat) |
`m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×;
最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with
`m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock).
### 1.1 Band draft
Co-op: random order (all seats, bots included), one pick per turn, 12 s per turn (timeout ⇒ `bandDraft.timeoutBandId`
华法琳), 50 s cap for the whole step (every unpicked seat gets 华法琳), one skip per player (`g.bandSkip`: the player moves to
the end of the order; refused when nobody is left to pass to), duplicates allowed, band must list the mode type in
`modeTypeList`. Solo: free pick, no timer, no skip. Starting LP = `bands[id].totalHp`.
### 1.2 机变 (SP draft)
Family = weighted pick from `choices.schedule[modeId].rounds[r].families`; cards: co-op 6 shared (each player takes 1,
random order, 30 s first / 16 s others, timeout ⇒ a random remaining card), solo 3 untimed. A 驰援 tactic card
(`single_special_choice_gain_bond_chess`) is only offered while its bond still has chess in this match's pool
(`Match.bondInPool`; a bond whose every member is banned would grant nothing); more generally a 驰援 or 盟誓
(`global_special_choice_bond_addlayer`) card is offered only while one of its bonds is live (`Match.bondLive`: not in
the mode's static `inactiveBondIds` — 标准 has no 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and with chess in the pool), so
玛恩纳的盟誓 / 莫斯提马的盟誓 / 卡西米尔驰援 never show up in 标准. Card generation and the
family defaults are documented in `choices.js` (bounty / supply / shop / tactic). 机密商店 cards are **free** (official
text "无需消耗资金"). A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (temp sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime`
(86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect`
resends `m.public`, `m.private` and the `b.start` of the field the player is on / watching.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off.
* `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 /
§10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final
Assault pairing (re-planned when it quits before the boss fight; the boss pool has no alive-player factor, so it does
not change); its running normal battle is force-ended; a pending band pick
becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived.
When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`.
### 1.3a Solo pause
`g.pause { on }` (`Match.setPause`, DESIGN §14 Solo pause): solo matches only (co-op → `WRONG_PHASE`), `on: true` only
while a battle runs (COMBAT / 最终攻势 / 隐秘核心 with a live field, not while the Final Assault is ending); `on: false`
always. While paused (`m.public.paused`) the field clocks, the authority deadlines / release timers, the boss clock
(overtime drain, silence watchdog) and the server pacers stand still; `_resume()` shifts `deadline`, `overtimeAt`, the
fields' `startAt` / `lastProgressAt` and the boss start by the paused time and re-arms the timers. A disconnect or
`onLeave` resumes; the battle phase ending clears it (`pausedMs` totals the paused time).
### 1.4 Watching fields
Client-side combat (the default, DESIGN §14 Spectating; `Match._watchClient`): `g.watch` answers with the field's
`b.start` (a display replica fast-forwarded to the field clock); an alive player may not watch another normal field
while its own normal battle runs (`WRONG_PHASE 'own battle running'`) nor the other pair's boss field (`BAD_TARGET 'other
group hidden'`); eliminated players watch anything. The rest of this section is the legacy server-run mode
(`SP_COMBAT=server`):
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
`'n:<pid>'` returns a one-shot board view — during a battle phase an `'n:<pid>'` id must name a live field
(`BAD_TARGET 'no such field'` otherwise, and the viewer keeps its stream). In the
最终攻势 / 隐秘核心 a player fighting in a boss field may only watch its own field ("两名参与者会处于同一个战场,但无法查看
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
### 1.5 AI player (bot.js)
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), then levels on a curve
(L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free levels always), then
spends the rest on merge progress, bond thresholds around a focus core bond (most owned members, ties → most copies
left in the shared pool), role needs (2 blockers, anti-air when the wave flies, ≤ 2 healers), 特质 that keep adding
layers (every prep / refresh — the layer engines) and items for free carriers; refreshes while a purchase stays
affordable; sells bench chess that neither make the lineup nor build toward something (a live pair, the focus bond,
an elite). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`). Placement
uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground
paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS of
the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then
damage dealers, then healers). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the
own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts
as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders.
**Rehearsal:** with `opts.botRehearsal = N` (default 3) the bot simulates the N best
distinct layout variants once each with the real `Battle` (a rehearsal seed, no meta dispatch, board restored exactly)
and keeps the one with the fewest leaks; a candidate whose counted leaks already exceed the best finished one's stops
early. A candidate is a whole battle — ≈ 20–300 ms of CPU (late rounds, 4 bots: 0.2–1 s per bot prep, about twice the
CPU of the real battles), so it never runs in one go on a real server: `Match.scheduleBotPrep` runs `botPrepBegin`
(economy + the default layout on the board), then steps the rehearsal in slices of ≤ `opts.botSliceMs` (default 8 ms of
wall clock, checked every 4 ticks; one scheduler callback each, so other rooms' battles and every request keep
flowing), then `botPrepEnd` (the rehearsed layout when it won, temp, Ready). The prep ending first drops the job (the
default layout stays). Virtual time runs it in one go (same decisions). Tests default rehearsal to 0
(`test/match/harness.js`); `tools/matchrun.mjs --rehearsal N` sets it.
---
## 2. Effect registry (content API)
```js
// server/sim/content/bands.js
export function registerMeta(registry) {
registry.band('band_cannot', { // 坎诺特 利滚利: +1 fund at round start when ≥ 5 were carried over
onPrepEnd(ctx) { ctx.setCounter('cannot:left', ctx.funds()); },
onRoundStart(ctx) {
const b = ctx.data.bands.band_cannot.params; // numbers from data, never hard-coded
if (ctx.counter('cannot:left') >= (b.capital ?? 5)) ctx.addFunds(b.interest ?? 1, 'band');
},
});
}
```
### 2.1 Keys
One handler object per key; a later `register` of the same key **replaces** the earlier one (content registers after
the engine built-ins, so content always wins). Sugar: `registry.band(id, h)`, `.bond`, `.garrison`, `.item`,
`.choice`, `.effect`, `.global`. A bare function registers `{ run: fn }`.
| key | runs for | source (`ctx.source`) |
|---|---|---|
| `global:<name>` | every player, every hook | `{ kind:'global' }` |
| `band:<bandId>` | the band's owner | `{ kind:'band', bandId, band }` |
| `bond:<bondId>` | every player (active or not — check `ctx.bondActive`) | `{ kind:'bond', bondId, bond: {count,active,tier,layers} }` |
| `garrison:<effectKey>` | owners of chess whose garrison has that `effectKey`, on its eventType hook only (§2.3) | `{ kind:'garrison', piece, garrisonId, garrison, bb, bbStr, where }` |
| `item:<itemKey>` | items **equipped** on owned chess (every hook); the item itself for onEquip / onArt / onDestroy | `{ kind:'item', piece, holder, item }` |
| `effect:<id>` | EffectRefs in `ps.effects` whose `key` is this key | `{ kind:'effect', ref }` |
| `choice:<effectId>` | the picker (and teammates for team cards) when the card is applied | `{ kind:'choice', card }` |
`itemKey` = item id without `_a`/`_b` (`chess_item_1_03_e`); Arts have no suffix (`chess_item_6_02_m`).
Normal and golden share the key: read `ctx.gd.item(ctx.source.piece.id).params` (or `ev.item`) for the numbers.
### 2.2 Hooks and events
Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispatch and may be mutated where noted.
| hook | when | `ev` |
|---|---|---|
| `onIncome` | ROUND_START, before the income is credited | `{ round, income, pending }` — write `ev.income` / `ev.pending` (e.g. 老鲤 withholds R1–R2 income until R3) |
| `onRoundStart` | ROUND_START, after income / shop roll | `{ round }` |
| `onPrepStart` | PREP opens (after 机变) | `{ round }` |
| `onPrepEnd` | prep end, before funds are cleared | `{ round }` |
| `onGain` | a chess / item was acquired (buy, reward, grant, merge result) | `{ piece, kind:'chess'|'item', source }` |
| `onSold` | a chess was sold (after refund of copies) | `{ piece, gain }` — **write `ev.gain`** to change the funds paid |
| `onRefresh` | manual refresh | `{ slots, free, price }` (slots mutable, or use `ctx.setShopSlot`) |
| `onPrice` | every price query of a shop slot (views + buy) — must be **pure** | `{ slot, kind, id, price }` — write `ev.price` / `ctx.setPrice` / `ctx.modifyPrice` |
| `onBuy` | after a purchase | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge) |
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid] }` |
| `onLevelUp` | shop level up | `{ level, price }` |
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden) | `{ input: PlayerBattleInput, kind, round }` — mutate/replace `ev.input` |
| `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` |
| `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` |
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
| `onArt` | `g.art` (item handler only) | `{ item, row, col, targets:[pieces], error, used }` |
| `onDestroy` | an item was destroyed (player / replaced) | `{ item, holder, reason }` |
| `onLayers` | bond layers were added (prep or battle gains) | `{ bondId, from, to, reason }` (milestones: 维多利亚 25, 远见 10, 奇迹 100 …) |
Dispatch order per player: `global` → `band` → `bond` (data order) → garrisons (board in deployment order, then hand)
→ equipped items → EffectRefs (insertion order). Every call is isolated with try/catch (the error is logged once and
counted in `match.dispatcher.errors`); nested dispatches are capped at depth 6.
### 2.3 Garrisons (特质)
The dispatcher calls `handler[hook] ?? handler.run` only on the hook of the garrison's `eventType` (a handler may widen
that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整期时><休整期结束时>"):
| eventType | hook | whose garrisons |
|---|---|---|
| `SERVER_GAIN` 获得时 | `onGain` | the gained piece — run **×2 while 投资人 is active, ×3 at ≥ 100 投资人 layers** |
| `SERVER_PREP_START` 进入休整期时 | `onRoundStart` | owned chess: board, and hand unless `bbStr.conditionkey === 'character_target_inboard'` |
| `SERVER_PREP_FIN` 休整期结束时 | `onPrepEnd` | same |
| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same |
| `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece |
| `SERVER_PRICE` 购买价格 | `onPrice` | the chess in the priced slot (`ctx.source.where === 'shop'`) |
| `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) |
```js
// garrisons.js — "<获得时>获得等于当前调度中心等级的【炎】层数(无需激活盟约)" (SERVER_ADD_BOND_METHOD, add_method shoplv)
registry.garrison('SERVER_ADD_BOND_METHOD', {
run(ctx, ev) {
const { bb, bbStr } = ctx.source; // garrison blackboard
let n = 0;
if (bbStr.add_method === 'shoplv') n = ctx.shopLevel() * (bb.multi ?? 1);
else if (bbStr.add_method === 'hand_count') n = ctx.hand().filter((p) => p && p.kind === 'chess').length * (bb.multi ?? 1);
const requireActive = ctx.source.garrison.desc.includes('已激活');
ctx.addLayers(bbStr.bond, n, { requireActive });
},
});
```
### 2.4 The handler context (`ctx`)
Reads: `playerId seat name round phase modeId difficulty isSolo isCoop data (frozen raw data) gd (GameData) rng`
(`rng()`, `rng.int(n)`, `rng.pick(a)`, `rng.chance(p)`, `rng.shuffle(a)` — the match's meta stream, never
`Math.random`), `funds() lp() alive() bandId() shopLevel() bond(id) bonds() bondActive(id) bondCount(id) layers(id)
board() hand() temp() piece(uid) pieceAt(row, col) pieceBonds(uid) garrisonsOf(uid) chessRecord(id) stats() roundStats()
shopSlots() effect(id)` (`piece(uid)` adds `area`, `holderUid`, `idx`; "身前一格" of (r, c) is (r, c + 1)),
counters `counter(k) setCounter(k, v) incCounter(k, n)` (player scope, persistent; prefix keys with your module).
Writes (all validated, never throw on bad input, never make funds / pools negative):
| helper | effect |
|---|---|
| `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) |
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added; fires onLayers |
| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain |
| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item) |
| `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) |
| `grantFreeRefresh(n)` | free refreshes (stack) |
| `modifyPrice(delta)` / `setPrice(v)` | onPrice only: edit `ev.price` |
| `promote(uid)` / `transform(uid, chessId)` / `upgradeItem(uid)` | elite in place / replace a chess (keeps tile) / item → golden |
| `destroyPiece(uid)` / `equipDirect(itemUid, chessUid)` | remove a piece (chess copies return, items go back) / attach without equip effects |
| `offerChess(ids, { tier })` | queue a pick-one offer (shown as `shop.rewardOffer`, free) — 寻呼模块 / 信标 style |
| `offerItems(ids, { tier })` | the same for items (slots of kind `'item'`) — 凯瑟琳 定向投放 style |
| `triggerGarrisons(uid, eventType, { asUid })` | run another owned chess's 特质 of that eventType now (铃兰 "触发…的获得时效果"); 投资人 still multiplies SERVER_GAIN; SERVER_PRICE cannot be triggered; depth-capped |
| `setShopSlot(i, { kind, id, price?, frozen? } \| null)` | rewrite a shop slot (special refreshes) |
| `addDeployCap(n)` / `setDeployCapAtLeast(n)` | deploy cap (+effects; 人事部文档 = 9) |
| `setBondCountBonus(bondId, n)` | extra member count for a bond |
| `setDeviceActive(alias, on)` / `setTileOverride(r, c, 'melee'\|'ranged'\|'none')` | terrain changes of this player's board (legality + battle input `deviceOverrides`) |
| `addEffect(ref)` / `removeEffect(id)` / `setEffectCounter(id, v)` | EffectRefs `{ id, key?, name, desc, iconKind, iconId, counter?, battle=true, params?, data?, hidden? }` — shown in `m.private.effects`, passed to battles as `playerEffects` when `battle` |
| `addBounty(card)` | a bounty (choices.json cards.bounty shape) on the next battles |
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
### 2.5 Items: consume-on-equip and Arts
Items whose data `kind` starts with `consume_on_equip` resolve through their `item:` handler's `onEquip` and are
destroyed (they never take a slot); set `ev.keep = true` to keep the item equipped instead (博士投影 normal), or
`ev.error = 'BAD_TARGET'` (+ `ev.detail`) to refuse. Without a registered handler the equip is refused
(`BAD_TARGET 'effect not available'`). Arts (`MAGIC`): `g.art` needs a handler; at most `maxArtsPerRound` (2) per round;
`ev.targets` are the pieces under the Art's `rangeGrid` at (row, col); set `ev.error` to refuse, `ev.used = false` to keep it.
Other equipment: 2 slots; a third replaces the equipped item the player picks in the replace dialog — `g.equip
{ itemUid, targetUid, replaceUid }` (research 09 §1.2 `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a `replaceUid` not
equipped on the target ⇒ `BAD_TARGET`, nothing changes) — and the replaced item is destroyed. Equipped items are
otherwise locked (research 04 §2 / addendum: they leave the operator only on promotion, merge or sale): `g.destroy`
refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden
item in the hand.
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
(+funds next round), 人事部文档 (cap 9), 博士投影 (elite now / at the next round start), 寻呼模块 / 信标 (pick-one
offers; 信标 gifts the original chess to the teammate with the most members of its bonds next round), 商业包装方案 (every
N sells → same-bond chess), 突变细胞 (after battle → random tier+1 chess), 画卷 (copy the operator in range with its
items), 教鞭 / “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content
(server/sim/content/items/meta.js) draws 3 of the band-bounty family `enemyeffect_b_*` and takes one at random (the
built-in fallback: a random tier ≤ II bounty of `cards.bounty`) and adds it with `ctx.addBounty`. A personal choice
overlay would need its own phase / protocol message outside SP_DRAFT (a second, simultaneous draft in co-op, with
timers and AI takeover) for a rarely used Art, while the random pick keeps the risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1
fund, the Art passes to the next alive player) is content too (`onDestroy`).
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
`effect:builtin_next_buy_elite` (升华).
### 2.6 机变 card application
`choice:<effectId>` handler (content) → else the family default (choices.js): bounty → `ctx.addBounty`; supply/shop →
item to the hand; tactic by buff key (`global_special_choice_gain_equip`, `_bond_addlayer`, `_gain_coin`, `_refresh_free`,
`single_special_choice_gloden_equip_chess`, `_gloden_char_chess`, `_gain_bond_chess`, `auto_chess_change_map`); every
other buff becomes a battle EffectRef (`key: 'choice:<effectId>'`, `params` = effect params) for the sim content.
Team cards (`team: true`) apply to every alive teammate as well.
```js
// choices.js — override the 自愈 card: keep it as a battle effect but show a counter
registry.choice('allybuff_select_11', {
onChoicePick(ctx, ev) {
// the EffectRef below carries this same key, so this handler also runs (source kind 'effect') on every LATER
// onChoicePick dispatch — apply the card only when it is the card being applied
if (ctx.source.kind !== 'choice') return;
ctx.addEffect({ id: `heal:${ctx.round}`, key: 'choice:allybuff_select_11', name: ev.card.name, desc: ev.card.desc,
iconKind: 'team', params: ctx.data.effects.allybuff_select_11.params });
},
});
```
Any `choice:` handler whose EffectRef reuses its own key must guard like this (or give the EffectRef an `effect:` key).
---
## 3. Rules implemented (summary; details in each module header)
* **Economy**: income `config.economy.income[r]` (= min(3+r, 12)); bounty coins / pending funds credited at the next
round start; leftover funds lost at prep end except `leftoverFundsKeptByBands`. Chess price by tier (2/3/3/3/4/4),
items by `price`, refresh 1 (free refreshes first), sell +1 (elite too; items cannot be sold, only destroyed).
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; `MAX_LEVEL` at 6. One freeze
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
selling, temp wipes and elimination return exactly what a piece holds (`left + held = cap` always).
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses buys unless the purchase completes a merge, and withdrawals unless the withdrawn summoner's own
summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new card: `HAND_FULL`,
never temp); temp blocks Ready; temp is resolved (chess sold back to the pool, items destroyed) at the
prep deadline and at the next round start.
* **Merge**: 3 normal copies (风丸 2) anywhere (board/hand/temp) → elite to the hand (the incoming copy, then temp, hand,
board copies are consumed); equipment returns to the hand; summons of consumed copies are removed; a reward offer of 3
free chess of tier min(level+1, 6) (pick 1, expires at prep end; queued when several merges happen). A merge
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep, and with
a full hand its elite takes a freed board tile of a consumed copy instead of temp (wiped at the round start).
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
latest when the battle input is built): a piece left on a tile it may no longer occupy — a melee operator on a new
射击台 — is withdrawn to the hand (overflow temp: re-placed during the prep; its summons leave with it), a summon back
onto its stack, with a toast (`PlayerState._evictIllegal`).
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
* **Waves**: waves.js header (stage/factions/boss per match, faction replacement per round with `k` copies, scaling by
`enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Multi-round bounties ("之后的每场作战")
also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the
boss battle then uses up one of the bounty's battles and its kill coins go to pending funds.
* **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2×
battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`,
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
45 s, R3's at 62 s of 55 s); × 2 every limit ≈ last spawn + one flyer crossing. docs/BALANCE.md §2.1.
* **SETTLE**: LP −min(counted leaks, 10) (after 联防: survivors attributed to their source, same cap); IN_BATTLE layer
gains applied; kill-bounty coins (paid by the Battle to the killer — a 联防 helper included) and perfect-bounty coins
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
Pool (`finalAssault.js bossPoolHp`, research 08 §6 #7): co-op = `bloodPoint[difficulty]` as is — one pool shared by
every boss field, no alive-player factor ("敌方领袖的总生命值不变"); solo = × `bossHpScale.solo` (0.25 [ASSUMED]); leaders
are never scaled by `enemyScale`. The merged team LP loses leaks (`lpr`), the overtime drain
(`bossTurnHpReduceTime` 150 counts REAL seconds, like the boss level's 120 s maxPlayTime that runs out first — the
battle goes on — so 1 LP per real second from 150 real s = 300 game s on the 2× field clock; `gd.bossOvertimeDue`)
and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back
to the alive players as shares of the LP each brought in (`lpAtFinal`, largest remainder), so `lp` in m.public /
m.private / m.result is what is left (Σ = team LP).
Client-side combat (DESIGN §14): a boss field's reports are plausibility-bounded on the SERVER's field clock — the
credited pool damage of one field ≤ the whole pool per `BOSS_MIN_CLEAR_GS` (5) game s (20 %/s; the balance model's
fastest mean kills run ≈ 5 %/s per field), its LP cost ≤ 10 + 1 per game s; reports are cumulative, so what exceeds
the budget is credited later (the boss clock re-applies the latest report), never lost. A boss field's `b.result` ends
it only when the pool is empty or after the match's `b.end`; any other (a 'forced' result at t = 0, 'cleared' while
the pool holds, a result failing validation) hands the field to the partner's replica or the server, and the sender is
never its authority again (`f.demoted`). Every boss field run by the server (takeover, or nobody connected at the
start) publishes its own damage as `b.pool.acked[fieldId]` (its CreditPool total), so the display replica of a
reconnecting / watching human stays in sync.
### 3.1 Balance layer (data/tuning.json)
`data/config.json` is generated and stays research-faithful. There is **no custom balance** any more (DESIGN §14
corrections, research 08 §6): enemy numbers are the official ones (the PRTS `enemyScale` table, leader pool =
`bloodPoint`). `data/tuning.json` (hand-maintained, loaded as `data.tuning`, layered on the config by gamedata.js only)
keeps just the result-title rules:
```jsonc
{ "titles": { "comment_3": { "stat": "lpLost", "rule": "min" } } } // merged over config.titles
```
The former `enemyHpMul` / `enemyAtkMul` / `enemySpeedMul` / `bossHpMul` / `flyPlaceholders` knobs were removed; a
tuning file that still carries them is ignored (`gd.bossHpMul()` always returns 1). `tools/balance.mjs --tuning off`
drops the file (titles only, so the numbers are the same). Titles: `rule: 'min'` ranks the players still alive by the
smallest stat (坚若磐石 "目标生命值损失最少" = least `lpLost`). docs/BALANCE.md has the balance measurements.
---
## 4. 联防 (Unite)
Planned after the normal combats, at the end of the COMBAT_END pause from the players still in (`Match._afterCombat`, so a
player who quit during the last field or the pause is neither a helper nor a leaker whose enemies re-enter; unite.js):
helpers = `unite.js helperOrder` (research 08 §5, PRTS 卫戍协议/帮助 §联防阶段): ≤ `unite.maxHelpers` (2) perfect players
chosen by most units on the field (downed included) > an active bond > most standing units > seat; the pair ordered by
units > active bond > Σ active layers > standing > seat (LP plays no part), the first one on the right-hand field
(colOffset +8, where escaped_multi enters), the other colOffset 0; the escaped template of that size routes the leaked
enemies by slot class; helpers' operators carry
`{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator dead at the end of the helper's own combat
stays out of the 联防 battle with its summons [ASSUMED: the sim has no "start undeployed with a running redeploy timer"
input]; `flags.layerGainsEnabled = false`; time limit = the round's combat limit.
Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP.
A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker
who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉 4 …; fields.js offspringPerParent).
---
## 5. Views (DESIGN §8.2 / §8.3) and deviations
`m.public` (throttled ≤ 10/s, only sent when it changed) carries the DESIGN fields plus: `drawnDisabledBonds` (the 3+4
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a),
`players[].autoplay`, and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, coin?, payout?,
rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
(SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the
overtime drain starts; `deadline` = the level's 120 s countdown), `unite { helpers, leakers }` (UNITE).
`players[].status`: INFO_CHECK ready/deciding · drafts ready (picked) / deciding (their turn) / acting (waiting) ·
PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others done · `left` / `dead` override.
`m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties).
`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:<pid>'` returns a one-shot board
view with `prep: true` (scouting a teammate).
**`b.snap` / `b.ev` game time**: every frame is `{ t: '<type>', … }`, so the snapshot's game time (DESIGN `b.snap.t`)
is sent as **`gt`** (game seconds); `b.ev` carries the same `gt`. The client reads `gt` (`render/interp.js frameTime`,
used by `normalizeSnapshot`). Legacy server-run mode only: client-side combat sends no `b.snap` / `b.ev`.
`m.result` (unicast to every human, `playerId` = the recipient) = `{ victory, roundsPassed, hiddenReached,
hiddenCleared, reason: 'victory'|'defeat'|'eliminated'|'abandoned'|'error', teamLp (merged Final Assault LP, null before
it; players[].lp are the alive players' shares), modeId, difficulty, stageId, bossId,
hiddenBossId, seed, durationMs, players:[{ playerId, seat, name, isBot, left, alive, victory (the team won AND this
player was still in at the end), roundsPassed,
eliminatedRound, lp, bandId, lineup:[{ id, golden, tier, row, col, items }], bonds, stats:{ dmgDealt, kills, leaks, gold,
refreshes, merges, itemsEquipped, bossDamage, activatedLayers, lpLost, perfectRounds }, title:{ id, name, picId, text }|null,
trophies, reward }] }`. Trophies and 卫戍认证 follow each row's OWN rounds passed (research 06 §6 / §10.4): the
Hidden-Core trophy row only for the players still in when it was cleared — a teammate eliminated or departed earlier
gets the table value of its own rounds. `onEnd(summary)` receives the same object (without `t`, plus `errors`).
`m.emote { playerId, id }`: `id` must be one of `shared/constants.js EMOTES` (`BAD_MSG` otherwise, also when `handle()`
is called without protocol validation); 1 per second per player (`RATE`).
Tickers (`m.ticker { text, id, type, priority, playerId }`) from `config.broadcasts`: SHOP_LEVEL, GOLDEN_CHAR,
CHAR_DAMAGE (per board unit per battle, highest threshold; summons created in battle once per unit type — a client
result may only name the unit types its lineup can field, fields.js validateClientResult), BOSS_HIT (20/50/80 % of the
pool per player), CHAR_GIFT (to the
receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
---
## 6. Testing & tools
* `test/match/fakeBattle.js` — scriptable DESIGN §5.1 Battle (`FakeBattle.script = (battle) => plan`); inject with
`new Match({ …, BattleClass: FakeBattle })`.
* `test/match/harness.js` — `makeMatch(opts)` (virtual scheduler, captured frames, `drive()` for human decisions,
`toPrep(r)`, `checkInvariants(m)`, `give()/giveItem()` scenario helpers).
* `VirtualScheduler({ instantCombat })`: `advance(ms)`, `runNext()`, `runUntil(pred)`, `runAll()`; with
`instantCombat` (default) battles run synchronously, so a full match takes milliseconds of wall time.
* Suites: pool, board, economy, merge, bonds, draft, combat (FakeBattle), finalAssault, connection, meta, waves,
results, fullmatch (fullmatch*.test.js, parallel files, runner fullmatchRun.js — REAL sim: solo × 4 difficulties, co-op
2/3/4 incl. AI, 20 seeds each; `MATCH_SEEDS=n` to change),
fuzz (random valid-shaped intents + connection churn), lobby-integration (real server + sockets), realtime (the real
server + lobby + Match with RealScheduler and the REAL sim: 2 scripted websocket humans + 2 AI from the room to the
prep of round 4 — drafts, 机变, buying/placing, combat snapshots `gt`, watching, 联防, throttled m.public), bot (field
model, layout planner, rehearsal side-effect freedom, economy/bench regression).
`node --test test/match/*.test.js`
* `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate
(`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds, `--rehearsal N`).
* `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md):
per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s
and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`.
Tests: `test/match/balance.test.js` (tuning layer, model), `test/match/followups.test.js`.
## 7. Assumptions (all documented in module headers)
* Unite helper choice: `unite.js helperOrder` (§4; selection units > active bond > standing units > seat, the pair
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
* A manual refresh while frozen keeps the new slots frozen until the next round start.
* Level-up does not add slots until the next roll (refresh / round start).
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it.
* Chess granted by effects need a free pool copy unless `requirePool: false` (then they hold 0 copies).
* Boss-round `local` pack spawns (boss parts) all spawn; content scripts (bosses.js) decide their behaviour.
* The Final Assault ends as a defeat when every field finished with the boss pool above 0 (boss escaped).
* Combat limits are real seconds (× 2 in game seconds) — see §3; research 00-INDEX §8 #10 assumed game seconds.
* Emotes faster than 1/s answer `RATE`.
* 中途退出 = elimination at once (research); a quitter's operators already fighting in a shared field (联防, boss
field) finish that battle, its LP already merged into the team LP stays there.
* After the LP merge (Final Assault) the per-player LP shown is a share of the team LP ∝ the LP each player brought in.
* A 联防 helper's operators dead at the end of its own combat do not take part in the 联防 battle.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; with a full hand its elite
takes a freed board tile (a prep merge still overflows into temp).