// server/match/pool.js — the SHARED chess pool (copies per base chess, across all players), per-match bans, // copy-weighted shop rolls (research 00-INDEX §3, §6; DESIGN §6.2). // // Model: // * Every visible (non-hidden, non-DIY) base chess that is not banned this match has `cap` copies // (config.economy.poolCopies[tier], measured identity overrides; co-op shared stock doubled). `left` is the // signed capacity minus occupation; the public left() and shop sampling clamp a deficit to zero. // * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays // do NOT reserve copies. An item buy fails (SOLD_OUT) when left = 0; an operator card already shown in the shop or a // reward can still be taken and overdraws (a negative raw left, the debt — GitHub #498, the owner's decision). // * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return // exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0. // * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it. // // Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level // ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a // uniform shop-eligible item of that tier (falling back to lower tiers). A roll may add entries outside the pool // (`extra`, after its own): one player's 自选 stock — the ordinary shop passes diyRollEntries (the shopLevel gate); // the promotion reward and random grants pass diyStockEntries (the roll's own tier, DESIGN §30.23). Weighted by its // copies like any chess, drawn by that player only. /** * Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon` * add-on bonds among weight > 0 bonds that are active in the mode. A visible chess is banned iff every one of its * bonds is in D ∪ mode.inactiveBondIds. * @param {import('./gamedata.js').GameData} gd * @param {Function} rng seeded rng (createRng) * @returns {{ drawn: string[], staticOff: string[], banned: string[] }} */ export function drawDisabledBonds(gd, rng) { const { core: nCore, addon: nAddon } = gd.bans(gd.difficulty); const staticOff = [...gd.modeInactiveBonds].filter((b) => gd.bond(b)).sort(); const eligible = gd.bondIds.filter((b) => { const bond = gd.bond(b); return bond && Number(bond.weight) > 0 && !gd.modeInactiveBonds.has(b); }); const core = eligible.filter((b) => gd.bond(b).isCore); const addon = eligible.filter((b) => !gd.bond(b).isCore); const drawn = [...sample(core, nCore, rng), ...sample(addon, nAddon, rng)].sort(); const off = new Set([...drawn, ...staticOff]); const banned = []; for (const id of gd.visibleChess) { const c = gd.chess(id); const bonds = Array.isArray(c.bonds) ? c.bonds : []; if (bonds.length > 0 && bonds.every((b) => off.has(b))) banned.push(id); } return { drawn, staticOff, banned }; } function sample(arr, n, rng) { const a = arr.slice(); rng.shuffle(a); return a.slice(0, Math.max(0, Math.min(n, a.length))); } export class SharedPool { /** * @param {import('./gamedata.js').GameData} gd * @param {{ banned?: Iterable }} [opts] */ constructor(gd, { banned = [] } = {}) { this.gd = gd; const ban = new Set(banned); /** @type {Map} */ this.entries = new Map(); for (const id of gd.visibleChess) { if (ban.has(id)) continue; const cap = gd.poolCopies(id); if (cap <= 0) continue; this.entries.set(id, { cap, left: cap, tier: gd.tierOf(id) }); } this.banned = [...ban].sort(); } /** Whether a base chess is part of this match's pool (visible, not banned). */ has(baseId) { return this.entries.has(baseId); } cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; } left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); } /** Take up to n copies; returns the number actually taken (0 when not in the pool / empty). */ take(baseId, n = 1, { overdraw = false } = {}) { const e = this.entries.get(baseId); if (!e || !(n > 0)) return 0; const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n)); e.left -= k; return k; } /** Return n copies (clamped at the cap). Returns the number actually returned. */ give(baseId, n = 1) { const e = this.entries.get(baseId); if (!e || !(n > 0)) return 0; const k = Math.min(e.cap - e.left, Math.floor(n)); e.left += k; return k; } /** * Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`): the pool's entries, then `extra` ([id, entry] * pairs of the same shape — a player's 自选 stock) under the same filters. */ _eligible({ maxTier = 6, tier = null, filter = null, extra = null, ignoreCounts = false } = {}) { const out = []; const scan = (list) => { for (const [id, e] of list) { if (!ignoreCounts && e.left <= 0) continue; if (tier != null ? e.tier !== tier : e.tier > maxTier) continue; if (filter && !filter(id, e)) continue; out.push([id, ignoreCounts ? 1 : e.left]); } }; scan(this.entries); if (extra) scan(extra); return out; } /** * Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null. * @param {Function} rng * @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean, * extra?: Iterable<[string, { left: number, tier: number }]>|null }} [opts] */ roll(rng, opts = {}) { const el = this._eligible(opts); let total = 0; for (const [, n] of el) total += n; if (total <= 0) return null; let r = rng() * total; for (const [id, n] of el) { r -= n; if (r < 0) return id; } return el[el.length - 1][0]; } /** Tier shares of a copy-weighted roll at shop level `maxTier` (current remaining copies). */ tierShares(maxTier) { const t = {}; let total = 0; for (const [, e] of this.entries) { if (e.tier > maxTier || e.left <= 0) continue; t[e.tier] = (t[e.tier] || 0) + e.left; total += e.left; } const out = {}; for (const k of Object.keys(t)) out[k] = total > 0 ? t[k] / total : 0; return out; } /** * Item roll for the shop's item slot: tier by the chess tier shares at this level, uniform item within the tier, * falling back to lower tiers when a tier has no item. Returns an item id or null. */ rollItem(rng, maxTier, stock = null) { const shares = this.tierShares(maxTier); const tiers = Object.keys(shares).map(Number).sort((a, b) => a - b); let tier = null; if (tiers.length) { let r = rng(); for (const t of tiers) { r -= shares[t]; if (r < 0) { tier = t; break; } } if (tier == null) tier = tiers[tiers.length - 1]; } else { tier = 1 + Math.floor(rng() * Math.max(1, maxTier)); } for (let t = tier; t >= 1; t--) { const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id)); if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)]; } for (let t = tier + 1; t <= 6; t++) { const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id)); if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)]; } return null; } /** { baseId: left } snapshot (tests / diagnostics). */ snapshot() { const o = {}; for (const [id, e] of this.entries) o[id] = Math.max(0, e.left); return o; } totalLeft() { let n = 0; for (const e of this.entries.values()) n += Math.max(0, e.left); return n; } } /** Shared equipment stock, derived from actual ownership. Merges, equips and returns cannot lose a reservation: * normal equipment occupies one, upgraded equipment two. Shop/choice displays reserve nothing. Source: the original * experiment BV1eLXXBqEgF, 1:30 and 2:30. [ASSUMED] An item that is consumed/destroyed or removed by elimination no * longer occupies stock, consistently with the video's ownership model; this is not a per-match purchase limit. */ export class SharedItemPool { constructor(gd, players) { this.gd = gd; this.players = players; } cap(id) { return this.gd.itemPoolCopies(id); } need(id) { return this.gd.isGolden(id) ? 2 : 1; } held(id) { const base = this.gd.baseIdOf(id); let count = 0; const see = p => { if (!p) return; if (p.kind === 'item' && this.gd.baseIdOf(p.id) === base) count += this.need(p.id); for (const item of p.items || []) if (this.gd.baseIdOf(item.id) === base) count += this.need(item.id); }; for (const ps of this.players()) { for (const p of ps.hand) see(p); for (const p of ps.temp) see(p); for (const p of ps.board.values()) see(p); } return count; } left(id) { const cap = this.cap(id); return cap == null ? Infinity : Math.max(0, cap - this.held(id)); } canGain(id) { return this.left(id) >= this.need(id); } /** Stock gates eligibility, not probability: retain the existing uniform item draw or explicit data weights. * The measured stock table alone does not establish shop probabilities. */ pick(rng, ids, weights = null) { const list = [...new Set(ids)].filter(id => this.canGain(id)); const pairs = list.map(id => [id, weights ? Math.max(0, Number(weights.get(id)) || 0) : 1]); const total = pairs.reduce((n, p) => n + Math.max(0, p[1]), 0); if (!(total > 0)) return null; let r = rng() * total; for (const [id, weight] of pairs) if ((r -= Math.max(0, weight)) < 0) return id; return pairs.at(-1)?.[0] || null; } }