Applied the host-only room option 「AI 队友最后选择」, default off. Off, the draft order and the random streams stay as they are. On, humans draft before AI seats after the existing shuffle, and a skip stays ahead of the AI seats. Dropped docs/history/0.2.2.md and every §27 citation. The player-facing sentence is in docs/PLAYING.md. The room-option rule is in docs/design/match.md, docs/design/client.md and docs/design/network.md. The #338 per-bot faction and ban presets are not included. Tests: node --test test/lobby-ai-last.test.js test/match/ai-picks-last.test.js test/ui/ai-picks-last.test.js — 18 pass. node --test test/lobby.test.js test/client-static.test.js — 378 pass. node tools/i18n.mjs check --all --strict — 1032/1032 in en, ja, ko and zh-TW, 0 errors.
201 lines
9.6 KiB
JavaScript
201 lines
9.6 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) 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 } from '../choices.js';
|
|
import { weightedPick } from '../waves.js';
|
|
import { botPickCard } from '../bot.js';
|
|
import { OK, fail, DELAYS } from './common.js';
|
|
|
|
export class MatchSpDraft {
|
|
enterSpDraft() {
|
|
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
|
|
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;
|
|
}
|
|
|
|
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.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
|
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.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
|
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.map((c) => c.idx).filter((i) => this.sp.taken[i] == null);
|
|
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.sp.taken[idx] != null) 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;
|
|
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();
|
|
this.startSpTurn();
|
|
}
|
|
|
|
finishSpDraft() {
|
|
if (this.phase !== PHASE.SP_DRAFT) return;
|
|
this.cancel(this._turnTimer);
|
|
this._turnTimer = null;
|
|
this.enterPrep();
|
|
}
|
|
|
|
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]));
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
}
|