feedback1 integrate: WA per-copy 拉普兰德 refresh counter; 突变细胞 reusable
This commit is contained in:
commit
a5e78ee73b
18 files changed
+638
-60
No files matched your search
+1
-1
@@ -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)","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)","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","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","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":{}}],"params":{"min":1,"max":1},"category":"ECONOMY",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
|
||||
+2
-1
@@ -285,7 +285,8 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
|
||||
| `requiresBondId` | `null` / `"yanShip"` | VI-tier bond signature items (research 04) |
|
||||
| `effectId`, `effectName`, `desc`, `descRaw` | `"eff_acarm041"`, …, `"攻击力+15%"` | |
|
||||
| `buffs[]`, `params` | `[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}]` | |
|
||||
| `category`, `kind`, `family`, `implFormula` | `"STAT"`, `"passive"`, `null`, `"ATK% += atk …"` | research 04 (categories: STAT, ON_HIT, SURVIVAL, SP, ECONOMY, RECRUIT, BOND, BOND_GRANT, SET, BOND_SIGNATURE, MAGIC) |
|
||||
| `category`, `kind`, `family`, `implFormula` | `"STAT"`, `"passive"`, `null`, `"ATK% += atk …"` | research 04 (categories: STAT, ON_HIT, SURVIVAL, SP, ECONOMY, RECRUIT, BOND, BOND_GRANT, SET, BOND_SIGNATURE, MAGIC); `implFormula` is overridden by `tools/build-data.mjs ITEM_RULES` where research 04 was wrong (突变细胞) |
|
||||
| `note` | `null` / `"生效时原干员销毁,…突变细胞与其他装备退回整备区,可再次配发"` | a rule the official text leaves out (`ITEM_RULES`, sourced; normal and golden), shown under the effect in the item card — 突变细胞 is not consumed (player feedback after 0.1.0: PRTS 下半 记录 备注 "生效时,原干员销毁…", players re-inject it every round) |
|
||||
| `rangeGrid` | `[[0,0]]` | Arts area (画卷 `[[0,0],[0,1]]`) |
|
||||
| `flavor` | | |
|
||||
|
||||
|
||||
+23
-6
@@ -189,7 +189,9 @@ Buys toward a full board first (the cap is 8 from R1; leftover funds are lost),
|
||||
(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
|
||||
layers (every prep / refresh — the layer engines) and items for free carriers (突变细胞 on its least valuable single
|
||||
normal operator below 6阶 — never an elite or one of a merge pair, `bot.js cellTarget`; a cell left in temp gets a hand
|
||||
slot made for it, `makeHandRoom`); 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
|
||||
@@ -283,7 +285,7 @@ that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整
|
||||
| `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_REFRESH_SHOP` 刷新时 | `onRefresh` | same — manual refreshes only (`ev.trigger` marks a re-run); 拉普兰德's "本回合首次主动刷新" counts per copy (`incPieceCounter`) |
|
||||
| `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece |
|
||||
| `SERVER_PRICE` 购买价格 | `onPrice` (first) | the chess in the priced slot (`ctx.source.where === 'shop'`); SERVER_CHESS_PRICE `bb.price` is the discount off the tier price (至简 3 − 2 = 1, 红豆 2 − 1 = 1: both texts say 购买价格为1) |
|
||||
| `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) |
|
||||
@@ -308,7 +310,12 @@ Reads: `playerId seat name round phase modeId difficulty isSolo isCoop data (fro
|
||||
`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).
|
||||
counters `counter(k) setCounter(k, v) incCounter(k, n)` (player scope, persistent; prefix keys with your module) and
|
||||
`pieceCounter(uid, k) incPieceCounter(uid, k, n)` (per piece, current round only: 0 in a new round and for a new piece —
|
||||
bought, granted, transformed —; a move keeps it; an elite merged this round keeps the highest of its copies'
|
||||
[ASSUMED]; `PlayerState.pieceRoundCount`; prefix keys with your module too) — 拉普兰德's "本回合首次主动刷新" is the
|
||||
first manual refresh that copy witnesses (player feedback after 0.1.0: "获得该干员后该回合的首次刷新" also stacks; a copy
|
||||
bought after selling one this round is a new copy and fires on its own first refresh [ASSUMED]).
|
||||
|
||||
Writes (all validated, never throw on bad input, never make funds / pools negative):
|
||||
|
||||
@@ -321,7 +328,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
|
||||
| `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 |
|
||||
| `promote(uid)` / `transform(uid, chessId, { returnItems })` / `upgradeItem(uid)` | elite in place / replace a chess (keeps tile; keeps its equipment, or with `returnItems` sends it to the hand like a destroyed operator's — 突变细胞) / item → golden |
|
||||
| `destroyPiece(uid)` / `equipDirect(itemUid, chessUid)` | remove a piece (chess copies return, items go back) / attach without equip effects |
|
||||
| `offerChess(ids, { tier, label })` | queue a pick-one offer (shown as `shop.rewardOffer`, free) — 寻呼模块 / 信标 style; `label` (default `effectsMeta.offerLabel(source)`: the strategy's effect name, the item's name or the 特质's operator) is the shop bar's header instead of 晋升奖励 (`rewardOffer.source` `'special'`; the promotion reward is `'merge'`) |
|
||||
| `offerItems(ids, { tier, label })` | the same for items (slots of kind `'item'`, drawn as item cards) — 凯瑟琳 定向投放 style (player report #6 after 0.1.0) |
|
||||
@@ -352,7 +359,11 @@ Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random
|
||||
紧急调度券 (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
|
||||
N sells → same-bond chess), 突变细胞 (after battle → a random NORMAL chess one tier higher, max 6, on the carrier's tile;
|
||||
the carrier's equipment, the cell included, returns to the hand before the new operator's summon cards — the cell is
|
||||
not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的装备
|
||||
无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after 0.1.0),
|
||||
画卷 (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
|
||||
@@ -643,7 +654,13 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
|
||||
* 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.
|
||||
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
|
||||
cell included).
|
||||
* An elite merged in a round keeps the highest per-piece round counter of its copies (`pieceRoundCount`): an elite made
|
||||
from 拉普兰德 copies that already fired this round does not fire again before the next round (conservative; the
|
||||
official server's instance handling is not observable). A 拉普兰德 bought after selling one in the same round is a new
|
||||
copy and fires on its own first refresh ("获得该干员后"; each such +4 costs 3 + 1 refresh − 1 refund and needs her in
|
||||
the shop).
|
||||
* 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).
|
||||
|
||||
+2
-1
@@ -43,7 +43,7 @@
|
||||
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
|
||||
| 刷新 | 1 资金 |
|
||||
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
|
||||
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区 |
|
||||
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被替换为高一阶(最高 VI)的随机普通干员(精锐也一样),突变细胞和它的其他装备退回整备区,可以再次配发给任意干员 |
|
||||
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
|
||||
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算 |
|
||||
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
|
||||
@@ -58,6 +58,7 @@
|
||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——从上到下、从左到右);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
||||
- 每回合商店会自动刷新未冻结的格子。
|
||||
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。
|
||||
|
||||
## 4. 摆阵与朝向
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ Param names below are the keys of `items[].normal.params` / `items[].golden.para
|
||||
- **商业包装方案** (`chess_item_5_07_e_a`, TV, 3) - Stays equipped. Counter of operators SOLD by the player (any): every `count` sales, give 1 random NORMAL operator sharing a bond with the carrier, tier <= current shop level.
|
||||
params: `count=8.0/7.0`; buff keys: `sell_char_count_gain_equip_owner_bond`
|
||||
- **突变细胞** (`chess_item_5_08_e_a`, TV, 2) - After the battle ends: replace the carrier with a random operator one tier higher (max tier 6); item consumed [ASSUMED]. Keeps elite status? [ASSUMED: result is NORMAL quality; other equipped item returns to hand]. Never merges.
|
||||
**Addendum (player feedback after 0.1.0):** the cell is **not consumed**. PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注: "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)" (normal result confirmed); PRTS 卫戍协议/帮助: "佩戴的装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除" (a destroyed operator's equipment comes off); the official text never says 销毁 for the cell (every consumable's does); players re-inject it every round (bilibili cv47000418 "之后就是一直打针,扎到核心卡或者叠层手干员就换人扎"; cg.163.com guide 2025-11-15 "这个道具可以无限使用"; bilibili cv48003106 "扎了好几次") — the destroyed operator's equipment, the cell included, returns to the hand.
|
||||
params: `-`; buff keys: `char_chess_transformation_equip`
|
||||
- **人事部文档** (`chess_item_6_08_e_a`, TVI, 4) - On equip: destroy item; player's max deployable operator count becomes `count` (9) (base maxBattleChessCnt = 8). Never merges; a second copy has no further effect [ASSUMED].
|
||||
params: `count=9.0`; buff keys: `equip_destory_deployment_cnt_change`
|
||||
@@ -441,7 +442,7 @@ Pools referenced but NOT defined in client data (server-side) - proposed content
|
||||
3. Whether equipped copies count toward the 2-copy merge (assumed yes).
|
||||
4. Exact contents of server pools (`pool_equip_*`) and of `hunter_band_1` bounty choices.
|
||||
5. 道具补给 / 机密商店 offer size and tier (3-pick-1 assumed); round schedule taken from a community post.
|
||||
6. M3茧甲 revive HP (full assumed); 突变细胞 result quality (normal assumed) and whether the other equipped item returns to hand.
|
||||
6. M3茧甲 revive HP (full assumed); 突变细胞 result quality and equipment — resolved after 0.1.0: a normal operator (PRTS 备注 "随机初始干员"), the equipment and the cell return to the hand (addendum above).
|
||||
7. 天马之枪 "30% true damage": `atk_scale=0.3` suggests 30% of ATK per damage instance; alternative reading 30% of damage dealt. We use 30% ATK.
|
||||
8. 蒸汽之心 doubling for 战栗锤: prob or duration? (we double prob 10%->20%).
|
||||
|
||||
|
||||
@@ -201,6 +201,8 @@
|
||||
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
|
||||
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
|
||||
.dhint--merge { color: var(--gold); }
|
||||
/* a rule the official item text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) */
|
||||
.dhint--rule { color: var(--text-md); }
|
||||
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
|
||||
.dsell { margin-left: .06rem; color: #1d1400; }
|
||||
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
|
||||
|
||||
@@ -312,7 +312,8 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
|
||||
/**
|
||||
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
|
||||
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`).
|
||||
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`); a rule the
|
||||
* official text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) is shown under the effect.
|
||||
*/
|
||||
export function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
const m = data.get('assets');
|
||||
@@ -327,6 +328,7 @@ export function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
</div>
|
||||
</div>
|
||||
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
|
||||
${item.note ? html`<p class="dhint dhint--rule"><${Icon} name="info" />${item.note}</p>` : null}
|
||||
${item.itemType === 'MAGIC'
|
||||
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
|
||||
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
// the one that deploys first — board.js mergeTile, [ASSUMED]); it replaces a deployed copy, so the deploy count
|
||||
// never grows, and it gets its own summon stack (grantTokensFor). Otherwise to the hand, overflow temp — also
|
||||
// outside PREP (a SETTLE merge's elite waits in temp for the next prep).
|
||||
// * 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.
|
||||
// * 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
|
||||
@@ -353,6 +359,27 @@ export class PlayerState {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-piece counter of the current round (`piece.meta.round` = { r, n: { key: count } }): 0 for a key not counted yet
|
||||
* this round. The counters belong to the operator: a move keeps them, a new piece (bought, granted, transformed)
|
||||
* starts at 0, and an elite merged this round keeps the highest count of its copies (_mergeChess) — 拉普兰德's
|
||||
* "本回合首次主动刷新" is the first manual refresh she witnesses (player feedback after 0.1.0, garrisons/meta.js).
|
||||
*/
|
||||
pieceRoundCount(piece, key) {
|
||||
const rc = piece && piece.meta && piece.meta.round;
|
||||
return rc && rc.r === this.m.round && Number.isFinite(rc.n[key]) ? rc.n[key] : 0;
|
||||
}
|
||||
|
||||
/** Add `n` to a piece's counter of the current round (pieceRoundCount); returns the new count. */
|
||||
bumpPieceRoundCount(piece, key, n = 1) {
|
||||
if (!piece || typeof key !== 'string' || !Number.isFinite(n)) return 0;
|
||||
if (!piece.meta || typeof piece.meta !== 'object') piece.meta = {};
|
||||
const v = this.pieceRoundCount(piece, key) + n;
|
||||
if (!piece.meta.round || piece.meta.round.r !== this.m.round) piece.meta.round = { r: this.m.round, n: {} };
|
||||
piece.meta.round.n[key] = v;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Return a piece's pool copies (and its equipped items are handled by the caller). */
|
||||
returnCopies(piece) {
|
||||
if (piece && piece.kind === 'chess' && piece.poolCopies > 0) {
|
||||
@@ -503,6 +530,12 @@ export class PlayerState {
|
||||
l.piece.items = [];
|
||||
}
|
||||
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
|
||||
// this round's per-piece counters: the highest of the copies' (an elite made from 拉普兰德 that already saw their
|
||||
// first refresh this round does not fire again this round — [ASSUMED] conservative, pieceRoundCount)
|
||||
for (const l of consumed) {
|
||||
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 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));
|
||||
@@ -548,10 +581,17 @@ 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; 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).
|
||||
* 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]
|
||||
*/
|
||||
transformChess(piece, newId) {
|
||||
transformChess(piece, newId, { returnItems = false } = {}) {
|
||||
const loc = this.find(piece.uid);
|
||||
const rec = this.gd.chess(newId);
|
||||
if (!loc || loc.piece.kind !== 'chess' || !rec) return null;
|
||||
@@ -563,14 +603,26 @@ export class PlayerState {
|
||||
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 });
|
||||
np.items = items;
|
||||
if (!rec.isGolden && this.completesChessMerge(newId)) {
|
||||
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);
|
||||
if (!np) { this.recompute(); return null; }
|
||||
} 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); this.grantTokensFor(np); } else if (!this.stow(np, { allowTemp: true })) { this.returnCopies(np); np = null; }
|
||||
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) {
|
||||
if (this.stow(it, { allowTemp: true })) continue;
|
||||
if (np && 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);
|
||||
this.recompute();
|
||||
if (np) this.m.dispatch(this, 'onGain', { piece: np, kind: 'chess', source: 'transform' });
|
||||
return np;
|
||||
|
||||
+56
-5
@@ -35,8 +35,11 @@
|
||||
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
||||
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
||||
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
|
||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
|
||||
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler);
|
||||
// 突变细胞 (it comes back after every transformation) on the least valuable single normal operator below 6阶 —
|
||||
// never an elite or one of a merge pair (cellTarget)
|
||||
// 6. resolve the temp slots (a 突变细胞 left there gets a hand slot made for it — makeHandRoom — instead of being
|
||||
// destroyed), keep one hand slot free, then Ready.
|
||||
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
||||
// of kill rate and rehearsal adds ≈ 5 more; see docs/META.md §1.5.
|
||||
|
||||
@@ -804,12 +807,15 @@ function context(m, ps) {
|
||||
return { owned, focus: focusBond(m, ps, owned), roles: roles(m, ps), fly: model.flyTotal, model };
|
||||
}
|
||||
|
||||
/** Sell the weakest bench chess that is not part of a merge pair (or anything when keepPairs is false). */
|
||||
function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity } = {}) {
|
||||
/**
|
||||
* Sell the weakest bench chess (temp and hand; the hand only with `handOnly`) that is not part of a merge pair (or
|
||||
* anything when keepPairs is false).
|
||||
*/
|
||||
function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity, handOnly = false } = {}) {
|
||||
const ctx = context(m, ps);
|
||||
let worst = null;
|
||||
let worstV = Infinity;
|
||||
for (const p of [...ps.temp, ...ps.hand]) {
|
||||
for (const p of handOnly ? ps.hand : [...ps.temp, ...ps.hand]) {
|
||||
if (!p || p.kind !== 'chess') continue;
|
||||
const base = m.gd.baseIdOf(p.id);
|
||||
if (keepPairs && !m.gd.isGolden(p.id) && ps.countCopies(base) >= 2) continue;
|
||||
@@ -1159,6 +1165,27 @@ function* placeTokensSteps(m, ps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 突变细胞 (buff char_chess_transformation_equip): after the battle its carrier becomes a random operator one tier higher. */
|
||||
const isMutationCell = (gd, itemId) => { const rec = gd.item(itemId); return !!(rec && Array.isArray(rec.buffs) && rec.buffs.some((b) => b && b.key === 'char_chess_transformation_equip')); };
|
||||
|
||||
/**
|
||||
* Whom the bot injects with 突变细胞 (after the battle the carrier is replaced by a random NORMAL operator one tier
|
||||
* higher and the cell comes back — builtinMeta char_chess_transformation_equip): its least valuable normal operator
|
||||
* below 6阶 with a free equip slot — never an elite or one of a merge pair (both would be lost), nobody already carrying
|
||||
* a cell. null: the cell waits in the hand.
|
||||
*/
|
||||
export function cellTarget(m, ps, ctx = context(m, ps)) {
|
||||
const gd = m.gd;
|
||||
const cands = ps.allChess().filter((p) => !gd.isGolden(p.id) && gd.tierOf(p.id) < 6 && (p.items || []).length < gd.equipPerChess
|
||||
&& !(p.items || []).some((it) => isMutationCell(gd, it.id)) && ps.countCopies(gd.baseIdOf(p.id)) < 2);
|
||||
let best = null, bv = Infinity;
|
||||
for (const p of cands) {
|
||||
const v = pieceValue(m, ps, p, ctx);
|
||||
if (v < bv) { bv = v; best = p; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function equipItems(m, ps) {
|
||||
const gd = m.gd;
|
||||
const ctx = context(m, ps);
|
||||
@@ -1168,6 +1195,11 @@ function equipItems(m, ps) {
|
||||
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
|
||||
if (!item) break;
|
||||
tried.add(item.uid);
|
||||
if (isMutationCell(gd, item.id)) {
|
||||
const target = cellTarget(m, ps, ctx);
|
||||
if (target) tryDo(() => ps.equip(item.uid, target.uid));
|
||||
continue;
|
||||
}
|
||||
const rec = gd.item(item.id);
|
||||
if (rec && rec.itemType === 'MAGIC') {
|
||||
const board = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess');
|
||||
@@ -1187,6 +1219,20 @@ function equipItems(m, ps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free a hand slot for an item worth keeping (突变细胞): sell the weakest single hand chess, else destroy the cheapest
|
||||
* other hand item, else sell the weakest hand chess even of a pair. false: the hand stays full (summon cards only).
|
||||
*/
|
||||
function makeHandRoom(m, ps) {
|
||||
if (freeSlot(ps.hand) >= 0) return true;
|
||||
if (sellWeakestHand(m, ps, { handOnly: true }) && freeSlot(ps.hand) >= 0) return true;
|
||||
const gd = m.gd;
|
||||
const junk = ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id))
|
||||
.sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0];
|
||||
if (junk && tryDo(() => ps.destroy(junk.uid)) && freeSlot(ps.hand) >= 0) return true;
|
||||
return sellWeakestHand(m, ps, { handOnly: true, keepPairs: false }) && freeSlot(ps.hand) >= 0;
|
||||
}
|
||||
|
||||
function resolveTemp(m, ps) {
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
const p = ps.temp[i];
|
||||
@@ -1200,6 +1246,11 @@ function resolveTemp(m, ps) {
|
||||
if (j >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }));
|
||||
}
|
||||
} else if (p.kind === 'item') {
|
||||
// 突变细胞 comes back after every transformation (昆图斯's strategy item): make room in the hand rather than lose it
|
||||
if (isMutationCell(m.gd, p.id) && makeHandRoom(m, ps)) {
|
||||
const j = freeSlot(ps.hand);
|
||||
if (j >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }))) continue;
|
||||
}
|
||||
tryDo(() => ps.destroy(p.uid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
// 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 tier+1
|
||||
// char_chess_transformation_equip 突变细胞 after battle: holder → random NORMAL tier+1
|
||||
// (max 6); its equipment, the cell included,
|
||||
// returns to the hand (not consumed)
|
||||
// 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 simplifications, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
|
||||
// a personal choice overlay; 突变细胞 consumes itself.]
|
||||
// [ASSUMED simplification, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
|
||||
// a personal choice overlay.]
|
||||
|
||||
import { getData } from '../data.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
@@ -175,15 +177,20 @@ 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.
|
||||
char_chess_transformation_equip: {
|
||||
onBattleResult(ctx) {
|
||||
const { piece, holder } = ctx.source;
|
||||
if (!piece || !holder) return;
|
||||
if (!piece || !holder || !ctx.piece(holder.uid)) return;
|
||||
const tier = Math.min(6, ctx.gd.tierOf(holder.id) + 1);
|
||||
const id = ctx.rollChess({ tier });
|
||||
if (!id) return;
|
||||
ctx.destroyPiece(piece.uid);
|
||||
ctx.transform(holder.uid, id);
|
||||
ctx.transform(holder.uid, ctx.gd.baseIdOf(id), { returnItems: true });
|
||||
},
|
||||
},
|
||||
trap_copy_front_char: {
|
||||
|
||||
@@ -440,6 +440,10 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
counter: (k) => (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0),
|
||||
setCounter: (k, v) => { if (typeof k === 'string' && Number.isFinite(v)) ps.counters[k] = v; return ps.counters[k] ?? 0; },
|
||||
incCounter: (k, n = 1) => { if (typeof k !== 'string' || !Number.isFinite(n)) return 0; ps.counters[k] = (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0) + n; return ps.counters[k]; },
|
||||
/** Per-piece counter of the current round (0 in a new round / for a new piece; an elite merged this round keeps the
|
||||
* highest of its copies' — PlayerState.pieceRoundCount). */
|
||||
pieceCounter: (uid, k) => { const l = ps.find(uid); return l ? ps.pieceRoundCount(l.piece, k) : 0; },
|
||||
incPieceCounter: (uid, k, n = 1) => { const l = ps.find(uid); return l ? ps.bumpPieceRoundCount(l.piece, k, n) : 0; },
|
||||
|
||||
// ---- economy
|
||||
addFunds: (n, reason = '') => ps.addFunds(finiteInt(n), { reason }),
|
||||
@@ -498,8 +502,9 @@ 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). */
|
||||
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; },
|
||||
/** 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; },
|
||||
/** 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). */
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
|
||||
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
|
||||
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
|
||||
// * 拉普兰德 SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT "若为本回合首次主动刷新": per copy — the first manual refresh this
|
||||
// operator witnesses in the round (players' report after 0.1.0); [ASSUMED] an elite merged this round keeps its
|
||||
// copies' count, and a copy bought after selling one this round is a new copy (fires on its own first refresh).
|
||||
// "本回合每刷新过1次" (SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER, 阿罗玛 / 安洁莉娜 / 售出时)
|
||||
// fires on another event and reads the player's refreshes of the round (roundStats), like 本回合每获得过 / 每花费.
|
||||
|
||||
import { metaBonds, frontPiece, behindPiece, distinctTiers } from '../support/meta.js';
|
||||
|
||||
@@ -188,11 +193,22 @@ H.SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER = {
|
||||
},
|
||||
};
|
||||
|
||||
// 拉普兰德 "<刷新时>若为本回合首次主动刷新…,此干员在整备区时也有效": the refresh count is the operator's own — the manual
|
||||
// refreshes this copy witnessed this round (board or hand), so a 拉普兰德 bought after the round's first refresh still
|
||||
// fires on the next one (players' report after 0.1.0: "获得该干员后该回合的首次刷新" also stacks — the official behaviour;
|
||||
// read as each trait instance counting its own SERVER_REFRESH_SHOP triggers against bb.refresh_cnt). A re-triggered trait
|
||||
// (ev.trigger) is no manual refresh: it neither fires nor counts. A new copy (bought, granted) starts at 0. [ASSUMED]:
|
||||
// the copies of an elite merged this round pass on their highest count (PlayerState.pieceRoundCount — no second trigger
|
||||
// that round, conservative); a copy bought after selling one this round is a new copy — "获得该干员后" — and fires on its
|
||||
// own first refresh (the server cannot tell it from any other copy; each such +4 costs her price + a refresh − the
|
||||
// 1-fund refund, and needs her in the shop again).
|
||||
const REFRESH_CNT_KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT:refreshes'; // per-piece counter (module-prefixed)
|
||||
H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
|
||||
onRefresh(ctx, ev) {
|
||||
if (ev && ev.trigger) return;
|
||||
const { bb, bbStr, garrison } = ctx.source;
|
||||
if ((ctx.roundStats().refreshes | 0) !== num(bb.refresh_cnt, 1)) return;
|
||||
const { bb, bbStr, garrison, piece } = ctx.source;
|
||||
if (!piece || !Number.isInteger(piece.uid) || piece.uid <= 0) return;
|
||||
if (ctx.incPieceCounter(piece.uid, REFRESH_CNT_KEY) !== num(bb.refresh_cnt, 1)) return;
|
||||
addAll(ctx, ids(bbStr.bond), num(bb.layer), requireActiveOf(garrison));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
// 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)). 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 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.
|
||||
// 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
|
||||
// the target's items skipped the item after a merge)
|
||||
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
|
||||
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
|
||||
// 突变细胞 after the battle the carrier becomes a NORMAL random tier+1 operator; its other equipment goes back to
|
||||
// the hand (built-in kept it on the new operator)
|
||||
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
|
||||
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
|
||||
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
|
||||
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
|
||||
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
|
||||
@@ -103,23 +103,6 @@ export function registerMeta(registry) {
|
||||
},
|
||||
}));
|
||||
|
||||
// 突变细胞 — char_chess_transformation_equip
|
||||
wrap(registry, 'chess_item_5_08_e', () => ({
|
||||
onBattleResult(ctx) {
|
||||
const { piece, holder } = ctx.source;
|
||||
if (!piece || !holder) return;
|
||||
const tier = Math.min(6, ctx.gd.tierOf(holder.id) + 1);
|
||||
const id = ctx.rollChess({ tier });
|
||||
if (!id) return;
|
||||
const others = (ctx.piece(holder.uid)?.items || holder.items || []).filter((it) => it && it.uid !== piece.uid);
|
||||
ctx.destroyPiece(piece.uid);
|
||||
for (const it of others) {
|
||||
if (ctx.destroyPiece(it.uid)) ctx.grantItem(it.id, { source: 'mutation' });
|
||||
}
|
||||
ctx.transform(holder.uid, ctx.gd.baseIdOf(id));
|
||||
},
|
||||
}));
|
||||
|
||||
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
|
||||
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
|
||||
wrap(registry, key, (base) => ({
|
||||
|
||||
@@ -964,7 +964,9 @@ test('商业包装方案: every 8 / 7 operators sold ⇒ 1 normal operator shari
|
||||
}
|
||||
});
|
||||
|
||||
test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 operator, the cell is consumed, other equipment returns', () => {
|
||||
test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 operator; its equipment, the cell included, returns to the hand', () => {
|
||||
// PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)"; the cell is not consumed (player
|
||||
// feedback after 0.1.0 — players re-inject it every round; test/match/feedback1-meta.test.js)
|
||||
const { m, ps, equip } = setup({ seed: 3 });
|
||||
const cid = plain((c) => c.tier === 2)[0];
|
||||
const holder = give(m, ps, cid, 'hand');
|
||||
@@ -978,8 +980,16 @@ test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 op
|
||||
assert.ok(!DATA.chess[p.id].isGolden);
|
||||
assert.deepEqual(p.items, [], 'no equipment left on it');
|
||||
assert.ok(handIds(ps, 'item').includes(A('1_01')), 'other item back in the hand');
|
||||
assert.ok(!handIds(ps, 'item').includes(A('5_08')), 'cell consumed');
|
||||
assert.ok(handIds(ps, 'item').includes(A('5_08')), 'the cell back in the hand (not consumed)');
|
||||
assert.equal(DATA.items[A('5_08')].upgradeNum, 100);
|
||||
// the golden record (same buff) behaves the same
|
||||
const g = ps.hand.find((x) => x && x.id === A('5_08'));
|
||||
g.id = B('5_08');
|
||||
assert.deepEqual(equip(g, p), OK);
|
||||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
const q = ps.hand.find((x) => x && x.kind === 'chess');
|
||||
assert.equal(DATA.chess[q.id].tier, 4);
|
||||
assert.ok(handIds(ps, 'item').includes(B('5_08')));
|
||||
cover(A('5_08'), B('5_08'));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// Player feedback after 0.1.0 (workstream WA, match-side effects), through the real match paths (g.refresh / g.buy /
|
||||
// g.move / g.equip, the round loop's SETTLE):
|
||||
// #1 拉普兰德 garrison_123 "<刷新时>若为本回合首次主动刷新,使已激活的【叙拉古】层数+4(+8),此干员在整备区时也有效": the
|
||||
// players' official behaviour — "获得该干员后该回合的首次刷新" also stacks. The refresh count is the 拉普兰德's own
|
||||
// (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 ("之后就是一直打针,扎到核心卡…就换人扎").
|
||||
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';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
const OK = { ok: true };
|
||||
const LAP = 'chess_char_2_16_a'; // 拉普兰德 (Ⅱ, 叙拉古) — garrison_123_a
|
||||
const LAP_B = 'chess_char_2_16_b';
|
||||
const PROVENCE = 'chess_char_1_07_a'; // 普罗旺斯 (Ⅰ, 叙拉古)
|
||||
const TEXAS = 'chess_char_1_08_a'; // 德克萨斯 (Ⅰ, 独行 / 叙拉古)
|
||||
const CELL = 'chess_item_5_08_e_a'; // 突变细胞
|
||||
const KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT';
|
||||
|
||||
function setup({ seed = 70, mode = 'solo' } = {}) {
|
||||
const h = makeMatch({ mode, seed, registry: REG, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
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;
|
||||
ps.funds = 50;
|
||||
ps.layers = {};
|
||||
ps.pendingFunds = 0;
|
||||
ps.shop.freeRefreshes = 0;
|
||||
ps.bandId = null; // isolate from the band's own prep effects
|
||||
ps.recompute();
|
||||
/** layers added by 拉普兰德's trait (the dispatcher's reason = the registry key) */
|
||||
const lap = { n: 0 };
|
||||
const addLayers = ps.addLayers.bind(ps);
|
||||
ps.addLayers = (b, n, o = {}) => { const a = addLayers(b, n, o); if (o.reason === KEY && b === 'siracusaShip') lap.n += a; return a; };
|
||||
const refresh = () => assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
|
||||
const L = () => lap.n;
|
||||
/** put `id` into shop slot `i` (as a roll would) and buy it through g.buy; returns the owned piece */
|
||||
const buy = (id, i = 0) => {
|
||||
ps.shop.slots[i] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
|
||||
const before = new Set(ps.allChess().map((p) => p.uid));
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: i }), OK, `buy ${id}`);
|
||||
return ps.allChess().find((p) => !before.has(p.uid));
|
||||
};
|
||||
const place = (piece) => {
|
||||
const [row, col] = legalTileFor(m, ps, piece.id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row, col } }), OK);
|
||||
return [row, col];
|
||||
};
|
||||
const activate = () => { ps.bondCountBonus.siracusaShip = 20; ps.recompute(); };
|
||||
return { h, m, ps, refresh, L, buy, place, activate };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// #1 拉普兰德
|
||||
|
||||
test('#1 players\' scenario: 普罗旺斯 + 德克萨斯 deployed, refresh, buy 拉普兰德, deploy her (3 叙拉古), refresh again → 叙拉古 +4', () => {
|
||||
const s = setup();
|
||||
const { m, ps } = s;
|
||||
give(m, ps, PROVENCE, 'board', legalTileFor(m, ps, PROVENCE));
|
||||
give(m, ps, TEXAS, 'board', legalTileFor(m, ps, TEXAS));
|
||||
assert.ok(!ps.bonds.siracusaShip.active, '2 叙拉古: not active yet');
|
||||
s.refresh(); // the round's first manual refresh — no 拉普兰德 owned yet
|
||||
const lap = s.buy(LAP);
|
||||
s.place(lap);
|
||||
assert.ok(ps.bonds.siracusaShip.active, '3 different 叙拉古 on the board: active');
|
||||
const before = ps.layers.siracusaShip || 0;
|
||||
s.refresh(); // the round's 2nd refresh, her 1st
|
||||
assert.equal((ps.layers.siracusaShip || 0) - before, 4, 'the first refresh after she joined stacks +4 (the official behaviour the players report)');
|
||||
s.refresh();
|
||||
assert.equal((ps.layers.siracusaShip || 0) - before, 4, 'only her first refresh of the round');
|
||||
// the next round: her first refresh of that round stacks again
|
||||
s.h.toPrep(2);
|
||||
ps.funds = 50;
|
||||
const b2 = ps.layers.siracusaShip || 0;
|
||||
s.refresh();
|
||||
assert.equal((ps.layers.siracusaShip || 0) - b2, 4, 'R2: first refresh +4');
|
||||
s.refresh();
|
||||
assert.equal((ps.layers.siracusaShip || 0) - b2, 4, 'R2: once');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#1 each copy counts its own refreshes: a bench copy (整备区时也有效) bought later fires on its own first refresh; an elite keeps "already fired"', () => {
|
||||
const s = setup();
|
||||
const { m, ps } = s;
|
||||
s.activate();
|
||||
const a = give(m, ps, LAP, 'board', legalTileFor(m, ps, LAP));
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 4, 'copy A (board): its first refresh');
|
||||
const b = s.buy(LAP); // copy B stays in the hand (整备区)
|
||||
assert.equal(ps.find(b.uid).area, 'hand');
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8, 'copy B: its first refresh (A already fired this round)');
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8, 'nothing more this round');
|
||||
// the third copy completes the elite: A and B already fired this round, so the elite does not fire again this round
|
||||
// [ASSUMED: conservative — the elite keeps the highest refresh count of its copies]
|
||||
const elite = s.buy(LAP);
|
||||
assert.equal(elite.id, LAP_B, 'merged into the elite');
|
||||
assert.ok(!ps.find(a.uid) && !ps.find(b.uid), 'copies consumed');
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8, 'the elite made this round from copies that already fired: no second trigger');
|
||||
s.h.toPrep(2);
|
||||
ps.funds = 50;
|
||||
s.activate();
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8 + 8, 'R2: the elite fires +8 on the round\'s first refresh');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#1 an elite merged from copies that had not fired yet this round fires on the next refresh; a re-bought copy is a new 拉普兰德', () => {
|
||||
const s = setup();
|
||||
const { m, ps } = s;
|
||||
s.activate();
|
||||
s.refresh();
|
||||
s.refresh(); // two refreshes before owning any copy
|
||||
give(m, ps, LAP, 'hand');
|
||||
give(m, ps, LAP, 'hand');
|
||||
const elite = s.buy(LAP);
|
||||
assert.equal(elite.id, LAP_B);
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8, 'the elite\'s first refresh (the round\'s 3rd) stacks +8');
|
||||
// sell it, buy a new copy: a new operator — it fires on its own first refresh (costs its price and a refresh)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: elite.uid }), OK);
|
||||
s.buy(LAP);
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 8 + 4, 'a newly acquired copy counts from its own first refresh');
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 12);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#1 only manual refreshes count: a re-triggered "刷新时" trait (ctx.triggerGarrisons) neither fires nor uses up her first refresh', () => {
|
||||
const s = setup();
|
||||
const { m, ps } = s;
|
||||
s.activate();
|
||||
const a = give(m, ps, LAP, 'hand');
|
||||
assert.equal(m.dispatcher.triggerGarrisons(ps, a, 'SERVER_REFRESH_SHOP'), 1, 'the trait ran as a trigger');
|
||||
assert.equal(s.L(), 0, 'not a manual refresh');
|
||||
s.refresh();
|
||||
assert.equal(s.L(), 4, 'her first manual refresh still counts');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// #4 突变细胞
|
||||
|
||||
/** Plain visible normal chess (only IN_BATTLE traits) of a tier with a free pool copy. */
|
||||
const plainOf = (m, tier) => Object.values(DATA.chess)
|
||||
.filter((c) => c.visible && !c.isGolden && c.tier === tier && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && !m.gd.placeableTokens(c.chessId).length && m.pool.has(c.chessId) && m.pool.left(c.chessId) > 0)
|
||||
.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 });
|
||||
|
||||
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', () => {
|
||||
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
|
||||
assert.deepEqual(equip(m, cell, carrier), OK);
|
||||
assert.deepEqual(equip(m, sword, carrier), OK);
|
||||
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.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.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.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.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', () => {
|
||||
{
|
||||
const s = setup({ seed: 9 });
|
||||
const { h, m, ps } = s;
|
||||
const t3 = plainOf(m, 3)[0];
|
||||
const elite = give(m, ps, m.gd.goldenIdOf(t3), 'hand');
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), elite), OK);
|
||||
h.toPrep(2);
|
||||
const got = ps.allChess();
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(m.gd.chess(got[0].id).tier, 4);
|
||||
assert.ok(!m.gd.isGolden(got[0].id), 'PRTS 备注: 获得一名高一阶的随机初始干员');
|
||||
assert.equal(ownedItems(ps, CELL).length, 1);
|
||||
m.dispose();
|
||||
}
|
||||
{
|
||||
const s = setup({ seed: 10 });
|
||||
const { h, m, ps } = s;
|
||||
const t6 = plainOf(m, 6)[0] || Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 6 && m.pool.has(c.chessId) && m.pool.left(c.chessId) > 0).chessId;
|
||||
const top = give(m, ps, t6, 'hand');
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), top), OK);
|
||||
h.toPrep(2);
|
||||
const got = ps.allChess();
|
||||
assert.equal(got.length, 1);
|
||||
assert.equal(m.gd.chess(got[0].id).tier, 6, '最高6阶');
|
||||
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 strategy 昆图斯 end to end: the R3 cell survives its first transformation; a bot injects its weakest single normal operator, never an elite', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 21, registry: REG, fake: true }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1, { band: 'band_quintus' });
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.bandId, 'band_quintus');
|
||||
h.toPrep(3, { band: 'band_quintus' });
|
||||
assert.equal(ownedItems(ps, CELL).length, 1, 'R3: the strategy grants the cell');
|
||||
// equip it on a deployed operator (any) and play the R3 battle
|
||||
const target = ps.allChess()[0] || give(m, ps, plainOf(m, 1)[0], 'board', legalTileFor(m, ps, plainOf(m, 1)[0]));
|
||||
assert.deepEqual(equip(m, ownedItems(ps, CELL)[0], target), OK);
|
||||
h.toPrep(4);
|
||||
const owned = [...ownedItems(ps, CELL), ...ps.allChess().flatMap((p) => (p.items || []).filter((it) => it.id === CELL))];
|
||||
assert.equal(owned.length, 1, 'R4: the cell is still owned after its transformation');
|
||||
m.dispose();
|
||||
|
||||
// the bot's choice: its least valuable single normal operator below 6阶 — never an elite, a merge pair or a 6阶
|
||||
const s = setup({ seed: 23 });
|
||||
const { m: m2, ps: p2 } = s;
|
||||
const [t1a, t1b] = plainOf(m2, 1);
|
||||
const t3 = plainOf(m2, 3)[0];
|
||||
const t6 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 6 && m2.pool.has(c.chessId) && m2.pool.left(c.chessId) > 0).chessId;
|
||||
const elite = give(m2, p2, m2.gd.goldenIdOf(t3), 'board', legalTileFor(m2, p2, t3));
|
||||
const pair = [give(m2, p2, t1a, 'board', legalTileFor(m2, p2, t1a)), give(m2, p2, t1a, 'hand')];
|
||||
const top = give(m2, p2, t6, 'hand');
|
||||
assert.equal(cellTarget(m2, p2), null, 'no candidate: the cell waits in the hand');
|
||||
const single = give(m2, p2, t1b, 'board', legalTileFor(m2, p2, t1b));
|
||||
assert.equal(cellTarget(m2, p2).uid, single.uid, 'the single normal operator');
|
||||
void elite; void pair; void top;
|
||||
// through the whole bot prep (it may buy, sell and move first): whoever carries the cell is a normal operator below 6阶
|
||||
p2.isBot = true;
|
||||
giveItem(m2, p2, CELL);
|
||||
botPrep(m2, p2);
|
||||
const carrierOf = p2.allChess().find((p) => (p.items || []).some((it) => it.id === CELL));
|
||||
assert.ok(carrierOf, 'the bot equipped the cell');
|
||||
assert.ok(!m2.gd.isGolden(carrierOf.id) && m2.gd.tierOf(carrierOf.id) < 6, 'never an elite or a 6阶');
|
||||
m2.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// #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
|
||||
|
||||
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 });
|
||||
const { m, ps } = s;
|
||||
forceVigil(m);
|
||||
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);
|
||||
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)');
|
||||
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 });
|
||||
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
|
||||
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);
|
||||
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');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 a bot never destroys the cell: left in temp with a full hand and nobody to inject, it gets a hand slot', () => {
|
||||
const s = setup({ seed: 33 });
|
||||
const { m, ps } = s;
|
||||
ps.isBot = true;
|
||||
const elites = [1, 2, 3, 4, 5, 6].flatMap((t) => plainOf(m, t)).map((id) => m.gd.goldenIdOf(id)).filter((g) => g && m.pool.left(m.gd.baseIdOf(g)) >= m.gd.goldenCopies);
|
||||
for (let i = 0; i < 8; i++) { const id = elites.shift(); give(m, ps, id, 'board', legalTileFor(m, ps, id)); }
|
||||
while (ps.hand.some((x) => x == null)) give(m, ps, elites.shift(), 'hand');
|
||||
giveItem(m, ps, CELL, 'temp');
|
||||
assert.equal(cellTarget(m, ps), null, 'elites only: nobody to inject');
|
||||
botPrepEnd(m, ps); // the end of the bot's prep: temp → hand / sell / destroy, a free hand slot, Ready
|
||||
assert.equal(ownedItems(ps, CELL).length, 1, 'the bot kept its strategy item (a hand chess was sold for the slot)');
|
||||
assert.ok(ps.tempEmpty, 'temp resolved');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -346,8 +346,11 @@ 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`);
|
||||
}
|
||||
assert.equal(ps.hand.filter((p) => p && p.kind === 'item').length, fillers0, `${label}: the hand's equipment stays`);
|
||||
assert.ok(ps.tempEmpty && ps.privateView().canReady, `${label}: nothing waits in temp`);
|
||||
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
|
||||
assert.equal([...ps.hand, ...ps.temp].filter((p) => p && p.id === CELL).length, 1, `${label}: the cell came back`);
|
||||
assert.ok(ps.temp.every((p) => !p || p.id === CELL), `${label}: nothing else waits in temp`);
|
||||
checkInvariants(m);
|
||||
// it expires at the end of that prep like any other offer
|
||||
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
|
||||
const { ItemDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const textOf = (v) => {
|
||||
if (v == null || typeof v === 'boolean') return '';
|
||||
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
||||
if (Array.isArray(v)) return v.map(textOf).join('');
|
||||
return typeof v === 'object' ? textOf(v.props?.children) : '';
|
||||
};
|
||||
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 () => {
|
||||
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']) {
|
||||
const cell = data.lookup('items', id);
|
||||
assert.ok(cell && cell.note, `${id}: items.json note`);
|
||||
const t = textOf(rule(cell));
|
||||
assert.match(t, /原干员销毁/);
|
||||
assert.match(t, /突变细胞与其他装备退回整备区,可再次配发/);
|
||||
}
|
||||
const plain = data.lookup('items', 'chess_item_1_01_e_a'); // 维式重锤
|
||||
assert.equal(plain.note, null);
|
||||
assert.equal(rule(plain), undefined);
|
||||
});
|
||||
|
||||
test('the rule line has its own style (game-panels.css .dhint--rule), not only a test hook', () => {
|
||||
const css = readFileSync(path.join(ROOT, 'public/css/screens/game-panels.css'), 'utf8');
|
||||
assert.match(css, /\.dhint--rule\s*\{[^}]*color:/);
|
||||
});
|
||||
+21
-1
@@ -1356,6 +1356,24 @@ const SHOP_EXCLUDED_ITEMS = Object.freeze({
|
||||
chess_item_5_08_e_a: '策略【不稳定要素】(昆图斯)', // 突变细胞
|
||||
});
|
||||
|
||||
/**
|
||||
* Rules the official item text leaves out, by normal item id (both qualities): `note` = a player-facing line shown under
|
||||
* the effect in the item card (items.json `note`), `implFormula` replaces research 04's formula.
|
||||
* 突变细胞 — not consumed (player feedback after 0.1.0): PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原干员销毁,
|
||||
* 获得一名高一阶的随机初始干员(最高六阶)", 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).
|
||||
*/
|
||||
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.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Build data/items.json: every item chess (EQUIP normal + golden, MAGIC Arts), keyed by chessId.
|
||||
*/
|
||||
@@ -1381,6 +1399,7 @@ function buildItems(ctx, effects) {
|
||||
const isGolden = !!t.isGolden;
|
||||
const upgradeNum = t.upgradeNum;
|
||||
const excluded = Object.hasOwn(SHOP_EXCLUDED_ITEMS, baseId) ? SHOP_EXCLUDED_ITEMS[baseId] : null;
|
||||
const rule = Object.hasOwn(ITEM_RULES, baseId) ? ITEM_RULES[baseId] : null;
|
||||
out[chessId] = {
|
||||
id: chessId, baseId, goldenId: shop?.goldenItemId || null, isGolden,
|
||||
trapId: t.charId, iconId: t.charId, identifier: t.identifier,
|
||||
@@ -1398,7 +1417,8 @@ function buildItems(ctx, effects) {
|
||||
buffs: (eff?.buffs || []).map((b) => ({ key: b.key, countType: b.countType, bb: b.bb, bbStr: b.bbStr })),
|
||||
params: eff?.params || {},
|
||||
category: ri?.category || null, kind: ri?.kind || null, family: ri?.family || null,
|
||||
implFormula: ri?.implFormula || null,
|
||||
implFormula: rule?.implFormula || ri?.implFormula || null,
|
||||
note: rule?.note || null,
|
||||
rangeGrid: Array.isArray(ri?.rangeGrids) ? ri.rangeGrids.map((g) => [g.row, g.col]) : null,
|
||||
flavor: ri?.flavor || null,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user