- 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.
257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
// server/match/match/spDraft.js — Match methods: the 机变 draft (SP_DRAFT: turn order, timers, AI picks, the card applied
|
|
// by choices.js applyCard), the personal bounty choice of the Art 教鞭 (offerBountyChoice / pickPersonalChoice /
|
|
// autoPickPersonalChoice: held by the player inside PREP — no phase, no timer of its own) and the rolls the meta effects
|
|
// ask for — bounties (addBounty), item ids (rollItemId) and choices.json pools (rollPool).
|
|
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
|
|
|
import { PHASE, ERR } from '../../../shared/constants.js';
|
|
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
|
|
import { onPickCard } from '../botEmotes.js';
|
|
import { weightedPick } from '../waves.js';
|
|
import { botPickCard } from '../bot.js';
|
|
import { OK, fail, DELAYS } from './common.js';
|
|
|
|
/** Cards of the personal choice (PRTS 卫戍协议:盟约 下半 §法术 教鞭: "于3个战术特训的悬赏任务中选择一项"). */
|
|
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), itemAvailable: id => this.itemPool.canGain(id) });
|
|
const alive = this.alivePlayers();
|
|
if (!draft || !alive.length) { this.enterPrep(); return; }
|
|
this.phase = PHASE.SP_DRAFT;
|
|
let order = alive.map((p) => p.playerId);
|
|
if (!this.isSolo) this.rngDraft.shuffle(order);
|
|
// 「AI 队友最后选择」 (GitHub #338): humans before AI seats, after the same shuffle (MatchPhases.humansFirst)
|
|
order = this.humansFirst(order);
|
|
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
|
const untimed = this.soloUntimed;
|
|
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
|
this.setDeadline(0);
|
|
this.startSpTurn();
|
|
this.markPublic();
|
|
}
|
|
|
|
spTurn() {
|
|
const s = this.sp;
|
|
if (!s) return null;
|
|
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);
|
|
this._turnTimer = null;
|
|
while (s.idx < s.order.length) {
|
|
const ps = this.players.get(s.order[s.idx]);
|
|
if (ps && ps.alive && s.picks[ps.playerId] == null) break;
|
|
s.idx++;
|
|
}
|
|
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) {
|
|
const first = s.idx === 0;
|
|
const secs = first ? this.gd.timer('spFirst') : this.gd.timer('spTurn');
|
|
this.setDeadline(secs, () => {
|
|
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken) return;
|
|
const ps = this.players.get(this.spTurn());
|
|
if (!ps) return;
|
|
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)]);
|
|
});
|
|
s.turnDeadline = this.deadline;
|
|
} else {
|
|
this.setDeadline(0);
|
|
}
|
|
const cur = this.players.get(this.spTurn());
|
|
if (cur && cur.botControlled) this.scheduleSpBot();
|
|
this.markPublic();
|
|
}
|
|
|
|
scheduleSpBot() {
|
|
const token = this._turnToken;
|
|
this.later(this.scaled(DELAYS.BOT_ACTION), () => {
|
|
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.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
|
if (!avail.length) return;
|
|
this._applyCard(ps, botPickCard(this, ps, this.sp.cards, avail));
|
|
});
|
|
}
|
|
|
|
pickCard(ps, idx) {
|
|
if (this.phase !== PHASE.SP_DRAFT || !this.sp) return fail(ERR.WRONG_PHASE);
|
|
if (!ps.alive) return fail(ERR.ELIMINATED);
|
|
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.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 || !this.spCardAvailable(s.cards[idx])) return;
|
|
const card = s.cards[idx];
|
|
if (!card) return;
|
|
s.picks[ps.playerId] = idx;
|
|
s.taken[idx] = ps.playerId;
|
|
try { applyCard(this, ps, card); } catch (e) { this.reportError(`applyCard ${card.id}`, e); }
|
|
this.markPrivate(ps);
|
|
this.markPublic();
|
|
onPickCard(this, ps, card); // AI bots say "思考" when they take a bounty (enabled by default; SP_BOT_EMOTES=0 silences it)
|
|
this.startSpTurn();
|
|
}
|
|
|
|
finishSpDraft() {
|
|
if (this.phase !== PHASE.SP_DRAFT) return;
|
|
this.cancel(this._turnTimer);
|
|
this._turnTimer = null;
|
|
this.enterPrep();
|
|
}
|
|
|
|
/**
|
|
* 教鞭 (content/items/meta.js, ctx.offerBountyChoice): the Art opens a PERSONAL choice (act2autochess choice event
|
|
* `hunter_band_1`, choiceType PERSONAL_CHOOSE) between up to three of `candidates` — drawn once from the meta rng, kept in
|
|
* `ps.personalChoice` (m.private only) until the owner confirms one with `g.choice { idx, choiceId }`. It lives inside
|
|
* PREP: no phase of its own and no timer — the prep's deadline resolves it (prepDeadline), Ready is refused while it is
|
|
* pending (PlayerPrep.setReady). Refused — before an rng draw or an id is used, so the Art stays in the hand — when it
|
|
* is not the player's PREP, a choice is already open or no card is left to offer.
|
|
* @param {import('../PlayerState.js').PlayerState} ps @param {any[]} candidates cards.bounty entries
|
|
* @param {string} sourceItemId the Art
|
|
*/
|
|
offerBountyChoice(ps, candidates, sourceItemId) {
|
|
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
|
if (!ps.alive) return fail(ERR.ELIMINATED);
|
|
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
|
|
if (ps.personalChoice) return fail(ERR.BAD_TARGET, '请先完成当前教鞭选择'); // i18n-ignore: developer error detail
|
|
if (!candidates.length) return fail(ERR.BAD_TARGET, '当前没有可用的战术特训'); // i18n-ignore: developer error detail
|
|
const cards = this.rngMeta.shuffle(candidates.slice()).slice(0, PERSONAL_OFFER_SIZE);
|
|
ps.personalChoice = { id: `${this.battlePrefix}.choice.${this.nextUid()}`, round: this.round, sourceItemId, cards };
|
|
ps.dirty();
|
|
return OK;
|
|
}
|
|
|
|
/** g.choice { idx, choiceId }: the owner's pick of its open personal choice — the card becomes a bounty on its next battles. */
|
|
pickPersonalChoice(ps, idx, choiceId) {
|
|
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
|
if (!ps.alive) return fail(ERR.ELIMINATED);
|
|
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
|
|
const pending = ps.personalChoice;
|
|
if (!pending || pending.id !== choiceId || pending.round !== this.round) return fail(ERR.BAD_TARGET);
|
|
if (!Number.isInteger(idx) || idx < 0 || idx >= pending.cards.length) return fail(ERR.BAD_TARGET);
|
|
if (!this.addBounty(ps, pending.cards[idx])) return fail(ERR.BAD_TARGET);
|
|
ps.personalChoice = null;
|
|
ps.dirty();
|
|
return OK;
|
|
}
|
|
|
|
/**
|
|
* The pick made for a player who did not make it. 'bot': an AI seat or a human under AI 托管 — the bounty scorer of the
|
|
* 机变 draft (bot.js botPickCard: the expected pay minus the expected LP lost against the own board, the bots' seeded rng
|
|
* only breaks ties); 'random': the prep's deadline and a seat the engine finishes by other means — one of the cards at
|
|
* random from the meta rng, like a 机变 turn that runs out. Nothing pending: nothing happens, no rng draw.
|
|
*/
|
|
autoPickPersonalChoice(ps, mode) {
|
|
const pending = ps.personalChoice;
|
|
if (!pending) return undefined;
|
|
const indices = pending.cards.map((c, i) => i);
|
|
const idx = mode === 'bot'
|
|
? botPickCard(this, ps, pending.cards.map((c) => bountyCard(this.gd, c)), indices)
|
|
: this.rngMeta.pick(indices);
|
|
return this.pickPersonalChoice(ps, idx, pending.id);
|
|
}
|
|
|
|
addBounty(ps, card) {
|
|
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
|
|
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
|
|
const rounds = bountyBattles(card);
|
|
const b = {
|
|
id: `bounty:${this.nextUid()}`,
|
|
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) }, // i18n-ignore: a data-less card's fallback name
|
|
roundsLeft: rounds,
|
|
};
|
|
ps.bounties.push(b);
|
|
ps.dirty();
|
|
return b.id;
|
|
}
|
|
|
|
/** Random item id: a choices.json server pool ({ pool }), a tier, or ≤ maxTier shop-eligible items. */
|
|
rollItemId({ pool = null, tier = null, maxTier = 6, shopLevel = 6 } = {}) {
|
|
const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {};
|
|
const p = typeof pool === 'string' && Object.hasOwn(pools, pool) ? pools[pool] : null;
|
|
const rng = this.rngMeta;
|
|
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]) && 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 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 this.itemPool.pick(rng, list);
|
|
}
|
|
const list = Number.isInteger(tier) ? tierList(tier, tier) : tierList(1, Math.max(1, Math.min(6, maxTier)));
|
|
return this.itemPool.pick(rng, list);
|
|
}
|
|
|
|
/**
|
|
* 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, 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;
|
|
const lvl = Math.max(1, Math.min(6, Number.isInteger(shopLevel) ? shopLevel : 6));
|
|
if (p.kind === 'equip') {
|
|
const id = this.rollItemId({ pool: poolId, shopLevel: lvl });
|
|
return id ? { kind: 'item', id } : null;
|
|
}
|
|
if (p.kind !== 'chess') return null;
|
|
const rng = this.rngMeta;
|
|
const free = (id) => {
|
|
if (typeof id !== 'string' || !this.gd.chess(id)) return false;
|
|
const base = this.gd.baseIdOf(id);
|
|
return !this.pool.has(base) || this.pool.left(base) > 0;
|
|
};
|
|
let id = null;
|
|
if (Array.isArray(p.weighted) && p.weighted.length) {
|
|
const pairs = p.weighted.filter((x) => Array.isArray(x) && free(x[0]));
|
|
id = pairs.length ? weightedPick(rng, pairs) : null;
|
|
} else if (Array.isArray(p.items) && p.items.length) {
|
|
const list = p.items.filter(free);
|
|
id = list.length ? list[Math.floor(rng() * list.length)] : null;
|
|
} else {
|
|
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(recOf(cid)?.bonds) && recOf(cid).bonds.includes(bond))),
|
|
extra,
|
|
});
|
|
}
|
|
if (!id) return null;
|
|
const golden = !!p.golden;
|
|
return { kind: 'chess', id: golden ? this.gd.goldenIdOf(id) || id : id, golden };
|
|
}
|
|
}
|