diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d020a46c..57ef2e53 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -386,7 +386,7 @@ Summons (§20.1): an operator on the board sends its manually deployable summons Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7). -Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; temp blocks ready). +Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready). **Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5). diff --git a/docs/META.md b/docs/META.md index 3ff9df23..31295740 100644 --- a/docs/META.md +++ b/docs/META.md @@ -407,8 +407,8 @@ Writes (all validated, never throw on bad input, never make funds / pools negati |---|---| | `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) | | `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0 | -| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain | -| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item) | +| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain. `toTemp` puts it into temp, but a free hand slot pulls it in at the next recompute (temp holds overflow only, `PlayerState._fillHandFromTemp`) | +| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item); `toTemp` as for grantChess | | `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` | @@ -508,9 +508,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o * **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies; selling, temp resolution and elimination return exactly what a piece holds (`left + held = cap` always). * **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment); - a full hand refuses buys unless the purchase completes a merge, and withdrawals unless the withdrawn summoner's own - summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new card: `HAND_FULL`, - never temp); temp blocks Ready. A temp piece is resolved (chess sold back to the pool, items destroyed, a summon stack + a full hand refuses every buy and reward pick — also one whose copy would complete a merge at once (PRTS + 卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作"; GitHub #82) — and withdrawals unless the withdrawn + summoner's own summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new + card: `HAND_FULL`, never temp). A free hand slot pulls the temp pieces in at once ("常规手牌区出现空位时自动移入"): + `PlayerState._fillHandFromTemp` at every recompute, the temp row right → left (the order it fills, [ASSUMED]) into the + hand's free slots right → left — after a sale, a deployment, a merge that consumed hand copies, an item equipped / + destroyed / used, a summon stack removed with its owner; so temp holds pieces only while the hand is full + (`invariants.js`). Temp blocks Ready. A temp piece is resolved (chess sold back to the pool, items destroyed, a summon stack removed — it comes back at the next round start, see Summons) at the deadline of the first prep in which its player could act on it (`PlayerState.tempDue` vs `prepsEnded`, recorded by `_putTemp`, user playtest #3): arrived during a prep before Ready → that prep's end; after Ready, during onPrepEnd, or outside PREP (COMBAT, 联防, SETTLE, ROUND_START, 机变) → the end of the NEXT prep, so it diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 1235246c..5b31c705 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -54,7 +54,7 @@ - **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。 - **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。 -- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。 +- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。 - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - 每回合商店会自动刷新未冻结的格子。 diff --git a/docs/research/00-INDEX.md b/docs/research/00-INDEX.md index b1aaccf1..dcfd98a0 100644 --- a/docs/research/00-INDEX.md +++ b/docs/research/00-INDEX.md @@ -73,7 +73,7 @@ Boss rounds: the battle continues past the timer. Merged team LP drains **1/s af The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same tier shares, then picks uniformly within the tier [ASSUMED]. -**Merge:** 3 copies of the same chess (board or hand) merge into 1 elite (`_b`), sent to the **hand** — or, when a consumed copy was deployed, to **that copy's board position** (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; 01 A1 row 7). 风丸 needs 2. Elites never merge. The equipment of the merged copies returns to the hand. Reward: the shop temporarily shows 3 operators of tier min(shopLv+1, 6) at price **0**; take 1; no refresh or freeze; gone at round end. A purchase that completes a merge is allowed with a full hand. [VERIFIED] +**Merge:** 3 copies of the same chess (board or hand) merge into 1 elite (`_b`), sent to the **hand** — or, when a consumed copy was deployed, to **that copy's board position** (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; 01 A1 row 7). 风丸 needs 2. Elites never merge. The equipment of the merged copies returns to the hand. Reward: the shop temporarily shows 3 operators of tier min(shopLv+1, 6) at price **0**; take 1; no refresh or freeze; gone at round end. A full regular hand refuses every purchase, also one that would complete a merge (PRTS 卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作"; corrected in 0.2.0, GitHub #82 — this line used to allow it). [VERIFIED] ## 4. Board, hand, combat diff --git a/docs/research/01-core-rules.md b/docs/research/01-core-rules.md index eccc2500..96ff6117 100644 --- a/docs/research/01-core-rules.md +++ b/docs/research/01-core-rules.md @@ -841,7 +841,7 @@ Local copies are in `scratchpad/gd/extra/critic/` (`bw_盟约.txt`, `bw_sweep.tx | 4.1 | round-start auto refresh [ASSUMED] | Confirmed: "进入此阶段时,触发<进入休整期时>的效果,分配一定资金,刷新调度中心未被冻结的位置". Income is granted **before** the 机变 phase: funds are visible behind every 机变 overlay screenshot. | VERIFIED | | 4.2 / 14 | freeze per slot [ASSUMED] | **One 冻结 button freezes ALL unsold slots**, operators and item: "【冻结】会将当前整备区的全部未售出栏位冻结,将其保留至下一回合。点击【解冻】可以解除该操作". The freeze is consumed at the next round start. In the official intro, frozen cards reappear in R3 and the button reads 冻结 again. A manual refresh is allowed while frozen (tip 4) and rerolls every slot, frozen ones included (2024 rule "重新调配包括冻结中的所有干员和道具"). Keep a per-slot `frozen` flag internally, because 梓兰 freezes one card. | VERIFIED | | new | – | At **combat start, all unfrozen shop contents are cleared** ("进入作战期后,栏上若未处于冻结状态则清空全部物资"). | VERIFIED | -| 4.2 / 8 | bench 10 + temp area | **Hand (手牌区/整备区) = 10 regular + 5 temporary slots**, shared by operators, **items** and hand-deployable **summons**. Several summons of one owner stack into 1 slot; summons of different owners don't stack.
When the regular hand is full, every action that would add a card is refused: buy, and withdraw from the board. Exception: a purchase that completes a 3-merge nets −1 card, so allow it.
Passive gains (merge result, band/trait/item grants, items returned by promotion) overflow into the temp slots. **While temp is non-empty the player cannot start combat.** Temp is wiped when the next phase starts.
A board↔hand swap is allowed even with a full hand. Cards fill hand slots **right→left**. | VERIFIED | +| 4.2 / 8 | bench 10 + temp area | **Hand (手牌区/整备区) = 10 regular + 5 temporary slots**, shared by operators, **items** and hand-deployable **summons**. Several summons of one owner stack into 1 slot; summons of different owners don't stack.
When the regular hand is full, every action that would add a card is refused: buy (also one that would complete a 3-merge — PRTS "例如招募/购入等通常情况下会增加手牌的操作"; GitHub #82 corrected the old exception), and withdraw from the board.
Passive gains (merge result, band/trait/item grants, items returned by promotion) overflow into the temp slots, and move into a regular slot as soon as one is free ("常规手牌区出现空位时自动移入"). **While temp is non-empty the player cannot start combat.** Temp is wiped when the next phase starts.
A board↔hand swap is allowed even with a full hand. Cards fill hand slots **right→left**. | VERIFIED | | 5.2 | – | **Items cannot be sold** ("多余的装备无法进行出售,可以销毁"). They can only be destroyed for 0 funds. Operators sell for 1 from board or hand. | VERIFIED | | 7 | promotion reward [COMM] | Confirmed (PRTS 帮助, BWIKI, official intro). On acquiring the 3rd copy:
1. The 3rd copy is not created, and the 2 owned copies are destroyed wherever they are.
2. **1 elite goes to the hand — or, when a consumed copy was deployed, to that copy's board position** (PRTS 帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)"; the user confirmed it first-hand after playtest #6 — this row used to read "not to a board tile", a misreading of the same sentence). Their equipment returns to the hand ("干员晋级后已配发装备会回收至整备区,需要重新配置"). [ASSUMED] where the text is silent: with two or three deployed copies the elite takes the tile of the one that deploys first (the left column first, top to bottom — the official deploy order, §4.3) with its facing; the copies' summons leave and the elite's own summon card joins the hand (§战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"); the deploy count never grows. A 突变细胞 carrier is destroyed before its new operator is gained (official footage, DESIGN §21.1), so a merge that operator completes never uses the carrier's tile.
3. The shop then temporarily shows **3 operators of tier min(shopLevel+1, 6) at price 0**. The official intro shows "II 0 ×3" after a tier-I merge at L1 and "III 0 ×3" at L2.
4. The player takes **1**. The offer cannot be refreshed or frozen and expires at round end. | VERIFIED (elite to the copy's board position: PRTS + the user); the tile among several deployed copies ASSUMED | | 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer | diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index c4316474..54899897 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -731,15 +731,16 @@ export function mergeTarget(priv, chessId, getChess = () => null) { return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' }; } -/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */ +/** Whether every hand slot is taken (the server refuses every purchase / reward pick then: HAND_FULL, GitHub #82). */ export function handFull(priv) { const hand = Array.isArray(priv?.hand) ? priv.hand : []; return hand.length >= GEO.HAND_SIZE && hand.every((p) => p != null); } /** - * Would gaining one more `slot` (shop / reward card: { kind: 'chess'|'item', id }) complete a merge right away, so it - * needs no hand slot? Mirror of server PlayerState completesChessMerge / completesItemMerge. + * Would gaining one more `slot` (shop / reward card: { kind: 'chess'|'item', id }) complete a merge right away? Mirror + * of server PlayerState completesChessMerge / completesItemMerge. (A full hand refuses such a purchase all the same: + * PRTS 卫戍协议/帮助 §手牌区, GitHub #82.) * @param {any} priv * @param {{kind?:string, id:string}} slot * @param {{ getChess?:(id:string)=>any, getItem?:(id:string)=>any, itemMergeCount?: number }} [o] @@ -795,10 +796,10 @@ export function offerHeader(offer) { /** * Why a shop action is unavailable (null when available). * @param {'buy'|'reward'|'refresh'|'freeze'|'levelUp'|'ready'} kind - * @param {{ priv:any, editable:boolean, slot?:any, getChess?:(id:string)=>any, getItem?:(id:string)=>any }} ctx + * @param {{ priv:any, editable:boolean, slot?:any }} ctx * @returns {string|null} Chinese reason */ -export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem } = {}) { +export function shopBlockReason(kind, { priv, editable, slot } = {}) { if (!priv) return '尚未就绪'; if (priv.alive === false) return '你已被淘汰'; if (kind === 'ready') return priv.canReady === false ? '临时整备区不为空,请先处理溢出的资源' : null; @@ -811,7 +812,8 @@ export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem if (kind === 'buy' || kind === 'reward') { if (!isObj(slot) || slot.sold) return kind === 'reward' ? '已选择' : '已售出'; if ((Number(slot.price) || 0) > funds) return '资金不足'; - if (handFull(priv) && !completesMerge(priv, slot, { getChess, getItem })) return '整备区已满'; + // a full hand refuses every purchase / pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82) + if (handFull(priv)) return '整备区已满'; return null; } if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? '资金不足' : null; diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 439cfae5..f4c16081 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -37,7 +37,7 @@ function PriceHex({ slot, free, poor = false }) { title=${poor ? '资金不足' : tone === 'discount' ? `折扣价(原价 ${slot.basePrice})` : tone === 'premium' ? `加价(原价 ${slot.basePrice})` : '价格'} />`; } -/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */ +/** Data lookups (merge progress / target, the loadout; shopBlockReason ignores them — a full hand refuses every purchase). */ const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) }; /** diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index 85d842c2..9a39c05b 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -2,9 +2,12 @@ // // Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they // return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2): -// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals, -// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge -// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战"). +// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys and reward picks — +// also one that would complete a merge at once (PRTS 卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作"; +// GitHub #82) — and withdrawals, except one whose own summon stack frees a slot. Passive gains (merge results, +// grants, returned equipment) overflow into temp, and a free hand slot pulls them back in at once, right→left +// (_fillHandFromTemp at every recompute: "常规手牌区出现空位时自动移入"); temp blocks Ready ("直到溢出情况排除才可开始 +// 进行作战"). // A temp piece is resolved (chess sold back to the pool, items destroyed, summon stacks removed — they come back at // the next round start, grantTokensFor) at the deadline of the // first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready diff --git a/server/match/bot.js b/server/match/bot.js index 1e2eab83..f2c08750 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -1432,6 +1432,11 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { // shed is pre-emptive (one slot is still free), so with only fresh singles it waits for the next prep. const used = ps.hand.filter(Boolean).length; if (used >= gd.benchSize - 1) sellWeakestHand(m, ps, { keepFresh: true }); + // a full hand refuses every purchase, the third copy of a held pair too (PlayerState.buy, PRTS 卫戍协议/帮助 §手牌区): + // an affordable one sheds the weakest hand single first (like the shed above, never one of this prep) + if (freeSlot(ps.hand) < 0 && ps.shop.slots.some((s) => s && !s.sold && s.kind === 'chess' && ps.priceOf(s) <= ps.funds && ps.completesChessMerge(s.id))) { + sellWeakestHand(m, ps, { handOnly: true, keepFresh: true }); + } let best = -1; let bestS = 0; let bestMerges = false; @@ -1443,7 +1448,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { let sc; if (s.kind === 'chess') { const merges = ps.completesChessMerge(s.id); - if ((freeSlot(ps.hand) < 0 || mergesOnly) && !merges) return; + if (freeSlot(ps.hand) < 0 || (mergesOnly && !merges)) return; const base = gd.baseIdOf(s.id); if (boardFull && !merges && ps.funds - price < reserve) return; // banked (坎诺特) sc = buyScore(m, ps, s.id, ctx) - price; @@ -1466,7 +1471,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { } else { if (fillOnly) return; if (!canUseItem(m, ps, { id: s.id })) return; - if (freeSlot(ps.hand) < 0 && !ps.completesItemMerge(s.id)) return; + if (freeSlot(ps.hand) < 0) return; if (ps.funds - price < reserve && !ps.completesItemMerge(s.id)) return; const carriers = [...ps.board.values()].filter((p) => p.kind === 'chess' && (p.items || []).length < gd.equipPerChess).length; if (!carriers) return; @@ -1805,34 +1810,40 @@ function equipItems(m, ps) { /** * Free a hand slot for an item worth keeping (突变细胞): sell the weakest single hand chess, else (on a bot's own seat * only — a human's items under AI 托管 are never destroyed to make room, like freeHandSlot) destroy the cheapest other - * hand item, else sell the weakest hand chess even of a pair. false: the hand stays full (summon cards only). + * hand item, else sell the weakest hand chess even of a pair. `piece` (in temp): done once the freed slot pulled it in + * (PlayerState._fillHandFromTemp — resolveTemp asks for the rightmost temp piece, the next one a slot pulls in). 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; +function makeHandRoom(m, ps, piece = null) { + const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece)); + if (room()) return true; + if (sellWeakestHand(m, ps, { handOnly: true }) && room()) return true; const gd = m.gd; const junk = ps.isBot && !ps.autoplay ? 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] : null; - 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; + if (junk && tryDo(() => ps.destroy(junk.uid)) && room()) return true; + return sellWeakestHand(m, ps, { handOnly: true, keepPairs: false }) && room(); } +/** + * Empty the temp row before Ready. A freed hand slot pulls the temp pieces in by itself, rightmost first + * (PlayerState._fillHandFromTemp), so the pieces are decided right → left: the one at hand is always the next a freed + * slot takes. A chess: the weakest bench single is sold to make room (it may be the temp chess itself), else the temp + * chess is sold; an item: see below, else destroyed. + */ function resolveTemp(m, ps) { - for (let i = 0; i < ps.temp.length; i++) { + for (let i = ps.temp.length - 1; i >= 0; i--) { const p = ps.temp[i]; if (!p) continue; - const idx = freeSlot(ps.hand); + const idx = freeSlot(ps.hand); // (a recompute pulls it in; a state set up without one) if (idx >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx }))) continue; if (p.kind === 'chess') { if (!sellWeakestHand(m, ps)) tryDo(() => ps.sell(p.uid)); - else { - const j = freeSlot(ps.hand); - 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), and a human's item under AI 托管 is theirs: // make room in the hand (sell a bench operator) rather than lose it — only a hand of nothing but items still drops it - if ((isMutationCell(m.gd, p.id) || ps.autoplay) && makeHandRoom(m, ps)) { + if ((isMutationCell(m.gd, p.id) || ps.autoplay) && makeHandRoom(m, ps, p)) { + if (!ps.temp.includes(p)) continue; const j = freeSlot(ps.hand); if (j >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }))) continue; } diff --git a/server/match/invariants.js b/server/match/invariants.js index e2fbbffe..6e855607 100644 --- a/server/match/invariants.js +++ b/server/match/invariants.js @@ -4,9 +4,10 @@ // collectViolations(m) → string[] (empty when every invariant holds): // pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies // economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0 -// pieces unique uids; hand 10 / temp 5 slots; chess carry ≤ equipPerChess known items; a normal piece holds ≤ 1 -// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never -// two copies of a mergeable normal item); every token's owner chess is deployed +// pieces unique uids; hand 10 / temp 5 slots; temp holds pieces only while the hand is full (a free hand slot pulls +// a temp piece in, PlayerState._fillHandFromTemp); chess carry ≤ equipPerChess known items; a normal piece +// holds ≤ 1 copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one +// chess, never two copies of a mergeable normal item); every token's owner chess is deployed // board tiles inside the own region and legal for the piece (a range-bound summon inside its owner's attack // range); no items on the board; chess count ≤ deploy cap // (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the @@ -52,6 +53,8 @@ export function collectViolations(m, { limit = 25 } = {}) { if (ps.alive && m.phase !== PHASE.SETTLE && ps.lp <= 0 && m.teamLp == null && m.round > 0 && ps.bandId) fail(`${id}: alive with lp ${ps.lp}`); if (ps.hand.length !== gd.benchSize) fail(`${id}: hand has ${ps.hand.length} slots`); if (ps.temp.length !== gd.tempSize) fail(`${id}: temp has ${ps.temp.length} slots`); + // 临时整备区 = overflow only (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入", GitHub #82) + if (ps.temp.some(Boolean) && ps.hand.some((x) => x == null)) fail(`${id}: a temp piece waits while a hand slot is free`); if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`); if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`); if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`); diff --git a/server/match/player/economy.js b/server/match/player/economy.js index b6fa9c9e..51bd9e67 100644 --- a/server/match/player/economy.js +++ b/server/match/player/economy.js @@ -97,6 +97,12 @@ export class PlayerEconomy { this.dirty(); } + /** + * g.buy {slot}. A full regular hand refuses every purchase, also one whose copy would complete a merge at once (PRTS + * 卫戍协议/帮助 §手牌区 "当常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入等通常情况下会增加手牌的 + * 操作"; BWIKI 盟约 "当整备区达到上限后将无法进行涉及到整备区的操作(购买、撤回等)"; GitHub #82: the reporter's first-hand + * check in the official game, "哪怕是已经有了两个相同干员情况"). Until 0.1.3 such a purchase was let through. + */ buy(slotIdx) { const g = this._gate(); if (g) return g; if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET); @@ -113,13 +119,13 @@ export class PlayerEconomy { const base = this.gd.baseIdOf(slot.id); const need = rec.isGolden ? this.gd.goldenCopies : 1; if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); - if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); + if (handFull) return fail(ERR.HAND_FULL); this.spend(price); slot.sold = true; piece = this.acquireChess(slot.id, { source: 'buy' }); } else { if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); - if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); + if (handFull) return fail(ERR.HAND_FULL); this.spend(price); slot.sold = true; piece = this.acquireItem(slot.id, { source: 'buy' }); diff --git a/server/match/player/pieces.js b/server/match/player/pieces.js index a18fa84c..acc048d1 100644 --- a/server/match/player/pieces.js +++ b/server/match/player/pieces.js @@ -1,7 +1,7 @@ // server/match/player/pieces.js — PlayerState methods: piece bookkeeping — new pieces, lookup by uid (board / hand / -// temp / equipped), the chess locations, detach and stow (hand first, overflow temp), the per-piece round counters -// (拉普兰德), pool copies back to the shared pool, and summon stacks (removal, lifting, counts, the stack granted for an -// owner). +// temp / equipped), the chess locations, detach and stow (hand first, overflow temp; a free hand slot pulls a temp piece +// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool, and summon stacks +// (removal, lifting, counts, the stack granted for an owner). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -68,7 +68,8 @@ export class PlayerPieces { /** * Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep - * in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null. + * in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null. A piece put into temp while a hand + * slot is free (`toTemp`) moves into the hand at the next recompute (_fillHandFromTemp). */ stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) { if (!toTemp) { @@ -85,6 +86,31 @@ export class PlayerPieces { return null; } + /** + * PRTS 卫戍协议/帮助 §手牌区 "溢出单位会自动进入临时手牌区,常规手牌区出现空位时自动移入" (GitHub #82; until 0.1.3 the + * player had to drag them back): every free regular hand slot takes a temp piece at once. Order [ASSUMED] (PRTS names + * none): the temp row empties in the order it fills — right→left, the way stow fills it (freeSlot), so the piece that + * overflowed first moves first — and each piece takes the hand's next free slot right→left ("被发送至手牌区的物资优先 + * 从右到左填充空位"). recompute() runs it, so it follows every change that frees a slot: a sale, a deployment from the + * hand, a merge that consumed hand copies, an item equipped / destroyed / used from the hand, a summon stack removed + * with its owner, an effect's destroyPiece… A piece that leaves temp is no longer due (tempDue). Returns the number + * moved. + * @returns {number} + */ + _fillHandFromTemp() { + let moved = 0; + for (let j = this.temp.length - 1; j >= 0; j--) { + const p = this.temp[j]; + if (!p) continue; + const i = freeSlot(this.hand); + if (i < 0) break; + this._detach({ piece: p, area: 'temp', idx: j }); + this.hand[i] = p; + moved++; + } + return moved; + } + /** * 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) diff --git a/server/match/player/prep.js b/server/match/player/prep.js index 1ac447bb..61cc6a72 100644 --- a/server/match/player/prep.js +++ b/server/match/player/prep.js @@ -16,6 +16,11 @@ export class PlayerPrep { return null; } + /** + * g.reward {idx}: the pick of the first queued offer (promotion reward, a strategy's special refresh, an item offer). + * A pick is a 招募 like a purchase: a full regular hand refuses it, also when it would complete a merge (PRTS + * 卫戍协议/帮助 §手牌区, see buy; GitHub #82). + */ pickReward(idx) { const g = this._gate(); if (g) return g; const offer = this.offers[0]; @@ -26,14 +31,14 @@ export class PlayerPrep { const handFull = freeSlot(this.hand) < 0; if (slot.kind === 'item') { if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); - if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); + if (handFull) return fail(ERR.HAND_FULL); } else { const rec = this.gd.chess(slot.id); if (!rec) return fail(ERR.BAD_TARGET); const base = this.gd.baseIdOf(slot.id); const need = rec.isGolden ? this.gd.goldenCopies : 1; if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); - if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); + if (handFull) return fail(ERR.HAND_FULL); } const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0; if (price > this.funds) return fail(ERR.NO_FUNDS); diff --git a/server/match/player/round.js b/server/match/player/round.js index 498f20e7..28d3870e 100644 --- a/server/match/player/round.js +++ b/server/match/player/round.js @@ -1,7 +1,7 @@ // server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending // coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality, -// out-of-range summons, bonds), the bond views — and battleInput (the player's PlayerBattleInput: board units with -// their loadout, carried 联防 state, the reached layers). +// out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's +// PlayerBattleInput: board units with their loadout, carried 联防 state, the reached layers). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -74,6 +74,8 @@ export class PlayerRound { this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale if (this._legalityStale) this._evictIllegal(); this._liftOutOfRange(); + // a free regular hand slot pulls a temp piece in (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入") + this._fillHandFromTemp(); this.bonds = computeBonds(this.gd, this); this.dirty(); } diff --git a/test/golden/matches.json b/test/golden/matches.json index f968a6b2..c6af3911 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -511,7 +511,7 @@ "combat": "client", "boost": null, "setup": {"stageId":"act2autochess_m04","bossId":"boss_3","hiddenBossId":"boss_8","factions":["FLY","TIMES","DOT"]}, - "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":58,"bossPoolLeft":0,"virtualMs":119900}, + "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":58,"bossPoolLeft":0,"virtualMs":121400}, "rng": {"setup":25,"shop":449,"waves":0,"draft":40,"bots":21634,"meta":8}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ @@ -568,17 +568,17 @@ { "round": 11, "ai_0": [1,45,12,0,2,5,8,"360613f3",118,"arcaneShip 2/34, miraShip 1/0, preciShip 0/10, sargonShip 0/10, siracusaShip 1/56, skillfulShip 0/10, soloShip 1/6, suntShip 1/0, swiftShip 1/22",23,23,0,99], - "ai_1": [1,28,12,0,0,5,8,"78cc3a2e",131,"arcaneShip 2/28, kazimierzShip 1/22, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/40, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/41",23,23,0,103] + "ai_1": [1,28,12,0,0,5,8,"9e46e6ec",131,"arcaneShip 2/28, kazimierzShip 1/22, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/40, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/41",21,23,2,103] }, { "round": 12, "ai_0": [1,45,14,0,0,6,8,"d434f9ef",168,"arcaneShip 2/46, miraShip 1/6, preciShip 0/10, sargonShip 0/10, siracusaShip 1/68, skillfulShip 1/18, soloShip 1/6, suntShip 1/0, swiftShip 1/24",105,105,0,113], - "ai_1": [1,28,12,0,0,6,8,"57452d77",157,"arcaneShip 2/28, kazimierzShip 1/30, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/45, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/54",105,105,0,114] + "ai_1": [1,28,12,0,0,6,8,"2e4f3094",157,"arcaneShip 2/28, kazimierzShip 1/30, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/45, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/54",105,105,0,114] }, { "round": 13, "ai_0": [1,37,12,0,2,6,8,"5deeb03a",214,"arcaneShip 2/70, miraShip 1/12, preciShip 0/10, sargonShip 0/10, siracusaShip 1/98, skillfulShip 1/28, soloShip 1/6, suntShip 1/0, swiftShip 0/32",26,34,8,125], - "ai_1": [1,21,12,0,0,6,8,"ecba4cc3",189,"arcaneShip 2/34, kazimierzShip 1/38, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/50, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/67",27,34,7,125] + "ai_1": [1,21,12,0,0,6,8,"dc3e0431",189,"arcaneShip 2/34, kazimierzShip 1/38, kjeragShip 0/8, lateranoShip 0/8, preciShip 1/50, sargonShip 0/10, steadShip 1/0, suntShip 1/0, swiftShip 1/67",27,34,7,125] } ], "players": { @@ -592,7 +592,7 @@ "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":250,"bossDamage":283411,"dmgDealt":2891237,"gold":139,"itemsEquipped":12,"kills":329,"leaks":10,"lpLost":8,"merges":5,"perfectRounds":10,"refreshes":23}, + "stats": {"activatedLayers":250,"bossDamage":291655,"dmgDealt":2959725,"gold":139,"itemsEquipped":12,"kills":331,"leaks":10,"lpLost":8,"merges":5,"perfectRounds":10,"refreshes":23}, "lineup": ["3_19_b@11,7[2_04_e_b]","5_12_a@11,8","4_10_a@10,5[1_02_e_b+2_01_e_a]","6_06_a@9,4","1_08_b@9,5","6_16_a@9,7","5_20_a@9,8[3_05_e_a+3_02_e_b]","3_13_b@9,9"], "bonds": ["arcaneShip 2/86","miraShip 1/12","preciShip 0/10","sargonShip 0/10","siracusaShip 1/110","skillfulShip 1/36","soloShip 1/6","suntShip 1/0","swiftShip 0/32"] }, @@ -606,8 +606,8 @@ "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":199,"bossDamage":425339,"dmgDealt":3265243,"gold":137,"itemsEquipped":18,"kills":322,"leaks":9,"lpLost":7,"merges":5,"perfectRounds":11,"refreshes":13}, - "lineup": ["4_20_a@12,8[3_06_e_a]","1_17_b@11,7","4_02_b@11,8[4_08_e_a+4_03_e_b]","4_01_b@10,7[1_02_e_a+3_08_e_a]","6_17_a@10,8[4_06_e_a+1_05_e_a]","6_19_a@9,7[1_01_e_b+4_04_e_a]","4_14_a@9,8[3_02_e_a]","5_07_a@9,9"], + "stats": {"activatedLayers":199,"bossDamage":417095,"dmgDealt":3217376,"gold":137,"itemsEquipped":18,"kills":320,"leaks":11,"lpLost":7,"merges":5,"perfectRounds":10,"refreshes":13}, + "lineup": ["4_20_a@12,8[3_06_e_a]","1_17_b@11,7[3_08_e_a]","4_02_b@11,8[3_02_e_a+4_03_e_b]","4_01_b@10,7[4_04_e_a+1_02_e_a]","6_17_a@10,8[4_06_e_a+4_08_e_a]","6_19_a@9,7[1_05_e_a+1_01_e_b]","4_14_a@9,8","5_07_a@9,9"], "bonds": ["arcaneShip 2/34","kazimierzShip 1/38","kjeragShip 0/8","lateranoShip 0/8","preciShip 2/55","raidShip 1/0","sargonShip 0/10","suntShip 1/0","swiftShip 1/72"] } } @@ -1300,7 +1300,7 @@ "boost": {"lp":400,"layers":300}, "setup": {"stageId":"act1autochess_m02","bossId":"boss_5","hiddenBossId":"boss_10","factions":["TIMES","ELEMENT","DOT"]}, "end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":399,"bossPoolLeft":0,"virtualMs":113700}, - "rng": {"setup":25,"shop":346,"waves":0,"draft":42,"bots":10350,"meta":3}, + "rng": {"setup":25,"shop":356,"waves":0,"draft":42,"bots":10350,"meta":3}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ { @@ -1353,7 +1353,7 @@ }, { "round": 13, - "ai_0": [1,399,12,0,2,6,8,"eb5e835a",1944,"arcaneShip 2/360, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/308, investShip 0/300, kazimierzShip 0/308, kjeragShip 0/300, lateranoShip 1/310, maniShip 0/300, miraShip 0/300, preciShip 0/302, raidShip 0/300, sargonShip 0/300, siracusaShip 1/332, skillfulShip 1/335, soloShip 1/307, steadShip 0/300, suntShip 1/300, swiftShip 0/316, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",35,35,0,151] + "ai_0": [1,399,12,0,2,6,8,"5091cd4e",1944,"arcaneShip 2/360, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/308, investShip 0/300, kazimierzShip 0/308, kjeragShip 0/300, lateranoShip 1/310, maniShip 0/300, miraShip 0/300, preciShip 0/302, raidShip 0/300, sargonShip 0/300, siracusaShip 1/332, skillfulShip 1/335, soloShip 1/307, steadShip 0/300, suntShip 1/300, swiftShip 0/316, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",35,35,0,150] } ], "players": { @@ -1367,9 +1367,9 @@ "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "trophies": 0, "reward": 425, - "stats": {"activatedLayers":2269,"bossDamage":1145000,"dmgDealt":3025009,"gold":176,"itemsEquipped":26,"kills":365,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34}, - "lineup": ["4_02_b@12,7[3_08_e_a+6_03_e_a]","6_01_a@12,8[3_03_e_a+3_02_e_a]","6_16_a@11,5[1_05_e_b+1_02_e_b]","3_18_a@11,8[4_11_e_a+4_05_e_b]","4_10_a@10,4[4_07_e_a+3_03_e_b]","6_05_a@10,5[1_02_e_a]","4_01_b@10,6[4_06_e_a+4_05_e_a]","1_08_b@9,9[4_10_e_a]"], - "bonds": ["arcaneShip 2/389","deputShip 0/300","egirShip 0/300","emptyShip 0/300","indomShip 0/308","investShip 0/300","kazimierzShip 0/308","kjeragShip 0/300","lateranoShip 1/310","maniShip 0/300","miraShip 0/300","preciShip 1/302","raidShip 0/300","sargonShip 0/300","siracusaShip 1/345","skillfulShip 0/335","soloShip 1/307","steadShip 0/300","suntShip 1/300","swiftShip 1/316","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] + "stats": {"activatedLayers":2283,"bossDamage":1145000,"dmgDealt":3025009,"gold":175,"itemsEquipped":26,"kills":365,"leaks":1,"lpLost":1,"merges":6,"perfectRounds":12,"refreshes":35}, + "lineup": ["4_02_b@12,7[3_08_e_a+3_03_e_b]","6_01_a@12,8[3_02_e_a]","6_16_a@11,5[1_05_e_b+3_03_e_a]","3_18_a@11,8[4_11_e_a+4_05_e_b]","4_10_b@10,4[4_07_e_a+1_02_e_b]","6_05_a@10,5","4_01_b@10,6[6_03_e_a+4_06_e_a]","1_08_b@9,9"], + "bonds": ["arcaneShip 2/395","deputShip 0/300","egirShip 0/300","emptyShip 0/300","indomShip 0/308","investShip 0/300","kazimierzShip 0/308","kjeragShip 0/300","lateranoShip 1/310","maniShip 0/300","miraShip 0/300","preciShip 1/302","raidShip 0/300","sargonShip 0/300","siracusaShip 1/353","skillfulShip 0/343","soloShip 1/307","steadShip 0/300","suntShip 1/300","swiftShip 1/316","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] } } }, @@ -1383,8 +1383,8 @@ "combat": "client", "boost": {"lp":400,"layers":300}, "setup": {"stageId":"act2autochess_m04","bossId":"boss_5","hiddenBossId":"boss_9","factions":["FLY","ELEMENT","INVISIBLE"]}, - "end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":800,"bossPoolLeft":0,"virtualMs":124650}, - "rng": {"setup":25,"shop":658,"waves":0,"draft":61,"bots":22084,"meta":147}, + "end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":747,"bossPoolLeft":0,"virtualMs":124650}, + "rng": {"setup":25,"shop":674,"waves":0,"draft":61,"bots":22077,"meta":140}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ { @@ -1435,22 +1435,22 @@ { "round": 10, "ai_0": [1,400,12,0,0,5,8,"5e407c98",2262,"arcaneShip 2/320, deputShip 0/304, egirShip 1/334, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/314, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/320, raidShip 1/338, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/332, suntShip 0/300, swiftShip 0/306, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,12,0,93], - "ai_1": [1,400,15,0,0,5,8,"fa4268ad",2207,"arcaneShip 0/302, deputShip 1/318, egirShip 0/300, emptyShip 0/300, indomShip 1/308, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/304, soloShip 1/316, steadShip 1/343, suntShip 1/302, swiftShip 0/300, victoriaShip 1/316, visiShip 0/300, yanShip 0/310",12,12,0,99] + "ai_1": [1,400,15,0,0,5,8,"1cb9c2e4",1583,"arcaneShip 0/302, deputShip 1/318, egirShip 0/300, emptyShip 0/300, indomShip 1/308, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/304, soloShip 0/314, steadShip 1/342, suntShip 0/300, swiftShip 0/300, victoriaShip 1/311, visiShip 0/300, yanShip 0/305",12,12,0,97] }, { "round": 11, - "ai_0": [1,400,12,0,2,5,8,"e2bf7b5d",2634,"arcaneShip 1/323, deputShip 0/304, egirShip 1/345, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/322, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/329, raidShip 1/360, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/336, suntShip 0/300, swiftShip 1/315, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,105], - "ai_1": [1,400,12,0,1,5,8,"5dc25201",2235,"arcaneShip 0/302, deputShip 1/320, egirShip 0/300, emptyShip 0/300, indomShip 1/310, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/313, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/304, soloShip 1/318, steadShip 1/350, suntShip 1/304, swiftShip 0/300, victoriaShip 1/320, visiShip 0/300, yanShip 0/310",34,34,0,111] + "ai_0": [1,400,12,0,2,5,8,"cfd3ac7b",2321,"arcaneShip 1/323, deputShip 0/304, egirShip 1/352, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/322, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/324, raidShip 1/360, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/336, suntShip 0/300, swiftShip 0/310, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,105], + "ai_1": [1,400,12,0,1,6,8,"b6bd931e",2209,"arcaneShip 0/302, deputShip 1/320, egirShip 0/300, emptyShip 0/300, indomShip 1/312, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/306, soloShip 0/314, steadShip 1/349, suntShip 1/302, swiftShip 0/300, victoriaShip 1/316, visiShip 1/304, yanShip 0/305",34,34,0,120] }, { "round": 12, - "ai_0": [1,400,14,0,0,6,8,"e09e9634",2679,"arcaneShip 1/326, deputShip 0/304, egirShip 1/351, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/330, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/342, raidShip 1/370, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/336, suntShip 0/300, swiftShip 1/320, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",6,14,8,119], - "ai_1": [1,400,13,0,2,6,8,"13f2f54b",2259,"arcaneShip 0/302, deputShip 1/322, egirShip 0/300, emptyShip 0/300, indomShip 1/312, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/317, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/304, soloShip 1/320, steadShip 1/358, suntShip 1/306, swiftShip 0/300, victoriaShip 1/324, visiShip 0/300, yanShip 0/310",15,15,0,124] + "ai_0": [1,400,14,0,0,6,8,"e7a322b8",2348,"arcaneShip 1/323, deputShip 0/304, egirShip 1/353, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/330, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/332, raidShip 1/370, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/336, suntShip 0/300, swiftShip 0/310, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",6,14,8,119], + "ai_1": [1,400,13,0,1,6,8,"58fdf70d",2235,"arcaneShip 0/302, deputShip 1/322, egirShip 0/300, emptyShip 0/300, indomShip 2/314, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/308, soloShip 0/314, steadShip 1/357, suntShip 1/304, swiftShip 0/300, victoriaShip 1/324, visiShip 1/306, yanShip 0/310",15,15,0,134] }, { "round": 13, - "ai_0": [1,400,12,0,0,6,8,"871ba326",3034,"arcaneShip 1/333, deputShip 0/304, egirShip 1/369, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/333, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/354, raidShip 1/376, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/340, suntShip 1/300, swiftShip 1/325, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,130], - "ai_1": [1,400,14,0,2,6,8,"0e053b02",2595,"arcaneShip 0/302, deputShip 1/324, egirShip 0/300, emptyShip 0/300, indomShip 1/318, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/319, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/304, soloShip 1/322, steadShip 1/366, suntShip 2/308, swiftShip 0/300, victoriaShip 1/332, visiShip 1/306, yanShip 0/310",41,41,0,170] + "ai_0": [1,400,12,0,0,6,8,"feef54ce",2706,"arcaneShip 2/330, deputShip 0/304, egirShip 1/368, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/334, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/340, raidShip 1/390, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/340, suntShip 1/300, swiftShip 0/310, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,133], + "ai_1": [1,400,13,0,2,6,8,"43bc2d14",1955,"arcaneShip 0/302, deputShip 1/324, egirShip 0/300, emptyShip 0/300, indomShip 2/318, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/310, soloShip 0/314, steadShip 1/367, suntShip 1/306, swiftShip 0/300, victoriaShip 1/330, visiShip 0/306, yanShip 0/310",41,41,0,150] } ], "players": { @@ -1459,28 +1459,28 @@ "victory": true, "roundsPassed": 15, "eliminatedRound": null, - "lp": 400, + "lp": 374, "bandId": "band_sarkazb", - "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, + "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "trophies": 8, "reward": 425, - "stats": {"activatedLayers":3054,"bossDamage":2348544,"dmgDealt":11081151,"gold":156,"itemsEquipped":15,"kills":288,"leaks":8,"lpLost":0,"merges":6,"perfectRounds":12,"refreshes":28}, - "lineup": ["4_09_b@11,5","4_14_a@11,8","5_07_a@10,4[1_01_e_a]","2_18_a@10,5","1_19_b@10,8[4_04_e_b+1_02_e_b]","3_09_a@9,7[2_01_e_a+3_08_e_a]","4_20_a@9,8","3_05_a@9,9[4_07_e_a+5_02_e_a]"], - "bonds": ["arcaneShip 1/333","deputShip 0/304","egirShip 1/369","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 1/333","kjeragShip 0/302","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/364","raidShip 1/376","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/300","soloShip 1/304","steadShip 1/340","suntShip 1/300","swiftShip 1/335","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] + "stats": {"activatedLayers":2986,"bossDamage":4772475,"dmgDealt":14261084,"gold":158,"itemsEquipped":18,"kills":291,"leaks":8,"lpLost":0,"merges":7,"perfectRounds":12,"refreshes":25}, + "lineup": ["4_09_b@11,5[4_11_e_a+6_09_e_a]","6_11_a@11,8","5_07_a@10,4[1_01_e_b+1_02_e_b]","1_19_b@10,8[5_02_e_a+4_04_e_a]","3_09_b@9,6[3_08_e_a+4_10_e_a]","6_05_a@9,7","4_20_a@9,8[2_01_e_a]","3_05_a@9,9[4_07_e_a+4_06_e_a]"], + "bonds": ["arcaneShip 1/330","deputShip 0/304","egirShip 1/368","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 1/334","kjeragShip 0/302","lateranoShip 0/300","maniShip 1/300","miraShip 0/300","preciShip 1/345","raidShip 1/390","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/300","soloShip 1/304","steadShip 0/340","suntShip 1/300","swiftShip 1/315","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] }, "ai_1": { "alive": true, "victory": true, "roundsPassed": 15, "eliminatedRound": null, - "lp": 400, + "lp": 373, "bandId": "band_doberm", - "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, + "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"}, "trophies": 8, "reward": 425, - "stats": {"activatedLayers":2623,"bossDamage":4589976,"dmgDealt":19161995,"gold":197,"itemsEquipped":35,"kills":283,"leaks":1,"lpLost":0,"merges":6,"perfectRounds":12,"refreshes":35}, - "lineup": ["1_13_b@11,5[4_06_e_b+4_09_e_a]","6_11_a@11,8[3_08_e_a+5_01_e_a]","6_08_a@10,3[1_05_e_b+2_01_e_b]","3_16_b@10,4[3_10_e_b+2_03_e_b]","4_01_a@10,7[3_01_e_a+6_09_e_a]","4_07_b@10,8[3_10_e_a+5_09_e_a]","6_01_a@9,8[3_05_e_b+3_02_e_b]","1_06_b@9,9[3_11_e_a+2_03_e_b]"], - "bonds": ["arcaneShip 0/302","deputShip 1/328","egirShip 0/300","emptyShip 0/300","indomShip 1/322","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 1/304","maniShip 1/304","miraShip 0/300","preciShip 1/323","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/304","soloShip 0/322","steadShip 1/390","suntShip 1/312","swiftShip 0/300","victoriaShip 1/340","visiShip 0/306","yanShip 0/310"] + "stats": {"activatedLayers":2315,"bossDamage":2158613,"dmgDealt":14206401,"gold":174,"itemsEquipped":28,"kills":283,"leaks":1,"lpLost":0,"merges":4,"perfectRounds":12,"refreshes":40}, + "lineup": ["2_17_a@12,8[3_08_e_a+4_02_e_a]","5_03_a@11,8[3_02_e_a+3_10_e_a]","1_10_b@10,4[4_11_e_a+2_01_e_b]","4_11_a@10,5[2_03_e_b+3_10_e_b]","5_06_a@10,8[3_01_e_b+5_02_e_a]","3_16_a@9,3[2_03_e_b+3_09_e_a]","6_20_a@9,6[1_05_e_a+4_08_e_b]","1_06_b@9,9[3_08_e_b+1_05_e_b]"], + "bonds": ["arcaneShip 0/302","deputShip 1/328","egirShip 0/300","emptyShip 0/300","indomShip 1/322","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 0/309","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/314","soloShip 1/318","steadShip 2/385","suntShip 1/310","swiftShip 0/300","victoriaShip 1/338","visiShip 0/306","yanShip 0/310"] } } } diff --git a/test/match/economy.test.js b/test/match/economy.test.js index 27f046aa..56fc5362 100644 --- a/test/match/economy.test.js +++ b/test/match/economy.test.js @@ -5,6 +5,14 @@ import { ERR, PHASE } from '../../shared/constants.js'; import { GameData } from '../../server/match/gamedata.js'; import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js'; +/** Fill every free hand slot with distinct plain equipment (nothing merges), so a piece put into temp stays there. */ +function fillHand(ps) { + const plain = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id); + let k = 0; + for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i] == null) ps.hand[i] = ps.newPiece('item', plain[k++]); + ps.recompute(); +} + test('income = min(3 + r, 12) in every mode; funds are lost at prep end (band_cannot keeps them)', () => { for (const modeId of ['mode_single_funny', 'mode_multi_normal', 'mode_multi_abyss']) { const gd = new GameData(DATA, modeId); @@ -190,16 +198,21 @@ test('selling: +1 (normal and elite, board or hand), items return to the hand, i m.dispose(); }); -test('ready gating: temp must be empty; unready allowed until everyone is ready; actions locked while ready', () => { +test('ready gating: temp must be empty (a freed hand slot pulls the temp piece in); unready allowed until everyone is ready; actions locked while ready', () => { const h = makeMatch({ mode: 'coop', humans: 2, seed: 10 }).start(); h.toPrep(1); const m = h.m; const a = h.ps('p_0'); + fillHand(a); const t = give(m, a, chessOfTier(1).find((x) => m.pool.has(x)), 'temp'); + assert.ok(a.temp.includes(t), 'a full hand: it waits in temp'); assert.equal(a.privateView().canReady, false); assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }); - const free = a.hand.findIndex((x) => x == null); - assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: t.uid, to: { area: 'hand', idx: free } }), { ok: true }); + // destroying a hand item frees its slot: the temp piece moves in by itself (PRTS 卫戍协议/帮助 §手牌区, GitHub #82) + const freed = a.hand[3]; + assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: freed.uid }), { ok: true }); + assert.equal(a.hand[3], t); + assert.ok(a.tempEmpty); assert.equal(a.privateView().canReady, true); assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' }); @@ -222,7 +235,9 @@ test('prep deadline (co-op): unready players are auto-readied and their temp is const a = h.ps('p_0'); const id = chessOfTier(1).find((x) => m.pool.has(x)); const left = m.pool.left(id); + fillHand(a); give(m, a, id, 'temp'); + assert.ok(!a.tempEmpty, 'a full hand: it waits in temp'); assert.ok(m.deadline > h.sched.now(), 'co-op prep is timed'); assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1)); h.sched.advance(m.deadline - h.sched.now() + 1); diff --git a/test/match/feedback5-hand.test.js b/test/match/feedback5-hand.test.js new file mode 100644 index 00000000..da44d9a5 --- /dev/null +++ b/test/match/feedback5-hand.test.js @@ -0,0 +1,151 @@ +// GitHub #82 (second part): the hand rules of PRTS 卫戍协议/帮助 §手牌区 — "手牌区域分为10个常规手牌区和5个临时手牌区。当 +// 常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" ("例如招募/购入等通常情况下会增加手牌的操作") and "溢出单位会自动 +// 进入临时手牌区,常规手牌区出现空位时自动移入". +// * a full regular hand refuses every purchase and reward pick — also one whose copy would complete a merge at once +// (the reporter's first-hand check in the official game; BWIKI 盟约 "无法进行涉及到整备区的操作(购买、撤回等)") +// * a free regular slot pulls a temp piece in at once (PlayerState._fillHandFromTemp, run by every recompute): after a +// sale, a deployment from the hand, a merge that consumed hand copies, an item destroyed / equipped from the hand, a +// summon stack removed with its owner. The temp row empties right → left (the order it fills) [ASSUMED]; each piece +// takes the hand's next free slot right → left. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ERR } from '../../shared/constants.js'; +import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js'; + +/** Plain equipment (no equip / merge side effects), all different: hand fillers that never merge with each other. */ +const PLAIN = Object.values(DATA.items) + .filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive') + .map((i) => i.itemId ?? i.id).filter(Boolean); + +/** Solo 标准 (untimed) PREP R1 with an empty board and hand, 100 funds, a known stage. */ +function prep(seed) { + const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start(); + h.toPrep(1); + h.setStage('act2autochess_m04'); + const ps = h.ps('p_0'); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); + ps.hand.fill(null); + ps.recompute(); + ps.funds = 100; + return { h, m: h.m, ps }; +} + +/** Fill every free hand slot with plain equipment (skipping `skip` ids). */ +function fillHand(ps, skip = []) { + const ids = PLAIN.filter((id) => !skip.includes(id)); + let k = 0; + while (ps.hand.some((x) => x == null)) giveItem(ps.m, ps, ids[k++]); +} + +const mergeable = (m) => chessOfTier(1, (c) => c.position === 'MELEE').find((id) => m.pool.has(id) && m.gd.mergeCount(id) === 3); + +test('a full hand refuses a purchase / pick that would complete a merge (chess, item, reward); nothing is spent', () => { + const { m, ps } = prep(8201); + const id = mergeable(m); + give(m, ps, id, 'hand', 0); + give(m, ps, id, 'hand', 1); + const twin = PLAIN[0]; + giveItem(m, ps, twin, 'hand', 2); + fillHand(ps, [twin]); + assert.ok(ps.completesChessMerge(id) && ps.completesItemMerge(twin)); + ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false }; + const itemSlot = ps.shop.slots.findIndex((s, i) => i > 0 && s && s.kind === 'item'); + ps.shop.slots[itemSlot] = { kind: 'item', id: twin, basePrice: 2, frozen: false, sold: false }; + const left = m.pool.left(id); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL }, 'the third copy of a held pair'); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL }, 'an item whose twin is in the hand'); + ps.pushRewardOffer('merge', { ids: [id] }); + assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL }, 'a merge-completing reward pick'); + assert.equal(ps.funds, 100, 'nothing spent'); + assert.equal(m.pool.left(id), left, 'no copy taken'); + assert.ok(!ps.shop.slots[0].sold && !ps.shop.slots[itemSlot].sold); + assert.equal(ps.countCopies(m.gd.baseIdOf(id)), 2, 'no merge'); + // a free slot (an item destroyed) lets the purchase through: it completes the merge, the elite goes to the hand + assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: ps.hand[9].uid }), { ok: true }); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); + assert.ok(ps.hand.some((p) => p && p.id === m.gd.goldenIdOf(id)), 'the elite in the hand'); + assert.equal(ps.hand.filter(Boolean).length, 8, '10 − the destroyed item − 2 copies + the elite'); + checkInvariants(m); + m.dispose(); +}); + +test('selling / destroying from a full hand pulls the temp pieces in, rightmost first, into the freed slot; Ready follows', () => { + const { m, ps } = prep(8202); + const sold = give(m, ps, chessOfTier(2).find((x) => m.pool.has(x)), 'hand', 4); + fillHand(ps); + const ids = chessOfTier(3).filter((x) => m.pool.has(x)); + const first = give(m, ps, ids[0], 'temp', 4); // the first overflow takes the rightmost temp slot (stow → freeSlot) + const second = give(m, ps, ids[1], 'temp', 3); + assert.ok(ps.temp.includes(first) && ps.temp.includes(second), 'a full hand: both wait in temp'); + assert.equal(ps.privateView().canReady, false); + checkInvariants(m); + assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: sold.uid }), { ok: true }); + assert.equal(ps.hand[4], first, 'the rightmost temp piece moves into the freed slot'); + assert.ok(ps.temp.includes(second) && !ps.temp.includes(first)); + assert.equal(ps._tempDue.has(first.uid), false, 'no longer due at the prep deadline'); + assert.equal(ps.privateView().canReady, false, 'one left in temp'); + checkInvariants(m); + const item = ps.hand[7]; + assert.equal(item.kind, 'item'); + assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: item.uid }), { ok: true }); + assert.equal(ps.hand[7], second); + assert.ok(ps.tempEmpty); + assert.equal(ps.privateView().canReady, true); + assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true }); + checkInvariants(m); + m.dispose(); +}); + +test('deploying a card from a full hand, or equipping an item from it, pulls a temp piece into the freed slot', () => { + const { m, ps } = prep(8203); + const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x)); + const card = give(m, ps, id, 'hand', 9); + fillHand(ps); + const t1 = give(m, ps, chessOfTier(2).find((x) => m.pool.has(x)), 'temp', 4); + const t2 = giveItem(m, ps, PLAIN[PLAIN.length - 1], 'temp', 3); + const [r, c] = legalTileFor(m, ps, id); + assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: card.uid, to: { area: 'board', row: r, col: c } }), { ok: true }); + assert.equal(ps.hand[9], t1, 'the deployed card\'s slot took the temp piece'); + checkInvariants(m); + const it = ps.hand[0]; + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: card.uid }), { ok: true }); + assert.equal(ps.hand[0], t2, 'the equipped item\'s slot took the temp piece'); + assert.ok(ps.tempEmpty); + checkInvariants(m); + m.dispose(); +}); + +test('a gain that merges two hand copies: the elite takes one freed slot, the other pulls a temp piece in', () => { + const { m, ps } = prep(8204); + const id = mergeable(m); + give(m, ps, id, 'hand', 0); + give(m, ps, id, 'hand', 1); + fillHand(ps); + const ids = chessOfTier(3).filter((x) => m.pool.has(x)); + const t1 = give(m, ps, ids[0], 'temp', 4); + const t2 = give(m, ps, ids[1], 'temp', 3); + const elite = ps.acquireChess(id, { source: 'grant' }); + assert.ok(elite && m.gd.isGolden(elite.id)); + assert.equal(ps.hand[1], elite, 'the elite: the hand\'s next free slot (right → left)'); + assert.equal(ps.hand[0], t1, 'the other freed slot: the rightmost temp piece'); + assert.deepEqual(ps.temp.filter(Boolean), [t2], 'the hand is full again: the other waits'); + checkInvariants(m); + m.dispose(); +}); + +test('selling a deployed summoner removes its summon stack from the hand: that slot pulls a temp piece in', () => { + const { m, ps } = prep(8205); + const owner = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && m.pool.has(c.chessId) && m.gd.placeableTokens(c.chessId).length && legalTileFor(m, ps, c.chessId)); + assert.ok(owner, 'a chess with a manually deployable summon'); + const op = give(m, ps, owner.chessId, 'board', legalTileFor(m, ps, owner.chessId)); + const stackAt = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === op.uid); + assert.ok(stackAt >= 0, 'its summon stack joined the hand'); + fillHand(ps); + const t = give(m, ps, chessOfTier(2).find((x) => m.pool.has(x)), 'temp', 4); + assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: op.uid }), { ok: true }); + assert.equal(ps.hand[stackAt], t, 'the stack\'s slot took the temp piece'); + assert.ok(ps.tempEmpty); + checkInvariants(m); + m.dispose(); +}); diff --git a/test/match/hardening.test.js b/test/match/hardening.test.js index ef14c9d6..89706bc1 100644 --- a/test/match/hardening.test.js +++ b/test/match/hardening.test.js @@ -85,7 +85,10 @@ test('merge with a full hand and a full temp: the elite takes its deployed copy\ for (let i = 0; i < 5; i++) give(m, ps, fillers[10 + i], 'temp', i); ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false }; checkInvariants(m); - assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a purchase that completes a merge is allowed with a full hand'); + // a full hand refuses the purchase, also one that would complete the merge (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); + // a gain (an effect's grant) still completes it + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: 'HAND_FULL' }, 'a full hand refuses the merge-completing purchase'); + assert.ok(ps.acquireChess(id, { source: 'grant' }), 'the gained copy completes the merge'); const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id)); assert.ok(elite, 'the elite stands on a consumed copy\'s tile'); assert.equal(ps.find(elite.uid).key, `${t1[0]},${t1[1]}`, 'the copy that deploys first (legalTileFor walks the top row left to right: on one row, the deploy order)'); @@ -260,7 +263,7 @@ test('garrisonHooks widens a garrison to extra hooks ("<进入休整期时><休 m.dispose(); }); -test('ctx.offerItems queues an item offer; g.reward takes the item (full hand refused unless it merges)', () => { +test('ctx.offerItems queues an item offer; g.reward takes the item (a full hand refuses it, also when it would merge)', () => { const reg = builtins(); const { m, ps } = prepSolo({ seed: 913, registry: reg }); const ctx = makeCtx(m, ps, { kind: 'band', key: 'band:test' }, 'onLevelUp'); @@ -269,8 +272,10 @@ test('ctx.offerItems queues an item offer; g.reward takes the item (full hand re const view = ps.privateView().shop.rewardOffer; assert.deepEqual(view.slots.map((s) => [s.kind, s.id, s.price]), [['item', 'chess_item_1_01_e_a', 0], ['item', 'chess_item_2_03_e_a', 0]]); const fillers = chessOfTier(1).filter((c) => m.pool.has(c)); - for (let i = 0; i < 10; i++) give(m, ps, fillers[i], 'hand', i); - assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'HAND_FULL'); + for (let i = 0; i < 9; i++) give(m, ps, fillers[i], 'hand', i); + giveItem(m, ps, 'chess_item_1_01_e_a', 'hand', 9); + assert.equal(ps.completesItemMerge('chess_item_1_01_e_a'), true); + assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'HAND_FULL', 'refused although it would merge (PRTS 卫戍协议/帮助 §手牌区)'); ps.hand[0] && ps.returnCopies(ps.hand[0]); ps.hand[0] = null; ps.recompute(); diff --git a/test/match/merge.test.js b/test/match/merge.test.js index 7736abd3..41f9b8e7 100644 --- a/test/match/merge.test.js +++ b/test/match/merge.test.js @@ -67,7 +67,7 @@ test('3 copies (board + hand + bought) merge into 1 elite on the board copy\'s t m.dispose(); }); -test('elites never merge; 风丸 merges with 2 copies; a full hand still buys the merge-completing copy', () => { +test('elites never merge; 风丸 merges with 2 copies; a full hand refuses the merge-completing copy too (GitHub #82)', () => { const { m, ps } = prep(22); const id = chessOfTier(2).find((x) => m.pool.has(x) && x !== 'chess_char_2_11_a'); const golden = DATA.chess[id].goldenId; @@ -83,7 +83,9 @@ test('elites never merge; 风丸 merges with 2 copies; a full hand still buys th m.handle('p_0', { t: 'g.buy', slot: 0 }); assert.ok(ps.hand.some((p) => p && p.id === DATA.chess.chess_char_2_11_a.goldenId), '风丸 elite after 2 copies'); } - // full hand + 2 copies in the hand: buying the 3rd completes the merge (net −1) + // full hand + 2 copies in the hand: the 3rd copy is refused like any purchase (PRTS 卫戍协议/帮助 §手牌区 "当常规手牌区 + // 全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入"; GitHub #82); a sale frees a slot, then the purchase + // completes the merge (the elite in the hand) for (const p of ps.hand) if (p) ps.returnCopies(p); ps.hand.fill(null); const x = chessOfTier(1).filter((c) => m.pool.has(c)); @@ -95,15 +97,19 @@ test('elites never merge; 风丸 merges with 2 copies; a full hand still buys th stock(m, ps, x[20] || x[x.length - 1], 1); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL }); stock(m, ps, target, 0); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL }, 'the merge-completing copy too'); + assert.ok(!ps.hand.some((p) => p && p.id === DATA.chess[target].goldenId)); + const filler = ps.hand.find((p) => p && p.id !== target); + assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: filler.uid }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); assert.ok(ps.hand.some((p) => p && p.id === DATA.chess[target].goldenId)); - assert.equal(ps.hand.filter(Boolean).length, 9); + assert.equal(ps.hand.filter(Boolean).length, 8, '10 − the sold filler − 2 copies + the elite'); assert.ok(ps.tempEmpty); checkInvariants(m); m.dispose(); }); -test('merge from two board copies with a full hand: the elite takes the copy that deploys first (no overflow, one tile freed)', () => { +test('merge from two board copies with a full hand (a gained copy; a purchase is refused): the elite takes the copy that deploys first (no overflow, one tile freed)', () => { const { m, ps } = prep(23); const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c)); const id = ids[0]; @@ -115,7 +121,8 @@ test('merge from two board copies with a full hand: the elite takes the copy tha for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]); stock(m, ps, id); assert.equal(ps.deployCount, 2); - assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL }, 'a full hand refuses the purchase (GitHub #82)'); + assert.ok(ps.acquireChess(id, { source: 'grant' }), 'a gain (an effect\'s grant) completes the merge'); // deploy order = by column from the left (col asc, then row desc; Battle.start): (9,3) before (9,4) — [ASSUMED] the copy // the battle deploys first const elite = ps.board.get('9,3'); @@ -134,12 +141,14 @@ test('merge whose copies are not deployed (temp) with a full hand overflows the const { m, ps } = prep(23); const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c)); const id = ids[0]; - give(m, ps, id, 'temp', 0); - give(m, ps, id, 'temp', 1); const others = chessOfTier(2).filter((c) => m.pool.has(c)); for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]); - stock(m, ps, id); - assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); + // with the hand full they wait in temp (a free hand slot would pull them in, PRTS 卫戍协议/帮助 §手牌区) + give(m, ps, id, 'temp', 0); + give(m, ps, id, 'temp', 1); + assert.equal(ps.temp.filter(Boolean).length, 2); + // a gain completes the merge (a purchase is refused with a full hand) + assert.ok(ps.acquireChess(id, { source: 'grant' })); assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp'); assert.equal(ps.board.size, 0, 'nothing was deployed'); assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }); @@ -187,11 +196,14 @@ test('equipment: max 2 (a third replaces the oldest), identical normal items mer assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_1_02_e_b'), 'golden item in the hand'); assert.ok(!a.items.some((x) => x.id === 'chess_item_1_02_e_a')); assert.equal(ps.stats.itemMerges, 1); - // buying an item whose twin is in the hand merges immediately (and works with a full hand) + // buying an item whose twin is in the hand merges immediately — but a full hand refuses the purchase all the same + // (PRTS 卫戍协议/帮助 §手牌区, GitHub #82) const i5 = giveItem(m, ps, 'chess_item_2_03_e_a'); const others = chessOfTier(1).filter((c) => m.pool.has(c)); for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]); ps.shop.slots[0] = { kind: 'item', id: 'chess_item_2_03_e_a', basePrice: 2, frozen: false, sold: false }; + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL }); + assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: ps.hand.find((p) => p && p.kind === 'chess' && p !== a).uid }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_2_03_e_b')); assert.ok(!ps.find(i5.uid)); diff --git a/test/match/playtest6-elite-to-board.test.js b/test/match/playtest6-elite-to-board.test.js index 232eed47..0ca0d088 100644 --- a/test/match/playtest6-elite-to-board.test.js +++ b/test/match/playtest6-elite-to-board.test.js @@ -199,7 +199,8 @@ test('equipment with the hand and temp full: the elite keeps up to its 2 equip s const warn = m.log.warn; m.log.warn = (s) => { warns.push(String(s)); }; stock(m, ps, id); - assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a merge-completing buy needs no hand slot'); + assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'HAND_FULL', 'a full hand refuses the purchase, also a merge-completing one (GitHub #82)'); + assert.ok(ps.acquireChess(id, { source: 'grant' }), 'a gained copy completes the merge'); m.log.warn = warn; const elite = ps.board.get(want); assert.ok(elite && m.gd.isGolden(elite.id), 'the elite took the first deployed copy\'s tile'); diff --git a/test/match/prep-bench.test.js b/test/match/prep-bench.test.js index ac3f8534..ec7e765a 100644 --- a/test/match/prep-bench.test.js +++ b/test/match/prep-bench.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { GEO, PHASE } from '../../shared/constants.js'; -import { makeMatch, give, giveItem, legalTileFor, chessOfTier } from './harness.js'; +import { DATA, makeMatch, give, giveItem, legalTileFor, chessOfTier } from './harness.js'; const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK'; @@ -98,7 +98,12 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f b.recompute(); const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x)); const a = give(m, b, id, 'hand', 0); + // a full hand (distinct plain equipment: nothing merges), so the temp piece stays in temp — a free hand slot would pull + // it in (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入") + const plain = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id); + for (let i = 1; i < b.hand.length; i++) b.hand[i] = b.newPiece('item', plain[i]); const c = give(m, b, id, 'temp', 0); // 临时整备区: PRTS 帮助把它算进手牌区,一起侦察 + assert.ok(b.temp.includes(c)); assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true }); const first = h.lastTo('p_0', 'm.field'); diff --git a/test/ui/uiFixes.test.js b/test/ui/uiFixes.test.js index 801e0b8b..8da3edae 100644 --- a/test/ui/uiFixes.test.js +++ b/test/ui/uiFixes.test.js @@ -247,16 +247,19 @@ describe('full hand: shop and promotion cards', () => { const privFull = { alive: true, ready: false, funds: 20, board: [], temp: new Array(GEO.TEMP_SIZE).fill(null), shop: {}, hand: fullHand([{ uid: 1, kind: 'chess', id: MELEE, items: [] }, { uid: 2, kind: 'chess', id: MELEE, items: [] }]) }; const o = { priv: privFull, editable: true, getChess, getItem }; - test('a card that needs a slot is blocked with 整备区已满; one completing a merge is not', () => { + test('every card is blocked with 整备区已满, one completing a merge too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82)', () => { assert.equal(handFull(privFull), true); assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'chess', id: other, price: 1 } }), '整备区已满'); assert.equal(completesMerge(privFull, { kind: 'chess', id: MELEE }, o), true); - assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'chess', id: MELEE, price: 1 } }), null); + assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'chess', id: MELEE, price: 1 } }), '整备区已满', 'the third copy of a held pair'); assert.equal(shopBlockReason('reward', { ...o, slot: { kind: 'chess', id: other, price: 0 } }), '整备区已满'); - // the hand is filled with a mergeable equipment: buying another copy merges instead of needing a slot - assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'item', id: 'chess_item_1_01_e_a', price: 1 } }), null); + assert.equal(shopBlockReason('reward', { ...o, slot: { kind: 'chess', id: MELEE, price: 0 } }), '整备区已满', 'a merge-completing pick'); + // the hand is filled with a mergeable equipment: another copy would merge, but the purchase is refused all the same + assert.equal(completesMerge(privFull, { kind: 'item', id: 'chess_item_1_01_e_a' }, o), true); + assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'item', id: 'chess_item_1_01_e_a', price: 1 } }), '整备区已满'); const notFull = { ...privFull, hand: privFull.hand.map((p, i) => (i === 5 ? null : p)) }; assert.equal(shopBlockReason('buy', { ...o, priv: notFull, slot: { kind: 'chess', id: other, price: 1 } }), null); + assert.equal(shopBlockReason('buy', { ...o, priv: notFull, slot: { kind: 'chess', id: MELEE, price: 1 } }), null); }); });