Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
@@ -5,7 +5,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { ELEMENT_GAUGE_MAX } from '../../server/sim/constants.js';
|
||||
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
|
||||
import { sortEnemyTargets } from '../../server/sim/targeting.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
@@ -301,6 +301,33 @@ test('1_09 跃跃: 乐趣加倍 throws cnt boomerangs per attack, ATK +atk; 戏
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 tiles/s); faster than its flight she waits for the catch; 乐趣加倍 hits land together', () => {
|
||||
const id = 'chess_char_1_09_a', bb = bbOf(id);
|
||||
const flight = (d) => d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
|
||||
// ASPD +200 (interval 0.333 s) at 3 tiles: the out-and-back flight (1.0 s) sets the pace
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 7] }],
|
||||
setup(b) { b.on('deploy', ({ unit }) => { if (unit.kind === 'op') b.addBuff(unit, { key: 'test:aspd', mods: { aspd: 200 } }); }); },
|
||||
});
|
||||
const u = h.unit(id);
|
||||
h.run(6);
|
||||
assert.ok(u.s.interval < flight(3) / 2);
|
||||
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
|
||||
assert.ok(at.length >= 4 && at.length <= 7, `${at.length} attacks in 6 s`);
|
||||
for (let i = 1; i < at.length; i++) assert.ok(Math.abs(at[i] - at[i - 1] - flight(3)) <= 2 * h.TICK, `gap ${(at[i] - at[i - 1]).toFixed(3)} ≈ ${flight(3)}`);
|
||||
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
|
||||
done(h);
|
||||
// 乐趣加倍: cnt boomerangs on the same path — cnt hits at the same moment, then one catch
|
||||
const h2 = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }] });
|
||||
const u2 = h2.unit(id);
|
||||
h2.run(5);
|
||||
assert.ok(u2.skill.active);
|
||||
const byT = new Map();
|
||||
for (const c of dealt(h2, u2, (x) => x.dmg.isSkill)) byT.set(c.t, (byT.get(c.t) ?? 0) + 1);
|
||||
assert.ok(byT.size >= 3 && [...byT.values()].every((n) => n === bb.cnt), `hits per landing ${[...byT.values()]}`);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
|
||||
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
|
||||
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
|
||||
|
||||
@@ -72,7 +72,11 @@ test('盟约·辅助干员 (elite): element damage vs elite enemies uses the mod
|
||||
assert.ok(near(el.amount, u.s.atk * u.def.talents[0].bb.ep_damage_ratio_boss, 1e-3));
|
||||
});
|
||||
|
||||
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 % centre)', () => {
|
||||
/** 蕾缪安 S3 fx of the battle: [{ kind, x, y, ...extra }] for 'lock' / 'bombardShell' / 'bombard'. */
|
||||
const lemFx = (h, kind) => h.eventsOf('fx').filter((e) => e[1] === kind).map((e) => ({ x: e[2], y: e[3], ...e[4] }));
|
||||
const SHELL_IV = 0.3; // PRTS S3 note: "以0.3s为间隔" (not in the blackboard)
|
||||
|
||||
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s, then the shells land one by one every 0.3 s (360 % centre)', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
@@ -85,13 +89,94 @@ test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 %
|
||||
const start = h.b.time;
|
||||
assert.ok(u.findBuff('lemuen:extradition'), 'ATK +10 % after 20 s');
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 20));
|
||||
const end = h.b.time;
|
||||
const ammo = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
|
||||
assert.equal(ammo.length, bb['attack@trigger_time'] + t1.add_count);
|
||||
assert.ok(Math.abs(h.b.time - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
|
||||
const n = bb['attack@trigger_time'] + t1.add_count;
|
||||
assert.equal(ammo.length, n);
|
||||
assert.ok(Math.abs(end - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
|
||||
assert.equal(lemFx(h, 'lock').length, n, 'one lock mark per bullet');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 0, 'nothing lands all at once at the end');
|
||||
const atk = u.s.atk;
|
||||
h.run(SHELL_IV * (n + 1));
|
||||
// one shell every 0.3 s in lock order, each on a random point within ±emit_offset of its (stationary) target
|
||||
const shells = lemFx(h, 'bombardShell');
|
||||
assert.deepEqual(shells.map((s) => s.i), [...Array(n).keys()], 'shells fired one by one, in lock order');
|
||||
const spread = bb['attack@emit_offset'] + 0.01;
|
||||
for (const s of shells) {
|
||||
assert.ok(Math.abs(s.x - 5) <= spread && Math.abs(s.y - 10) <= spread, `aimed within ±${bb['attack@emit_offset']} of the lock (${s.x}, ${s.y})`);
|
||||
assert.equal(s.id, u.id);
|
||||
assert.equal(s.r, bb['attack@dist_2']);
|
||||
assert.ok(s.t > 0 && s.t <= SHELL_IV + 1e-9, `flight ${s.t} s`);
|
||||
}
|
||||
assert.ok(new Set(shells.map((s) => `${s.x},${s.y}`)).size > 1, 'the aim points are spread (seeded rng)');
|
||||
const bombs = h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard'));
|
||||
assert.equal(bombs.length, ammo.length, 'one bomb per lock');
|
||||
assert.ok(near(bombs[0].amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 %');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < h.b.time - 0.1).length, 0, 'no normal attacks while locking');
|
||||
assert.equal(bombs.length, n, 'one hit per shell on the single enemy (limited_hit_time 1)');
|
||||
bombs.forEach((b, i) => assert.ok(Math.abs(b.t - end - SHELL_IV * (i + 1)) <= 2 / 30 + 1e-9, `shell ${i} lands at +${(b.t - end).toFixed(3)} s`));
|
||||
assert.equal(lemFx(h, 'bombard').length, n, 'an explosion fx per shell');
|
||||
for (const b of bombs) assert.ok(near(b.amount, atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 % (the ATK when the skill ended)');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < end - 0.1).length, 0, 'no normal attacks while locking');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('蕾缪安: S3 lock marks follow a walking target and stay where it died; the outer ring deals 240 %', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
|
||||
enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [9, 6], end: [9, 2], checkpoints: [] } }, { key: 'enemy_dummy', pos: [10, 6] }],
|
||||
});
|
||||
const u = h.unit('chess_char_6_01_a');
|
||||
const bb = u.def.skill.bb;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 10));
|
||||
const walker = enemyAt(h, 'enemy_walker'), dummyE = enemyAt(h, 'enemy_dummy');
|
||||
const locks = lemFx(h, 'lock');
|
||||
assert.deepEqual(locks.slice(0, 2).map((l) => l.id).sort(), [walker.id, dummyE.id].sort(), 'unlocked targets first, then a new round');
|
||||
// the walker's shells aim at where it is when each is fired (it keeps walking), not where it was locked
|
||||
const firstLock = locks.find((l) => l.id === walker.id);
|
||||
const at0 = { x: walker.x, y: walker.y };
|
||||
assert.ok(Math.abs(at0.x - firstLock.x) > 0.6, `the walker moved on since its lock (${firstLock.x} → ${at0.x})`);
|
||||
const order = locks.map((l) => l.id);
|
||||
const spread = bb['attack@emit_offset'] + 0.01;
|
||||
const shell0 = lemFx(h, 'bombardShell')[0];
|
||||
if (order[0] === walker.id) assert.ok(Math.abs(shell0.x - at0.x) <= spread && Math.abs(shell0.y - at0.y) <= spread, 'first shell on the walker\'s current spot');
|
||||
// it dies before its later shells are fired: they fall where it died
|
||||
h.b.kill(walker);
|
||||
const died = { x: walker.x, y: walker.y };
|
||||
h.run(SHELL_IV * (order.length + 1));
|
||||
const shells = lemFx(h, 'bombardShell');
|
||||
assert.equal(shells.length, order.length);
|
||||
shells.forEach((s, i) => {
|
||||
if (order[i] !== walker.id || i === 0) return;
|
||||
assert.ok(Math.abs(s.x - died.x) <= spread && Math.abs(s.y - died.y) <= spread, `shell ${i} on the spot the walker left (${s.x}, ${s.y})`);
|
||||
});
|
||||
// the dummy one row below the walker's line: centre (≤ dist_1) or outer ring (≤ dist_2) damage per shell
|
||||
const onDummy = h.dmg.filter((d) => d.src === u.id && d.tgt === dummyE.id && d.tags.includes('bombard'));
|
||||
assert.ok(onDummy.every((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3) || near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)));
|
||||
assert.ok(onDummy.some((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)), 'a shell on the walker line reaches it with the outer ring (240 %)');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('蕾缪安: knocked out after S3 ended, the shell in the air still lands and the rest are dropped', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit('chess_char_6_01_a');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 10));
|
||||
assert.equal(lemFx(h, 'bombardShell').length, 1, 'the first shell is fired as the skill ends');
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, false);
|
||||
h.run(3);
|
||||
assert.equal(lemFx(h, 'bombardShell').length, 1, 'no shell after she left the field');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 1, 'the fired shell landed');
|
||||
// knocked out while locking: no bombardment at all
|
||||
const h2 = battle({ units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] });
|
||||
const u2 = h2.unit('chess_char_6_01_a');
|
||||
assert.ok(h2.runUntil(() => u2.skill.active && lemFx(h2, 'lock').length >= 2, 10));
|
||||
h2.b.dealDamage(null, u2, { amount: 1e9, type: 'true' });
|
||||
h2.run(3);
|
||||
assert.equal(lemFx(h2, 'bombardShell').length, 0);
|
||||
assert.equal(h2.dmg.filter((d) => d.src === u2.id && d.tags.includes('bombard')).length, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
// m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a
|
||||
// synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items
|
||||
// locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from
|
||||
// local art, 标准 = 战场#01 only.
|
||||
// local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can
|
||||
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the shared picking
|
||||
// rule / drag target, the live LP, the detail card order and the static game data.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -16,7 +18,12 @@ import { NET_DEFAULTS } from '../server/net.js';
|
||||
import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
|
||||
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
|
||||
import { validateC2S } from '../shared/protocol.js';
|
||||
import { ERR } from '../shared/constants.js';
|
||||
import { ERR, PHASE } from '../shared/constants.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../server/sim/constants.js';
|
||||
import { SUB } from '../server/sim/professions.js';
|
||||
import { BODY_H, PROBE_MARGIN } from '../public/js/render/pick.js';
|
||||
import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js';
|
||||
import { RETRY_DELAYS_MS } from '../public/js/data.js';
|
||||
import { DATA, makeMatch } from './match/harness.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -173,3 +180,74 @@ test('solo pause: g.pause {on} is solo-only and m.public.paused follows (code)
|
||||
assert.match(README, /暂停(独立模拟)/);
|
||||
assert.match(PLAYING, /同盟模拟的作战不能暂停/);
|
||||
});
|
||||
|
||||
test('temp overflow (user playtest #3 item 3): kept through the round start, resolved at the deadline of the first prep its player can act in (code) — every doc says so', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 4343, fake: true }).start();
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
// distinct plain equipment (two identical ones would merge)
|
||||
const plain = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id);
|
||||
assert.ok(plain.length > ps.hand.length);
|
||||
h.toPrep(1);
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SETTLE && m.round === 1), 'SETTLE of R1');
|
||||
ps.hand.fill(null);
|
||||
plain.slice(0, ps.hand.length).forEach((id, i) => { ps.hand[i] = ps.newPiece('item', id); });
|
||||
const late = ps.newPiece('item', plain[ps.hand.length]);
|
||||
assert.equal(ps.stow(late), 'temp', 'a gain after the battle with a full hand overflows into temp');
|
||||
h.toPrep(2);
|
||||
assert.ok(ps.temp.includes(late), 'not wiped at the round start');
|
||||
assert.equal(ps.tempDue(late), ps.prepsEnded, 'due at the end of this prep — the first one its player can act in');
|
||||
assert.equal(ps.privateView().canReady, false, 'it blocks Ready');
|
||||
assert.ok(h.drive(() => m.phase === PHASE.COMBAT && m.round === 2, { ready: false }), 'the prep deadline passes');
|
||||
assert.ok(!ps.temp.includes(late), 'resolved at that deadline');
|
||||
m.dispose();
|
||||
assert.ok(!/temp hand wiped/.test(DESIGN), 'DESIGN §6.1: the round start no longer wipes temp');
|
||||
assert.match(DESIGN, /PlayerState\.tempDue/);
|
||||
assert.ok(!/temp wiped \(reward offers/.test(META) && !/wiped at the round start/.test(META), 'META §1 / rules');
|
||||
assert.match(META, /PlayerState\.tempDue/);
|
||||
assert.ok(!/下一回合开始时自动销毁/.test(PLAYING), 'PLAYING §3: no round-start destruction');
|
||||
assert.match(PLAYING, /保留到\*\*下一个休整期\*\*/);
|
||||
});
|
||||
|
||||
test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4–5): code and SIM / DESIGN agree', () => {
|
||||
assert.equal(PROJECTILE_SPEEDS.boomerang, 15, 'PRTS 跃跃: out 15');
|
||||
assert.equal(BOOMERANG_RETURN_SPEED, 3.75, 'PRTS 跃跃: back 3.75');
|
||||
assert.equal(SUB.loopshooter.projectile, 'boomerang');
|
||||
assert.equal(typeof SUB.loopshooter.canAttack, 'function', 'attacks only while holding the boomerang');
|
||||
assert.match(SIM, /boomerang 15 out,\s*3\.75 back/);
|
||||
assert.ok(!/next attack waits for the boomerang \(2 × distance \/ 10 s\)/.test(SIM), 'SIM §8: the old lob rule is gone');
|
||||
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|chain\|chainHeal`/);
|
||||
assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/);
|
||||
// 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants
|
||||
const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8');
|
||||
assert.match(kit, /const LEMUEN_SHELL_INTERVAL = 0\.3;/);
|
||||
assert.match(kit, /battle\.fx\('bombardShell'/);
|
||||
assert.match(DESIGN, /'bombardShell' \{x, y, id: shooter, r, t: flight game s, i\}/);
|
||||
assert.match(DESIGN, /ONE shell every 0\.3 s in lock order/);
|
||||
assert.match(SIM, /S3 礼炮·强制追思/);
|
||||
});
|
||||
|
||||
test('picking, drag target, lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => {
|
||||
assert.equal(BODY_H, 1.27, 'median chibi height (tiles)');
|
||||
assert.equal(PROBE_MARGIN, 0.5);
|
||||
assert.match(DESIGN, /`PROBE_MARGIN` 0\.5 tile/);
|
||||
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(app, /export const TOUCH_LIFT_TILES = 0\.6;/);
|
||||
assert.match(DESIGN, /`TOUCH_LIFT_TILES` = 0\.6 tile above the finger/);
|
||||
assert.match(DESIGN, /render\/pick\.js/);
|
||||
assert.equal(SPINE_EVICT_DELAY_MS, 1000);
|
||||
assert.equal(SPINE_QUIET_DELAY_MS, 3000);
|
||||
assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/);
|
||||
assert.match(DESIGN, /`SPINE_QUIET_DELAY_MS` \(3 s\)/);
|
||||
assert.deepEqual([...RETRY_DELAYS_MS], [600, 2000]);
|
||||
assert.match(DESIGN, /after 600 ms and 2 s \(`RETRY_DELAYS_MS`\)/);
|
||||
const panel = readFileSync(join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
|
||||
assert.match(panel, /CHESS_SECTIONS = Object\.freeze\(\['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents'/);
|
||||
assert.match(DESIGN, /`detailPanel\.js CHESS_SECTIONS`\): header .* → \*\*特质\*\*/);
|
||||
assert.match(panel, /export function garrisonTypeIconKey\(garrison\)/);
|
||||
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
|
||||
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
|
||||
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
|
||||
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
|
||||
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
|
||||
});
|
||||
@@ -264,7 +264,7 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; a full hand sends the elite to a freed board tile', () => {
|
||||
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => {
|
||||
const X = 'chess_char_2_04_a';
|
||||
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
|
||||
for (const onBoard of [false, true]) {
|
||||
@@ -295,7 +295,19 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
assert.ok(elite, 'the elite survived the round start');
|
||||
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
|
||||
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
|
||||
if (onBoard) assert.ok(tiles.includes(ps.find(elite.uid).key), 'full hand outside PREP: the elite took a freed board tile, not temp');
|
||||
if (onBoard) {
|
||||
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into
|
||||
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it
|
||||
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp');
|
||||
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row');
|
||||
assert.equal(ps.privateView().canReady, false);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
|
||||
const [r, c] = tiles[0].split(',').map(Number);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile');
|
||||
assert.ok(ps.tempEmpty && ps.privateView().canReady);
|
||||
} else {
|
||||
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots');
|
||||
}
|
||||
assert.ok(ps.tempEmpty);
|
||||
checkInvariants(m);
|
||||
// it expires at the end of that prep like any other offer
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The browser battle runner (public/js/battle/runner.js) under Node with a fake socket, a manual clock and manual
|
||||
// animation frames: pacing (60 ticks per real second at 2×, ≤ 8 per frame), the b.snap / b.ev feed and the field meta,
|
||||
// b.progress / b.result when authoritative (valid protocol frames, the same result as the server's simulation),
|
||||
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing.
|
||||
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing, the live
|
||||
// leak count of normal fields (state().leaks, user playtest #3 item 2).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBattleRunner, ticksPerFrameCap } from '../../public/js/battle/runner.js';
|
||||
@@ -329,3 +330,77 @@ test('solo pause: m.public.paused freezes every local battle clock (no ticks, no
|
||||
assert.ok(Math.abs(e.battle.tickCount - (t1 + 60)) <= 2, `resumed on the same clock, no catch-up burst (${e.battle.tickCount})`);
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
/** realStart() with an empty board: every enemy of the wave walks into the blue gate. */
|
||||
function leakingStart(seed = 7307) {
|
||||
const start = realStart(seed);
|
||||
const spec = JSON.parse(JSON.stringify(start.spec));
|
||||
for (const p of spec.players) p.units = [];
|
||||
return { ...start, spec };
|
||||
}
|
||||
|
||||
test('live leaks (user playtest #3 item 2): state().leaks carries the own field\'s counted leaks as they happen — authoritative or display — published in the frame of each leak; the settle rule\'s count at the end', async () => {
|
||||
const start = leakingStart();
|
||||
const r = rig();
|
||||
const seen = [];
|
||||
r.store.subscribe((s, prev) => {
|
||||
const n = s.match.battle && s.match.battle.leaks ? s.match.battle.leaks[start.fieldId] : undefined;
|
||||
const was = prev.match.battle && prev.match.battle.leaks ? prev.match.battle.leaks[start.fieldId] : undefined;
|
||||
if (n !== was && n !== undefined) seen.push(n);
|
||||
});
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get(start.battleId);
|
||||
assert.deepEqual(r.store.get().match.battle.leaks, { [start.fieldId]: 0 }, 'published with the field, 0 before any leak');
|
||||
// step frame by frame: whenever the battle recorded a counted leak, the store has it after that same frame
|
||||
for (let i = 0; i < 200 * 60 && !e.done; i++) {
|
||||
r.advance(1000 / 60);
|
||||
const now = specMod.battleProgress(e.battle).leaks;
|
||||
assert.equal(r.store.get().match.battle.leaks[start.fieldId], now, `frame ${i}: the store follows the battle`);
|
||||
}
|
||||
assert.ok(e.done, 'finished');
|
||||
const res = e.battle.result();
|
||||
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
|
||||
assert.ok(counted >= 3, `the empty board leaks (${counted})`);
|
||||
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'the settle rule\'s count (Match.settle: counted !== false)');
|
||||
assert.equal(seen[seen.length - 1], counted, 'the last publish carries the final count');
|
||||
assert.ok(seen.every((n, i) => i === 0 || n > seen[i - 1]), `one publish per new count, increasing (${seen})`);
|
||||
r.runner.dispose();
|
||||
|
||||
// a display replica of the own field (the server simulates it: reconnect after a takeover) counts the same
|
||||
const r2 = rig();
|
||||
r2.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 30 });
|
||||
await r2.settle();
|
||||
const e2 = r2.runner._entries.get(start.battleId);
|
||||
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], specMod.battleProgress(e2.battle).leaks, 'counted during the silent catch-up');
|
||||
for (let i = 0; i < 200 && !e2.done; i++) r2.advance(1000, 50);
|
||||
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted);
|
||||
assert.equal(r2.net.sent.length, 0, 'still never reports');
|
||||
// the round moves on: the next prep drops the battles and their counts
|
||||
r2.store.patch('match', { public: { phase: 'SETTLE' } });
|
||||
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted, 'kept through SETTLE (the top bar resets on the settled m.private)');
|
||||
r2.store.patch('match', { public: { phase: 'PREP' } });
|
||||
assert.equal(r2.store.get().match.battle, null);
|
||||
r2.runner.dispose();
|
||||
});
|
||||
|
||||
test('live leaks: a boss field is not counted (the merged team LP moves through b.pool); b.end forced publishes the final count', async () => {
|
||||
const start = leakingStart(7308);
|
||||
const r = rig();
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
r.advance(1500);
|
||||
r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'timeout' });
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get(start.battleId);
|
||||
assert.ok(e.done);
|
||||
const res = e.battle.result();
|
||||
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
|
||||
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'timeout leaks (the enemies left on the field) are counted at once');
|
||||
r.runner.dispose();
|
||||
const r2 = rig();
|
||||
r2.net.emit('b.start', { ...start, kind: 'boss', fieldId: 'b1', battleId: `${start.battleId}x` });
|
||||
await r2.settle();
|
||||
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
|
||||
r2.runner.dispose();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// 临时整备区 (temp overflow) lifetime (PlayerState tempDue, research 06 §4.3 "直到溢出情况排除才可开始进行作战" /
|
||||
// "处于临时整备区的调度资源,在进入下一回合后会自动销毁"): a temp piece is resolved at the deadline of the first prep in
|
||||
// which its player could act on it. What overflows after that — battle-result grants, SETTLE merges, returned
|
||||
// equipment, <休整期结束时> grants after Ready — survives the round start and is shown and usable in the next prep.
|
||||
// FakeBattle; the rule auditor (server/match/audit.js) watches every match.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { MetaRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { registerBuiltins } from '../../server/match/builtinMeta.js';
|
||||
import { attachAudit } from '../../server/match/audit.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
|
||||
|
||||
/** Plain equipment (no equip / merge side effects): fillers for the hand and the pieces granted by the tests. */
|
||||
const PLAIN = Object.values(DATA.items)
|
||||
.filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive')
|
||||
.map((i) => i.itemId ?? i.id).filter(Boolean);
|
||||
const GRANTS = PLAIN.slice(-3);
|
||||
const FILL = PLAIN.slice(0, 10);
|
||||
|
||||
/**
|
||||
* Co-op match (2 humans: timed prep, FakeBattle) in PREP R1 with p_0's hand full of plain items and one carrier on the
|
||||
* board; `grants(ctx)` runs for p_0 on the hook `hook` (registry global). Audited from the start.
|
||||
*/
|
||||
function setup({ seed = 1500, hook = 'onBattleResult', grants = (ctx) => { for (const id of GRANTS.slice(0, 2)) ctx.grantItem(id); } } = {}) {
|
||||
const reg = registerBuiltins(new MetaRegistry(), DATA);
|
||||
let armed = false;
|
||||
reg.global('temp_test', { [hook](ctx) { if (armed && ctx.playerId === 'p_0') { armed = false; grants(ctx); } } });
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed, fake: true, registry: reg }).start();
|
||||
const audit = attachAudit(h.m);
|
||||
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 carrierId = chessOfTier(2, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
|
||||
const carrier = give(m, ps, carrierId, 'board', legalTileFor(m, ps, carrierId));
|
||||
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, FILL[i]);
|
||||
return { h, m, ps, audit, carrier, arm: () => { armed = true; } };
|
||||
}
|
||||
|
||||
test('equipment granted after the battle overflows into temp, survives the round start and is usable in the next prep', () => {
|
||||
const { h, m, ps, audit, carrier, arm } = setup();
|
||||
arm();
|
||||
h.drive(() => m.phase === PHASE.SETTLE && m.round === 1);
|
||||
const got = ps.temp.filter(Boolean).map((p) => p.id);
|
||||
assert.deepEqual(got.sort(), GRANTS.slice(0, 2).sort(), 'the battle-result grants overflowed into temp (full hand)');
|
||||
h.toPrep(2);
|
||||
const kept = ps.temp.filter(Boolean);
|
||||
assert.deepEqual(kept.map((p) => p.id).sort(), GRANTS.slice(0, 2).sort(), 'not wiped at the round start');
|
||||
const view = ps.privateView();
|
||||
assert.deepEqual(view.temp.filter(Boolean).map((v) => v.uid).sort(), kept.map((p) => p.uid).sort(), 'shown in the temp row');
|
||||
assert.equal(view.canReady, false);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }, 'Ready is blocked until the overflow is cleared');
|
||||
// usable: equip one straight from temp, destroy the other
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: kept[0].uid, targetUid: carrier.uid }), { ok: true });
|
||||
assert.ok(carrier.items.some((it) => it.uid === kept[0].uid), 'equipped from temp');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: kept[1].uid }), { ok: true });
|
||||
assert.ok(ps.tempEmpty && ps.privateView().canReady);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
|
||||
checkInvariants(m);
|
||||
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
/** A tier-1 chess of this match's pool without summons (a plain card for the grants). */
|
||||
const plainChess = (m) => chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id) && m.pool.left(id) > 0);
|
||||
|
||||
test('a chess granted after the battle can be moved into a freed hand slot; what is left at the next deadline is resolved', () => {
|
||||
let T = null;
|
||||
const { h, m, ps, audit, arm } = setup({ seed: 1501, grants: (ctx) => { ctx.grantChess(T); ctx.grantItem(GRANTS[2]); } });
|
||||
T = plainChess(m);
|
||||
const cap = m.pool.cap(T);
|
||||
arm();
|
||||
h.toPrep(2);
|
||||
const chess = ps.temp.find((p) => p && p.kind === 'chess');
|
||||
const item = ps.temp.find((p) => p && p.kind === 'item');
|
||||
assert.ok(chess && chess.id === T && item && item.id === GRANTS[2], 'both grants waited in temp');
|
||||
assert.equal(m.pool.left(T), cap - 1, 'the temp chess holds its pool copy');
|
||||
// free a hand slot (destroy a filler) and take the chess into it
|
||||
const filler = ps.hand.findIndex((p) => p && p.kind === 'item');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: ps.hand[filler].uid }), { ok: true });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: chess.uid, to: { area: 'hand', idx: filler } }), { ok: true });
|
||||
assert.equal(ps.find(chess.uid).area, 'hand');
|
||||
// the item stays unhandled: the R2 deadline (co-op prep is timed) destroys it
|
||||
const p1 = h.ps('p_1');
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
|
||||
assert.ok(m.deadline > h.sched.now());
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(p1.ready && ps.ready);
|
||||
assert.ok(ps.tempEmpty, 'resolved at the deadline of the prep it was shown in');
|
||||
assert.ok(!ps.find(item.uid), 'the item was destroyed');
|
||||
assert.equal(ps.find(chess.uid).area, 'hand', 'the chess taken out of temp is kept');
|
||||
assert.equal(m.pool.left(T), cap - 1);
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a chess still in temp at the next deadline is sold back: its pool copy returns', () => {
|
||||
let T = null;
|
||||
const { h, m, ps, audit, arm } = setup({ seed: 1502, grants: (ctx) => { ctx.grantChess(T); } });
|
||||
T = plainChess(m);
|
||||
const cap = m.pool.cap(T);
|
||||
arm();
|
||||
h.toPrep(2);
|
||||
assert.ok(ps.temp.some((p) => p && p.id === T));
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(ps.tempEmpty);
|
||||
assert.equal(m.pool.left(T), cap, 'copy back in the shared pool');
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('<休整期结束时> grants after Ready overflow into temp and are kept for the next prep (never wiped unseen)', () => {
|
||||
const { h, m, ps, audit, arm } = setup({ seed: 1503, hook: 'onPrepEnd' });
|
||||
arm();
|
||||
// both players ready with empty temps → the prep ends → onPrepEnd grants overflow into p_0's temp
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
|
||||
h.run(() => m.phase !== PHASE.PREP);
|
||||
assert.equal(ps.temp.filter(Boolean).length, 2, 'the prep-end grants are in temp');
|
||||
const uids = ps.temp.filter(Boolean).map((p) => p.uid).sort();
|
||||
assert.ok(ps.temp.filter(Boolean).every((p) => ps.tempDue(p) === ps.prepsEnded), 'due at the next prep');
|
||||
h.toPrep(2);
|
||||
assert.deepEqual(ps.temp.filter(Boolean).map((p) => p.uid).sort(), uids, 'kept through COMBAT, SETTLE and the round start');
|
||||
assert.equal(ps.privateView().canReady, false);
|
||||
// left alone they expire at this prep's deadline
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(ps.tempEmpty);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('an overflow while ready waits for the next prep; un-readying makes it due at this prep', () => {
|
||||
for (const unready of [false, true]) {
|
||||
const { h, m, ps, audit } = setup({ seed: 1504 });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
|
||||
// a passive gain while p_0 is ready (a teammate's effect, a delayed grant …)
|
||||
const got = ps.acquireItem(GRANTS[0], { source: 'test' });
|
||||
assert.equal(ps.find(got.uid).area, 'temp');
|
||||
assert.equal(ps.tempDue(got), ps.prepsEnded + 1, 'the player could not act on it any more: due at the next prep');
|
||||
if (unready) {
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
|
||||
assert.equal(ps.tempDue(got), ps.prepsEnded, 'un-ready: it can be handled now, so it is due at this prep');
|
||||
}
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
// still ready: everybody is, so the prep ends at once; un-readied: the deadline ends it
|
||||
if (unready) h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
else h.run(() => m.phase !== PHASE.PREP);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
if (unready) assert.ok(!ps.find(got.uid), 'resolved at this deadline');
|
||||
else assert.equal(ps.find(got.uid)?.area, 'temp', 'kept for the next prep');
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(audit.violations, [], `${unready}:\n${audit.violations.join('\n')}`);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('an overflow during the prep before Ready expires at that prep\'s deadline (and is due now)', () => {
|
||||
const { h, m, ps, audit } = setup({ seed: 1505 });
|
||||
const got = ps.acquireItem(GRANTS[1], { source: 'test' });
|
||||
assert.equal(ps.find(got.uid).area, 'temp');
|
||||
assert.equal(ps.tempDue(got), ps.prepsEnded);
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(!ps.find(got.uid) && ps.tempEmpty);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the rule auditor flags a temp piece kept past its prep deadline (engine regression guard)', () => {
|
||||
const { h, m, ps, audit } = setup({ seed: 1506 });
|
||||
ps.resolveTemp = () => {}; // simulated engine bug: the deadline never resolves temp
|
||||
ps.acquireItem(GRANTS[1], { source: 'test' });
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
assert.notEqual(m.phase, PHASE.PREP);
|
||||
assert.ok(audit.violations.some((v) => /p_0: temp piece .* survived its prep end/.test(v)), audit.violations.join('\n'));
|
||||
assert.ok(!audit.violations.some((v) => v.includes('p_1')));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a bot clears the temp contents it finds at the start of its prep and readies before the deadline', () => {
|
||||
const reg = registerBuiltins(new MetaRegistry(), DATA);
|
||||
let armed = true;
|
||||
reg.global('temp_test', {
|
||||
onBattleResult(ctx) {
|
||||
if (!armed || ctx.playerId !== 'ai_0') return;
|
||||
armed = false;
|
||||
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();
|
||||
const audit = attachAudit(h.m);
|
||||
const m = h.m;
|
||||
h.toPrep(2);
|
||||
const bot = h.ps('ai_0');
|
||||
assert.ok(!bot.tempEmpty, 'the bot starts its prep with an overflow');
|
||||
const deadline = m.deadline;
|
||||
assert.ok(h.run(() => bot.ready || m.phase !== PHASE.PREP));
|
||||
assert.ok(bot.ready && h.sched.now() < deadline, 'ready before the deadline');
|
||||
assert.ok(bot.tempEmpty);
|
||||
checkInvariants(m);
|
||||
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
|
||||
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
|
||||
m.dispose();
|
||||
});
|
||||
+143
-3
@@ -10,7 +10,7 @@ import {
|
||||
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
|
||||
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
|
||||
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
|
||||
SPINE_IDLE_GRACE_MS,
|
||||
SPINE_IDLE_GRACE_MS, SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS,
|
||||
} from '../../public/js/assets.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -295,6 +295,143 @@ describe('RefLru', () => {
|
||||
await p;
|
||||
assert.deepEqual(unloads, ['a']);
|
||||
});
|
||||
|
||||
// user playtest #3 item 1 (invisible operator models): an unload is asynchronous; a new load of the same key while
|
||||
// it is in flight must not get the doomed value
|
||||
test('a load of a key whose unload is still in flight waits for that unload (never the doomed value)', async () => {
|
||||
const order = [];
|
||||
let finishUnload;
|
||||
let n = 0;
|
||||
const lru = new RefLru({ max: 1,
|
||||
load: async (k) => { order.push(`load ${k}`); return `${k}#${++n}`; },
|
||||
unload: (k, v) => { order.push(`unload ${v}`); return new Promise((r) => { finishUnload = () => { order.push(`unloaded ${v}`); r(); }; }); } });
|
||||
assert.equal(await lru.acquire('a'), 'a#1');
|
||||
await lru.acquire('b');
|
||||
lru.release('a'); // over max: a goes, its unload is in flight
|
||||
assert.ok(lru.unloading('a') && lru.stats().unloading === 1);
|
||||
const again = lru.acquire('a'); // same task: taken again
|
||||
await tick();
|
||||
assert.deepEqual(order, ['load a', 'load b', 'unload a#1'], 'the new load waits');
|
||||
finishUnload();
|
||||
assert.equal(await again, 'a#3', 'a fresh value once the old one is gone (b was #2)');
|
||||
assert.deepEqual(order.slice(3), ['unloaded a#1', 'load a']);
|
||||
await tick();
|
||||
assert.equal(lru.unloading('a'), false);
|
||||
// an unloader that throws or returns nothing never blocks a later load
|
||||
const lru2 = new RefLru({ max: 1, load: async (k) => k, unload: () => { throw new Error('x'); } });
|
||||
await lru2.acquire('a'); await lru2.acquire('b'); lru2.release('a');
|
||||
assert.equal(await lru2.acquire('a'), 'a');
|
||||
});
|
||||
|
||||
test('evictDelay: releases schedule one eviction pass; a scene rebuilt in one go keeps what it takes back', async () => {
|
||||
const c = clock(), unloads = [];
|
||||
let loads = 0;
|
||||
const lru = new RefLru({ max: 2, load: async (k) => { loads++; return k; }, unload: (k) => unloads.push(k), evictDelay: 500, now: c.now, timers: c.timers });
|
||||
for (const k of ['a', 'b', 'c']) await lru.acquire(k);
|
||||
// every view destroyed, then the next scene's views built (same task): nothing is dropped meanwhile
|
||||
for (const k of ['a', 'b', 'c']) lru.release(k);
|
||||
for (const k of ['a', 'b']) await lru.acquire(k);
|
||||
assert.deepEqual(unloads, []);
|
||||
assert.equal(c.pending.length, 1, 'one pass pending');
|
||||
c.advance(510);
|
||||
assert.deepEqual(unloads, ['c'], 'the pass drops only what nobody took back');
|
||||
assert.equal(loads, 3, 'a and b were never reloaded');
|
||||
});
|
||||
|
||||
test('quietDelay: the instant zero of a scene switch is not "no scene"; a lasting one is', async () => {
|
||||
const c = clock(), unloads = [];
|
||||
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 10,
|
||||
maxIdleWeight: 100, quietWeight: 0, idleGrace: 5000, quietDelay: 3000, now: c.now, timers: c.timers });
|
||||
await lru.acquire('bench'); await lru.acquire('op');
|
||||
lru.release('bench'); // prep → battle: the bench model idles for the whole battle
|
||||
c.advance(20000);
|
||||
lru.release('op'); // battle → prep: nothing referenced for an instant …
|
||||
assert.deepEqual(unloads, [], 'the budget of a shown scene applies (10 + 10 ≤ 100)');
|
||||
await lru.acquire('bench'); await lru.acquire('op'); // … the prep views take their models back
|
||||
c.advance(5000);
|
||||
assert.deepEqual(unloads, []);
|
||||
lru.release('bench'); lru.release('op'); // the view is gone for good (lobby)
|
||||
c.advance(1000);
|
||||
assert.deepEqual(unloads, [], 'not quiet long enough yet');
|
||||
c.advance(2100);
|
||||
assert.deepEqual(unloads, [], 'quiet, but both are still inside their grace');
|
||||
c.advance(2000);
|
||||
assert.deepEqual(unloads.sort(), ['bench', 'op'], 'the quiet budget empties the cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spine store: unload / reload of the same skeleton (user playtest #3 item 1)', () => {
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
/**
|
||||
* PIXI.Assets semantics that caused the invisible models: `unload` frees an asset a microtask after the call
|
||||
* (Loader.unload awaits the cached promise, then deletes the cache entry and destroys the asset), and `load` of that
|
||||
* URL meanwhile returns the cached — doomed — asset.
|
||||
*/
|
||||
function pixiAssets() {
|
||||
const cache = new Map();
|
||||
let seq = 0;
|
||||
const loader = {
|
||||
async unload(url) {
|
||||
const hit = cache.get(url);
|
||||
if (!hit) return;
|
||||
const asset = await hit;
|
||||
cache.delete(url);
|
||||
asset.destroyed = true;
|
||||
},
|
||||
};
|
||||
const Assets = {
|
||||
loader,
|
||||
load(url) {
|
||||
if (!cache.has(url)) cache.set(url, Promise.resolve({ url, id: ++seq, destroyed: false, animations: [] }));
|
||||
return cache.get(url);
|
||||
},
|
||||
unload(url) { return loader.unload(url); },
|
||||
};
|
||||
const prev = globalThis.PIXI;
|
||||
globalThis.PIXI = { Assets, spine: {} };
|
||||
return { Assets, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
|
||||
}
|
||||
|
||||
test('the hazard: PIXI.Assets hands out an asset whose unload is in flight', async () => {
|
||||
const f = pixiAssets();
|
||||
try {
|
||||
await f.Assets.load('/x.skel');
|
||||
f.Assets.unload('/x.skel');
|
||||
const again = await f.Assets.load('/x.skel');
|
||||
await tick();
|
||||
assert.equal(again.destroyed, true, 'the model a view would build from it has no textures');
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test('battle → prep after a long battle: the bench models come back alive (defaults), with or without an eviction', async () => {
|
||||
for (const eager of [false, true]) {
|
||||
const f = pixiAssets();
|
||||
try {
|
||||
const c = clock();
|
||||
// eager = the old timing (evict at once, no quiet delay): the bench model IS dropped at the instant zero —
|
||||
// the re-acquire must then wait for its unload and load a fresh skeleton
|
||||
const a = createAssets({ manifest: M, spineNow: c.now, spineTimers: c.timers, ...(eager ? { spineEvictDelay: 0, spineQuietDelay: 0 } : {}) });
|
||||
const bench = a.spineEntry('char_010_chen'), op = a.spineEntry('char_002_amiya'), foe = a.spineEntry('enemy_1007_slime');
|
||||
const bench0 = await a.spine.acquire(bench);
|
||||
await a.spine.acquire(op);
|
||||
// prep → battle: the prep views go (the bench model idles), the battle views take the board model and the foe
|
||||
a.spine.release(bench); a.spine.release(op);
|
||||
await a.spine.acquire(op); await a.spine.acquire(foe);
|
||||
c.advance(SPINE_IDLE_GRACE_MS + 20000); // a long battle: the bench model idles past its grace
|
||||
// battle → prep in one go (render/app.js enterPrepMode + setPrep): every battle view destroyed, prep views built
|
||||
a.spine.release(op); a.spine.release(foe);
|
||||
const [b1, o1] = await Promise.all([a.spine.acquire(bench), a.spine.acquire(op)]);
|
||||
await tick(); await tick();
|
||||
c.advance(SPINE_EVICT_DELAY_MS + SPINE_QUIET_DELAY_MS + 10);
|
||||
await tick(); await tick();
|
||||
assert.equal(b1.destroyed, false, `${eager ? 'eager' : 'default'}: the bench model is alive`);
|
||||
assert.equal(o1.destroyed, false, `${eager ? 'eager' : 'default'}: the board model is alive`);
|
||||
if (eager) assert.notEqual(b1, bench0, 'eager: dropped, then loaded afresh');
|
||||
else assert.equal(b1, bench0, 'default: never dropped, never reloaded');
|
||||
assert.equal(a.spine.stats().unloading, 0);
|
||||
} finally { f.restore(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spineDataWeight', () => {
|
||||
@@ -418,12 +555,15 @@ describe('spine unload frees the atlas page images too', () => {
|
||||
const f = fakePixi();
|
||||
try {
|
||||
const E = (id, pages) => ({ ...SP(id), textures: pages });
|
||||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1 });
|
||||
const c = clock();
|
||||
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1, spineNow: c.now, spineTimers: c.timers });
|
||||
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
|
||||
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
|
||||
await a.spine.acquire(ea);
|
||||
await a.spine.acquire(eb);
|
||||
a.spine.release(ea); // a evicted (max 1) while b still uses common.png
|
||||
a.spine.release(ea); // a evicted (max 1) while b still uses common.png …
|
||||
assert.deepEqual(f.calls.assets, [], '… in the eviction pass a moment later (SPINE_EVICT_DELAY_MS)');
|
||||
c.advance(SPINE_EVICT_DELAY_MS + 10);
|
||||
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
|
||||
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
|
||||
a.spine.release(eb);
|
||||
|
||||
@@ -77,28 +77,70 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
|
||||
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
|
||||
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
|
||||
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s)
|
||||
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
|
||||
// in flight (every projectile kind incl. the boomerang) and 蕾缪安's S3 locks (the gallery plays it at 'lock')
|
||||
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
|
||||
await wait(Math.min(45000, kinds * 260 + 1500));
|
||||
const seen = await page.evaluate(async (ms) => {
|
||||
const seen = { projectiles: 0, particles: 0, locks: 0, auras: 0 };
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < ms) {
|
||||
const st = window.__demo.stats();
|
||||
for (const k of Object.keys(seen)) seen[k] = Math.max(seen[k], st[k] || 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return seen;
|
||||
}, Math.min(45000, kinds * 260 + 1500));
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(st.mode, 'battle');
|
||||
assert.ok(st.units >= 10, `units ${st.units}`);
|
||||
assert.ok(seen.projectiles > 0 && seen.particles > 20 && seen.auras > 0, `projectiles, particles and a skill aura drawn (${JSON.stringify(seen)})`);
|
||||
assert.ok(seen.locks >= 2, `蕾缪安's lock reticles shown (${JSON.stringify(seen)})`);
|
||||
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
|
||||
});
|
||||
|
||||
test('stress: 120 units + FX keep the per-frame JS cost bounded', async () => {
|
||||
/** fps / JS-per-frame (frame() + Pixi render EMAs, app.js stats) / particles over `ms`, sampled every 200 ms. */
|
||||
async function frameCost(page, ms) {
|
||||
return page.evaluate(async (ms) => {
|
||||
const rows = [];
|
||||
const t0 = performance.now();
|
||||
while (performance.now() - t0 < ms) {
|
||||
const s = window.__demo.stats();
|
||||
rows.push([s.fps, s.cpuMs, s.renderMs, s.particles, s.projectiles]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const avg = (i) => rows.reduce((a, r) => a + r[i], 0) / rows.length;
|
||||
return { fps: avg(0), cpuMs: avg(1), renderMs: avg(2), particles: avg(3), maxParticles: Math.max(...rows.map((r) => r[3])), projectiles: avg(4) };
|
||||
}, ms);
|
||||
}
|
||||
const costLine = (c) => `fps ${c.fps.toFixed(1)} · JS ${c.cpuMs.toFixed(2)} + render ${c.renderMs.toFixed(2)} ms/frame · particles ${c.particles.toFixed(0)} (max ${c.maxParticles}) · shots ${c.projectiles.toFixed(1)}`;
|
||||
|
||||
test('stress: 120 units + FX keep the per-frame JS cost bounded', async (t) => {
|
||||
const { page, problems } = await open('scene=stress&panel=0');
|
||||
await wait(6000);
|
||||
await wait(3000);
|
||||
const cost = await frameCost(page, 3000);
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
|
||||
await page.close();
|
||||
t.diagnostic(`stress 1920×1080: ${costLine(cost)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(st.units, 120);
|
||||
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
|
||||
assert.ok(st.cpuMs + st.renderMs < 16, `JS per frame ${st.cpuMs} + ${st.renderMs} ms`);
|
||||
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
|
||||
});
|
||||
|
||||
test('crowded boss battle: the FX keep the per-frame JS cost bounded', async (t) => {
|
||||
const { page, problems } = await open('scene=boss-m02&t=16&panel=0');
|
||||
await wait(2500);
|
||||
// t ≈ 21–29 (2×): 蕾缪安's S3 locks (21–24) and her shells one by one (24–26), skills, crowds of shots and hits
|
||||
const cost = await frameCost(page, 4000);
|
||||
await page.close();
|
||||
t.diagnostic(`boss-m02 1920×1080: ${costLine(cost)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
assert.ok(cost.fps > 20, `fps ${cost.fps}`);
|
||||
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
|
||||
});
|
||||
|
||||
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ function harness(opts = {}) {
|
||||
const c = createDragController({
|
||||
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
|
||||
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
|
||||
dropPoint: opts.dropPoint,
|
||||
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
|
||||
canPlace: opts.canPlace,
|
||||
emit: (name, p) => events.push([name, p]),
|
||||
@@ -217,6 +218,86 @@ describe('drag controller', () => {
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
|
||||
});
|
||||
|
||||
// user playtest #3 item 7: the drop target is the tile under the dragged ghost's feet (render/app.js dropPoint: the
|
||||
// pointer for a mouse, a little above the finger for touch) — the hover tile, legality, the ghost and the drop agree
|
||||
test('dropPoint: target, tileHover, legality and the drop follow the ghost\'s feet; default = the pointer', () => {
|
||||
const calls = [];
|
||||
const h = harness({
|
||||
dropPoint: (e, piece) => { calls.push([e.pointerType, piece.uid]); return e.pointerType === 'touch' ? { x: e.x, y: e.y - 100 } : null; },
|
||||
canPlace: (p, r) => r !== 11,
|
||||
});
|
||||
h.c.setEditable(true);
|
||||
// mouse: the hook returns nothing → the pointer itself
|
||||
h.ev('pointerDown', 50, 550);
|
||||
h.ev('pointerMove', 350, 250);
|
||||
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.hx, mv.hy, mv.target], [350, 250, { area: 'board', row: 10, col: 3 }]);
|
||||
h.ev('pointerUp', 350, 250);
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 });
|
||||
// touch: the ghost (and the target) one row above the finger
|
||||
h.events.length = 0;
|
||||
h.ev('pointerDown', 50, 550, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 450, 350, { pointerType: 'touch' });
|
||||
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.x, mv.y, mv.hx, mv.hy], [450, 350, 450, 250], 'pointer and drop point both reported');
|
||||
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 4 });
|
||||
assert.equal(mv.legal, true);
|
||||
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 4), 'tileHover = the target');
|
||||
h.ev('pointerMove', 450, 250, { pointerType: 'touch' });
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].legal, false, 'row 11 under the ghost: illegal');
|
||||
h.ev('pointerUp', 450, 350, { pointerType: 'touch' });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
|
||||
assert.ok(calls.every(([, uid]) => uid === 1) && calls.some(([t]) => t === 'touch'));
|
||||
});
|
||||
|
||||
// the view may skip its pixel probe on hover picks (a readback per mouse move stalls on the GPU): the controller says
|
||||
// which calls are hovers — mouse moves without a press, drag moves — and which must be exact: presses and releases
|
||||
test('hitPiece / dropPoint tell hovers from presses and releases', () => {
|
||||
const hits = [];
|
||||
const drops = [];
|
||||
const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }];
|
||||
const c = createDragController({
|
||||
hitPiece: (x, y, hover) => { hits.push(hover === true); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; },
|
||||
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
|
||||
dropPoint: (e, piece, final) => { drops.push(final === true); return null; },
|
||||
emit: () => {},
|
||||
});
|
||||
const ev = (type, x, y) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y });
|
||||
c.setEditable(true);
|
||||
ev('pointerMove', 50, 550);
|
||||
assert.deepEqual(hits, [true], 'a mouse move without a press is a hover');
|
||||
ev('pointerDown', 50, 550);
|
||||
assert.deepEqual(hits, [true, false], 'a press probes');
|
||||
ev('pointerMove', 350, 250);
|
||||
ev('pointerMove', 360, 250);
|
||||
ev('pointerUp', 360, 250);
|
||||
assert.ok(drops.length >= 3);
|
||||
assert.ok(drops.slice(0, -1).every((f) => f === false), 'drag moves are hovers');
|
||||
assert.equal(drops[drops.length - 1], true, 'the release is final');
|
||||
});
|
||||
|
||||
// review of item 7: on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it — a
|
||||
// touch ghost lifted onto the bench has the finger on the shop bar. DOM cover is judged at the drop point.
|
||||
test('dropPoint: DOM cover is tested at the ghost\'s feet, not at the finger (touch onto the bench above the shop bar)', () => {
|
||||
const h = harness({ domCover: true, dropPoint: (e) => (e.pointerType === 'touch' ? { x: e.x, y: e.y - 400 } : null) });
|
||||
h.c.setEditable(true);
|
||||
// board unit (10,4) dragged by touch; the finger ends on the covered strip (y > 900), its ghost on bench slot 2
|
||||
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
|
||||
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual(mv.target, { area: 'hand', idx: 2 });
|
||||
assert.equal(mv.legal, true);
|
||||
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 });
|
||||
// the ghost itself under the DOM: outside (the piece goes back), as for a mouse released on the DOM
|
||||
h.events.length = 0;
|
||||
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
|
||||
h.ev('pointerMove', 250, 1350, { pointerType: 'touch' });
|
||||
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].target, null);
|
||||
h.ev('pointerUp', 250, 1350, { pointerType: 'touch' });
|
||||
assert.equal(h.events.find((e) => e[0] === 'pieceDrop')[1].target.area, 'outside');
|
||||
});
|
||||
|
||||
test('a throwing listener or hit-test never breaks the controller', () => {
|
||||
const c = createDragController({
|
||||
hitPiece: () => { throw new Error('boom'); },
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// test/render/fxproj.test.js — battle FX of render/fx.js against a headless fake PIXI (test/render/fakepixi.js):
|
||||
// * projectile speeds follow the sim (style.js PROJ mirrors server/sim/constants.js; the live table wins)
|
||||
// * the FX atlas layout (render/textures.js fxFrames): no two frames share a pixel (the light pillar used to run into
|
||||
// the status-icon row), frame sizes other modules scale by are kept
|
||||
// * pooled particles: a freed sprite is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`) and
|
||||
// its record is reused — a steady stream of shots stops allocating sprites
|
||||
// * shots: flight time = distance / sim speed / clock rate, homing on the moving target, one arrival burst, then gone
|
||||
// * 回环射手 boomerang: out at the sim's speed, back to the thrower's CURRENT position at the return speed, caught there;
|
||||
// dropped when the thrower is gone
|
||||
// * 蕾缪安 S3: a lock reticle follows its enemy until the 'bombard' of the shell fired at it; 'bombardShell' flies for
|
||||
// `t` game s — however long the S3 runs (extra ammo, stuns); blasts named after the shooter happen at their (x, y);
|
||||
// the S2 aim lock ends with its 'crit'; a lock of a shooter gone quiet times out
|
||||
// * fx anchoring (_where), quality / load gating of cosmetic particles, melee slash direction, skill burst + aura
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as SIM from '../../server/sim/constants.js';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { PROJ, HIT_TINT } from '../../public/js/render/style.js';
|
||||
|
||||
let fake, FX, T;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
T = await import('../../public/js/render/textures.js');
|
||||
});
|
||||
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
|
||||
|
||||
const DT = 1 / 120;
|
||||
const cam = presetCamera('normal', { width: 1600, height: 900 });
|
||||
|
||||
/** A unit view as the FX system reads it. */
|
||||
function unit(id, x, y, o = {}) {
|
||||
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {}, ...o };
|
||||
}
|
||||
|
||||
/** A FxSystem on fake layers; `views` resolves fx ids; ts = battle clock rate. */
|
||||
function makeFx({ quality = 'high', ts = 2, load = 0, views = [] } = {}) {
|
||||
const P = fake.P;
|
||||
const ctx = fakeViewCtx(P);
|
||||
const map = new Map(views.map((v) => [v.id, v]));
|
||||
const fx = new FX.FxSystem({
|
||||
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
|
||||
timeScale: () => ts, loadLevel: () => load, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
return { fx, map };
|
||||
}
|
||||
|
||||
const run = (fx, seconds, dt = DT) => { for (let t = 0; t < seconds - 1e-9; t += dt) fx.update(dt); };
|
||||
const texName = (fx, tex) => Object.keys(fx.tex).find((k) => fx.tex[k] === tex);
|
||||
const liveTex = (fx, name) => fx.parts.filter((p) => p.sp.texture === fx.tex[name]);
|
||||
|
||||
describe('projectile speeds follow the sim', () => {
|
||||
test('style.js PROJ mirrors PROJECTILE_SPEEDS for every sim kind (and the boomerang return speed)', () => {
|
||||
for (const [kind, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) {
|
||||
assert.ok(PROJ[kind], `render/style.js PROJ has no visual for the sim's projectile kind '${kind}'`);
|
||||
assert.equal(PROJ[kind].speed, v, `PROJ.${kind}.speed ${PROJ[kind].speed} ≠ sim PROJECTILE_SPEEDS.${kind} ${v}: update the mirror`);
|
||||
}
|
||||
if (SIM.BOOMERANG_RETURN_SPEED !== undefined) assert.equal(PROJ.boomerang.back, SIM.BOOMERANG_RETURN_SPEED, 'PROJ.boomerang.back mirrors BOOMERANG_RETURN_SPEED');
|
||||
});
|
||||
|
||||
test('projSpeed: the live sim table first, then the style.js copy; boomerangReturn = the return speed', () => {
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
assert.equal(FX.projSpeed('orb'), PROJ.orb.speed);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||||
assert.equal(FX.projSpeed('nope'), 12);
|
||||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, orb: 99, boomerang: 20 }, 5);
|
||||
assert.equal(FX.projSpeed('orb'), 99);
|
||||
assert.equal(FX.projSpeed('boomerang'), 20);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), 5);
|
||||
FX.setSimProjectileSpeeds({ arrow: 1 }); // an older sim table: the rest from the copy
|
||||
assert.equal(FX.projSpeed('arrow'), 1);
|
||||
assert.equal(FX.projSpeed('bolt'), PROJ.bolt.speed);
|
||||
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
|
||||
FX.setSimProjectileSpeeds(SIM.PROJECTILE_SPEEDS, SIM.BOOMERANG_RETURN_SPEED);
|
||||
for (const [k, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) assert.equal(FX.projSpeed(k), v);
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FX atlas layout', () => {
|
||||
test('frames never overlap and stay inside the atlas; shared frame sizes are kept', () => {
|
||||
const { size: [W, H], frames } = T.fxFrames();
|
||||
const list = Object.entries(frames);
|
||||
for (const [name, [x, y, w, h]] of list) assert.ok(x >= 0 && y >= 0 && x + w <= W && y + h <= H, `${name} inside ${W}×${H}`);
|
||||
const hits = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [a, [ax, ay, aw, ah]] = list[i], [b, [bx, by, bw, bh]] = list[j];
|
||||
if (ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah) hits.push(`${a} × ${b}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(hits, [], 'overlapping FX atlas frames');
|
||||
// sizes other modules scale sprites by (units.js glow / chevron, tiles.js soft / ring / dot / smoke, app.js coin)
|
||||
const size = (n) => frames[n].slice(2).join('×');
|
||||
for (const [n, s] of [['glow', '128×128'], ['soft', '128×128'], ['ring', '128×128'], ['hex', '128×128'], ['smoke', '128×128'], ['dot', '32×32'], ['chevron', '64×64'], ['coin', '48×48'], ['pillar', '64×256'], ['streak', '128×32'], ['slash', '128×64']]) assert.equal(size(n), s, n);
|
||||
for (const k of T.STATUS_KEYS) assert.equal(size('st_' + k), '32×32', k);
|
||||
for (const n of ['tracer', 'shock', 'reticle', 'flare', 'boomerang', 'muzzle']) assert.ok(frames[n], `new frame ${n}`);
|
||||
});
|
||||
|
||||
test('fxAtlas builds one texture per frame on one base texture', () => {
|
||||
const a = T.fxAtlas();
|
||||
const { frames } = T.fxFrames();
|
||||
assert.deepEqual(Object.keys(a.tex).sort(), Object.keys(frames).sort());
|
||||
for (const t of Object.values(a.tex)) assert.equal(t.baseTexture, a.base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('particles', () => {
|
||||
test('a freed particle is a zero-size transparent quad and its record is reused', () => {
|
||||
const { fx } = makeFx();
|
||||
const p = fx.particle('glow', 10, 10, { life: 0.05, s0: 1, a0: 1, a1: 1 });
|
||||
const sp = p.sp;
|
||||
run(fx, 0.1);
|
||||
assert.equal(fx.parts.length, 0);
|
||||
assert.equal(sp.alpha, 0, 'alpha 0 (the particle renderer draws invisible children too)');
|
||||
assert.deepEqual([sp.scale.x, sp.scale.y], [0, 0], 'zero size: no fill cost');
|
||||
const n = fx.addPc.children.length;
|
||||
const q = fx.particle('spark', 5, 5, { life: 0.3 });
|
||||
assert.equal(q, p, 'the pooled record comes back');
|
||||
assert.equal(fx.addPc.children.length, n, 'no new sprite');
|
||||
assert.equal(q.sp.texture, fx.tex.spark);
|
||||
assert.ok(q.sp.alpha > 0 && q.sp.scale.x > 0);
|
||||
});
|
||||
|
||||
test('a steady stream of shots and hits only ever makes as many sprites as were alive at once', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 11, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
const kinds = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'boomerang', 'none'];
|
||||
let peakAdd = 0, peakNorm = 0, peakProj = 0;
|
||||
const sample = () => {
|
||||
let add = 0;
|
||||
for (const p of fx.parts) if (p.add) add++;
|
||||
peakAdd = Math.max(peakAdd, add);
|
||||
peakNorm = Math.max(peakNorm, fx.parts.length - add);
|
||||
peakProj = Math.max(peakProj, fx.projs.length);
|
||||
};
|
||||
for (let f = 0; f < 60 * 12; f++) {
|
||||
if (f % 6 === 0) {
|
||||
const i = f / 6;
|
||||
fx.attack(a, b, kinds[i % kinds.length]);
|
||||
fx.damage(b, 120, i % 2 ? 'arts' : 'phys', i % 9 === 8 ? a : null);
|
||||
}
|
||||
sample(); // (particles are freed only at the start of an update)
|
||||
fx.update(1 / 60);
|
||||
sample();
|
||||
}
|
||||
assert.ok(peakProj >= 3 && peakAdd > 20, `busy enough (${peakProj} shots, ${peakAdd} particles at once)`);
|
||||
assert.equal(fx.addPc.children.length, peakAdd, 'additive particle sprites = the peak alive at once');
|
||||
assert.equal(fx.normPc.children.length, peakNorm, 'normal-blend particle sprites = the peak alive at once');
|
||||
assert.equal(fx.projLayer.children.length, 3 * peakProj, 'three sprites per projectile record');
|
||||
assert.equal(fx.shadowLayer.children.length, peakProj);
|
||||
assert.ok(fx.parts.length <= fx.maxParticles);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shots', () => {
|
||||
test('flight time = distance / sim speed / clock rate; homing on the moving target; one arrival burst', () => {
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
const a = unit(1, 3, 10), b = unit(2, 9, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ ts: 2, views: [a, b] });
|
||||
fx.attack(a, b, 'arrow');
|
||||
assert.equal(fx.projs.length, 1);
|
||||
const pr = fx.projs[0];
|
||||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
assert.ok(Math.abs(pr.dur - dist / PROJ.arrow.speed / 2) < 1e-9, `dur ${pr.dur}`);
|
||||
assert.equal(pr.trail.texture, fx.tex.tracer);
|
||||
assert.equal(liveTex(fx, 'muzzle').length, 1, 'muzzle flash at the shooter');
|
||||
b.x = 9.5; // the target moves: the shot follows it
|
||||
let t = 0;
|
||||
while (!pr.hit && t < 1) { fx.update(DT); t += DT; }
|
||||
assert.ok(Math.abs(t - pr.dur) <= DT + 1e-9, `arrived after ${t} (dur ${pr.dur})`);
|
||||
assert.equal(pr.tx, 9.5);
|
||||
const flares = liveTex(fx, 'flare').length;
|
||||
assert.ok(flares >= 1, 'arrival flare');
|
||||
run(fx, 0.2);
|
||||
assert.equal(fx.projs.length, 0, 'released after its short fade');
|
||||
assert.equal(pr.trail.visible || pr.halo.visible || pr.core.visible, false);
|
||||
});
|
||||
|
||||
test('every projectile kind flies and lands; shells cast a shadow and explode', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 7, 11, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
for (const kind of Object.keys(PROJ)) {
|
||||
fx.clear();
|
||||
fx.attack(a, b, kind);
|
||||
assert.equal(fx.projs.length, 1, kind);
|
||||
const pr = fx.projs[0];
|
||||
fx.update(DT);
|
||||
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
|
||||
if (kind === 'bomb') {
|
||||
run(fx, pr.dur);
|
||||
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
|
||||
}
|
||||
run(fx, 3);
|
||||
assert.equal(fx.projs.length, 0, `${kind} done`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an arts blastcaster's bomb explodes purple", () => {
|
||||
const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null);
|
||||
fx.attack(a, b, 'bomb');
|
||||
assert.equal(fx.projs[0].glow, 0xb36bff);
|
||||
});
|
||||
|
||||
test('chain / beam kinds are beams, melee shots are no projectile', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'chain');
|
||||
fx.attack(a, b, 'beam');
|
||||
fx.attack(a, b, 'none');
|
||||
assert.equal(fx.projs.length, 0);
|
||||
assert.equal(fx.beamList.length, 2);
|
||||
assert.equal(fx._slashAt, a.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回环射手 boomerang', () => {
|
||||
test('out at the boomerang speed, back to where the thrower is now at the return speed, caught there', () => {
|
||||
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, boomerang: 15 }, 3.75);
|
||||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ ts: 2, views: [thrower, target] });
|
||||
fx.attack(thrower, target, 'boomerang');
|
||||
const pr = fx.projs[0];
|
||||
const out = Math.hypot(target.x - pr.bx, target.y - pr.by);
|
||||
let t = 0;
|
||||
while (pr.phase === 0 && t < 3) { fx.update(DT); t += DT; }
|
||||
assert.ok(Math.abs(t - out / (15 * 2)) <= 2 * DT, `out leg ${t.toFixed(3)} s ≈ ${(out / 30).toFixed(3)} s`);
|
||||
thrower.x = 3; // the thrower moved meanwhile: it flies to where it is now
|
||||
const back = Math.hypot(thrower.x - target.x, thrower.y - target.y);
|
||||
let t2 = 0;
|
||||
while (fx.projs.includes(pr) && t2 < 5) { fx.update(DT); t2 += DT; }
|
||||
assert.ok(Math.abs(t2 - back / (3.75 * 2)) <= 2 * DT, `back leg ${t2.toFixed(3)} s ≈ ${(back / 7.5).toFixed(3)} s`);
|
||||
assert.ok(Math.abs(pr.bx - 3) < 1e-9, 'caught at the thrower');
|
||||
FX.setSimProjectileSpeeds(null);
|
||||
});
|
||||
|
||||
test('it spins, and is gone at once when the thrower leaves the field', () => {
|
||||
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [thrower, target] });
|
||||
fx.attack(thrower, target, 'boomerang');
|
||||
const pr = fx.projs[0];
|
||||
fx.update(DT);
|
||||
const r0 = pr.core.rotation;
|
||||
fx.update(DT);
|
||||
assert.notEqual(pr.core.rotation, r0, 'spinning');
|
||||
assert.equal(pr.core.texture, fx.tex.boomerang);
|
||||
while (pr.phase === 0) fx.update(DT);
|
||||
fx.update(DT);
|
||||
thrower.destroyed = true;
|
||||
fx.update(DT);
|
||||
assert.equal(fx.projs.length, 0, 'dropped on the way back');
|
||||
// a thrower already knocked out at the hit gets nothing back
|
||||
const t2 = unit(3, 2, 11);
|
||||
fx.attack(t2, target, 'boomerang');
|
||||
t2.alive = false;
|
||||
run(fx, 1);
|
||||
assert.equal(fx.projs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('蕾缪安 S3: lock, bombardShell, bombard', () => {
|
||||
const setup = () => {
|
||||
const lem = unit(1, 2, 10), e1 = unit(21, 8, 10, { isEnemy: true }), e2 = unit(23, 9, 11.5, { isEnemy: true });
|
||||
return { lem, e1, e2, ...makeFx({ ts: 2, views: [lem, e1, e2] }) };
|
||||
};
|
||||
|
||||
test('a lock follows its enemy until the bombard of the shell fired at it', () => {
|
||||
const { fx, e1 } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
assert.equal(fx.locks.length, 2);
|
||||
const [L1, L2] = fx.locks;
|
||||
e1.x = 8.4;
|
||||
fx.update(DT);
|
||||
assert.equal(L1.x, 8.4, 'the reticle follows the locked enemy');
|
||||
assert.equal(L1.ring.texture, fx.tex.reticle);
|
||||
// the shell fired at e1's spot takes that lock; it flies t game s = t / 2 real s
|
||||
fx.simFx('bombardShell', 8.5, 10.1, { id: 1, r: 1.5, t: 0.3, i: 0 });
|
||||
const shell = fx.projs.find((p) => p.kind === 'bombardShell');
|
||||
assert.ok(shell, 'a shell in the air');
|
||||
assert.ok(Math.abs(shell.dur - 0.15) < 1e-9);
|
||||
assert.equal(L1.shell, true);
|
||||
assert.equal(L2.shell, false);
|
||||
assert.ok(fx.rings.filter((r) => r.x === 8.5 && r.y === 10.1).length >= 2, 'warning rings on the spot');
|
||||
run(fx, 0.16);
|
||||
assert.equal(fx.projs.filter((p) => p.kind === 'bombardShell').length, 0, 'landed');
|
||||
assert.ok(L1.out < 0, 'the lock waits for the impact itself');
|
||||
fx.simFx('bombard', 8.5, 10.1, { id: 1, r: 1.5 });
|
||||
assert.ok(L1.out >= 0, 'released by its bombard');
|
||||
assert.ok(L2.out < 0, 'the other lock stays');
|
||||
run(fx, 0.3);
|
||||
assert.deepEqual(fx.locks, [L2]);
|
||||
});
|
||||
|
||||
test("bombard happens at its (x, y), never on the shooter named in `id`; it is a heavy blast", () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||||
assert.ok(fx.rings.length >= 2);
|
||||
for (const r of fx.rings) assert.deepEqual([r.x, r.y], [8, 10]);
|
||||
assert.ok(!fx.rings.some((r) => r.x === lem.x), 'nothing on 蕾缪安');
|
||||
assert.ok(liveTex(fx, 'shard').length > 0, 'debris (heavy)');
|
||||
});
|
||||
|
||||
test('a bombard with no shell (older sims) releases the nearest lock; locks time out; the S2 snipe ends its aim lock', () => {
|
||||
const { fx } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
fx.simFx('bombard', 9, 11.5, { id: 1, r: 1.5 });
|
||||
assert.deepEqual(fx.locks.map((L) => L.out >= 0), [false, true]);
|
||||
run(fx, 5 / 2 + 0.3); // LOCK_T game s at 2×, plus the fade
|
||||
assert.equal(fx.locks.length, 0, 'timed out');
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('crit', 8, 10, { id: 23, src: 1 }); // another enemy: stays
|
||||
assert.ok(fx.locks[0].out < 0);
|
||||
fx.simFx('crit', 8, 10, { id: 21, src: 1 });
|
||||
assert.ok(fx.locks[0].out >= 0, 'the snipe on the locked enemy ends the lock');
|
||||
});
|
||||
|
||||
test('the shooter knocked out: locks still waiting for a shell go, the one whose shell is in the air stays', () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||||
const [L1, L2] = fx.locks;
|
||||
lem.alive = false;
|
||||
fx.update(DT);
|
||||
assert.ok(L1.out < 0, 'its shell still lands');
|
||||
assert.ok(L2.out >= 0, 'no shell will come');
|
||||
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
|
||||
assert.ok(L1.out >= 0);
|
||||
});
|
||||
|
||||
test('a long S3 (extra ammo, a stun mid-lock): every reticle stays until the bombard of its own shell', () => {
|
||||
// 拉特兰 ammo grants / reloads give S3 more than its 5 shots: 20 locks 0.5 game s apart (a 3 game s stun in the
|
||||
// middle), then one shell every 0.3 game s landing 0.3 game s later — the first lock waits ≈ 16 game s for its shell
|
||||
const enemies = Array.from({ length: 4 }, (_, k) => unit(30 + k, 7 + k, 9 + (k % 2), { isEnemy: true }));
|
||||
const lem = unit(1, 2, 10);
|
||||
const { fx } = makeFx({ ts: 2, views: [lem, ...enemies] });
|
||||
const g = (s) => s / 2; // game s → real s at 2×
|
||||
const locks = [];
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const e = enemies[k % 4];
|
||||
fx.simFx('lock', e.x, e.y, { id: e.id, src: 1 });
|
||||
locks.push(fx.locks[fx.locks.length - 1]);
|
||||
run(fx, g(k === 9 ? 3.5 : 0.5));
|
||||
}
|
||||
assert.equal(new Set(locks).size, 20, 'no reticle recycled for another lock');
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const e = enemies[k % 4];
|
||||
fx.simFx('bombardShell', e.x + 0.1, e.y, { id: 1, r: 1.5, t: 0.3, i: k });
|
||||
run(fx, g(0.3));
|
||||
const live = fx.locks.filter((L) => L.out < 0).length;
|
||||
assert.equal(live, 20 - k, `shell ${k}: every lock whose bombard is still to come is up`);
|
||||
fx.simFx('bombard', e.x + 0.1, e.y, { id: 1, r: 1.5 });
|
||||
}
|
||||
run(fx, 0.3);
|
||||
assert.equal(fx.locks.length, 0);
|
||||
});
|
||||
|
||||
test('a lock sticks to the enemy named in `id` even when its view is a little off the event spot', () => {
|
||||
const { fx, e1 } = setup();
|
||||
fx.simFx('lock', 9, 10, { id: 21, src: 1 }); // e1 drawn at (8, 10): a fast walker between snapshots
|
||||
assert.equal(fx.locks[0].view, e1);
|
||||
e1.x = 7.5;
|
||||
fx.update(DT);
|
||||
assert.equal(fx.locks[0].x, 7.5);
|
||||
});
|
||||
|
||||
test('a lock whose shooter goes quiet still times out (S2 aim at an enemy that died)', () => {
|
||||
const { fx } = setup();
|
||||
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
|
||||
run(fx, 5 / 2 - 0.05);
|
||||
assert.equal(fx.locks[0].out, -1);
|
||||
run(fx, 0.35);
|
||||
assert.equal(fx.locks.length, 0);
|
||||
});
|
||||
|
||||
test('a long shell flight climbs out of the shooter first; a short one only falls', () => {
|
||||
const { fx, lem } = setup();
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 1.2 });
|
||||
fx.simFx('bombardShell', 9, 11.5, { id: 1, r: 1.5, t: 0.3 });
|
||||
const [long, short] = fx.projs;
|
||||
assert.ok(long.rise > 0 && short.rise === 0);
|
||||
lem.x = 2.5;
|
||||
fx.update(DT);
|
||||
assert.equal(long.x0, 2.5, 'rising from where she stands');
|
||||
run(fx, 1);
|
||||
assert.equal(fx.projs.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fx placement, quality, melee, skill', () => {
|
||||
test('_where: on the unit when the fx happens there, else at the event position', () => {
|
||||
const caster = unit(1, 2, 10);
|
||||
const { fx } = makeFx({ views: [caster] });
|
||||
assert.equal(fx._where(2.2, 10.1, { id: 1 }).v, caster, 'on the unit');
|
||||
assert.equal(fx._where(NaN, NaN, { id: 1 }).v, caster, 'no position: the unit');
|
||||
const far = fx._where(5, 10, { id: 1 });
|
||||
assert.deepEqual([far.x, far.y, far.v], [5, 10, null], 'an aoe ahead of its caster');
|
||||
fx.simFx('aoe', 6, 11, { id: 1, r: 1 });
|
||||
assert.ok(fx.rings.length && fx.rings.every((r) => r.x === 6 && r.y === 11), 'drawn where it happens');
|
||||
});
|
||||
|
||||
test("quality 'low' and a heavy load skip the cosmetic particles, not the shots", () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||||
for (const [opts, cosmetic] of [[{ quality: 'high' }, true], [{ quality: 'low' }, false], [{ quality: 'high', load: 2 }, false]]) {
|
||||
const { fx } = makeFx({ ...opts, views: [a, b] });
|
||||
fx.attack(a, b, 'bolt');
|
||||
fx.attack(a, b, 'arrow');
|
||||
run(fx, 0.05);
|
||||
assert.equal(fx.projs.length, 2);
|
||||
const extras = liveTex(fx, 'dot').length + liveTex(fx, 'muzzle').length;
|
||||
assert.equal(extras > 0, cosmetic, JSON.stringify(opts));
|
||||
}
|
||||
});
|
||||
|
||||
test('a melee blow draws a slash in the hit colour, swept along the blow', () => {
|
||||
const a = unit(1, 4, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'none');
|
||||
fx.damage(b, 300, 'arts', a);
|
||||
const slashes = liveTex(fx, 'slash');
|
||||
assert.equal(slashes.length, 2);
|
||||
assert.equal(slashes[0].sp.tint, HIT_TINT.arts);
|
||||
const pa = cam.project(a.x, a.y, 0.54, {}), pb = cam.project(b.x, b.y, 0.54, {});
|
||||
const blow = Math.atan2(pb.y - pa.y, pb.x - pa.x) + Math.PI / 2;
|
||||
const d = Math.atan2(Math.sin(slashes[0].sp.rotation - blow), Math.cos(slashes[0].sp.rotation - blow));
|
||||
assert.ok(Math.abs(d) <= 0.46, `rotation ${slashes[0].sp.rotation} vs blow ${blow}`);
|
||||
assert.equal(fx._slashAt, null);
|
||||
fx.damage(b, 300, 'phys', a); // a second hit without a new blow: no slash
|
||||
assert.equal(liveTex(fx, 'slash').length, 2);
|
||||
});
|
||||
|
||||
test('skill activation bursts (pillars, flare, shockwave + hex) and keeps an aura until it ends', () => {
|
||||
const v = unit(1, 4, 10);
|
||||
const { fx } = makeFx({ views: [v] });
|
||||
fx.skill(v, true);
|
||||
assert.equal(liveTex(fx, 'pillar').length, 2);
|
||||
assert.equal(liveTex(fx, 'flare').length, 1);
|
||||
assert.deepEqual(fx.rings.map((r) => texName(fx, r.sp.texture)).sort(), ['hex', 'shock']);
|
||||
assert.equal(fx.auras.size, 1);
|
||||
run(fx, 1);
|
||||
const a = fx.auras.get(1);
|
||||
assert.ok(a.sp.alpha > 0.9 && a.hex.alpha > 0.5, 'aura shown');
|
||||
fx.skill(v, false);
|
||||
run(fx, 0.6);
|
||||
assert.equal(fx.auras.size, 0, 'faded out');
|
||||
});
|
||||
|
||||
test('clear() drops shots, locks, auras and particles', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.attack(a, b, 'boomerang');
|
||||
fx.simFx('lock', 8, 10, { id: 2, src: 1 });
|
||||
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
|
||||
fx.skill(a, true);
|
||||
fx.update(DT);
|
||||
fx.clear();
|
||||
assert.deepEqual([fx.projs.length, fx.locks.length, fx.auras.size, fx.parts.length], [0, 0, 0, 0]);
|
||||
fx.update(DT);
|
||||
fx.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// test/render/models.browser.test.js — user playtest #3 items 1 and 7 in headless Chrome, through the in-match mock
|
||||
// (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
|
||||
// 1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch
|
||||
// (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand
|
||||
// the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible
|
||||
// chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After
|
||||
// each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the
|
||||
// canvas pixels over it change when it is hidden (it really draws something).
|
||||
// 7 picking: with an operator directly behind another (same column), presses on its head / torso / legs, on its
|
||||
// feet where they show and on its tile beside the front one's head select IT; the front one's face selects the
|
||||
// front one — in prep and in battle; equipment dropped on the body of the one behind equips it; a dragged unit's
|
||||
// ghost stands on the pointer and the drop (direction wheel) is that tile. Every press on a drawn unit pixel (the
|
||||
// whole prep board and a battle) picks the front-most unit drawn there (render/pick.js pixel probe).
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.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';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
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.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Page-side helpers (installed once per page): the prep pieces, their views, the invariants of item 1. */
|
||||
function installHelpers() {
|
||||
const G = { HAND_ROW: 7, TEMP_ROW: 8, TEMP_C0: 4 };
|
||||
const raw = () => globalThis.__SP_VIEW__.raw;
|
||||
const priv = () => globalThis.__MOCK__.store.get().match.private;
|
||||
const texOk = (t) => !!t && !t.destroyed && !!t.baseTexture && !t.baseTexture.destroyed && t.baseTexture.valid !== false;
|
||||
window.__t = {
|
||||
pieces() {
|
||||
const p = priv(), out = [];
|
||||
(p.board || []).forEach((x) => x && out.push({ uid: x.uid, kind: x.kind, row: x.row, col: x.col }));
|
||||
(p.hand || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.HAND_ROW, col: i }));
|
||||
(p.temp || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.TEMP_ROW, col: G.TEMP_C0 + i }));
|
||||
return out;
|
||||
},
|
||||
/** Client point `up` tiles above the feet of a view (+ `dx` tiles aside); views keyed 'p:uid' (prep) or unit id. */
|
||||
at(key, up, dx = 0) {
|
||||
const R = raw(), v = R.debug.views.get(key), cv = R.debug.app.view.getBoundingClientRect();
|
||||
return { x: cv.left + v.screen.x + dx * v.screen.s, y: cv.top + v.screen.y - up * v.screen.s };
|
||||
},
|
||||
/** Item 1 invariants; returns the problems found. */
|
||||
check() {
|
||||
const R = raw(), problems = [];
|
||||
for (const p of this.pieces()) {
|
||||
const v = R.debug.views.get('p:' + p.uid), tag = `${p.uid}@${p.row},${p.col}`;
|
||||
if (!v || v.destroyed) { problems.push(`${tag}: no view`); continue; }
|
||||
if (!v.root.visible || !(v.root.alpha > 0.9)) problems.push(`${tag}: hidden (${v.root.visible}, ${v.root.alpha})`);
|
||||
if (Math.abs(v.x - p.col) > 0.05 || Math.abs(v.y - p.row) > 0.05) problems.push(`${tag}: parked at ${v.x.toFixed(2)},${v.y.toFixed(2)}`);
|
||||
if (p.kind === 'item') continue;
|
||||
if (!v.actor || !v.spineReady) { problems.push(`${tag}: no Spine model`); continue; }
|
||||
let dead = 0, n = 0;
|
||||
for (const slot of v.actor.spine.skeleton.slots) { const s = slot.currentSprite || slot.currentMesh; if (!s || !s.texture) continue; n++; if (!texOk(s.texture)) dead++; }
|
||||
if (dead) problems.push(`${tag}: ${dead}/${n} textures destroyed`);
|
||||
if (v.imp) { if (!texOk(v.imp.sprite.texture)) problems.push(`${tag}: impostor texture destroyed`); }
|
||||
else if (v.actor.spine.parent !== v.body || !v.actor.spine.visible || !(v.actor.spine.alpha > 0.9)) problems.push(`${tag}: model not shown`);
|
||||
}
|
||||
return problems;
|
||||
},
|
||||
/** Share of the pixels over each unit's body that change when the unit is hidden (≈ 0: nothing drawn). */
|
||||
drawn() {
|
||||
const R = raw(), app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution, H = rd.view.height, out = {};
|
||||
for (const p of this.pieces()) {
|
||||
if (p.kind === 'item') continue;
|
||||
const v = R.debug.views.get('p:' + p.uid);
|
||||
if (!v) continue;
|
||||
const b = v.bounds();
|
||||
const x0 = Math.max(0, Math.floor(b.x * res)), y0 = Math.max(0, Math.floor(b.y * res));
|
||||
const w = Math.max(1, Math.min(Math.floor(b.width * res), rd.view.width - x0)), h = Math.max(1, Math.min(Math.floor(b.height * res), H - y0));
|
||||
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(x0, H - y0 - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
|
||||
const a = read();
|
||||
v.root.visible = false;
|
||||
const c = read();
|
||||
v.root.visible = true;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 4) if (Math.abs(a[i] - c[i]) + Math.abs(a[i + 1] - c[i + 1]) + Math.abs(a[i + 2] - c[i + 2]) + Math.abs(a[i + 3] - c[i + 3]) > 40) diff++;
|
||||
out[p.uid] = diff / (w * h);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const require = createRequire(path.join(ROOT, 'package.json'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--force-device-scale-factor=1'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(query, w = 1920, h = 1080) {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.setViewport({ width: w, height: h });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
|
||||
await page.evaluate(installHelpers);
|
||||
await sleep(2000); // Spine models
|
||||
return { page, problems };
|
||||
}
|
||||
const setPhase = (page, ph) => page.evaluate(async (p) => { const { PHASE } = await import('/shared/constants.js'); globalThis.__MOCK__.setPhase(PHASE[p]); }, ph);
|
||||
const uidAt = (page, row, col) => page.evaluate((r, c) => globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === c)?.uid ?? null, row, col);
|
||||
const assertAllDrawn = async (page, label) => {
|
||||
assert.deepEqual(await page.evaluate(() => window.__t.check()), [], `${label}: every prep model alive and in place`);
|
||||
const drawn = await page.evaluate(() => window.__t.drawn());
|
||||
for (const [uid, frac] of Object.entries(drawn)) assert.ok(frac > 0.05, `${label}: unit ${uid} draws pixels (${frac.toFixed(3)})`);
|
||||
};
|
||||
|
||||
test('1: the prep models come back alive after a battle that outlasts the Spine LRU grace', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await assertAllDrawn(page, 'first prep');
|
||||
// a short grace stands in for a long battle (default 15 s)
|
||||
await page.evaluate(async () => { const { assets } = await import('/js/assets.js'); assets.spine.cache.idleGrace = 400; });
|
||||
for (let round = 0; round < 2; round++) {
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(2000);
|
||||
await setPhase(page, 'PREP');
|
||||
await sleep(2500);
|
||||
await assertAllDrawn(page, `prep after battle ${round + 1}`);
|
||||
}
|
||||
const st = await page.evaluate(async () => (await import('/js/assets.js')).assets.spine.stats());
|
||||
assert.equal(st.unloading, 0);
|
||||
await page.screenshot({ path: path.join(OUT, 'models-after-battles.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('1: rapid drags, swaps, wheel previews (all four directions) with commit / Esc / ✕ and re-drags keep every model', async () => {
|
||||
const { page, problems } = await open('phase=PREP', 1600, 900);
|
||||
let seed = 7;
|
||||
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
|
||||
const tiles = await page.evaluate(() => { const st = globalThis.__MOCK__.S().stage; return [...st.deployTiles.normal.melee, ...st.deployTiles.normal.rangedOnly]; });
|
||||
const drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 6; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6);
|
||||
await page.mouse.up();
|
||||
};
|
||||
const wheel = async (how) => {
|
||||
const g = await page.evaluate(() => { const el = document.querySelector('.fwheel__dia'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.height / 2 }; });
|
||||
if (!g) return;
|
||||
if (how === 'esc') { await page.keyboard.press('Escape'); return; }
|
||||
if (how === 'x') { await page.click('.fwheel__cancel').catch(() => {}); return; }
|
||||
await page.mouse.move(g.x, g.y); await page.mouse.down();
|
||||
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) await page.mouse.move(g.x + dx * g.h * 0.6, g.y + dy * g.h * 0.6, { steps: 3 });
|
||||
if (how === 'up') await page.mouse.move(g.x, g.y - g.h * 0.6, { steps: 2 });
|
||||
await page.mouse.up();
|
||||
};
|
||||
for (let burst = 0; burst < 2; burst++) {
|
||||
for (let k = 0; k < 16; k++) {
|
||||
const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item');
|
||||
const p = units[Math.floor(rnd() * units.length)];
|
||||
const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)];
|
||||
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), p.uid);
|
||||
const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col);
|
||||
await drag(from, to);
|
||||
await sleep(40 + rnd() * 80);
|
||||
await wheel(['up', 'up', 'left', 'esc', 'x'][Math.floor(rnd() * 5)]);
|
||||
await sleep(rnd() < 0.5 ? 20 : 300); // re-drag before the mock server answered, or after
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(1800);
|
||||
await assertAllDrawn(page, `burst ${burst + 1}`);
|
||||
}
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('7: picking an operator standing directly behind another — prep and battle; equipment on the one behind', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
// col 4: row 9 (ground) · rows 10–12 (high ground) — a ranged operator behind a melee one, and two on the same height
|
||||
const [a9, b10, c11] = [await uidAt(page, 9, 4), await uidAt(page, 10, 4), await uidAt(page, 11, 4)];
|
||||
assert.ok(a9 && b10 && c11, 'mock premise: three operators in one column');
|
||||
const clicks = async (keyOf, idOf, cases) => {
|
||||
await page.evaluate(() => { window.__picked = []; window.__offPick?.(); window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.uid)); });
|
||||
for (const [label, key, up, dx, want] of cases) {
|
||||
const pt = await page.evaluate((k, u, d) => window.__t.at(k, u, d), keyOf(key), up, dx);
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await sleep(120);
|
||||
const got = await page.evaluate(() => window.__picked.splice(0));
|
||||
assert.deepEqual(got.slice(-1), [idOf(want)], `${label}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(60);
|
||||
}
|
||||
};
|
||||
const cases = [
|
||||
// [label, whose feet, tiles up, tiles aside, expected]
|
||||
['raised B: face', b10, 0.86, 0, b10], ['raised B: torso', b10, 0.45, 0, b10], ['raised B: legs', b10, 0.22, 0, b10],
|
||||
// B's feet where they show (straight under B's centre, A's hair crown is drawn over them: that pixel is A's)
|
||||
['raised B: feet', b10, 0.05, -0.12, b10],
|
||||
['raised B: its tile beside A\'s head', b10, 0, 0.42, b10],
|
||||
['A (in front): face', a9, 0.86, 0, a9], ['A: torso', a9, 0.45, 0, a9], ['A: feet', a9, 0.05, 0, a9],
|
||||
// (B's head and staff are drawn over the middle of C's lower torso and legs: those pixels are B's)
|
||||
['same height C behind B: face', c11, 0.86, 0, c11], ['C: torso', c11, 0.62, 0, c11], ['C: legs beside B\'s head', c11, 0.4, -0.2, c11],
|
||||
['C: its tile beside B\'s head', c11, 0, -0.42, c11], ['B in front of C: face', b10, 0.86, 0, b10],
|
||||
];
|
||||
await clicks((u) => 'p:' + u, (u) => u, cases);
|
||||
// equipment: an item dropped on the torso of the one behind (C) equips C; one on A's face equips A
|
||||
const items = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.filter((x) => x && x.kind === 'item').map((x) => x.uid));
|
||||
for (const [target, up] of [[c11, 0.62], [a9, 0.86]]) {
|
||||
const it = items.shift();
|
||||
const from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, it);
|
||||
const to = await page.evaluate((u, k) => window.__t.at('p:' + u, k), target, up);
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
|
||||
await sleep(120);
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.dragHit?.over ?? null), target, 'the plate is over the target (pieceDragOver)');
|
||||
await page.mouse.up();
|
||||
await sleep(600);
|
||||
const eq = await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.find((p) => p.uid === u).items.map((x) => x.uid), target);
|
||||
assert.ok(eq.includes(it), `item ${it} equipped on ${target} (got ${eq})`);
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
|
||||
// battle: the same presses on the battle views (ids → piece uids)
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(2500);
|
||||
// …at a moment of the battle when the units stand at rest: a press takes whatever is DRAWN under it (pixel probe),
|
||||
// and the mock battle keeps every operator attacking (lunges, attack clips) and in random skill poses, which move
|
||||
// the drawn face / torso / legs off the aimed points (then the unit in front or behind rightly takes the press —
|
||||
// the pixel-truth test below covers moving poses). So the mock battle is stopped with one last snapshot that clears
|
||||
// the skill flags, and the presses wait until every ally is back in its idle pose.
|
||||
await page.evaluate(async () => {
|
||||
const { net } = await import('/js/net.js');
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); });
|
||||
clearInterval(S.battle.timer);
|
||||
net._emit('b.snap', { ...last, gt: last.gt + 0.2, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) });
|
||||
});
|
||||
await page.waitForFunction(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()]
|
||||
.filter((v) => v.info?.side === 'ally' && v.alive && v.actor)
|
||||
.every((v) => v.actor.mode === 'base' && !v.actor.skillOn && !(v.lunge > 0)), { timeout: 15000, polling: 100 });
|
||||
await sleep(300);
|
||||
const ids = await page.evaluate(() => { const out = {}; for (const [id, v] of globalThis.__SP_VIEW__.raw.debug.views) if (v.info?.uid != null) out[v.info.uid] = id; return out; });
|
||||
await clicks((u) => ids[u], (u) => u, cases.filter(([label]) => !label.includes('tile')));
|
||||
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
// review of item 7: the pick against what is really drawn. Every unit is rendered alone (its pixels = where its model
|
||||
// is opaque); the truth at a pixel is the front-most unit drawn there. Sampled over the whole prep board and a
|
||||
// battle: the shape rule alone got 7 % (battle 18 %) of these presses wrong, mostly giving the front unit's hair /
|
||||
// arms to the unit behind; the pixel probe settles every overlap.
|
||||
test('7: picks agree with the rendered pixels (prep and battle)', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const measure = (phase) => page.evaluate((ph) => {
|
||||
const R = globalThis.__SP_VIEW__.raw, app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution;
|
||||
const keyOf = new Map([...R.debug.views].map(([k, v]) => [v, k]));
|
||||
const views = [...R.debug.views].filter(([k, v]) => v.pickShape && v.info && v.info.kind !== 'item' && v.info.kind !== 'device'
|
||||
&& v.alive && v.spineReady && !v.culled && (ph === 'PREP' ? k.startsWith('p:') : true));
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const [, v] of views) { const s = v.screen.s; x0 = Math.min(x0, v.screen.x - s); x1 = Math.max(x1, v.screen.x + s); y0 = Math.min(y0, v.screen.y - 2 * s); y1 = Math.max(y1, v.screen.y + 0.3 * s); }
|
||||
x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0));
|
||||
x1 = Math.min(rd.view.width / res - 1, Math.ceil(x1)); y1 = Math.min(rd.view.height / res - 1, Math.ceil(y1));
|
||||
const w = Math.floor((x1 - x0) * res), h = Math.floor((y1 - y0) * res), H = rd.view.height;
|
||||
const all = [...R.debug.views.values()];
|
||||
const saved = all.map((v) => [v, v.root?.visible, v.hud?.visible, v.shadow?.visible]);
|
||||
for (const v of all) { if (v.hud) v.hud.visible = false; if (v.shadow) v.shadow.visible = false; if (v.root) v.root.visible = false; }
|
||||
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(Math.floor(x0 * res), H - Math.floor(y0 * res) - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
|
||||
const bg = read();
|
||||
const masks = [];
|
||||
for (const [k, v] of views) {
|
||||
v.root.visible = true; const a = read(); v.root.visible = false;
|
||||
const m = new Uint8Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) { const j = i * 4; if (a[j + 3] > 40 && Math.abs(a[j] - bg[j]) + Math.abs(a[j + 1] - bg[j + 1]) + Math.abs(a[j + 2] - bg[j + 2]) + Math.abs(a[j + 3] - bg[j + 3]) > 40) m[i] = 1; }
|
||||
masks.push({ k, m, z: v.root.zIndex });
|
||||
}
|
||||
for (const [v, a, b, c] of saved) { if (v.root) v.root.visible = a; if (v.hud) v.hud.visible = b; if (v.shadow) v.shadow.visible = c; }
|
||||
const pick = (x, y) => { if (ph === 'PREP') { const p = R.debug.pick.pieceAt(x, y); return p ? 'p:' + p.uid : null; } const v = R.debug.pick.battleUnitAt(x, y); return v ? keyOf.get(v) : null; };
|
||||
let n = 0, ok = 0, wrong = 0;
|
||||
for (let py = 0; py < h; py += 4) for (let px = 0; px < w; px += 4) {
|
||||
const i = (h - 1 - py) * w + px;
|
||||
let truth = null, tz = -Infinity;
|
||||
for (const mk of masks) if (mk.m[i] && mk.z > tz) { tz = mk.z; truth = mk.k; }
|
||||
if (!truth) continue;
|
||||
const got = pick(x0 + px / res, y0 + py / res);
|
||||
n++;
|
||||
if (got === truth) ok++; else if (got) wrong++;
|
||||
}
|
||||
return { units: views.length, n, ok: ok / n, wrong: wrong / n };
|
||||
}, phase);
|
||||
const prep = await measure('PREP');
|
||||
assert.ok(prep.units >= 10 && prep.n > 3000, `premise: a full prep board (${JSON.stringify(prep)})`);
|
||||
assert.ok(prep.ok > 0.95 && prep.wrong < 0.015, `prep: presses on a drawn unit pick it (${JSON.stringify(prep)})`);
|
||||
await setPhase(page, 'COMBAT');
|
||||
await sleep(3000);
|
||||
const battle = await measure('COMBAT');
|
||||
assert.ok(battle.units >= 10 && battle.n > 3000, `premise: a battle (${JSON.stringify(battle)})`);
|
||||
assert.ok(battle.ok > 0.93 && battle.wrong < 0.03, `battle: presses on a drawn unit pick it (${JSON.stringify(battle)})`);
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.find((x) => x && x.kind === 'chess').uid);
|
||||
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), hand);
|
||||
const to = await page.evaluate(() => globalThis.__SP_VIEW__.raw.tileScreen(9, 4)); // an occupied melee tile: a swap
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
|
||||
await sleep(150);
|
||||
const ghost = await page.evaluate((u) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + u); return { x: v.x, y: v.y, lift: v.lift }; }, hand);
|
||||
assert.ok(Math.abs(ghost.x - 4) < 0.06 && Math.abs(ghost.y - 9) < 0.06 && ghost.lift > 0, `the ghost stands on the pointer's tile (${ghost.x}, ${ghost.y})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
|
||||
await page.mouse.up();
|
||||
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
|
||||
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
// review of item 7: a touch drag holds its ghost (and drop point) 0.6 tile above the finger. On a phone the bench is
|
||||
// the lowest canvas row with the shop bar right under it, so putting a unit back on the bench has the finger on the
|
||||
// shop bar: the drop is judged where the ghost is, not under the finger (it used to send the piece back).
|
||||
test('7: touch — a board unit dragged back onto a bench slot above the shop bar lands there (phone)', async () => {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
|
||||
await sleep(2000);
|
||||
const g = await page.evaluate(() => {
|
||||
const V = globalThis.__SP_VIEW__.raw, cv = V.debug.app.view.getBoundingClientRect();
|
||||
const u = globalThis.__MOCK__.S().priv.board[0], v = V.debug.views.get('p:' + u.uid), t = V.tileScreen(7, 5);
|
||||
const finger = { x: t.x, y: t.y + 0.6 * t.s };
|
||||
const el = document.elementFromPoint(finger.x, finger.y);
|
||||
return { uid: u.uid, from: { x: cv.left + v.screen.x, y: cv.top + v.screen.y - 0.5 * v.screen.s }, finger, onDom: !!el && el !== V.debug.app.view };
|
||||
});
|
||||
assert.ok(g.onDom, 'premise: the finger is on the DOM (shop bar) below the bench slot');
|
||||
await page.touchscreen.touchStart(g.from.x, g.from.y);
|
||||
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(g.from.x + ((g.finger.x - g.from.x) * i) / 12, g.from.y + ((g.finger.y - g.from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(100);
|
||||
await page.touchscreen.touchEnd();
|
||||
await sleep(800);
|
||||
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), g.uid, 'the unit is on bench slot 5');
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path
|
||||
// (user playtest #3 item 7: "clicking an operator often selects the one a row below it; equipment too"), checked on the
|
||||
// geometry of the official cameras (render/projection.js presetCamera: prep / battle at 1920×1080 and 1280×720): a unit
|
||||
// B standing directly behind a unit A (same column, adjacent rows; B on the same height or on a raised tile).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { pickBody, hitBody, coreDistance, bodyBounds, bodyParts, BODY_H, probeCandidates, PROBE_MARGIN } from '../../public/js/render/pick.js';
|
||||
import { unitDepthKey } from '../../public/js/render/units.js';
|
||||
|
||||
const COL = 4;
|
||||
/** A / B bodies on a camera: A at row 9 (height zA), B at row 10 (height zB), model heights hA / hB. */
|
||||
function pair(kind, W, H, { zA = 0, zB = 0, hA = BODY_H, hB = BODY_H } = {}) {
|
||||
const cam = presetCamera(kind, { width: W, height: H });
|
||||
const body = (key, row, z, h) => { const p = cam.project(COL, row, z); return { key, x: p.x, y: p.y, s: p.s, h, flip: 1, depth: unitDepthKey(cam, COL, row), tile: { row, col: COL } }; };
|
||||
const A = body('A', 9, zA, hA), B = body('B', 10, zB, hB);
|
||||
const heights = new Map([[`9,${COL}`, zA], [`10,${COL}`, zB]]);
|
||||
// the tile under a screen point, raised tops first (render/projection.js pickTile semantics)
|
||||
const ground = (x, y) => {
|
||||
for (const hz of [...new Set([zA, zB, 0])].sort((a, b) => b - a)) {
|
||||
const g = cam.unproject(x, y, hz);
|
||||
if (!g) continue;
|
||||
const t = { row: Math.round(g.y), col: Math.round(g.x) };
|
||||
if (hz === 0 || Math.abs((heights.get(`${t.row},${t.col}`) || 0) - hz) < 1e-6) return t;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/** Who a press `up` tiles above the feet of `u` (and `dx` tiles to the side) picks. */
|
||||
const at = (u, up, dx = 0, bodies = [A, B]) => { const x = u.x + dx * u.s, y = u.y - up * u.s; return pickBody(bodies, x, y, ground(x, y))?.key ?? null; };
|
||||
return { cam, A, B, at, rowGap: (A.y - B.y) / A.s };
|
||||
}
|
||||
|
||||
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
|
||||
|
||||
describe('pickBody on the official cameras: B directly behind A', () => {
|
||||
for (const [kind, W, H] of CAMS) {
|
||||
test(`${kind} ${W}×${H}, same height: B's head / face / torso / legs / tile beside A are B's; A's face, torso and feet are A's`, () => {
|
||||
const { at, A, B, rowGap } = pair(kind, W, H);
|
||||
assert.ok(rowGap > 0.8 && rowGap < 0.95, `rows ${rowGap.toFixed(2)} of a tile apart on screen (test premise)`);
|
||||
assert.ok(BODY_H > rowGap, 'a chibi is taller than a row: A covers B\'s feet (test premise)');
|
||||
for (const up of [1.2, 0.86, 0.6, 0.45, 0.3]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
|
||||
for (const dx of [-0.4, 0.4]) assert.equal(at(B, 0, dx), 'B', `B's tile beside A's head (${dx})`);
|
||||
for (const up of [0.86, 0.6, 0.3, 0.05, -0.1]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
|
||||
// the feet of B are drawn behind A's face: that press is A's (nothing of B shows there)
|
||||
assert.equal(at(B, 0), 'A');
|
||||
// A's hair crown over B's legs / tile goes to B; with nobody behind it is A's
|
||||
assert.equal(at(A, 1.2), 'B');
|
||||
assert.equal(at(A, 1.2, 0, [A]), 'A');
|
||||
assert.equal(at(A, 0.86, 0, [A]), 'A');
|
||||
});
|
||||
|
||||
test(`${kind} ${W}×${H}, B on a raised tile (ranged high ground behind a melee lane): B's feet are B's too`, () => {
|
||||
const { at, B, A } = pair(kind, W, H, { zB: 0.42 });
|
||||
for (const up of [1.2, 0.86, 0.45, 0.2, 0]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
|
||||
for (const up of [0.86, 0.45, 0.05]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
|
||||
});
|
||||
}
|
||||
|
||||
test('the old rule would have picked A for most of B\'s lower half (the playtest report)', () => {
|
||||
// the pre-fix hit box: 0.7 tile wide, 1.18 tiles above the feet + 0.1 below, front-most wins
|
||||
const { A, B } = pair('prep', 1920, 1080);
|
||||
const box = (u, x, y) => Math.abs(x - u.x) <= 0.35 * u.s && y >= u.y - 1.18 * u.s && y <= u.y + 0.1 * u.s;
|
||||
const old = (x, y) => (box(A, x, y) ? 'A' : box(B, x, y) ? 'B' : null);
|
||||
let wrong = 0;
|
||||
for (const up of [0.3, 0.2, 0.1, 0]) for (const dx of [-0.3, 0, 0.3]) if (old(B.x + dx * B.s, B.y - up * B.s) === 'A') wrong++;
|
||||
assert.ok(wrong >= 6, `old rule: ${wrong}/12 presses on B's lower body / feet went to A`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBody rule details', () => {
|
||||
const cam = presetCamera('prep', { width: 1920, height: 1080 });
|
||||
const at = (row, col, extra = {}) => { const p = cam.project(col, row, 0); return { x: p.x, y: p.y, s: p.s, h: BODY_H, depth: unitDepthKey(cam, col, row), tile: { row, col }, ...extra }; };
|
||||
|
||||
test('off every body, the unit standing on the pressed tile is picked; an empty tile picks nothing', () => {
|
||||
const U = { key: 'U', ...at(10, 6) };
|
||||
const tile = cam.project(6, 10, 0);
|
||||
// the tile's corner, well away from the body
|
||||
const cx = tile.x + 0.45 * tile.s, cy = tile.y + 0.3 * tile.s;
|
||||
assert.equal(hitBody(U, cx, cy), 0, 'premise: not on the body');
|
||||
assert.equal(pickBody([U], cx, cy, { row: 10, col: 6 }), U);
|
||||
assert.equal(pickBody([U], cx, cy, { row: 10, col: 7 }), null);
|
||||
assert.equal(pickBody([U], cx, cy, null), null);
|
||||
});
|
||||
|
||||
test('side by side (same row): each unit keeps its own body, weapon edges go by the tile', () => {
|
||||
const L = { key: 'L', ...at(10, 5) }, R = { key: 'R', ...at(10, 6) };
|
||||
for (const u of [L, R]) for (const up of [0.9, 0.5, 0.15]) assert.equal(pickBody([L, R], u.x, u.y - up * u.s, u.tile), u);
|
||||
const mid = (L.x + R.x) / 2;
|
||||
assert.equal(pickBody([L, R], mid - 2, L.y - 0.3 * L.s, { row: 10, col: 5 }), L);
|
||||
assert.equal(pickBody([L, R], mid + 2, L.y - 0.3 * L.s, { row: 10, col: 6 }), R);
|
||||
});
|
||||
|
||||
test('a moving unit (no tile, e.g. an enemy walking in front) is picked by its body only, front-most on cores', () => {
|
||||
const op = { key: 'op', ...at(10, 5) };
|
||||
const foe = { key: 'foe', ...at(9, 5), tile: null, w: 1.1, h: 1.3 };
|
||||
assert.equal(pickBody([op, foe], foe.x, foe.y - 0.5 * foe.s, { row: 9, col: 5 }), foe, 'its torso');
|
||||
assert.equal(pickBody([op, foe], op.x, op.y - 0.6 * op.s, { row: 10, col: 5 }), op, 'the operator above it');
|
||||
// off its body on the tile it walks over: nothing stands there
|
||||
assert.equal(pickBody([foe], foe.x + 0.48 * foe.s, foe.y + 0.35 * foe.s, { row: 9, col: 5 }), null);
|
||||
});
|
||||
|
||||
test('the HUD (tier chip / bars) is an outline: its own unit when nothing else is there, never over a torso', () => {
|
||||
const B = { key: 'B', ...at(10, 5) }, C = { key: 'C', ...at(11, 5) };
|
||||
const chipY = B.y - 1.3 * B.s;
|
||||
B.hud = { x0: B.x - 12, y0: chipY - 12, x1: B.x + 12, y1: chipY + 12 };
|
||||
assert.equal(pickBody([B], B.x, chipY, null), B, 'alone: the chip is its unit\'s');
|
||||
assert.equal(hitBody(C, B.x, chipY), 2, 'premise: C\'s legs are behind B\'s chip');
|
||||
assert.equal(pickBody([B, C], B.x, chipY, { row: 11, col: 5 }), C);
|
||||
});
|
||||
|
||||
test('model height, width factor and facing shape the outline', () => {
|
||||
const tall = { ...at(10, 5), h: 1.45 }, short = { ...at(10, 5), h: 1.1 };
|
||||
const topOf = (b) => bodyBounds(b).y;
|
||||
assert.ok(topOf(tall) < topOf(short), 'taller model → higher outline');
|
||||
assert.ok(Math.abs(topOf(tall) - (tall.y - 1.45 * tall.s)) < 1, 'the outline tops out at the model height');
|
||||
const big = { ...at(10, 5), h: 2.4, w: 2 };
|
||||
assert.ok(bodyBounds(big).width > 1.9 * bodyBounds(short).width, 'enemies scale with their size');
|
||||
const right = bodyParts({ ...at(10, 5), flip: 1 }).outline[1].rect, left = bodyParts({ ...at(10, 5), flip: -1 }).outline[1].rect;
|
||||
const x = at(10, 5).x;
|
||||
assert.ok(x - right.x0 > right.x1 - x && left.x1 - x > x - left.x0, 'the back side (hair, capes) is the wider one');
|
||||
});
|
||||
|
||||
test('avatar diamonds and item plates', () => {
|
||||
const d = { kind: 'diamond', ...at(10, 5), d: 0.78 };
|
||||
const centre = d.y - (0.08 + 0.39) * d.s;
|
||||
assert.equal(hitBody(d, d.x, centre), 2);
|
||||
assert.equal(hitBody(d, d.x + 0.33 * d.s, centre), 1, 'near its tip');
|
||||
assert.equal(hitBody(d, d.x + 0.45 * d.s, centre), 0);
|
||||
const plate = { kind: 'plate', x: 500, y: 300, s: 100, r: 0.31 };
|
||||
assert.equal(hitBody(plate, 500, 300), 2);
|
||||
assert.equal(hitBody(plate, 528, 300), 1);
|
||||
assert.equal(hitBody(plate, 540, 300), 0);
|
||||
assert.deepEqual(bodyBounds(plate), { x: 469, y: 269, width: 62, height: 62 });
|
||||
assert.equal(coreDistance(plate, 500, 300), 0);
|
||||
assert.ok(Math.abs(coreDistance(plate, 541.7, 300) - 0.2) < 1e-6, 'px to the core / px per tile');
|
||||
});
|
||||
|
||||
test('junk input picks nothing', () => {
|
||||
const U = { key: 'U', ...at(10, 5) };
|
||||
assert.equal(pickBody([U], NaN, 3), null);
|
||||
assert.equal(pickBody(null, 3, 3), null);
|
||||
assert.equal(pickBody([null, { x: 1, y: 1, s: 0 }, { x: NaN, y: 1, s: 5 }], 1, 1, { row: 1, col: 1 }), null);
|
||||
assert.equal(hitBody(null, 0, 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Review of item 7: measured against the rendered pixels of the mock match (every drawn unit pixel of the prep board at
|
||||
// 1920×1080), the shape rule alone put ~8 % of the presses on the wrong unit — mostly the unit BEHIND: the hair crown
|
||||
// and the sides (arms, clothes, weapons) of the unit in front lie over the legs / tile of the one behind. The pixel
|
||||
// probe (render/app.js drawnAt) settles every overlap by what is really drawn on top.
|
||||
describe('pickBody with the pixel probe', () => {
|
||||
const { A, B, cam } = pair('prep', 1920, 1080);
|
||||
const ground = (x, y) => { const g = cam.unproject(x, y, 0); return g ? { row: Math.round(g.y), col: Math.round(g.x) } : null; };
|
||||
/** A probe that says which bodies draw the point (and records who was asked, in order). */
|
||||
const probeOf = (drawing, calls = []) => (b) => { calls.push(b.key); return drawing.includes(b.key); };
|
||||
|
||||
test('A\'s hair crown drawn over B\'s torso: A (the shape rule alone says B)', () => {
|
||||
const x = A.x, y = A.y - 1.2 * A.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B', 'premise: the rule gives it to B');
|
||||
const calls = [];
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A', 'B'], calls))?.key, 'A');
|
||||
assert.deepEqual(calls, ['A'], 'front-most first; stops at the first that draws');
|
||||
});
|
||||
|
||||
test('B\'s legs showing beside A\'s head: B', () => {
|
||||
const x = B.x + 0.3 * B.s, y = B.y - 0.3 * B.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['B']))?.key, 'B');
|
||||
});
|
||||
|
||||
test('A\'s arm over the tile of B, outside B\'s outline: A (a single candidate the rule did not pick)', () => {
|
||||
// a point on B's tile in front of B's feet, beside A's body: the rule picks B (standing on the pressed tile)
|
||||
const t = cam.project(COL, 10, 0);
|
||||
const x = t.x + 0.45 * t.s, y = t.y + 0.3 * t.s;
|
||||
assert.equal(ground(x, y)?.row, 10, 'premise: B\'s tile');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B');
|
||||
const cand = probeCandidates([A, B], x, y);
|
||||
if (cand.length === 1) assert.equal(cand[0].key, 'A');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A']))?.key, 'A');
|
||||
// nothing drawn there: the rule (B's tile)
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf([]))?.key, 'B');
|
||||
});
|
||||
|
||||
test('the probe is only asked where it can change the answer; null / throwing → the shape rule', () => {
|
||||
const lone = { ...A, key: 'L' };
|
||||
const calls = [];
|
||||
assert.equal(pickBody([lone], lone.x, lone.y - 0.5 * lone.s, null, probeOf([], calls))?.key, 'L');
|
||||
assert.deepEqual(calls, [], 'one body that the rule picks: no probe');
|
||||
assert.equal(pickBody([lone], lone.x + 5 * lone.s, lone.y, null, probeOf([], calls)), null);
|
||||
assert.deepEqual(calls, [], 'no body around: no probe');
|
||||
const x = A.x, y = A.y - 1.2 * A.s;
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), () => null)?.key, 'B', 'cannot tell → the rule');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), () => { throw new Error('lost context'); })?.key, 'B');
|
||||
// the one in front cannot tell: the rule decides (never a unit behind an unknown one)
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true))?.key, 'B');
|
||||
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true)), pickBody([A, B], x, y, ground(x, y)));
|
||||
});
|
||||
|
||||
test('probe candidates: bodies whose outline + PROBE_MARGIN holds the point, front-most first', () => {
|
||||
const r = bodyBounds({ ...A, hud: null });
|
||||
assert.deepEqual(probeCandidates([B, A], A.x, A.y - 0.9 * A.s).map((b) => b.key), ['A', 'B']);
|
||||
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN - 0.05) * A.s, A.y - 0.5 * A.s).map((b) => b.key), ['A']);
|
||||
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN + 0.05) * A.s, A.y - 0.5 * A.s), []);
|
||||
assert.deepEqual(probeCandidates([null, { x: NaN, y: 0, s: 1 }], 0, 0), []);
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,86 @@ describe('field view teardown (app.js releaseGl)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// user playtest #3 item 7: the body as drawn, for render/pick.js (the shared picking rule)
|
||||
describe('pickShape / bounds / hitTest', () => {
|
||||
test('the avatar diamond until the Spine model shows, then the chibi at its own measured height', async () => {
|
||||
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true }, store({ spine: true, imageDelay: 50 }));
|
||||
v.update(1 / 60, cam(), 0);
|
||||
assert.equal(v.pickShape().kind, 'diamond', 'no model yet');
|
||||
await tick(); await tick();
|
||||
assert.ok(v.spineReady);
|
||||
// the posed skeleton's bounds: 425.6 units above the feet = 1.33 tiles (UNIT.modelScale 1/320)
|
||||
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -425.6, width: 120, height: 430 });
|
||||
v.update(1 / 60, cam(), 1 / 60);
|
||||
const b = v.pickShape();
|
||||
assert.equal(b.kind, 'chibi');
|
||||
assert.ok(Math.abs(b.h - 1.33) < 1e-6, `measured height ${b.h}`);
|
||||
assert.equal(b.w, 1, 'operators: the measured width profile');
|
||||
assert.equal(b.x, v.screen.x); assert.equal(b.y, v.screen.y); assert.equal(b.s, v.screen.s);
|
||||
assert.equal(b.depth, v.root.zIndex);
|
||||
// measured once: a later pose (a raised weapon) does not move the outline
|
||||
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -700, width: 120, height: 700 });
|
||||
v.update(1 / 60, cam(), 2 / 60);
|
||||
assert.ok(Math.abs(v.pickShape().h - 1.33) < 1e-6);
|
||||
// the outline tops out at the model; the tier chip (prep) is part of the pick shape, not of bounds()
|
||||
const r = v.bounds();
|
||||
assert.ok(Math.abs(r.y - (v.screen.y - 1.33 * v.screen.s)) < 1, 'bounds top = model top');
|
||||
assert.ok(Math.abs(r.y + r.height - (v.screen.y + 0.08 * v.screen.s)) < 1, 'bounds bottom = just below the feet');
|
||||
assert.ok(b.hud && b.hud.y1 < v.screen.y - v.screen.s, 'the chip is up at the head (UNIT.headroom)');
|
||||
assert.ok(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), 'the torso');
|
||||
assert.ok(!v.hitTest(v.screen.x + 0.6 * v.screen.s, v.screen.y - 0.5 * v.screen.s), 'beside it');
|
||||
// a new model (Front ⇄ Back swap, _dropActor) is measured again
|
||||
v._dropActor();
|
||||
assert.equal(v._bodyH, null);
|
||||
});
|
||||
|
||||
test('implausible skeleton bounds are clamped; enemies scale their width with their size', async () => {
|
||||
const op = view({ defId: 'char_p2', avatar: 'char_p2' }, { prep: true }, store({ spine: true }));
|
||||
await tick(); await tick();
|
||||
op.actor.spine.getLocalBounds = () => ({ x: -500, y: -1200, width: 1000, height: 1200 }); // an effect in the idle pose
|
||||
op.update(1 / 60, cam(), 0);
|
||||
assert.equal(op.pickShape().h, 1.55, 'operators ≤ 1.55 tiles');
|
||||
const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true }));
|
||||
await tick(); await tick();
|
||||
foe.actor.spine.getLocalBounds = () => ({ x: -300, y: -640, width: 600, height: 640 });
|
||||
foe.update(1 / 60, cam(), 0);
|
||||
const b = foe.pickShape();
|
||||
assert.ok(Math.abs(b.h - 2) < 1e-6);
|
||||
assert.ok(Math.abs(b.w - 2 / 1.2) < 1e-6, 'width factor from the height');
|
||||
assert.equal(b.hud, null, 'an undamaged enemy shows no bar');
|
||||
});
|
||||
|
||||
test('a battle unit\'s bars are its HUD rect; a dead unit is not hit', async () => {
|
||||
const v = view({ defId: 'char_p3', avatar: 'char_p3' }, {}, store({ spine: true }));
|
||||
await tick(); await tick();
|
||||
v.sync({ x: 5, y: 12, hp: 500, maxHp: 1000, sp: 3, spMax: 10, flags: 0, anim: 0, vx: 0 });
|
||||
v.update(1 / 60, cam(), 0);
|
||||
const hud = v.pickShape().hud;
|
||||
assert.ok(hud && hud.x1 > hud.x0 && hud.y1 > hud.y0, 'HP / SP bars and chip');
|
||||
assert.ok(hud.y1 < v.screen.y - v.screen.s, 'above the head');
|
||||
v.die();
|
||||
assert.equal(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), false);
|
||||
});
|
||||
|
||||
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
|
||||
const { ItemView } = await import('../../public/js/render/units.js');
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(), cam });
|
||||
const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 });
|
||||
it.setWorld(3, 7, 0.16);
|
||||
it.update(1 / 60, cam(), 0);
|
||||
let b = it.pickShape();
|
||||
assert.equal(b.kind, 'plate');
|
||||
assert.ok(Math.abs(b.y - (it.screen.y - 0.31 * it.screen.s)) < 1e-6, 'resting: the plate above its anchor');
|
||||
it.lift = 0.3;
|
||||
it.update(1 / 60, cam(), 0);
|
||||
b = it.pickShape();
|
||||
const g = cam().project(3, 7, 0.16);
|
||||
assert.ok(Math.abs(b.x - g.x) < 1e-6 && Math.abs(b.y - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)');
|
||||
assert.equal(it.plate.anchor.y, 0.5);
|
||||
assert.ok(it.hitTest(g.x, g.y));
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemy preview pen figures (lod idle)', () => {
|
||||
/** A UnitView of a pen figure with a Spine model, an impostor atlas (full or not) and a counting renderer. */
|
||||
async function penFigure(full) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { SUB, resolveProfile } from '../../server/sim/professions.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
@@ -221,6 +222,72 @@ test('stalker dodges and hits everything in range; hunter consumes and reloads a
|
||||
assert.ok(hu.trait.ammo >= 2);
|
||||
});
|
||||
|
||||
// 回环射手 (loopshooter, 跃跃): "持有回旋投射物时才能够攻击(投射物需要时间回收)" — out at 15 tiles/s, back at 3.75
|
||||
// (PRTS 跃跃 特性 note); a 0.2 s attack interval so the boomerang, not the cooldown, sets the pace
|
||||
const LOOP = () => mk('loopshooter', 'SNIPER', { id: 't_loop', attackKind: 'ranged', projectile: 'arrow', canHitFly: true, stats: { atk: 100, bat: 0.2, respawnTime: 5 } });
|
||||
|
||||
test('loopshooter: the boomerang flies out (15 tiles/s), hits on arrival, flies back (3.75 tiles/s); the next attack waits for the catch', () => {
|
||||
const p = resolveProfile({ profession: 'SNIPER', subProf: 'loopshooter', attackKind: 'ranged', dmgType: 'phys', projectile: 'arrow', traitBb: {} });
|
||||
assert.equal(p.projectile, 'boomerang', "the data's generic 'arrow' never replaces the boomerang");
|
||||
assert.equal(PROJECTILE_SPEEDS.boomerang, 15);
|
||||
assert.equal(BOOMERANG_RETURN_SPEED, 3.75);
|
||||
for (const col of [7, 5]) { // 3 tiles / 1 tile away
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_loop: LOOP() }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_loop', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, col] }],
|
||||
content: 'none', captureNoisy: true, hooks: ['attack', 'damaged'], autoFinish: false,
|
||||
});
|
||||
h.run(8);
|
||||
const u = h.unit('t_loop');
|
||||
const d = col - 4;
|
||||
const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
|
||||
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
assert.ok(at.length >= 5, `${d} tiles: ${at.length} attacks`);
|
||||
for (let i = 1; i < at.length; i++) {
|
||||
const gap = at[i] - at[i - 1];
|
||||
assert.ok(gap >= flight - 1e-9 && gap <= flight + 3 * h.TICK, `${d} tiles: gap ${gap.toFixed(3)} ≈ out + back ${flight.toFixed(3)}`);
|
||||
}
|
||||
// one hit per throw (the way back deals nothing), landing out-flight time after the throw
|
||||
const landed = at.filter((t) => t + d / PROJECTILE_SPEEDS.boomerang + 2 * h.TICK < h.b.time).length;
|
||||
assert.ok(hits.length >= landed && hits.length <= at.length, `one hit per boomerang (${hits.length} hits, ${at.length} throws)`);
|
||||
const lag = hits[0].t - at[0];
|
||||
assert.ok(lag >= d / PROJECTILE_SPEEDS.boomerang - 2 * h.TICK && lag <= d / PROJECTILE_SPEEDS.boomerang + h.TICK, `hit ${lag.toFixed(3)} s after the throw`);
|
||||
const atk = h.eventsOf('atk').filter((e) => e[1] === u.id);
|
||||
assert.ok(atk.length && atk.every((e) => e[3] === 'boomerang'), "b.ev ['atk', thrower, target, 'boomerang']");
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('loopshooter: a target killed mid-flight is not hit, the boomerang still comes back; a knocked-out thrower gets a fresh one on redeploy', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_loop: LOOP() }, enemies: { enemy_dummy: dummy(), enemy_dummy2: dummy({ key: 'enemy_dummy2', hp: 1e7 }) } },
|
||||
units: [{ chessId: 't_loop', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy2', pos: [10, 7] }],
|
||||
content: 'none', captureNoisy: true, hooks: ['attack', 'damaged'], autoFinish: false,
|
||||
});
|
||||
const u = h.unit('t_loop');
|
||||
assert.ok(h.runUntil(() => u.stats.attacks === 1, 2));
|
||||
const first = h.hooksOf('attack')[0].targets[0];
|
||||
assert.equal(u.trait.boomerangsOut, 1);
|
||||
h.b.kill(first);
|
||||
h.run(0.5);
|
||||
assert.equal(h.hooksOf('damaged').filter((c) => c.target === first && c.source === u).length, 0, 'the dead target took nothing');
|
||||
assert.equal(u.stats.attacks, 1, 'still waiting for the boomerang');
|
||||
assert.ok(h.runUntil(() => u.stats.attacks === 2, 1.5), 'caught at the thrower → the next throw');
|
||||
const second = h.hooksOf('attack')[1].targets[0];
|
||||
assert.notEqual(second, first);
|
||||
// knocked out with the boomerang in flight: it is lost; the redeployed thrower holds a fresh one
|
||||
assert.equal(u.trait.boomerangsOut, 1);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, false);
|
||||
h.run(2);
|
||||
assert.equal(u.stats.attacks, 2);
|
||||
assert.ok(h.runUntil(() => u.alive, 10), 'redeployed');
|
||||
assert.equal(u.trait.boomerangsOut, 0, 'a fresh boomerang');
|
||||
assert.ok(h.runUntil(() => u.stats.attacks === 3, 1), 'attacks at once');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('fortress: melee on blocked enemies, ranged splash otherwise; tactician calls a reinforcement', () => {
|
||||
const fort = mk('fortress', 'TANK', { attackKind: 'ranged', projectile: 'arrow', canHitFly: true, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], stats: { atk: 500, blockCnt: 3, maxHp: 1e6 } });
|
||||
const h = makeBattle({
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
|
||||
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, pickPieceAt, equipRetarget,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { pairPlayers } from '../../server/match/finalAssault.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
|
||||
@@ -492,3 +493,66 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
|
||||
assert.deepEqual(prepCamera(pub(14, [pl('a', 0)]), 'a'), { kind: 'bossPrep', opts: { side: 'L' } });
|
||||
});
|
||||
});
|
||||
|
||||
// user playtest #3 item 7: equipment dropped on the operator standing behind another selected the one in front
|
||||
describe('pickPieceAt / equipRetarget (the unit under a dragged item)', () => {
|
||||
// two operators in one column on the official prep camera: uid 1 on row 9 (front), uid 2 on row 10 (behind, same
|
||||
// height); `shape` = the drawn body the render engine reports through view.pieceScreenRect
|
||||
const cam = presetCamera('prep', { width: 1920, height: 1080 });
|
||||
const unit = (uid, row, col) => {
|
||||
const p = cam.project(col, row, 0);
|
||||
const shape = { kind: 'chibi', x: p.x, y: p.y, s: p.s, h: 1.27, flip: 1, depth: -cam.depthOf(col, row, 0) * 100 + col * 0.001 };
|
||||
// the pre-fix rect (0.7 × 1.28 tiles) — plain rects are what the DOM fallback reports
|
||||
const rect = { left: p.x - 0.35 * p.s, right: p.x + 0.35 * p.s, top: p.y - 1.18 * p.s, bottom: p.y + 0.1 * p.s };
|
||||
return { uid, row, col, p, shape, rect };
|
||||
};
|
||||
const A = unit(1, 9, 4), B = unit(2, 10, 4);
|
||||
const engine = [A, B].map((u) => ({ uid: u.uid, ...u.rect, shape: u.shape, tile: { row: u.row, col: u.col } }));
|
||||
const plain = [A, B].map((u) => ({ uid: u.uid, ...u.rect }));
|
||||
const up = (u, k, dx = 0) => [u.p.x + dx * u.p.s, u.p.y - k * u.p.s];
|
||||
|
||||
test('engine shapes: the face / torso of each is its own; the lower half of the unit behind is no longer the front one\'s', () => {
|
||||
for (const k of [1.0, 0.6, 0.35]) assert.equal(pickPieceAt(engine, ...up(B, k), { row: 10, col: 4 }), 2, `B ${k} tile up`);
|
||||
for (const k of [0.86, 0.5, 0.1]) assert.equal(pickPieceAt(engine, ...up(A, k), { row: 9, col: 4 }), 1, `A ${k} tile up`);
|
||||
// B's tile showing beside A's head: B (the old front-most rect gave A)
|
||||
const side = up(B, 0, 0.3);
|
||||
assert.equal(pickPieceAt(engine, ...side, { row: 10, col: 4 }), 2);
|
||||
assert.equal(pickPieceAt(plain, ...side), 1, 'plain rects: the overlap still favours the nearer centre');
|
||||
// off both bodies: the tile under the point decides
|
||||
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 4 }), 2);
|
||||
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 5 }), null);
|
||||
});
|
||||
|
||||
test('plain rects (DOM fallback): the rect whose centre is nearest relative to its size, ties → the front-most', () => {
|
||||
const rects = [
|
||||
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // front (lower on screen)
|
||||
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // behind
|
||||
];
|
||||
assert.equal(pickPieceAt(rects, 130, 450), 1, 'nearer the front one\'s centre');
|
||||
assert.equal(pickPieceAt(rects, 130, 430), 2, 'nearer the one behind (the front-most rule gave 1)');
|
||||
assert.equal(pickPieceAt(rects, 130, 435), 1, 'equidistant: the front-most');
|
||||
assert.equal(pickPieceAt(rects, 130, 360), 2);
|
||||
assert.equal(pickPieceAt(rects, 10, 10), null);
|
||||
assert.equal(pickPieceAt(rects, NaN, 10), null);
|
||||
assert.equal(pickPieceAt(null, 1, 1), null);
|
||||
assert.equal(pickPieceAt([{ uid: 'x', left: 0, right: 9, top: 0, bottom: 9 }, null], 5, 5), null);
|
||||
});
|
||||
|
||||
test('equipRetarget sends the item to the unit the engine picked (hitUid), Arts never', () => {
|
||||
const priv = {
|
||||
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 2,
|
||||
board: [piece(RANGED, { row: 9, col: 3 }), piece(RANGED, { row: 10, col: 3 })],
|
||||
hand: [item(EQUIP), item(MAGIC), ...new Array(GEO.HAND_SIZE - 2).fill(null)],
|
||||
temp: new Array(GEO.TEMP_SIZE).fill(null), shop: { slots: [] },
|
||||
};
|
||||
const ctx = placementContext({ priv, stage: STAGE, editable: true, getChess, getToken, getItem });
|
||||
const [front, behind] = priv.board;
|
||||
const [eq, art] = priv.hand;
|
||||
const onFrontTile = { area: 'board', row: 9, col: 3 };
|
||||
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, behind.uid), { area: 'board', row: 10, col: 3 });
|
||||
assert.deepEqual(dropIntent(ctx, eq.uid, equipRetarget(ctx, eq.uid, onFrontTile, behind.uid)).fields, { itemUid: eq.uid, targetUid: behind.uid });
|
||||
assert.deepEqual(equipRetarget(ctx, art.uid, onFrontTile, behind.uid), onFrontTile, 'an Art is used on its tile');
|
||||
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, null), onFrontTile);
|
||||
assert.deepEqual(equipRetarget(ctx, front.uid, onFrontTile, behind.uid), onFrontTile, 'units are not retargeted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
// Regression tests (Node) of user playtest #3, HUD / UI items:
|
||||
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
|
||||
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
|
||||
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
|
||||
// server's pendingLp (server/match/Match.js _pendingLpView)
|
||||
// 3 the temp overflow row (临时整备区): the ready button's visible reason, the row's frame geometry (tempRowFrame)
|
||||
// 8 the operator's own effect (特质 / garrison) right under the detail card's header (CHESS_SECTIONS, rendered order)
|
||||
// 9 no spinning busy indicators next to texts: no wait / progress cursors, the button's busy bar instead of a
|
||||
// spinning ring, the 机变 card's 选择中 strip (never stuck), a still hourglass; the data store retries a transient
|
||||
// failure and every data file the match UI reads is one the match screen waits for (GAME_FILES); the 特质 chip's
|
||||
// icon is the garrison's official type icon (the spoked 特异化 glyph only on 特异化 特质, not on every 休整期 one)
|
||||
// (browser counterparts: the scratch checks of the playtest-3 workstream; the E2E suites cover the flows)
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
// the browser data store reads the real data files from disk (ChessDetail / ChoiceOverlay renders below)
|
||||
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 { pendingLoss, liveLp, ownLeaks, pendingTip, tempInfo, tempReadyReason, ReadyToggle, TEMP_RULE } = await import('../../public/js/ui/hud.js');
|
||||
const { rowLp } = await import('../../public/js/ui/teamPanel.js');
|
||||
const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js');
|
||||
const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js');
|
||||
const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { ChoiceOverlay, pickBusy } = await import('../../public/js/ui/choiceOverlay.js');
|
||||
const { Button, Spinner } = await import('../../public/js/ui/components.js');
|
||||
const { data, createDataStore, RETRY_DELAYS_MS } = 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);
|
||||
}
|
||||
/** The text of a vnode tree in document order. */
|
||||
const textOf = (v) => {
|
||||
if (v == null || typeof v === 'boolean') return '';
|
||||
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
||||
if (Array.isArray(v)) return v.map(textOf).join('');
|
||||
return typeof v === 'object' ? textOf(v.props?.children) : '';
|
||||
};
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
|
||||
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('2: the own LP drops live while the battle runs', () => {
|
||||
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
|
||||
assert.equal(pendingLoss(0), 0);
|
||||
assert.equal(pendingLoss(3), 3);
|
||||
assert.equal(pendingLoss(10), 10);
|
||||
assert.equal(pendingLoss(14), 10, 'capped (config lpCapPerRound 10)');
|
||||
assert.equal(pendingLoss(14, 12), 12, 'the configured cap');
|
||||
for (const junk of [-3, NaN, null, undefined, 'x']) assert.equal(pendingLoss(junk), 0, String(junk));
|
||||
const cfg = JSON.parse(read('data/config.json'));
|
||||
assert.equal(cfg.lpCapPerRound, 10, 'the cap the client reads (gd.config.lpCapPerRound)');
|
||||
});
|
||||
|
||||
test('the own count: the further of the local runner\'s and the server\'s (a display replica stands still while the player watches a teammate)', () => {
|
||||
assert.equal(ownLeaks(3, 1), 3, 'the local battle is ahead of the ~1 Hz b.progress');
|
||||
assert.equal(ownLeaks(2, 5), 5, 'a display replica paused while watching another field: the server moved on');
|
||||
assert.equal(ownLeaks(undefined, 4), 4, 'no local entry (reconnect, still loading)');
|
||||
assert.equal(ownLeaks(4, undefined), 4);
|
||||
for (const [a, b] of [[undefined, undefined], [NaN, null], [-2, 'x']]) assert.equal(ownLeaks(a, b), 0);
|
||||
// the game screen feeds liveLp with it
|
||||
assert.match(read('public/js/screens/game.js'), /leaks: ownLeaks\(localLeaks, meP\?\.pendingLp\)/);
|
||||
});
|
||||
|
||||
/** Feed liveLp a sequence of states, keeping its base like the game screen (lpBaseRef). */
|
||||
const run = (steps) => {
|
||||
let base = null;
|
||||
return steps.map((s) => { const r = liveLp(base, s); base = r.base; return r; });
|
||||
};
|
||||
|
||||
test('COMBAT: lp − pending as leaks come in; the settled m.private (sent before the SETTLE m.public) drops the pending part — never subtracted twice', () => {
|
||||
const at = (o) => ({ phase: PHASE.COMBAT, round: 5, lp: 30, statsLeaks: 7, leaks: 0, cap: 10, ...o });
|
||||
const out = run([
|
||||
at({}),
|
||||
at({ leaks: 1 }),
|
||||
at({ leaks: 3 }),
|
||||
// the settlement: m.private (lp 27, stats.leaks 10) lands while m.public still says COMBAT (Match.flush order)
|
||||
at({ leaks: 3, lp: 27, statsLeaks: 10 }),
|
||||
// then the SETTLE m.public
|
||||
{ phase: PHASE.SETTLE, round: 5, lp: 27, statsLeaks: 10, leaks: 3, cap: 10 },
|
||||
// the next round
|
||||
{ phase: PHASE.PREP, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
|
||||
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
|
||||
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 2, cap: 10 },
|
||||
]);
|
||||
assert.deepEqual(out.map((r) => r.shown), [30, 29, 27, 27, 27, 27, 27, 25]);
|
||||
assert.deepEqual(out.map((r) => r.pending), [0, 1, 3, 0, 0, 0, 0, 2]);
|
||||
assert.ok(out.every((r) => r.unite === false));
|
||||
});
|
||||
|
||||
test('a settlement that charged nothing (a 联防 saved everything) is seen through stats.leaks; the SETTLE phase alone drops it too', () => {
|
||||
const at = (o) => ({ phase: PHASE.UNITE, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10, ...o });
|
||||
const out = run([
|
||||
{ phase: PHASE.COMBAT, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10 },
|
||||
at({}),
|
||||
at({ statsLeaks: 9 }), // settled: lp unchanged (0 lost), the counted leaks recorded
|
||||
{ phase: PHASE.SETTLE, round: 8, lp: 20, statsLeaks: 9, leaks: 5, cap: 10 },
|
||||
]);
|
||||
assert.deepEqual(out.map((r) => r.shown), [15, 15, 20, 20]);
|
||||
assert.deepEqual(out.map((r) => r.unite), [false, true, false, false], '联防中 while the 联防 may still save part of it');
|
||||
// without stats (older view): the SETTLE phase ends it
|
||||
const bare = run([{ phase: PHASE.UNITE, round: 8, lp: 20, leaks: 5 }, { phase: PHASE.SETTLE, round: 8, lp: 20, leaks: 5 }]);
|
||||
assert.deepEqual(bare.map((r) => r.shown), [15, 20]);
|
||||
});
|
||||
|
||||
test('clamped at 0; nothing pending for the eliminated, in prep, in boss rounds or without an LP', () => {
|
||||
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).shown, 0);
|
||||
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).pending, 4, 'never more than the LP left');
|
||||
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 40, leaks: 9, alive: false }).pending, 0);
|
||||
for (const phase of [PHASE.PREP, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.RESULT]) {
|
||||
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
|
||||
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
|
||||
}
|
||||
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
|
||||
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
|
||||
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
|
||||
assert.equal(pendingTip(30, 0), null);
|
||||
});
|
||||
|
||||
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
|
||||
const pub = { phase: PHASE.COMBAT };
|
||||
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
|
||||
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
|
||||
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
|
||||
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
|
||||
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
|
||||
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
|
||||
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
|
||||
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
|
||||
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
|
||||
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
|
||||
});
|
||||
|
||||
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
|
||||
const plain = LpTower({ value: 24, size: 'lg' });
|
||||
assert.ok(!hasClass(plain, 'is-pending'));
|
||||
assert.equal(textOf(plain), '24');
|
||||
const v = LpTower({ value: 24, size: 'lg', pending: 3, tip: 'x' });
|
||||
assert.ok(hasClass(v, 'is-pending'));
|
||||
const pend = [...walk(v)].find((n) => hasClass(n, 'lp__pend'));
|
||||
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
|
||||
assert.equal(textOf(pend), '−3');
|
||||
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
|
||||
assert.equal(textOf(val), '21');
|
||||
assert.equal(v.props.title, 'x');
|
||||
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
|
||||
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
|
||||
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
|
||||
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
|
||||
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
|
||||
});
|
||||
|
||||
test('server: m.public players[].pendingLp = min(cap, the authority\'s reported leaks) during COMBAT, the own battle\'s count in 联防, gone at SETTLE', async () => {
|
||||
const { makeMatch } = await import('../match/harness.js');
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 0, seed: 7331, captureFrames: false, clientCombat: true, clients: false });
|
||||
h.autoHumans();
|
||||
h.m.start();
|
||||
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1);
|
||||
const m = h.m;
|
||||
const view = (pid) => m.publicView().players.find((p) => p.playerId === pid);
|
||||
const f = m.fields.find((x) => x.players.includes('p_0'));
|
||||
assert.equal(f.mode, 'client');
|
||||
assert.equal('pendingLp' in view('p_0'), false, 'omitted while nothing leaked');
|
||||
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 5, killed: 1, total: 9, leaks: 3 });
|
||||
assert.equal(view('p_0').pendingLp, 3);
|
||||
assert.equal('pendingLp' in view('p_1'), false);
|
||||
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 9, killed: 1, total: 20, leaks: 14 });
|
||||
assert.equal(view('p_0').pendingLp, m.gd.lpCapPerRound, 'capped');
|
||||
// 联防: the recorded own results
|
||||
m.phase = PHASE.UNITE;
|
||||
m.lastResults = new Map([['p_0', { leaked: [{ enemyKey: 'a' }, { enemyKey: 'b', counted: false }, { enemyKey: 'c', counted: true }] }]]);
|
||||
assert.equal(view('p_0').pendingLp, 2, 'counted !== false, like settle()');
|
||||
assert.equal('pendingLp' in view('p_1'), false);
|
||||
for (const phase of [PHASE.SETTLE, PHASE.PREP, PHASE.FINAL_ASSAULT]) {
|
||||
m.phase = phase;
|
||||
assert.equal('pendingLp' in view('p_0'), false, phase);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 3: the temp overflow row --------------------------------------------------------------------------------------
|
||||
|
||||
describe('3: the temp overflow row (临时整备区)', () => {
|
||||
const priv = (temp) => ({ alive: true, ready: false, canReady: !temp.some(Boolean), temp });
|
||||
|
||||
test('the ready button shows why it is refused, under it, not only on hover', () => {
|
||||
const p = priv([{ uid: 1, kind: 'item', id: 'x' }, { uid: 2, kind: 'chess', id: 'y' }, null, null, null]);
|
||||
assert.deepEqual(tempInfo(p), { count: 2, items: 1 });
|
||||
assert.match(tempReadyReason(p), /^临时整备区还有 2 个单位:/);
|
||||
assert.ok(tempReadyReason(p).includes(TEMP_RULE));
|
||||
assert.match(TEMP_RULE, /休整期结束时.*销毁/, 'says what happens at the end of the prep');
|
||||
const v = ReadyToggle({ priv: p, onToggle() {}, readyCount: 1, total: 4 });
|
||||
const why = [...walk(v)].find((n) => hasClass(n, 'readywrap__why'));
|
||||
assert.ok(why, 'visible reason');
|
||||
assert.equal(textOf(why), '临时整备区 2 个单位待处理');
|
||||
const btn = [...walk(v)].find((n) => n.type === 'button');
|
||||
assert.equal(btn.props.disabled, true);
|
||||
// empty temp: no reason, enabled
|
||||
const ok = ReadyToggle({ priv: priv([null, null, null, null, null]), onToggle() {} });
|
||||
assert.equal([...walk(ok)].find((n) => hasClass(n, 'readywrap__why')), undefined);
|
||||
assert.ok(![...walk(ok)].find((n) => n.type === 'button').props.disabled);
|
||||
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
|
||||
});
|
||||
|
||||
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
|
||||
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
|
||||
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
|
||||
const a = tile(800, 640), b = tile(1260, 640);
|
||||
const f = tempRowFrame(a, b, { labelW: 290, gap: 10, vw: 1920 });
|
||||
assert.deepEqual(f.quad, [[756, 610], [1304, 610], [1310, 670], [750, 670]]);
|
||||
assert.deepEqual([f.left, f.right, f.top, f.bottom, f.y], [750, 1310, 610, 670, 640]);
|
||||
assert.equal(f.side, 'left');
|
||||
// the Final Assault prep of the right-hand player draws the board mirrored: the ends swap on screen
|
||||
assert.deepEqual(tempRowFrame(b, a, { labelW: 290, gap: 10, vw: 1920 }).quad, f.quad);
|
||||
// no room on the left (a narrow phone): the label goes right
|
||||
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 1200 }).side, 'right');
|
||||
// neither side has room: left (clipped rather than covering the row)
|
||||
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 900 }).side, 'left');
|
||||
for (const bad of [null, {}, { poly: [[0, 0]] }, { poly: [[0, 0], [1, 0], [1, NaN], [0, 1]] }]) assert.equal(tempRowFrame(bad, b), null);
|
||||
});
|
||||
|
||||
test('the row\'s label matches the server\'s due rule (PlayerState tempDue): destroyed at this prep\'s end unless ready — then kept through the next prep', () => {
|
||||
assert.match(tempRowRule(false), /才能准备;休整期结束时仍在此处的将被销毁$/);
|
||||
// Ready is refused while the row holds pieces: what lies there while ready arrived after it (due at the next prep)
|
||||
assert.match(tempRowRule(true), /保留到下个休整期/);
|
||||
assert.match(tempRowRule(true), /取消准备则在本休整期结束时销毁/);
|
||||
});
|
||||
|
||||
test('the game screen frames the row only on the own prep board while it holds pieces (source contract)', () => {
|
||||
const game = read('public/js/screens/game.js');
|
||||
assert.match(game, /const tempNotice = temp\.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;/);
|
||||
assert.match(game, /<\$\{TempRowNotice\} view=\$\{view\} count=\$\{temp\.count\} items=\$\{temp\.items\} label=\$\{!drag && !facing\}\s+ready=\$\{phase === PHASE\.PREP && !!priv\?\.ready\} \/>/);
|
||||
assert.match(game, /toast\(tempReadyReason\(L\.priv\) \|\| refused, 'warn'\)/, 'Space says why');
|
||||
const css = read('public/css/screens/game-panels.css');
|
||||
assert.match(css, /\.tempnote \{ position: fixed; inset: 0; z-index: 2; pointer-events: none; \}/, 'over the field, under the HUD, never takes the pointer');
|
||||
});
|
||||
|
||||
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
|
||||
const game = read('public/js/screens/game.js');
|
||||
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
|
||||
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
|
||||
assert.match(game, /readyWhy && 'has-readywhy'/);
|
||||
const css = read('public/css/screens/game.css');
|
||||
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
|
||||
assert.match(css, /\.gm\.has-readywhy \.gm__effects \{ top: calc\(2\.4rem \+ max\(\.3rem, 16px\)\); \}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 8: the operator's own effect first --------------------------------------------------------------------------------
|
||||
|
||||
describe('8: 特质 right under the detail card\'s header', () => {
|
||||
test('the block order', () => {
|
||||
assert.deepEqual(CHESS_SECTIONS, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
|
||||
});
|
||||
|
||||
test('a rendered operator card: header, then 特质 (trigger + description), then the rest in CHESS_SECTIONS order', async () => {
|
||||
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
|
||||
const c = data.lookup('chess', 'chess_char_3_04_b'); // 琳琅诗怀雅 (elite): <休整期结束时> garrison, modules, talents
|
||||
assert.ok(c && c.garrisonIds && c.garrisonIds[0]);
|
||||
const g = data.lookup('garrisons', c.garrisonIds[0]);
|
||||
assert.equal(g.eventType, 'SERVER_PREP_FIN');
|
||||
const blocks = ChessDetail({ chess: c, piece: { uid: 7, kind: 'chess', id: c.chessId, golden: true, items: [] }, editable: false, onSell() {}, bonds: [], loadout: null, onBond: null });
|
||||
const keys = blocks.map((b) => b.key);
|
||||
assert.deepEqual(keys, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents']);
|
||||
assert.equal(blocks[1].props.garrison, g, 'the operator\'s own garrison');
|
||||
// the garrison block itself: the 特质 label, the trigger chip and the description
|
||||
const gb = blocks[1].type(blocks[1].props);
|
||||
assert.ok(hasClass(gb, 'dgarrison'));
|
||||
assert.ok(textOf(gb).includes('特质'));
|
||||
assert.ok(textOf(gb).includes(g.eventTypeDesc));
|
||||
const rich = [...walk(gb)].find((n) => n.props?.text != null);
|
||||
assert.equal(rich.props.text, g.descRaw || g.desc);
|
||||
// an operator without a garrison (a summon's chess record may have none) simply starts with the trait
|
||||
const bare = ChessDetail({ chess: { ...c, garrisonIds: [] }, piece: null, editable: false, bonds: [], loadout: null });
|
||||
assert.deepEqual(bare.map((b) => b.key), ['head', 'trait', 'stats', 'skill', 'module', 'talents']);
|
||||
});
|
||||
|
||||
test('the 特质 chip carries the garrison\'s official type icon (eventTypeIcon) — the spoked 特异化 glyph only on 特异化', async () => {
|
||||
await data.loadAll('garrisons', 'assets', 'chess');
|
||||
const all = data.list('garrisons');
|
||||
assert.ok(all.length > 200);
|
||||
const bad = all.filter((g) => garrisonTypeIconKey(g) !== `s_${g.eventTypeIcon}`);
|
||||
assert.deepEqual(bad.map((g) => g.garrisonId), [], 'every garrison shows its own type icon');
|
||||
// (the spoked icon_support reads like a loading spinner: it used to stand before every <休整期开始时 / 结束时> 特质)
|
||||
const spoked = all.filter((g) => garrisonTypeIconKey(g) === 's_icon_support');
|
||||
assert.ok(spoked.length > 0 && spoked.every((g) => g.eventTypeDesc === '特异化'), 'icon_support = 特异化 only');
|
||||
const prep = all.filter((g) => g.eventType === 'SERVER_PREP_START' || g.eventType === 'SERVER_PREP_FIN');
|
||||
assert.ok(prep.filter((g) => garrisonTypeIconKey(g) !== 's_icon_support').length > prep.length / 2, 'most 休整期 特质 are 持续叠加 / 整备能力');
|
||||
// no official icon in the record: a guess by the trigger, never a path from the data
|
||||
assert.equal(garrisonTypeIconKey({ eventType: 'SERVER_PREP_FIN' }), 's_icon_bond');
|
||||
assert.equal(garrisonTypeIconKey({ eventType: 'IN_BATTLE', eventTypeIcon: '../../x' }), 's_icon_battle');
|
||||
assert.equal(garrisonTypeIconKey(null), 's_icon_bond');
|
||||
// the rendered card of 琳琅诗怀雅 (elite): <休整期结束时> · 整备能力 → the gold 整备能力 icon
|
||||
const c = data.lookup('chess', 'chess_char_3_04_b');
|
||||
const g = data.lookup('garrisons', c.garrisonIds[0]);
|
||||
assert.equal(g.eventTypeDesc, '整备能力');
|
||||
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
|
||||
const gb = blocks.find((b) => b.key === 'garrison');
|
||||
const img = [...walk(gb.type(gb.props))].find((n) => hasClass(n, 'dgarrison__icon'));
|
||||
assert.match(String(img.props.src), /garrisonTypeIcon\/s_icon_gold\.png$/);
|
||||
});
|
||||
|
||||
test('css: a compact gold-accented block; stats in a 4 × 2 grid so the skill stays close', () => {
|
||||
const css = read('public/css/screens/game-panels.css');
|
||||
assert.match(css, /\.dgarrison \{[^}]*border-left: 3px solid var\(--gold-2\)/);
|
||||
assert.match(css, /\.dstats \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/);
|
||||
assert.match(read('public/css/devices.css'), /@media \(max-height: 460px\) \{\n {2}\.dhead__art \{ height: 1\.66rem; \}\n {2}\.dhead__en \{ display: none; \}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 9: no spinner next to texts; data loaded once, before the match screen --------------------------------------------
|
||||
|
||||
describe('9: busy indicators and data loading', () => {
|
||||
const cssFiles = () => {
|
||||
const out = [];
|
||||
const walkDir = (d) => { for (const e of readdirSync(path.join(ROOT, d), { withFileTypes: true })) { const p = `${d}/${e.name}`; if (e.isDirectory()) walkDir(p); else if (p.endsWith('.css')) out.push(p); } };
|
||||
walkDir('public/css');
|
||||
return out;
|
||||
};
|
||||
|
||||
test('no wait / progress cursor anywhere (the OS shows a spinning wheel next to the pointer — over the detail card beside an underframe button, the 交流 button …)', () => {
|
||||
for (const f of cssFiles()) assert.doesNotMatch(read(f), /cursor:\s*(wait|progress)/, f);
|
||||
});
|
||||
|
||||
test('a busy button keeps its icon and gets a delayed sweeping bar — no spinning ring', () => {
|
||||
const v = Button({ loading: true, icon: 'play', children: '继续作战' });
|
||||
assert.equal(v.props.disabled, true);
|
||||
assert.equal(v.props['aria-busy'], 'true');
|
||||
const nodes = [...walk(v)];
|
||||
assert.ok(nodes.some((n) => hasClass(n, 'btn__icon')), 'the icon stays');
|
||||
assert.ok(nodes.some((n) => hasClass(n, 'btn__busy')));
|
||||
assert.ok(!nodes.some((n) => hasClass(n, 'btn__spin')));
|
||||
const idle = [...walk(Button({ icon: 'play', children: 'x' }))];
|
||||
assert.ok(!idle.some((n) => hasClass(n, 'btn__busy')));
|
||||
const css = read('public/css/components.css');
|
||||
assert.doesNotMatch(css, /\.btn__spin/);
|
||||
assert.match(css, /\.btn__busy \{[^}]*animation: btn-busy-in 1ms linear 250ms forwards;/, 'only after 250 ms: quick replies show nothing');
|
||||
assert.match(css, /@keyframes btn-busy \{ from \{ transform: translateX\(-100%\); \} to \{ transform: translateX\(250%\); \} \}/, 'a sweep, not a rotation');
|
||||
});
|
||||
|
||||
test('机变: the card being picked shows 选择中 (no spinner) only until the pick shows in m.public', () => {
|
||||
assert.equal(pickBusy(2, { idx: 2 }, null), true);
|
||||
assert.equal(pickBusy(2, { idx: 1 }, null), false);
|
||||
assert.equal(pickBusy(null, { idx: 2 }, null), false);
|
||||
assert.equal(pickBusy(2, { idx: 2 }, 2), false, 'the pick landed: never stuck on the card');
|
||||
assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too');
|
||||
assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false);
|
||||
const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true };
|
||||
const v = ChoiceOverlay({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
|
||||
const nodes = [...walk(v)];
|
||||
const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy'));
|
||||
assert.equal(busyCards.length, 1);
|
||||
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中');
|
||||
assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text');
|
||||
// the pick landed (m.public) while the request still waits for its reply
|
||||
const landed = ChoiceOverlay({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
|
||||
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
|
||||
});
|
||||
|
||||
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
|
||||
const css = read('public/css/screens/game.css');
|
||||
assert.doesNotMatch(css, /hg-flip/);
|
||||
assert.match(css, /\.chud__msg \.icon \{ width: \.22rem; height: \.22rem; color: var\(--gold\); \}/);
|
||||
});
|
||||
|
||||
test('data store: a transient failure is retried (the file stays loading), a 404 / bad JSON is not; a superseded retry stays silent', async () => {
|
||||
const waits = [];
|
||||
const wait = async (ms) => { waits.push(ms); };
|
||||
let calls = 0;
|
||||
const flaky = createDataStore({
|
||||
wait,
|
||||
fetch: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
|
||||
return { ok: true, status: 200, json: async () => ({ list: [{ id: 'x', v: 1 }] }) };
|
||||
},
|
||||
});
|
||||
const p = flaky.load('chess');
|
||||
assert.equal(flaky.status('chess'), 'loading');
|
||||
await p;
|
||||
assert.equal(flaky.status('chess'), 'ready');
|
||||
assert.equal(flaky.lookup('chess', 'x').v, 1);
|
||||
assert.deepEqual(waits, RETRY_DELAYS_MS.slice(0, 2));
|
||||
assert.deepEqual(RETRY_DELAYS_MS, [600, 2000]);
|
||||
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
let n404 = 0;
|
||||
const gone = createDataStore({ wait, fetch: async () => { n404++; return { ok: false, status: 404, json: async () => ({}) }; } });
|
||||
await gone.load('bands');
|
||||
assert.equal(gone.status('bands'), 'missing');
|
||||
assert.equal(n404, 1, 'a 404 is final');
|
||||
let nBad = 0;
|
||||
const bad = createDataStore({ wait, fetch: async () => { nBad++; return { ok: true, status: 200, json: async () => JSON.parse('{"broken": ') }; } });
|
||||
await bad.load('bands');
|
||||
assert.equal(bad.status('bands'), 'missing');
|
||||
assert.equal(nBad, 1, 'a delivered file that is not JSON is final');
|
||||
let nDown = 0;
|
||||
const down = createDataStore({ wait, fetch: async () => { nDown++; throw new TypeError('offline'); } });
|
||||
await down.load('items');
|
||||
assert.equal(down.status('items'), 'missing');
|
||||
assert.equal(nDown, 1 + RETRY_DELAYS_MS.length, 'gives up after the retries');
|
||||
// invalidated while waiting to retry: the old load stops, the new one decides
|
||||
let release;
|
||||
let nInv = 0;
|
||||
const inv = createDataStore({
|
||||
wait: () => new Promise((r) => { release = r; }),
|
||||
fetch: async () => { nInv++; if (nInv === 1) throw new TypeError('offline'); return { ok: true, status: 200, json: async () => ({ a: { v: 2 } }) }; },
|
||||
});
|
||||
const seen = [];
|
||||
inv.subscribe((name) => seen.push(`${name}:${inv.status(name)}`));
|
||||
const first = inv.load('config');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const second = inv.invalidate('config');
|
||||
await second;
|
||||
release();
|
||||
await first;
|
||||
assert.equal(inv.status('config'), 'ready');
|
||||
assert.equal(inv.get('config').a.v, 2);
|
||||
assert.equal(nInv, 2, 'the superseded load did not fetch again');
|
||||
assert.deepEqual(seen, ['config:loading', 'config:ready']);
|
||||
} finally { console.warn = warn; }
|
||||
});
|
||||
|
||||
test('every data file the in-match UI reads is in GAME_FILES (the match screen waits for them: no text appears late); main.js warms them in a room', () => {
|
||||
const files = [
|
||||
...readdirSync(path.join(ROOT, 'public/js/ui')).filter((f) => f.endsWith('.js')).map((f) => `public/js/ui/${f}`),
|
||||
...['game', 'briefing', 'bandDraft', 'result'].map((s) => `public/js/screens/${s}.js`),
|
||||
];
|
||||
const used = new Set();
|
||||
for (const f of files) for (const m of read(f).matchAll(/data\.(?:get|lookup|list|load|status)\('([A-Za-z-]+)'/g)) used.add(m[1]);
|
||||
for (const m of read('public/js/ui/gameComponents.js').matchAll(/useData\('([A-Za-z-]+)'/g)) used.add(m[1]);
|
||||
assert.ok(used.size >= 10, [...used].join(','));
|
||||
const missing = [...used].filter((n) => !GAME_FILES.includes(n));
|
||||
assert.deepEqual(missing, [], 'read by the match UI but not awaited by the match screen');
|
||||
const main = read('public/js/main.js');
|
||||
assert.match(main, /if \(s\.room && !prev\.room\) warmGameData\(\);/);
|
||||
assert.match(main, /data\.loadAll\(GAME_FILES\)/);
|
||||
});
|
||||
});
|
||||
|
||||
test('GEO: the temp row the notice frames is the server\'s (row 8, cols 4–8)', () => {
|
||||
assert.deepEqual([GEO.TEMP_ROW, GEO.TEMP_C0, GEO.TEMP_SIZE], [8, 4, 5]);
|
||||
});
|
||||
@@ -202,7 +202,7 @@ describe('equipment dropped on an operator', () => {
|
||||
};
|
||||
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
|
||||
|
||||
test('pickPieceAt picks the front-most sprite under the pointer', () => {
|
||||
test('pickPieceAt: the rect whose centre is nearest (overlap → the nearer unit)', () => {
|
||||
const rects = [
|
||||
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // row 9 (nearer the camera: lower on screen)
|
||||
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // row 10, behind
|
||||
|
||||
Reference in new issue
Block a user