diff --git a/docs/META.md b/docs/META.md index 56897a24..7548518c 100644 --- a/docs/META.md +++ b/docs/META.md @@ -520,8 +520,10 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o 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]; effects that draw a random operator from the shared pool never draw it, and 信标 never sends one to a - teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and + reward offers]; 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 `m.result` lineups carry the pick (`diy`) so other players' cards compose the operator. * **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment); a full hand refuses every buy and reward pick — also one whose copy would complete a merge at once (PRTS diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 02f9e77d..de6c004c 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -60,7 +60,7 @@ - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 -- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 +- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 - 每回合商店会自动刷新未冻结的格子。 - **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。 diff --git a/server/match/choices.js b/server/match/choices.js index 1491e22b..04cee051 100644 --- a/server/match/choices.js +++ b/server/match/choices.js @@ -482,9 +482,13 @@ function applyDefault(m, ps, card) { handled = true; break; case 'single_special_choice_gain_bond_chess': { + // the player's 自选 stock joins the draw, its bonds read through the player's view (player/diy.js diyStockEntries) + const pgd = ps.gd || gd; + const hasBond = (cid) => { const c = pgd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); }; for (let i = 0; i < count; i++) { - const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } }) - || m.pool.roll(m.rngMeta, { maxTier: 6, filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } }); + const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null; + const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: hasBond, extra }) + || m.pool.roll(m.rngMeta, { maxTier: 6, filter: hasBond, extra }); if (id) ps.acquireChess(id, { source: 'choice' }); } handled = true; diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 5cc50e55..7ccfb73f 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -544,20 +544,30 @@ export function makeCtx(m, ps, source, hook, ev = null) { }); return p ? view(p) : null; }, - /** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */ + /** + * Random chess id from the shared pool (copy-weighted) — and the player's own 自选 stock (player/diy.js + * diyStockEntries, 0.2.0: 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」). opts: { maxTier, tier, bond, filter(id) }; + * bonds are read through the player's data view (a slotted slot: its operator's). + */ rollChess: (opts = {}) => { const f = (id) => { if (opts.bond) { const c = gd.chess(id); if (!c || !Array.isArray(c.bonds) || !c.bonds.includes(opts.bond)) return false; } return typeof opts.filter === 'function' ? !!opts.filter(id) : true; }; - return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f }); + const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null; + return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra }); }, rollItem: (opts = {}) => m.rollItemId(opts), /** * Roll a choices.json pool: equip pools → { kind: 'item', id }; chess pools (items / weighted / shopEligible with * tier, minTier, bond, golden) → { kind: 'chess', id, golden } (a pool chess needs a free copy). null when empty. */ - rollPool: (poolId, opts = {}) => m.rollPool(poolId, { shopLevel: ps.shop.level, ...opts }), + rollPool: (poolId, opts = {}) => m.rollPool(poolId, { + shopLevel: ps.shop.level, + // a shared-pool draw also takes the player's 自选 stock, its bonds read through the player's view (rollChess) + extra: typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null, chessOf: (id) => gd.chess(id), + ...opts, + }), /** * Run another owned chess's 特质 of `eventType` now (SERVER_GAIN / SERVER_PREP_START / SERVER_PREP_FIN / * SERVER_CHESS_SOLD / SERVER_REFRESH_SHOP). opts.asUid: run them as if they belonged to that piece. Returns the diff --git a/server/match/match/spDraft.js b/server/match/match/spDraft.js index d8ee43c0..5bac0e6f 100644 --- a/server/match/match/spDraft.js +++ b/server/match/match/spDraft.js @@ -152,9 +152,11 @@ export class MatchSpDraft { * Roll a choices.json pool (ctx.rollPool). Equip pools → rollItemId. Chess pools: an `items` (uniform) or `weighted` * list — only chess with a free pool copy (or outside the pool) qualify — else a copy-weighted draw from the shared * pool filtered by `tier` / `minTier` / `maxTier` (number or 'shopLevel') / `bond`; `golden: true` yields the elite id. + * `extra` (the drawing player's 自选 stock, player/diy.js diyStockEntries) joins that shared-pool draw, `chessOf` reads the + * bonds (the player's data view: a slotted slot's operator) — 0.2.0 WE2. * @returns {{ kind: 'item'|'chess', id: string, golden?: boolean } | null} */ - rollPool(poolId, { shopLevel = 6 } = {}) { + rollPool(poolId, { shopLevel = 6, extra = null, chessOf = null } = {}) { const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {}; const p = typeof poolId === 'string' && Object.hasOwn(pools, poolId) ? pools[poolId] : null; if (!p || typeof p !== 'object') return null; @@ -181,10 +183,12 @@ export class MatchSpDraft { const maxTier = p.maxTier === 'shopLevel' ? lvl : Number.isInteger(p.maxTier) ? p.maxTier : 6; const minTier = Number.isInteger(p.minTier) ? p.minTier : 1; const bond = typeof p.bond === 'string' ? p.bond : null; + const recOf = typeof chessOf === 'function' ? chessOf : (cid) => this.gd.chess(cid); id = this.pool.roll(rng, { tier: Number.isInteger(p.tier) ? p.tier : null, maxTier, - filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(this.gd.chess(cid)?.bonds) && this.gd.chess(cid).bonds.includes(bond))), + filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(recOf(cid)?.bonds) && recOf(cid).bonds.includes(bond))), + extra, }); } if (!id) return null; diff --git a/server/match/player/diy.js b/server/match/player/diy.js index a7fd0a05..d4a08b37 100644 --- a/server/match/player/diy.js +++ b/server/match/player/diy.js @@ -25,7 +25,11 @@ // shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once // the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at // level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any -// chess of its tier: 4). Effects that grant a random operator from the shared pool never draw it. +// chess of its tier: 4). +// * random grants (diyStockEntries, 0.2.0 WE2 #9): an effect, reward or 机变 card that grants this player a random +// operator from the pool draws its stock too — 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」 (bilibili), "调度中心 +// 随机资源的范围将被扩大" (PRTS 新手教程) — under the roll's own tier rules (the effect's tier / maxTier: no 调度中心 gate, +// as for a preset chess) and filters (bonds read through the player's data view). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -204,6 +208,17 @@ export class PlayerDiy { return out.length ? out : null; } + /** + * The stock entries a random grant of this player draws besides the shared pool (see the header): every slotted piece + * with copies left, whatever the 调度中心 level (the roll's own tier rules apply), or null (none). + * @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null} + */ + diyStockEntries() { + if (!this.diyStock || !this.diyStock.entries.size) return null; + const out = [...this.diyStock.entries].filter(([, e]) => e.left > 0); + return out.length ? out : null; + } + /** * The 自选 pick a chess id (normal or elite) of this player fields — its slot's — or null (not a slotted slot). * @param {string} id diff --git a/test/match/feedback5-we2-diy-grants.test.js b/test/match/feedback5-we2-diy-grants.test.js new file mode 100644 index 00000000..0f7e3f2f --- /dev/null +++ b/test/match/feedback5-we2-diy-grants.test.js @@ -0,0 +1,78 @@ +// test/match/feedback5-we2-diy-grants.test.js — 0.2.0 WE2 #9: an effect / reward / 机变 card that grants a player a random +// operator draws that player's 自选 stock too (「自选干员放入后模拟中的补给池随机范围也将被相应扩大」; PRTS 新手教程 "调度中心随机资源 +// 的范围将被扩大"): effectsMeta ctx.rollChess / ctx.rollPool (player/diy.js diyStockEntries), choices.js 驰援 fallback — +// the roll's own tier rules, bonds through the player's view; never another player's stock. +// Run: node --test test/match/feedback5-we2-diy-grants.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeCtx } from '../../server/match/effectsMeta.js'; +import { applyCard, tacticCard } from '../../server/match/choices.js'; +import { makeMatch, checkInvariants } from './harness.js'; + +const T5A = 'chess_char_5_diy1_a'; +const SIEGE = 'char_112_siege'; // 推进之王 (维多利亚) +const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }; + +/** A co-op match: p_0 slots 推进之王 at T5A, p_1 slots nothing; first prep. */ +function prep(seed = 911) { + const seats = [ + { seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: { [T5A]: PICK } }, + { seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true }, + ]; + const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed }).start(); + h.toPrep(1); + return { h, m: h.m, p0: h.ps('p_0'), p1: h.ps('p_1') }; +} +/** Take every shared-pool copy of the chess `pred` admits (the draw then sees only what is left); m.restore() gives them back. */ +function drain(m, pred) { + const back = (m.drained ??= []); + for (const [id, e] of m.pool.entries) if (pred(id, e) && e.left > 0) back.push([id, m.pool.take(id, e.left)]); + m.restore = () => { for (const [id, n] of back.splice(0)) m.pool.give(id, n); }; +} + +test('#9 effect rolls (ctx.rollChess, any tier / bond filter) draw the player\'s 自选 stock — its own player only', () => { + const { m, p0, p1 } = prep(); + assert.equal(p0.diyStock.left(T5A), 8); + drain(m, (id, e) => e.tier === 5); + const c0 = makeCtx(m, p0, { key: 'test' }, 'onTest'); + const c1 = makeCtx(m, p1, { key: 'test' }, 'onTest'); + assert.equal(c0.rollChess({ tier: 5 }), T5A, 'the only tier-5 copies left: her stock'); + assert.equal(c1.rollChess({ tier: 5 }), null, 'another player never draws it'); + // the 调度中心 level is not the shop's gate here: an effect's tier rule alone (p_0 is at level 1) + assert.ok(p0.shop.level < 5); + // a bond filter reads the operator: 推进之王 is 维多利亚 + drain(m, (id) => (m.gd.chess(id)?.bonds || []).includes('victoriaShip')); + assert.equal(c0.rollChess({ bond: 'victoriaShip' }), T5A); + assert.equal(c0.rollChess({ bond: 'yanShip', tier: 5 }), null, 'not a 炎 operator'); + // granting it takes one copy of her own stock + assert.ok(c0.grantChess(T5A)); + assert.equal(p0.diyStock.left(T5A), 7); + m.restore(); + checkInvariants(m); + m.dispose(); +}); + +test('#9 reward pools (ctx.rollPool: a shared-pool chess pool) and the 维多利亚驰援 card draw it too', () => { + const { m, p0, p1 } = prep(910); + drain(m, (id, e) => e.tier === 5); + const c0 = makeCtx(m, p0, { key: 'test' }, 'onTest'); + const c1 = makeCtx(m, p1, { key: 'test' }, 'onTest'); + assert.deepEqual(c0.rollPool('pool_chess_shop_5_reward'), { kind: 'chess', id: T5A, golden: false }); + assert.equal(c1.rollPool('pool_chess_shop_5_reward'), null); + // 维多利亚驰援 (allybuff_select_7_7, single_special_choice_gain_bond_chess) with every 维多利亚 copy of the pool taken: + // the card grants her 推进之王 from her own stock + drain(m, (id) => (m.gd.chess(id)?.bonds || []).includes('victoriaShip')); + const card = tacticCard(m.gd.choices.cards.tactic.find((c) => c.effectId === 'allybuff_select_7_7')); + const owned = (ps) => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((x) => x && x.kind === 'chess' && x.id === T5A).length; + const before = p0.diyStock.left(T5A); + applyCard(m, p0, card); + assert.equal(owned(p0), 1, 'she got her 自选 piece'); + assert.equal(p0.diyStock.left(T5A), before - 1, 'from her stock'); + const had1 = [...p1.hand, ...p1.temp].filter(Boolean).length; + applyCard(m, p1, card); + assert.equal([...p1.hand, ...p1.temp].filter(Boolean).length, had1, 'p_1 has no 自选 piece to draw: nothing'); + m.restore(); + checkInvariants(m); + m.dispose(); +});