feedback7: merge fb7-dobermann — 教鞭 is the official three-choice (PR #353 by @Sukvii, the owner's decision), the Final Assault / Hidden Core pay a 战术特训 card's perfect coins (guarded by b.progress leaksBy), the bots put zero-range heavy guards on the road first (PR #339 by @IceCodeNew, placement only)
- Conflicts (the branch started atae22335c): spec.js / protocol.js b.progress (resolved from fb7-ui-backlog + leaksBy), gameLogic.js / game.js exports (device tips + normalizePersonalChoice), docs/PLAYING.md / design/network.md lines merged against the base, META.md (画卷 note + 教鞭), test/docs-consistency.test.js pin (both fields). - Grok review: MERGE AFTER FIXES — the HIGH (a forged boss result paid the cards) fixed on the branch (66a228c6), the META wording (1ab12763). - Golden: matches 10 (the 杜宾 bot picks and the guard placement); union: coop3-ABYSS-5 differs from HEAD only in ai_2's dmgDealt (1238011 → 1186269), no outcome change — HEAD's side had moved it, the new placement applies on that path. - Broad node --test 5623 (5621 pass, 0 fail, 2 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
commit
2de2feee3c
57 files changed
+1339
-276
No files matched your search
@@ -38,9 +38,45 @@ test('bounty pick: the card the own board can beat, never one it cannot (even wh
|
||||
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
|
||||
ps.lp = 2;
|
||||
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
|
||||
const raw = [hard, easy].map((c) => DATA.choices.cards.bounty.find((x) => x.effectId === c.id));
|
||||
assert.deepEqual(m.offerBountyChoice(ps, raw, 'chess_item_6_03_m'), { ok: true });
|
||||
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.bounties.at(-1).card.enemyKey, easy.enemyKey, 'personal cards use bounty scoring too');
|
||||
const metaState = m.rngMeta.state(), botState = m.rngBots.state();
|
||||
m.autoPickPersonalChoice(ps, 'bot');
|
||||
m.autoPickPersonalChoice(ps, 'random');
|
||||
assert.equal(m.rngMeta.state(), metaState);
|
||||
assert.equal(m.rngBots.state(), botState, 'nothing pending: no RNG draws');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a bot\'s 教鞭 pick is deterministic: one seed offers the same three cards and picks the same one, drawing only on the bots\' rng', () => {
|
||||
const runs = [];
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const h = soloBot({ seed: 5, difficulty: 'HARD' }).start();
|
||||
const m = h.m;
|
||||
const ps = m.order[0];
|
||||
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||
ps.lp = 999;
|
||||
h.run(() => m.phase === PHASE.PREP && m.round === 5);
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const offered = ps.personalChoice.cards.map((c) => c.effectId);
|
||||
assert.equal(offered.length, 3);
|
||||
assert.equal(new Set(offered).size, 3, 'three different cards');
|
||||
const meta = m.rngMeta.state(), bots = m.rngBots.state();
|
||||
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
|
||||
assert.equal(m.rngMeta.state(), meta, 'the pick leaves the meta rng alone');
|
||||
assert.notEqual(m.rngBots.state(), bots, 'one scoring draw per card from the bots\' rng');
|
||||
assert.equal(ps.personalChoice, null);
|
||||
runs.push({ offered, picked: ps.bounties.at(-1).card.effectId, bots: m.rngBots.state() });
|
||||
assert.ok(offered.includes(runs[k].picked));
|
||||
m.dispose();
|
||||
}
|
||||
assert.deepEqual(runs[0], runs[1]);
|
||||
});
|
||||
|
||||
test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => {
|
||||
let drafts = 0;
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
@@ -138,9 +174,12 @@ test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect
|
||||
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
|
||||
h.run(() => m.phase === PHASE.PREP && m.round === 4);
|
||||
m.lastResults.set(ps.playerId, clean);
|
||||
giveItem(m, ps, WHIP);
|
||||
const bounties = ps.bounties.length;
|
||||
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
|
||||
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
|
||||
assert.ok(!owns(ps, WHIP) && ps.bounties.length === bounties + 2, 'both Arts used and picked after a perfect battle');
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.round.arts, 2);
|
||||
assert.equal(m.errorCount, 0);
|
||||
m.dispose();
|
||||
}
|
||||
|
||||
+79
-2
@@ -3,11 +3,62 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
|
||||
import { FIELD, canPlace, placeClass, parseKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
|
||||
import { FIELD, canPlace, placeClass, parseKey, legalTiles, tileKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
|
||||
test('user: Given a saturated layout, When placing a self-range defender, Then prefer a free enemy path tile', () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 11 }).start().toPrep();
|
||||
const m = h.m;
|
||||
const ps = m.order[0];
|
||||
try {
|
||||
const pieces = ['chess_char_6_03_a', 'chess_char_5_11_a', 'chess_char_1_02_a',
|
||||
'chess_char_1_10_a', 'chess_char_2_08_a', 'chess_char_3_16_a'].map((id) => give(m, ps, id, 'hand'));
|
||||
const plan = planLayout(m, ps, pieces);
|
||||
// act2autochess_m04, first wave: the ground route crosses these deployable tiles.
|
||||
const road = new Set(['9,9', '9,8', '9,7', '9,6', '11,5', '11,4', '10,4', '9,4', '9,3']);
|
||||
assert.equal(m.stageId, 'act2autochess_m04');
|
||||
assert.ok(road.has(plan.get(pieces.at(-1).uid)), `Cuora must block the route, got ${plan.get(pieces.at(-1).uid)}`);
|
||||
for (const [key, expectedToBlock] of [[plan.get(pieces.at(-1).uid), true], ['12,5', false]]) {
|
||||
const [row, col] = parseKey(key);
|
||||
const battle = makeBattle({ stageId: m.stageId, waveTemplate: 'act1autochess_01',
|
||||
units: [{ chessId: 'chess_char_3_16_a', row, col }] });
|
||||
battle.runToEnd();
|
||||
assert.equal(battle.hooksOf('blocked').length > 0, expectedToBlock, `blocking at ${key}`);
|
||||
assert.equal(battle.result().perPlayer.p1.damageDealt > 0, expectedToBlock, `damage at ${key}`);
|
||||
}
|
||||
// When every path position is occupied, a legal fallback remains available.
|
||||
const fallback = planLayout(m, ps, [pieces.at(-1)], undefined, { occupied: road });
|
||||
const key = fallback.get(pieces.at(-1).uid);
|
||||
assert.ok(key && !road.has(key));
|
||||
assert.equal(ps.deployMap().get(key), 'melee');
|
||||
} finally { m.dispose(); }
|
||||
});
|
||||
|
||||
test('self-range blockers (range 0-1, only their own tile) fill the free enemy-road tiles before any other tile, on every stage and seed', () => {
|
||||
// official range_table "0-1": the one grid (0,0) — 角峰 古米 泡泡 折桠 菲莱 蛇屠箱 塞雷娅 余
|
||||
const guards = Object.values(DATA.chess).filter((c) => c && !c.isGolden && c.rangeGrid && c.rangeGrid.length === 1 && c.rangeGrid[0].every((v) => v === 0));
|
||||
assert.equal(guards.length, 8);
|
||||
const stages = new Set();
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
const h = makeMatch({ mode: 'solo', seed }).start().toPrep();
|
||||
const m = h.m;
|
||||
const ps = m.order[0];
|
||||
try {
|
||||
stages.add(m.stageId);
|
||||
const pieces = guards.map((c) => give(m, ps, c.chessId, 'hand'));
|
||||
const plan = planLayout(m, ps, pieces);
|
||||
const model = fieldModel(m, ps);
|
||||
const road = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => model.ground.has(tileKey(r, c)));
|
||||
const onRoad = pieces.filter((p) => model.ground.has(plan.get(p.uid))).length;
|
||||
assert.equal(onRoad, Math.min(pieces.length, road.length), `seed ${seed}, ${m.stageId}: ${onRoad} of ${pieces.length} guards on the ${road.length} road tiles`);
|
||||
} finally { m.dispose(); }
|
||||
}
|
||||
assert.equal(stages.size, 8, 'every stage was tried');
|
||||
});
|
||||
|
||||
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
|
||||
const h = soloBot({ seed: 2 }).start();
|
||||
const m = h.m;
|
||||
@@ -222,6 +273,32 @@ test('a sliced rehearsal is dropped when the prep ends first; the default layout
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('AI takeover resolves a personal choice in untimed PREP, including generator-error Ready cleanup', () => {
|
||||
for (const broken of [false, true]) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, seed: 6, fake: true, botSliceMs: 0 }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
assert.equal(m.deadline, 0, 'single-human co-op is untimed');
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const pick = m.autoPickPersonalChoice.bind(m), modes = [];
|
||||
m.autoPickPersonalChoice = (p, mode) => {
|
||||
if (p.personalChoice) modes.push(mode);
|
||||
if (broken && mode === 'bot') throw new Error('scripted choice scoring failure');
|
||||
return pick(p, mode);
|
||||
};
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.autoplay', on: true }), { ok: true });
|
||||
assert.ok(h.run(() => m.phase !== PHASE.PREP));
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.bounties.length, 1);
|
||||
assert.equal(ps.round.arts, 1);
|
||||
assert.ok(ps.ready);
|
||||
assert.ok(modes.includes(broken ? 'random' : 'bot'));
|
||||
assert.equal(m.errorCount > 0, broken);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
|
||||
for (const seed of [5, 8]) {
|
||||
const h = soloBot({ seed }).start();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S, isBattleResult } from '../../shared/protocol.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js';
|
||||
import { DataSource } from '../../server/sim/simdata.js';
|
||||
import { validateClientResult, specBounds } from '../../server/match/fields.js';
|
||||
import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js';
|
||||
import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
|
||||
@@ -516,6 +516,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
|
||||
|
||||
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
|
||||
const { h, m, f } = faWithForger(9122);
|
||||
const ps = h.ps('p_0');
|
||||
const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
m.addBounty(ps, { ...training, rounds: 2, multiRound: false });
|
||||
const pending = ps.pendingFunds;
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
|
||||
const gt = m._fieldElapsed(f);
|
||||
@@ -544,10 +548,141 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\'
|
||||
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
|
||||
assert.equal(m.verifyStats.takeovers, takeovers0);
|
||||
assert.equal(m.verifyStats.rejected, rejected0);
|
||||
assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount');
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||||
assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice');
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => {
|
||||
for (const [ending, synthetic, perfect, leaked, eligible] of [
|
||||
['cleared', false, true, [], true],
|
||||
['cleared', true, true, [], false],
|
||||
['cleared', false, false, [], false],
|
||||
['cleared', false, true, [{ counted: true }], false],
|
||||
['cleared', false, true, [{ counted: false }], true],
|
||||
['forced', false, true, [], false],
|
||||
]) {
|
||||
const { m, f } = faWithForger(9122);
|
||||
const ps = m.players.get(f.players[0]);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
const res = syntheticResult(f.players);
|
||||
if (!synthetic) delete res.synthetic;
|
||||
res.reason = 'forced';
|
||||
Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 });
|
||||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||||
m._finalEnding = ending;
|
||||
m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat
|
||||
m._finishFinal(false, () => res);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0));
|
||||
assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0));
|
||||
assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) -------
|
||||
|
||||
/**
|
||||
* A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same
|
||||
* perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player
|
||||
* split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight
|
||||
* is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged.
|
||||
*/
|
||||
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
|
||||
const { h, m, f } = faWithForger(seed);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const ps = f.players.map((pid) => h.ps(pid));
|
||||
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const before = ps.map((p) => p.pendingFunds);
|
||||
const lp0 = m.teamLp;
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(500);
|
||||
const gt = m._fieldElapsed(f);
|
||||
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
|
||||
const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) };
|
||||
assert.equal(validateC2S(progress), null);
|
||||
assert.deepEqual(m.handle('p_0', progress), { ok: true });
|
||||
const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true });
|
||||
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
|
||||
perPlayer: Object.fromEntries(f.players.map((pid) => {
|
||||
const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid));
|
||||
return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }];
|
||||
})) };
|
||||
assert.equal(isBattleResult(res), true);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||||
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
|
||||
const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) };
|
||||
m.dispose();
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => {
|
||||
const r = bossPaid(9124, { leaks: 1, leaked: {} });
|
||||
assert.equal(r.ending, 'cleared');
|
||||
assert.equal(r.source, 'client');
|
||||
assert.equal(r.charged, 1, 'the server charged the team leak');
|
||||
assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed');
|
||||
assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles');
|
||||
});
|
||||
|
||||
test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => {
|
||||
// progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway)
|
||||
const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } });
|
||||
assert.equal(r.ending, 'cleared');
|
||||
assert.equal(r.charged, 1);
|
||||
assert.deepEqual(r.paid, [0, 0]);
|
||||
// …and with no split at all the result decides nothing either: the same leak pinned on p_1
|
||||
assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]);
|
||||
});
|
||||
|
||||
test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => {
|
||||
const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } });
|
||||
assert.equal(leak.charged, 1);
|
||||
assert.deepEqual(leak.paid, [leak.coin, 0]);
|
||||
// 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect
|
||||
const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} });
|
||||
assert.equal(fx.charged, 2);
|
||||
assert.deepEqual(fx.paid, [fx.coin, fx.coin]);
|
||||
// nothing charged, nothing leaked: as before
|
||||
assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]);
|
||||
assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0));
|
||||
});
|
||||
|
||||
test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start();
|
||||
const m = h.m;
|
||||
h.autoHumans();
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
|
||||
for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const [a, b] = [p0.pendingFunds, p1.pendingFunds];
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, true);
|
||||
const f = fields(h)[0];
|
||||
assert.equal(f.resultSource, 'client');
|
||||
assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2');
|
||||
assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0');
|
||||
assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid');
|
||||
assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => {
|
||||
const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } };
|
||||
assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null);
|
||||
assert.equal(validateC2S({ ...base, leaksBy: {} }), null);
|
||||
assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates');
|
||||
for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad));
|
||||
});
|
||||
|
||||
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
|
||||
const { h, m, f } = faWithForger(9123);
|
||||
const max = m.bossPool.maxHp;
|
||||
|
||||
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import { PHASE, ERR } from '../../shared/constants.js';
|
||||
import { Match } from '../../server/match/Match.js';
|
||||
import { StubMatch } from '../../server/match/StubMatch.js';
|
||||
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
|
||||
import { DATA, makeMatch, checkInvariants, give, giveItem, chessOfTier } from './harness.js';
|
||||
import { bossPoolHp } from '../../server/match/finalAssault.js';
|
||||
|
||||
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
|
||||
@@ -51,6 +51,7 @@ test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effect
|
||||
assert.ok(s.kind === 'chess' || s.kind === 'item');
|
||||
}
|
||||
assert.equal(priv.shop.rewardOffer, null);
|
||||
assert.equal(priv.personalChoice, null);
|
||||
assert.equal(priv.deployCap, 8);
|
||||
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
|
||||
assert.ok(priv.nextEnemies.length > 0);
|
||||
@@ -109,6 +110,45 @@ test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at dea
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
|
||||
for (const ps of [a, b]) {
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
}
|
||||
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
|
||||
assert.notEqual(aChoice.id, bChoice.id);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
|
||||
assert.equal(a.bounties.length + b.bounties.length, 0);
|
||||
m.onDisconnect('p_0');
|
||||
m.onReconnect('p_0');
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
|
||||
m.flush(true);
|
||||
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
|
||||
assert.ok(!h.allTo('watcher', 'm.private').length);
|
||||
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
|
||||
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||||
const next = giveItem(m, a, 'chess_item_6_03_m');
|
||||
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
|
||||
const nextId = a.personalChoice.id;
|
||||
assert.notEqual(nextId, aChoice.id);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||||
assert.equal(a.personalChoice.id, nextId);
|
||||
assert.equal(a.bounties.length, 1);
|
||||
m.onDisconnect('p_1');
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
|
||||
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
|
||||
const m = h.m;
|
||||
|
||||
@@ -150,6 +150,8 @@ test('机变 (co-op): 6 shared cards, random order, 30 s first / 16 s others, ti
|
||||
assert.deepEqual(sp.order.slice().sort(), ['p_0', 'p_1', 'p_2']);
|
||||
assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 30, 'first picker 30 s');
|
||||
const [a, b, c] = sp.order;
|
||||
assert.equal(m2.handle(a, { t: 'g.choice', idx: 2, choiceId: 'old.choice.1' }).error, ERR.WRONG_PHASE);
|
||||
assert.deepEqual(m2.sp.picks, {}, 'personal IDs never fall through to the global draft');
|
||||
assert.equal(m2.handle(b, { t: 'g.choice', idx: 0 }).error, ERR.NOT_YOUR_TURN);
|
||||
assert.deepEqual(m2.handle(a, { t: 'g.choice', idx: 2 }), { ok: true });
|
||||
assert.equal(m2.handle(a, { t: 'g.choice', idx: 3 }).error, ERR.ALREADY);
|
||||
|
||||
@@ -214,6 +214,15 @@ test('ready gating: temp must be empty (a freed hand slot pulls the temp piece i
|
||||
assert.equal(a.hand[3], t);
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(a.privateView().canReady, true);
|
||||
const art = giveItem(m, a, 'chess_item_6_03_m', 'temp');
|
||||
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(a.privateView().canReady, false);
|
||||
assert.deepEqual(a.setReady(true), { error: ERR.BAD_TARGET, detail: '请先完成教鞭选择' });
|
||||
assert.deepEqual(a.setReady(false), { ok: true });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: a.personalChoice.id }), { ok: true });
|
||||
assert.equal(a.privateView().canReady, true);
|
||||
assert.equal(a.round.arts, 1);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
|
||||
@@ -235,13 +244,26 @@ test('prep deadline (co-op): unready players are auto-readied and their temp is
|
||||
const a = h.ps('p_0');
|
||||
const id = chessOfTier(1).find((x) => m.pool.has(x));
|
||||
const left = m.pool.left(id);
|
||||
const art = giveItem(m, a, 'chess_item_6_03_m');
|
||||
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
|
||||
fillHand(a);
|
||||
give(m, a, id, 'temp');
|
||||
assert.ok(!a.tempEmpty, 'a full hand: it waits in temp');
|
||||
const endPrep = m.endPrep.bind(m);
|
||||
let checked = false;
|
||||
m.endPrep = () => {
|
||||
assert.equal(a.personalChoice, null, 'deadline picks before endPrep builds the battle');
|
||||
assert.equal(a.bounties.length, 1);
|
||||
assert.equal(a.round.arts, 1);
|
||||
assert.ok(a.ready && a.tempEmpty);
|
||||
checked = true;
|
||||
endPrep();
|
||||
};
|
||||
assert.ok(m.deadline > h.sched.now(), 'co-op prep is timed');
|
||||
assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1));
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(checked);
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
|
||||
checkInvariants(m);
|
||||
@@ -252,7 +274,12 @@ test('solo prep and 机变 are untimed', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start();
|
||||
h.toPrep(1);
|
||||
assert.equal(h.m.deadline, 0);
|
||||
const ps = h.ps('p_0');
|
||||
const art = giveItem(h.m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const id = ps.personalChoice.id;
|
||||
h.sched.advance(10 * 60 * 1000);
|
||||
assert.equal(h.m.phase, PHASE.PREP);
|
||||
assert.equal(ps.personalChoice.id, id, 'no separate personal-choice timer');
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { fieldModel } from '../../server/match/bot.js';
|
||||
import { FIELD, parseKey } from '../../server/match/board.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
import { DATA, makeMatch, checkInvariants, giveItem } from './harness.js';
|
||||
|
||||
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
|
||||
|
||||
@@ -56,6 +56,90 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金");
|
||||
// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income.
|
||||
test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(14);
|
||||
const ps = h.ps('p_0');
|
||||
const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||||
m.addBounty(ps, card);
|
||||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
m.hiddenLayerSum = 1e6; // the Hidden Core opens
|
||||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle');
|
||||
h.run(() => m.runner === null);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count');
|
||||
assert.equal(ps.stats.fundsGained, gained + 3 + card.coin);
|
||||
const left = bountyBattles(card);
|
||||
assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles');
|
||||
if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1);
|
||||
h.toPrep(15);
|
||||
assert.equal(ps.pendingFunds, 0);
|
||||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds');
|
||||
m.addBounty(ps, second);
|
||||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core');
|
||||
h.runToEnd();
|
||||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||||
assert.equal(ps.bounties.length, 0, 'every bounty used its battles');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('教鞭 in the boss rounds (R14, R15): the card the owner chose spawns in the boss battle and pays at the next income, the Hidden Core takes another choice', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(14);
|
||||
const ps = h.ps('p_0');
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.equal(ps.bounties.length, 0, 'nothing is applied before the owner confirms');
|
||||
assert.equal(ps.setReady(true).error, 'BAD_TARGET', 'Ready waits for the choice');
|
||||
const card = ps.personalChoice.cards[2];
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 2, choiceId: ps.personalChoice.id }), { ok: true });
|
||||
assert.equal(ps.bounties[0].card.effectId, card.effectId, 'the card of the tapped slot');
|
||||
const pending = ps.pendingFunds;
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
m.hiddenLayerSum = 1e6;
|
||||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the chosen card\'s enemies come in the boss battle');
|
||||
h.run(() => m.runner === null);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + the chosen card\'s coins');
|
||||
h.toPrep(15);
|
||||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'paid at the R15 income, apart from the base income');
|
||||
const hiddenArt = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(hiddenArt.uid, 10, 5), { ok: true });
|
||||
const hiddenCard = ps.personalChoice.cards[0];
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id }), { ok: true });
|
||||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === hiddenCard.enemyKey));
|
||||
h.runToEnd();
|
||||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start();
|
||||
h.toPrep(14);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
const gained = ps.stats.fundsGained;
|
||||
const result = h.drive(() => h.ended != null);
|
||||
assert.ok(result && h.ended.victory);
|
||||
assert.equal(ps.pendingFunds, 3);
|
||||
assert.equal(ps.stats.fundsGained, gained + 3);
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
|
||||
const m = h.m;
|
||||
|
||||
@@ -397,6 +397,22 @@ test('collectViolations reports corrupted state (funds, pool accounting, duplica
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('collectViolations: a 教鞭 choice belongs to its owner\'s open prep only', () => {
|
||||
const { m, ps } = prepSolo({ seed: 922 });
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.deepEqual(collectViolations(m), [], 'an open choice in its own prep is fine');
|
||||
const pc = ps.personalChoice;
|
||||
pc.round = m.round - 1;
|
||||
assert.match(collectViolations(m).join('\n'), /personal choice outside its own prep/, 'a choice left from an earlier round');
|
||||
pc.round = m.round;
|
||||
pc.cards = [pc.cards[0], pc.cards[0]];
|
||||
assert.match(collectViolations(m).join('\n'), /personal choice of 2 cards/, 'the same card twice');
|
||||
ps.personalChoice = null;
|
||||
assert.deepEqual(collectViolations(m), []);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the rule auditor flags an LP loss that does not match the leaks (engine regression guard)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 921, fake: true, script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, 2])) } : {}) }).start();
|
||||
const m = h.m;
|
||||
|
||||
@@ -124,6 +124,7 @@ export function makeMatch(o = {}) {
|
||||
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
|
||||
}
|
||||
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
|
||||
if (ps.personalChoice) m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id });
|
||||
if (!ps.tempEmpty) ps.resolveTemp();
|
||||
m.handle(ps.playerId, { t: 'g.ready', ready: true });
|
||||
}
|
||||
|
||||
@@ -217,6 +217,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
|
||||
assert.ok(last.bossDmg <= pool.cum);
|
||||
assert.ok(last.by && typeof last.by === 'object');
|
||||
assert.equal(typeof last.leaks, 'number');
|
||||
// the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the
|
||||
// leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest)
|
||||
assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy');
|
||||
assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort());
|
||||
assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0));
|
||||
assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9);
|
||||
r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
|
||||
assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged');
|
||||
r.runner.dispose();
|
||||
|
||||
@@ -135,6 +135,7 @@ export class SimClient {
|
||||
if (bossLike) {
|
||||
const pool = e.battle.sharedBoss;
|
||||
msg.leaks = e.meter.lp;
|
||||
if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js
|
||||
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
|
||||
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
|
||||
} else {
|
||||
|
||||
@@ -42,6 +42,11 @@ test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.uni
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
|
||||
assert.ok(S2C.includes('m.unitStats'));
|
||||
assert.equal(validateC2S({ t: 'g.choice', idx: 0 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.choice', idx: 2, choiceId: 'seed-2.choice.12' }), null);
|
||||
for (const choiceId of [null, '', 'a b', 12, 'a'.repeat(65)]) {
|
||||
assert.notEqual(validateC2S({ t: 'g.choice', idx: 0, choiceId }), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
|
||||
|
||||
Reference in new issue
Block a user