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.
This commit is contained in:
1 parent
6e0c26af9e
commit
8ac1aaadbb
9 files changed
+542
-241
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
+109
-36
@@ -3,18 +3,20 @@
|
||||
//
|
||||
// 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 悬赏决策 distinct cards.bounty entries whose enemy exists, draft cards only (`draftBounty`, choices.json
|
||||
// `draft`: the PRTS 下半 记录 §机变阶段 "敌人轮选" table — never 战术特训, which only 法术教鞭 creates,
|
||||
// nor the hidden 鸭爵 set; the 7 multi-round "之后 / 后续的每场作战" cards stay in, as the table lists
|
||||
// them) of the draft's half (`bountyDraftHalf`, choices.json `draftHalf`; player feedback after
|
||||
// 0.1.0, report #2 — late bounty enemies in the early drafts): rounds ≤ 7 (the official wave
|
||||
// generator's first half: R3, 险境 R6) offer the "接下来两场作战" cards, R8 on (R9) the "下场作战" cards
|
||||
// (boss bounties, 特异 giants, the faction _7 / _8 cards …) and the multi-round ones ([ASSUMED] beyond
|
||||
// the evidence in tools/build-data.mjs bountyDraftHalf, which also lists the counter-evidence);
|
||||
// drawn uniformly (no tier window: the official late draft shows boss bounties worth 1 to 6
|
||||
// together); 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 lasts
|
||||
// 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)
|
||||
@@ -41,7 +43,7 @@
|
||||
// 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, firstHalfMax } from './waves.js';
|
||||
import { weightedPick } from './waves.js';
|
||||
|
||||
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
|
||||
|
||||
@@ -86,12 +88,12 @@ function scheduleFor(gd, round) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of the match a 悬赏决策 draft of `round` belongs to: 1 up to the official wave generator's first-half end
|
||||
* (waves.js firstHalfMax, 7), else 2. Its cards are the cards.bounty entries with that `draftHalf` (player feedback
|
||||
* after 0.1.0, report #2).
|
||||
* 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 bountyDraftHalf(round, gd = null) {
|
||||
return Number(round) <= firstHalfMax(gd) ? 1 : 2;
|
||||
export function bountyDraftKind(round) {
|
||||
const r = Number(round);
|
||||
return r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter';
|
||||
}
|
||||
|
||||
function formatCount(gd) {
|
||||
@@ -160,32 +162,103 @@ export function cardTargetBonds(gd, effectId) {
|
||||
|
||||
/**
|
||||
* A bounty card the 悬赏决策 draft may offer (choices.json cards.bounty `draft`, tools/build-data.mjs
|
||||
* bountyDraftExclusion: the PRTS "敌人轮选" table — no 战术特训, no 鸭爵 set; user playtest #6 item 4). Data without the
|
||||
* flag (older builds, test fixtures) falls back to the kill bounties. With `half` (1 | 2, bountyDraftHalf) the card must
|
||||
* also belong to that half (choices.json `draftHalf`; a card without one — test fixtures — fits both).
|
||||
* 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, half = null) {
|
||||
export function draftBounty(c, kind = null) {
|
||||
if (!c) return false;
|
||||
if (half != null && Number.isInteger(c.draftHalf) && c.draftHalf !== half) 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') {
|
||||
const half = sch.bountyHalf === 1 || sch.bountyHalf === 2 ? sch.bountyHalf : bountyDraftHalf(round, gd);
|
||||
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && draftBounty(c, half) && gd.enemy(c.enemyKey));
|
||||
const pick = pool.slice();
|
||||
rng.shuffle(pick);
|
||||
return pick.slice(0, n).map((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,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
|
||||
if (family === 'supply' || family === 'shop') {
|
||||
let lo = 1;
|
||||
let hi = 6;
|
||||
|
||||
@@ -122,9 +122,7 @@ function placeholderMap(gd) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Last round of the official generator's first half (factions.generation.firstHalfMaxRound: maxLevelCnt / 2 = 7); also
|
||||
* the half of the 悬赏决策 drafts (choices.js bountyDraftHalf). */
|
||||
export const firstHalfMax = (gd) => intOr(gd && gd.factions ? gen(gd).firstHalfMaxRound : null, Math.floor(intOr(gd && gd.factions ? gen(gd).maxLevelCnt : null, 15) / 2));
|
||||
const firstHalfMax = (gd) => intOr(gen(gd).firstHalfMaxRound, Math.floor(intOr(gen(gd).maxLevelCnt, 15) / 2));
|
||||
|
||||
/** The special entry list (factions.json entries) sorted by key. */
|
||||
function entryList(gd) {
|
||||
|
||||
@@ -27,9 +27,9 @@ function dataWith(modeId, round, family, { tactic = null, bounty = null, cards =
|
||||
const out = { ...DATA, choices: { ...ch, schedule: { ...ch.schedule, [modeId]: { ...sch, rounds: { ...sch.rounds, [String(round)]: r } } }, cards: { ...ch.cards } } };
|
||||
if (tactic) out.choices.cards.tactic = ch.cards.tactic.filter((c) => tactic.includes(c.effectId));
|
||||
// the listed bounty cards are offered by this draft even when the real one would not (战术特训 comes from 法术教鞭
|
||||
// only — choices.json `draft`, user playtest #6 item 4; a "下场作战" card belongs to the drafts from R8 on —
|
||||
// `draftHalf`, player feedback #2): these tests drive the payouts through the draft
|
||||
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map((c) => ({ ...c, draft: true, draftHalf: null }));
|
||||
// only — choices.json `draft`, user playtest #6 item 4; each card belongs to one kind of official draft —
|
||||
// `draftPool`, player feedback #2; a card without one fits every draft): these tests drive the payouts through the draft
|
||||
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map(({ draftPool, ...c }) => ({ ...c, draft: true }));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"_doc": "Official 机变 drafts at the start of rounds 3, 9 and 11, read from screenshots of 11 official co-op matches collected by the user (2026-10-03; player names left out). The cards are in screen order (left to right, top to bottom). A bounty card is [title, enemy as printed, coins, battles] (battles 2 = 接下来两场作战, 1 = 下场作战 / 下场战斗; 源石虫·特训 pays 0); a 机密商店 card is [item, tier]; a 战术决策 card is [name]. Names are converted to Simplified Chinese. Matches 1 and 2 come from the Traditional-Chinese client, 3-11 from Simplified-Chinese video captures. Every match is a 4-player co-op match of 绝境 or 终极 (the modes whose SP rounds are 3, 9 and 11; the HUD does not say which). Used by test/match/feedback1-bounty.test.js.",
|
||||
"season": "act2autochess",
|
||||
"aliases": { "深池重甲卫队队长": "深池重甲卫士队长" },
|
||||
"matches": {
|
||||
"1": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["“庞贝”·悬赏", "“庞贝”", 2, 1], ["喷气人·悬赏", "喷气人", 3, 1], ["纠缠藤蔓·悬赏", "纠缠藤蔓", 3, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["W·悬赏", "W", 1, 1], ["源石虫·特训", "源石虫", 0, 1]] },
|
||||
"11": { "family": "bounty", "cards": [["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·折射II", "深池重甲卫士队长", 2, 1], ["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·飞行I", "法术大师A2", 1, 1]] }
|
||||
},
|
||||
"2": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·特异I", "节日气球", 1, 2], ["悬赏·特异II", "咸鳞汁推荐者", 2, 2], ["悬赏·特异I", "简饲源石虫", 1, 2], ["悬赏·损伤III", "反派阵营角色", 3, 2], ["悬赏·折射I", "深池侦察犬pro", 1, 2], ["悬赏·飞行II", "吉兆飞鳞", 2, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["源石虫·特训", "源石虫", 0, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["泥岩·悬赏", "泥岩", 4, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["“庞贝”·悬赏", "“庞贝”", 2, 1], ["澪·悬赏", "澪", 3, 1]] },
|
||||
"11": { "family": "shop", "cards": [["变形同构体", 6], ["护盾无人机", 4], ["骑士储蓄罐", 3], ["盟约之币", 1], ["天马之盔", 5], ["天师古鼎", 6]] }
|
||||
},
|
||||
"3": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·特异I", "高能源石虫", 1, 2], ["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异II", "寻仇者", 2, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["腐败骑士·悬赏", "腐败骑士", 3, 1], ["“墓碑”·悬赏", "“墓碑”", 4, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["“巨大的丑东西”·悬赏", "“巨大的丑东西”", 5, 1]] },
|
||||
"11": { "family": "bounty", "cards": [["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·特异III", "狂暴宿主组长", 3, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·频次I", "沏虹", 1, 1]] }
|
||||
},
|
||||
"4": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤II", "控潮术师", 2, 2], ["悬赏·折射I", "深池方阵战士", 1, 2], ["悬赏·持续III", "萨卡兹枯朽战车", 3, 2], ["悬赏·飞行II", "暴鸰", 2, 2], ["悬赏·特异I", "潜伏者", 1, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["弑君者·悬赏", "弑君者", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["W·悬赏", "W", 1, 1], ["“庞贝”·悬赏", "“庞贝”", 2, 1], ["源石虫·特训", "源石虫", 0, 1]] },
|
||||
"11": { "family": "shop", "cards": [["双模机械臂", 5], ["人事部文档", 6], ["盟约之币", 1], ["家族徽章", 6], ["商业包装方案", 5], ["盟约之币", 1]] }
|
||||
},
|
||||
"5": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "高能源石虫", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "圣堂剑士", 2, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["喷气人·悬赏", "喷气人", 3, 1], ["纠缠藤蔓·悬赏", "纠缠藤蔓", 3, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["W·悬赏", "W", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["源石虫·特训", "源石虫", 0, 1]] },
|
||||
"11": { "family": "shop", "cards": [["铳骑之威", 6], ["博士投影", 5], ["盟约之币", 1], ["变形同构体", 6], ["寻呼模块", 4], ["盟约之币", 1]] }
|
||||
},
|
||||
"6": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·损伤III", "术师快艇", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·频次II", "烹泉", 2, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·频次I", "俗心", 1, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["“邪魔的利刃”·悬赏", "“邪魔的利刃”", 3, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["萨卡兹百夫长·悬赏", "萨卡兹百夫长", 2, 1], ["W·悬赏", "W", 1, 1], ["“复仇者”·悬赏", "“复仇者”", 6, 1], ["弑君者·悬赏", "弑君者", 1, 1]] },
|
||||
"11": { "family": "bounty", "cards": [["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1]] }
|
||||
},
|
||||
"7": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·特异II", "水遁忍者", 2, 2], ["悬赏·特异I", "雪孩子", 1, 2], ["悬赏·特异II", "骇笑看客", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·特异I", "冒失的小弟", 1, 2], ["悬赏·特异I", "新手祭司学徒", 1, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["W·悬赏", "W", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["弑君者·悬赏", "弑君者", 1, 1]] },
|
||||
"11": { "family": "tactic", "cards": [["补给"], ["补给"], ["谢拉格驰援"], ["列装"], ["莫斯提马的盟誓"], ["升华"]] }
|
||||
},
|
||||
"8": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·折射I", "深池方阵战士", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次II", "烹泉", 2, 2], ["悬赏·特异II", "“独轮车玩具”", 2, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·隐匿III", "山海众头目", 3, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["弑君者·悬赏", "弑君者", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["W·悬赏", "W", 1, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
|
||||
"11": { "family": "shop", "cards": [["变形同构体", 6], ["盟约之币", 1], ["商业包装方案", 5], ["变形同构体", 6], ["天马之盔", 5], ["双模机械臂", 5]] }
|
||||
},
|
||||
"9": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "高能源石虫", 1, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·损伤I", "临时收音师", 1, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["“鼠王”·悬赏", "“鼠王”", 2, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["源石虫·特训", "源石虫", 0, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["W·悬赏", "W", 1, 1]] },
|
||||
"11": { "family": "tactic", "cards": [["锐利"], ["升华"], ["财富"], ["银灰的盟誓"], ["阿戈尔驰援"], ["列装"]] }
|
||||
},
|
||||
"10": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·特异I", "潜伏者", 1, 2], ["悬赏·折射I", "深池方阵战士", 1, 2], ["悬赏·损伤II", "控潮术师", 2, 2], ["悬赏·飞行II", "暴鸰", 2, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·持续III", "萨卡兹枯朽战车", 3, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["W·悬赏", "W", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["“鼠王”·悬赏", "“鼠王”", 2, 1]] },
|
||||
"11": { "family": "bounty", "cards": [["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·特异III", "萨卡兹骸骨拷打者", 3, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·折射II", "愤怒的守墓石像", 2, 1]] }
|
||||
},
|
||||
"11": {
|
||||
"3": { "family": "bounty", "cards": [["悬赏·持续I", "萨卡兹枯朽战士组长", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次III", "清明", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·隐匿II", "隐形术师", 2, 2]] },
|
||||
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1]] },
|
||||
"11": { "family": "bounty", "cards": [["悬赏·特异II", "拥霜羽兽", 2, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·特异III", "乌顶巨角卢鲁", 3, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1]] }
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"bahamut-12294": {
|
||||
"_doc": "The co-op 悬赏决策 screenshot of Bahamut thread 33651/12294 (research 06 §15, image Wrkhtxv); team LP 6 / 10 / 9 / 15, round not shown (a boss draft: R9).",
|
||||
"family": "bounty",
|
||||
"cards": [["“复仇者”·悬赏", "“复仇者”", 6, 1], ["“庞贝”·悬赏", "“庞贝”", 2, 1], ["W·悬赏", "W", 1, 1], ["萨卡兹百夫长·悬赏", "萨卡兹百夫长", 2, 1], ["“邪魔的利刃”·悬赏", "“邪魔的利刃”", 3, 1], ["碎骨·悬赏", "碎骨", 1, 1]]
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
-104
@@ -1,16 +1,15 @@
|
||||
// Player feedback after 0.1.0, report #2 (workstream WB): "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过".
|
||||
// The 悬赏决策 draft of the first half (co-op / solo 绝境 · 终极 R3, 险境 co-op R3 / R6) offered boss bounties (碎骨, W,
|
||||
// 弑君者, 萨卡兹百夫长, 鼠王, 庞贝, 大鲍勃), second-half specials (尖端萨卡兹枯朽战车, 假想敌:蚀裂 / 淤困 …, 法术大师A2,
|
||||
// 灼藤, 重弩突袭者) and their upgraded variants (新硎, 家族暗影灭迹人, 异光体孽生者 …): the card tier read from the I/II/III
|
||||
// suffix, the boss bounties' small coin values and the multi-round cards' fixed tier 2 all passed the R3 tier window
|
||||
// [1, 2]. Evidence (tools/build-data.mjs bountyDraftHalf): the one official co-op draft screenshot shows six boss
|
||||
// bounties with coins 1–6 together at team LP 6 / 10 / 9 / 15 (a late draft); in the faction series 12 of the 18
|
||||
// "接下来两场作战" cards bring first-half enemies (5 bring enemies outside the wave tables, enemyeffect_12_4 底海滑动者
|
||||
// one attached only to second-half specials) and no "下场作战" card a first-half one — a trend, not a rule. Now a
|
||||
// first-half draft (R ≤ 7, the official wave generator's half) offers the two-battle cards, a draft from R8 on the
|
||||
// next-battle and multi-round cards ([ASSUMED] beyond that evidence: the 假想敌 cards are named enemyInitial_*, which
|
||||
// could point at the early draft — kept late, a question for the user). Real data, real draft code, the real match path
|
||||
// for the players' scenario.
|
||||
// The user then collected the official 悬赏决策 of rounds 3, 9 and 11 in 11 official co-op matches (33 screenshots, 绝境 /
|
||||
// 终极; the readings are test/fixtures/official-bounty-drafts.json, player names left out). They settle the draft rules
|
||||
// (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js bountyDraftCards):
|
||||
// R3 six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III, one of the official fixed sets (a whole series 17 / 18 / 19,
|
||||
// or one card from each of 6 of the series 10–15 / 20) — 7 of the 10 events seen;
|
||||
// R9 boss bounties + 源石虫·特训: a group of named bosses that come together + cheap ones to 6;
|
||||
// R11 悬赏决策 / 机密商店 / 战术决策 (never 道具补给); its bounty: one 特异III giant + 5 "下场战斗" cards, at most one per
|
||||
// faction series;
|
||||
// no draft shows a multi-round card, a pre-series card (enemyeffect_3_*), 战术特训 or the 鸭爵 set; no card twice.
|
||||
// Each card's enemy is fixed by its effect; the title only names category and tier (悬赏·损伤I = 底海滑动者 in
|
||||
// enemyeffect_12_4, 临时收音师 in enemyeffect_18_1). Real data, real draft code, the real match path for the players' case.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
@@ -18,135 +17,243 @@ import { PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, bountyDraftHalf, MULTI_ROUND_BOUNTY_BATTLES } from '../../server/match/choices.js';
|
||||
import { generateDraft, bountyDraftKind } from '../../server/match/choices.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
|
||||
const BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss'];
|
||||
const CARD = new Map(DATA.choices.cards.bounty.map((c) => [c.effectId, c]));
|
||||
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
|
||||
const CARDS = DATA.choices.cards.bounty;
|
||||
const CARD = new Map(CARDS.map((c) => [c.effectId, c]));
|
||||
const SPEC = DATA.choices.bountyDrafts;
|
||||
const COOP_BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss'];
|
||||
const isBoss = (key) => DATA.enemies[key] && DATA.enemies[key].rank === 'BOSS';
|
||||
const sameSet = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join();
|
||||
|
||||
/** The official wave generator's halves (activity_table specialEnemyInfoDict isInFirstHalf), when the cache has it. */
|
||||
function officialHalves() {
|
||||
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
|
||||
if (!existsSync(p)) return null;
|
||||
const act = JSON.parse(readFileSync(p, 'utf8')).activity.AUTOCHESS_SEASON.act2autochess;
|
||||
const first = new Set(), second = new Set();
|
||||
for (const v of Object.values(act.specialEnemyInfoDict)) (v.isInFirstHalf ? first : second).add(v.specialEnemyKey);
|
||||
return { first, second, secondOnly: new Set([...second].filter((k) => !first.has(k))) };
|
||||
/** The data card of a screenshot reading [title, enemy, coins, battles] (by title, the enemy in the text, coins, battles). */
|
||||
function resolve([title, enemy, coin, battles]) {
|
||||
const name = OFFICIAL.aliases[enemy] || enemy;
|
||||
const hits = CARDS.filter((c) => c.name === title && c.coin === coin && (c.desc.includes(`1只${name}`) || c.desc.includes(`1个${name}`))
|
||||
&& (battles === 2 ? c.rounds === 2 : c.rounds === 1));
|
||||
assert.equal(hits.length, 1, `${title} ${enemy} ${coin}: ${hits.map((c) => c.effectId).join(', ') || 'no card'}`);
|
||||
return hits[0].effectId;
|
||||
}
|
||||
/** Every official draft of the fixture: [{ where, round, family, ids? }] (ids for 悬赏决策). */
|
||||
function officialDrafts() {
|
||||
const out = [];
|
||||
for (const [match, rounds] of Object.entries(OFFICIAL.matches)) {
|
||||
for (const [round, d] of Object.entries(rounds)) out.push({ where: `match ${match} R${round}`, round: Number(round), family: d.family, ids: d.family === 'bounty' ? d.cards.map(resolve) : null });
|
||||
}
|
||||
for (const [key, d] of Object.entries(OFFICIAL.extra)) out.push({ where: key, round: 9, family: d.family, ids: d.cards.map(resolve) });
|
||||
return out;
|
||||
}
|
||||
const tiersOf = (ids) => ids.map((id) => CARD.get(id).tier).sort().join('');
|
||||
|
||||
/** The rule of the unseen R3 slots (and of the seen mixed sets): one card from each of 6 of the series, I I I II II III. */
|
||||
function mixedRule(ids) {
|
||||
const mx = SPEC.initial.mixed;
|
||||
const series = ids.map((id) => CARD.get(id).series);
|
||||
return ids.length === mx.count && ids.every((id) => CARD.get(id).draftPool === 'initial') && new Set(series).size === series.length
|
||||
&& series.every((s) => mx.series.includes(s)) && tiersOf(ids) === mx.tiers.slice().sort().join('');
|
||||
}
|
||||
/** Which official R3 structure a set of ids is: 'set:<i>' (a seen set), 'mixed' (the unseen-slot rule) or null. */
|
||||
function initialFit(ids) {
|
||||
const i = SPEC.initial.sets.findIndex((s) => sameSet(s.cards, ids));
|
||||
if (i >= 0) return `set:${i}`;
|
||||
return mixedRule(ids) ? 'mixed' : null;
|
||||
}
|
||||
/** The R9 groups a set of ids fits (its featured bosses all there, the rest from the group's fill). */
|
||||
const bossFits = (ids) => SPEC.boss.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.boss.count
|
||||
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i);
|
||||
/** Whether a set of ids follows the R11 rule (one giant, the rest from the next-battle cards, one per faction series). */
|
||||
function hunterFits(ids) {
|
||||
const h = SPEC.hunter;
|
||||
const anchors = ids.filter((id) => h.anchors.includes(id));
|
||||
const rest = ids.filter((id) => !h.anchors.includes(id));
|
||||
const series = rest.map((id) => CARD.get(id).series).filter((s) => h.onePerSeries.includes(s));
|
||||
return ids.length === h.count && anchors.length === h.anchorCount && rest.every((id) => h.rest.includes(id)) && new Set(series).size === series.length;
|
||||
}
|
||||
|
||||
/** Every bounty draft of every bounty mode and SP round over `seeds` seeds: [{ modeId, round, cards }]. */
|
||||
function drafts(seeds = 30) {
|
||||
/** Bounty drafts generated for `modeId` at `round` over `seeds` seeds. */
|
||||
function drafts(modeId, round, seeds, data = DATA) {
|
||||
const gd = new GameData(data, modeId);
|
||||
const out = [];
|
||||
for (const modeId of BOUNTY_MODES) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
for (const r of DATA.choices.schedule[modeId].spRounds) {
|
||||
for (let seed = 1; seed <= seeds; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 7919 + r * 31), r, { stageId: 'act2autochess_m01' });
|
||||
if (d && d.family === 'bounty') out.push({ modeId, round: r, cards: d.cards });
|
||||
}
|
||||
}
|
||||
for (let seed = 1; seed <= seeds; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 7919 + round * 31), round, { stageId: 'act2autochess_m01' });
|
||||
if (d && d.family === 'bounty') out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#2 data: every drafted bounty card names its draft half — the "接下来两场作战" cards the first, the "下场作战" and multi-round cards the second', () => {
|
||||
let first = 0, second = 0;
|
||||
for (const c of DATA.choices.cards.bounty) {
|
||||
if (!c.draft) { assert.equal(c.draftHalf, null, `${c.effectId}: not drafted, no half`); continue; }
|
||||
assert.equal(c.draftHalf, c.rounds === 2 ? 1 : 2, `${c.effectId} ${c.name} (${c.rounds} battles): half ${c.draftHalf}`);
|
||||
if (c.draftHalf === 1) first++; else second++;
|
||||
test('#2 screenshots → data: every card read is one act2autochess (下半) effect, whose enemy_id is the enemy the card brings', (t) => {
|
||||
const seen = new Set();
|
||||
for (const d of officialDrafts()) for (const id of d.ids || []) seen.add(id);
|
||||
assert.equal(seen.size, 72, 'distinct cards in the 27 bounty drafts: 36 at R3, 20 at R9, 16 at R11');
|
||||
// the R3(1) question: the title names category and tier only — two different 悬赏·损伤I cards, each with its own enemy
|
||||
assert.equal(resolve(['悬赏·损伤I', '临时收音师', 1, 2]), 'enemyeffect_18_1');
|
||||
assert.equal(resolve(['悬赏·损伤I', '底海滑动者', 1, 2]), 'enemyeffect_12_4');
|
||||
assert.equal(CARD.get('enemyeffect_18_1').enemyKey, 'enemy_10094_crstf');
|
||||
assert.equal(CARD.get('enemyeffect_12_4').enemyKey, 'enemy_1148_dssbr');
|
||||
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
|
||||
if (!existsSync(p)) { t.diagnostic('no .cache/gamedata: the season cross-check is skipped'); return; }
|
||||
const act = JSON.parse(readFileSync(p, 'utf8')).activity.AUTOCHESS_SEASON;
|
||||
for (const id of seen) {
|
||||
const bb = act.act2autochess.effectBuffInfoDataDict[id][0].blackboard.find((x) => x.key === 'enemy_id').valueStr;
|
||||
assert.equal(CARD.get(id).enemyKey, bb, `${id}: the card's enemy is the effect's enemy_id`);
|
||||
}
|
||||
assert.equal(first, 42, '42 two-battle cards (6 faction series × I–III, 4 sets of 6)');
|
||||
assert.equal(second, 63, '56 next-battle cards (incl. 源石虫·特训, 24 boss bounties) + 7 multi-round cards');
|
||||
assert.equal(bountyDraftHalf(3), 1);
|
||||
assert.equal(bountyDraftHalf(7), 1);
|
||||
assert.equal(bountyDraftHalf(8), 2);
|
||||
assert.equal(bountyDraftHalf(9), 2);
|
||||
// 上半 (act1autochess) lacks the series 17–20 and 澪 / 纠缠藤蔓 … and has 沉沙 where 下半 has 清明: the screenshots are 下半
|
||||
for (const id of ['enemyeffect_17_1', 'enemyeffect_18_1', 'enemyeffect_19_1', 'enemyeffect_20_2', 'enemyeffect_b_19', 'enemyeffect_b_24']) {
|
||||
assert.ok(seen.has(id) && !act.act1autochess.effectInfoDataDict[id], `${id} is 下半-only`);
|
||||
}
|
||||
assert.match(act.act1autochess.effectInfoDataDict.enemyeffect_11_6.effectDesc, /沉沙/);
|
||||
});
|
||||
|
||||
test('#2 data cross-check (official tables): no first-half card brings a boss or a second-half-only special; every such card is a second-half card', (t) => {
|
||||
const halves = officialHalves();
|
||||
if (!halves) { t.skip('no .cache/gamedata (run tools/build-data.mjs once)'); return; }
|
||||
let late = 0;
|
||||
for (const c of DATA.choices.cards.bounty) {
|
||||
if (!c.draft) continue;
|
||||
const lateEnemy = isBoss(c.enemyKey) || halves.secondOnly.has(c.enemyKey);
|
||||
if (c.draftHalf === 1) assert.ok(!lateEnemy, `${c.effectId} ${c.name}: ${DATA.enemies[c.enemyKey].name} is a boss / second-half special`);
|
||||
if (lateEnemy) { assert.equal(c.draftHalf, 2, `${c.effectId} ${c.name}`); late++; }
|
||||
test('#2 R3 (11 of 11 official drafts): one of the official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III', () => {
|
||||
const r3 = officialDrafts().filter((d) => d.round === 3);
|
||||
assert.equal(r3.length, 11);
|
||||
const hits = SPEC.initial.sets.map(() => 0);
|
||||
for (const d of r3) {
|
||||
assert.equal(d.family, 'bounty', `${d.where}: R3 is a 悬赏决策`);
|
||||
assert.ok(d.ids.every((id) => CARD.get(id).rounds === 2 && CARD.get(id).draftPool === 'initial'), `${d.where}: two-battle cards`);
|
||||
assert.equal(tiersOf(d.ids), '111223', `${d.where}: tiers`);
|
||||
assert.ok(d.ids.every((id) => !isBoss(CARD.get(id).enemyKey)), `${d.where}: no boss`);
|
||||
const fit = initialFit(d.ids);
|
||||
assert.match(fit || '', /^set:/, `${d.where}: a seen set`);
|
||||
hits[Number(fit.slice(4))]++;
|
||||
}
|
||||
assert.ok(late >= 30, `${late} cards with a boss or a second-half special`);
|
||||
// the rule follows the battle count: the faction series' _4–_6 (two battles) are first-half cards, _7 / _8 (next
|
||||
// battle) second-half ones — whatever wave half their enemy has (enemyeffect_12_4 底海滑动者 is a second-half
|
||||
// attachment, yet a two-battle card)
|
||||
for (const s of [10, 11, 12, 13, 14, 15]) {
|
||||
for (const i of [4, 5, 6]) assert.equal(CARD.get(`enemyeffect_${s}_${i}`).draftHalf, 1, `enemyeffect_${s}_${i}`);
|
||||
for (const i of [7, 8]) assert.equal(CARD.get(`enemyeffect_${s}_${i}`).draftHalf, 2, `enemyeffect_${s}_${i}`);
|
||||
assert.deepEqual(hits, [4, 1, 1, 2, 1, 1, 1], 'series 18 ×4, 19, 17, the 10_5… set ×2, three more mixed sets');
|
||||
// each seen set is a whole series 17 / 18 / 19 or follows the rule the unseen slots are built by
|
||||
for (const s of SPEC.initial.sets) {
|
||||
const series = new Set(s.cards.map((id) => CARD.get(id).series));
|
||||
assert.ok(series.size === 1 ? [17, 18, 19].includes([...series][0]) && s.cards.length === 6 : mixedRule(s.cards), s.cards.join());
|
||||
assert.equal(tiersOf(s.cards), '111223');
|
||||
}
|
||||
assert.equal(SPEC.initial.slots, 10, 'enemy_initial_1..10');
|
||||
});
|
||||
|
||||
test('#2 the first-half drafts (R3, 险境 R6) offer only two-battle cards: no boss, no second-half special, no multi-round card', () => {
|
||||
const halves = officialHalves();
|
||||
let early = 0, cards = 0;
|
||||
for (const d of drafts()) {
|
||||
if (d.round > 7) continue;
|
||||
early++;
|
||||
for (const c of d.cards) {
|
||||
cards++;
|
||||
const card = CARD.get(c.id);
|
||||
assert.equal(card.rounds, 2, `${d.modeId} R${d.round}: ${c.id} ${c.name} lasts ${card.rounds} battle(s) — a later draft's card`);
|
||||
assert.ok(!isBoss(c.enemyKey), `${d.modeId} R${d.round}: ${c.name} — a boss bounty in the first half`);
|
||||
if (halves) assert.ok(!halves.secondOnly.has(c.enemyKey), `${d.modeId} R${d.round}: ${c.name} — a second-half special`);
|
||||
assert.ok(!card.multiRound, `${d.modeId} R${d.round}: ${c.name} — a multi-round card`);
|
||||
test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6 of the series 10–15 / 20, I I I II II III) — every seen set comes up, the unseen slots about 3 in 10', () => {
|
||||
const counts = new Map();
|
||||
let n = 0;
|
||||
for (const modeId of COOP_BOUNTY_MODES) {
|
||||
for (const round of DATA.choices.schedule[modeId].spRounds.filter((r) => bountyDraftKind(r) === 'initial')) {
|
||||
for (const d of drafts(modeId, round, modeId === 'mode_multi_hard' ? 400 : 60)) {
|
||||
const ids = d.cards.map((c) => c.id);
|
||||
assert.equal(new Set(ids).size, 6, 'six different cards');
|
||||
const fit = initialFit(ids);
|
||||
assert.ok(fit, `${modeId} R${round}: ${d.cards.map((c) => c.name).join(', ')}`);
|
||||
assert.ok(d.cards.every((c) => c.rounds === 2 && !isBoss(c.enemyKey)), 'two-battle cards, no boss');
|
||||
assert.match(d.eventId, /^enemy_initial_\d+$/);
|
||||
if (modeId === 'mode_multi_hard') { counts.set(fit, (counts.get(fit) || 0) + 1); n++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(early >= 100 && cards >= 400, `${early} first-half bounty drafts, ${cards} cards`);
|
||||
for (let i = 0; i < SPEC.initial.sets.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
|
||||
const mixed = (counts.get('mixed') || 0) / n;
|
||||
assert.ok(mixed > 0.2 && mixed < 0.4, `unseen-slot sets ${(mixed * 100).toFixed(0)} %`);
|
||||
// solo: 3 cards of such a set
|
||||
for (const d of drafts('mode_single_hard', 3, 40)) {
|
||||
assert.equal(d.cards.length, 3);
|
||||
assert.ok(d.cards.every((c) => CARD.get(c.id).draftPool === 'initial'));
|
||||
}
|
||||
});
|
||||
|
||||
test('#2 R9 (12 of 12 official drafts): boss bounties + 源石虫·特训 — a group of named bosses always together, filled to 6 with the cheap ones', () => {
|
||||
const r9 = officialDrafts().filter((d) => d.round === 9);
|
||||
assert.equal(r9.length, 12, '11 matches + the Bahamut co-op screenshot');
|
||||
const hits = SPEC.boss.templates.map(() => 0);
|
||||
for (const d of r9) {
|
||||
assert.equal(d.family, 'bounty', `${d.where}: R9 is a 悬赏决策`);
|
||||
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'boss' && CARD.get(id).rounds === 1), `${d.where}: boss bounties / 源石虫·特训, 下场作战`);
|
||||
const fits = bossFits(d.ids);
|
||||
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`);
|
||||
hits[fits[0]]++;
|
||||
}
|
||||
assert.ok(hits.every((x) => x > 0), `every group seen (${hits.join(' ')})`);
|
||||
for (const d of drafts('mode_multi_abyss', 9, 300)) {
|
||||
const ids = d.cards.map((c) => c.id);
|
||||
assert.equal(new Set(ids).size, 6);
|
||||
assert.ok(bossFits(ids).length > 0, ids.join(', '));
|
||||
assert.match(d.eventId, /^bossInitial_\d+$/);
|
||||
}
|
||||
const offered = new Set(drafts('mode_multi_hard', 9, 300).flatMap((d) => d.cards.map((c) => c.id)));
|
||||
for (const t of SPEC.boss.templates) for (const id of t.featured) assert.ok(offered.has(id), `${CARD.get(id).name} offered`);
|
||||
for (const id of ['enemyeffect_b_6', 'enemyeffect_b_7', 'enemyeffect_b_15', 'enemyeffect_b_16', 'enemyeffect_b_18']) {
|
||||
assert.equal(CARD.get(id).draftExcluded, 'unseen', `${CARD.get(id).name}: in no official R9 draft`);
|
||||
assert.ok(!offered.has(id));
|
||||
}
|
||||
});
|
||||
|
||||
test('#2 the second-half drafts (R9) offer the next-battle and multi-round cards — boss bounties of every coin value among them (the official screenshot: 碎骨 1 … “复仇者” 6)', () => {
|
||||
const coins = new Set();
|
||||
let late = 0, multi = 0;
|
||||
for (const d of drafts(60)) {
|
||||
if (d.round < 8) continue;
|
||||
late++;
|
||||
for (const c of d.cards) {
|
||||
const card = CARD.get(c.id);
|
||||
assert.notEqual(card.rounds, 2, `${d.modeId} R${d.round}: ${c.id} ${c.name} — a first-half (two-battle) card`);
|
||||
if (isBoss(c.enemyKey)) coins.add(c.coin);
|
||||
if (card.multiRound) {
|
||||
multi++;
|
||||
assert.equal(c.rounds, MULTI_ROUND_BOUNTY_BATTLES, 'a multi-round card still lasts two battles (settled, DESIGN §20.9)');
|
||||
test('#2 R11 (11 matches: 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0): the families, and the bounty rule — one 特异III giant + 5 "下场战斗" cards, one per faction series', () => {
|
||||
const r11 = officialDrafts().filter((d) => d.round === 11);
|
||||
const fam = {};
|
||||
for (const d of r11) fam[d.family] = (fam[d.family] || 0) + 1;
|
||||
assert.deepEqual(fam, { bounty: 5, shop: 4, tactic: 2 });
|
||||
for (const d of r11.filter((x) => x.family === 'bounty')) {
|
||||
assert.ok(hunterFits(d.ids), `${d.where}: ${d.ids.join(', ')}`);
|
||||
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'hunter' && CARD.get(id).rounds === 1));
|
||||
}
|
||||
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss']) {
|
||||
const sch = DATA.choices.schedule[modeId].rounds['11'];
|
||||
assert.deepEqual(sch.families.map((f) => f.family).sort(), ['bounty', 'shop', 'tactic'], `${modeId} R11`);
|
||||
assert.equal(sch.bountyDraft, 'hunter');
|
||||
}
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const seen = {};
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' });
|
||||
seen[d.family] = (seen[d.family] || 0) + 1;
|
||||
if (d.family !== 'bounty') continue;
|
||||
assert.ok(hunterFits(d.cards.map((c) => c.id)), d.cards.map((c) => c.name).join(', '));
|
||||
assert.match(d.eventId, /^bounty_hunter_\d+$/);
|
||||
}
|
||||
assert.deepEqual(Object.keys(seen).sort(), ['bounty', 'shop', 'tactic'], 'no 道具补给 at R11');
|
||||
assert.ok(seen.bounty > seen.tactic, JSON.stringify(seen));
|
||||
});
|
||||
|
||||
test('#2 never offered by a draft (in none of the 27 official bounty drafts): multi-round cards, enemyeffect_3_*, 战术特训, the 鸭爵 set', () => {
|
||||
const reasons = {};
|
||||
for (const c of CARDS) {
|
||||
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
|
||||
assert.equal(c.draft, c.draftPool != null, `${c.effectId}: drafted iff it has a draft kind`);
|
||||
if (c.multiRound) assert.equal(c.draftExcluded, c.payout === 'kill' ? 'unseen' : 'perfect', `${c.effectId} ${c.name}`);
|
||||
if (/^enemyeffect_3_/.test(c.effectId)) assert.equal(c.draftExcluded, 'unseen', `${c.effectId} ${c.name}`);
|
||||
}
|
||||
assert.deepEqual(reasons, { perfect: 20, hidden: 4, unseen: 19 });
|
||||
const pools = {};
|
||||
for (const c of CARDS) if (c.draft) pools[c.draftPool] = (pools[c.draftPool] || 0) + 1;
|
||||
assert.deepEqual(pools, { initial: 42, boss: 20, hunter: 24 });
|
||||
for (const modeId of [...COOP_BOUNTY_MODES, 'mode_single_hard', 'mode_single_abyss']) {
|
||||
for (const r of DATA.choices.schedule[modeId].spRounds) {
|
||||
for (const d of drafts(modeId, r, 40)) {
|
||||
for (const c of d.cards) {
|
||||
const card = CARD.get(c.id);
|
||||
assert.ok(card.draft && card.draftPool === bountyDraftKind(r), `${modeId} R${r}: ${c.id} ${c.name}`);
|
||||
assert.ok(!card.multiRound && card.payout === 'kill');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(late >= 100, `${late} second-half drafts`);
|
||||
for (const n of [1, 2, 3, 4, 5, 6]) assert.ok(coins.has(n), `a boss bounty worth ${n} was offered (seen: ${[...coins].sort().join(', ')})`);
|
||||
assert.ok(multi > 0, 'multi-round cards are offered in the second half');
|
||||
});
|
||||
|
||||
test('#2 the mode\'s inactive enemy list does not thin the bounty draft (PRTS 卫戍协议:盟约 11/18 note "不影响悬赏决策出场")', () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||||
const dropped = ['enemy_1272_nhtank_2', 'enemy_10067_ftsjc', 'enemy_9008_acbunn'];
|
||||
// 险境 turns off 尖端萨卡兹枯朽战车 and 灼藤 in its waves; their R11 cards (13_8, 12_7) stay in an R11-kind draft there
|
||||
const sch = DATA.choices.schedule.mode_multi_normal;
|
||||
const data = { ...DATA, choices: { ...DATA.choices, schedule: { ...DATA.choices.schedule, mode_multi_normal: { ...sch, rounds: { ...sch.rounds, 9: { ...sch.rounds['9'], families: [{ family: 'bounty', weight: 1 }], bountyDraft: 'hunter' } } } } } };
|
||||
const gd = new GameData(data, 'mode_multi_normal');
|
||||
const dropped = ['enemy_1272_nhtank_2', 'enemy_10067_ftsjc'];
|
||||
for (const k of dropped) assert.ok(gd.inactiveEnemies.has(k), `${k} is inactive in 险境 waves`);
|
||||
const seen = new Set();
|
||||
for (let seed = 1; seed <= 400 && seen.size < dropped.length; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed), 9, { stageId: 'act2autochess_m01' });
|
||||
if (d && d.family === 'bounty') for (const c of d.cards) if (dropped.includes(c.enemyKey)) seen.add(c.enemyKey);
|
||||
}
|
||||
assert.deepEqual([...seen].sort(), dropped.slice().sort(), 'the three bounties stay in 险境\'s second-half drafts');
|
||||
for (const d of drafts('mode_multi_normal', 9, 200, data)) for (const c of d.cards) if (dropped.includes(c.enemyKey)) seen.add(c.enemyKey);
|
||||
assert.deepEqual([...seen].sort(), dropped.slice().sort());
|
||||
});
|
||||
|
||||
test('#2 E2E (co-op 绝境, the players\' case): the real R3 draft offers no late bounty, and the picked enemy comes for R3 and R4 at their round scale', () => {
|
||||
test('#2 E2E (co-op 绝境, the players\' case): the real R3 draft is an official R3 set, and the picked enemy comes for R3 and R4 at their round scale', () => {
|
||||
for (let seed = 1; seed <= 6; seed++) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 910 + seed, fake: true }).start();
|
||||
const m = h.m;
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true }), 'the R3 draft');
|
||||
assert.equal(m.sp.family, 'bounty', 'R3 of 绝境 is always a bounty draft');
|
||||
const names = m.sp.cards.map((c) => `${c.name}(${CARD.get(c.id).rounds})`).join(', ');
|
||||
for (const c of m.sp.cards) {
|
||||
assert.equal(CARD.get(c.id).draftHalf, 1, `seed ${seed}: ${names}`);
|
||||
assert.ok(!isBoss(c.enemyKey), `seed ${seed}: ${c.name} is a boss bounty`);
|
||||
}
|
||||
const names = m.sp.cards.map((c) => c.name).join(', ');
|
||||
assert.ok(initialFit(m.sp.cards.map((c) => c.id)), `seed ${seed}: ${names}`);
|
||||
for (const c of m.sp.cards) assert.ok(!isBoss(c.enemyKey), `seed ${seed}: ${c.name} is a boss bounty`);
|
||||
assert.equal(m.publicView().sp.cards.length, m.sp.cards.length);
|
||||
// the human takes a card when its turn comes; the bot picks by itself
|
||||
let picked = null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, cardView, MULTI_ROUND_BOUNTY_BATTLES } from '../../server/match/choices.js';
|
||||
import { generateDraft, cardView, bountyCard, MULTI_ROUND_BOUNTY_BATTLES } from '../../server/match/choices.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { uniteLeft, battleProgress } from '../../server/sim/spec.js';
|
||||
@@ -106,12 +106,12 @@ test('#19 a tier short of different operators: the offer tops up from the tier b
|
||||
|
||||
// =====================================================================================================================
|
||||
// #4 — "官方版悬赏决策的敌人我没记错的话正常只会在选择之后出现两回合,现在是一直出"
|
||||
// The draft pool follows the PRTS 敌人轮选 table (卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段), which lists the multi-round
|
||||
// "之后 / 后续的每场作战" cards too: v2.4.1 also drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put
|
||||
// one or more multi-round cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场"
|
||||
// card looked like a "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js), the
|
||||
// multi-round cards stay in as PRTS lists them, and each card names its battles in the official colours (blue "下场作战" /
|
||||
// "两场作战", red "每场"); a 1–2-battle bounty stops after its battles.
|
||||
// v2.4.1 drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put one or more multi-round "之后 / 后续的每场
|
||||
// 作战" cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场" card looked like a
|
||||
// "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js); since player feedback
|
||||
// #2 the drafts follow the 33 official screenshots (test/match/feedback1-bounty.test.js), where no multi-round card
|
||||
// appears; each card names its battles in the official colours (blue "下场作战" / "两场作战", red "每场"); a 1–2-battle
|
||||
// bounty stops after its battles; a multi-round card (教鞭's 法术大师A2·多轮战术特训) lasts two battles.
|
||||
|
||||
const BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss'];
|
||||
/** 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏: commented out of the PRTS 敌人轮选 table */
|
||||
@@ -121,10 +121,9 @@ const DURATION_RE = { 1: /<@ba\.vup>下场(作战|战斗)<\/>/, 2: /<@ba\.vup>
|
||||
/** The official multi-round cards ("之后 / 后续的每场作战"): two battles since the user's playtest #6 answer. */
|
||||
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
|
||||
|
||||
test('#4 the 悬赏决策 draft: PRTS 敌人轮选 cards only (no 战术特训, no 鸭爵 set), each naming its battles in the official colours', () => {
|
||||
test('#4 the 悬赏决策 draft: kill bounties only (no 战术特训, no 鸭爵 set, no multi-round card), each naming its battles in the official colours', () => {
|
||||
let bountyDrafts = 0;
|
||||
let cards = 0;
|
||||
let withMulti = 0;
|
||||
for (const modeId of BOUNTY_MODES) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
const sch = DATA.choices.schedule[modeId];
|
||||
@@ -133,53 +132,46 @@ test('#4 the 悬赏决策 draft: PRTS 敌人轮选 cards only (no 战术特训,
|
||||
const d = generateDraft(gd, createRng(seed * 131 + r), r, { stageId: 'act2autochess_m01' });
|
||||
if (!d || d.family !== 'bounty') continue;
|
||||
bountyDrafts++;
|
||||
let multi = 0;
|
||||
assert.equal(new Set(d.cards.map((c) => c.id)).size, d.cards.length, 'distinct cards');
|
||||
for (const c of d.cards) {
|
||||
cards++;
|
||||
assert.equal(c.payout, 'kill', `${modeId} R${r}: ${c.id} ${c.name} — 战术特训 comes from 法术教鞭 only`);
|
||||
assert.ok(!HIDDEN.has(c.id), `${c.id} ${c.name}: hidden from the PRTS table`);
|
||||
assert.ok([1, 2].includes(c.rounds), `${c.id}: ${c.rounds} battles`);
|
||||
if (MULTI.has(c.id)) {
|
||||
multi++;
|
||||
assert.equal(c.rounds, 2, `${c.id} ${c.name}: a multi-round card lasts two battles (the user's call)`);
|
||||
assert.ok(!/每场/.test(c.descRaw || ''), `${c.id}: no 每场 left in the card text`);
|
||||
}
|
||||
assert.ok(!MULTI.has(c.id), `${c.id} ${c.name}: no official draft shows a multi-round card (player feedback #2)`);
|
||||
assert.match(c.descRaw || '', DURATION_RE[c.rounds], `${c.id} ${c.name}: the card text shows its battles (${c.descRaw})`);
|
||||
assert.equal(cardView(c).descRaw, c.descRaw, 'the public card carries the rich text (ui/choiceOverlay.js renders it first)');
|
||||
}
|
||||
if (multi) withMulti++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(bountyDrafts >= 100 && cards >= 400, `${bountyDrafts} bounty drafts, ${cards} cards`);
|
||||
assert.ok(withMulti > 0, 'the multi-round cards are still offered (PRTS lists them), as two-battle cards');
|
||||
});
|
||||
|
||||
test('#4 data: cards.bounty marks the draft pool after the PRTS table — 战术特训 (教鞭) and the 鸭爵 set are out, the multi-round cards and 源石虫·特训 in', () => {
|
||||
test('#4 data: cards.bounty marks the draft pool — 战术特训 (教鞭), the 鸭爵 set and the cards no official draft shows are out, 源石虫·特训 in', () => {
|
||||
const list = DATA.choices.cards.bounty;
|
||||
const reasons = {};
|
||||
for (const c of list) {
|
||||
const expect = c.payout === 'kill' && !HIDDEN.has(c.effectId);
|
||||
assert.equal(c.draft, expect, `${c.effectId} ${c.name}: draft ${c.draft}`);
|
||||
if (c.payout !== 'kill') assert.equal(c.draftExcluded, 'perfect', `${c.effectId} ${c.name}`);
|
||||
else if (HIDDEN.has(c.effectId)) assert.equal(c.draftExcluded, 'hidden', `${c.effectId} ${c.name}`);
|
||||
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
|
||||
else assert.equal(c.draftExcluded, null);
|
||||
}
|
||||
assert.deepEqual(reasons, { perfect: 20, hidden: 4 });
|
||||
assert.equal(list.filter((c) => c.draft).length, 105);
|
||||
assert.deepEqual(reasons, { perfect: 20, hidden: 4, unseen: 19 });
|
||||
assert.equal(list.filter((c) => c.draft).length, 86);
|
||||
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
|
||||
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏');
|
||||
assert.ok(multi.every((c) => c.draft), 'PRTS lists them in 敌人轮选');
|
||||
assert.equal(list.find((c) => c.effectId === 'enemyeffect_5_1').draft, true, '源石虫·特训 is in the table too');
|
||||
assert.ok(multi.every((c) => !c.draft && c.draftExcluded === 'unseen'), 'in none of the 27 official bounty drafts');
|
||||
const slime = list.find((c) => c.effectId === 'enemyeffect_5_1');
|
||||
assert.ok(slime.draft && slime.draftPool === 'boss', '源石虫·特训 is an R9 card (8 of the 11 official R9 drafts)');
|
||||
});
|
||||
|
||||
/** Drive a co-op 绝境 match to its R3 (or R`round`) bounty draft; the human takes the first free card `want(card, free)`
|
||||
* accepts. */
|
||||
function pickAtR3(seed, want, round = 3) {
|
||||
/** Drive a co-op 绝境 match to its R3 bounty draft; the human takes the first free card `want(card, free)` accepts. */
|
||||
function pickAtR3(seed, want) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed, fake: true }).start();
|
||||
const m = h.m;
|
||||
const ok = h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === round, { ready: true });
|
||||
assert.ok(ok && m.sp.family === 'bounty', `seed ${seed}: the R${round} bounty draft`);
|
||||
const ok = h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true });
|
||||
assert.ok(ok && m.sp.family === 'bounty', `seed ${seed}: the R3 bounty draft`);
|
||||
let picked = null;
|
||||
for (let guard = 0; guard < 200 && m.phase === PHASE.SP_DRAFT; guard++) {
|
||||
if (m.spTurn() === 'p_0') {
|
||||
@@ -222,28 +214,29 @@ test('#4 E2E (co-op 绝境, the real R3 draft): a 1–2-battle bounty\'s enemies
|
||||
});
|
||||
|
||||
test('#4 E2E: a multi-round card lasts two battles like the "两场作战" cards — blue text on the card and in the effects column (the user\'s call)', () => {
|
||||
// "我不记得有过多轮悬赏" (user, after playtest #6): choices.js MULTI_ROUND_BOUNTY_BATTLES = 2; null restores 每场. The
|
||||
// multi-round cards are second-half cards (the R9 draft; player feedback #2, choices.js bountyDraftHalf)
|
||||
// "我不记得有过多轮悬赏" (user, after playtest #6): choices.js MULTI_ROUND_BOUNTY_BATTLES = 2; null restores 每场. No
|
||||
// official draft shows a multi-round card (player feedback #2); 教鞭's 法术大师A2·多轮战术特训 is one
|
||||
assert.equal(MULTI_ROUND_BOUNTY_BATTLES, 2);
|
||||
let done = false;
|
||||
for (let seed = 1; seed <= 40 && !done; seed++) {
|
||||
const { h, m, ps, picked } = pickAtR3(700 + seed, (c) => MULTI.has(c.id), 9);
|
||||
if (!picked) { m.dispose(); continue; }
|
||||
done = true;
|
||||
assert.equal(picked.rounds, 2);
|
||||
assert.match(picked.descRaw, /接下来<@ba\.vup>两场作战<\/>/, 'the draft card says 两场作战 in blue');
|
||||
assert.ok(!/每场/.test(picked.descRaw), 'no red 每场');
|
||||
const b = ps.bounties.find((x) => x.card.effectId === picked.id);
|
||||
assert.equal(b.roundsLeft, 2);
|
||||
const e = ps.privateView().effects.find((x) => x.id === b.id);
|
||||
assert.equal(e.counterText, '还剩 2 场作战');
|
||||
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/, 'the effects tooltip says the same');
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 13, { ready: true });
|
||||
assert.deepEqual(bountyRounds(b.id), [9, 10], 'its enemy comes for two battles, then never again');
|
||||
assert.ok(!ps.bounties.some((x) => x.id === b.id), 'and the bounty is gone');
|
||||
m.dispose();
|
||||
}
|
||||
assert.ok(done, 'some R9 draft offered a multi-round card');
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 701, fake: true }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(4);
|
||||
const ps = h.ps('p_0');
|
||||
const src = DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_2');
|
||||
assert.ok(MULTI.has(src.effectId) && src.payout === 'perfect', '法术大师A2·多轮战术特训');
|
||||
const card = bountyCard(m.gd, src);
|
||||
assert.equal(card.rounds, 2);
|
||||
assert.match(card.descRaw, /接下来<@ba\.vup>两场作战<\/>/, 'the card says 两场作战 in blue');
|
||||
assert.ok(!/每场/.test(card.descRaw), 'no red 每场');
|
||||
const id = m.addBounty(ps, src);
|
||||
const b = ps.bounties.find((x) => x.id === id);
|
||||
assert.equal(b.roundsLeft, 2);
|
||||
const e = ps.privateView().effects.find((x) => x.id === b.id);
|
||||
assert.equal(e.counterText, '还剩 2 场作战');
|
||||
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/, 'the effects tooltip says the same');
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 7, { ready: true });
|
||||
assert.deepEqual(bountyRounds(b.id), [4, 5], 'its enemies come for two battles, then never again');
|
||||
assert.ok(!ps.bounties.some((x) => x.id === b.id), 'and the bounty is gone');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 the active bounty says how many battles it has left, with its card text in the official colours (effects column)', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DATA } from '../match/harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, cardView } from '../../server/match/choices.js';
|
||||
import { generateDraft, cardView, bountyCard } from '../../server/match/choices.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -37,12 +37,15 @@ test('#4 the draft overlay shows each bounty card\'s battles in the official col
|
||||
// the official multi-round cards: two battles, in the two-battle cards' blue (the user's call after playtest #6)
|
||||
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
|
||||
const seen = new Set();
|
||||
// R3 offers the two-battle cards, R9 the next-battle and multi-round ones (player feedback #2, choices.js bountyDraftHalf)
|
||||
// R3 offers the two-battle cards, R9 the next-battle ones (player feedback #2: the official drafts); no draft offers a
|
||||
// multi-round card any more, so one (教鞭's 法术大师A2·多轮战术特训) goes through the same card view on its own
|
||||
const multiCard = bountyCard(gd, DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_2'));
|
||||
for (let seed = 1; seed <= 80 && seen.size < 3; seed++) {
|
||||
const round = seed % 2 ? 3 : 9;
|
||||
const d = generateDraft(gd, createRng(seed * 977 + 3), round, { stageId: 'act2autochess_m02' });
|
||||
assert.equal(d.family, 'bounty', `co-op 绝境 R${round} is a bounty draft`);
|
||||
const sp = normalizeSp({ family: d.family, cards: d.cards.map(cardView), order: ['p_0'], turn: 'p_0', picks: {}, taken: {} }, [{ playerId: 'p_0' }]);
|
||||
const cards = seed === 1 ? [...d.cards.slice(0, 5), { ...multiCard, idx: 5, family: 'bounty' }] : d.cards;
|
||||
const sp = normalizeSp({ family: d.family, cards: cards.map(cardView), order: ['p_0'], turn: 'p_0', picks: {}, taken: {} }, [{ playerId: 'p_0' }]);
|
||||
for (const card of sp.cards) {
|
||||
const view = resolveSpCard(card, sp.family);
|
||||
const segs = parseRichText(view.desc).filter((s) => s.text);
|
||||
|
||||
+99
-41
@@ -2319,11 +2319,15 @@ const ROMAN = { I: 1, II: 2, III: 3 };
|
||||
* "※以下悬赏任务仅由法术教鞭生成"
|
||||
* 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy
|
||||
* “神秘顾客” swaps those enemies into the waves instead)
|
||||
* Everything else is drafted, as the table lists it: the "下场作战" and "接下来两场作战" kill bounties, 源石虫·特训
|
||||
* ("但不获得资金") and the 7 multi-round cards ("之后 / 后续的每场作战": 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), whose
|
||||
* 'unseen' kill bounties no official draft showed (33 screenshots of 11 official matches, player feedback after
|
||||
* 0.1.0 report #2 — see BOUNTY_INITIAL_SETS below): the 7 multi-round cards ("之后 / 后续的每场作战": 山海众头目·
|
||||
* 多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards enemyeffect_3_* and the boss bounties of no seen R9 group
|
||||
* (凋零骑士, “遗弃者”, 锏, 扎罗, 迷路的巨像) [ASSUMED: not offered]
|
||||
* The rest is drafted, by the kind of draft it belongs to (`draftPool`, bountyDraftPool): the "接下来两场作战" cards at R3,
|
||||
* the boss bounties and 源石虫·特训 ("但不获得资金") at R9, the faction / 特异 "下场战斗" cards at R11. A multi-round card's
|
||||
* official text carries the red "每场" (`multiRound`, `rounds` 99 kept as the data has them; the server makes such a
|
||||
* card last MULTI_ROUND_BOUNTY_BATTLES = 2 battles and rewrites its text — server/match/choices.js bountyBattles /
|
||||
* bountyText, the user's call after playtest #6). The 战术特训 cards are what the
|
||||
* bountyText, the user's call after playtest #6; 教鞭's 法术大师A2·多轮战术特训 is one). The 战术特训 cards are what the
|
||||
* 教鞭 Art offers (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"; server/sim/content/items/meta.js); nothing in
|
||||
* act2 offers the 鸭爵 set.
|
||||
*/
|
||||
@@ -2335,36 +2339,70 @@ function bountyDraftExclusion(e, main) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of the match whose 悬赏决策 drafts offer a drafted bounty card (player feedback after 0.1.0, report #2: "本来应该
|
||||
* 后期出的悬赏的怪物在前期的悬赏就出现了"): 1 = the drafts of rounds ≤ firstHalfMaxRound (7: co-op / solo 绝境 · 终极 R3,
|
||||
* 险境 co-op R3 / R6), 2 = the drafts from R8 on (R9). The official card list of each draft event (enemy_initial_1..10,
|
||||
* bounty_hunter_1..15, bossInitial_1..6) is server-side; no table (activity_table, PRTS 敌人轮选, BWIKI) gives rounds.
|
||||
* Evidence for the split:
|
||||
* - the one official co-op 悬赏决策 screenshot (Bahamut 12294, research 06 §15 "Wrkhtxv") holds six boss bounties —
|
||||
* “复仇者” 6, “庞贝” 2, W 1, 萨卡兹百夫长 2, “邪魔的利刃” 3, 碎骨 1 — with the team at LP 6 / 10 / 9 / 15: a late
|
||||
* draft, coin values 1–6 together;
|
||||
* - a trend, not a rule, in the six faction series against the wave generator's halves (specialEnemyInfoDict
|
||||
* isInFirstHalf and the attached keys): of the 18 "接下来两场作战" cards (_4–_6) 12 bring an enemy of the first half
|
||||
* (妖怪, 磨砻, 逐腐兽, 萨卡兹枯朽战车, 山海众头目, 深池伙友卫队 …), 5 one outside the wave tables (暴鸰, 烹泉, 清明,
|
||||
* 术师快艇, 隐形术师) and 1 (enemyeffect_12_4 底海滑动者) an enemy attached only to second-half specials; of the 12
|
||||
* "下场作战" cards (_7 / _8) 6 bring a second-half special (法术大师A2, 灼藤, 假想敌:蚀裂, 尖端萨卡兹枯朽战车 …) and 6
|
||||
* an enemy outside the wave tables (新硎, 家族暗影灭迹人, 异光体孽生者 …), none a first-half one;
|
||||
* - strength: the half-1 enemies have at most 16000 base HP (median 5000), the half-2 ones up to 100000 (median 22000);
|
||||
* - the user's playtest #6 report "悬赏决策的敌人…正常只会在选择之后出现两回合" (DESIGN §20.6): the bounty met most
|
||||
* often — the R3 draft every 绝境 / 终极 match has — lasts two battles.
|
||||
* Counter-evidence, not reconciled: the multi-round 假想敌 cards are enemyInitial_1..6, a name like the enemy_initial_*
|
||||
* events (used here as the first-half header); a community guide "第三回合懸賞怪机制,数值及对策" (NGA tid 46551276,
|
||||
* linked from Bahamut 12316; NGA needs a login, unread) may describe the R3 pool. The pre-series cards follow the
|
||||
* battle count too, so the next-battle and multi-round cards of the first-half specials “萨科塔之眼” and 山海众头目
|
||||
* (enemyeffect_3_2_e / 3_4 / 4) go to the late drafts, while 山海众头目 is offered early through enemyeffect_14_6.
|
||||
* Rule [ASSUMED beyond the evidence above]: a "接下来两场作战" card (data `round` 2) is a first-half card; a "下场作战"
|
||||
* card (the boss bounties, the 特异 giants, the faction _7 / _8 cards, the e3_* cards, 源石虫·特训) and a multi-round card
|
||||
* (山海众头目·多轮悬赏, the 6 假想敌 — second-half specials, 12000–36000 base HP) a second-half one. Not a drafted card ⇒
|
||||
* null. Flip for the multi-round cards: return 1 for `main.rounds >= 99`.
|
||||
* The official 悬赏决策 drafts (player feedback after 0.1.0, report #2: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了
|
||||
* 打不过"), read from 33 screenshots of 11 official co-op 绝境 / 终极 matches at the start of rounds 3, 9 and 11 (collected
|
||||
* by the user, 2026-10-03; the readings are test/fixtures/official-bounty-drafts.json). Every card's title, enemy and coin
|
||||
* value is an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only
|
||||
* names the category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The draft
|
||||
* of a round is one of three kinds (the event ids are the data's; which event lists which cards is server-side):
|
||||
* initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III. All 11 R3
|
||||
* drafts are one of 7 fixed sets (BOUNTY_INITIAL_SETS): a whole series 17 / 18 / 19 (18 in 4 matches) or one
|
||||
* card from each of 6 of the series 10–15 and 20 (one such set in two matches). The 3 other events of the 10
|
||||
* are unseen: built by that rule (BOUNTY_INITIAL_MIXED) [ASSUMED].
|
||||
* boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训, all "下场作战". All 12 drafts (11 matches +
|
||||
* the Bahamut 12294 co-op screenshot) are a group of 0–3 named bosses that always come together, filled up to 6
|
||||
* with cheap ones (W / 碎骨 / 弑君者 1, 大鲍勃 2, 庞贝 or 鼠王 2, 源石虫·特训 0): BOUNTY_BOSS_TEMPLATES. 7 groups
|
||||
* were seen for 6 events (庞贝 and 鼠王 never in one draft: perhaps two versions); uniform [ASSUMED]. 凋零骑士,
|
||||
* “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not offered [ASSUMED].
|
||||
* hunter R11 (bounty_hunter_*; R11 is a 悬赏决策 in 5 of the 11 matches): six "下场战斗" cards, exactly one 特异III giant
|
||||
* (enemyeffect_16_1..8, 3 coins) and 5 of the faction _7 / _8 cards and enemyeffect_16_9..12, at most one per
|
||||
* faction series 10–15 (5 of 5 drafts): BOUNTY_HUNTER.
|
||||
* In none of the 27 bounty drafts: the 7 multi-round cards (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards
|
||||
* enemyeffect_3_* (法术大师A2·悬赏 …), 战术特训 (法术教鞭 only) and the 鸭爵 set; no card twice in one draft.
|
||||
*/
|
||||
function bountyDraftHalf(main, draftExcluded) {
|
||||
const EE = (s) => `enemyeffect_${s}`;
|
||||
const seriesIds = (n) => [1, 2, 3, 4, 5, 6].map((i) => EE(`${n}_${i}`));
|
||||
/** The R3 sets seen (`seen` = match numbers of test/fixtures/official-bounty-drafts.json). */
|
||||
const BOUNTY_INITIAL_SETS = [
|
||||
{ cards: seriesIds(18), seen: [1, 3, 5, 9] },
|
||||
{ cards: seriesIds(19), seen: [2] },
|
||||
{ cards: seriesIds(17), seen: [7] },
|
||||
{ cards: ['10_5', '11_4', '12_5', '13_6', '15_4', '20_3'].map(EE), seen: [4, 10] },
|
||||
{ cards: ['10_4', '11_5', '12_6', '14_4', '15_5', '20_2'].map(EE), seen: [6] },
|
||||
{ cards: ['10_4', '11_5', '12_4', '14_6', '15_4', '20_4'].map(EE), seen: [8] },
|
||||
{ cards: ['10_4', '11_6', '12_4', '13_4', '14_5', '15_5'].map(EE), seen: [11] },
|
||||
];
|
||||
/** An unseen R3 set: one card from each of `count` of these series, with these tiers (the seen mixed sets' rule). */
|
||||
const BOUNTY_INITIAL_MIXED = { series: [10, 11, 12, 13, 14, 15, 20], count: 6, tiers: [1, 1, 1, 2, 2, 3] };
|
||||
const BOSS_FILL_A = ['b_1', 'b_3', 'b_10', 'b_12', 'b_13', '5_1'].map(EE); // 碎骨 弑君者 W 庞贝 大鲍勃 源石虫·特训
|
||||
const BOSS_FILL_B = ['b_1', 'b_3', 'b_10', 'b_11', 'b_13', '5_1'].map(EE); // 鼠王 in place of 庞贝
|
||||
/** The R9 groups seen: `featured` always all on the card row, `fill` drawn to make 6. */
|
||||
const BOUNTY_BOSS_TEMPLATES = [
|
||||
{ featured: [], fill: BOSS_FILL_A, seen: [4] },
|
||||
{ featured: ['b_8', 'b_24'].map(EE), fill: BOSS_FILL_A, seen: [1, 5] }, // 喷气人 纠缠藤蔓
|
||||
{ featured: ['b_4', 'b_19'].map(EE), fill: BOSS_FILL_A, seen: [2] }, // 泥岩 澪
|
||||
{ featured: ['b_5', 'b_17', 'b_20'].map(EE), fill: BOSS_FILL_A, seen: [3] }, // 腐败骑士 “墓碑” “巨大的丑东西”
|
||||
{ featured: ['b_22', 'b_21', 'b_2'].map(EE), fill: BOSS_FILL_A, seen: [6, 'bahamut-12294'] }, // “邪魔的利刃” “复仇者” 萨卡兹百夫长
|
||||
{ featured: ['b_9', 'b_23'].map(EE), fill: BOSS_FILL_B, seen: [7, 11] }, // 杰斯顿·威廉姆斯 陷落雪祀
|
||||
{ featured: ['b_9', 'b_14'].map(EE), fill: BOSS_FILL_B, seen: [8, 9, 10] }, // 杰斯顿·威廉姆斯 “自在”
|
||||
];
|
||||
/** The R11 rule: `anchorCount` of `anchors` + the rest from `rest`, at most one card per series of `onePerSeries`. */
|
||||
const BOUNTY_HUNTER = {
|
||||
anchors: [1, 2, 3, 4, 5, 6, 7, 8].map((i) => EE(`16_${i}`)), anchorCount: 1,
|
||||
rest: [...[10, 11, 12, 13, 14, 15].flatMap((n) => [EE(`${n}_7`), EE(`${n}_8`)]), ...[9, 10, 11, 12].map((i) => EE(`16_${i}`))],
|
||||
onePerSeries: [10, 11, 12, 13, 14, 15], count: 6,
|
||||
};
|
||||
/** The kind of a round's 悬赏决策: R3 (and 险境 R6, [ASSUMED]) initial, R9 boss, R11 hunter. */
|
||||
const bountyDraftOf = (r) => (r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter');
|
||||
|
||||
/** The draft kind a bounty card belongs to by its shape, null when no draft offers it (see above). */
|
||||
function bountyDraftPool(e, main, draftExcluded) {
|
||||
if (draftExcluded) return null;
|
||||
return main.rounds === 2 ? 1 : 2;
|
||||
if (main.rounds === 2) return 'initial';
|
||||
if (main.rounds !== 1) return null; // multi-round
|
||||
if (/^enemyeffect_b_\d+$/.test(e.effectId) || e.effectId === 'enemyeffect_5_1') return 'boss';
|
||||
if (/^enemyeffect_1[0-5]_[78]$/.test(e.effectId) || /^enemyeffect_16_\d+$/.test(e.effectId)) return 'hunter';
|
||||
return null; // the pre-series cards enemyeffect_3_*
|
||||
}
|
||||
|
||||
/** Classify a 机变 choice event id into a family. */
|
||||
@@ -2415,15 +2453,27 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
if (multiRound) tier = 2;
|
||||
for (const a of adds) if (a.enemyKey && !ctx.enemyDb.has(a.enemyKey)) warn(`bounty ${e.effectId}: unknown enemy ${a.enemyKey}`);
|
||||
const coin = e.enemyPrice || main.coin;
|
||||
const draftExcluded = bountyDraftExclusion(e, main);
|
||||
let draftExcluded = bountyDraftExclusion(e, main);
|
||||
if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`);
|
||||
let draftPool = bountyDraftPool(e, main, draftExcluded);
|
||||
// a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_TEMPLATES)
|
||||
if (draftPool === 'boss' && !BOUNTY_BOSS_TEMPLATES.some((t) => t.featured.includes(e.effectId) || t.fill.includes(e.effectId))) draftPool = null;
|
||||
if (!draftExcluded && !draftPool) draftExcluded = 'unseen';
|
||||
const sm = /^enemyeffect_(\d+)_\d+$/.exec(e.effectId);
|
||||
bounty.push({
|
||||
effectId: e.effectId, name: e.name, desc: e.desc, tier, coin,
|
||||
payout: main.payout, rounds: main.rounds, multiRound, enemyKey: main.enemyKey, count: main.count, adds,
|
||||
draft: !draftExcluded, draftExcluded, draftHalf: bountyDraftHalf(main, draftExcluded),
|
||||
draft: !draftExcluded, draftExcluded, draftPool, series: sm ? Number(sm[1]) : null,
|
||||
});
|
||||
}
|
||||
bounty.sort((a, b) => naturalCmp(a.effectId, b.effectId));
|
||||
// the official draft structures (see BOUNTY_INITIAL_SETS) only name cards of their own kind
|
||||
const bountyById = new Map(bounty.map((c) => [c.effectId, c]));
|
||||
const checkKind = (ids, kind, what) => { for (const id of ids) if (bountyById.get(id)?.draftPool !== kind) warn(`bounty draft ${what}: ${id} is not a "${kind}" card`); };
|
||||
for (const s of BOUNTY_INITIAL_SETS) checkKind(s.cards, 'initial', 'R3 set');
|
||||
for (const t of BOUNTY_BOSS_TEMPLATES) checkKind([...t.featured, ...t.fill], 'boss', 'R9 group');
|
||||
checkKind([...BOUNTY_HUNTER.anchors, ...BOUNTY_HUNTER.rest], 'hunter', 'R11 rule');
|
||||
for (const c of bounty) if (c.draftPool === 'hunter' && !BOUNTY_HUNTER.anchors.includes(c.effectId) && !BOUNTY_HUNTER.rest.includes(c.effectId)) warn(`bounty ${c.effectId}: an R11 card outside BOUNTY_HUNTER`);
|
||||
|
||||
// Tactic cards (BUFF_GAIN effects).
|
||||
const tactic = [];
|
||||
@@ -2445,9 +2495,8 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
// Per-mode schedule of families (research 01 A4).
|
||||
const schedule = {};
|
||||
const supplyWindow = { 3: [1, 4], 6: [2, 5], 9: [3, 6], 11: [4, 6] };
|
||||
// the 悬赏决策 draft of a round offers the cards of its half (cards.bounty `draftHalf`, bountyDraftHalf): the official
|
||||
// wave generator's first half ends at maxLevelCnt / 2 (factions.generation.firstHalfMaxRound)
|
||||
const firstHalfMaxRound = Math.floor((ctx.ac.constData.maxLevelCnt ?? 15) / 2);
|
||||
// the 悬赏决策 of a round is the kind of official draft its round has (bountyDraftOf: R3 initial, R9 boss, R11 hunter)
|
||||
const bountyEventRe = { initial: /^enemy_initial_/, boss: /^bossInitial_/, hunter: /^bounty_hunter_/ };
|
||||
for (const [modeId, rounds] of Object.entries(act.battleDataDict)) {
|
||||
const mode = act.modeDataDict[modeId];
|
||||
const solo = mode.modeType === 'SINGLE';
|
||||
@@ -2458,17 +2507,19 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
let families;
|
||||
if (diff === 'TRAINING') families = [{ family: 'supply', weight: 100 }];
|
||||
else if (diff === 'HARD' || diff === 'ABYSS') {
|
||||
families = r === 11 ? [{ family: 'supply', weight: 50 }, { family: 'shop', weight: 50 }] : [{ family: 'bounty', weight: 100 }];
|
||||
// R11 of the 11 official co-op matches (player feedback after 0.1.0 report #2, the screenshots of BOUNTY_INITIAL_SETS):
|
||||
// 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0 — the weights follow those counts [ASSUMED]
|
||||
families = r === 11 ? [{ family: 'bounty', weight: 45 }, { family: 'shop', weight: 35 }, { family: 'tactic', weight: 20 }] : [{ family: 'bounty', weight: 100 }];
|
||||
} else if (diff === 'NORMAL') {
|
||||
families = solo
|
||||
? [{ family: r === 9 ? 'tactic' : 'supply', weight: 100 }]
|
||||
: [{ family: 'bounty', weight: 50 }, { family: 'supply', weight: 25 }, { family: 'shop', weight: 10 }, { family: 'tactic', weight: 15 }];
|
||||
} else families = [{ family: 'supply', weight: 45 }, { family: 'tactic', weight: 35 }, { family: 'shop', weight: 20 }];
|
||||
const bountyHalf = r <= firstHalfMaxRound ? 1 : 2;
|
||||
const bountyEvents = bountyHalf === 1 ? eventsOf('bounty', (e) => /^enemy_initial/.test(e.id) && e.solo === solo) : eventsOf('bounty', (e) => /^(bounty_hunter|bossInitial)/.test(e.id) && e.solo === solo);
|
||||
const bountyDraft = bountyDraftOf(r);
|
||||
const bountyEvents = eventsOf('bounty', (e) => bountyEventRe[bountyDraft].test(e.id) && e.solo === solo);
|
||||
m[r] = {
|
||||
families, cards: solo ? 3 : 6,
|
||||
supplyTiers: supplyWindow[r] || [1, 6], bountyHalf,
|
||||
supplyTiers: supplyWindow[r] || [1, 6], bountyDraft,
|
||||
events: {
|
||||
bounty: bountyEvents,
|
||||
supply: diff === 'TRAINING' ? eventsOf('supply', (e) => e.training) : eventsOf('supply', (e) => !e.training && e.solo === solo),
|
||||
@@ -2484,7 +2535,7 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
return {
|
||||
events,
|
||||
families: {
|
||||
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'cards.bounty entries with draft: true (PRTS 敌人轮选: no 战术特训 — the 教鞭 Art offers those — and no 鸭爵 set) and draftHalf = schedule[*].bountyHalf (1: the "接下来两场作战" cards, rounds ≤ firstHalfMaxRound; 2: the "下场作战" and multi-round cards)' },
|
||||
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'bountyDrafts[schedule[*].bountyDraft] — the official draft structure of the round (initial: one of the R3 sets; boss: an R9 group + cheap fill; hunter: one 特异III giant + 5 next-battle cards, one per faction series), over cards.bounty entries with draft: true and that draftPool' },
|
||||
supply: { name: '道具补给', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items in schedule[*].supplyTiers (duplicates allowed)' },
|
||||
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items of any tier I–VI (duplicates allowed)' },
|
||||
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
|
||||
@@ -2495,6 +2546,13 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
opensAfterIncome: true,
|
||||
},
|
||||
cards: { bounty, tactic },
|
||||
// the official 悬赏决策 structures by kind (tools/build-data.mjs BOUNTY_INITIAL_SETS; `seen` = the match numbers of
|
||||
// test/fixtures/official-bounty-drafts.json; `slots` = the official events, the unseen ones built by `mixed`)
|
||||
bountyDrafts: {
|
||||
initial: { events: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo).length, sets: BOUNTY_INITIAL_SETS, mixed: BOUNTY_INITIAL_MIXED, count: 6, assumed: ['the unseen slots built by `mixed`', '险境 R6 drafts like R3'] },
|
||||
boss: { events: eventsOf('bounty', (e) => bountyEventRe.boss.test(e.id) && !e.solo), templates: BOUNTY_BOSS_TEMPLATES, count: 6, assumed: ['a uniform pick among the seen groups', 'boss bounties of no seen group are not offered'] },
|
||||
hunter: { events: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo), ...BOUNTY_HUNTER, assumed: ['uniform draws within the rule'] },
|
||||
},
|
||||
schedule,
|
||||
pools: {
|
||||
pool_equip_normal: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },
|
||||
|
||||
Reference in new issue
Block a user