diff --git a/docs/META.md b/docs/META.md index 4622a402..0a5975c0 100644 --- a/docs/META.md +++ b/docs/META.md @@ -559,9 +559,10 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o stock (`ps.diyStock`: `poolCopies` of the slot's tier, 8 / 5 [ASSUMED]; none — `diyBanned` — when every bond of it is off this match; `initDiyStock` once the bans are drawn); `poolOf(baseId)` routes its copy accounting (buy, reward picks, merges, promotions, sells, temp, elimination; `invariants.js` checks `left + held = cap` per player). The - shop's chess slots and the reward offers' temporary refreshes roll it with the shared pool (`pool.roll({ extra })`, - copy-weighted like any chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6) [ASSUMED for the - reward offers]; an effect, reward pool or 机变 card that grants the player a random operator draws its stock too + shop's chess slots roll it with the shared pool (`pool.roll({ extra })`, copy-weighted like any chess of its tier) + once the 调度中心 reaches the slot's `shopLevel` (5 / 6); promotion reward offers include the player's unbanned, + stocked 自选 at the reward tier regardless of that shop-level gate (#515), and only the selected operator takes one + copy; an effect, reward pool or 机变 card that grants the player a random operator draws its stock too (effectsMeta `rollChess` / `rollPool`, the 驰援 fallback in choices.js: `ps.diyStockEntries()` — the roll's own tier rules, no 调度中心 gate, bonds through the player's view; 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」, 0.2.0), and 信标 never sends one to a teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and diff --git a/docs/design/match.md b/docs/design/match.md index b5a5729d..2fed2119 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -60,7 +60,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. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). 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; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); 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). +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; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); 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; includes the player's unbanned, stocked 自选 of that tier regardless of the ordinary shop's slot-level gate, #515); a tier short of different chess tops up from the tier below [ASSUMED], §20.6; offers reserve no copies, and the pick takes one from the matching pool); 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/history/0.2.4.md b/docs/history/0.2.4.md index 9f5c8917..7cf40299 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -274,3 +274,12 @@ enemy: picking emitted a unit click with no definition, instead of the tile clic engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields does not carry old devices across. Gameplay and allied targeting rules are unchanged. + +### 29.15 Promotion rewards include the player's stocked 自选 at the reward tier (#515) + +The player confirmed from gameplay that a 自编干员 can appear in the three-in-a-row promotion reward. Include every +unbanned 自选 entry with remaining personal stock in the reward roll, filtered by the reward's tier rather than the +ordinary shop's slot-level gate. The ordinary shop keeps its existing unlock gate. Displaying a candidate still reserves +no copies; `g.reward` rechecks stock and acquiring the pick takes exactly one from that player's `DiyStock`. Coverage in +`test/match/diy-shop.test.js` forces the shared tier-5/6 pools empty to check the level-4→tier-5 and level-5→tier-6 +boundaries, then checks stock is unchanged on display and decremented once on selection. diff --git a/server/match/player/acquire.js b/server/match/player/acquire.js index e3c61e2b..b96a4fd9 100644 --- a/server/match/player/acquire.js +++ b/server/match/player/acquire.js @@ -215,8 +215,8 @@ export class PlayerAcquire { * copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops * up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice — * the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one). - * The player's slotted 自选 pieces join the draw like in its shop (player/diy.js diyRollEntries: once the 调度中心 is at - * the slot's level) — the reward is a temporary refresh of the shop ("临时刷新3名…干员") [ASSUMED]. + * Slotted 自选 pieces with stock join the draw regardless of the shop's slot-level gate: players confirm they can + * appear in a three-in-a-row promotion reward. The reward tier still filters them like every other candidate. */ pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) { const ro = this.gd.rewardOffer(); @@ -228,7 +228,7 @@ export class PlayerAcquire { const fresh = (id) => !list.includes(id); for (let i = 0; i < ro.count; i++) { let id = null; - for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyRollEntries() }); + for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyStockEntries() }); if (id) list.push(id); } } diff --git a/test/match/diy-shop.test.js b/test/match/diy-shop.test.js index 904da8f7..1c845c3e 100644 --- a/test/match/diy-shop.test.js +++ b/test/match/diy-shop.test.js @@ -175,16 +175,26 @@ test('buy → 3 copies merge into the elite (the stock pays the copies); sell / p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false }; assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT'); h.invariants(); - // reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level + // promotion rewards include stocked 自选 pieces at the reward tier, before the shop unlocks that slot clear(p0); p0.offers = []; - let hit = 0; - p0.shop.level = 4; - for (let i = 0; i < 200; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => /_diy/.test(s.id))) hit++; p0.offers = []; } - assert.equal(hit, 0, 'a level-4 promotion reward (tier 5) never holds a tier-5 自选 piece'); - p0.shop.level = 6; - for (let i = 0; i < 300; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => s.id === T6A)) hit++; p0.offers = []; } - assert.ok(hit > 0, 'a level-6 reward can hold the tier-6 piece'); + const drained = []; + for (const [id, e] of m.pool.entries) if (e.tier === 5 || e.tier === 6) drained.push([id, m.pool.take(id, e.left)]); + p0.shop.level = 4; + const level4Offer = p0.pushRewardOffer('merge'); + assert.ok(level4Offer.slots.some((s) => s.id === T5A), 'a level-4 reward can offer the stocked tier-5 自选'); + assert.ok(level4Offer.slots.some((s) => s.id === T5B), 'both distinct stocked tier-5 自选 pieces are candidates'); + assert.equal(p0.diyStock.left(T5A), 8, 'showing an offer does not reserve personal stock'); + const t5Idx = level4Offer.slots.findIndex((s) => s.id === T5A); + assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: t5Idx }), { ok: true }); + assert.equal(p0.diyStock.left(T5A), 7, 'choosing the reward takes exactly one personal copy'); + p0.offers = []; + p0.shop.level = 5; + const level5Offer = p0.pushRewardOffer('merge'); + assert.ok(level5Offer.slots.some((s) => s.id === T6A), 'a level-5 reward can offer the stocked tier-6 自选'); + assert.equal(p0.diyStock.left(T6A), 5, 'showing a tier-6 offer does not reserve stock'); + p0.offers = []; + for (const [id, n] of drained) m.pool.give(id, n); // elimination returns everything gain(p0, T6A); p0.eliminate(m.round);