feedback5 WE: the 整备区 rules of PRTS 卫戍协议/帮助 §手牌区 (GitHub #82, second part) — a full regular hand refuses every purchase and reward pick, also one whose copy would complete a merge at once ("当常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入等通常情况下会增加手牌的操作"; BWIKI 盟约 says the same with no exception; the reporter checked it in the official game), and a free regular slot pulls the 临时整备区 in at once ("常规手牌区出现空位时自动移入"): PlayerState._fillHandFromTemp runs in every recompute, the temp row right to left (the order it fills, [ASSUMED]) into the hand's free slots right to left — after a sale, a deployment from the hand, a merge that consumed hand copies, an item equipped / destroyed / used, a summon stack removed with its owner. invariants.js: temp holds pieces only while the hand is full. The client's refusal path follows (gameLogic.shopBlockReason: 整备区已满 on every card — a one-line change in a file the R5 split moves). Bots: a full hand never tries a purchase, an affordable third copy of a held pair sheds the weakest hand single first (never one of this prep), resolveTemp decides temp right to left. Tests: test/match/feedback5-hand.test.js; merge / hardening / playtest6-elite-to-board / economy / prep-bench / uiFixes updated to the rule. Docs: META §1 Hand, DESIGN §6, PLAYING §3, research 00-INDEX / 01 (the old "a merge-completing purchase is allowed" corrected). Golden: 3 match scenarios moved (coop2-NORMAL-3, solo-HARD-9-boosted, coop2-ABYSS-10-boosted — bot seats that reached a full hand: the merge-completing purchase refused or a single shed first, temp pieces moving in by themselves); every battle digest unchanged

This commit is contained in:
sganggs committed 2026-10-05 18:17:08 +08:00
1 parent b1ec04b58c
commit db1b44bb71
22 files changed
+356 -101

No files matched your search

+1 -1
View File
@@ -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).
+10 -5
View File
@@ -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
+1 -1
View File
@@ -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 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- 每回合商店会自动刷新未冻结的格子。
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.<br>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.<br>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.<br>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.<br>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.<br>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.<br>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:<br>1. The 3rd copy is not created, and the 2 owned copies are destroyed wherever they are.<br>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.<br>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.<br>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 |
+8 -6
View File
@@ -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;
+1 -1
View File
@@ -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) };
/**
+6 -3
View File
@@ -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
+26 -15
View File
@@ -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;
}
+6 -3
View File
@@ -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}`);
+8 -2
View File
@@ -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' });
+30 -4
View File
@@ -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)
+7 -2
View File
@@ -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);
+4 -2
View File
@@ -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();
}
+31 -31
View File
@@ -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"]
}
}
}
+18 -3
View File
@@ -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);
+151
View File
@@ -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();
});
+9 -4
View File
@@ -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();
+22 -10
View File
@@ -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));
+2 -1
View File
@@ -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');
+6 -1
View File
@@ -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');
+7 -4
View File
@@ -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);
});
});