feedback1 cell: a 突变细胞 transformation gains the new operator into the 整备区 and frees the carrier's tile (pointed out in PR #2)

Official gameplay footage (bilibili BV1vzyVBuEN9 at about 8:24, BV1Qkw1zMEoR at about 7:25) shows the
next prep with the carrier's tile empty, one more deployment left, and the returned equipment plus the
new tier+1 operator on the bench, deployed by hand afterwards. That is the PRTS 备注 read as written
("生效时,原干员销毁,获得一名高一阶的随机初始干员"): a destroy followed by a gain. The closed PR #2 made
the same claim from a playtest.

- PlayerState.transformChess: the carrier is destroyed wherever it stands (board tile freed, summons
  removed, copies back to the pool); its equipment, the cell included, returns to the hand first
  (overflow temp, auto-merge); then the new operator is gained through acquireChess like any gained
  operator (hand, overflow temp, the no-room rule of every gain; no summon card until it is deployed).
  The returnItems option is gone (the cell's built-in was its only caller).
- A merge the gain completes merges like any gained copy: the elite takes a consumed deployed copy's
  tile, else the hand. _mergeChess loses fromKey / fromDir, so the destroyed carrier's tile never
  counts (PR #2 had kept that path; not adopted); audit.js drops its exception for it.
- builtinMeta, ctx.transform, items/meta.js and bot.js comments follow. Bots and AI 托管 deploy the
  gained operator in their normal lineup step (no bot change needed; a real co-op round pins it).
- ITEM_RULES note and implFormula for the item card; data/items.json rebuilt with build-data --offline.
- Tests: facing, merge, playtest6-elite-to-board and items no longer pin the kept tile; feedback1-meta
  covers a deployed and a bench carrier, hand full -> temp, hand and temp full, a merge with and
  without a deployed copy (never the carrier's tile), 商业包装方案's count on the returned item, and bots /
  AI 托管 redeploying; feedback1-cell checks the new note.
This commit is contained in:
sganggs committed 2026-10-03 15:18:42 +08:00
1 parent 842e1e13c4
commit 91c9b21d7f
14 files changed
+400 -178

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"chess_item_1_01_e_a":{"id":"chess_item_1_01_e_a","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":false,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":267,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_01_e_b","duration":-1,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm041","effectName":"维式重锤","desc":"攻击力+15%","descRaw":"攻击力<@ba.vup>+15%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.15,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","note":null,"rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_01_e_b":{"id":"chess_item_1_01_e_b","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":true,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":326,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm041","effectName":"维式重锤","desc":"攻击力+25%","descRaw":"攻击力<@ba.vup>+25%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.25},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.25,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","note":null,"rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_02_e_a":{"id":"chess_item_1_02_e_a","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":false,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":273,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_02_e_b","duration":-1,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm047","effectName":"坚守盾牌","desc":"防御力+20%","descRaw":"防御力<@ba.vup>+20%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.2},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.2,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","note":null,"rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_02_e_b":{"id":"chess_item_1_02_e_b","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":true,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":332,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm047","effectName":"坚守盾牌","desc":"防御力+35%","descRaw":"防御力<@ba.vup>+35%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.35},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.35,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","note":null,"rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_03_e_a":{"id":"chess_item_1_03_e_a","baseId":"chess_item_1_03_e_a","goldenId":"chess_item_1_03_e_b","isGolden":false,"trapId":"trap_1066_acarm066","iconId":"trap_1066_acarm066","identifier":292,"name":"盟约之币","itemType":"EQUIP","tier":1,"shopSortId":3,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_03_e_b","duration":-1,"giveBondId":null,"givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm066","effectName":"盟约之币","desc":"装备时销毁,并获得1资金","descRaw":"装备时销毁,并获得<@ba.vup>1</>资金","buffs":[{"key":"equip_destory_gain_random_coin","countType":"NONE","bb":{"min":1,"max":1},"bbStr":{}}],Line truncated
{"chess_item_1_01_e_a":{"id":"chess_item_1_01_e_a","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":false,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":267,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_01_e_b","duration":-1,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm041","effectName":"维式重锤","desc":"攻击力+15%","descRaw":"攻击力<@ba.vup>+15%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.15,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","note":null,"rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_01_e_b":{"id":"chess_item_1_01_e_b","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":true,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":326,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm041","effectName":"维式重锤","desc":"攻击力+25%","descRaw":"攻击力<@ba.vup>+25%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.25},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.25,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","note":null,"rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_02_e_a":{"id":"chess_item_1_02_e_a","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":false,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":273,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_02_e_b","duration":-1,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm047","effectName":"坚守盾牌","desc":"防御力+20%","descRaw":"防御力<@ba.vup>+20%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.2},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.2,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","note":null,"rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_02_e_b":{"id":"chess_item_1_02_e_b","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":true,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":332,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":5,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm047","effectName":"坚守盾牌","desc":"防御力+35%","descRaw":"防御力<@ba.vup>+35%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.35},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.35,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","note":null,"rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_03_e_a":{"id":"chess_item_1_03_e_a","baseId":"chess_item_1_03_e_a","goldenId":"chess_item_1_03_e_b","isGolden":false,"trapId":"trap_1066_acarm066","iconId":"trap_1066_acarm066","identifier":292,"name":"盟约之币","itemType":"EQUIP","tier":1,"shopSortId":3,"price":1,"hideInShop":false,"shopExcluded":false,"shopExcludedBy":null,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_03_e_b","duration":-1,"giveBondId":null,"givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm066","effectName":"盟约之币","desc":"装备时销毁,并获得1资金","descRaw":"装备时销毁,并获得<@ba.vup>1</>资金","buffs":[{"key":"equip_destory_gain_random_coin","countType":"NONE","bb":{"min":1,"max":1},"bbStr":{}}],Line truncated
+42 -48
View File
@@ -33,9 +33,12 @@
// * Per-piece round counters (pieceRoundCount, piece.meta.round): an operator's own counts of the current round
// (拉普兰德: the manual refreshes she witnessed — player feedback after 0.1.0); a new piece starts at 0, an elite
// merged this round keeps the highest of its copies' [ASSUMED].
// * Transformations (transformChess, 突变细胞): the new chess keeps the tile when legal; `returnItems` sends the old
// piece's equipment to the hand (overflow temp) like a destroyed operator's — before the new piece's summon stack,
// as in a merge, so a summon card never pushes the returned 突变细胞 into temp.
// * Transformations (transformChess, 突变细胞 — PRTS 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员"): a destroy
// followed by a gain. The carrier leaves wherever it stands (a board tile is freed, the deploy count drops), its
// equipment — the cell included — returns to the hand first (overflow temp), then the new chess is gained like any
// other (acquireChess: hand, overflow temp, the no-room rule; a merge it completes puts the elite on a consumed
// deployed copy's tile — never the carrier's —, else in the hand). Official footage: the tile is empty at the next
// prep and the new operator waits in the 整备区 (pointed out in PR #2).
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
// absent; equipped items are otherwise locked: g.destroy refuses them),
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
@@ -58,9 +61,9 @@
// the next round start (grantTokensFor) — no out-of-range placement reaches the battle.
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
// copy's tile, a transformation keeping the tile) keep that tile's dir, anything else defaults to RIGHT
// (`pieceDir`). g.art {…, dir} rotates the Art's range (画卷 1-1: its tile + the tile in front).
// place; a swap keeps the occupant's dir; a piece put on the board by an effect (a merge elite taking a consumed
// copy's tile) keeps that tile's dir, anything else defaults to RIGHT (`pieceDir`). g.art {…, dir} rotates the
// Art's range (画卷 1-1: its tile + the tile in front).
// * Operator loadout (DESIGN §16): the human's checked `seat.loadout` ({ [baseChessId]: { skill, module } }, entries
// equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
@@ -507,10 +510,10 @@ export class PlayerState {
* the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
* 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy
* stood on the board the elite takes its tile and facing — of several, the one that deploys first (board reading
* order: top → bottom, then left → right) [ASSUMED]; `fromKey` / `fromDir` = the tile the incoming piece stood on (a
* transformation of a deployed operator) counts as such a copy. It replaces a deployed copy, so the deploy count never
* grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp
* through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
* order: top → bottom, then left → right) [ASSUMED]. The incoming copy is never deployed (a 突变细胞 transformation
* destroyed its carrier before the gain: that tile is no copy's). It replaces a deployed copy, so the deploy count
* never grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in
* temp through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
* destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain
* (checkItemMerges); their summons are removed, and an elite on the board
@@ -518,15 +521,14 @@ export class PlayerState {
* the elite could not be stored).
* @param {string} baseId
* @param {any} incoming the acquired, not yet stowed copy (null: only owned copies)
* @param {{ fromKey?: string|null, fromDir?: string }} [opts]
*/
_mergeChess(baseId, incoming, { fromKey = null, fromDir = undefined } = {}) {
_mergeChess(baseId, incoming) {
const need = this.gd.mergeCount(baseId);
const goldenId = this.gd.goldenIdOf(baseId);
if (!(need > 1) || !goldenId) return null;
const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId);
const consumed = [];
if (incoming) consumed.push({ piece: incoming, area: 'new', key: fromKey || undefined, dir: fromDir });
if (incoming) consumed.push({ piece: incoming, area: 'new' });
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
if (consumed.length < need) return null;
let copies = 0;
@@ -545,7 +547,7 @@ export class PlayerState {
const rc = l.piece.meta && l.piece.meta.round;
if (rc && rc.r === this.m.round) for (const [k, v] of Object.entries(rc.n)) this.bumpPieceRoundCount(elite, k, Math.max(0, v - this.pieceRoundCount(elite, k)));
}
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: l.area === 'new' ? l.dir : pieceDir(l.piece) }));
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) }));
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c));
let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true });
@@ -591,51 +593,43 @@ export class PlayerState {
}
/**
* Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes
* to the hand/temp) and keeps the equipment — or, with `returnItems`, the equipment returns to the hand like the
* equipment of a destroyed / promoted operator (突变细胞: PRTS 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员"; the
* cell itself comes back, player feedback after 0.1.0), overflowing into temp; with both full an item stays on the new
* piece up to its equipPerChess slots (any further one is destroyed with a log warning, as in a merge). Pool copies are
* swapped; completes a merge when possible — a deployed piece's tile then counts as a consumed copy's for the elite
* (_mergeChess fromKey, when legal for it), and the merge returns the equipment itself. A new piece on the board gets
* its summon stack only after the equipment came back (like _mergeChess: a summon card must not push the returned
* equipment — 突变细胞 — into temp, where it would be lost at the deadline).
* @param {{ returnItems?: boolean }} [opts]
* Transformation (突变细胞 "战斗结束后,装备者替换为高一阶的随机干员"; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原
* 干员销毁,获得一名高一阶的随机初始干员(最高六阶)"): a destroy followed by a gain. The carrier is destroyed wherever it
* stands — a board tile is freed (the deploy count drops), its summons are removed, its pool copies return. Its
* equipment, the cell included, comes off first (PRTS 卫戍协议/帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除"): to
* the hand, overflowing into temp, auto-merging like any gain. Then `newId` is gained like any other gained operator
* (acquireChess, onGain source 'transform'): the hand, overflow temp ("被发送至手牌区的物资优先从右到左填充空位"), and with
* both full it goes back to the pool ("整备区已满,获得的干员已返还"); it gets no summon card in the hand (only a deployment
* brings one), and a merge it completes follows the ordinary rule (_mergeChess: the elite on a consumed deployed copy's
* tile, else the hand — the carrier's freed tile is no copy's). An item that found no slot takes one the gain freed (a
* merge consumes copies), else stays on the gained operator up to its equipPerChess slots, else it is destroyed with a
* log warning (as in a merge). Official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR; pointed out in PR #2): at the
* next prep the carrier's tile is empty, one more deployment is left and the new operator waits in the 整备区.
* @param {any} piece the carrier (an owned chess piece)
* @param {string} newId chess id gained in its place
* @returns {any} the gained piece (the elite when it completed a merge) or null
*/
transformChess(piece, newId, { returnItems = false } = {}) {
transformChess(piece, newId) {
const loc = this.find(piece.uid);
const rec = this.gd.chess(newId);
if (!loc || loc.piece.kind !== 'chess' || !rec) return null;
if (!loc || loc.piece.kind !== 'chess' || !this.gd.chess(newId)) return null;
// 原干员销毁: off its tile / slot, its summons removed, its copies back to the pool
this._detach(loc);
this.removeTokensOf(piece.uid);
const items = piece.items || [];
piece.items = [];
this.returnCopies(piece);
const base = this.gd.baseIdOf(newId);
const taken = this.m.pool.take(base, rec.isGolden ? this.gd.goldenCopies : 1);
let np = this.newPiece('chess', newId, { poolCopies: taken });
const merging = !rec.isGolden && this.completesChessMerge(newId);
// a merge returns the consumed copies' equipment (this one's too) before the elite's summons (_mergeChess)
const pending = returnItems && !merging ? items : [];
np.items = returnItems && !merging ? [] : items;
let deployed = false;
if (merging) {
np = this._mergeChess(base, np, loc.area === 'board' ? { fromKey: loc.key, fromDir: pieceDir(piece) } : undefined);
} else if (loc.area === 'board') {
const [r, c] = parseKey(loc.key);
if (this._legal(np, r, c)) { np.dir = pieceDir(piece); this.board.set(loc.key, np); deployed = true; } else if (!this.stow(np, { allowTemp: true })) { this.returnCopies(np); np = null; }
} else if (!this._putBack(loc, np)) { this.returnCopies(np); np = null; }
// after the new piece took its place (a hand piece keeps its slot), its predecessor's equipment comes off
for (const it of pending) {
// its equipment comes off first (the returned pair auto-merges, which may free a slot for the gain)
const left = items.filter((it) => !this.stow(it, { allowTemp: true }));
this.checkItemMerges();
// 获得一名…干员: gained like any other gained operator
const np = this.acquireChess(newId, { source: 'transform' });
for (const it of left) {
if (this.stow(it, { allowTemp: true })) continue;
if (np && np.items.length < this.gd.equipPerChess) { np.items.push(it); continue; }
if (np && this.find(np.uid) && np.items.length < this.gd.equipPerChess) { np.items.push(it); continue; }
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
}
if (returnItems) this.checkItemMerges();
// deployed in its predecessor's place: its manually deployable summons join the hand, after the returned equipment
if (deployed) this.grantTokensFor(np);
if (left.length) this.checkItemMerges();
this.recompute();
if (np) this.m.dispatch(this, 'onGain', { piece: np, kind: 'chess', source: 'transform' });
return np;
}
+9 -9
View File
@@ -17,8 +17,9 @@
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// merges a merge consuming a deployed copy puts the elite on that copy's tile (of several, the first in deploy
// order legal for it — board.js mergeTile; a transformed piece's own tile counts) with its facing, else
// into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest #6 follow-up)
// order legal for it — board.js mergeTile; a 突变细胞 carrier destroyed before the gain is no copy) with
// its facing, else into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest
// #6 follow-up)
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
@@ -227,15 +228,14 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
});
// merges (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", user playtest #6 follow-up): with a
// deployed copy among the consumed ones the elite stands on the first such tile in deploy order that is legal for
// it (the incoming piece's own tile when a transformation merged it in place), with that copy's facing; else in the
// hand / temp. A merge never grows the deploy count. Every owned normal copy is consumed (merges are immediate).
wrap(ps, '_mergeChess', function (orig, baseId, incoming, opts) {
// it, with that copy's facing; else in the hand / temp (the incoming copy is never deployed: a 突变细胞
// transformation destroyed its carrier before the gain, so that freed tile is no copy's). A merge never grows the
// deploy count. Every owned normal copy is consumed (merges are immediate).
wrap(ps, '_mergeChess', function (orig, baseId, incoming) {
const tiles = new Map(); // tile key → facing of the copy standing there
for (const [k, p] of ps.board) if (p.kind === 'chess' && !gd.isGolden(p.id) && gd.baseIdOf(p.id) === baseId) tiles.set(k, pieceDir(p));
if (opts && opts.fromKey) tiles.set(opts.fromKey, pieceDir({ dir: opts.fromDir }));
// a transformation (transformChess) detached its deployed carrier before the merge: it still counts as deployed
const deployed0 = ps.deployCount + (opts && opts.fromKey ? 1 : 0);
const elite = orig(baseId, incoming, opts);
const deployed0 = ps.deployCount;
const elite = orig(baseId, incoming);
if (elite) check('merge', () => {
const id = ps.playerId;
const loc = ps.find(elite.uid);
+4 -2
View File
@@ -53,7 +53,8 @@
// 5. items by what they do (itemTarget): equipment on the strongest deployed damage dealers (survival items on
// blockers, bond signature items on a member), 信标 on a bench single, 拟态物质 on a pair, 博士投影 (both
// qualities) on the strongest normal operator, 突变细胞 on the least valuable normal single below 6阶 (cellTarget:
// never an elite or a pair member), bond items on a focus member; Arts (useArt): 画卷 copies the most valuable
// never an elite or a pair member; the operator its transformation gains joins the bench and steps 3–4 of the next
// prep deploy it like any owned unit), bond items on a focus member; Arts (useArt): 画卷 copies the most valuable
// deployed operator, 教鞭 / “神秘顾客” are used after a perfect battle and kept otherwise (consume-on-equip items /
// Arts only with a handler)
// 6. resolve the temp slots (a 突变细胞 left there — it comes back after every transformation — gets a hand slot made
@@ -1613,7 +1614,8 @@ const isMutationCell = (gd, itemId) => itemEffect(gd.item(itemId)) === 'char_che
/**
* Whom the bot injects with 突变细胞 (after the battle its carrier — deployed or on the bench: every owned carrier's item
* hooks run — becomes a random operator one tier higher; items meta char_chess_transformation_equip): the least
* hooks run — is destroyed and a random operator one tier higher joins the bench, PlayerState.transformChess; the next
* prep's lineup step deploys it like any owned unit, so a deployed carrier only costs a re-placement): the least
* valuable normal operator below 6阶 with a free equip slot — never an elite, never one of a merge pair (the merge
* progress would be lost), nobody already carrying a cell. null: the cell waits in the hand.
*/
+14 -10
View File
@@ -17,9 +17,10 @@
// use_equip_recruit_new_char_and_give_char_to_player_most_bond {refresh_cnt} 信标 destroy target, offer N same-tier
// chess, gift the original next prep
// sell_char_count_gain_equip_owner_bond {count} 商业包装方案 every `count` sells → same-bond chess
// char_chess_transformation_equip 突变细胞 after battle: holder → random NORMAL tier+1
// (max 6); its equipment, the cell included,
// returns to the hand (not consumed)
// char_chess_transformation_equip 突变细胞 after battle: holder destroyed, its equipment
// (the cell included — not consumed) back to
// the hand, then a random NORMAL tier+1 (max 6)
// operator gained into the hand
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front
// trap_create_self_choice {choice_event} 教鞭 / 神秘顾客 (Art) add a random bounty to your next battle
// [ASSUMED simplification, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
@@ -177,12 +178,15 @@ const ITEM_HANDLERS = {
}
},
},
// 突变细胞 "战斗结束后,装备者替换为高一阶的随机干员": the carrier is replaced by a random NORMAL operator one tier higher
// (at most 6; an elite carrier too — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原干员销毁,获得一名高一阶的随机
// 初始干员(最高六阶)"); the destroyed operator's equipment, the cell included, returns to the hand (PRTS 卫戍协议/帮助
// "佩戴的装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除"; the official text never
// says 销毁 for the cell — unlike every consumable item —, and players re-inject it every round: "之后就是一直打针,扎到
// 核心卡…就换人扎", bilibili cv47000418; player feedback after 0.1.0). The new operator keeps the tile when legal.
// 突变细胞 "战斗结束后,装备者替换为高一阶的随机干员": PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原干员销毁,获得
// 一名高一阶的随机初始干员(最高六阶)" — the carrier (deployed or on the bench) is destroyed and a random NORMAL operator
// one tier higher (at most 6; an elite carrier too) is gained like any gained operator: into the 整备区 (overflow temp),
// never onto the carrier's tile — official footage (bilibili BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25): at the next
// prep the tile is empty, one more deployment is left and the new operator waits on the bench (pointed out in PR #2).
// The destroyed operator's equipment, the cell included, returns to the hand first (PRTS 卫戍协议/帮助 "佩戴的装备无法
// 手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除"; the official text never says 销毁 for the
// cell — unlike every consumable item —, and players re-inject it every round: "之后就是一直打针,扎到核心卡…就换人扎",
// bilibili cv47000418; player feedback after 0.1.0). PlayerState.transformChess.
char_chess_transformation_equip: {
onBattleResult(ctx) {
const { piece, holder } = ctx.source;
@@ -190,7 +194,7 @@ const ITEM_HANDLERS = {
const tier = Math.min(6, ctx.gd.tierOf(holder.id) + 1);
const id = ctx.rollChess({ tier });
if (!id) return;
ctx.transform(holder.uid, ctx.gd.baseIdOf(id), { returnItems: true });
ctx.transform(holder.uid, ctx.gd.baseIdOf(id));
},
},
trap_copy_front_char: {
+7 -6
View File
@@ -24,9 +24,9 @@
// Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on
// owned chess (ev.source.holder). That step walks a snapshot — the owned chess and each holder's items as they stand
// when it begins — and runs an item only if, when its turn comes, it is still equipped on that holder and the holder
// is still owned: handlers move and destroy pieces mid-walk (突变细胞 transforms its holder and returns the equipment
// to the hand; normal 博士投影 destroys itself, which splices holder.items), and a live walk skipped the next item or
// ran it with a holder that was gone. Pieces gained or equipped during the walk wait for the next dispatch.
// is still owned: handlers move and destroy pieces mid-walk (突变细胞 destroys its holder, returns the equipment to the
// hand and gains an operator; normal 博士投影 destroys itself, which splices holder.items), and a live walk skipped the
// next item or ran it with a holder that was gone. Pieces gained or equipped during the walk wait for the next dispatch.
// Dispatch order per player: global → band → bonds → garrisons (board reading order,
// then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N
// sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is
@@ -515,9 +515,10 @@ export function makeCtx(m, ps, source, hook, ev = null) {
return m.dispatcher.triggerGarrisons(ps, l.piece, eventType, { asPiece: as ? as.piece : null, triggeredBy: source.key || null });
},
promote: (uid) => { const l = ps.find(uid); return l ? ps.promote(l.piece) : false; },
/** Replace a chess piece by another chess (keeps its tile when legal, keeps its equipment — `{ returnItems: true }`:
* the equipment returns to the hand instead, as from a destroyed operator; PlayerState.transformChess). */
transform: (uid, chessId, opts = {}) => { const l = ps.find(uid); if (!l || l.piece.kind !== 'chess') return null; const p = ps.transformChess(l.piece, chessId, { returnItems: !!(opts && opts.returnItems) }); return p ? view(p) : null; },
/** 突变细胞's transformation: destroy a chess piece wherever it stands (its equipment returns to the hand first), then
* gain `chessId` like any gained operator — hand, overflow temp, a completed merge as usual
* (PlayerState.transformChess). Returns the gained piece's view (the elite after a merge) or null. */
transform: (uid, chessId) => { const l = ps.find(uid); if (!l || l.piece.kind !== 'chess') return null; const p = ps.transformChess(l.piece, chessId); return p ? view(p) : null; },
/** Normal item piece → its golden version in place. */
upgradeItem: (uid) => { const l = ps.find(uid); return l && l.piece.kind === 'item' ? ps.upgradeItem(l.piece) : false; },
/** Attach an owned hand/temp item to an owned chess without equip effects (copies, restores). */
+4 -4
View File
@@ -3,10 +3,10 @@
// The engine built-ins (server/match/builtinMeta.js) already implement most consume-on-equip items and Arts from the
// concrete item's params; they are reviewed and kept (盟约之币, 骑士储蓄罐, 随身身份牌, 精打细算玩偶, 简易通讯机 (tier ≤
// shop level), 见钱眼开玩偶, 人事部文档 (cap 9), 博士投影 (golden now / normal at the next round start), 拟态物质, 信标,
// 商业包装方案 (count from the concrete record: 8 / golden 7), 突变细胞 (after the battle the carrier becomes a NORMAL
// random tier+1 operator, max 6; its equipment — the cell too, which is not consumed (player feedback after 0.1.0) —
// returns to the hand)). The Arts' rangeGrid (画卷 = placed tile + the tile in front) and the per-round limit are engine
// rules.
// 商业包装方案 (count from the concrete record: 8 / golden 7), 突变细胞 (after the battle the carrier is destroyed, its
// equipment — the cell too, which is not consumed (player feedback after 0.1.0) — returns to the hand, then a NORMAL
// random tier+1 operator, max 6, is gained into the hand, not onto the carrier's tile (PR #2)). The Arts' rangeGrid
// (画卷 = placed tile + the tile in front) and the per-round limit are engine rules.
// Overridden / added here (a built-in, when present, is wrapped — never re-implemented):
// 画卷 the copy keeps the target's items; a copied normal item that pairs with an owned one merges and the
// golden stays in the hand (the built-in equipped the merged golden on the copy, and its live loop over
+19 -1
View File
@@ -4,7 +4,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
import { makeMatch, give, giveItem, DATA, legalTileFor } from '../match/harness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { lendItemEffects, itemGrants, PRIO_REVIVE } from '../../server/sim/content/items/battle.js';
import { unitBonds } from '../../server/sim/content/support/index.js';
@@ -993,6 +993,24 @@ test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 op
cover(A('5_08'), B('5_08'));
});
test('突变细胞: a DEPLOYED carrier leaves the field — the new operator is gained into the 整备区, the deploy slot comes back (PR #2)', () => {
// official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR): the tile is empty at the next prep and the new operator waits
// on the bench; the case added by the closed PR #2, on the destroy-then-gain rule (DESIGN §21.1)
const { m, ps, equip } = setup({ seed: 3 });
const cid = plain((c) => c.tier === 2)[0];
const holder = give(m, ps, cid, 'board', legalTileFor(m, ps, cid));
assert.deepEqual(equip(giveItem(m, ps, A('5_08')), holder), OK);
assert.equal(ps.deployCount, 1, 'the carrier is deployed');
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
assert.equal(ps.board.size, 0, 'the carrier left the field');
assert.equal(ps.deployCount, 0, 'the deploy slot came back');
const chess = ps.hand.filter((p) => p && p.kind === 'chess');
assert.equal(chess.length, 1, 'the new operator waits in the 整备区');
assert.equal(DATA.chess[chess[0].id].tier, 3, 'the random tier+1 operator');
assert.equal(DATA.chess[chess[0].id].isGolden, false);
assert.ok(handIds(ps, 'item').includes(A('5_08')), 'the cell back in the hand');
});
test('人事部文档: deploy cap becomes 9 (a second copy adds nothing)', () => {
const { m, ps, equip } = setup();
const t = give(m, ps, plain((c) => c.tier === 1)[0], 'hand');
+5 -3
View File
@@ -134,14 +134,16 @@ test('the dir persists across rounds and reaches the round\'s battle', () => {
m.dispose();
});
test('effect-placed pieces: a transformation / a merge elite taking a consumed copy\'s tile keep that tile\'s dir', () => {
test('effect-placed pieces: a transformation leaves the tile (the new operator waits in the hand); a merge elite taking a consumed copy\'s tile keeps that tile\'s dir', () => {
const { m, ps } = prepMatch({ seed: 13 });
const [id, other] = meleeIds(m, 2);
const a = give(m, ps, id, 'board', [9, 3]);
a.dir = 'DOWN';
const np = ps.transformChess(a, other);
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
assert.equal(np.dir, 'DOWN');
// 突变细胞 (PRTS 备注 "原干员销毁,获得一名…干员"; official footage, PR #2): the tile is freed, nothing inherits its dir
assert.ok(np && !ps.board.has(tileKey(9, 3)), 'the tile is freed');
assert.ok(ps.hand.includes(np), 'the new operator waits in the hand');
assert.equal(ps.deployCount, 0, 'one deployment fewer');
// merge of deployed copies (PRTS: the elite goes to the consumed copy's board position), with its dir
ps.board.clear();
ps.hand.fill(null);
+249 -63
View File
@@ -5,12 +5,16 @@
// (each copy counts the manual refreshes it witnessed this round), not the player's.
// #4 昆图斯 突变细胞 "战斗结束后,装备者替换为高一阶的随机干员": the cell is not consumed — the original operator is
// destroyed (PRTS 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)") and its equipment, the cell
// included, returns to the hand, to be equipped again ("之后就是一直打针,扎到核心卡…就换人扎").
// included, returns to the hand, to be equipped again ("之后就是一直打针,扎到核心卡…就换人扎"); then the new operator
// is gained like any gained operator — into the 整备区, never onto the carrier's tile (official footage: bilibili
// BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25 — the tile is empty at the next prep, one more deployment is left, the
// new operator waits on the bench; pointed out in PR #2). Bots / AI 托管 deploy it in their normal placement step.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, give, giveItem, legalTileFor, checkInvariants, DATA } from './harness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { botPrep, botPrepEnd, cellTarget } from '../../server/match/bot.js';
import { mergeTile } from '../../server/match/board.js';
const QUIET = { warn() {}, error() {}, info() {} };
const REG = createRegistry({ log: QUIET });
@@ -164,37 +168,91 @@ const plainOf = (m, tier) => Object.values(DATA.chess)
.map((c) => c.chessId).sort();
const ownedItems = (ps, id) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'item' && p.id === id);
const equip = (m, item, target) => m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid });
const SWORD = 'chess_item_1_01_e_a'; // 维式重锤
const PACK = 'chess_item_5_07_e_a'; // 商业包装方案 (counts sells per item: player counter `pack:<item uid>`)
const VIGIL = 'chess_char_3_19_a'; // 伺夜 (Ⅲ) — a placeable 狼群 summon card
/** Distinct normal equipment ids (never merge with each other), the cell excluded. */
const fillerItems = () => Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden && it.id !== CELL).map((it) => it.id).sort();
const unusedFillers = (ps) => fillerItems().filter((id) => ![...ps.hand, ...ps.temp].some((p) => p && p.id === id));
/** Fill every free hand slot but `leave` with distinct items. */
const fillHand = (m, ps, leave = 1) => {
const ids = unusedFillers(ps);
while (ps.hand.filter((x) => x == null).length > leave) giveItem(m, ps, ids.shift());
};
/** Fill every free temp slot with distinct items. */
const fillTemp = (m, ps) => {
const ids = unusedFillers(ps);
while (ps.temp.some((x) => x == null)) giveItem(m, ps, ids.shift(), 'temp');
};
/** Force the cell's roll (the random draw) of `tier` to `id`; everything else stays the real path. */
const forceRoll = (m, tier, id) => { const roll = m.pool.roll.bind(m.pool); m.pool.roll = (rng, o = {}) => (o.tier === tier ? id : roll(rng, o)); };
const where = (ps, pred) => (ps.hand.some((p) => p && pred(p)) ? 'hand' : ps.temp.some((p) => p && pred(p)) ? 'temp' : null);
const handIdx = (ps, pred) => ps.hand.findIndex((p) => p && pred(p));
/** The round loop's SETTLE dispatch, alone. */
const battleResult = (m, ps) => m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
test('#4 players\' report: 突变细胞 is not used up — after each battle the carrier is replaced (normal, tier +1) and the cell returns to the hand', () => {
test('#4 players\' report and the official flow: after each battle the deployed carrier is destroyed — its tile empties, one more deployment is left —, its equipment (the cell too) comes back first, then a NORMAL tier+1 operator joins the hand', () => {
const s = setup({ seed: 5 });
const { h, m, ps } = s;
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const tile = ps.find(carrier.uid).key;
const cell = giveItem(m, ps, CELL);
const sword = giveItem(m, ps, 'chess_item_1_01_e_a'); // 维式重锤: other equipment returns too
const sword = giveItem(m, ps, SWORD); // other equipment returns too
assert.deepEqual(equip(m, cell, carrier), OK);
assert.deepEqual(equip(m, sword, carrier), OK);
assert.equal(ps.deployCount, 1);
h.toPrep(2);
assert.ok(!ps.find(carrier.uid), 'the original operator is gone (原干员销毁)');
const next = ps.board.get(tile);
assert.ok(next && next.kind === 'chess', 'the new operator takes the tile');
assert.ok(!ps.board.has(tile), 'its tile is empty (bilibili BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25)');
assert.equal(ps.deployCount, 0, 'one more deployment is left');
const next = ps.hand.find((p) => p && p.kind === 'chess');
assert.ok(next, 'the new operator waits in the hand (整备区)');
assert.equal(m.gd.chess(next.id).tier, 3, 'one tier higher');
assert.ok(!m.gd.isGolden(next.id), 'an initial (normal) operator');
assert.deepEqual(next.items, [], 'its equipment came off');
assert.deepEqual(next.items, [], 'nothing equipped');
assert.equal(ownedItems(ps, CELL).length, 1, 'the cell is back in the hand, not consumed');
assert.equal(ownedItems(ps, 'chess_item_1_01_e_a').length, 1, 'the other equipment too');
// "一直打针": inject again — the same new operator here; it climbs once more
assert.equal(ownedItems(ps, SWORD).length, 1, 'the other equipment too');
// the hand fills right → left ("被发送至手牌区的物资优先从右到左填充空位"): the equipment came off first, then the gain
assert.deepEqual([handIdx(ps, (p) => p.id === CELL), handIdx(ps, (p) => p.id === SWORD), ps.hand.indexOf(next)], [9, 8, 7]);
assert.ok(ps.tempEmpty);
// the player deploys it by hand, then "一直打针": inject again — it climbs once more and comes back to the bench
const [row, col] = legalTileFor(m, ps, next.id);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: next.uid, to: { area: 'board', row, col } }), OK);
assert.equal(ps.deployCount, 1);
assert.deepEqual(equip(m, ownedItems(ps, CELL)[0], next), OK);
h.toPrep(3);
const third = ps.board.get(tile);
assert.equal(m.gd.chess(third.id).tier, 4, 'tier 4 after the second battle');
assert.ok(!ps.find(next.uid) && !ps.board.has(`${row},${col}`), 'the second carrier is gone too, its tile empty');
assert.equal(ps.deployCount, 0);
const third = ps.hand.find((p) => p && p.kind === 'chess');
assert.equal(m.gd.chess(third.id).tier, 4, 'tier 4 after the second battle, on the bench');
assert.equal(ownedItems(ps, CELL).length, 1, 'and the cell is back again');
checkInvariants(m);
m.dispose();
});
test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier higher; 6阶 → another 6阶; no room → it stays on the new operator', () => {
test('#4 bench carriers transform too: a hand or temp carrier leaves its slot; the cell, then the new operator, fill the hand right → left', () => {
for (const area of ['hand', 'temp']) {
const s = setup({ seed: 6 });
const { m, ps } = s;
const t1 = plainOf(m, 1)[0];
const carrier = give(m, ps, t1, area);
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
battleResult(m, ps);
assert.ok(!ps.find(carrier.uid), `${area}: the carrier is destroyed`);
const got = ps.allChess();
assert.equal(got.length, 1, `${area}: one operator gained`);
assert.equal(m.gd.chess(got[0].id).tier, 2, `${area}: one tier higher`);
assert.ok(!m.gd.isGolden(got[0].id));
assert.deepEqual([handIdx(ps, (p) => p.id === CELL), ps.hand.indexOf(got[0])], [9, 8], `${area}: the cell first, then the gain`);
assert.ok(ps.tempEmpty, `${area}: nothing waits in temp`);
assert.equal(ps.deployCount, 0);
checkInvariants(m);
m.dispose();
}
});
test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier higher; 6阶 → another 6阶', () => {
{
const s = setup({ seed: 9 });
const { h, m, ps } = s;
@@ -222,23 +280,85 @@ test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier h
assert.equal(ownedItems(ps, CELL).length, 1);
m.dispose();
}
{
// hand and temp full: the cell cannot come off and stays on the new operator (nothing is lost)
const s = setup({ seed: 11 });
const { m, ps } = s;
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
const at = ps.find(carrier.uid).key;
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_01_e_a', 'hand', i);
for (let i = 0; i < ps.temp.length; i++) if (!ps.temp[i]) giveItem(m, ps, 'chess_item_1_01_e_a', 'temp', i);
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
const next = ps.board.get(at);
assert.ok(next && next.uid !== carrier.uid && m.gd.chess(next.id).tier === 3);
assert.deepEqual(next.items.map((it) => it.id), [CELL], 'kept on the new operator');
checkInvariants(m);
m.dispose();
}
});
test('#4 hand full: the returned cell takes the last hand slot, the new operator overflows into temp — with no summon card until it is deployed', () => {
const s = setup({ seed: 31 });
const { m, ps } = s;
forceRoll(m, 3, VIGIL);
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const at = ps.find(carrier.uid).key;
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
fillHand(m, ps, 1);
battleResult(m, ps);
assert.ok(!ps.board.has(at), 'the carrier\'s tile is empty');
assert.equal(ps.deployCount, 0);
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back first, into the last hand slot');
const vigil = ps.temp.find((p) => p && p.id === VIGIL);
assert.ok(vigil, '伺夜 overflows into temp like any gain with a full hand');
assert.equal(where(ps, (p) => p.kind === 'token'), null, 'no 狼群 card yet: a summon card comes with a deployment');
// deployed by hand from temp, it brings its card like any operator placed there
const [row, col] = legalTileFor(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: vigil.uid, to: { area: 'board', row, col } }), OK);
assert.ok(where(ps, (p) => p.kind === 'token' && p.ownerUid === vigil.uid), 'its 狼群 card arrived');
checkInvariants(m);
m.dispose();
});
test('#4 hand and temp full: the gained operator follows the no-room rule of every gain (back to the pool, the same toast); the carrier is destroyed all the same', () => {
const s = setup({ seed: 11 });
const { h, m, ps } = s;
const t2 = plainOf(m, 2)[0];
const t3 = plainOf(m, 3)[0];
forceRoll(m, 3, t3);
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const at = ps.find(carrier.uid).key;
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
fillHand(m, ps, 0);
fillTemp(m, ps);
const left0 = m.pool.left(t3);
const left2 = m.pool.left(t2);
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (who, kind, text, ...rest) => { if (who === ps) toasts.push(text); return toast(who, kind, text, ...rest); };
battleResult(m, ps);
assert.ok(!ps.board.has(at), 'the carrier is destroyed wherever it stands');
assert.equal(ps.deployCount, 0);
assert.equal(m.pool.left(t2), left2 + 1, 'its copy went back to the pool');
assert.ok(!ps.allChess().some((p) => p.id === t3), 'no slot anywhere: the gained operator is not kept');
assert.equal(m.pool.left(t3), left0, 'its copy went back to the pool (PlayerState.acquireChess)');
assert.ok(toasts.includes('整备区已满,获得的干员已返还'), 'the toast of any gained operator with no room');
// the cell found no slot and no gained operator to stay on: destroyed with a log warning, as in a merge
assert.equal(ownedItems(ps, CELL).length, 0);
assert.ok(h.logs.warn.some((w) => w.includes(`returned item ${CELL} destroyed (no space)`)), 'logged');
checkInvariants(m);
m.dispose();
});
test('#4 per-piece state rides on the returned equipment: 商业包装方案 keeps its sell count through the transformation', () => {
const s = setup({ seed: 36 });
const { m, ps } = s;
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const pack = giveItem(m, ps, PACK);
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
assert.deepEqual(equip(m, pack, carrier), OK);
const [fodder] = plainOf(m, 1);
const sell = () => assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: give(m, ps, fodder, 'hand').uid }), OK);
for (let i = 0; i < 3; i++) sell();
const key = `pack:${pack.uid}`;
assert.equal(ps.counters[key], 3, 'three sells counted while equipped');
battleResult(m, ps);
const back = ownedItems(ps, PACK)[0];
assert.ok(back && back.uid === pack.uid, 'the same item came back to the hand');
assert.equal(ps.counters[key], 3, 'with its progress');
const next = ps.hand.find((p) => p && p.kind === 'chess');
assert.deepEqual(equip(m, back, next), OK);
sell();
assert.equal(ps.counters[key], 4, 'it goes on counting on its next carrier');
checkInvariants(m);
m.dispose();
});
test('#4 strategy 昆图斯 end to end: the R3 cell survives its first transformation; a bot injects its weakest single normal operator, never an elite', () => {
@@ -281,59 +401,125 @@ test('#4 strategy 昆图斯 end to end: the R3 cell survives its first transform
});
// ---------------------------------------------------------------------------------------------------------------------
// #4 follow-up: the returned cell must not be pushed into temp by the new operator's summon card (it would be lost at
// the deadline), and a bot never throws it away
// #4: a gain that completes a merge merges like any gained copy (DESIGN §20.11) — the elite goes to a consumed DEPLOYED
// copy's tile (board.js mergeTile), else to the hand; the destroyed carrier's tile is no copy's. The returned cell is
// stowed before the elite's summon card (it would be lost in temp at the deadline).
const VIGIL = 'chess_char_3_19_a'; // 伺夜 (Ⅲ) — a placeable 狼群 summon card
/** Distinct normal equipment ids (never merge with each other), the cell excluded. */
const fillerItems = () => Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden && it.id !== CELL).map((it) => it.id).sort();
/** Fill every free hand slot but `leave` with distinct items. */
const fillHand = (m, ps, leave = 1) => {
const ids = fillerItems().filter((id) => ![...ps.hand, ...ps.temp].some((p) => p && p.id === id));
while (ps.hand.filter((x) => x == null).length > leave) giveItem(m, ps, ids.shift());
};
/** Force the cell's roll (the random draw) to 伺夜 for tier 3; everything else stays the real path. */
const forceVigil = (m) => { const roll = m.pool.roll.bind(m.pool); m.pool.roll = (rng, o = {}) => (o.tier === 3 ? VIGIL : roll(rng, o)); };
const where = (ps, pred) => (ps.hand.some((p) => p && pred(p)) ? 'hand' : ps.temp.some((p) => p && pred(p)) ? 'temp' : null);
test('#4 one free hand slot, the new operator brings a summon card: the returned cell takes the slot, the card waits in temp', () => {
const s = setup({ seed: 31 });
test('#4 the gain completes a merge with deployed copies: the elite takes the first deployed copy\'s tile, never the carrier\'s; the cell is returned before the elite\'s summon card', () => {
const s = setup({ seed: 32 });
const { m, ps } = s;
forceVigil(m);
forceRoll(m, 3, VIGIL);
// the carrier deploys FIRST: its tile leads the deploy order (under the 0.1.1 WA rule the elite took it)
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const at = ps.find(carrier.uid).key;
const ct = ps.find(carrier.uid).key;
const copies = [give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL))];
copies.push(give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL)));
copies[0].dir = 'UP';
copies[1].dir = 'DOWN';
for (const c of copies) ps.removeTokensOf(c.uid); // keep the hand count exact
const copyTiles = copies.map((c) => ps.find(c.uid).key);
assert.equal(mergeTile([ct, ...copyTiles].map((key) => ({ key }))).key, ct, 'the carrier\'s tile is the first in deploy order');
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
fillHand(m, ps, 1);
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
const next = ps.board.get(at);
assert.equal(next && next.id, VIGIL, '伺夜 takes the carrier\'s tile');
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back into the hand (it would be lost in temp at the deadline)');
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === next.uid), 'temp', 'the 狼群 card overflows into temp (it comes back at the next round start)');
battleResult(m, ps);
const want = mergeTile(copyTiles.map((key) => ({ key })));
const elite = ps.board.get(want.key);
assert.ok(elite && elite.id === m.gd.goldenIdOf(VIGIL), 'the elite 伺夜 stands on the first deployed copy\'s tile');
assert.equal(elite.dir, want.key === copyTiles[0] ? 'UP' : 'DOWN', 'with that copy\'s facing');
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
assert.equal(ps.deployCount, 1, 'carrier + two copies deployed → the elite alone');
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back first, into the last hand slot');
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === elite.uid), 'temp', 'the elite\'s 狼群 card overflows into temp');
assert.equal(ps.stats.merges, 1);
checkInvariants(m);
m.dispose();
});
test('#4 the transformation completes a merge on the board: the cell is returned before the elite\'s summon card', () => {
const s = setup({ seed: 32 });
test('#4 the gain completes a merge with no deployed copy: the elite goes to the hand (no summon card); the carrier\'s tile stays empty', () => {
const s = setup({ seed: 35 });
const { m, ps } = s;
forceVigil(m);
const copies = [give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL))];
copies.push(give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL)));
for (const c of copies) ps.removeTokensOf(c.uid); // keep the hand count exact
forceRoll(m, 3, VIGIL);
const t2 = plainOf(m, 2)[0];
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
const ct = ps.find(carrier.uid).key;
const copies = [give(m, ps, VIGIL, 'hand'), give(m, ps, VIGIL, 'hand')];
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
fillHand(m, ps, 1);
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(VIGIL));
assert.ok(elite, 'the elite 伺夜 is deployed on a consumed copy\'s tile');
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell is in the hand');
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === elite.uid), 'temp', 'the elite\'s 狼群 card in temp');
battleResult(m, ps);
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(VIGIL));
assert.ok(elite, 'the gain completed the merge');
assert.ok(copies.every((c) => !ps.find(c.uid)), 'both bench copies were consumed');
assert.equal(ps.find(elite.uid).area, 'hand', 'no consumed copy was deployed (the carrier\'s tile is no copy\'s): the hand');
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
assert.equal(ps.deployCount, 0);
assert.equal(where(ps, (p) => p.kind === 'token'), null, 'an elite on the bench brings no summon card');
assert.equal(where(ps, (p) => p.id === CELL), 'hand');
assert.equal(ps.stats.merges, 1);
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward as for any merge');
checkInvariants(m);
m.dispose();
});
test('#4 bots and AI 托管 deploy the gained operator in their normal placement step (a real co-op round: SETTLE → the next prep)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 41, registry: REG, fake: true }).start();
const m = h.m;
h.toPrep(1);
const human = h.ps('p_0');
const bot = h.ps('ai_0');
h.run(() => bot.ready); // the bot's own R1 prep is done
// each seat: a small board — a tier-2 carrier of the cell and a tier-1 operator, nothing on the bench
const [t1a, t1b] = plainOf(m, 1);
const [t2a, t2b] = plainOf(m, 2);
const before = new Map();
for (const [ps, t1, t2] of [[human, t1a, t2a], [bot, t1b, t2b]]) {
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.offers.length = 0;
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
give(m, ps, t1, 'board', legalTileFor(m, ps, t1));
carrier.items.push(ps.newPiece('item', CELL));
ps.recompute();
before.set(ps, { uids: new Set(ps.allChess().map((p) => p.uid)), carrier });
}
// SETTLE: each carrier is destroyed and a tier-3 operator gained into the hand
const gained = new Map();
const settle = m.settle.bind(m);
m.settle = (...a) => {
const r = settle(...a);
for (const [ps, b] of before) gained.set(ps, ps.allChess().find((p) => !b.uids.has(p.uid)) || null);
return r;
};
// the layout each seat readies with in R2 (its bot prep ends with g.ready)
const atReady = new Map();
for (const ps of [human, bot]) {
const setReady = ps.setReady.bind(ps);
ps.setReady = (on) => {
const g = gained.get(ps);
if (on && m.round === 2 && g && !atReady.has(ps)) atReady.set(ps, { area: ps.find(g.uid)?.area ?? null, deployed: ps.deployCount });
return setReady(on);
};
}
h.toPrep(2);
for (const [ps, b] of before) {
const g = gained.get(ps);
assert.ok(g && m.gd.chess(g.id).tier === 3 && !m.gd.isGolden(g.id), `${ps.playerId}: a NORMAL tier-3 operator was gained at SETTLE`);
assert.ok(!ps.find(b.carrier.uid), `${ps.playerId}: the carrier is gone`);
assert.notEqual(ps.find(g.uid)?.area, 'board', `${ps.playerId}: it starts the prep on the bench`);
}
assert.ok(!human.ready, 'the human has not readied yet');
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: true }), OK); // AI 托管 plays the human seat from here
h.run(() => atReady.size === 2);
for (const ps of [bot, human]) {
const r = atReady.get(ps);
assert.ok(r, `${ps.playerId}: readied in R2`);
assert.equal(r.area, 'board', `${ps.playerId}: the gained operator was deployed by the placement step`);
assert.ok(r.deployed >= 2, `${ps.playerId}: the freed deployment is used (${r.deployed} deployed)`);
}
m.dispose();
});
test('#10 AI 托管 never destroys a human\'s item to clear temp: a bench operator is sold for its slot instead', () => {
const s = setup({ seed: 34 });
const { m, ps } = s;
+9 -5
View File
@@ -296,7 +296,7 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (the carrier\'s counts) or goes to the hand', () => {
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (never the carrier\'s) or goes to the hand', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
// where the carrier of 突变细胞 and the two copies of X are: hand/hand, board/hand, board/board
@@ -317,7 +317,9 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
const holder = carrierAt === 'board' ? give(m, ps, T, 'board', legalTileFor(m, ps, T)) : give(m, ps, T);
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = copiesAt === 'board' ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const deployedTiles = [holder, ...copies].map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
// the carrier is destroyed before its gain (PRTS 备注 "原干员销毁,获得一名…"): only the copies' tiles count
const holderTile = ps.find(holder.uid).key || null;
const deployedTiles = copies.map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
const deployed0 = ps.deployCount;
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
const fillers0 = ps.hand.filter((p) => p && p.kind === 'item').length;
@@ -332,10 +334,10 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
const loc = ps.find(elite.uid);
if (holderTile) assert.ok(!ps.board.has(holderTile), `${label}: the carrier's tile is empty`);
if (deployedTiles.length) {
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; the
// transformed carrier stood on the board, so its tile counts (when legal for the elite); of several, the first
// in deploy order (row desc, col asc)
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; of
// several, the first in deploy order (row desc, col asc)
const map = ps.deployMap();
const pos = positionClass(m.gd.chess(elite.id));
const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
@@ -346,6 +348,8 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
} else {
assert.equal(loc.area, 'hand', `${label}: the consumed hand pieces freed the slots`);
}
// the carrier's deployment is gone; deployed copies were replaced by at most the one elite
assert.equal(ps.deployCount, deployed0 - (holderTile ? 1 : 0) - deployedTiles.length + (loc.area === 'board' ? 1 : 0), `${label}: deploy count`);
const CELL = 'chess_item_5_08_e_a';
assert.equal(ps.hand.filter((p) => p && p.kind === 'item' && p.id !== CELL).length, fillers0, `${label}: the hand's equipment stays`);
// the cell is not consumed (player feedback after 0.1.0): it came back to the hand — or temp, the hand being full
+23 -18
View File
@@ -2,11 +2,11 @@
// official game): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置". PRTS 卫戍协议/帮助 §干员的获得与精锐化:
// "不获得第3名干员,销毁已有的2名初始干员,发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区
// 对应位置)". Remake (server/match/PlayerState.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a transformed deployed
// piece's own tile counts; with no deployed copy it goes to the hand (overflow temp). Equipment returns to the hand
// ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on the board gets its own stack
// (its loadout), the deploy count never grows, the reward offer is unchanged — for buys, rewards, effect grants, SETTLE
// merges and the boss-field prep alike. audit.js checks the rule in every audited match.
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a 突变细胞 carrier is
// destroyed before its gain, so its tile never counts; with no deployed copy it goes to the hand (overflow temp).
// Equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on
// the board gets its own stack (its loadout), the deploy count never grows, the reward offer is unchanged — for buys,
// rewards, effect grants, SETTLE merges and the boss-field prep alike. audit.js checks the rule in every audited match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
@@ -112,20 +112,22 @@ test('buy: two deployed copies → the copy that deploys first (row desc, then c
}
});
test('three deployed copies (a deployed operator transformed into the third, 突变细胞-style) → the first of the three tiles in deploy order', () => {
test('a deployed operator transformed into the third copy (突变细胞) → the first deployed COPY\'s tile, never the carrier\'s (destroyed first)', () => {
const { m, ps } = prep({ seed: 43 });
const [id, other] = meleeTier1(m);
// the carrier deploys first: under the 0.1.1 WA rule its tile won; the official destroy-then-gain frees it instead
const carrier = deploy(m, ps, other, 'UP');
const c1 = deploy(m, ps, id, 'LEFT');
const c2 = deploy(m, ps, id, 'DOWN');
const tiles = [carrier, c1, c2].map((p) => keyOf(ps, p));
const dirOf = { [tiles[0]]: 'UP', [tiles[1]]: 'LEFT', [tiles[2]]: 'DOWN' };
const [ct, t1, t2] = [carrier, c1, c2].map((p) => keyOf(ps, p));
assert.equal(mergeTile([ct, t1, t2].map((key) => ({ key }))).key, ct, 'the carrier\'s tile is the first in deploy order');
const elite = ps.transformChess(carrier, id);
assert.ok(elite && elite.id === chess(id).goldenId, 'the transformation completed the merge');
const want = mergeTile(tiles.map((key) => ({ key })));
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed tile');
assert.equal(elite.dir, dirOf[want.key]);
assert.equal(ps.deployCount, 1, 'three deployed → one');
assert.ok(elite && elite.id === chess(id).goldenId, 'the gain completed the merge');
const want = mergeTile([t1, t2].map((key) => ({ key })));
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed copy\'s tile');
assert.equal(elite.dir, want.key === t1 ? 'LEFT' : 'DOWN', 'with that copy\'s facing');
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
assert.equal(ps.deployCount, 1, 'carrier + two copies deployed → the elite alone');
assert.equal(ps.stats.merges, 1);
checkInvariants(m);
m.dispose();
@@ -420,7 +422,7 @@ test('audit.js flags an elite that misses the deployed copy\'s tile (and passes
m.dispose();
});
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform; a deployed carrier is detached first), in PREP and SETTLE', () => {
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform: the carrier is destroyed before the gain, its tile no copy\'s), in PREP and SETTLE', () => {
// carrier / the two copies: 'board' or 'hand'
const layouts = [['board', 'hand', 'hand'], ['board', 'board', 'hand'], ['board', 'board', 'board'], ['hand', 'board', 'board'], ['hand', 'hand', 'hand']];
for (const phase of ['PREP', 'SETTLE']) {
@@ -436,23 +438,26 @@ test('audit.js passes transformations that complete a merge (突变细胞 ctx.tr
const [id, other] = meleeTier1(m);
const place = (cid, at, dir) => (at === 'board' ? deploy(m, ps, cid, dir) : give(m, ps, cid));
const carrier = place(other, carrierAt, 'UP');
const carrierTile = keyOf(ps, carrier);
const copies = copiesAt.map((at, j) => place(id, at, j ? 'DOWN' : 'LEFT'));
const dirOf = new Map([carrier, ...copies].filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
// only the copies' tiles count (PRTS: a destroy, then a gain — the carrier is no copy of the merge)
const dirOf = new Map(copies.filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
const before = ps.deployCount;
if (phase === 'SETTLE') h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(m.phase, PHASE[phase]);
const ctx = makeCtx(m, ps, { kind: 'item', key: 'item:test' }, phase === 'SETTLE' ? 'onSettle' : 'onBuy');
const got = ctx.transform(carrier.uid, id);
assert.ok(got && got.id === chess(id).goldenId, `${label}: the transformation completed the merge`);
assert.ok(got && got.id === chess(id).goldenId, `${label}: the gain completed the merge`);
const want = mergeTile([...dirOf.keys()].map((key) => ({ key })));
const loc = ps.find(got.uid);
if (carrierTile) assert.ok(!ps.board.has(carrierTile), `${label}: the carrier's tile is empty`);
if (want) {
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed tile`);
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed copy's tile`);
assert.equal(loc.piece.dir, dirOf.get(want.key), `${label}: with that copy's facing`);
assert.equal(ps.deployCount, 1, `${label}: ${before} deployed → 1`);
} else {
assert.equal(loc.area, 'hand', `${label}: no deployed copy → the hand`);
assert.equal(ps.deployCount, 0);
assert.equal(ps.deployCount, 0, `${label}: ${before} deployed → 0`);
}
assert.deepEqual(audit.violations, [], `${label}: no audit violation`);
m.dispose();
+5 -3
View File
@@ -1,6 +1,7 @@
// Player feedback after 0.1.0, #4 (workstream WA): the 突变细胞 item card says what the official text leaves out — the
// cell is not consumed, it returns to the hand with the destroyed operator's other equipment (items.json `note`, built
// by tools/build-data.mjs ITEM_RULES; server: builtinMeta char_chess_transformation_equip).
// cell is not consumed, it returns to the hand with the destroyed operator's other equipment, and the new operator joins
// the 整备区 to be deployed again (official footage; PR #2) (items.json `note`, built by tools/build-data.mjs ITEM_RULES;
// server: builtinMeta char_chess_transformation_equip).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -35,7 +36,7 @@ const textOf = (v) => {
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand; an ordinary item has no rule line', async () => {
test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand and the new operator joins the 整备区; an ordinary item has no rule line', async () => {
await data.loadAll('items', 'assets');
const rule = (item) => [...walk(ItemDetail({ item, piece: null, editable: false }))].find((n) => hasClass(n, 'dhint--rule'));
for (const id of ['chess_item_5_08_e_a', 'chess_item_5_08_e_b']) {
@@ -44,6 +45,7 @@ test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand; an
const t = textOf(rule(cell));
assert.match(t, /原干员销毁/);
assert.match(t, /突变细胞与其他装备退回整备区,可再次配发/);
assert.match(t, /高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署/);
}
const plain = data.lookup('items', 'chess_item_1_01_e_a'); // 维式重锤
assert.equal(plain.note, null);
+9 -5
View File
@@ -1367,14 +1367,18 @@ const SHOP_EXCLUDED_ITEMS = Object.freeze({
* 获得一名高一阶的随机初始干员(最高六阶)", and a destroyed operator's equipment comes off (PRTS 卫戍协议/帮助 "佩戴的
* 装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除"); the text never says 销毁 for
* the cell (every consumable item's does), and players re-inject it every round ("之后就是一直打针,扎到核心卡或者叠层
* 手干员就换人扎", bilibili cv47000418; "这个道具可以无限使用", cg.163.com guide 2025-11-15).
* 手干员就换人扎", bilibili cv47000418; "这个道具可以无限使用", cg.163.com guide 2025-11-15). The new operator is gained
* into the 整备区, never onto the carrier's tile — official footage (bilibili BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25):
* at the next prep the tile is empty, one more deployment is left and the new operator waits on the bench (PRTS 帮助:
* what a player gains goes to the 手牌区; pointed out in PR #2).
*/
const ITEM_RULES = Object.freeze({
chess_item_5_08_e_a: {
note: '生效时原干员销毁,获得一名高一阶的随机初始干员(最高6阶);突变细胞与其他装备退回整备区,可再次配发',
implFormula: 'After the battle: the carrier is destroyed and replaced by a random NORMAL operator one tier higher (max 6; '
+ 'an elite carrier too) on its tile when legal; its equipment, the cell included, returns to the hand (the cell is '
+ 'not consumed). Never merges.',
note: '生效时原干员销毁,突变细胞与其他装备退回整备区,可再次配发;随后获得一名高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署',
implFormula: 'After the battle: the carrier is destroyed wherever it stands (a board tile is freed); its equipment, the '
+ 'cell included, returns to the hand first (overflow temp; the cell is not consumed); then a random NORMAL operator '
+ 'one tier higher (max 6; an elite carrier too) is gained like any gained operator: the hand, overflow temp, a '
+ 'completed merge as usual (the elite on a consumed deployed copy\'s tile, never the carrier\'s). Never merges.',
},
});