Files
ark-stronghold-protocol/server/match/pool.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

223 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (0.2.0, player/diy.js diyRollEntries) — weighted by its copies like
// any chess, drawn by that player's shop 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<string> }} [opts]
*/
constructor(gd, { banned = [] } = {}) {
this.gd = gd;
const ban = new Set(banned);
/** @type {Map<string, { cap: number, left: number, tier: number }>} */
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;
}
}