feedback1 WB: bounty drafts and the 机密商店 from 66 official screenshots (22 matches)

Bounty drafts: every 悬赏决策 event is a fixed card list and the draft shows
6 different cards of it, drawn with weight 1 + the official drafts of its
group the card showed in.
- R3: 9 of the 10 fixed sets seen (two new: 20_1 + 20_5, and one with 17_6);
  3 x I + 2 x II + 1 x III in 22 of 22. The unseen 10th set is built by a
  rule whose III is 10_6 or 20_6, the only two-battle cards no draft showed.
- R9: 6 groups of 9 cards, one per bossInitial event. The 鼠王 group came in
  14 of 22 matches (every grey-board match among them), so groups are picked
  by the matches they came in. Named bosses no longer always come together.
- R11: lists of 7 cards, the draft leaves out one (matches 1 / 6 / 21 / 22
  leave out four different cards of one list); 7 of 15 lists seen, the rest
  and the 4 partial lists' 7th cards built by rule. Families 14 / 4 / 4.

机密商店 (the user: "机密商店按官方改成可以重复吧"): choices.json shopDraft,
six slots drawn with replacement - VI, VI, V, 盟约之币 and twice
V / IV / III / 盟约之币, items weighted by the official cards they showed on.
A repeated item now comes in about 61 % of shops (official 3 of 4). Two
identical cards stay two cards (picks by index): E2E, overlay and a
headless-Chrome test (game-mock ?variant=shop, the official match 8 shop).
This commit is contained in:
sganggs committed 2026-10-03 07:42:20 +08:00
1 parent 7c13e38422
commit d1f8a49829
12 files changed
+799 -261

No files matched your search

