Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -224,20 +224,20 @@ test('a terrain change by content (setDeviceActive at the prep end, no recompute
|
||||
|
||||
test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => {
|
||||
const { m, ps } = prepMatch('act2autochess_m04');
|
||||
// 赫默 → one 医疗探机 (deployLimit 1): once deployed no stack is left in the hand to join
|
||||
const medic = give(m, ps, 'chess_char_2_02_a');
|
||||
// 伺夜 → one 狼群 (deployLimit 1): once deployed no stack is left in the hand to join
|
||||
const medic = give(m, ps, 'chess_char_3_19_a');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
|
||||
const drone = ps.hand.find((p) => p && p.kind === 'token');
|
||||
assert.ok(drone && drone.count === 1, 'one drone');
|
||||
assert.ok(drone && drone.count === 1, 'one 狼群');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
|
||||
// golden items never merge with each other: a plain full hand
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
|
||||
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
|
||||
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
|
||||
assert.equal(ps.board.get('9,4'), drone, 'the drone stays on the board');
|
||||
assert.equal(ps.board.get('9,4'), drone, 'the 狼群 stays on the board');
|
||||
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
|
||||
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
|
||||
// a free slot: the drone comes back
|
||||
// a free slot: the 狼群 comes back
|
||||
ps.hand[3] = null;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
|
||||
assert.equal(ps.hand[3], drone);
|
||||
|
||||
@@ -175,13 +175,13 @@ test('联防: an operator dead at the end of the helper\'s own combat stays out
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const helper = h.ps('p_1');
|
||||
// 赫默 (placeable summon) is knocked out in p_1's own combat; a second operator survives it
|
||||
const t0 = legalTileFor(m, helper, 'chess_char_2_02_a');
|
||||
const hemo = give(m, helper, 'chess_char_2_02_a', 'board', t0);
|
||||
// 伺夜 (placeable talent summon 狼群) is knocked out in p_1's own combat; a second operator survives it
|
||||
const t0 = legalTileFor(m, helper, 'chess_char_3_19_a');
|
||||
const hemo = give(m, helper, 'chess_char_3_19_a', 'board', t0);
|
||||
const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
|
||||
const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other));
|
||||
const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
|
||||
assert.ok(stack, '赫默 brought its summon stack');
|
||||
assert.ok(stack, '伺夜 brought its summon stack');
|
||||
let placed = null;
|
||||
for (const [k] of helper.deployMap()) {
|
||||
const [r, c] = k.split(',').map(Number);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// INFO_CHECK, band draft (order / skip / timeouts / 队友已选) and 机变 SP drafts (order / timers / auto-assign / effects).
|
||||
// INFO_CHECK, band draft (order / skip / one turn clock / timeouts / highlighted band / 队友已选) and 机变 SP drafts (order /
|
||||
// timers / auto-assign / effects); a single human (solo or co-op with AI teammates) is never timed outside battles.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
|
||||
|
||||
const TURN_MS = BAND_TURN_SECONDS * 1000;
|
||||
|
||||
test('INFO_CHECK: ends when every human confirmed (bots/departed count as ready) or at the 25 s deadline', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 1 }).start();
|
||||
@@ -61,16 +65,27 @@ test('band draft (co-op): random order, one pick per turn, NOT_YOUR_TURN, one sk
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns the rest; bots pick by themselves', () => {
|
||||
test('band draft timers (user playtest #4 item 4): one countdown of BAND_TURN_SECONDS per turn → the highlighted band or 华法琳; AI seats pick at once; no step cap', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 2, seed: 9 }).start();
|
||||
const m = h.m;
|
||||
m.handle('p_0', { t: 'g.infoReady' });
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
// humans never act: each human turn times out after 12 s; bots pick within ~1 s
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 });
|
||||
// humans never act: each human turn times out after BAND_TURN_SECONDS; bots pick at once (no bot turn is timed out)
|
||||
const t0 = h.sched.now();
|
||||
const seen = [];
|
||||
h.onBroadcast.push((msg) => { if (msg.t === 'm.public' && msg.phase === PHASE.BAND_DRAFT && msg.draft) seen.push(msg); });
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
|
||||
assert.equal(m.phase, PHASE.BATTLE_CHECK);
|
||||
const took = h.sched.now() - t0;
|
||||
assert.ok(took >= 2 * TURN_MS - 5 && took <= 2 * TURN_MS + 50, `two human turns of ${BAND_TURN_SECONDS} s each (${took} ms)`);
|
||||
// every published draft view: the step's deadline IS the current turn's
|
||||
assert.ok(seen.length > 0);
|
||||
for (const pub of seen) {
|
||||
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
|
||||
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
|
||||
}
|
||||
// a timeout gives 华法琳 while no teammate holds it, else the first free strategy by sortId — never 队友已选
|
||||
// (Match.defaultBand): replay the assignments in draft order
|
||||
const taken = new Set();
|
||||
@@ -83,17 +98,22 @@ test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns
|
||||
}
|
||||
assert.equal(new Set(Object.values(m.draft.picks)).size, 4, 'no duplicate strategies');
|
||||
m.dispose();
|
||||
// overall cap: with 4 humans × 12 s > 50 s the last one is assigned at the cap
|
||||
// 4 humans idle: 4 whole turns (the old 50 s step cap would have cut the last ones short)
|
||||
const h2 = makeMatch({ mode: 'coop', humans: 4, seed: 9 }).start();
|
||||
for (const ps of h2.m.players.values()) h2.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h2.sched.advance(1);
|
||||
const t0 = h2.sched.now();
|
||||
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
|
||||
assert.ok(h2.sched.now() - t0 <= 50000 + 5, `ended by the 50 s cap (${h2.sched.now() - t0} ms)`);
|
||||
const first = h2.m.draft.order[0];
|
||||
assert.equal(h2.ps(first).bandId, 'band_bldsk', 'the first timeout gets 华法琳');
|
||||
assert.deepEqual(h2.m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
|
||||
const s0 = h2.sched.now();
|
||||
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
|
||||
const took2 = h2.sched.now() - s0;
|
||||
assert.ok(took2 >= 4 * TURN_MS - 5 && took2 <= 4 * TURN_MS + 50, `four turns (${took2} ms)`);
|
||||
assert.equal(h2.ps(first).bandId, 'band_amiya', 'the first timeout takes the highlighted strategy');
|
||||
assert.equal(h2.ps(h2.m.draft.order[1]).bandId, 'band_bldsk', 'the next one the official default');
|
||||
assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies');
|
||||
h2.m.dispose();
|
||||
// data and code say the same: data/config.json timers.bandTurn (tools/build-data.mjs) = Match.BAND_TURN_SECONDS
|
||||
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
|
||||
});
|
||||
|
||||
test('band draft (solo): free pick, no timer, no skip; mode-restricted bands rejected', () => {
|
||||
@@ -214,3 +234,43 @@ test('机变 tactic defaults: team cards reach teammates; layers / funds / free
|
||||
});
|
||||
|
||||
const awaitImport = await import('../../server/match/choices.js');
|
||||
|
||||
test('a single human (a 同盟 room started alone or with AI teammates only) is never timed outside battles — user playtest #4 item 3', () => {
|
||||
for (const bots of [0, 3]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots, seed: 12, fake: true, instant: false }).start();
|
||||
const m = h.m;
|
||||
assert.equal(m.soloUntimed, true);
|
||||
assert.equal(m.isSolo, false, 'the co-op mode rules stay');
|
||||
const seen = new Map(); // phase → deadlines published
|
||||
const note = (pub) => { if (!seen.has(pub.phase)) seen.set(pub.phase, []); seen.get(pub.phase).push(pub.deadline); };
|
||||
for (const msg of h.bc) if (msg.t === 'm.public') note(msg);
|
||||
h.onBroadcast.push((msg) => { if (msg.t === 'm.public') note(msg); });
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(10 * 60_000);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK, `briefing waits (${bots} AI)`);
|
||||
m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
h.run(() => m.draftTurn() === 'p_0', { maxTime: 1000 });
|
||||
h.sched.advance(10 * 60_000);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT, 'the strategy draft waits');
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'reached PREP R2');
|
||||
h.sched.advance(60 * 60_000);
|
||||
assert.equal(m.phase, PHASE.PREP, 'prep waits');
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 4), 'past the R3 机变');
|
||||
h.flushAll();
|
||||
for (const ph of [PHASE.INFO_CHECK, PHASE.BAND_DRAFT, PHASE.BATTLE_CHECK, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP, PHASE.SETTLE]) {
|
||||
assert.ok(seen.has(ph), `saw ${ph}`);
|
||||
assert.ok(seen.get(ph).every((d) => d === 0), `${ph}: no countdown with a single human (${bots} AI): ${seen.get(ph)}`);
|
||||
}
|
||||
assert.ok(seen.get(PHASE.COMBAT).some((d) => d > 0), 'the battles keep their clock');
|
||||
m.dispose();
|
||||
}
|
||||
// two humans: the co-op timers stay
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
|
||||
assert.equal(h.m.soloUntimed, false);
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.deadline - pub.serverNow, 25000, 'INFO_CHECK 25 s');
|
||||
h.toPrep(1);
|
||||
assert.ok(h.m.deadline > 0, 'co-op prep is timed');
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -411,8 +411,10 @@ test('audited FakeBattle matches (all phases incl. 联防, 机变, Final Assault
|
||||
const h = makeMatch({
|
||||
mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }),
|
||||
}).start();
|
||||
h.autoHumans();
|
||||
});
|
||||
// AI-played before the start: a single human's briefing is untimed (Match.soloUntimed, user playtest #4 item 3) and
|
||||
// waits for 准备就绪, which only an autoplaying seat gives by itself
|
||||
h.autoHumans().start();
|
||||
const audit = attachAudit(h.m);
|
||||
for (const ps of h.m.players.values()) ps.lp = 200;
|
||||
h.runToEnd({ maxSteps: 3e6 });
|
||||
|
||||
@@ -256,7 +256,8 @@ test('solo: no deadline outside combat (INFO_CHECK, strategy draft, 机变, prep
|
||||
});
|
||||
|
||||
test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 12 }).start();
|
||||
// two humans: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
|
||||
const m = h.m;
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.phase, PHASE.INFO_CHECK);
|
||||
@@ -268,13 +269,15 @@ test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines'
|
||||
|
||||
// ---- review fixes -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('a skill summon comes only with that skill: 赫默 S2 (default) gets the 医疗无人机 token, 赫默 on S1 does not', async () => {
|
||||
test('a skill summon is never a hand card (user playtest #4): 赫默 on any skill gets no 医疗无人机 token in the prep phase', async () => {
|
||||
const { give } = await import('./harness.js');
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const DRONE = 'token_10000_silent_healrb';
|
||||
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
|
||||
const tokensIn = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === DRONE);
|
||||
for (const [skill, want] of [[null, 1], [0, 0], [1, 1]]) {
|
||||
// "获得一个医疗无人机": the drone appears in battle when S2 fires (test/content/tokens_devices.test.js), never before
|
||||
const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
|
||||
assert.equal(DATA.tokens[DRONE].placeable, false, 'tokens.json: a skill summon is not placeable');
|
||||
for (const skill of [null, 0, 1]) {
|
||||
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
|
||||
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
|
||||
h.toPrep(1);
|
||||
@@ -283,11 +286,16 @@ test('a skill summon comes only with that skill: 赫默 S2 (default) gets the
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, SILENCE, 'board', [9, 3]);
|
||||
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, 0, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
|
||||
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, 0, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
// a talent summon still is a hand card: 伺夜 → 狼群
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, 'chess_char_3_19_a', 'board', [9, 5]);
|
||||
assert.equal(tokensIn(ps, 'token_10028_vigil_wolf').length, 1, '伺夜 sends its 狼群 to the hand');
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
@@ -320,8 +320,9 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => {
|
||||
for (const onto of ['stack', 'other']) {
|
||||
const { m, ps } = prep(24);
|
||||
const hemo = give(m, ps, 'chess_char_2_02_a');
|
||||
const [r, c] = legalTileFor(m, ps, 'chess_char_2_02_a');
|
||||
// 伺夜: its talent summon 狼群 is a hand card (a skill's summon such as 赫默's drone never is)
|
||||
const hemo = give(m, ps, 'chess_char_3_19_a');
|
||||
const [r, c] = legalTileFor(m, ps, 'chess_char_3_19_a');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true });
|
||||
const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
|
||||
assert.ok(stackIdx >= 0, 'its summon stack went to the hand');
|
||||
|
||||
+42
-1
@@ -88,12 +88,53 @@ test('item slot: tier ≤ level, shop-eligible normal equipment only', () => {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const id = pool.rollItem(rng, level);
|
||||
const it = DATA.items[id];
|
||||
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop, id);
|
||||
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop && !it.shopExcluded, id);
|
||||
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// user playtest #4 item 5: the special 维式重锤 (维多利亚 25-layer reward / 洛洛's 定制品) and 突变细胞 (strategy 昆图斯)
|
||||
// are never sold although the official shop table lists them (tools/build-data.mjs SHOP_EXCLUDED_ITEMS)
|
||||
const EFFECT_ONLY = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a'];
|
||||
|
||||
test('effect-only items are never shop items: not in shopItemsByTier, never in the item slot, the pools or the 机变 supply', () => {
|
||||
for (const id of EFFECT_ONLY) {
|
||||
assert.equal(DATA.items[id].shopExcluded, true, `${id} (${DATA.items[id].name}) marked`);
|
||||
assert.equal(DATA.items[id].hideInShop, false, `${id}: the official table does not hide it`);
|
||||
assert.ok(DATA.items[id.replace(/_a$/, '_b')].shopExcluded, `${id}: the golden too`);
|
||||
}
|
||||
assert.equal(DATA.items.chess_item_1_01_e_a.shopExcluded, false, 'the plain 维式重锤 is sold');
|
||||
const gd = gdOf();
|
||||
const listed = new Set(Object.values(gd.shopItemsByTier).flat());
|
||||
assert.equal(listed.size, 51, '56 normal equipment − 5 effect-only');
|
||||
for (const id of EFFECT_ONLY) assert.ok(!listed.has(id), `${id} not a shop item`);
|
||||
assert.ok(listed.has('chess_item_1_01_e_a'));
|
||||
// the shop item slot at every level
|
||||
const pool = new SharedPool(gd);
|
||||
const rng = createRng(4);
|
||||
for (let level = 1; level <= 6; level++) for (let i = 0; i < 400; i++) assert.ok(!EFFECT_ONLY.includes(pool.rollItem(rng, level)));
|
||||
// every shop-eligible pool (凯瑟琳 / 列装 / 定向投放 / 见者有份) and the plain "random item" roll of effects
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
for (const pid of ['pool_equip_normal', 'pool_equip_shop_1', 'pool_equip_kathe', 'pool_equip_narant']) {
|
||||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ pool: pid, shopLevel: 6 })), pid);
|
||||
}
|
||||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ maxTier: 6 })));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
|
||||
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
|
||||
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
|
||||
assert.deepEqual([...seen].sort(), special, `${pid}: every special hammer can come, nothing else`);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
|
||||
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
|
||||
@@ -404,3 +404,53 @@ test('live leaks: a boss field is not counted (the merged team LP moves through
|
||||
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
|
||||
r2.runner.dispose();
|
||||
});
|
||||
|
||||
test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle on screen — the sim\'s last computed stats, never unit.s (no recompute from the UI)', async () => {
|
||||
const start = realStart(7305);
|
||||
const r = rig();
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get(start.battleId);
|
||||
r.advance(2000);
|
||||
const ally = e.battle.allyUnits.find((u) => u.alive && u.deployed && u.kind === 'op');
|
||||
assert.ok(ally, 'a deployed operator');
|
||||
// a trap on the lazy getter: reading the live stats must not recompute them (it would move the sim's floats)
|
||||
const s = ally._s;
|
||||
Object.defineProperty(ally, 's', { configurable: true, get() { throw new Error('unit.s read by the UI'); } });
|
||||
const got = r.runner.unitStats(ally.id);
|
||||
delete ally.s;
|
||||
assert.ok(got, 'the unit of the battle on screen');
|
||||
assert.equal(got.id, ally.id);
|
||||
assert.equal(got.uid, ally.uid);
|
||||
assert.equal(got.defId, ally.defId);
|
||||
assert.equal(got.atk, Math.round(s.atk));
|
||||
assert.equal(got.maxHp, Math.round(s.maxHp));
|
||||
assert.equal(got.def, Math.round(s.def));
|
||||
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
||||
assert.equal(got.blockCnt, s.blockCnt);
|
||||
assert.equal(got.hp, Math.round(ally.hp));
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
|
||||
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
||||
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
||||
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
||||
// an own board piece's card left open into the battle: its unit by uid and owner
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), ally.id);
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, start.fieldId), ally.id);
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, 'someone_else'), null, 'another player\'s piece');
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
|
||||
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
|
||||
assert.equal(r.runner.unitStats('x'), null);
|
||||
// enemies too, once one is out
|
||||
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50);
|
||||
const foe = e.battle.enemies.find((x) => x.alive);
|
||||
if (foe) {
|
||||
const fs = r.runner.unitStats(foe.id);
|
||||
assert.ok(fs && fs.maxHp > 0 && fs.base.maxHp > 0, 'an enemy\'s live stats');
|
||||
assert.equal(fs.moveSpeed, Math.round((foe._s || foe.base).moveSpeed * 100) / 100);
|
||||
}
|
||||
r.runner.clear();
|
||||
assert.equal(r.runner.unitStats(ally.id), null, 'nothing on screen after the battles were dropped');
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), null);
|
||||
r.runner.dispose();
|
||||
});
|
||||
@@ -201,7 +201,8 @@ test('a bot clears the temp contents it finds at the start of its prep and readi
|
||||
for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]);
|
||||
},
|
||||
});
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 1507, fake: true, registry: reg }).start();
|
||||
// two humans: the prep stays timed (a single human is untimed, Match.soloUntimed — user playtest #4 item 3)
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 1507, fake: true, registry: reg }).start();
|
||||
const audit = attachAudit(h.m);
|
||||
const m = h.m;
|
||||
h.toPrep(2);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Live operator stats (user playtest #4 item 7 — the detail card showed the fixed record numbers): g.unitStats answers
|
||||
// m.unitStats { seq, round, units } with the stats every unit of the player's board starts its next battle with — the
|
||||
// real battle input (equipment, bonds / layers, 特质, band and 机变 effects, the onBattleStart meta) built into a Battle
|
||||
// that is started and read, never stepped (server/match/Match.js unitStats); the shape is shared/protocol.js
|
||||
// unitStatsEntry (also the browser runner's live battle stats, test/match/runner.test.js). The preview changes nothing
|
||||
// of the match.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S, unitStatsEntry, S2C } from '../../shared/protocol.js';
|
||||
import { makeMatch, give, giveItem, chessOfTier, legalTileFor } from './harness.js';
|
||||
|
||||
/** 阿戈尔重刃: ATK +40 % (its own multiplier), attack speed −10 (data/items.json). */
|
||||
const BLADE = 'chess_item_3_07_e_a';
|
||||
/** 双模机械臂: ATK +30 % (a second, different item: both apply — two equal ones would merge into the hand). */
|
||||
const ARM = 'chess_item_5_03_e_a';
|
||||
|
||||
/** Solo PREP R1 with one chess on an empty board carrying 阿戈尔重刃 (equipped through g.equip). */
|
||||
function setup(seed = 21, opts = {}) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...opts }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x));
|
||||
const carrier = give(m, ps, id, 'board', legalTileFor(m, ps, id));
|
||||
const item = giveItem(m, ps, BLADE);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
|
||||
return { h, m, ps, carrier };
|
||||
}
|
||||
|
||||
test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.unitStats is a known push', () => {
|
||||
assert.equal(validateC2S({ t: 'g.unitStats' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.unitStats', seq: 12 }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: -1 }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: 1.5 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus' }), null, 'no band = clear the highlight');
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: null }), null);
|
||||
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'));
|
||||
});
|
||||
|
||||
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
|
||||
const u = { id: 3, uid: 44, defId: 'chess_x', hp: 812.6, alive: true, base: { maxHp: 1000, atk: 300, def: 100, res: 10, aspd: 100, bat: 1.2, blockCnt: 2, moveSpeed: 0 } };
|
||||
const e = unitStatsEntry(u, { maxHp: 1250.4, atk: 420.49, def: 90, res: 12.34, interval: 1.0833333, blockCnt: 3, moveSpeed: 0 });
|
||||
assert.deepEqual(e, {
|
||||
id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true,
|
||||
maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0,
|
||||
base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 },
|
||||
});
|
||||
const plain = unitStatsEntry(u);
|
||||
assert.equal(plain.atk, 300, 'no aggregated stats: the base');
|
||||
assert.equal(plain.interval, 1.2);
|
||||
assert.equal(unitStatsEntry(null).maxHp, 0, 'never throws');
|
||||
assert.equal(unitStatsEntry({ base: { bat: 0 } }).interval, null, 'no attack');
|
||||
});
|
||||
|
||||
test('g.unitStats: the board\'s start-of-battle stats (equipment in: ATK ×1.4, a slower attack), the same numbers the battle starts with; seq echoed', () => {
|
||||
const { h, m, ps, carrier } = setup();
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: 7 }), { ok: true });
|
||||
const msg = h.lastTo('p_0', 'm.unitStats');
|
||||
assert.ok(msg, 'answered with a push');
|
||||
assert.equal(msg.seq, 7);
|
||||
assert.equal(msg.round, 1);
|
||||
const boardUids = [...ps.board.values()].map((p) => p.uid).sort((a, b) => a - b);
|
||||
assert.deepEqual(msg.units.map((u) => u.uid).sort((a, b) => a - b), boardUids, 'every board unit, by uid');
|
||||
const u = msg.units.find((x) => x.uid === carrier.uid);
|
||||
assert.ok(u.atk > u.base.atk, `ATK up (${u.base.atk} → ${u.atk})`);
|
||||
assert.ok(Math.abs(u.atk - Math.round(u.base.atk * 1.4)) <= 1, `阿戈尔重刃 multiplies ATK by 1.4 (${u.atk})`);
|
||||
assert.ok(u.interval > u.base.interval, `attack speed −10: a longer interval (${u.base.interval} → ${u.interval})`);
|
||||
assert.equal(u.hp, u.maxHp, 'full HP at the start');
|
||||
assert.equal(u.alive, true);
|
||||
// exactly what the real battle of this board starts with (the same input, onBattleStart meta included)
|
||||
const b = m.newBattle(m._normalOpts(ps));
|
||||
b.start();
|
||||
const bu = b.allyUnits.find((x) => x.uid === carrier.uid);
|
||||
const { id: _a, ...want } = unitStatsEntry(bu, bu.s);
|
||||
const { id: _b, ...have } = u;
|
||||
assert.deepEqual(have, want);
|
||||
// no seq: null; a second request with the same state answers the cached numbers
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats' }), { ok: true });
|
||||
assert.equal(h.lastTo('p_0', 'm.unitStats').seq, null);
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.unitStats').units, msg.units);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('g.unitStats follows the prep state: another item changes the numbers; prep phases only', () => {
|
||||
const { h, m, ps, carrier } = setup(22);
|
||||
m.handle('p_0', { t: 'g.unitStats', seq: 1 });
|
||||
const before = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
|
||||
const item = giveItem(m, ps, ARM);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
|
||||
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
|
||||
m.handle('p_0', { t: 'g.unitStats', seq: 2 });
|
||||
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
|
||||
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * 1.4 * 1.3)) <= 1, `each item its own multiplier: ATK ${before.atk} → ${after.atk}`);
|
||||
// outside the prep phases: WRONG_PHASE, nothing pushed
|
||||
const n = h.allTo('p_0', 'm.unitStats').length;
|
||||
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
|
||||
assert.equal(m.handle('p_0', { t: 'g.unitStats', seq: 3 }).error, ERR.WRONG_PHASE);
|
||||
assert.equal(h.allTo('p_0', 'm.unitStats').length, n);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the preview changes nothing of the match: two identical matches, one asking for the stats all along, stay identical', () => {
|
||||
const run = (ask) => {
|
||||
const { h, m, ps } = setup(23);
|
||||
const snaps = [];
|
||||
for (let r = 1; r <= 3; r++) {
|
||||
if (ask) for (let k = 0; k < 3; k++) assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: k }), { ok: true });
|
||||
snaps.push(JSON.stringify(ps.privateView()));
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === r + 1), `PREP R${r + 1}`);
|
||||
}
|
||||
snaps.push(JSON.stringify(ps.privateView()), JSON.stringify({ ...m.publicView(), serverNow: 0, deadline: 0 }));
|
||||
m.dispose();
|
||||
return snaps;
|
||||
};
|
||||
assert.deepEqual(run(true), run(false));
|
||||
});
|
||||
Reference in new issue
Block a user