Files
ark-stronghold-protocol/server/match/choices.js
T
sganggs 8ac1aaadbb feedback1 WB: bounty drafts follow the official R3 / R9 / R11 drafts (33 screenshots of 11 official matches)
R3 offers one of the official sets of six "接下来两场作战" cards (3 x I, 2 x II, 1 x III):
the 7 sets seen, the 3 unseen slots built by their rule. R9 offers a seen group of
named bosses filled to 6 with the cheap boss bounties and 源石虫·特训. R11 of 绝境 / 终极
is 悬赏决策, 机密商店 or 战术决策 (never 道具补给); its bounty is one 特异III giant and 5
"下场战斗" cards, one per faction series. Cards no official draft showed (the
multi-round cards, enemyeffect_3_*, five boss bounties) are no longer drafted.
The screenshot readings are a test fixture; tests check every reading against the
data and every generated draft against the official structure.
2026-10-03 04:43:44 +08:00

398 lines
21 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/choices.js — 机变 (SP draft) card generation and application (DESIGN §6.1, research 01 A4,
// data/choices.json).
//
// Generation (generateDraft): the family is a weighted pick from choices.schedule[modeId].rounds[r].families; the card
// count is `cards` (co-op 6, solo 3):
// bounty 悬赏决策 six distinct cards.bounty entries (solo: 3 of them) built like the official draft of the round
// (`bountyDraftCards`; player feedback after 0.1.0, report #2 — late bounty enemies in the early
// drafts; 33 official screenshots, tools/build-data.mjs BOUNTY_INITIAL_SETS): schedule `bountyDraft`
// names the kind and choices.json `bountyDrafts[kind]` its structure —
// initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of "接下来两场作战" cards — a seen
// set, or for an unseen slot one card from each of 6 of the series 10–15 / 20 with the
// tiers I I I II II III;
// boss (R9): a seen group of named bosses + the group's cheap boss bounties / 源石虫·特训 to 6;
// hunter (R11): one 特异III giant + 5 "下场战斗" cards, at most one per faction series;
// only `draft` cards of that `draftPool` (never 战术特训 — 法术教鞭 only —, the 鸭爵 set, the 7
// multi-round cards or the pre-series cards: no official draft shows them); positions shuffled; the
// mode's inactive enemy list does not apply (PRTS 卫戍协议:盟约 11/18 note "以上调整仅针对战术特训敌人,
// 不影响悬赏决策出场"); each card carries its official rich text `descRaw` (the battles in blue
// "下场作战" / "两场作战"); a multi-round card (教鞭's 法术大师A2·多轮战术特训) lasts
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
// after playtest #6 — "我不记得有过多轮悬赏")
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
// tactic 战术决策 distinct cards.tactic entries; terrain cards only for the match stage; a 驰援 card
// (single_special_choice_gain_bond_chess) or a 盟誓 card (global_special_choice_bond_addlayer)
// only while at least one of its bonds is live in this match (opts.bondAvailable = Match.bondLive:
// not in the mode's static inactive list — 标准 turns off 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … —
// and still with chess in the pool); a card acting only on dead bonds would do nothing
// Application (applyCard): a registered `choice:<effectId>` handler (content) wins; otherwise the family default:
// bounty → the picker's bounty list (waves.js adds the enemies to the next `rounds` battles; kill payout to the
// killer via the Battle, perfect payout at settlement when the picker's own battle was perfect)
// supply / shop → the item goes to the picker's hand (overflow temp)
// tactic → by effect buff key (data/effects.json):
// global_special_choice_gain_equip {count,pool} `count` items from the server pool
// global_special_choice_bond_addlayer {count,bond_list} layers +count to each bond (无需激活)
// global_special_choice_gain_coin {count} funds
// global_special_choice_refresh_free {count} free refreshes (stack)
// single_special_choice_gloden_equip_chess {count} next `count` bought items become golden (EffectRef)
// single_special_choice_gloden_char_chess {count} next `count` bought operators become elite (EffectRef)
// single_special_choice_gain_bond_chess {count,bond} `count` random chess of the bond (≤ shop level)
// auto_chess_change_map {alias: 0|1} device toggles on the picker's board (deploy legality) + battle
// anything else (env_gbuff…, enemy_attribute_mul…) a battle EffectRef for the sim content (playerEffects)
// cards with `team: true` apply to the picker AND every alive teammate ("若存在其他队友则他们也获得").
import { weightedPick } from './waves.js';
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
/**
* Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场</>作战":
* 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, 法术大师A2·多轮战术特训). The user does not remember any multi-round bounty
* (playtest #6 answer, "我不记得有过多轮悬赏"): until that is confirmed otherwise every such card lasts two battles,
* exactly like the "接下来两场作战" cards, and its text says so in the same blue (`bountyText`). `null` restores the
* official "每场" (every later battle, red text) everywhere: the draft, 教鞭 / 神秘顾客, the bounty list and the effects
* column (DESIGN §20).
*/
export const MULTI_ROUND_BOUNTY_BATTLES = 2;
/** A multi-round bounty card ("之后 / 后续的每场作战"; choices.json `multiRound`, or data `rounds` ≥ 90). */
export const isMultiRoundBounty = (c) => !!c && (c.multiRound === true || Number(c.rounds) >= 90);
/** The battles a bounty card's enemies come for (MULTI_ROUND_BOUNTY_BATTLES for a multi-round card), 1–99. */
export function bountyBattles(c) {
if (isMultiRoundBounty(c) && Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return MULTI_ROUND_BOUNTY_BATTLES;
const r = Number(c && c.rounds);
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1));
}
const N_ZH = ['', '一', '两', '三', '四', '五'];
/**
* A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场</>` or plain) reads
* "接下来<@ba.vup>两场作战</>" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other
* text is returned as is.
*/
export function bountyText(text, c) {
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
const n = MULTI_ROUND_BOUNTY_BATTLES;
const battles = `${N_ZH[n] || n}场作战`;
return text
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
}
function scheduleFor(gd, round) {
const sch = gd.choices.schedule && gd.choices.schedule[gd.modeId];
const r = sch && sch.rounds && sch.rounds[String(round)];
if (r && typeof r === 'object') return r;
// fallback: a supply draft
return { families: [{ family: 'supply', weight: 1 }], cards: gd.isSolo ? 3 : 6, supplyTiers: [1, Math.min(6, 1 + Math.floor(round / 3))] };
}
/**
* The kind of official 悬赏决策 a round has when its schedule does not say (`bountyDraft`): R3 'initial', R9 'boss',
* R11 'hunter' (choices.json bountyDrafts; player feedback after 0.1.0, report #2).
*/
export function bountyDraftKind(round) {
const r = Number(round);
return r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter';
}
function formatCount(gd) {
const f = gd.choices.format || {};
const multi = f.multi && Number.isInteger(f.multi.cards) ? f.multi.cards : 6;
const solo = f.solo && Number.isInteger(f.solo.cards) ? f.solo.cards : 3;
return gd.isSolo ? solo : multi;
}
function eligibleItems(gd, lo, hi) {
const out = [];
for (let t = lo; t <= hi; t++) for (const id of gd.shopItemsByTier[t] || []) out.push(id);
return out;
}
function itemCard(gd, id) {
const it = gd.item(id);
return { kind: 'item', id, name: it ? it.name : id, desc: it ? it.desc || '' : '', tier: it && Number.isInteger(it.tier) ? it.tier : 1, price: 0 };
}
/**
* 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 } = {}) {
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 };
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;
cards.forEach((c, i) => { c.idx = i; c.family = family; });
const famInfo = gd.choices.families && gd.choices.families[family];
const events = sch.events && Array.isArray(sch.events[family]) ? sch.events[family] : [];
const eventId = events.length ? events[Math.floor(rng() * events.length)] : null;
return { family, name: famInfo && famInfo.name ? famInfo.name : FAMILY_NAMES[family] || family, desc: famInfo && famInfo.desc ? famInfo.desc : '', eventId, cards };
}
/** Bond granted by a 驰援 tactic card (effect buff single_special_choice_gain_bond_chess), else null. */
export function reinforcementBond(gd, effectId) {
const eff = gd.effect(effectId);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs : [];
const b = buffs.find((x) => x && x.key === 'single_special_choice_gain_bond_chess');
return b && b.bbStr && typeof b.bbStr.bond === 'string' ? b.bbStr.bond : null;
}
/**
* Bonds a tactic card only acts on: the 驰援 bond (single_special_choice_gain_bond_chess) and the bonds of a 盟誓 card
* (global_special_choice_bond_addlayer bond_list). null when the card does anything else (it is always useful).
*/
export function cardTargetBonds(gd, effectId) {
const eff = gd.effect(effectId);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs.filter(Boolean) : [];
if (!buffs.length) return null;
const out = [];
for (const b of buffs) {
const bs = b.bbStr || {};
if (b.key === 'single_special_choice_gain_bond_chess' && typeof bs.bond === 'string') out.push(bs.bond);
else if (b.key === 'global_special_choice_bond_addlayer') out.push(...String(bs.bond_list || '').split(',').map((x) => x.trim()).filter(Boolean));
else return null;
}
return out.length ? out : null;
}
/**
* A bounty card the 悬赏决策 draft may offer (choices.json cards.bounty `draft`, tools/build-data.mjs
* bountyDraftExclusion: no 战术特训, no 鸭爵 set — user playtest #6 item 4 —, nothing no official draft showed — player
* feedback #2). Data without the flag (older builds, test fixtures) falls back to the kill bounties. With `kind`
* ('initial' | 'boss' | 'hunter') the card must also belong to that kind of draft (choices.json `draftPool`; a card
* without one — test fixtures — fits every kind).
*/
export function draftBounty(c, kind = null) {
if (!c) return false;
if (kind != null && typeof c.draftPool === 'string' && c.draftPool !== kind) return false;
if (typeof c.draft === 'boolean') return c.draft;
return c.payout !== 'perfect';
}
/** The draft card of a cards.bounty entry (the official rich text, a multi-round card's battles and text rewritten). */
export function bountyCard(gd, c) {
const eff = typeof gd.effect === 'function' ? gd.effect(c.effectId) : null;
return {
kind: 'bounty', id: c.effectId, name: c.name, desc: bountyText(c.desc || '', c), descRaw: bountyText((eff && eff.descRaw) || null, c), tier: c.tier, coin: c.coin,
payout: c.payout, rounds: bountyBattles(c), enemyKey: c.enemyKey, count: c.count,
};
}
/** Up to `k` cards of `list` not in `taken`, at most one per series of `onePer` (a Set), in a shuffled order. */
function drawDistinct(rng, list, k, taken, onePer = null) {
const out = [];
const used = new Set([...taken].map((c) => c.series));
for (const c of rng.shuffle(list.slice())) {
if (out.length >= k) break;
if (taken.has(c)) continue;
if (onePer && onePer.has(c.series) && used.has(c.series)) continue;
out.push(c);
taken.add(c);
used.add(c.series);
}
return out;
}
/**
* The cards of a 悬赏决策 of kind `kind` after its official structure (choices.json `bountyDrafts[kind]`, see the module
* header), over the eligible cards `byId` (effectId → cards.bounty entry). Ids the eligible list lacks are skipped.
*/
function structuredBounty(rng, kind, spec, byId) {
const get = (ids) => (Array.isArray(ids) ? ids : []).map((id) => byId.get(id)).filter(Boolean);
const taken = new Set();
const count = Number.isInteger(spec.count) ? spec.count : 6;
if (kind === 'initial') {
const sets = Array.isArray(spec.sets) ? spec.sets : [];
const slot = rng.int(Math.max(sets.length, Number.isInteger(spec.slots) ? spec.slots : 0));
if (slot < sets.length) return get(sets[slot].cards).slice(0, count);
// an unseen official set: one card from each of `count` series, with the seen sets' tier mix
const mx = spec.mixed || {};
const series = rng.shuffle((mx.series || []).slice()).slice(0, mx.count || count);
const tiers = rng.shuffle((mx.tiers || []).slice());
const all = [...byId.values()];
const out = [];
series.forEach((s, i) => {
const ofSeries = all.filter((c) => c.series === s);
const want = ofSeries.filter((c) => c.tier === tiers[i]);
const c = rng.pick(want.length ? want : ofSeries);
if (c) out.push(c);
});
return out;
}
if (kind === 'boss') {
const t = rng.pick(Array.isArray(spec.templates) ? spec.templates : []);
if (!t) return [];
const featured = get(t.featured);
featured.forEach((c) => taken.add(c));
return [...featured, ...drawDistinct(rng, get(t.fill), count - featured.length, taken)].slice(0, count);
}
if (kind === 'hunter') {
const onePer = new Set(Array.isArray(spec.onePerSeries) ? spec.onePerSeries : []);
const anchors = drawDistinct(rng, get(spec.anchors), Number.isInteger(spec.anchorCount) ? spec.anchorCount : 1, taken);
return [...anchors, ...drawDistinct(rng, get(spec.rest), count - anchors.length, taken, onePer)];
}
return [];
}
/**
* Build `n` 悬赏决策 cards for `round` (module header): the official structure of the round's kind, topped up with other
* eligible cards of that kind when it falls short (test fixtures with a restricted card list), shuffled into place —
* the official card positions vary — and cut to `n` (solo 3 [ASSUMED]).
*/
function bountyDraftCards(gd, rng, n, sch, round) {
const kind = typeof sch.bountyDraft === 'string' ? sch.bountyDraft : bountyDraftKind(round);
const all = Array.isArray(gd.choices.cards && gd.choices.cards.bounty) ? gd.choices.cards.bounty : [];
const eligible = all.filter((c) => c && draftBounty(c, kind) && gd.enemy(c.enemyKey));
const byId = new Map(eligible.map((c) => [c.effectId, c]));
const spec = gd.choices.bountyDrafts && gd.choices.bountyDrafts[kind];
const out = spec ? structuredBounty(rng, kind, spec, byId) : [];
const taken = new Set(out);
if (out.length < n) out.push(...drawDistinct(rng, eligible, n - out.length, taken));
return rng.shuffle(out).slice(0, n).map((c) => bountyCard(gd, c));
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
const cardsData = gd.choices.cards || {};
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
if (family === 'supply' || family === 'shop') {
let lo = 1;
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 (!list.length) list = eligibleItems(gd, 1, 6);
const out = [];
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
return out;
}
if (family === 'tactic') {
// a card whose every target bond is dead in this match (mode-inactive, or no chess left in the pool) does nothing
const bondOk = (c) => {
if (typeof bondAvailable !== 'function') return true;
const bonds = cardTargetBonds(gd, c.effectId);
return !bonds || bonds.some((b) => !!bondAvailable(b));
};
const pool = (Array.isArray(cardsData.tactic) ? cardsData.tactic : []).filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
const pick = pool.slice();
rng.shuffle(pick);
return pick.slice(0, n).map((c) => ({ kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind }));
}
return [];
}
/** Public card view. */
export function cardView(c) {
const v = { idx: c.idx, kind: c.kind, id: c.id, name: c.name, desc: c.desc, tier: c.tier ?? null };
if (c.kind === 'bounty') Object.assign(v, { descRaw: c.descRaw ?? null, coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
if (c.kind === 'item') v.price = 0;
if (c.kind === 'tactic') Object.assign(v, { team: c.team, tacticKind: c.tacticKind });
return v;
}
/**
* Apply a picked card to a player (registry override → family default). Team tactic cards are applied to every
* alive teammate as well.
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
*/
export function applyCard(m, ps, card) {
const targets = card.kind === 'tactic' && card.team ? [ps, ...m.alivePlayers().filter((p) => p !== ps)] : [ps];
for (const p of targets) {
const ev = { card: { ...card }, family: card.family, picker: ps.playerId, forTeammate: p !== ps };
const handled = m.dispatcher.runKey(p, `choice:${card.id}`, 'onChoicePick', { kind: 'choice', card }, ev);
if (!handled) applyDefault(m, p, card);
// every other source observes the pick (the card's own key already ran above)
m.dispatch(p, 'onChoicePick', ev, { skipKey: `choice:${card.id}` });
p.recompute();
}
}
function applyDefault(m, ps, card) {
const gd = m.gd;
if (card.kind === 'bounty') {
const src = (gd.choices.cards && Array.isArray(gd.choices.cards.bounty) ? gd.choices.cards.bounty : []).find((c) => c.effectId === card.id) || card;
m.addBounty(ps, src);
return;
}
if (card.kind === 'item') {
ps.acquireItem(card.id, { source: 'choice' });
return;
}
if (card.kind !== 'tactic') return;
const eff = gd.effect(card.id);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs : [];
let handled = false;
let battle = false;
for (const b of buffs) {
const bb = b.bb || {};
const bs = b.bbStr || {};
const count = Number.isInteger(bb.count) ? bb.count : 1;
switch (b.key) {
case 'global_special_choice_gain_equip': {
for (let i = 0; i < count; i++) {
const id = m.rollItemId({ pool: bs.pool });
if (id) ps.acquireItem(id, { source: 'choice' });
}
handled = true;
break;
}
case 'global_special_choice_bond_addlayer': {
const bonds = String(bs.bond_list || '').split(',').map((s) => s.trim()).filter(Boolean);
for (const bid of bonds) ps.addLayers(bid, count, { requireActive: false, reason: 'choice' });
handled = true;
break;
}
case 'global_special_choice_gain_coin':
ps.addFunds(count, { reason: 'choice' });
handled = true;
break;
case 'global_special_choice_refresh_free':
ps.shop.freeRefreshes += count;
ps.dirty();
handled = true;
break;
case 'single_special_choice_gloden_equip_chess':
addRef(ps, card, eff, 'effect:builtin_next_buy_golden_item', { counter: count, battle: false });
handled = true;
break;
case 'single_special_choice_gloden_char_chess':
addRef(ps, card, eff, 'effect:builtin_next_buy_elite', { counter: count, battle: false });
handled = true;
break;
case 'single_special_choice_gain_bond_chess': {
for (let i = 0; i < count; i++) {
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } })
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } });
if (id) ps.acquireChess(id, { source: 'choice' });
}
handled = true;
break;
}
case 'auto_chess_change_map': {
for (const [alias, v] of Object.entries(bb)) if (alias.includes('#')) ps.deviceOverrides[alias] = Number(v) !== 0;
ps.invalidateDeployMap();
battle = true;
break;
}
case 'global_special_choice_all_activated':
break;
default:
battle = true;
}
}
if (battle || !handled) addRef(ps, card, eff, `choice:${card.id}`, { battle: true });
}
function addRef(ps, card, eff, key, { counter = null, battle = true } = {}) {
const id = `${card.id}#${ps.m.nextUid()}`;
ps.effects.push({
id, key, name: card.name, desc: card.desc, iconKind: card.team ? 'team' : 'choice', iconId: eff && eff.decoIconId ? eff.decoIconId : card.id,
counter, battle, params: eff && eff.params ? { ...eff.params } : {}, data: { effectId: card.id, tacticKind: card.tacticKind ?? null },
});
ps.dirty();
}