+134 -64
View File
@@ -5,15 +5,17 @@
// 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 of the 15 official events — a seen group (a 特异III giant + its tier-II
// "下场战斗" cards, always together) or for an unseen slot a group of that shape
// [ASSUMED] — filled to 6 with tier-I cards, at most one card per faction series;
// drafts; 66 official screenshots of 22 matches, tools/build-data.mjs BOUNTY_INITIAL_SETS): schedule
// `bountyDraft` names the kind and choices.json `bountyDrafts[kind]` its card lists — the event is a
// fixed list and the draft shows 6 different cards of it, each drawn with its weight (1 + the
// official drafts of the group it showed in):
// initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of six "接下来两场作战" cards (all
// six) — 9 seen, the 10th built by `rule` (I I I II II III) [ASSUMED];
// boss (R9): one of the 6 groups of 9 boss bounties / 源石虫·特训, picked by the matches it came in
// [ASSUMED];
// hunter (R11): one of the 15 events, a list of 7 "下场战斗" cards — 7 seen (their `open` cards and
// the 8 unseen lists built by `rule`: one 特异III giant + tier I / II cards, one per faction
// series) [ASSUMED];
// 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 "以上调整仅针对战术特训敌人,
@@ -22,8 +24,13 @@
// 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
// shop 机密商店 choices.json `shopDraft` (the 4 official 机密商店 at R11; the user: "机密商店按官方改成可以重复吧"):
// six slots, each drawn on its own — with replacement, so the same item can be offered twice
// (official: 盟约之币 ×2, 变形同构体 ×2): VI, VI, V, 盟约之币 and twice V / IV / III / 盟约之币, an
// item within its tier by `itemWeights` (solo: 3 of the 6 slots [ASSUMED]); without `shopDraft`
// random normal EQUIP shop items of any tier. FREE — the official card text is "无需消耗资金,获得装备
// 补给" (research 01 A4/04 addendum), so the price is 0. Two identical cards are two cards: picks,
// `taken` and the client go by the card's `idx`
// 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:
@@ -185,76 +192,107 @@ export function bountyCard(gd, c) {
};
}
/** 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) {
/** Up to `k` cards of `list` not in `taken`, in a shuffled order. */
function drawDistinct(rng, list, k, taken) {
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;
}
/** `k` different entries of `list` drawn one by one with weights `weights` (missing / bad weights count 1). */
function drawWeighted(rng, list, weights, k) {
const pool = list.map((c, i) => [c, Math.max(0, Number(weights && weights[i]) || 1)]);
const out = [];
while (out.length < k && pool.length) {
const c = weightedPick(rng, pool);
out.push(c);
pool.splice(pool.findIndex(([x]) => x === c), 1);
}
return out;
}
/**
* The cards of a 悬赏决策 of kind `kind` after its official structure (choices.json `bountyDrafts[kind]`, see the module
* The cards of an unseen R3 set (`spec.rule`, [ASSUMED]): one card per tier of `tiers` from the two-battle cards of
* `series`, at most `perSeries` of one series; a tier the `prefer` cards have is filled first, by one of them.
*/
function initialRuleSet(rng, rule, all) {
const out = [];
const perSeries = Number.isInteger(rule.perSeries) ? rule.perSeries : 1;
const ofSeries = (s) => out.filter((c) => c.series === s).length;
const prefer = new Set(Array.isArray(rule.prefer) ? rule.prefer : []);
const series = new Set(Array.isArray(rule.series) ? rule.series : []);
// the tiers a preferred card has go first, so that it still fits its series
const prefTiers = new Set(all.filter((c) => prefer.has(c.effectId)).map((c) => c.tier));
const tiers = rng.shuffle((Array.isArray(rule.tiers) ? rule.tiers : []).slice());
for (const tier of [...tiers.filter((t) => prefTiers.has(t)), ...tiers.filter((t) => !prefTiers.has(t))]) {
const ok = (c) => !out.includes(c) && c.tier === tier && series.has(c.series) && ofSeries(c.series) < perSeries;
const pref = all.filter((c) => prefer.has(c.effectId) && ok(c));
const c = rng.pick(pref.length ? pref : all.filter(ok));
if (c) out.push(c);
}
return out;
}
/**
* Fill an R11 list up to `rule.size` (an unseen event: from nothing; a seen group: its `open` cards) [ASSUMED]: a list
* without a 特异III giant gets one, the rest are tier I / II cards, at most one per `onePerSeries` series and
* `maxSeries16` of series 16.
*/
function hunterFill(rng, rule, list, byId) {
const out = list.slice();
const size = Number.isInteger(rule.size) ? rule.size : 7;
const giants = (Array.isArray(rule.giants) ? rule.giants : []).map((id) => byId.get(id)).filter(Boolean);
const onePer = new Set(Array.isArray(rule.onePerSeries) ? rule.onePerSeries : []);
const max16 = Number.isInteger(rule.maxSeries16) ? rule.maxSeries16 : 2;
if (out.length < size && !out.some((c) => giants.includes(c))) { const g = rng.pick(giants.filter((c) => !out.includes(c))); if (g) out.push(g); }
const fits = (c) => !out.includes(c) && (onePer.has(c.series) ? !out.some((x) => x.series === c.series) : c.series !== 16 || out.filter((x) => x.series === 16 && !giants.includes(x)).length < max16);
for (const c of rng.shuffle((Array.isArray(rule.cards) ? rule.cards : []).map((id) => byId.get(id)).filter(Boolean))) {
if (out.length >= size) break;
if (fits(c)) out.push(c);
}
return out;
}
/**
* The cards of a 悬赏决策 of kind `kind` after its official card lists (choices.json `bountyDrafts[kind]`, see the module
* header), over the eligible cards `byId` (effectId → cards.bounty entry). Ids the eligible list lacks are skipped.
* pick 'slot': one of `slots` events, uniform — a seen group, or for an unseen event a list built by `rule`;
* pick 'seen': a group by the number of official matches it came in.
* The draft is `count` different cards of the list drawn by the group's `weights` (a rule-built card weighs 1).
*/
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') {
// one of the official events: a seen group, or for an unseen slot a group of the same shape; then tier-I fill to 6
const onePer = new Set(Array.isArray(spec.onePerSeries) ? spec.onePerSeries : []);
const templates = Array.isArray(spec.templates) ? spec.templates : [];
const slots = Math.max(templates.length, Number.isInteger(spec.slots) ? spec.slots : 0);
const groups = Array.isArray(spec.groups) ? spec.groups : [];
let group = null;
if (spec.pick === 'seen') {
group = weightedPick(rng, groups.map((g) => [g, (Array.isArray(g.seen) ? g.seen.filter((x) => Number.isInteger(x)).length : 0) || 1]));
} else {
const slots = Math.max(groups.length, Number.isInteger(spec.slots) ? spec.slots : 0);
const slot = slots > 0 ? rng.int(slots) : 0;
let group;
let fill;
if (slot < templates.length) {
group = get(templates[slot].featured);
group.forEach((c) => taken.add(c));
fill = get(templates[slot].fill);
} else {
const r = spec.rule || {};
const sizes = Array.isArray(r.groupSizes) && r.groupSizes.length ? r.groupSizes : [1];
const giant = drawDistinct(rng, get(r.giants), 1, taken);
group = [...giant, ...drawDistinct(rng, get(r.groupCards), rng.pick(sizes), taken, onePer)];
fill = get(r.fill);
}
return [...group, ...drawDistinct(rng, fill, count - group.length, taken, onePer)];
group = slot < groups.length ? groups[slot] : null;
}
return [];
const rule = spec.rule || {};
let list;
let weights;
if (group) {
const ids = Array.isArray(group.cards) ? group.cards : [];
list = [];
weights = [];
ids.forEach((id, i) => { const c = byId.get(id); if (c) { list.push(c); weights.push(Array.isArray(group.weights) ? group.weights[i] : 1); } });
if (kind === 'hunter' && Number(group.open) > 0) list = hunterFill(rng, { ...rule, size: list.length + Number(group.open) }, list, byId);
} else if (kind === 'initial') {
list = initialRuleSet(rng, rule, [...byId.values()]);
} else if (kind === 'hunter') {
list = hunterFill(rng, rule, [], byId);
} else {
list = [];
}
return drawWeighted(rng, list, weights, count);
}
/**
@@ -274,9 +312,41 @@ function bountyDraftCards(gd, rng, n, sch, round) {
return rng.shuffle(out).slice(0, n).map((c) => bountyCard(gd, c));
}
/**
* The 机密商店 cards (choices.json `shopDraft`, module header): every slot drawn on its own — a tier (or `coin`, the
* 盟约之币) by the slot's weights, then an item of that tier by `itemWeights` (1 when unlisted; an empty tier falls back to
* the nearest lower one, then any) — so one item can fill two slots. Positions shuffled; `n` < the slots (solo) keeps
* `n` of them. Null without a usable `shopDraft`.
*/
export function shopDraftCards(gd, rng, n) {
const spec = gd.choices.shopDraft;
const slots = spec && Array.isArray(spec.slots) ? spec.slots.filter((x) => x && typeof x === 'object') : [];
if (!slots.length) return null;
const w = spec.itemWeights && typeof spec.itemWeights === 'object' ? spec.itemWeights : {};
const coin = typeof spec.coin === 'string' && gd.item(spec.coin) ? spec.coin : null;
const ofTier = (t) => {
for (let k = t; k >= 1; k--) if ((gd.shopItemsByTier[k] || []).length) return gd.shopItemsByTier[k];
return eligibleItems(gd, 1, 6);
};
const out = [];
for (const slot of slots) {
const kinds = Object.entries(slot).filter(([k]) => k === 'coin' ? !!coin : Number.isInteger(Number(k)));
const kind = weightedPick(rng, kinds);
if (kind == null) continue;
const list = kind === 'coin' ? [coin] : ofTier(Number(kind));
const id = weightedPick(rng, list.map((x) => [x, Object.hasOwn(w, x) ? w[x] : 1]));
if (id) out.push(itemCard(gd, id));
}
return rng.shuffle(out).slice(0, Math.max(0, n));
}
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 === 'shop') {
const cards = shopDraftCards(gd, rng, n);
if (cards) return cards;
}
if (family === 'supply' || family === 'shop') {
let lo = 1;
let hi = 6;