0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -178,7 +178,7 @@ import { DataSource } from '../sim/simdata.js';
|
||||
import { createRng, deriveSeed } from '../sim/rng.js';
|
||||
import { GameData } from './gamedata.js';
|
||||
import { RealScheduler } from './scheduler.js';
|
||||
import { SharedPool, drawDisabledBonds } from './pool.js';
|
||||
import { SharedPool, SharedItemPool, drawDisabledBonds } from './pool.js';
|
||||
import { PlayerState } from './PlayerState.js';
|
||||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||||
import { setupMatchWaves } from './waves.js';
|
||||
@@ -332,6 +332,7 @@ export class Match {
|
||||
this.staticInactiveBonds = bans.staticOff;
|
||||
this.bannedChess = bans.banned;
|
||||
this.pool = new SharedPool(this.gd, { banned: bans.banned });
|
||||
this.itemPool = new SharedItemPool(this.gd, () => this.order);
|
||||
// 自选编队 (0.2.0): each human's slotted DIY pieces get their own stock — none for one whose bonds are all off this
|
||||
// match (player/diy.js initDiyStock); no randomness is drawn here
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
|
||||
@@ -143,19 +143,20 @@ const ITEM_HANDLERS = {
|
||||
},
|
||||
},
|
||||
// 拟态物质 「若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员」: the 否则 is the owned < 2
|
||||
// case only. With 2 copies owned and none left in the pool (an elite holds 3 of a Ⅵ阶's 5) the grant fails and the
|
||||
// item gives nothing (research 06 §7: some effects fail at the copy cap) — it never falls back to a same-bond operator
|
||||
// (GitHub #207).
|
||||
// case only. The original experiment BV1eLXXBqEgF (3:00) distinguishes this item from pool-weighted grants:
|
||||
// it samples eligible operator identities and can grant beyond stock. All obtained copies still occupy the pool.
|
||||
use_equip_reward_char_chess: {
|
||||
onEquip(ctx, ev) {
|
||||
const base = ctx.gd.baseIdOf(ev.target.id);
|
||||
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
|
||||
if (owned >= 2) {
|
||||
if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null);
|
||||
if (!ctx.grantChess(base, { requirePool: false })) toastNothing(ctx, ev);
|
||||
return;
|
||||
}
|
||||
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
|
||||
if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev);
|
||||
const bonds = new Set(ctx.pieceBonds(ev.target.uid));
|
||||
const id = ctx.rollChess({ maxTier: 6, ignoreCounts: true,
|
||||
filter: cid => ctx.gd.chess(cid)?.bonds?.some(b => bonds.has(b)) });
|
||||
if (!id || !ctx.grantChess(id, { requirePool: false })) toastNothing(ctx, ev);
|
||||
},
|
||||
},
|
||||
use_equip_reward_special_goods_char_chess: {
|
||||
@@ -285,7 +286,8 @@ const EFFECT_HANDLERS = {
|
||||
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
|
||||
},
|
||||
},
|
||||
// 整备: the next purchased item becomes advanced (golden)
|
||||
// 整备: the next purchased item becomes advanced (golden). upgradeItem leaves it normal, and this
|
||||
// charge unspent, when the shared stock has no room for the golden's second copy.
|
||||
builtin_next_buy_golden_item: {
|
||||
onBuy(ctx, ev) {
|
||||
const ref = ctx.source.ref;
|
||||
|
||||
+10
-7
@@ -136,12 +136,12 @@ function itemCard(gd, id) {
|
||||
* Build the draft cards for an SP round.
|
||||
* @returns {{ family: string, name: string, desc: string, eventId: string|null, cards: object[] } | null}
|
||||
*/
|
||||
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null } = {}) {
|
||||
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null, itemAvailable = null } = {}) {
|
||||
const sch = scheduleFor(gd, round);
|
||||
const fams = Array.isArray(sch.families) && sch.families.length ? sch.families.map((f) => [f.family, f.weight]) : [['supply', 1]];
|
||||
let family = weightedPick(rng, fams) || 'supply';
|
||||
const n = Number.isInteger(sch.cards) && sch.cards > 0 ? Math.min(sch.cards, 6) : formatCount(gd);
|
||||
const opts = { stageId, bondAvailable, round };
|
||||
const opts = { stageId, bondAvailable, round, itemAvailable };
|
||||
let cards = buildCards(gd, rng, family, n, sch, opts);
|
||||
if (!cards.length && family !== 'supply') { family = 'supply'; cards = buildCards(gd, rng, family, n, sch, opts); }
|
||||
if (!cards.length) return null;
|
||||
@@ -328,16 +328,17 @@ function bountyDraftCards(gd, rng, n, sch, round) {
|
||||
* the nearest lower one, then any) — so one item can fill two slots. Positions shuffled; `n` < the slots (solo) keeps
|
||||
* `n` of them. Null without a usable `shopDraft` or at a round its `rounds` (when given) does not list.
|
||||
*/
|
||||
export function shopDraftCards(gd, rng, n, round = null) {
|
||||
export function shopDraftCards(gd, rng, n, round = null, itemAvailable = null) {
|
||||
const spec = gd.choices.shopDraft;
|
||||
const slots = spec && Array.isArray(spec.slots) ? spec.slots.filter((x) => x && typeof x === 'object') : [];
|
||||
if (!slots.length) return null;
|
||||
if (Array.isArray(spec.rounds) && !spec.rounds.includes(round)) return null;
|
||||
const w = spec.itemWeights && typeof spec.itemWeights === 'object' ? spec.itemWeights : {};
|
||||
const coin = typeof spec.coin === 'string' && gd.item(spec.coin) ? spec.coin : null;
|
||||
const free = list => itemAvailable ? list.filter(itemAvailable) : list;
|
||||
const ofTier = (t) => {
|
||||
for (let k = t; k >= 1; k--) if ((gd.shopItemsByTier[k] || []).length) return gd.shopItemsByTier[k];
|
||||
return eligibleItems(gd, 1, 6);
|
||||
for (let k = t; k >= 1; k--) { const list = free(gd.shopItemsByTier[k] || []); if (list.length) return list; }
|
||||
return free(eligibleItems(gd, 1, 6));
|
||||
};
|
||||
const out = [];
|
||||
for (const slot of slots) {
|
||||
@@ -382,10 +383,10 @@ export function tacticDraftCards(gd, rng, n, { stageId = null, bondAvailable = n
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1, itemAvailable = null } = {}) {
|
||||
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
|
||||
if (family === 'shop') {
|
||||
const cards = shopDraftCards(gd, rng, n, round);
|
||||
const cards = shopDraftCards(gd, rng, n, round, itemAvailable);
|
||||
if (cards) return cards;
|
||||
}
|
||||
if (family === 'supply' || family === 'shop') {
|
||||
@@ -393,7 +394,9 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
|
||||
let hi = 6;
|
||||
if (family === 'supply' && Array.isArray(sch.supplyTiers) && sch.supplyTiers.length === 2) [lo, hi] = sch.supplyTiers;
|
||||
let list = eligibleItems(gd, lo, hi);
|
||||
if (itemAvailable) list = list.filter(itemAvailable);
|
||||
if (!list.length) list = eligibleItems(gd, 1, 6);
|
||||
if (itemAvailable) list = list.filter(itemAvailable);
|
||||
const out = [];
|
||||
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
|
||||
return out;
|
||||
|
||||
@@ -354,7 +354,8 @@ export class EffectDispatcher {
|
||||
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
|
||||
const loc = ps.find(self.uid);
|
||||
const where = loc ? (loc.area === 'board' ? 'board' : loc.area) : 'trigger';
|
||||
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
|
||||
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, copiedFrom: asPiece ? piece : null,
|
||||
garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
|
||||
n++;
|
||||
}
|
||||
} finally {
|
||||
@@ -582,7 +583,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
|
||||
};
|
||||
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 });
|
||||
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, ignoreCounts: opts.ignoreCounts === true });
|
||||
},
|
||||
rollItem: (opts = {}) => m.rollItemId(opts),
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
|
||||
import { layerGainRoom } from '../../shared/constants.js';
|
||||
import { uniteLeft } from '../sim/spec.js';
|
||||
import { diyTokenOwner } from '../../shared/diy.js';
|
||||
import { diyTokenOwner, diyRecordOf } from '../../shared/diy.js';
|
||||
|
||||
export const MAX_TICKS_PER_INTERVAL = 8;
|
||||
export const INTERVAL_MS = 1000 / 30;
|
||||
@@ -679,6 +679,7 @@ export function specBounds(spec, gd = null) {
|
||||
if (!p || typeof p.playerId !== 'string') continue;
|
||||
const chess = new Map();
|
||||
const all = new Map();
|
||||
const names = new Map();
|
||||
// unit types this player can field: its board's chess / tokens, their summons, and the ownerless summons of
|
||||
// bonds / bands (炎佑, 预备干员) — the only names a statistic of a unit created in battle (no board uid) may carry
|
||||
const defIds = new Set(gd ? ownerlessSummons(gd) : []);
|
||||
@@ -686,6 +687,11 @@ export function specBounds(spec, gd = null) {
|
||||
if (!u || !Number.isInteger(u.uid)) continue;
|
||||
const defId = u.kind === 'token' ? u.tokenId : u.chessId;
|
||||
all.set(u.uid, defId);
|
||||
if (gd) {
|
||||
const base = gd.chess?.(defId) || gd.token?.(defId);
|
||||
const rec = u.standIn === true ? gd.standIn?.(defId) : u.diy && base?.isDiy ? diyRecordOf(base, u.diy, gd.raw) : base;
|
||||
if (typeof rec?.name === 'string') names.set(u.uid, rec.name);
|
||||
}
|
||||
if (typeof defId === 'string') defIds.add(defId);
|
||||
if (u.kind !== 'token') {
|
||||
chess.set(u.uid, defId);
|
||||
@@ -699,7 +705,7 @@ export function specBounds(spec, gd = null) {
|
||||
const v = Number(b && b.layers);
|
||||
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
|
||||
}
|
||||
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
|
||||
players.set(p.playerId, { chess, all, names, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
|
||||
}
|
||||
const round = Number(spec && spec.round) || 0;
|
||||
return {
|
||||
@@ -875,7 +881,7 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
const rec = gd ? (gd.chess?.(defId) || gd.token?.(defId)) : null;
|
||||
if (gd && !rec) continue;
|
||||
unitStats.push({
|
||||
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: rec && typeof rec.name === 'string' ? rec.name : defId, kind: u.kind === 'token' ? 'token' : 'op',
|
||||
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: (onBoard && own.names.get(u.uid)) || (typeof rec?.name === 'string' ? rec.name : defId), kind: u.kind === 'token' ? 'token' : 'op',
|
||||
dmg: Math.max(0, Number(u.dmg) || 0), kills: Math.max(0, Math.trunc(Number(u.kills) || 0)), heal: Math.max(0, Number(u.heal) || 0),
|
||||
taken: Math.max(0, Number(u.taken) || 0), attacks: Math.max(0, Math.trunc(Number(u.attacks) || 0)),
|
||||
});
|
||||
|
||||
@@ -295,11 +295,21 @@ export class GameData {
|
||||
/** Copies of a base chess in the shared pool. */
|
||||
poolCopies(baseId) {
|
||||
const ov = this.economy.poolCopiesOverrides;
|
||||
if (ov && typeof ov === 'object' && Number.isInteger(ov[baseId]) && ov[baseId] >= 0) return ov[baseId];
|
||||
const tier = this.tierOf(baseId);
|
||||
const pc = this.economy.poolCopies;
|
||||
const v = pc && typeof pc === 'object' ? pc[tier] : undefined;
|
||||
return Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
|
||||
const v = ov && Number.isInteger(ov[baseId]) && ov[baseId] >= 0 ? ov[baseId] : pc?.[tier];
|
||||
const base = Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
|
||||
// The measured doubled pool is shared by the whole co-op team, independent of team size. DIY is private stock.
|
||||
const multiplier = this.isSolo || this.chess(baseId)?.isDiy ? 1 : posIntOr(this.economy.coopPoolMultiplier, 1);
|
||||
return base * multiplier;
|
||||
}
|
||||
|
||||
/** Normal equipment copies in the shared pool; effect-only items have no shop stock limit. */
|
||||
itemPoolCopies(id) {
|
||||
const base = this.baseIdOf(id), rec = this.item(base);
|
||||
if (!rec || !isShopItem(rec)) return null;
|
||||
const count = this.economy.itemPoolCopiesOverrides?.[base] ?? this.economy.itemPoolCopies?.[rec.tier];
|
||||
return Number.isInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
/** Copies needed to merge (0 = never merges: golden chess). */
|
||||
@@ -443,8 +453,8 @@ export class GameData {
|
||||
* Team LP the overtime drain has taken when a boss field clock reads `gt` game seconds: bossOvertimeDrainReal per
|
||||
* whole REAL second past bossOvertimeAfterReal (the first point at 151 real s).
|
||||
*/
|
||||
bossOvertimeDue(gt) {
|
||||
const over = (Number(gt) || 0) / this.combatTimeScale - this.bossOvertimeAfterReal;
|
||||
bossOvertimeDue(gt, afterReal = this.bossOvertimeAfterReal) {
|
||||
const over = (Number(gt) || 0) / this.combatTimeScale - afterReal;
|
||||
return over >= 1 ? Math.floor(over) * this.bossOvertimeDrainReal : 0;
|
||||
}
|
||||
get dp() {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
|
||||
//
|
||||
// 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;
|
||||
// pool raw balance ≤ cap and raw balance + Σ copies held by pieces == cap (public availability is clamped at zero) per base chess; non-pool chess hold 0 copies;
|
||||
// every capped shop item is held by at most its cap (normal occupies 1, upgraded 2; effect-only items have no cap)
|
||||
// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY
|
||||
// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots
|
||||
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
|
||||
@@ -186,7 +187,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
// 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing
|
||||
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
|
||||
if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`);
|
||||
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: balance ${e.left} cap ${e.cap}`);
|
||||
const h = diyHeld.get(base) || 0;
|
||||
if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
||||
}
|
||||
@@ -195,12 +196,23 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
|
||||
// shared pool accounting
|
||||
for (const [base, e] of m.pool.entries) {
|
||||
if (!(e.left >= 0 && e.left <= e.cap)) fail(`pool ${base}: left ${e.left} cap ${e.cap}`);
|
||||
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`pool ${base}: balance ${e.left} cap ${e.cap}`);
|
||||
const h = held.get(base) || 0;
|
||||
if (e.left + h !== e.cap) fail(`pool ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
||||
}
|
||||
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
|
||||
|
||||
// Shared shop equipment. The chess pool above may go negative (Mimic). Item stock may not: held ≤ cap.
|
||||
if (m.itemPool && gd.raw.items) {
|
||||
for (const [id, rec] of Object.entries(gd.raw.items)) {
|
||||
if (!rec || rec.isGolden) continue;
|
||||
const cap = m.itemPool.cap(id);
|
||||
if (!Number.isInteger(cap)) continue;
|
||||
const h = m.itemPool.held(id);
|
||||
if (h > cap) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// combat fields
|
||||
if (m.phase === PHASE.COMBAT) {
|
||||
// one field per alive player (+ the force-ended field of a player who quit during this combat)
|
||||
|
||||
@@ -120,8 +120,13 @@ export class MatchBoss {
|
||||
// overtime drain starts (150 real s), both on the field clock
|
||||
const onClock = (realS) => this.sched.now() + Math.round(((realS * this.gd.combatTimeScale) / this.gameSpeed) * 1000);
|
||||
const levelTime = this.gd.bossLevelTime(this.round);
|
||||
// Maintainer 2026-10-10: only teams with an AI teammate keep the extra grace after the visible countdown.
|
||||
// Freeze this choice for the fight; a disconnect / host transfer cannot change its LP clock midway.
|
||||
const team = this.order.filter((p) => !p.left);
|
||||
this.bossOvertimeStartReal = team.length > 1 && team.some((p) => p.isBot)
|
||||
? this.gd.bossOvertimeAfterReal : (levelTime || this.gd.bossOvertimeAfterReal);
|
||||
this.deadline = this.sched.instant || !levelTime ? 0 : onClock(levelTime);
|
||||
this.overtimeAt = this.sched.instant ? 0 : onClock(this.gd.bossOvertimeAfterReal);
|
||||
this.overtimeAt = this.sched.instant ? 0 : onClock(this.bossOvertimeStartReal);
|
||||
if (this.clientCombat) { this._startFinalClient(hidden); return; }
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
@@ -480,7 +485,7 @@ export class MatchBoss {
|
||||
* (bossTurnHpReduceTime, gamedata.js bossOvertimeDue).
|
||||
*/
|
||||
_applyOvertime(gt) {
|
||||
const due = this.gd.bossOvertimeDue(gt);
|
||||
const due = this.gd.bossOvertimeDue(gt, this.bossOvertimeStartReal);
|
||||
if (due > this.overtimeApplied) {
|
||||
const loss = due - this.overtimeApplied;
|
||||
this.overtimeApplied = due;
|
||||
|
||||
@@ -16,7 +16,7 @@ const PERSONAL_OFFER_SIZE = 3;
|
||||
|
||||
export class MatchSpDraft {
|
||||
enterSpDraft() {
|
||||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
|
||||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId), itemAvailable: id => this.itemPool.canGain(id) });
|
||||
const alive = this.alivePlayers();
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
@@ -38,6 +38,8 @@ export class MatchSpDraft {
|
||||
return s.order[s.idx] ?? null;
|
||||
}
|
||||
|
||||
spCardAvailable(card) { return !!card && this.sp?.taken[card.idx] == null && (card.kind !== 'item' || this.itemPool.canGain(card.id)); }
|
||||
|
||||
startSpTurn() {
|
||||
const s = this.sp;
|
||||
this.cancel(this._turnTimer);
|
||||
@@ -47,7 +49,7 @@ export class MatchSpDraft {
|
||||
if (ps && ps.alive && s.picks[ps.playerId] == null) break;
|
||||
s.idx++;
|
||||
}
|
||||
const available = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||||
const available = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (s.idx >= s.order.length || !available.length) { this.setDeadline(0); this.later(0, () => this.finishSpDraft()); return; }
|
||||
const token = ++this._turnToken;
|
||||
if (!s.untimed) {
|
||||
@@ -57,7 +59,7 @@ export class MatchSpDraft {
|
||||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken) return;
|
||||
const ps = this.players.get(this.spTurn());
|
||||
if (!ps) return;
|
||||
const avail = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||||
const avail = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (!avail.length) { this.finishSpDraft(); return; }
|
||||
this._applyCard(ps, avail[Math.floor(this.rngDraft() * avail.length)]);
|
||||
});
|
||||
@@ -76,7 +78,7 @@ export class MatchSpDraft {
|
||||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken || !this.sp) return;
|
||||
const ps = this.players.get(this.spTurn());
|
||||
if (!ps || !ps.botControlled) return;
|
||||
const avail = this.sp.cards.map((c) => c.idx).filter((i) => this.sp.taken[i] == null);
|
||||
const avail = this.sp.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (!avail.length) return;
|
||||
this._applyCard(ps, botPickCard(this, ps, this.sp.cards, avail));
|
||||
});
|
||||
@@ -88,14 +90,14 @@ export class MatchSpDraft {
|
||||
if (this.sp.picks[ps.playerId] != null) return fail(ERR.ALREADY);
|
||||
if (this.spTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= this.sp.cards.length) return fail(ERR.BAD_TARGET);
|
||||
if (this.sp.taken[idx] != null) return fail(ERR.SOLD_OUT);
|
||||
if (!this.spCardAvailable(this.sp.cards[idx])) return fail(ERR.SOLD_OUT);
|
||||
this._applyCard(ps, idx);
|
||||
return OK;
|
||||
}
|
||||
|
||||
_applyCard(ps, idx) {
|
||||
const s = this.sp;
|
||||
if (!s || s.picks[ps.playerId] != null || s.taken[idx] != null) return;
|
||||
if (!s || s.picks[ps.playerId] != null || !this.spCardAvailable(s.cards[idx])) return;
|
||||
const card = s.cards[idx];
|
||||
if (!card) return;
|
||||
s.picks[ps.playerId] = idx;
|
||||
@@ -188,24 +190,20 @@ export class MatchSpDraft {
|
||||
const tierList = (lo, hi) => { const out = []; for (let t = lo; t <= hi; t++) for (const id of this.gd.shopItemsByTier[t] || []) out.push(id); return out; };
|
||||
if (p && p.kind === 'equip') {
|
||||
if (Array.isArray(p.weighted) && p.weighted.length) {
|
||||
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]));
|
||||
let total = 0;
|
||||
for (const [, w] of pairs) total += Math.max(0, Number(w) || 0);
|
||||
let r = rng() * total;
|
||||
for (const [id, w] of pairs) { r -= Math.max(0, Number(w) || 0); if (r < 0) return id; }
|
||||
return pairs.length ? pairs[pairs.length - 1][0] : null;
|
||||
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]) && this.itemPool.canGain(x[0]));
|
||||
return this.itemPool.pick(rng, pairs.map(x => x[0]), new Map(pairs));
|
||||
}
|
||||
if (Array.isArray(p.items) && p.items.length) {
|
||||
const items = p.items.filter((id) => this.gd.item(id));
|
||||
return items.length ? items[Math.floor(rng() * items.length)] : null;
|
||||
return this.itemPool.pick(rng, items);
|
||||
}
|
||||
let list;
|
||||
if (Array.isArray(p.tiers) && p.tiers.length) list = p.tiers.flatMap((t) => this.gd.shopItemsByTier[t] || []);
|
||||
else list = tierList(1, p.maxTier === 'shopLevel' ? Math.max(1, Math.min(6, shopLevel)) : 6);
|
||||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||||
return this.itemPool.pick(rng, list);
|
||||
}
|
||||
const list = Number.isInteger(tier) ? tierList(tier, tier) : tierList(1, Math.max(1, Math.min(6, maxTier)));
|
||||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||||
return this.itemPool.pick(rng, list);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,8 @@ export class MatchViews {
|
||||
if (this.phase === PHASE.SP_DRAFT && this.sp) {
|
||||
const s = this.sp;
|
||||
v.sp = {
|
||||
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId, cards: s.cards.map(cardView), order: s.order.slice(),
|
||||
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId,
|
||||
cards: s.cards.map(c => ({ ...cardView(c), soldOut: c.kind === 'item' && !this.itemPool.canGain(c.id) })), order: s.order.slice(),
|
||||
turn: this.spTurn(), picks: { ...s.picks }, taken: { ...s.taken }, untimed: !!s.untimed,
|
||||
};
|
||||
}
|
||||
@@ -243,10 +244,29 @@ export class MatchViews {
|
||||
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
|
||||
}
|
||||
|
||||
/** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at
|
||||
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
|
||||
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
|
||||
prepMapChars(ps) {
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
|
||||
const id = 'char_605_cmedic';
|
||||
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
|
||||
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
|
||||
const rec = this.gd.token(id);
|
||||
if (!slot || !rec) return [];
|
||||
const dir = slot.dir || 'RIGHT';
|
||||
return [{
|
||||
id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id,
|
||||
area: 'board', name: rec.name || id, tier: 1, golden: false,
|
||||
spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id,
|
||||
x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1,
|
||||
}];
|
||||
}
|
||||
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
|
||||
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
|
||||
_prepBoardUnits(ps) {
|
||||
const units = [];
|
||||
const units = this.prepMapChars(ps);
|
||||
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
|
||||
const gd = ps.gd || this.gd;
|
||||
for (const { r, c, piece } of boardOrder(ps.board)) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class PlayerAcquire {
|
||||
const base = this.gd.baseIdOf(chessId);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
// the shared pool, or this player's stock of a slotted 自选 piece (player/diy.js poolOf)
|
||||
const taken = fromPool ? this.poolOf(base).take(base, need) : 0;
|
||||
const taken = fromPool ? this.poolOf(base).take(base, need, { overdraw: true }) : 0;
|
||||
const piece = this.newPiece('chess', chessId, { poolCopies: taken });
|
||||
this.round.gainedChess++;
|
||||
let owned = piece;
|
||||
@@ -102,6 +102,7 @@ export class PlayerAcquire {
|
||||
// a new piece: this round's per-piece counters start at 0, none is carried over from the copies — a newly merged
|
||||
// elite 拉普兰德 is a new 拉普兰德 and fires +8 on its own first manual refresh this round (GitHub #169; the owner's
|
||||
// decision of 2026-10-06; pieceRoundCount)
|
||||
copies += this.poolOf(baseId).take(baseId, Math.max(0, this.gd.goldenCopies - copies), { overdraw: true });
|
||||
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
|
||||
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) }));
|
||||
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
|
||||
@@ -146,7 +147,7 @@ export class PlayerAcquire {
|
||||
if (!goldenId) return false;
|
||||
const base = this.gd.baseIdOf(piece.id);
|
||||
const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0));
|
||||
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra);
|
||||
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra, { overdraw: true });
|
||||
piece.id = goldenId;
|
||||
this.recompute();
|
||||
// an elite on the board tops its summon stacks up to the elite's deploy limit, like an elite merged onto a tile
|
||||
@@ -196,12 +197,14 @@ export class PlayerAcquire {
|
||||
return np;
|
||||
}
|
||||
|
||||
/** Normal item → golden version in place (整备). */
|
||||
/** Normal item → golden version in place (整备). A golden occupies two shared copies and this piece
|
||||
* already occupies one, so the upgrade waits when no second copy is free. The caller keeps its charge. */
|
||||
upgradeItem(piece) {
|
||||
const rec = this.gd.item(piece.id);
|
||||
if (!rec || rec.isGolden) return false;
|
||||
const gid = rec.upgradeChessId || rec.goldenId;
|
||||
if (!gid || !this.gd.item(gid)) return false;
|
||||
if (this.m.itemPool.left(piece.id) < 1) return false;
|
||||
piece.id = gid;
|
||||
this.recompute();
|
||||
return true;
|
||||
@@ -241,7 +244,7 @@ export class PlayerAcquire {
|
||||
* 'item' under its `label` (the effect's name; player report #6 after 0.1.0).
|
||||
*/
|
||||
pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) {
|
||||
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
|
||||
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id) && this.m.itemPool.canGain(id)).slice(0, MAX_OFFER_SLOTS);
|
||||
if (!list.length) return null;
|
||||
const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
|
||||
this.offers.push(offer);
|
||||
@@ -285,6 +288,10 @@ export class PlayerAcquire {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
if (!this.m.itemPool.canGain(itemId)) {
|
||||
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
|
||||
return null;
|
||||
}
|
||||
let piece = this.newPiece('item', itemId);
|
||||
// A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies
|
||||
// without this mark, so the fight that is about to start is not reported as a missed merge.
|
||||
|
||||
@@ -53,13 +53,13 @@ export class DiyStock {
|
||||
|
||||
has(baseId) { return this.entries.has(baseId); }
|
||||
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
|
||||
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
|
||||
left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); }
|
||||
|
||||
/** Take up to n copies; returns the number taken (0 for a base id without stock). */
|
||||
take(baseId, n = 1) {
|
||||
take(baseId, n = 1, { overdraw = false } = {}) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.left, Math.floor(n));
|
||||
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
|
||||
e.left -= k;
|
||||
return k;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class DiyStock {
|
||||
/** { baseId: left } (tests / diagnostics). */
|
||||
snapshot() {
|
||||
const o = {};
|
||||
for (const [id, e] of this.entries) o[id] = e.left;
|
||||
for (const [id, e] of this.entries) o[id] = Math.max(0, e.left);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export class PlayerEconomy {
|
||||
}
|
||||
|
||||
_rollItemSlot() {
|
||||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level);
|
||||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level, this.m.itemPool);
|
||||
return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export class PlayerEconomy {
|
||||
piece = this.acquireChess(slot.id, { source: 'buy' });
|
||||
} else {
|
||||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||||
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
this.spend(price);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ERR } from '../../../shared/constants.js';
|
||||
import { msg, dn } from '../../../shared/i18n.js';
|
||||
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
|
||||
import { summonPlacementGrid } from '../../../shared/summonPlacement.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerPlacement {
|
||||
@@ -46,7 +47,8 @@ export class PlayerPlacement {
|
||||
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
|
||||
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
|
||||
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
|
||||
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
|
||||
* (summonExcluded). Jessica's shield instead uses the fixed four-adjacent-tile grid (shared/summonPlacement.js).
|
||||
* `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
|
||||
* with its own owner).
|
||||
*/
|
||||
_legal(piece, r, c, owner = null) {
|
||||
@@ -62,15 +64,18 @@ export class PlayerPlacement {
|
||||
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
|
||||
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
|
||||
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
|
||||
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other
|
||||
* tile (board.js ownerRangeKeys). Jessica's shield uses its four-adjacent-tile placement grid, independent of her
|
||||
* facing, skill or module. Null when the piece is not range-bound or its owner is not on the board (the other
|
||||
* rules refuse such a placement).
|
||||
* @param {any} piece
|
||||
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
summonRange(piece, owner = null) {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
if (!piece || piece.kind !== 'token') return null;
|
||||
const grid = summonPlacementGrid(piece.id);
|
||||
if (!grid && this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner, grid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,14 +91,14 @@ export class PlayerPlacement {
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
}
|
||||
|
||||
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
|
||||
_ownerRangeOf(piece, owner = null) {
|
||||
/** The owner's attack or fixed placement range of a summon, or null when its owner is off the board. */
|
||||
_ownerRangeOf(piece, owner = null, placementGrid = null) {
|
||||
let at = owner;
|
||||
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
|
||||
const chess = at && this.gd.chess(at.piece.id);
|
||||
if (!chess) return null;
|
||||
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
|
||||
const grid = placementGrid || attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
|
||||
const [r, c] = parseKey(at.key);
|
||||
return ownerRangeKeys(grid, r, c, at.dir);
|
||||
}
|
||||
@@ -108,7 +113,7 @@ export class PlayerPlacement {
|
||||
* battle. Returns the number taken off the board; a toast names them.
|
||||
*/
|
||||
_liftOutOfRange() {
|
||||
const back = [], gone = [], backOut = [], goneOut = [];
|
||||
const back = [], gone = [], backOut = [], goneOut = [], backAdjacent = [], goneAdjacent = [];
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token') continue;
|
||||
const range = this.summonRange(p);
|
||||
@@ -117,14 +122,17 @@ export class PlayerPlacement {
|
||||
if (!inside && (!range || range.has(k))) continue;
|
||||
this.board.delete(k);
|
||||
const ok = this._returnToken(p, null, { allowTemp: true });
|
||||
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
|
||||
const returned = inside ? (ok ? backOut : goneOut) : summonPlacementGrid(p.id) ? (ok ? backAdjacent : goneAdjacent) : (ok ? back : gone);
|
||||
returned.push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
||||
// an outside-bound summon (战术锚点) its owner's new range now covers
|
||||
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
|
||||
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
|
||||
return back.length + gone.length + backOut.length + goneOut.length;
|
||||
if (backAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,已退回整备区', { names: backAdjacent.map(dn) }));
|
||||
if (goneAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,整备区已满,下回合返还', { names: goneAdjacent.map(dn) }));
|
||||
return back.length + gone.length + backOut.length + goneOut.length + backAdjacent.length + goneAdjacent.length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
} else {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
|
||||
@@ -88,6 +88,8 @@ export class PlayerViews {
|
||||
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
|
||||
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
|
||||
board,
|
||||
// The stage's 外勤医疗 reserve medic is present during prep, but is not a draggable board piece.
|
||||
prepMapChars: this.m.prepMapChars(this),
|
||||
deployCap: this.deployCap,
|
||||
deployCount: this.deployCount,
|
||||
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
|
||||
|
||||
+54
-15
@@ -3,12 +3,13 @@
|
||||
//
|
||||
// Model:
|
||||
// * Every visible (non-hidden, non-DIY) base chess that is not banned this match has `cap` copies
|
||||
// (config.economy.poolCopies[tier], overrides e.g. 缪尔赛思 4). `left[baseId]` = copies not owned by anyone.
|
||||
// (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; buying fails (SOLD_OUT) when left = 0.
|
||||
// * 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 (tests): 0 ≤ left ≤ cap and left + Σ held copies == cap for every base chess.
|
||||
// * 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
|
||||
@@ -72,13 +73,13 @@ export class SharedPool {
|
||||
/** 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 this.entries.get(baseId)?.left ?? 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) {
|
||||
take(baseId, n = 1, { overdraw = false } = {}) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.left, Math.floor(n));
|
||||
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
|
||||
e.left -= k;
|
||||
return k;
|
||||
}
|
||||
@@ -96,14 +97,14 @@ export class SharedPool {
|
||||
* 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 } = {}) {
|
||||
_eligible({ maxTier = 6, tier = null, filter = null, extra = null, ignoreCounts = false } = {}) {
|
||||
const out = [];
|
||||
const scan = (list) => {
|
||||
for (const [id, e] of list) {
|
||||
if (e.left <= 0) continue;
|
||||
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, e.left]);
|
||||
out.push([id, ignoreCounts ? 1 : e.left]);
|
||||
}
|
||||
};
|
||||
scan(this.entries);
|
||||
@@ -145,7 +146,7 @@ export class SharedPool {
|
||||
* 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) {
|
||||
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;
|
||||
@@ -157,12 +158,12 @@ export class SharedPool {
|
||||
tier = 1 + Math.floor(rng() * Math.max(1, maxTier));
|
||||
}
|
||||
for (let t = tier; t >= 1; t--) {
|
||||
const list = this.gd.shopItemsByTier[t];
|
||||
if (list && list.length) return list[Math.floor(rng() * list.length)];
|
||||
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];
|
||||
if (list && list.length) return list[Math.floor(rng() * list.length)];
|
||||
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;
|
||||
}
|
||||
@@ -170,13 +171,51 @@ export class SharedPool {
|
||||
/** { baseId: left } snapshot (tests / diagnostics). */
|
||||
snapshot() {
|
||||
const o = {};
|
||||
for (const [id, e] of this.entries) o[id] = e.left;
|
||||
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 += e.left;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user