feedback1 WB: 战术决策 drawn with replacement like the 4 official drafts; R11 ally cards weighted by the screenshots
The user: "战术决策也按官方改成可以重复吧". The 4 official 战术决策 (R11 of matches 7, 9, 18 and 20) show six cards each; match 7 offers 补给 twice. - Every 战术决策 card is drawn on its own (with replacement), so the same card can come twice. choices.js tacticDraftCards / tacticCard. - R11 (绝境 / 终极, the round of the screenshots; choices.json tacticDraft, build-data TACTIC_DRAFT): only the ally cards, weighted 1 + the official cards each showed on. All 24 official cards are ally cards (no 排斥 / 责罚 / 裁决, no terrain card: about 0.04 % for the previous draw) and 列装 / 财富 / 补给 / 整备 / 升华 made 12 of them (uniform: about 4.6). Other rounds (标准 / 险境, no screenshot) keep every card, uniform, with replacement too. The weights and the R11 terrain exclusion are assumed. - The header is the official "进行协同调整,做好迎战准备。". - Identical cards are separate cards: own index, own object; a pick applies its card once and leaves the twin pickable; two picks of a team card stack. Match / bot / overlay already go by index. - Tests: official drafts vs data, generated R11 / solo / earlier rounds, a seeded draft with 补给 twice, co-op and solo E2E picks of twins, the bot, the overlay, and a headless-Chrome render (game-mock variant=tactic is now the official match 7 draft). Tests that drive given tactic cards lay them out explicitly. The 奥术 Final Assault test takes the first of a few seeds whose bots pair two 奥术 players (the draws moved seed 12).
This commit is contained in:
1 parent
bb6078efc2
commit
67e862d2b3
9 files changed
+550
-28
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
|
||||
@@ -288,7 +288,8 @@ function setPhase(phase, variant) {
|
||||
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
|
||||
// the official 机密商店 of match 8 R11 (test/fixtures/official-bounty-drafts.json): the same item twice
|
||||
else if (fam === 'shop') cards = ['变形同构体', '盟约之币', '商业包装方案', '变形同构体', '天马之盔', '双模机械臂'].map((n) => ({ itemId: shopItems().find((i) => i.name === n).id }));
|
||||
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
|
||||
// the official 战术决策 of match 7 R11 (test/fixtures/official-bounty-drafts.json): the same card twice
|
||||
else if (fam === 'tactic') cards = ['补给', '补给', '谢拉格驰援', '列装', '莫斯提马的盟誓', '升华'].map((n) => ({ effectId: ch.cards.tactic.find((t) => t.name === n).effectId }));
|
||||
else cards = shuffle(ch.cards.bounty.filter((b) => b.draft !== false)).slice(0, 6).map((b) => ({ effectId: b.effectId }));
|
||||
if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
|
||||
pub.sp = { family: fam, cards, order: solo ? ['p1'] : ['p4', 'p1', 'ai_2', 'p3'], turn: solo ? 'p1' : 'p1', picks: solo ? {} : { p4: 2 }, untimed: solo };
|
||||
|
||||
+44
-18
@@ -33,11 +33,18 @@
|
||||
// each card on its own (duplicates allowed) [ASSUMED]. 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:
|
||||
// not in the mode's static inactive list — 标准 turns off 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … —
|
||||
// and still with chess in the pool); a card acting only on dead bonds would do nothing
|
||||
// tactic 战术决策 cards.tactic entries, each card drawn on its own — with replacement, so the same card can be
|
||||
// offered twice (the 4 official 战术决策, R11 of matches 7 / 9 / 18 / 20: 补给 ×2 in match 7; the
|
||||
// user: "战术决策也按官方改成可以重复吧"; `tacticDraftCards`): at the rounds of choices.json
|
||||
// `tacticDraft` (R11) only its `kinds` (the ally cards — the official 24 cards hold no debuff and
|
||||
// no terrain card), a card by `weights` (1 + the official cards it showed on) [ASSUMED: the
|
||||
// weights]; other rounds (标准 / 险境, no screenshot) every card uniform, terrain cards only for
|
||||
// the match stage [ASSUMED]; a 驰援 card (single_special_choice_gain_bond_chess) or a 盟誓 card
|
||||
// (global_special_choice_bond_addlayer) only while at least one of its bonds is live in this match
|
||||
// (opts.bondAvailable = Match.bondLive: not in the mode's static inactive list — 标准 turns off
|
||||
// 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and still with chess in the pool); a card acting only on dead
|
||||
// bonds would do nothing. Two identical cards are two cards: picks, `taken` and the client go by
|
||||
// the card's `idx`, and each pick applies its own card once (two picks of one team card stack)
|
||||
// Application (applyCard): a registered `choice:<effectId>` handler (content) wins; otherwise the family default:
|
||||
// bounty → the picker's bounty list (waves.js adds the enemies to the next `rounds` battles; kill payout to the
|
||||
// killer via the Battle, perfect payout at settlement when the picker's own battle was perfect)
|
||||
@@ -344,8 +351,38 @@ export function shopDraftCards(gd, rng, n, round = null) {
|
||||
return rng.shuffle(out).slice(0, Math.max(0, n));
|
||||
}
|
||||
|
||||
/** The draft card of a cards.tactic entry. */
|
||||
export function tacticCard(c) {
|
||||
return { kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 战术决策 cards (module header): `n` cards, each drawn on its own — with replacement, so one card can fill two places
|
||||
* (official R11 of match 7: 补给 ×2). At the rounds of choices.json `tacticDraft` (`rounds`; R11) only the cards of its
|
||||
* `kinds` (ally), each by `weights` (1 when unlisted); elsewhere — or when those kinds leave nothing — every card, uniform.
|
||||
* Terrain cards only for the match stage; a card whose every target bond is dead in this match (mode-inactive, or no
|
||||
* chess left in the pool: `bondAvailable`) is never offered.
|
||||
*/
|
||||
export function tacticDraftCards(gd, rng, n, { stageId = null, bondAvailable = null, round = null } = {}) {
|
||||
const bondOk = (c) => {
|
||||
if (typeof bondAvailable !== 'function') return true;
|
||||
const bonds = cardTargetBonds(gd, c.effectId);
|
||||
return !bonds || bonds.some((b) => !!bondAvailable(b));
|
||||
};
|
||||
const list = Array.isArray(gd.choices.cards && gd.choices.cards.tactic) ? gd.choices.cards.tactic : [];
|
||||
const pool = list.filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
|
||||
const spec = gd.choices.tacticDraft && typeof gd.choices.tacticDraft === 'object' ? gd.choices.tacticDraft : null;
|
||||
const official = !!spec && (!Array.isArray(spec.rounds) || spec.rounds.includes(round));
|
||||
const kinds = official && Array.isArray(spec.kinds) && spec.kinds.length ? spec.kinds : null;
|
||||
const w = official && spec.weights && typeof spec.weights === 'object' ? spec.weights : {};
|
||||
const ofKinds = kinds ? pool.filter((c) => kinds.includes(c.kind)) : pool;
|
||||
const pairs = (ofKinds.length ? ofKinds : pool).map((c) => [c, Object.hasOwn(w, c.effectId) ? w[c.effectId] : 1]);
|
||||
const out = [];
|
||||
for (let i = 0; i < n && pairs.length; i++) out.push(tacticCard(weightedPick(rng, pairs)));
|
||||
return out;
|
||||
}
|
||||
|
||||
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, round);
|
||||
@@ -361,18 +398,7 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
|
||||
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
|
||||
return out;
|
||||
}
|
||||
if (family === 'tactic') {
|
||||
// a card whose every target bond is dead in this match (mode-inactive, or no chess left in the pool) does nothing
|
||||
const bondOk = (c) => {
|
||||
if (typeof bondAvailable !== 'function') return true;
|
||||
const bonds = cardTargetBonds(gd, c.effectId);
|
||||
return !bonds || bonds.some((b) => !!bondAvailable(b));
|
||||
};
|
||||
const pool = (Array.isArray(cardsData.tactic) ? cardsData.tactic : []).filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
|
||||
const pick = pool.slice();
|
||||
rng.shuffle(pick);
|
||||
return pick.slice(0, n).map((c) => ({ kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind }));
|
||||
}
|
||||
if (family === 'tactic') return tacticDraftCards(gd, rng, n, { stageId, bondAvailable, round });
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { makeMatch, give, giveItem, chessOfTier, legalTileFor, checkInvariants a
|
||||
import { FakeBattle } from '../match/fakeBattle.js';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { applyCard, generateDraft } from '../../server/match/choices.js';
|
||||
import { applyCard, generateDraft, tacticCard } from '../../server/match/choices.js';
|
||||
import { withBounties } from '../../server/match/waves.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { bountyOf, battlePlanOf, choiceCardIds, gateOf, hasBattlePart, mapAliases, BUILTIN_REFS } from '../../server/sim/content/choices.js';
|
||||
@@ -41,6 +41,17 @@ function toDraft(h, r) {
|
||||
return m.sp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out the open 战术决策 as exactly the tactic cards `ids` (each its own index). The draft draws every card on its own,
|
||||
* with replacement (the official 补给 ×2), so a small test pool can repeat one card and miss another: the tests that
|
||||
* drive a card's effect through g.choice offer their cards this way, after checking what the generator drew.
|
||||
*/
|
||||
function offerTactic(m, ids) {
|
||||
m.sp.cards = ids.map((id, idx) => ({ ...tacticCard(DATA.choices.cards.tactic.find((c) => c.effectId === id)), idx, family: 'tactic' }));
|
||||
m.markPublic();
|
||||
return m.sp;
|
||||
}
|
||||
|
||||
/** Pick cards in the draft's (random) order: plan = { playerId: cardId }; bots pick by themselves. */
|
||||
function draftPicks(h, plan) {
|
||||
const m = h.m;
|
||||
@@ -428,6 +439,66 @@ test('机密商店 E2E (co-op): two identical cards are two cards — both can b
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('战术决策 E2E (co-op): identical cards are separate cards — one pick applies its card exactly once, the twin stays pickable, picks stack', () => {
|
||||
// a pool of one card: every place draws 补给 (with replacement; the official match 7 R11 offered 补给 twice)
|
||||
const one = 'allybuff_select_4';
|
||||
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: [one] });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 44, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
assert.equal(sp.family, 'tactic');
|
||||
const pub = m.publicView().sp;
|
||||
assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six 补给');
|
||||
assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index');
|
||||
assert.equal(new Set(sp.cards).size, 6, 'six card objects, not one shared');
|
||||
const all = [...m.players.values()];
|
||||
const free0 = new Map(all.map((ps) => [ps.playerId, ps.shop.freeRefreshes]));
|
||||
const freeNow = () => all.map((ps) => ps.shop.freeRefreshes - free0.get(ps.playerId));
|
||||
const picked = {};
|
||||
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
|
||||
const pid = m.spTurn();
|
||||
if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; }
|
||||
const taken = Object.keys(m.sp.taken).map(Number);
|
||||
if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken while a twin is free');
|
||||
const before = freeNow();
|
||||
const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx;
|
||||
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true });
|
||||
picked[pid] = idx;
|
||||
assert.deepEqual(m.sp.taken[idx], pid, 'that index alone is taken');
|
||||
assert.equal(Object.values(m.sp.taken).filter((x) => x === pid).length, 1, 'one card per pick');
|
||||
// 补给 is a team card: +2 free refreshes for every alive player, once — not once per identical card
|
||||
assert.deepEqual(freeNow().map((x, i) => x - before[i]), all.map(() => 2), `${pid}: one 补给 applied once`);
|
||||
}
|
||||
assert.equal(m.phase, 'PREP');
|
||||
assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same tactic');
|
||||
assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards');
|
||||
assert.deepEqual(freeNow(), all.map(() => 8), 'four picks of 补给 stack (+2 each)');
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('战术决策 E2E (solo): of three identical personal cards the one picked applies once; the other two stay untaken', () => {
|
||||
const one = 'allybuff_select_6'; // 升华: the next purchased operator becomes elite
|
||||
const data = dataWith('mode_single_normal', 9, 'tactic', { tactic: [one] });
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 45, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 9);
|
||||
assert.deepEqual(sp.cards.map((c) => [c.idx, c.id]), [[0, one], [1, one], [2, one]]);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1 }), { ok: true });
|
||||
assert.deepEqual(sp.taken, { 1: 'p_0' });
|
||||
assert.deepEqual(sp.picks, { p_0: 1 });
|
||||
const refs = h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess);
|
||||
assert.equal(refs.length, 1, 'one 升华');
|
||||
assert.equal(refs[0].counter, 1);
|
||||
for (let guard = 0; guard < 50 && m.phase === 'SP_DRAFT'; guard++) h.sched.runNext();
|
||||
assert.equal(m.phase, 'PREP');
|
||||
assert.equal(h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess).length, 1, 'still one after the draft closed');
|
||||
matchInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 战术决策 — prep-side cards
|
||||
|
||||
@@ -441,7 +512,10 @@ test('战术决策 E2E (co-op 3): team cards reach every teammate (AI included),
|
||||
const sp = toDraft(h, 3);
|
||||
assert.equal(sp.family, 'tactic');
|
||||
assert.equal(m.publicView().sp.name, '战术决策');
|
||||
assert.deepEqual(sp.cards.map((c) => c.id).sort(), [...TEAM_PREP].sort());
|
||||
assert.equal(m.publicView().sp.desc, '进行协同调整,做好迎战准备。', 'the official header');
|
||||
assert.equal(sp.cards.length, 6);
|
||||
assert.ok(sp.cards.every((c) => TEAM_PREP.includes(c.id)), 'drawn from the pool (with replacement)');
|
||||
offerTactic(m, TEAM_PREP);
|
||||
const view = m.publicView().sp.cards;
|
||||
for (const c of view) assert.equal(c.team, DATA.choices.cards.tactic.find((x) => x.effectId === c.id).team, `${c.id} team flag`);
|
||||
const all = [...m.players.values()];
|
||||
@@ -532,6 +606,7 @@ test('战术决策 升华 / 整备: the next purchased operator becomes elite, t
|
||||
const m = h.m;
|
||||
h.start();
|
||||
toDraft(h, 9);
|
||||
offerTactic(m, ['allybuff_select_5', 'allybuff_select_6', 'allybuff_select_3']);
|
||||
draftPicks(h, { p_0: 'allybuff_select_6' });
|
||||
const ps = h.ps('p_0');
|
||||
const ref = ps.effects.find((e) => e.key === BUILTIN_REFS.eliteChess);
|
||||
@@ -575,6 +650,7 @@ test('战术决策 E2E: 火力 / 锐利 evaluate the 手牌区 at every prep end
|
||||
const m = h.m;
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
offerTactic(m, ['allybuff_select_13', 'allybuff_select_19']);
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: 'allybuff_select_13', [b]: 'allybuff_select_19' });
|
||||
const A = h.ps(a);
|
||||
@@ -637,6 +713,7 @@ test('战术决策 E2E (real battles): 排斥 debuffs every enemy of the picker\
|
||||
};
|
||||
h.start();
|
||||
const sp = toDraft(h, 3);
|
||||
offerTactic(m, [id, 'allybuff_select_3']);
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: id, [b]: 'allybuff_select_3' });
|
||||
assert.ok(h.ps(a).effects.some((e) => e.key === `choice:${id}` && e.iconId === 'icon_enemy_debuff'));
|
||||
@@ -787,6 +864,7 @@ test('模拟战场演变 E2E (act1 m01): the card clears the picker\'s crates fo
|
||||
h.setStage('act1autochess_m01');
|
||||
const sp = toDraft(h, 3);
|
||||
assert.ok(!sp.cards.some((c) => c.id === 'map_m02_1'), 'terrain cards only for the match stage');
|
||||
offerTactic(m, ['map_m01_1', 'allybuff_select_3']);
|
||||
const [a, b] = sp.order;
|
||||
draftPicks(h, { [a]: 'map_m01_1', [b]: 'allybuff_select_3' });
|
||||
const A = h.ps(a);
|
||||
|
||||
@@ -116,16 +116,24 @@ test('绝境 Final Assault: both pair fields drain the one pool, every hit exact
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('终极 Final Assault vs 假想敌:胄 (seed 12): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
|
||||
test('终极 Final Assault vs 假想敌:胄 (seeded bot match): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
|
||||
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
|
||||
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 72 000 at 终极
|
||||
// seed 12: since the elite-to-board merge (DESIGN §20.11) the bots' boards differ; seed 7 no longer pairs two 奥术 players
|
||||
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 12, bossId: 'boss_1' });
|
||||
// the bots' boards follow every draw of the match (the elite-to-board merge, DESIGN §20.11, moved seed 7 to 12; the
|
||||
// 战术决策 drawn with replacement moved 12 on): the first of these seeds whose bots pair two 奥术 players
|
||||
const pairOf = (m) => m.fields.filter((f) => f.battle).find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
|
||||
let h = null;
|
||||
for (const seed of [13, 7, 11, 21, 23]) {
|
||||
h = toFinalAssault({ difficulty: 'ABYSS', seed, bossId: 'boss_1' });
|
||||
if (pairOf(h.m)) break;
|
||||
h.m.dispose();
|
||||
h = null;
|
||||
}
|
||||
assert.ok(h, 'precondition: a pair field where both players run 奥术 (bot lineups of one of the seeds)');
|
||||
const m = h.m;
|
||||
const fields = m.fields.filter((f) => f.battle);
|
||||
const bb = bondBb('arcaneShip');
|
||||
const both = fields.find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
|
||||
assert.ok(both, 'precondition: a pair field where both players run 奥术 (bot lineups of seed 12)');
|
||||
const both = pairOf(m);
|
||||
const allowed = [];
|
||||
for (const pid of both.players) {
|
||||
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// The 战术决策 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
|
||||
// "战术决策也按官方改成可以重复吧"). The 4 official 战术决策 (R11 of matches 7, 9, 18 and 20 in
|
||||
// test/fixtures/official-bounty-drafts.json) show six cards each; match 7 offers 补给 twice, the other three six different
|
||||
// cards. All 24 cards are ally cards (no 排斥 / 责罚 / 裁决 debuff, no 模拟战场演变 terrain card), and 列装 / 财富 / 补给 /
|
||||
// 整备 / 升华 make half of them. choices.json `tacticDraft` (tools/build-data.mjs TACTIC_DRAFT) draws each card on its own,
|
||||
// with replacement, at R11 (绝境 / 终极 only, the round of the screenshots) from the ally cards weighted 1 + the official
|
||||
// cards each showed on; other rounds (标准 / 险境, no screenshot) keep every card, uniform, now with replacement too.
|
||||
// Picking one of two identical cards through the match: test/content/choices.test.js (E2E); the overlay:
|
||||
// test/ui/feedback1-tactic.test.js; headless Chrome: test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DATA } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, cardView, tacticDraftCards } from '../../server/match/choices.js';
|
||||
import { botPickCard } from '../../server/match/bot.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
|
||||
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
|
||||
const SPEC = DATA.choices.tacticDraft;
|
||||
const TACTIC = DATA.choices.cards.tactic;
|
||||
const ALLY = TACTIC.filter((c) => c.kind === 'ally');
|
||||
const byName = (name) => TACTIC.filter((c) => c.name === name);
|
||||
/** The official 战术决策: [{ match, names }]. */
|
||||
const DRAFTS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.entries(rs).filter(([, d]) => d.family === 'tactic').map(([r, d]) => ({ match: Number(m), round: Number(r), names: d.cards.map(([n]) => n) })));
|
||||
const BASIC = ['列装', '财富', '补给', '整备', '升华'];
|
||||
const repeats = (ids) => ids.length - new Set(ids).size;
|
||||
const count = (list, pred) => list.filter(pred).length;
|
||||
|
||||
test('战术决策 (4 official drafts, R11): six ally cards each, 补给 twice in match 7, half of the cards 列装 / 财富 / 补给 / 整备 / 升华', () => {
|
||||
assert.deepEqual(DRAFTS.map((d) => [d.match, d.round]), [[7, 11], [9, 11], [18, 11], [20, 11]]);
|
||||
for (const { match, names } of DRAFTS) {
|
||||
assert.equal(names.length, 6, `match ${match}`);
|
||||
for (const n of names) {
|
||||
assert.equal(byName(n).length, 1, `match ${match}: ${n} is one cards.tactic entry`);
|
||||
assert.equal(byName(n)[0].kind, 'ally', `match ${match}: ${n} is an ally card`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(DRAFTS.map((d) => repeats(d.names)), [1, 0, 0, 0], 'one repeat in four drafts');
|
||||
assert.equal(count(DRAFTS[0].names, (n) => n === '补给'), 2, 'match 7: 补给 ×2');
|
||||
const all = DRAFTS.flatMap((d) => d.names);
|
||||
assert.equal(all.length, 24);
|
||||
assert.equal(new Set(all).size, 14, '14 different cards');
|
||||
assert.equal(count(all, (n) => BASIC.includes(n)), 12, 'the five basic cards: 12 of 24 (a uniform draw of the 26 ally cards: about 4.6)');
|
||||
// the data: R11, ally cards, weight 1 + the official cards a card showed on
|
||||
assert.deepEqual(SPEC.seen, [7, 9, 18, 20]);
|
||||
assert.deepEqual(SPEC.rounds, [11], 'where the screenshots are: R11 (绝境 / 终极)');
|
||||
assert.deepEqual(SPEC.kinds, ['ally']);
|
||||
assert.equal(SPEC.count, 6);
|
||||
const seen = {};
|
||||
for (const n of all) seen[byName(n)[0].effectId] = (seen[byName(n)[0].effectId] || 0) + 1;
|
||||
assert.deepEqual(SPEC.weights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k])));
|
||||
assert.equal(SPEC.weights[byName('补给')[0].effectId], 4, '补给 on 3 of the 24 cards');
|
||||
// the official header of the event
|
||||
for (const id of ['buff_select_1', 'hardbuff_select_1']) assert.equal(DATA.choices.events[id].desc, '进行协同调整,做好迎战准备。');
|
||||
assert.equal(DATA.choices.families.tactic.desc, '进行协同调整,做好迎战准备。');
|
||||
});
|
||||
|
||||
test('战术决策 generated (co-op 绝境 / 终极 R11): six ally cards, each drawn on its own — the same card can come twice', () => {
|
||||
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss']) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
let drafts = 0;
|
||||
let dup = 0;
|
||||
let basic = 0;
|
||||
const freq = {};
|
||||
for (let seed = 1; drafts < 2000 && seed < 40000; seed++) {
|
||||
// act1 m02 is a 绝境 / 终极 stage with two terrain cards (map_m02_1 / 2): none comes at R11
|
||||
const d = generateDraft(gd, createRng(seed * 7919 + 3), 11, { stageId: 'act1autochess_m02', bondAvailable: () => true });
|
||||
if (d.family !== 'tactic') continue;
|
||||
drafts++;
|
||||
assert.equal(d.name, '战术决策');
|
||||
assert.equal(d.desc, '进行协同调整,做好迎战准备。');
|
||||
assert.equal(d.cards.length, 6);
|
||||
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
|
||||
assert.equal(new Set(d.cards).size, 6, 'each card its own object');
|
||||
for (const c of d.cards) {
|
||||
assert.equal(c.kind, 'tactic');
|
||||
assert.equal(c.tacticKind, 'ally', `${modeId}: ${c.name} is an ally card`);
|
||||
freq[c.id] = (freq[c.id] || 0) + 1;
|
||||
if (BASIC.includes(c.name)) basic++;
|
||||
}
|
||||
if (repeats(d.cards.map((c) => c.id))) dup++;
|
||||
}
|
||||
assert.equal(drafts, 2000, modeId);
|
||||
assert.ok(dup / drafts > 0.4 && dup / drafts < 0.7, `${modeId}: a repeated card in ${(100 * dup / drafts).toFixed(0)} % (official 1 of 4)`);
|
||||
assert.ok(basic / drafts > 1.6 && basic / drafts < 2.5, `${modeId}: ${(basic / drafts).toFixed(2)} basic cards a draft (official 3; uniform 1.2)`);
|
||||
for (const c of ALLY) assert.ok(freq[c.effectId] > 0, `${modeId}: ${c.name} can come`);
|
||||
const of = (name) => freq[byName(name)[0].effectId];
|
||||
assert.ok(of('补给') > 2.5 * of('自愈'), `${modeId}: 补给 (weight 4) ${of('补给')} vs 自愈 (weight 1) ${of('自愈')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('战术决策 seeded (co-op 绝境 R11, seed 30): 补给 twice — two cards with their own index and the same face', () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const d = generateDraft(gd, createRng(30), 11, { stageId: 'act1autochess_m02' });
|
||||
assert.equal(d.family, 'tactic');
|
||||
const names = d.cards.map((c) => c.name);
|
||||
const at = names.flatMap((n, i) => (n === '补给' ? [i] : []));
|
||||
assert.equal(at.length, 2, `补给 twice: ${names.join(' ')}`);
|
||||
const [a, b] = at.map((i) => d.cards[i]);
|
||||
assert.notEqual(a, b);
|
||||
assert.notEqual(a.idx, b.idx);
|
||||
const { idx: ia, ...va } = cardView(a);
|
||||
const { idx: ib, ...vb } = cardView(b);
|
||||
assert.deepEqual([ia, ib], at);
|
||||
assert.deepEqual(va, vb, 'the same card face');
|
||||
assert.deepEqual(va, { kind: 'tactic', id: 'allybuff_select_4', name: '补给', desc: byName('补给')[0].desc, tier: null, team: true, tacticKind: 'ally' });
|
||||
});
|
||||
|
||||
test('战术决策 solo 绝境 R11: three ally cards, a repeat sometimes [ASSUMED: solo extrapolated from co-op]', () => {
|
||||
const gd = new GameData(DATA, 'mode_single_hard');
|
||||
let drafts = 0;
|
||||
let dup = 0;
|
||||
for (let seed = 1; drafts < 1000 && seed < 40000; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 31 + 5), 11, {});
|
||||
if (d.family !== 'tactic') continue;
|
||||
drafts++;
|
||||
assert.equal(d.cards.length, 3);
|
||||
assert.ok(d.cards.every((c) => c.tacticKind === 'ally'));
|
||||
if (repeats(d.cards.map((c) => c.id))) dup++;
|
||||
}
|
||||
assert.equal(drafts, 1000);
|
||||
assert.ok(dup / drafts > 0.05 && dup / drafts < 0.3, `a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
|
||||
});
|
||||
|
||||
test('战术决策 outside R11 (标准 R3 / R9, 险境 R3 / R6 / R9, 险境 solo R9 — no screenshot): every card uniform, with replacement', () => {
|
||||
for (const [modeId, rounds, stageId, cards] of [
|
||||
['mode_multi_funny', [3, 9], 'act1autochess_m01', 6],
|
||||
['mode_multi_normal', [3, 6, 9], 'act1autochess_m02', 6],
|
||||
['mode_single_normal', [9], 'act1autochess_m02', 3],
|
||||
]) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
const stageTerrain = TACTIC.filter((c) => c.kind === 'terrain' && c.stageId === stageId).map((c) => c.effectId);
|
||||
for (const round of rounds) {
|
||||
let drafts = 0;
|
||||
let dup = 0;
|
||||
const kinds = new Set();
|
||||
const ids = new Set();
|
||||
for (let seed = 1; drafts < 600 && seed < 60000; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 104729 + round), round, { stageId });
|
||||
if (d.family !== 'tactic') continue;
|
||||
drafts++;
|
||||
assert.equal(d.cards.length, cards);
|
||||
assert.deepEqual(d.cards.map((c) => c.idx), [...Array(cards).keys()]);
|
||||
for (const c of d.cards) {
|
||||
kinds.add(c.tacticKind);
|
||||
ids.add(c.id);
|
||||
if (c.tacticKind === 'terrain') assert.ok(stageTerrain.includes(c.id), `${modeId} R${round}: ${c.name} belongs to ${stageId}`);
|
||||
}
|
||||
if (repeats(d.cards.map((c) => c.id))) dup++;
|
||||
}
|
||||
assert.equal(drafts, 600, `${modeId} R${round}`);
|
||||
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff', 'terrain'], `${modeId} R${round}: ally, debuff and the stage's terrain cards`);
|
||||
for (const id of stageTerrain) assert.ok(ids.has(id), `${modeId} R${round}: ${id}`);
|
||||
const [lo, hi] = cards === 6 ? [0.2, 0.5] : [0.02, 0.2];
|
||||
assert.ok(dup / drafts > lo && dup / drafts < hi, `${modeId} R${round}: a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('tacticDraftCards: a draw over one card fills every place; dead-bond cards never come; without tacticDraft every card is uniform', () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
// a 驰援 whose bond is dead: never offered, even at R11
|
||||
const yan = byName('炎盟约驰援')[0].effectId;
|
||||
for (let s = 1; s <= 300; s++) assert.ok(!tacticDraftCards(gd, createRng(s), 6, { round: 11, bondAvailable: (b) => b !== 'yanShip' }).some((c) => c.id === yan));
|
||||
// only one card left: six copies of it, six separate objects
|
||||
const only = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.name === '升华') } } };
|
||||
const six = tacticDraftCards(new GameData(only, 'mode_multi_hard'), createRng(1), 6, { round: 11 });
|
||||
assert.deepEqual(six.map((c) => c.name), Array(6).fill('升华'));
|
||||
assert.equal(new Set(six).size, 6);
|
||||
// R11 kinds that leave nothing (a debuff-only pool): every card instead
|
||||
const debuffs = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.kind === 'enemyDebuff') } } };
|
||||
assert.equal(tacticDraftCards(new GameData(debuffs, 'mode_multi_hard'), createRng(2), 6, { round: 11 }).length, 6);
|
||||
// no tacticDraft (older data): every card, uniform, with replacement, at R11 too
|
||||
const { tacticDraft, ...rest } = DATA.choices; // eslint-disable-line no-unused-vars
|
||||
const plain = new GameData({ ...DATA, choices: rest }, 'mode_multi_hard');
|
||||
const kinds = new Set();
|
||||
for (let s = 1; s <= 200; s++) for (const c of tacticDraftCards(plain, createRng(s), 6, { round: 11 })) kinds.add(c.tacticKind);
|
||||
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff']);
|
||||
});
|
||||
|
||||
test('bot: two identical tactic cards are two choices — it takes one index, and the other once the first is gone', () => {
|
||||
const m = { rngBots: () => 0 };
|
||||
const card = (idx) => ({ idx, kind: 'tactic', id: 'allybuff_select_4', name: '补给', team: true, tacticKind: 'ally' });
|
||||
const cards = [card(0), { idx: 1, kind: 'tactic', id: 'allybuff_select_5', team: false, tacticKind: 'ally' }, card(2)];
|
||||
assert.equal(botPickCard(m, null, cards, [0, 1, 2]), 0, 'the first of the two team cards');
|
||||
assert.equal(botPickCard(m, null, cards, [1, 2]), 2, 'the twin, once the first is taken');
|
||||
assert.equal(botPickCard(m, null, cards, [1]), 1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// The 战术决策 overlay with the same card twice, in headless Chrome (puppeteer-core + system Chrome) through the mock
|
||||
// harness (public/dev/game-mock.html ?phase=SP_DRAFT&variant=tactic: the official 战术决策 of match 7 R11 — 补给 in
|
||||
// slots 1 and 2; the user: "战术决策也按官方改成可以重复吧"). Opt-in: SP_E2E=1.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/feedback1-tactic.e2e.test.js
|
||||
//
|
||||
// Two identical cards render as two cards with the same face (name, 全队获得, text) under the official header; a
|
||||
// teammate taking the first leaves the second pickable; two taps on the second pick it — its own index goes to the
|
||||
// server (g.choice), only it shows my avatar. Screenshots: test/e2e/out/fb1-tactic-*.png.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('战术决策: the same card twice (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
const cards = (page) => page.$$eval('.spov__grid .spcard', (els) => els.map((c) => ({
|
||||
name: c.querySelector('.spcard__name')?.textContent, taken: c.classList.contains('is-taken'), mine: c.classList.contains('is-mine'),
|
||||
armed: c.classList.contains('is-armed'), disabled: c.disabled, badge: !!c.querySelector('.spcard__taker'),
|
||||
team: !!c.querySelector('.spcard__tag--team'), desc: c.querySelector('.spcard__desc')?.textContent, w: c.getBoundingClientRect().width,
|
||||
})));
|
||||
|
||||
for (const [name, w, h, touch] of [['756x366', 756, 366, true], ['1920x1080', 1920, 1080, false]]) {
|
||||
test(`${name}: two 补给 are two cards; the second stays pickable after the first is taken, and picking it sends its own index`, { timeout: 120000 }, async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=SP_DRAFT&variant=tactic`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.spov__grid .spcard', { timeout: 20000 });
|
||||
await sleep(600);
|
||||
const head = await page.$eval('.spov__title', (e) => e.textContent);
|
||||
assert.ok(head.includes('战术决策') && head.includes('进行协同调整,做好迎战准备。'), `the official header: ${head}`);
|
||||
// the teammate p4 has taken the first 补给 (slot 0)
|
||||
await page.evaluate(() => { const S = globalThis.__MOCK__.S(); S.pub.sp = { ...S.pub.sp, picks: { p4: 0 }, turn: 'p1' }; globalThis.__MOCK__.pushPublic(); });
|
||||
await sleep(300);
|
||||
let c = await cards(page);
|
||||
assert.equal(c.length, 6);
|
||||
assert.deepEqual(c.map((x) => x.name), ['补给', '补给', '谢拉格驰援', '列装', '莫斯提马的盟誓', '升华']);
|
||||
assert.deepEqual([c[0].team, c[0].desc, Math.round(c[0].w)], [c[1].team, c[1].desc, Math.round(c[1].w)], 'the same face');
|
||||
assert.ok(c[0].team && /2.*次刷新/.test(c[0].desc), `补给: ${c[0].desc}`);
|
||||
assert.ok(c[0].taken && c[0].badge && c[0].disabled, 'the first twin: taken, with the taker\'s avatar');
|
||||
assert.ok(!c[1].taken && !c[1].badge && !c[1].disabled, 'the second twin: free');
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}.png`) });
|
||||
// two taps on the second twin
|
||||
const tap = async (i) => { const btn = (await page.$$('.spov__grid .spcard'))[i]; if (touch) await btn.tap(); else await btn.click(); await sleep(250); };
|
||||
await tap(1);
|
||||
c = await cards(page);
|
||||
assert.deepEqual(c.map((x) => x.armed), [false, true, false, false, false, false], 'only the tapped twin is armed');
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}-armed.png`) });
|
||||
await tap(1);
|
||||
const picks = await page.evaluate(() => ({ ...globalThis.__MOCK__.S().pub.sp.picks }));
|
||||
assert.deepEqual(picks, { p4: 0, p1: 1 }, 'g.choice sent the second twin\'s index');
|
||||
await sleep(250);
|
||||
c = await cards(page);
|
||||
assert.ok(c[1].mine && c[1].badge, 'the second twin is mine');
|
||||
assert.ok(c[0].taken && !c[0].mine, 'the first twin stays the teammate\'s');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// The 战术决策 overlay with the same card twice (the user: "战术决策也按官方改成可以重复吧"; official match 7 R11: 补给 ×2).
|
||||
// A real server draft (server/match/choices.js generateDraft → cardView; co-op 绝境 R11, seed 30: 补给 twice), through
|
||||
// m.public's normalisation (ui/gameLogic.js normalizeSp), rendered by the overlay's pure view (ui/choiceOverlay.js
|
||||
// ChoiceView): two identical cards are two buttons keyed by their index, with the same face (name, icon, 全队获得, text) —
|
||||
// selecting one does not select the other, a teammate taking one leaves the other pickable, and the pick sends that
|
||||
// card's own index. The header is the official one. The browser render is test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
// the browser data store reads the real data files from disk
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
|
||||
const { ChoiceView, cardPickable, spTap, armedCard, resolveSpCard } = await import('../../public/js/ui/choiceOverlay.js');
|
||||
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
const { GameData } = await import('../../server/match/gamedata.js');
|
||||
const { generateDraft, cardView } = await import('../../server/match/choices.js');
|
||||
const { createRng } = await import('../../server/sim/rng.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
|
||||
/** Every vnode of a preact tree (htm output), depth first. */
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const text = (v) => [...walk(v)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : Array.isArray(n.props?.children) ? n.props.children.filter((x) => typeof x === 'string') : [])).join('');
|
||||
|
||||
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
|
||||
|
||||
test('战术决策 overlay: the same card twice is two cards — own keys, same face, own selection, one taken leaves the other pickable', () => {
|
||||
const d = generateDraft(new GameData(DATA, 'mode_multi_hard'), createRng(30), 11, { stageId: 'act1autochess_m02' });
|
||||
assert.equal(d.family, 'tactic');
|
||||
const at = d.cards.flatMap((c, i) => (c.name === '补给' ? [i] : []));
|
||||
assert.equal(at.length, 2, 'seed 30: 补给 twice');
|
||||
const [a, b] = at;
|
||||
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
|
||||
const pub = (picks) => normalizeSp({ family: 'tactic', name: d.name, desc: d.desc, cards: d.cards.map(cardView), order: ['p2', 'me'], turn: Object.keys(picks).length ? 'me' : 'p2', picks, taken: {} }, players);
|
||||
const view = (sp, armed = null) => [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed }))];
|
||||
const cardsOf = (nodes) => nodes.filter((n) => hasClass(n, 'spcard'));
|
||||
|
||||
// the official header
|
||||
let sp = pub({});
|
||||
const all = view(sp);
|
||||
assert.ok(text(all.find((n) => hasClass(n, 'spov__title'))).includes('战术决策'));
|
||||
assert.equal(all.find((n) => hasClass(n, 'spov__desc')).props.children.props.text, '进行协同调整,做好迎战准备。');
|
||||
|
||||
// six buttons keyed by index; the twins have the same name, icon, team tag and text
|
||||
let cards = cardsOf(all);
|
||||
assert.equal(cards.length, 6);
|
||||
assert.deepEqual(cards.map((c) => c.key), [0, 1, 2, 3, 4, 5], 'keyed by index, never by card id');
|
||||
const face = (c) => ({ name: [...walk(c)].find((n) => hasClass(n, 'spcard__name')).props.children, team: [...walk(c)].some((n) => hasClass(n, 'spcard__tag--team')), title: c.props.title });
|
||||
assert.deepEqual(face(cards[a]), face(cards[b]));
|
||||
assert.equal(face(cards[a]).name, '补给');
|
||||
assert.equal(face(cards[a]).team, true, '全队获得');
|
||||
assert.deepEqual(resolveSpCard(sp.cards[a], 'tactic'), resolveSpCard(sp.cards[b], 'tactic'));
|
||||
assert.equal(sp.cards[a].id, sp.cards[b].id);
|
||||
assert.notEqual(sp.cards[a].idx, sp.cards[b].idx);
|
||||
|
||||
// the teammate takes the first twin: only that card shows the taker; the second twin is still mine to pick
|
||||
sp = pub({ p2: a });
|
||||
assert.equal(sp.cards[a].takenBy, 'p2');
|
||||
assert.equal(sp.cards[b].takenBy, null);
|
||||
assert.equal(cardPickable(sp, sp.cards[a], { myId: 'me', solo: false }), false);
|
||||
assert.equal(cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }), true);
|
||||
cards = cardsOf(view(sp));
|
||||
assert.ok(hasClass(cards[a], 'is-taken') && !hasClass(cards[b], 'is-taken'));
|
||||
assert.ok([...walk(cards[a])].some((n) => hasClass(n, 'spcard__taker')) && ![...walk(cards[b])].some((n) => hasClass(n, 'spcard__taker')));
|
||||
assert.equal(cards[a].props.disabled, true);
|
||||
assert.equal(cards[b].props.disabled, false);
|
||||
|
||||
// two taps on the second twin: it alone is armed, then its own index is sent
|
||||
let r = spTap(null, b, cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }));
|
||||
assert.deepEqual(r, { armed: b, pick: null });
|
||||
assert.equal(armedCard(r.armed, sp, { myId: 'me', solo: false }), b);
|
||||
cards = cardsOf(view(sp, r.armed));
|
||||
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), cards.map((_, i) => i === b), 'only the tapped twin is armed');
|
||||
r = spTap(r.armed, b, true);
|
||||
assert.deepEqual(r, { armed: null, pick: b });
|
||||
// after my pick: the first twin the teammate's, the second mine
|
||||
sp = pub({ p2: a, me: b });
|
||||
cards = cardsOf(view(sp));
|
||||
assert.ok(hasClass(cards[b], 'is-mine') && !hasClass(cards[a], 'is-mine'));
|
||||
assert.ok(cards.every((c) => c.props.disabled), 'nothing left to pick for me');
|
||||
});
|
||||
+34
-1
@@ -2460,6 +2460,23 @@ const SHOP_DRAFT = {
|
||||
seen: { 变形同构体: 4, 天师古鼎: 1, 人事部文档: 1, 家族徽章: 1, 铳骑之威: 1, 天马之盔: 2, 双模机械臂: 2, 商业包装方案: 2, 博士投影: 1, 护盾无人机: 1, 寻呼模块: 1, 骑士储蓄罐: 1, 盟约之币: 6 },
|
||||
matches: [2, 4, 5, 8],
|
||||
};
|
||||
/**
|
||||
* The official 战术决策 (R11 of matches 7, 9, 18 and 20; the user: "战术决策也按官方改成可以重复吧"): six cards, and the same
|
||||
* card can be offered twice (补给 ×2 in match 7; the other three show six different cards). All 24 cards are ally cards
|
||||
* (allybuff_select_*: 盟誓, 驰援, 列装 / 财富 / 补给 / 整备 / 升华, 锐利): no 排斥 / 责罚 / 裁决 debuff (four drafts without
|
||||
* one: about 0.04 % for the previous draw, 6 different cards of the 26 ally + 9 debuff cards) and no 模拟战场演变 terrain
|
||||
* card. 列装 / 财富 / 补给 / 整备 / 升华 made 12 of the 24 cards (a uniform draw of the 26 ally cards: about 0.07 %), so
|
||||
* the draw is weighted. At R11 (`rounds`; 绝境 / 终极 only, the round of the screenshots): each card drawn on its own (with
|
||||
* replacement) from the ally cards (`kinds`), weight 1 + the official cards it showed on (`seen`) [ASSUMED: the weights;
|
||||
* terrain cards left out — the maps of the 4 matches are unknown]; solo shows 3 [ASSUMED]. Other rounds (标准 / 险境, no
|
||||
* screenshot) keep every card, uniform, terrain cards only for the match stage — with replacement too [ASSUMED].
|
||||
*/
|
||||
const TACTIC_DRAFT = {
|
||||
rounds: [11],
|
||||
kinds: ['ally'],
|
||||
seen: { 补给: 3, 列装: 3, 升华: 3, 莫斯提马的盟誓: 2, 银灰的盟誓: 2, 阿戈尔驰援: 2, 财富: 2, 谢拉格驰援: 1, 锐利: 1, 整备: 1, 玛恩纳的盟誓: 1, 斯卡蒂的盟誓: 1, 萨尔贡驰援: 1, 叙拉古驰援: 1 },
|
||||
matches: [7, 9, 18, 20],
|
||||
};
|
||||
/** 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');
|
||||
|
||||
@@ -2566,6 +2583,13 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
tactic.push({ effectId: e.effectId, name: e.name, desc: e.desc, kind, stageId, team });
|
||||
}
|
||||
tactic.sort((a, b) => naturalCmp(a.effectId, b.effectId));
|
||||
// the official 战术决策 (TACTIC_DRAFT) names cards of its `kinds` only
|
||||
const tacticByName = (name) => {
|
||||
const hits = tactic.filter((c) => c.name === name);
|
||||
if (hits.length !== 1) warn(`tactic draft card "${name}": ${hits.length} cards of that name`);
|
||||
if (hits[0] && !TACTIC_DRAFT.kinds.includes(hits[0].kind)) warn(`tactic draft card "${name}" is a ${hits[0].kind} card`);
|
||||
return hits[0]?.effectId ?? null;
|
||||
};
|
||||
|
||||
const equipNormal = Object.values(items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden);
|
||||
const itemByName = (name) => equipNormal.find((i) => i.name === name)?.id || (warn(`pool item "${name}" not found`), null);
|
||||
@@ -2620,7 +2644,8 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'bountyDrafts[schedule[*].bountyDraft] — one official card list of the round (initial: an R3 set of 6; boss: an R9 group of up to 9; hunter: one of the 7 seen R11 lists of 7), six different cards of it drawn by weight, 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: 'at shopDraft.rounds (R11): six slots drawn with replacement (VI, VI, V, 盟约之币, 2 × V / IV / III / 盟约之币); other rounds: random normal EQUIP shop items of tiers I–VI (duplicates allowed) — the same item can come twice' },
|
||||
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
|
||||
// desc = the official header (effectChoiceInfoDict buff_select_* / hardbuff_select_* "进行协同调整,做好迎战准备。")
|
||||
tactic: { name: '战术决策', desc: '进行协同调整,做好迎战准备。', cards: 'cards.tactic, each card drawn on its own (with replacement) — the same card can come twice; at tacticDraft.rounds (R11) the ally cards by tacticDraft.weights, other rounds every card uniform (terrain cards only for the match stage)' },
|
||||
},
|
||||
format: {
|
||||
multi: { cards: 6, pickOrder: 'random', firstPickSec: 30, otherPickSec: 16, onTimeout: 'autoPickRandom', eachPlayerPicks: 1 },
|
||||
@@ -2658,6 +2683,14 @@ function buildChoices(ctx, effects, items, chess) {
|
||||
seen: SHOP_DRAFT.matches, count: 6,
|
||||
assumed: ['the slot split', 'item weights 1 + seen', 'solo shows 3 of the 6', 'other rounds (标准 / 险境) keep the previous draw: tiers I–VI with replacement'],
|
||||
},
|
||||
// the official 战术决策 (TACTIC_DRAFT) at `rounds`: each card drawn on its own (with replacement) from the cards.tactic
|
||||
// entries of `kinds`, by `weights` (1 + the official cards it showed on; other cards of those kinds 1)
|
||||
tacticDraft: {
|
||||
rounds: TACTIC_DRAFT.rounds, kinds: TACTIC_DRAFT.kinds,
|
||||
weights: Object.fromEntries(Object.entries(TACTIC_DRAFT.seen).map(([n, k]) => [tacticByName(n), 1 + k]).filter(([id]) => id).sort((a, b) => naturalCmp(a[0], b[0]))),
|
||||
seen: TACTIC_DRAFT.matches, count: 6,
|
||||
assumed: ['card weights 1 + seen', 'no terrain card at R11', 'solo shows 3', 'other rounds (标准 / 险境) keep every card, uniform, with replacement'],
|
||||
},
|
||||
schedule,
|
||||
pools: {
|
||||
pool_equip_normal: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },
|
||||
|
||||
Reference in new issue
Block a user