Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -319,6 +319,18 @@ describe('audio banks and plan id sets', () => {
|
||||
assert.deepEqual(pickUnitSfx(banks), { hit: ['e/hit.mp3'] });
|
||||
});
|
||||
|
||||
test('operators: normal-mode sounds only, their own projectile banks as fallbacks (user playtest #4 item 6)', () => {
|
||||
// 纯烬艾雅法拉: the numbered ability variant attack.2 is her S3 impact (_s) — never the normal hit
|
||||
const banks = new Map([['ON_ABILITY_START.attack', ['p/p_atk_x_n.mp3']], ['ON_ABILITY_HIT.attack.2', ['p/p_imp_x_s.mp3']]]);
|
||||
assert.deepEqual(pickUnitSfx(banks, { operator: true, projectile: { hit: ['p/p_imp_x_n.mp3'] } }), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_n.mp3'] });
|
||||
assert.deepEqual(pickUnitSfx(banks), { attack: ['p/p_atk_x_n.mp3'], hit: ['p/p_imp_x_s.mp3'] }, 'enemies / tokens unchanged');
|
||||
// a skill-mode plain bank (_d / _h / _s) is skipped; the plain ability wins over any numbered variant
|
||||
const b2 = new Map([['ON_ABILITY_START.attack', ['p/p_atk_y_h.mp3']], ['ON_ABILITY_START.attack.0', ['p/p_atk_y_n.mp3']], ['ON_ABILITY_ON.attack', ['p/p_atk_y_n2.mp3']]]);
|
||||
assert.deepEqual(pickUnitSfx(b2, { operator: true }).attack, ['p/p_atk_y_n2.mp3']);
|
||||
assert.deepEqual(pickUnitSfx(new Map([['ON_ABILITY_START.attack.1', ['p/p_atk_z_s.mp3']]]), { operator: true }), {}, 'only skill-mode files: no attack sound');
|
||||
assert.deepEqual(pickUnitSfx(undefined, { operator: true, projectile: { born: ['p/b.mp3'] } }), { attack: ['p/b.mp3'] }, 'no unit banks: the projectile bank');
|
||||
});
|
||||
|
||||
test('enemy set covers 07 list, bosses and their summons', () => {
|
||||
const assets07 = readJson('docs/research/07-assets.json');
|
||||
const ids = new Set(collectEnemyIds({ assets07, enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'), ops03: readJson('docs/research/03-operators.json') }));
|
||||
|
||||
@@ -55,9 +55,12 @@ test('炎: members ATK ×(1+0.23+0.009L), 调和 enjoys it, non-members unchange
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
|
||||
test('炎 6 / 9: one / two 炎佑 with their template stats + 30 % of the real 炎 sums; 9: ATK ×1.5, damage taken ×0.1', () => {
|
||||
assert.equal(yanyouShare(), 0.3);
|
||||
const bb = bondBb('yanShip');
|
||||
// PRTS: "登场时使自身攻击力、生命值增加…所有【炎】盟约干员攻击力、生命值的30%(最终加算)" — added to the template
|
||||
const tpl = DATA.tokens.enemy_9012_acloon.stats;
|
||||
assert.deepEqual([tpl.atk, tpl.maxHp], [600, 12000], 'enemy_9012_acloon template');
|
||||
for (const n of [6, 9]) {
|
||||
const list = [];
|
||||
for (let i = 0; i < n - 1; i++) list.push([`y${i}_a`, ['yanShip']]);
|
||||
@@ -72,8 +75,8 @@ test('炎 6 / 9: one / two 炎佑 with 30 % of the real 炎 sums; 9: ATK ×1.5,
|
||||
const sumAtk = (n - 1) * 1000 * (1 + bb.base_atk); // 调和 excluded from the sums
|
||||
const sumHp = (n - 1) * 10000;
|
||||
for (const y of yy) {
|
||||
close(y.s.atk, sumAtk * 0.3 * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
|
||||
close(y.s.maxHp, sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
|
||||
close(y.s.atk, (tpl.atk + sumAtk * 0.3) * (n === 9 ? bb.atk : 1), 1e-6, `炎佑 ATK @${n}`);
|
||||
close(y.s.maxHp, tpl.maxHp + sumHp * 0.3, 1e-6, `炎佑 HP @${n}`);
|
||||
close(y.s.dmgTakenMul, n === 9 ? 1 - bb.damage_resistance : 1, 1e-9, `炎佑 dmgTaken @${n}`);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
|
||||
@@ -79,7 +79,10 @@ test('coverage: every 悬赏 / 战术决策 effect and every 道具补给 / 机
|
||||
const ids = choiceCardIds(DATA);
|
||||
assert.equal(ids.bounty.length, 129, '129 ENEMY_GAIN effects');
|
||||
assert.equal(ids.tactic.length, 43, '43 BUFF_GAIN effects');
|
||||
assert.equal(ids.items.length, 56, '56 shop-eligible normal EQUIP items');
|
||||
// 56 normal EQUIP items − the 5 effect-only ones (4 special 维式重锤, 突变细胞: never sold, user playtest #4)
|
||||
assert.equal(ids.items.length, 51, '51 shop items');
|
||||
for (const id of ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a']) assert.ok(!ids.items.includes(id), `${id} is never a supply card`);
|
||||
assert.ok(ids.items.includes('chess_item_1_01_e_a'), 'the plain 维式重锤 is');
|
||||
for (const id of [...ids.bounty, ...ids.tactic, ...ids.items]) {
|
||||
const h = REG.get(`choice:${id}`);
|
||||
assert.ok(h && typeof h.onChoicePick === 'function', `choice:${id}`);
|
||||
|
||||
@@ -713,19 +713,22 @@ test(`${nm('enemy_1042_frostd')}: operators within ${tb('enemy_1042_frostd', 'de
|
||||
assert.equal(h.unit('t_mage').s.aspd, 100);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1040_bombd')}: bombs splash around the target; speeds up after the first throw`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }] });
|
||||
test(`${nm('enemy_1040_bombd')}: no normal attack; ONE bomb on the target + its 8 tiles, then move speed ×${skb('enemy_1040_bombd', 'boomb').bb.move_speed}`, () => {
|
||||
// PRTS 暴鸰: "不进行普通攻击" · 投弹 "对目标及其周围八格的我方单位造成100%物理伤害 … 技能结束后移速最终提升至200% ※此技能仅能触发一次"
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 });
|
||||
const v0 = E.enemy_1040_bombd.stats.moveSpeed;
|
||||
h.runUntil(() => e.stats.attacks >= 1, 10);
|
||||
h.run(0.5);
|
||||
assert.ok(h.unit('t_wall').stats.taken > 0 && h.unit('t_wall2').stats.taken > 0);
|
||||
assert.equal(e.base.moveSpeed, 0);
|
||||
approx(e.s.moveSpeed, 0);
|
||||
assert.ok(e.findBuff('ab:bombRun'));
|
||||
approx(e.findBuff('ab:bombRun').mods.moveMul, 1 + skb('enemy_1040_bombd', 'boomb').bb.move_speed);
|
||||
assert.ok(v0 > 0);
|
||||
const s = skb('enemy_1040_bombd', 'boomb');
|
||||
h.run(s.initCooldown + 0.2);
|
||||
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
|
||||
approx(w2.stats.taken, e.s.atk, 1e-6, 'the target (latest deployed in range): 100 % ATK');
|
||||
approx(w1.stats.taken, e.s.atk, 1e-6, 'a tile next to it: splash 100 %');
|
||||
assert.equal(w3.stats.taken, 0, 'two rows away: outside the 3×3');
|
||||
assert.equal(e.stats.attacks, 0, 'never a normal attack');
|
||||
approx(e.findBuff('ab:bombRun').mods.moveMul, s.bb.move_speed, 1e-9, '移速最终提升至200%');
|
||||
h.run(20);
|
||||
approx(w2.stats.taken + w1.stats.taken, 2 * e.s.atk, 1e-6, 'only once');
|
||||
assert.equal(e.stats.attacks, 0);
|
||||
});
|
||||
|
||||
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
|
||||
@@ -738,6 +741,7 @@ for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hlle
|
||||
assert.equal(f.length, 1);
|
||||
approx(f[0].duration, tb(key, 'SelfFear.fear'));
|
||||
assert.ok(e.findBuff('ab:fearRun'));
|
||||
approx(e.findBuff('ab:fearRun').mods.moveMul, tb(key, 'SelfFear.move_speed'), 1e-9, '移动速度最终提升至150%');
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
assert.equal(statuses(h, e.id, 'fear').length, 1, 'only once');
|
||||
});
|
||||
@@ -774,19 +778,66 @@ test(`${nm('enemy_1407_hummbd')}: death exposes nearby operators (damage taken
|
||||
assert.equal(h.unit('t_wall2').s.dmgTakenMul, 1);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_9009_acfort')}: devours nearby normal flyers for ammo, then fires it all (ATK×1.3 per round)`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
|
||||
test(`${nm('enemy_9009_acfort')}: grabs ≤ 3 normal flyers within 2.5 × range_radius for ammo, then fires one ATK×1.3 hit per ammo at random allies`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 12, col: 2 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_9009_acfort', [10, 7], { route: 2 });
|
||||
e.profile.noAttack = true;
|
||||
const y1 = put(h, 'enemy_1005_yokai', [10, 7], { route: 2 });
|
||||
const y2 = put(h, 'enemy_1005_yokai', [11, 7], { route: 2 });
|
||||
const k = skb('enemy_9009_acfort', 'KillOthers');
|
||||
h.run(k.initCooldown + k.bb.duration + 0.1);
|
||||
assert.ok(!y1.alive && !y2.alive);
|
||||
assert.ok(h.unit('t_wall').stats.taken >= 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale - 1e-6 || e.mem.ab.ammo === 2);
|
||||
h.run(skb('enemy_9009_acfort', 'FireWeapon').cooldown + 0.1);
|
||||
approx(h.unit('t_wall').stats.taken, 2 * e.s.atk * skb('enemy_9009_acfort', 'FireWeapon').bb.atk_scale);
|
||||
const k = skb('enemy_9009_acfort', 'KillOthers'), f = skb('enemy_9009_acfort', 'FireWeapon');
|
||||
const R = k.bb.range_radius * 2.5; // PRTS: 半径3.75 (2.5倍可变半径)
|
||||
assert.equal(R, 3.75);
|
||||
// four prey inside 3.75 (the nearest three are taken), one outside
|
||||
const near = [[10, 7], [11, 7], [10, 9], [12, 10]].map((p) => put(h, 'enemy_1005_yokai', p, { route: 2 }));
|
||||
const out = put(h, 'enemy_1005_yokai', [9, 2], { route: 2 });
|
||||
assert.ok(Math.hypot(out.x - e.x, out.y - e.y) > R && Math.hypot(near[3].x - e.x, near[3].y - e.y) <= R);
|
||||
h.run(k.initCooldown + 0.1);
|
||||
assert.ok(e.findBuff('ab:grabbing')?.flags.bind, '黑云 holds still while grabbing');
|
||||
h.run(0.5 + k.bb.duration + 0.1);
|
||||
assert.deepEqual(near.map((y) => y.alive), [false, false, false, true], 'the nearest three are devoured');
|
||||
assert.ok(out.alive, 'out of reach');
|
||||
const w1 = h.unit('t_wall'), w2 = h.unit('t_wall2');
|
||||
// 全弹发射 fires as soon as it has ammo: 3 hits of ATK×1.3, each on a random ally of the whole field
|
||||
assert.ok(h.runUntil(() => w1.stats.taken + w2.stats.taken > 0, f.cooldown + 1));
|
||||
h.run(0.1);
|
||||
approx(w1.stats.taken + w2.stats.taken, 3 * e.s.atk * f.bb.atk_scale, 1e-6, 'one hit per ammo');
|
||||
assert.equal(e.mem.ab.ammo, 0, 'all SP spent');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1112_emppnt')} / 中枢: every attack is a shell that lands 3 s later on the target's spot (all allies within 1.2)`, () => {
|
||||
for (const key of ['enemy_1112_emppnt', 'enemy_1112_emppnt_2']) {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }], captureNoisy: true });
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7], { route: 2 });
|
||||
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10), 'attacks');
|
||||
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
|
||||
const t0 = h.b.time;
|
||||
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'nothing on launch');
|
||||
assert.ok(h.events.some((ev) => ev[0] === 'atk' && ev[1] === e.id && ev[3] === 'mortar'), 'attack event without a projectile');
|
||||
const shell = h.eventsOf('fx').find((ev) => ev[1] === 'bombardShell');
|
||||
assert.ok(shell, 'the shell fx (3 s flight)');
|
||||
h.run(2.8);
|
||||
assert.equal(w1.stats.taken + w2.stats.taken + w3.stats.taken, 0, 'still in the air');
|
||||
h.run(0.3);
|
||||
const hit = [w1, w2, w3].filter((w) => w.stats.taken > 0);
|
||||
// the target (latest deployed in range 2) is t_wall3 or t_wall2; the blast covers every wall within 1.2 of it
|
||||
assert.ok(hit.length >= 2 && hit.every((w) => Math.abs(w.stats.taken - e.s.atk) < 1e-6), `${key}: ${hit.map((w) => w.defId)}`);
|
||||
assert.ok(h.b.time - t0 >= 3 - 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${nm('enemy_10084_hlegle')}: never attacks a flying ally (不会攻击飞行单位) — a ground one in range instead`, () => {
|
||||
// a flying ally (the 炎佑 is one) deployed after the wall, so the engine's order (latest deployed) would pick it
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 8 }] });
|
||||
h.step();
|
||||
const w = h.unit('t_wall'), fly = h.unit('t_wall2');
|
||||
fly.motion = 'FLY';
|
||||
assert.ok(fly.isFlying && fly.deploySeq > w.deploySeq);
|
||||
const e = put(h, 'enemy_10084_hlegle', [10, 7], { route: 2 });
|
||||
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10));
|
||||
h.run(0.5);
|
||||
assert.equal(fly.stats.taken ?? 0, 0, 'the flyer is never hit');
|
||||
assert.ok(w.stats.taken > 0, 'the ground wall is');
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1321_wdarft')} + ${nm('enemy_1269_nhfly')}: the apostle spawns seeds that dive onto operators and self-destruct`, () => {
|
||||
|
||||
@@ -973,21 +973,27 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
|
||||
});
|
||||
|
||||
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
|
||||
});
|
||||
const blaze = h.unit('chess_char_5_03_a'), nymph = h.unit('chess_char_5_22_a');
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
const melt = tagged(h, 'blazeMelt', e);
|
||||
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
|
||||
const arena = () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
|
||||
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
|
||||
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
|
||||
};
|
||||
const a = arena();
|
||||
a.h.b.dealDamage(null, a.e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
const melt = tagged(a.h, 'blazeMelt', a.e);
|
||||
assert.equal(melt.length, 1);
|
||||
assert.equal(melt[0].type, 'elemental');
|
||||
approx(melt[0].amount, blaze.s.atk * tal(blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
|
||||
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
|
||||
clean(a.h);
|
||||
const { h, e, nymph } = arena();
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
|
||||
const soul = tagged(h, 'nymphSoul', e)[0];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
|
||||
// #11 赫默's 医疗无人机 appears only when her S2 fires — never on the field at battle start (tools/build-data.mjs:
|
||||
// a skill's summon is not a prep hand card; test/match/loadout.test.js covers the prep side);
|
||||
// #12 the 炎 bond's “炎佑” (PRTS “炎佑” 级别0(卫戍协议)): follows the highest-aggro enemy of the whole field and stays
|
||||
// where it is without one (no fixed return point); 祛恶之焰 [模式乙] locks one target, channels up to 20 s (no normal
|
||||
// attacks meanwhile), ends when the lock is lost or on silence; burn on every damage it deals; 元素脆弱 aura
|
||||
// (radius 1.5); immune to element damage; bond stats = template + 30 % of the 炎 sums.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const dummy = (o = {}) => enemyRec({ key: o.key ?? 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5, def: 0 }, ...o });
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const flameRec = (h) => h.b.data.rawToken(TOKEN_IDS.yanyou).skills.find((s) => s.prefabKey === 'Skill_2');
|
||||
|
||||
// =====================================================================================================================
|
||||
// #11 赫默
|
||||
|
||||
test('#11 赫默 (full kit, default S2): no drone at battle start; the 医疗探机 appears when 医疗无人机 fires', REAL, () => {
|
||||
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const hm = h.unit('chess_char_2_02_a');
|
||||
assert.equal(hm.skill.id, 'skchr_silent_2');
|
||||
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
|
||||
assert.equal(drones().length, 0, 'nothing but the operators at t = 0');
|
||||
h.unit('test_guard').hp = 1000;
|
||||
const cost = hm.skill.spCost;
|
||||
h.run(cost - 2);
|
||||
assert.equal(hm.skill.activations, 0);
|
||||
assert.equal(drones().length, 0, 'no drone before the skill is ready');
|
||||
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 10), 'S2 fires once its SP is full (initSp 0)');
|
||||
h.step();
|
||||
assert.equal(drones().length, 1, 'the drone comes with the skill');
|
||||
assert.equal(drones()[0].ownerUnit, hm);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// #12 炎佑
|
||||
|
||||
test('#12 炎佑 stays where it is when no enemy is on the field (no fixed return point)', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 1000, hp: 5000 });
|
||||
const home = { x: y.x, y: y.y };
|
||||
const e = h.enemy('enemy_dummy');
|
||||
assert.ok(h.runUntil(() => dist(y, e) <= 0.3, 30), 'flies over the enemy');
|
||||
h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
|
||||
assert.equal(e.alive, false);
|
||||
h.step();
|
||||
const at = { x: y.x, y: y.y };
|
||||
assert.ok(Math.hypot(at.x - home.x, at.y - home.y) > 1, 'it had left its spawn point');
|
||||
h.run(10);
|
||||
assert.ok(Math.abs(y.x - at.x) < 1e-9 && Math.abs(y.y - at.y) < 1e-9, `hovers in place (${y.x.toFixed(2)},${y.y.toFixed(2)} vs ${at.x.toFixed(2)},${at.y.toFixed(2)})`);
|
||||
// an empty field from the start: it never moves
|
||||
const h2 = makeBattle({ units: [], autoFinish: false, timeLimit: 30 });
|
||||
h2.step();
|
||||
const [y2] = spawnYanyou(h2.b, 'p1', { atk: 1000, hp: 5000 });
|
||||
const p0 = { x: y2.x, y: y2.y };
|
||||
h2.run(10);
|
||||
assert.deepEqual({ x: y2.x, y: y2.y }, p0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#12 炎佑 tracks the highest-aggro enemy of the whole field (least remaining path), not the nearest one', REAL, () => {
|
||||
// two standing enemies on the walk route to the goal (9,2): (9,4) is 2 tiles from it, (12,9) far away
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [12, 9] }, { key: 'enemy_dummy', pos: [9, 4] }], autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
|
||||
const [far, lead] = h.enemies();
|
||||
assert.ok(h.b.remainingDistance(lead) < h.b.remainingDistance(far), 'fixture: (9,4) leads');
|
||||
// start next to the far enemy: it still leaves it for the leader
|
||||
y.x = far.x; y.y = far.y;
|
||||
assert.ok(h.runUntil(() => dist(y, lead) <= 0.3, 40), 'flies over the leading enemy');
|
||||
h.run(3);
|
||||
assert.ok(dist(y, lead) <= 0.3, 'and keeps hovering over it');
|
||||
});
|
||||
|
||||
test('#12 祛恶之焰 [模式乙]: locks the normal attack target, no normal attacks while channelling, ends when the target dies', REAL, () => {
|
||||
const burn = [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy(), enemy_frail: dummy({ key: 'enemy_frail', hp: 1e7 }) } }, units: [],
|
||||
enemies: [{ key: 'enemy_frail', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['damaged'],
|
||||
setup: (b) => b.on('damaged', (c) => { if (c.type === 'element' && c.dmg?.tags?.includes('yanyou')) burn.push({ t: b.time, id: c.target.id }); }),
|
||||
});
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); // ATK 600 (template): no burn burst in the test window
|
||||
const sk = flameRec(h);
|
||||
assert.deepEqual([sk.cooldown, sk.initCooldown, sk.bb.atk_scale, sk.bb.hit_duration, sk.bb.range_radius], [15, 15, 0.6, 20, 1], 'official Skill_2 blackboard');
|
||||
assert.ok(h.runUntil(() => y.skill.active, 25), '祛恶之焰');
|
||||
const t = y.mem.flame.target;
|
||||
assert.ok(t && t.alive, 'locked target');
|
||||
const attacks = y.stats.attacks;
|
||||
for (const e of h.enemies()) e.elem.burn = 0; // the normal attacks' burn so far: keep the gauges unburst
|
||||
const burnBefore = burn.length;
|
||||
h.run(4.05);
|
||||
assert.equal(y.stats.attacks, attacks, 'no normal attack while channelling');
|
||||
assert.equal(y.mem.flame.target, t, 'the lock holds');
|
||||
// 5 ticks (at once, then every second): both enemies (same tile) burn from every flame tick
|
||||
assert.ok(burn.length - burnBefore >= 2 * 5, `burn from the flame ticks (${burn.length - burnBefore})`);
|
||||
assert.ok(y.skill.active);
|
||||
// the locked target dies → the channel ends at once (no retarget), long before its 20 s
|
||||
h.b.dealDamage(null, t, { amount: 1e12, type: 'true' });
|
||||
h.step();
|
||||
assert.equal(y.skill.active, false, 'ends with its target');
|
||||
assert.ok(h.b.time - y.skill.lastStart < sk.bb.hit_duration - 5);
|
||||
assert.equal(y.mem.flame, null);
|
||||
// normal attacks resume; the next flame needs the whole cooldown again
|
||||
const t1 = h.b.time;
|
||||
assert.ok(h.runUntil(() => y.stats.attacks > attacks, 5), 'attacks again');
|
||||
assert.ok(h.runUntil(() => y.skill.activations >= 2, 20));
|
||||
assert.ok(h.b.time - t1 >= sk.cooldown - 0.5, `cooldown after the channel (${(h.b.time - t1).toFixed(2)} s)`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#12 祛恶之焰 lasts at most hit_duration on a surviving target, and silence ends it at once', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 100, hp: 50000 });
|
||||
assert.ok(h.runUntil(() => y.skill.active, 25));
|
||||
const t0 = y.skill.lastStart;
|
||||
assert.ok(h.runUntil(() => !y.skill.active, 25));
|
||||
const len = h.b.time - t0;
|
||||
assert.ok(Math.abs(len - flameRec(h).bb.hit_duration) < 0.1, `20 s channel (${len.toFixed(2)})`);
|
||||
assert.ok(h.runUntil(() => y.skill.active, 20), 'next flame');
|
||||
h.b.applyStatus(y, 'silence', { duration: 3, source: null });
|
||||
h.step();
|
||||
assert.equal(y.skill.active, false, 'silenced: the channel ends');
|
||||
});
|
||||
|
||||
test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element damage, burn = 20 % ATK on normal hits', REAL, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 8] }], autoFinish: false, timeLimit: 60 });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 });
|
||||
// pin it at (10, 4.8): the enemy at (10,6) is 1.2 away, the one at (10,8) 3.2
|
||||
h.b.addBuff(y, { key: 'test:pin', persist: true, flags: { noMove: true } });
|
||||
y.x = 4.8; y.y = 10;
|
||||
h.run(0.5);
|
||||
const [near, far] = h.enemies();
|
||||
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
|
||||
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
|
||||
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
|
||||
// element damage on the dragon is cancelled
|
||||
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
|
||||
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
|
||||
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
|
||||
const before = near.elem.burn;
|
||||
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
|
||||
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
|
||||
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -463,9 +463,9 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
|
||||
assert.equal(ys.length, 2);
|
||||
const y = ys[0];
|
||||
assert.equal(y.motion, 'FLY');
|
||||
approx(y.s.atk, 1500, 1e-9);
|
||||
assert.equal(y.s.maxHp, 6000);
|
||||
assert.equal(y.hp, 6000);
|
||||
approx(y.s.atk, (600 + 1000) * 1.5, 1e-9, 'template 600 + the bond share, ×1.5');
|
||||
assert.equal(y.s.maxHp, 12000 + 6000);
|
||||
assert.equal(y.hp, 18000);
|
||||
approx(y.s.dmgTakenMul, 0.1, 1e-9);
|
||||
assert.equal(y.s.blockCnt, 0);
|
||||
const x0 = y.x, y0 = y.y;
|
||||
|
||||
+9
-3
@@ -416,10 +416,16 @@ test('chess/tokens: talent tokens resolve and every token variant says where it
|
||||
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']);
|
||||
// 夕's skill "cnt" is a charge count, not a token count.
|
||||
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null);
|
||||
// Every placeable (hand) token is produced by the chess's default skill or a talent.
|
||||
for (const t of Object.values(tokens)) if (t.placeable) {
|
||||
for (const v of Object.values(t.variants)) assert.ok(v.sources.some((s) => s !== 'display'), `${t.tokenId}: placeable display-only token`);
|
||||
// Placeable (hand) tokens = the TALENT summons the shop state shows (tokenDisplayType DEFAULT); a skill's summon
|
||||
// ("获得一个医疗无人机" 赫默, "获得一个诅咒娃娃" 巫恋) appears when the skill fires, never at battle start (user
|
||||
// playtest #4 item 11).
|
||||
for (const t of Object.values(tokens)) {
|
||||
if (t.kind !== 'summon') continue;
|
||||
const talent = Object.values(t.variants).some((v) => v.sources.includes('talent') || Object.values(v.bySkill || {}).some((b) => b.sources.includes('talent')));
|
||||
assert.equal(t.placeable, t.displayType === 'DEFAULT' && talent, `${t.tokenId} (${t.name}): placeable`);
|
||||
}
|
||||
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(), ['斯卡蒂的海嗣', '流形', '狼群'].sort());
|
||||
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll']) assert.equal(tokens[id].placeable, false, `${id}: skill summon`);
|
||||
assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
// 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; 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.
|
||||
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail
|
||||
// card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the
|
||||
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
|
||||
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -19,9 +21,10 @@ 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, PHASE } from '../shared/constants.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../server/sim/constants.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE } from '../server/sim/constants.js';
|
||||
import { SUB } from '../server/sim/professions.js';
|
||||
import { BODY_H, PROBE_MARGIN } from '../public/js/render/pick.js';
|
||||
import { ENEMY_REACH } from '../public/js/render/pick.js';
|
||||
import { BAND_TURN_SECONDS } from '../server/match/Match.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';
|
||||
@@ -227,14 +230,8 @@ test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4
|
||||
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/);
|
||||
test('lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => {
|
||||
assert.match(DESIGN, /### 17\.2 Picking and the drag target \(#7\) — superseded by §18\.1/);
|
||||
assert.equal(SPINE_EVICT_DELAY_MS, 1000);
|
||||
assert.equal(SPINE_QUIET_DELAY_MS, 3000);
|
||||
assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/);
|
||||
@@ -251,3 +248,58 @@ test('picking, drag target, lost models, live LP, detail card order, static game
|
||||
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
|
||||
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
|
||||
});
|
||||
|
||||
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
|
||||
// #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes)
|
||||
assert.equal(ENEMY_REACH, 0.6);
|
||||
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(app, /export const DRAG_HOLD_TILES = 0\.45;/);
|
||||
assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)');
|
||||
assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/);
|
||||
assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/);
|
||||
assert.match(DESIGN, /render\/pick\.js/);
|
||||
assert.ok(!/pieceDragOver'\|/.test(DESIGN), 'DESIGN §9: no pieceDragOver event');
|
||||
assert.match(README, /按地上的方格/);
|
||||
for (const [name, text] of [['README', README], ['PLAYING', PLAYING]]) {
|
||||
assert.ok(!/画面上实际画出的干员/.test(text), `${name}: no body picking`);
|
||||
assert.ok(!/模型抬高到手指上方|模型在手指上方/.test(text), `${name}: no touch lift`);
|
||||
}
|
||||
// #3 a single human is untimed outside battles (code + docs)
|
||||
const lone = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 8, fake: true }).start();
|
||||
assert.equal(lone.m.soloUntimed, true);
|
||||
assert.equal(lone.m.publicView().deadline, 0, 'no briefing countdown');
|
||||
lone.m.dispose();
|
||||
assert.match(DESIGN, /solo and any single-human match untimed/);
|
||||
assert.match(META, /solo \/ single human untimed/);
|
||||
assert.match(PLAYING, /只有你一名玩家/);
|
||||
// #4 one countdown: BAND_TURN_SECONDS per turn (code = data = docs)
|
||||
assert.equal(BAND_TURN_SECONDS, 30);
|
||||
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
|
||||
assert.match(DESIGN, /`BAND_TURN_SECONDS` 30 s per turn = m\.public\.deadline/);
|
||||
assert.match(META, /`Match\.BAND_TURN_SECONDS` 30/);
|
||||
assert.match(PLAYING, /\*\*每人 30 秒\*\*/);
|
||||
assert.ok(!/12 s\/turn/.test(DESIGN) && !/`bandTurn` 12/.test(META) && !/每人 12 秒/.test(PLAYING), 'the old 12 s turn is gone');
|
||||
assert.match(META, /`ev\.preview` true/, 'META: onBattleStart handlers must not change the match for the stats preview');
|
||||
// #2 机变 two taps
|
||||
assert.match(PLAYING, /选卡要\*\*点两次\*\*/);
|
||||
assert.match(README, /机变选卡/);
|
||||
// #8 / #9 element gauges (爆发冷却) and knocked-out operators
|
||||
assert.equal(ELEMENT.erosion.ally.duration, 10, 'operators\' 侵蚀 burst has its 10 s cooldown');
|
||||
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
|
||||
assert.deepEqual({ ...DOWN_STATE }, { COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
assert.match(DESIGN, /\*\*爆发冷却\*\*/);
|
||||
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state\]\]`/);
|
||||
assert.match(SIM, /`burstLocked\(unit\)` in damage\.js/);
|
||||
assert.match(SIM, /损伤抵抗 = the target's data `epResistance`/);
|
||||
assert.ok(!/800 phys; no lock/.test(SIM), 'SIM §3: operators\' 侵蚀 locks too');
|
||||
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state\]\]`/);
|
||||
// #5 / #11 / #12 data rules
|
||||
const special = DATA.items.chess_item_4_09_e_a;
|
||||
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
|
||||
assert.match(dataRow('shopExcluded`, `shopExcludedBy'), /isShopItem/);
|
||||
assert.match(PLAYING, /突变细胞只来自昆图斯的策略/);
|
||||
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, false, '赫默\'s drone is no hand piece');
|
||||
assert.match(dataRow('displayType`, `placeable'), /DEFAULT \*\*and\*\* a talent summon/);
|
||||
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
|
||||
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
|
||||
});
|
||||
+11
-1
@@ -74,7 +74,12 @@ async function checkHud(c, where) {
|
||||
if (r.count != null) assert.equal(r.boardChess, r.count, `${tag}: deployCount = chess on board`);
|
||||
if (r.round > 0 && r.round <= r.lastRound) assert.equal(r.domRound, String(r.round), `${tag}: top bar round`);
|
||||
assert.equal(r.teamRows, r.players, `${tag}: one team row per player`);
|
||||
if (r.domLp != null && !['FINAL_ASSAULT', 'HIDDEN_CORE'].includes(r.phase)) assert.equal(r.domLp, String(r.lp), `${tag}: top bar LP`);
|
||||
if (r.domLp != null && ['COMBAT', 'UNITE'].includes(r.phase)) {
|
||||
// live LP (DESIGN §17.5): while the own battle's enemies enter the blue gate the top bar already shows lp − the
|
||||
// pending loss (≤ 10 a round), settled at SETTLE
|
||||
const shown = Number(r.domLp);
|
||||
assert.ok(shown <= r.lp && shown >= Math.max(0, r.lp - 10), `${tag}: top bar LP ${r.domLp} within the round's pending loss of ${r.lp}`);
|
||||
} else if (r.domLp != null && !['FINAL_ASSAULT', 'HIDDEN_CORE'].includes(r.phase)) assert.equal(r.domLp, String(r.lp), `${tag}: top bar LP`);
|
||||
if (r.shopShown) {
|
||||
assert.equal(r.domFunds, String(r.funds), `${tag}: funds card`);
|
||||
assert.equal(r.domRemain, String(Math.max(0, r.cap - r.count)), `${tag}: 剩余可放置角色`);
|
||||
@@ -962,7 +967,9 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
|
||||
for (let round = 1; round <= 3; round++) {
|
||||
const s0 = await c.waitFor((s) => (s.phase === 'PREP' && s.round === round && !s.ready) || (s.phase === 'SP_DRAFT' && s.round === round), `prep ${round}`, 90000);
|
||||
if (s0.phase === 'SP_DRAFT') {
|
||||
// two taps (user playtest #4 item 2): select, then confirm on the selected card
|
||||
await c.click('.spcard.is-pickable');
|
||||
await c.click('.spcard.is-armed');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
|
||||
}
|
||||
await sleep(1800);
|
||||
@@ -1126,6 +1133,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
|
||||
if (!seen.has(`sp-${c.label}-${s.round}`)) { seen.add(`sp-${c.label}-${s.round}`); await sleep(2000); await c.shot(`sp-r${s.round}`); }
|
||||
if (s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
|
||||
await c.click('.spcard.is-pickable');
|
||||
await c.click('.spcard.is-armed');
|
||||
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', '机变 pick', 8000);
|
||||
did.sp += 1;
|
||||
c.note(`机变 picked (round ${s.round})`);
|
||||
@@ -1238,6 +1246,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
|
||||
await sleep(2000);
|
||||
await solo.shot(`sp-r${round}`);
|
||||
await solo.click('.spcard.is-pickable');
|
||||
await solo.click('.spcard.is-armed');
|
||||
await solo.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
|
||||
solo.note(`solo 机变 picked (round ${round})`);
|
||||
}
|
||||
@@ -1314,6 +1323,7 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
|
||||
await ready(c);
|
||||
} else if (s.phase === 'SP_DRAFT' && (await c.exists('.spcard.is-pickable'))) {
|
||||
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 2000 });
|
||||
await c.click('.spcard.is-armed', null, { optional: true, timeout: 2000 });
|
||||
} else if ((s.phase === 'FINAL_ASSAULT' || s.phase === 'HIDDEN_CORE') && !bossShot) {
|
||||
await waitUnits(c, 8000);
|
||||
await sleep(1200);
|
||||
|
||||
@@ -224,20 +224,20 @@ test('a terrain change by content (setDeviceActive at the prep end, no recompute
|
||||
|
||||
test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => {
|
||||
const { m, ps } = prepMatch('act2autochess_m04');
|
||||
// 赫默 → one 医疗探机 (deployLimit 1): once deployed no stack is left in the hand to join
|
||||
const medic = give(m, ps, 'chess_char_2_02_a');
|
||||
// 伺夜 → one 狼群 (deployLimit 1): once deployed no stack is left in the hand to join
|
||||
const medic = give(m, ps, 'chess_char_3_19_a');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
|
||||
const drone = ps.hand.find((p) => p && p.kind === 'token');
|
||||
assert.ok(drone && drone.count === 1, 'one drone');
|
||||
assert.ok(drone && drone.count === 1, 'one 狼群');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
|
||||
// golden items never merge with each other: a plain full hand
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
|
||||
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
|
||||
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
|
||||
assert.equal(ps.board.get('9,4'), drone, 'the drone stays on the board');
|
||||
assert.equal(ps.board.get('9,4'), drone, 'the 狼群 stays on the board');
|
||||
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
|
||||
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
|
||||
// a free slot: the drone comes back
|
||||
// a free slot: the 狼群 comes back
|
||||
ps.hand[3] = null;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
|
||||
assert.equal(ps.hand[3], drone);
|
||||
|
||||
@@ -175,13 +175,13 @@ test('联防: an operator dead at the end of the helper\'s own combat stays out
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const helper = h.ps('p_1');
|
||||
// 赫默 (placeable summon) is knocked out in p_1's own combat; a second operator survives it
|
||||
const t0 = legalTileFor(m, helper, 'chess_char_2_02_a');
|
||||
const hemo = give(m, helper, 'chess_char_2_02_a', 'board', t0);
|
||||
// 伺夜 (placeable talent summon 狼群) is knocked out in p_1's own combat; a second operator survives it
|
||||
const t0 = legalTileFor(m, helper, 'chess_char_3_19_a');
|
||||
const hemo = give(m, helper, 'chess_char_3_19_a', 'board', t0);
|
||||
const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
|
||||
const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other));
|
||||
const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
|
||||
assert.ok(stack, '赫默 brought its summon stack');
|
||||
assert.ok(stack, '伺夜 brought its summon stack');
|
||||
let placed = null;
|
||||
for (const [k] of helper.deployMap()) {
|
||||
const [r, c] = k.split(',').map(Number);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// INFO_CHECK, band draft (order / skip / timeouts / 队友已选) and 机变 SP drafts (order / timers / auto-assign / effects).
|
||||
// INFO_CHECK, band draft (order / skip / one turn clock / timeouts / highlighted band / 队友已选) and 机变 SP drafts (order /
|
||||
// timers / auto-assign / effects); a single human (solo or co-op with AI teammates) is never timed outside battles.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
|
||||
|
||||
const TURN_MS = BAND_TURN_SECONDS * 1000;
|
||||
|
||||
test('INFO_CHECK: ends when every human confirmed (bots/departed count as ready) or at the 25 s deadline', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 1 }).start();
|
||||
@@ -61,16 +65,27 @@ test('band draft (co-op): random order, one pick per turn, NOT_YOUR_TURN, one sk
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns the rest; bots pick by themselves', () => {
|
||||
test('band draft timers (user playtest #4 item 4): one countdown of BAND_TURN_SECONDS per turn → the highlighted band or 华法琳; AI seats pick at once; no step cap', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 2, seed: 9 }).start();
|
||||
const m = h.m;
|
||||
m.handle('p_0', { t: 'g.infoReady' });
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
// humans never act: each human turn times out after 12 s; bots pick within ~1 s
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 });
|
||||
// humans never act: each human turn times out after BAND_TURN_SECONDS; bots pick at once (no bot turn is timed out)
|
||||
const t0 = h.sched.now();
|
||||
const seen = [];
|
||||
h.onBroadcast.push((msg) => { if (msg.t === 'm.public' && msg.phase === PHASE.BAND_DRAFT && msg.draft) seen.push(msg); });
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
|
||||
assert.equal(m.phase, PHASE.BATTLE_CHECK);
|
||||
const took = h.sched.now() - t0;
|
||||
assert.ok(took >= 2 * TURN_MS - 5 && took <= 2 * TURN_MS + 50, `two human turns of ${BAND_TURN_SECONDS} s each (${took} ms)`);
|
||||
// every published draft view: the step's deadline IS the current turn's
|
||||
assert.ok(seen.length > 0);
|
||||
for (const pub of seen) {
|
||||
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
|
||||
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
|
||||
}
|
||||
// a timeout gives 华法琳 while no teammate holds it, else the first free strategy by sortId — never 队友已选
|
||||
// (Match.defaultBand): replay the assignments in draft order
|
||||
const taken = new Set();
|
||||
@@ -83,17 +98,22 @@ test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns
|
||||
}
|
||||
assert.equal(new Set(Object.values(m.draft.picks)).size, 4, 'no duplicate strategies');
|
||||
m.dispose();
|
||||
// overall cap: with 4 humans × 12 s > 50 s the last one is assigned at the cap
|
||||
// 4 humans idle: 4 whole turns (the old 50 s step cap would have cut the last ones short)
|
||||
const h2 = makeMatch({ mode: 'coop', humans: 4, seed: 9 }).start();
|
||||
for (const ps of h2.m.players.values()) h2.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h2.sched.advance(1);
|
||||
const t0 = h2.sched.now();
|
||||
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
|
||||
assert.ok(h2.sched.now() - t0 <= 50000 + 5, `ended by the 50 s cap (${h2.sched.now() - t0} ms)`);
|
||||
const first = h2.m.draft.order[0];
|
||||
assert.equal(h2.ps(first).bandId, 'band_bldsk', 'the first timeout gets 华法琳');
|
||||
assert.deepEqual(h2.m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
|
||||
const s0 = h2.sched.now();
|
||||
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 10 * TURN_MS });
|
||||
const took2 = h2.sched.now() - s0;
|
||||
assert.ok(took2 >= 4 * TURN_MS - 5 && took2 <= 4 * TURN_MS + 50, `four turns (${took2} ms)`);
|
||||
assert.equal(h2.ps(first).bandId, 'band_amiya', 'the first timeout takes the highlighted strategy');
|
||||
assert.equal(h2.ps(h2.m.draft.order[1]).bandId, 'band_bldsk', 'the next one the official default');
|
||||
assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies');
|
||||
h2.m.dispose();
|
||||
// data and code say the same: data/config.json timers.bandTurn (tools/build-data.mjs) = Match.BAND_TURN_SECONDS
|
||||
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
|
||||
});
|
||||
|
||||
test('band draft (solo): free pick, no timer, no skip; mode-restricted bands rejected', () => {
|
||||
@@ -214,3 +234,43 @@ test('机变 tactic defaults: team cards reach teammates; layers / funds / free
|
||||
});
|
||||
|
||||
const awaitImport = await import('../../server/match/choices.js');
|
||||
|
||||
test('a single human (a 同盟 room started alone or with AI teammates only) is never timed outside battles — user playtest #4 item 3', () => {
|
||||
for (const bots of [0, 3]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots, seed: 12, fake: true, instant: false }).start();
|
||||
const m = h.m;
|
||||
assert.equal(m.soloUntimed, true);
|
||||
assert.equal(m.isSolo, false, 'the co-op mode rules stay');
|
||||
const seen = new Map(); // phase → deadlines published
|
||||
const note = (pub) => { if (!seen.has(pub.phase)) seen.set(pub.phase, []); seen.get(pub.phase).push(pub.deadline); };
|
||||
for (const msg of h.bc) if (msg.t === 'm.public') note(msg);
|
||||
h.onBroadcast.push((msg) => { if (msg.t === 'm.public') note(msg); });
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(10 * 60_000);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK, `briefing waits (${bots} AI)`);
|
||||
m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
h.run(() => m.draftTurn() === 'p_0', { maxTime: 1000 });
|
||||
h.sched.advance(10 * 60_000);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT, 'the strategy draft waits');
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'reached PREP R2');
|
||||
h.sched.advance(60 * 60_000);
|
||||
assert.equal(m.phase, PHASE.PREP, 'prep waits');
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 4), 'past the R3 机变');
|
||||
h.flushAll();
|
||||
for (const ph of [PHASE.INFO_CHECK, PHASE.BAND_DRAFT, PHASE.BATTLE_CHECK, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP, PHASE.SETTLE]) {
|
||||
assert.ok(seen.has(ph), `saw ${ph}`);
|
||||
assert.ok(seen.get(ph).every((d) => d === 0), `${ph}: no countdown with a single human (${bots} AI): ${seen.get(ph)}`);
|
||||
}
|
||||
assert.ok(seen.get(PHASE.COMBAT).some((d) => d > 0), 'the battles keep their clock');
|
||||
m.dispose();
|
||||
}
|
||||
// two humans: the co-op timers stay
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
|
||||
assert.equal(h.m.soloUntimed, false);
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.deadline - pub.serverNow, 25000, 'INFO_CHECK 25 s');
|
||||
h.toPrep(1);
|
||||
assert.ok(h.m.deadline > 0, 'co-op prep is timed');
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -411,8 +411,10 @@ test('audited FakeBattle matches (all phases incl. 联防, 机变, Final Assault
|
||||
const h = makeMatch({
|
||||
mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }),
|
||||
}).start();
|
||||
h.autoHumans();
|
||||
});
|
||||
// AI-played before the start: a single human's briefing is untimed (Match.soloUntimed, user playtest #4 item 3) and
|
||||
// waits for 准备就绪, which only an autoplaying seat gives by itself
|
||||
h.autoHumans().start();
|
||||
const audit = attachAudit(h.m);
|
||||
for (const ps of h.m.players.values()) ps.lp = 200;
|
||||
h.runToEnd({ maxSteps: 3e6 });
|
||||
|
||||
@@ -256,7 +256,8 @@ test('solo: no deadline outside combat (INFO_CHECK, strategy draft, 机变, prep
|
||||
});
|
||||
|
||||
test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 12 }).start();
|
||||
// two humans: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
|
||||
const m = h.m;
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.phase, PHASE.INFO_CHECK);
|
||||
@@ -268,13 +269,15 @@ test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines'
|
||||
|
||||
// ---- review fixes -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('a skill summon comes only with that skill: 赫默 S2 (default) gets the 医疗无人机 token, 赫默 on S1 does not', async () => {
|
||||
test('a skill summon is never a hand card (user playtest #4): 赫默 on any skill gets no 医疗无人机 token in the prep phase', async () => {
|
||||
const { give } = await import('./harness.js');
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const DRONE = 'token_10000_silent_healrb';
|
||||
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
|
||||
const tokensIn = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === DRONE);
|
||||
for (const [skill, want] of [[null, 1], [0, 0], [1, 1]]) {
|
||||
// "获得一个医疗无人机": the drone appears in battle when S2 fires (test/content/tokens_devices.test.js), never before
|
||||
const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
|
||||
assert.equal(DATA.tokens[DRONE].placeable, false, 'tokens.json: a skill summon is not placeable');
|
||||
for (const skill of [null, 0, 1]) {
|
||||
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
|
||||
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
|
||||
h.toPrep(1);
|
||||
@@ -283,11 +286,16 @@ test('a skill summon comes only with that skill: 赫默 S2 (default) gets the
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, SILENCE, 'board', [9, 3]);
|
||||
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, 0, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
|
||||
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, 0, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
// a talent summon still is a hand card: 伺夜 → 狼群
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, 'chess_char_3_19_a', 'board', [9, 5]);
|
||||
assert.equal(tokensIn(ps, 'token_10028_vigil_wolf').length, 1, '伺夜 sends its 狼群 to the hand');
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
@@ -320,8 +320,9 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => {
|
||||
for (const onto of ['stack', 'other']) {
|
||||
const { m, ps } = prep(24);
|
||||
const hemo = give(m, ps, 'chess_char_2_02_a');
|
||||
const [r, c] = legalTileFor(m, ps, 'chess_char_2_02_a');
|
||||
// 伺夜: its talent summon 狼群 is a hand card (a skill's summon such as 赫默's drone never is)
|
||||
const hemo = give(m, ps, 'chess_char_3_19_a');
|
||||
const [r, c] = legalTileFor(m, ps, 'chess_char_3_19_a');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true });
|
||||
const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
|
||||
assert.ok(stackIdx >= 0, 'its summon stack went to the hand');
|
||||
|
||||
+42
-1
@@ -88,12 +88,53 @@ test('item slot: tier ≤ level, shop-eligible normal equipment only', () => {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const id = pool.rollItem(rng, level);
|
||||
const it = DATA.items[id];
|
||||
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop, id);
|
||||
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop && !it.shopExcluded, id);
|
||||
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// user playtest #4 item 5: the special 维式重锤 (维多利亚 25-layer reward / 洛洛's 定制品) and 突变细胞 (strategy 昆图斯)
|
||||
// are never sold although the official shop table lists them (tools/build-data.mjs SHOP_EXCLUDED_ITEMS)
|
||||
const EFFECT_ONLY = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a'];
|
||||
|
||||
test('effect-only items are never shop items: not in shopItemsByTier, never in the item slot, the pools or the 机变 supply', () => {
|
||||
for (const id of EFFECT_ONLY) {
|
||||
assert.equal(DATA.items[id].shopExcluded, true, `${id} (${DATA.items[id].name}) marked`);
|
||||
assert.equal(DATA.items[id].hideInShop, false, `${id}: the official table does not hide it`);
|
||||
assert.ok(DATA.items[id.replace(/_a$/, '_b')].shopExcluded, `${id}: the golden too`);
|
||||
}
|
||||
assert.equal(DATA.items.chess_item_1_01_e_a.shopExcluded, false, 'the plain 维式重锤 is sold');
|
||||
const gd = gdOf();
|
||||
const listed = new Set(Object.values(gd.shopItemsByTier).flat());
|
||||
assert.equal(listed.size, 51, '56 normal equipment − 5 effect-only');
|
||||
for (const id of EFFECT_ONLY) assert.ok(!listed.has(id), `${id} not a shop item`);
|
||||
assert.ok(listed.has('chess_item_1_01_e_a'));
|
||||
// the shop item slot at every level
|
||||
const pool = new SharedPool(gd);
|
||||
const rng = createRng(4);
|
||||
for (let level = 1; level <= 6; level++) for (let i = 0; i < 400; i++) assert.ok(!EFFECT_ONLY.includes(pool.rollItem(rng, level)));
|
||||
// every shop-eligible pool (凯瑟琳 / 列装 / 定向投放 / 见者有份) and the plain "random item" roll of effects
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
for (const pid of ['pool_equip_normal', 'pool_equip_shop_1', 'pool_equip_kathe', 'pool_equip_narant']) {
|
||||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ pool: pid, shopLevel: 6 })), pid);
|
||||
}
|
||||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ maxTier: 6 })));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
|
||||
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
|
||||
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
|
||||
assert.deepEqual([...seen].sort(), special, `${pid}: every special hammer can come, nothing else`);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
|
||||
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
|
||||
@@ -404,3 +404,53 @@ test('live leaks: a boss field is not counted (the merged team LP moves through
|
||||
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
|
||||
r2.runner.dispose();
|
||||
});
|
||||
|
||||
test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle on screen — the sim\'s last computed stats, never unit.s (no recompute from the UI)', async () => {
|
||||
const start = realStart(7305);
|
||||
const r = rig();
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
const e = r.runner._entries.get(start.battleId);
|
||||
r.advance(2000);
|
||||
const ally = e.battle.allyUnits.find((u) => u.alive && u.deployed && u.kind === 'op');
|
||||
assert.ok(ally, 'a deployed operator');
|
||||
// a trap on the lazy getter: reading the live stats must not recompute them (it would move the sim's floats)
|
||||
const s = ally._s;
|
||||
Object.defineProperty(ally, 's', { configurable: true, get() { throw new Error('unit.s read by the UI'); } });
|
||||
const got = r.runner.unitStats(ally.id);
|
||||
delete ally.s;
|
||||
assert.ok(got, 'the unit of the battle on screen');
|
||||
assert.equal(got.id, ally.id);
|
||||
assert.equal(got.uid, ally.uid);
|
||||
assert.equal(got.defId, ally.defId);
|
||||
assert.equal(got.atk, Math.round(s.atk));
|
||||
assert.equal(got.maxHp, Math.round(s.maxHp));
|
||||
assert.equal(got.def, Math.round(s.def));
|
||||
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
||||
assert.equal(got.blockCnt, s.blockCnt);
|
||||
assert.equal(got.hp, Math.round(ally.hp));
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
|
||||
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
||||
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
||||
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
||||
// an own board piece's card left open into the battle: its unit by uid and owner
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), ally.id);
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, start.fieldId), ally.id);
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, 'someone_else'), null, 'another player\'s piece');
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
|
||||
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
|
||||
assert.equal(r.runner.unitStats('x'), null);
|
||||
// enemies too, once one is out
|
||||
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50);
|
||||
const foe = e.battle.enemies.find((x) => x.alive);
|
||||
if (foe) {
|
||||
const fs = r.runner.unitStats(foe.id);
|
||||
assert.ok(fs && fs.maxHp > 0 && fs.base.maxHp > 0, 'an enemy\'s live stats');
|
||||
assert.equal(fs.moveSpeed, Math.round((foe._s || foe.base).moveSpeed * 100) / 100);
|
||||
}
|
||||
r.runner.clear();
|
||||
assert.equal(r.runner.unitStats(ally.id), null, 'nothing on screen after the battles were dropped');
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), null);
|
||||
r.runner.dispose();
|
||||
});
|
||||
@@ -201,7 +201,8 @@ test('a bot clears the temp contents it finds at the start of its prep and readi
|
||||
for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]);
|
||||
},
|
||||
});
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 1507, fake: true, registry: reg }).start();
|
||||
// two humans: the prep stays timed (a single human is untimed, Match.soloUntimed — user playtest #4 item 3)
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 1507, fake: true, registry: reg }).start();
|
||||
const audit = attachAudit(h.m);
|
||||
const m = h.m;
|
||||
h.toPrep(2);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Live operator stats (user playtest #4 item 7 — the detail card showed the fixed record numbers): g.unitStats answers
|
||||
// m.unitStats { seq, round, units } with the stats every unit of the player's board starts its next battle with — the
|
||||
// real battle input (equipment, bonds / layers, 特质, band and 机变 effects, the onBattleStart meta) built into a Battle
|
||||
// that is started and read, never stepped (server/match/Match.js unitStats); the shape is shared/protocol.js
|
||||
// unitStatsEntry (also the browser runner's live battle stats, test/match/runner.test.js). The preview changes nothing
|
||||
// of the match.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S, unitStatsEntry, S2C } from '../../shared/protocol.js';
|
||||
import { makeMatch, give, giveItem, chessOfTier, legalTileFor } from './harness.js';
|
||||
|
||||
/** 阿戈尔重刃: ATK +40 % (its own multiplier), attack speed −10 (data/items.json). */
|
||||
const BLADE = 'chess_item_3_07_e_a';
|
||||
/** 双模机械臂: ATK +30 % (a second, different item: both apply — two equal ones would merge into the hand). */
|
||||
const ARM = 'chess_item_5_03_e_a';
|
||||
|
||||
/** Solo PREP R1 with one chess on an empty board carrying 阿戈尔重刃 (equipped through g.equip). */
|
||||
function setup(seed = 21, opts = {}) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...opts }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x));
|
||||
const carrier = give(m, ps, id, 'board', legalTileFor(m, ps, id));
|
||||
const item = giveItem(m, ps, BLADE);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
|
||||
return { h, m, ps, carrier };
|
||||
}
|
||||
|
||||
test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.unitStats is a known push', () => {
|
||||
assert.equal(validateC2S({ t: 'g.unitStats' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.unitStats', seq: 12 }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: -1 }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: 1.5 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus' }), null, 'no band = clear the highlight');
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: null }), null);
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
|
||||
assert.ok(S2C.includes('m.unitStats'));
|
||||
});
|
||||
|
||||
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
|
||||
const u = { id: 3, uid: 44, defId: 'chess_x', hp: 812.6, alive: true, base: { maxHp: 1000, atk: 300, def: 100, res: 10, aspd: 100, bat: 1.2, blockCnt: 2, moveSpeed: 0 } };
|
||||
const e = unitStatsEntry(u, { maxHp: 1250.4, atk: 420.49, def: 90, res: 12.34, interval: 1.0833333, blockCnt: 3, moveSpeed: 0 });
|
||||
assert.deepEqual(e, {
|
||||
id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true,
|
||||
maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0,
|
||||
base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 },
|
||||
});
|
||||
const plain = unitStatsEntry(u);
|
||||
assert.equal(plain.atk, 300, 'no aggregated stats: the base');
|
||||
assert.equal(plain.interval, 1.2);
|
||||
assert.equal(unitStatsEntry(null).maxHp, 0, 'never throws');
|
||||
assert.equal(unitStatsEntry({ base: { bat: 0 } }).interval, null, 'no attack');
|
||||
});
|
||||
|
||||
test('g.unitStats: the board\'s start-of-battle stats (equipment in: ATK ×1.4, a slower attack), the same numbers the battle starts with; seq echoed', () => {
|
||||
const { h, m, ps, carrier } = setup();
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: 7 }), { ok: true });
|
||||
const msg = h.lastTo('p_0', 'm.unitStats');
|
||||
assert.ok(msg, 'answered with a push');
|
||||
assert.equal(msg.seq, 7);
|
||||
assert.equal(msg.round, 1);
|
||||
const boardUids = [...ps.board.values()].map((p) => p.uid).sort((a, b) => a - b);
|
||||
assert.deepEqual(msg.units.map((u) => u.uid).sort((a, b) => a - b), boardUids, 'every board unit, by uid');
|
||||
const u = msg.units.find((x) => x.uid === carrier.uid);
|
||||
assert.ok(u.atk > u.base.atk, `ATK up (${u.base.atk} → ${u.atk})`);
|
||||
assert.ok(Math.abs(u.atk - Math.round(u.base.atk * 1.4)) <= 1, `阿戈尔重刃 multiplies ATK by 1.4 (${u.atk})`);
|
||||
assert.ok(u.interval > u.base.interval, `attack speed −10: a longer interval (${u.base.interval} → ${u.interval})`);
|
||||
assert.equal(u.hp, u.maxHp, 'full HP at the start');
|
||||
assert.equal(u.alive, true);
|
||||
// exactly what the real battle of this board starts with (the same input, onBattleStart meta included)
|
||||
const b = m.newBattle(m._normalOpts(ps));
|
||||
b.start();
|
||||
const bu = b.allyUnits.find((x) => x.uid === carrier.uid);
|
||||
const { id: _a, ...want } = unitStatsEntry(bu, bu.s);
|
||||
const { id: _b, ...have } = u;
|
||||
assert.deepEqual(have, want);
|
||||
// no seq: null; a second request with the same state answers the cached numbers
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats' }), { ok: true });
|
||||
assert.equal(h.lastTo('p_0', 'm.unitStats').seq, null);
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.unitStats').units, msg.units);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('g.unitStats follows the prep state: another item changes the numbers; prep phases only', () => {
|
||||
const { h, m, ps, carrier } = setup(22);
|
||||
m.handle('p_0', { t: 'g.unitStats', seq: 1 });
|
||||
const before = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
|
||||
const item = giveItem(m, ps, ARM);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
|
||||
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
|
||||
m.handle('p_0', { t: 'g.unitStats', seq: 2 });
|
||||
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
|
||||
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * 1.4 * 1.3)) <= 1, `each item its own multiplier: ATK ${before.atk} → ${after.atk}`);
|
||||
// outside the prep phases: WRONG_PHASE, nothing pushed
|
||||
const n = h.allTo('p_0', 'm.unitStats').length;
|
||||
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
|
||||
assert.equal(m.handle('p_0', { t: 'g.unitStats', seq: 3 }).error, ERR.WRONG_PHASE);
|
||||
assert.equal(h.allTo('p_0', 'm.unitStats').length, n);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the preview changes nothing of the match: two identical matches, one asking for the stats all along, stay identical', () => {
|
||||
const run = (ask) => {
|
||||
const { h, m, ps } = setup(23);
|
||||
const snaps = [];
|
||||
for (let r = 1; r <= 3; r++) {
|
||||
if (ask) for (let k = 0; k < 3; k++) assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: k }), { ok: true });
|
||||
snaps.push(JSON.stringify(ps.privateView()));
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === r + 1), `PREP R${r + 1}`);
|
||||
}
|
||||
snaps.push(JSON.stringify(ps.privateView()), JSON.stringify({ ...m.publicView(), serverNow: 0, deadline: 0 }));
|
||||
m.dispose();
|
||||
return snaps;
|
||||
};
|
||||
assert.deepEqual(run(true), run(false));
|
||||
});
|
||||
@@ -164,8 +164,9 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
|
||||
assert.ok(placed, 'piece moved to the board');
|
||||
assert.deepEqual([placed.row, placed.col], [10, 5]);
|
||||
const r2 = await page.evaluate(() => window.__demo.view.pieceScreenRect(window.__demo.scene.state.board[1].uid));
|
||||
await page.mouse.click(r2.left + r2.width / 2, r2.top + r2.height / 2, { button: 'right' });
|
||||
// picking is by tile (user playtest #4 item 1): right-click the unit's own tile
|
||||
const r2 = await page.evaluate(() => { const p = window.__demo.scene.state.board[1]; return window.__demo.view.tileScreen(p.row, p.col); });
|
||||
await page.mouse.click(r2.x, r2.y, { button: 'right' });
|
||||
await wait(150);
|
||||
const log = await page.evaluate(() => window.__demo.log.join('\n'));
|
||||
assert.match(log, /pieceClick .*"detail":true/);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// test/render/downelem.browser.test.js — user playtest #4 items 8 and 9 in headless Chrome through the render demo
|
||||
// (public/dev/render-demo.html, real Spine models): frames fed to the field view like the client runner does (local
|
||||
// feed) with b.snap `down` / `elem` — a knocked-out operator stays on its tile in its held Die pose under a redeploy
|
||||
// ring counting down, then "DP", then comes back with its deploy clip (a press on its tile selects it meanwhile);
|
||||
// operators and enemies show the official element icon beside their bars, refilling over a 爆发冷却; a summon and an
|
||||
// enemy that die still vanish.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/downelem.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/downelem-*.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)';
|
||||
|
||||
/** Page side: a small field fed frame by frame (local feed) — `step(gt, units, extra, ev)` pushes one frame. */
|
||||
function installFeed() {
|
||||
const v = window.__demo.view;
|
||||
const U = (id, side, spine, x, y, extra = {}) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3, ...extra });
|
||||
const units = [
|
||||
U(1, 'ally', 'char_102_texas', 5, 10), U(2, 'ally', 'char_1016_agoat2', 3, 11), U(3, 'ally', 'token_10028_vigil_wolf', 4, 12, { kind: 'token' }),
|
||||
U(4, 'enemy', 'enemy_1000_gopro_2', 8, 10), U(5, 'enemy', 'enemy_1000_gopro_2', 9, 11),
|
||||
];
|
||||
v.enterBattle({ fieldId: 'dn', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
|
||||
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
const tup = (u, hp = 1000, anim = 0) => [u.id, u.x, u.y, hp, 1000, 0, 0, 0, anim];
|
||||
window.__feed = {
|
||||
units,
|
||||
step(gt, list, extra = {}, ev = null) {
|
||||
if (ev && ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'dn', gt, ev });
|
||||
v.pushSnapshot({ t: 'b.snap', fieldId: 'dn', gt, units: list.map((x) => (Array.isArray(x) ? x : tup(x))), dp: 5, killed: 0, total: 5, ...extra });
|
||||
},
|
||||
tup,
|
||||
state() {
|
||||
const out = {};
|
||||
for (const [id, x] of v.debug.views) {
|
||||
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
|
||||
el: x._elRing?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('knocked-out operators and element gauges in 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'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('down ring countdown → DP → redeploy; element icons; summons and enemies still vanish', async () => {
|
||||
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}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
// a recording scene, paused: it feeds nothing, the test drives the view
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
await page.evaluate(installFeed);
|
||||
// phase 1 (gt 1–3): everyone stands; texas2's neighbour 艾雅法拉 carries a burn gauge, an enemy is in a neural cooldown
|
||||
const run = (fn, from, to) => page.evaluate(async (src, from, to) => {
|
||||
const f = window.__feed;
|
||||
const body = new Function('f', 'gt', src);
|
||||
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { body(f, +gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
|
||||
}, fn, from, to);
|
||||
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 1, 3);
|
||||
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
|
||||
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 3, 4);
|
||||
let st = await page.evaluate(() => window.__feed.state());
|
||||
assert.equal(st[2].el, 'burn', 'operator element icon');
|
||||
assert.equal(st[4].el, 'neural', 'enemy element icon (cooldown)');
|
||||
assert.equal(st[1].el, null);
|
||||
// phase 2: texas (1) is knocked out, the wolf summon (3) and an enemy (5) die
|
||||
await page.evaluate(() => {
|
||||
const f = window.__feed, U = f.units;
|
||||
f.step(4.05, [f.tup(U[0], 0, 4), U[1], f.tup(U[2], 0, 4), U[3], f.tup(U[4], 0, 4)], { down: [[1, 24.05, 20, 0]] }, [['die', 1, 'killed'], ['die', 3, 'killed'], ['die', 5, 'killed']]);
|
||||
});
|
||||
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 4.1, 8);
|
||||
await new Promise((r) => setTimeout(r, 1200)); // die clips and fades run out (the last frame stays)
|
||||
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 0]], elem: [[2, 'burn', 0.4, 0, 0]] });`, 8, 9);
|
||||
st = await page.evaluate(() => window.__feed.state());
|
||||
await page.screenshot({ path: path.join(OUT, 'downelem-countdown.png') });
|
||||
assert.equal(st[1].alive, false);
|
||||
assert.equal(st[1].down, 0, 'knocked down');
|
||||
assert.ok(st[1].ring, 'redeploy ring');
|
||||
assert.equal(st[1].label, '16', 'seconds left');
|
||||
assert.equal(st[1].dieClip, 'Die', 'held Die pose');
|
||||
assert.ok(st[1].alpha > 0.8, `the model is drawn (${st[1].alpha})`);
|
||||
assert.equal(st[3], undefined, 'the summon vanished');
|
||||
assert.equal(st[5], undefined, 'the enemy vanished');
|
||||
// a press on its tile selects it like any unit on a tile (picking by tile, user playtest #4 item 1)
|
||||
const picked = await page.evaluate(() => {
|
||||
const v = window.__demo.view, t = v.tileScreen(10, 5), r = v.debug.app.view.getBoundingClientRect();
|
||||
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
|
||||
});
|
||||
assert.equal(picked, 1, 'the knocked-down operator is picked on its tile');
|
||||
// phase 3: the timer is done but the DP is short
|
||||
await run(`const U = f.units; f.step(gt, [U[1], U[3]], { down: [[1, 24.05, 20, 1]] });`, 24.1, 25);
|
||||
st = await page.evaluate(() => window.__feed.state());
|
||||
await page.screenshot({ path: path.join(OUT, 'downelem-waitdp.png') });
|
||||
assert.equal(st[1].label, 'DP');
|
||||
// phase 4: redeploy
|
||||
await page.evaluate(() => { const f = window.__feed, U = f.units; f.step(25.05, [f.tup(U[0], 1000, 6), U[1], U[3]], {}, [['deploy', 1]]); });
|
||||
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[3]]);`, 25.1, 26);
|
||||
st = await page.evaluate(() => window.__feed.state());
|
||||
await page.screenshot({ path: path.join(OUT, 'downelem-redeployed.png') });
|
||||
assert.equal(st[1].alive, true, 'back on its tile');
|
||||
assert.equal(st[1].down, null);
|
||||
assert.equal(st[1].ring, false);
|
||||
assert.equal(st[2].el, null, 'the gauge left the snapshot: icon hidden');
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
|
||||
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
|
||||
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
|
||||
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
|
||||
|
||||
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, DOWN_STATE, DOWN_LOOK } = await import('../../public/js/render/units.js'));
|
||||
T = await import('../../public/js/render/textures.js');
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const tuple = (id, x = 5, y = 10, hp = 1000) => [id, x, y, hp, 1000, 0, 0, 0, 0];
|
||||
|
||||
describe('interp: b.snap `elem` and `down`', () => {
|
||||
test('`elem` entries are appended to their unit tuple and sampled as el / elFill / elUntil / elDur', () => {
|
||||
const s = normalizeSnapshot({ t: 3, units: [tuple(1), tuple(2), tuple(3)], elem: [[1, 'burn', 0.4, 0, 0], [2, 'neural', 1, 12.5, 10], [9, 'burn', 0.5, 0, 0], [3, 'lava', 0.5, 0, 0], 'x'] });
|
||||
assert.deepEqual(s.units.get(1).slice(TUPLE.EL), ['burn', 0.4, 0, 0]);
|
||||
assert.deepEqual(s.units.get(2).slice(TUPLE.EL), ['neural', 1, 12.5, 10]);
|
||||
assert.equal(s.units.get(3).length, 9, 'unknown element dropped');
|
||||
assert.equal(s.down, null);
|
||||
const buf = new SnapshotBuffer({ delay: 0 });
|
||||
buf.push({ t: 1, units: [tuple(1)], elem: [[1, 'apoptosis', 0.2, 0, 0]] }, 0);
|
||||
buf.push({ t: 1.1, units: [tuple(1)] }, 0.05);
|
||||
const out = buf.sample(1.02);
|
||||
assert.equal(out.get(1).el, 'apoptosis');
|
||||
assert.equal(out.get(1).elFill, 0.2);
|
||||
buf.sample(1.1, out);
|
||||
assert.equal(out.get(1).el, null, 'cleared once the gauge is gone');
|
||||
assert.equal(out.get(1).elFill, 0);
|
||||
});
|
||||
|
||||
test('`down` is kept per snapshot; downAt(time) reads the snapshot shown at that time', () => {
|
||||
const buf = new SnapshotBuffer({ delay: 0 });
|
||||
buf.push({ t: 1, units: [] }, 0);
|
||||
buf.push({ t: 1.1, units: [], down: [[7, 20.5, 18, 0], ['bad'], [8, 'x', -3, 1.7]] }, 0.05);
|
||||
buf.push({ t: 1.2, units: [] }, 0.1);
|
||||
assert.equal(buf.downAt(1.05), null);
|
||||
assert.deepEqual(buf.downAt(1.15), [[7, 20.5, 18, 0], ['bad', 0, 0, 0], [8, 0, 0, 1]]);
|
||||
assert.equal(buf.downAt(1.25), null);
|
||||
});
|
||||
});
|
||||
|
||||
function view(info = {}) {
|
||||
const ctx = fakeViewCtx(fake.P, { cam });
|
||||
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
|
||||
}
|
||||
const frames = (v, n, dt = 1 / 60, gameT = null) => { for (let i = 0; i < n; i++) { if (gameT) v.gameT = gameT(i); v.update(dt, cam(), i * dt); } };
|
||||
|
||||
describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
test('stays on its tile greyed under a redeploy ring counting down, then "DP", then redeploys', () => {
|
||||
const v = view();
|
||||
frames(v, 3);
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
|
||||
assert.equal(v.alive, false, 'knocked down');
|
||||
assert.ok(v.down);
|
||||
frames(v, 400, 1 / 60, () => 10); // 6.7 s: a dying view would long be gone
|
||||
assert.equal(v.remove, false, 'never removed while down');
|
||||
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
|
||||
const r = v._downRing;
|
||||
assert.ok(r && r.root.visible, 'ring shown');
|
||||
assert.equal(r.text.text, '20', 'seconds left (game s)');
|
||||
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
|
||||
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
|
||||
frames(v, 1, 1 / 60, () => 25);
|
||||
assert.equal(r.text.text, '5');
|
||||
assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
|
||||
frames(v, 1, 1 / 60, () => 31);
|
||||
assert.equal(r.text.text, 'DP');
|
||||
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
|
||||
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
|
||||
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
|
||||
frames(v, 1);
|
||||
assert.equal(r.text.text, '!');
|
||||
const kids = v.hud.children.length;
|
||||
frames(v, 30);
|
||||
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
|
||||
v.onDeploy(); // the redeploy (b.ev 'deploy')
|
||||
assert.equal(v.alive, true);
|
||||
assert.equal(v.down, null);
|
||||
frames(v, 2);
|
||||
assert.equal(r.root.visible, false, 'ring gone');
|
||||
});
|
||||
|
||||
test('a view made for a unit already down starts on the held end of the Die clip', () => {
|
||||
const v = view();
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true);
|
||||
assert.equal(v.alive, false);
|
||||
assert.ok(v.dieT >= 30);
|
||||
frames(v, 2, 1 / 60, () => 12);
|
||||
assert.ok(v._downRing.root.visible);
|
||||
assert.equal(v._downRing.text.text, '18');
|
||||
});
|
||||
|
||||
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
|
||||
const v = view();
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
|
||||
frames(v, 10);
|
||||
v.setDown(null, 11);
|
||||
frames(v, 60);
|
||||
assert.equal(v.remove, true);
|
||||
const w = view({ id: 2 });
|
||||
frames(w, 2);
|
||||
w.die();
|
||||
frames(w, 200);
|
||||
assert.equal(w.remove, true, 'a summon / enemy death is unchanged');
|
||||
});
|
||||
});
|
||||
|
||||
describe('element gauge ring', () => {
|
||||
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
|
||||
|
||||
test('operators: the element disc with its glyph in a white ring of the remaining 元素值', () => {
|
||||
const v = view();
|
||||
v.sync(sample(), 5);
|
||||
frames(v, 2);
|
||||
assert.equal(v._elRing, null, 'nothing built without a gauge');
|
||||
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
|
||||
frames(v, 1);
|
||||
const r = v._elRing;
|
||||
assert.ok(r.root.visible);
|
||||
assert.equal(r.disc.texture, T.hudRings().disc.burn);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.75), 'remaining EP = 1 − fill');
|
||||
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
|
||||
frames(v, 1);
|
||||
assert.equal(r.disc.texture, T.hudRings().disc.neural);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.4));
|
||||
v.sync(sample(), 6);
|
||||
frames(v, 1);
|
||||
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
|
||||
});
|
||||
|
||||
test('enemies: a smaller plain disc; during a 爆发冷却 the ring refills over the cooldown', () => {
|
||||
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
|
||||
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
|
||||
frames(e, 1);
|
||||
const r = e._elRing;
|
||||
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
|
||||
assert.equal(r.arc.texture, T.ringArc(0), 'empty at the burst');
|
||||
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
|
||||
frames(e, 1);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.5), 'half refilled halfway through');
|
||||
const op = view({ id: 4 });
|
||||
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
|
||||
frames(op, 1);
|
||||
assert.ok(r.root.scale.x < op._elRing.root.scale.x, 'the enemy icon is smaller');
|
||||
});
|
||||
|
||||
test('a burst lock status (爆发冷却) is not repeated in the status row while the element icon shows it', () => {
|
||||
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
|
||||
e.onStatus('burnBurst', true);
|
||||
e.onStatus('fragile', true);
|
||||
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
|
||||
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
|
||||
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element icon carries the 爆发冷却');
|
||||
});
|
||||
|
||||
test('the atlas: one frame per step, every element in both styles', () => {
|
||||
const R = T.hudRings();
|
||||
assert.equal(R.arcs.length, T.RING_STEPS + 1);
|
||||
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
|
||||
assert.equal(T.ringArc(0.001), R.arcs[1], 'a sliver never shows as empty');
|
||||
assert.equal(T.ringArc(2), R.arcs[T.RING_STEPS]);
|
||||
});
|
||||
});
|
||||
+42
-64
@@ -77,7 +77,6 @@ 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]),
|
||||
@@ -218,84 +217,63 @@ 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'));
|
||||
// user playtest #4 item 1: everything is judged at the pointer — the tile under it is the target (tileHover, legality,
|
||||
// the drop) for a mouse and a finger alike (v2.2 lifted a touch drag's target 0.6 tile above the finger)
|
||||
test('the target, tileHover, legality and the drop are the tile under the pointer (mouse and touch)', () => {
|
||||
for (const pointerType of ['mouse', 'touch']) {
|
||||
const h = harness({ canPlace: (p, r) => r !== 11 });
|
||||
h.c.setEditable(true);
|
||||
h.ev('pointerDown', 50, 550, { pointerType });
|
||||
h.ev('pointerMove', 450, 350, { pointerType });
|
||||
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.x, mv.y, mv.target, mv.legal], [450, 350, { area: 'board', row: 9, col: 4 }, true], pointerType);
|
||||
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 9 && e[1].col === 4), `${pointerType}: tileHover = the target`);
|
||||
h.ev('pointerMove', 450, 150, { pointerType });
|
||||
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.target, mv.legal], [{ area: 'board', row: 11, col: 4 }, false], `${pointerType}: row 11 under the pointer is illegal`);
|
||||
h.ev('pointerMove', 450, 350, { pointerType });
|
||||
h.ev('pointerUp', 450, 350, { pointerType });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 9, col: 4 }, pointerType);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 = [];
|
||||
test('presses and hover pick the piece at the pointer (the view\'s hitPiece: the piece on the tile there)', () => {
|
||||
const asked = [];
|
||||
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; },
|
||||
hitPiece: (x, y) => { asked.push([x, y]); 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 });
|
||||
const ev = (type, x, y, pointerType = 'mouse') => c[type]({ pointerId: 1, pointerType, 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');
|
||||
ev('pointerMove', 60, 560);
|
||||
ev('pointerDown', 55, 545, 'touch');
|
||||
assert.deepEqual(asked, [[60, 560], [55, 545]], 'hover and press ask at the pointer itself');
|
||||
ev('pointerMove', 355, 255, 'touch');
|
||||
ev('pointerUp', 355, 255, 'touch');
|
||||
assert.equal(asked.length, 2, 'a drag asks no hit-test');
|
||||
});
|
||||
|
||||
// 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) });
|
||||
// on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it: a release with the finger
|
||||
// on the shop bar is 'outside' (the piece goes back); on the bench slot above it, it lands there
|
||||
test('DOM cover is judged at the pointer: a touch release over the shop bar is outside, on the bench it lands', () => {
|
||||
const h = harness({ domCover: true });
|
||||
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' });
|
||||
h.ev('pointerMove', 250, 550, { pointerType: 'touch' });
|
||||
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.deepEqual([mv.target, mv.legal], [{ area: 'hand', idx: 2 }, true]);
|
||||
h.ev('pointerUp', 250, 550, { 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');
|
||||
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
|
||||
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.equal(mv.target, null, 'the finger on the DOM: no target');
|
||||
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] === null), 'no tile lit');
|
||||
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
|
||||
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'outside', clientX: 250, clientY: 950 });
|
||||
});
|
||||
|
||||
test('a throwing listener or hit-test never breaks the controller', () => {
|
||||
|
||||
@@ -120,9 +120,9 @@ describe('field view features in headless Chrome', { skip }, () => {
|
||||
const clicks = [];
|
||||
const off = v.on('pieceClick', (e) => clicks.push(e));
|
||||
const pv = [...v.debug.penViews.values()].find((p) => !p.culled);
|
||||
const b = pv.bounds();
|
||||
const rect = v.debug.app.view.getBoundingClientRect();
|
||||
out.click = { x: rect.left + b.x + b.width / 2, y: rect.top + b.y + b.height * 0.6, key: pv.info.enemyKey };
|
||||
// picking is by tile (user playtest #4 item 1): a press at the figure's feet, on its own tile
|
||||
out.click = { x: rect.left + pv.screen.x, y: rect.top + pv.screen.y, key: pv.info.enemyKey };
|
||||
window.__clicks = clicks; window.__offClick = off;
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// test/render/flash.browser.test.js — user playtest #4 item 13 in headless Chrome ("屏幕很偶尔的情况下会有个巨大的ui的一部分
|
||||
// 遮挡一两秒": once in a while a huge piece of UI covered the screen for a second or two).
|
||||
// Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi
|
||||
// objects > 25 % of the viewport or showing a whole atlas page, CSS animations scaling ≥ 2): the only unexpected hit
|
||||
// was the bond-layer pop of render/fx.js — the first in-battle layer gain of each bond (b.ev 'layer') popped the bond's
|
||||
// icon, a PIXI.Texture.from(url) that is 1×1 until its image has loaded, and pop() sized the sprite from that: 46× too
|
||||
// big, ~5000 px over the whole screen for the pop's 1.4 s (test/render/fxpop.test.js has the unit test). Here the real
|
||||
// mock battle (public/dev/game-mock.html) gets 'layer' events for bonds whose icons were never loaded on the page.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/flash.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/flash-*.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));
|
||||
|
||||
describe('user playtest #4 item 13: no screen-sized UI flash (mock battle, 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();
|
||||
});
|
||||
|
||||
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
|
||||
test(`${w}×${h}: a bond layer gained in battle pops its icon at ~46 px — also the first time (icon still loading)`, async () => {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
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&phase=COMBAT`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && globalThis.__SP_VIEW__?.raw?.mode === 'battle', { timeout: 30000 });
|
||||
await sleep(1500);
|
||||
const r = await page.evaluate(async () => {
|
||||
const { net } = await import('/js/net.js');
|
||||
const R = globalThis.__SP_VIEW__.raw, S = globalThis.__MOCK__.S();
|
||||
const icons = (await import('/js/assets.js')).assets;
|
||||
// bonds with an icon that no view has turned into a texture yet (the bond strip's <img>s do not count)
|
||||
const bonds = ['yanShip', 'preciShip', 'abyssShip', 'kazimierzShip'].filter((b) => icons.bondIcon?.(b) && !PIXI.utils.TextureCache[icons.bondIcon(b)]);
|
||||
const gt = (await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m.gt); }); })) + 0.05;
|
||||
net._emit('b.ev', { t: 'b.ev', fieldId: S.battle.fieldRef.id, gt, ev: bonds.map((b, i) => ['layer', 'p1', b, i + 1]) });
|
||||
const t0 = performance.now();
|
||||
const icon = (p) => p.c.children.find((c) => c.texture && c.texture !== R.debug.fx.tex.glow);
|
||||
let worst = 0, seen = 0;
|
||||
// every frame of the pops' life (1.4 s): the icon sprite's drawn box
|
||||
while (performance.now() - t0 < 1600) {
|
||||
await new Promise((res) => requestAnimationFrame(res));
|
||||
for (const p of R.debug.fx.pops) {
|
||||
const sp = icon(p);
|
||||
if (!sp || !sp.visible || !(sp.worldAlpha > 0.02)) continue;
|
||||
const b = sp.getBounds();
|
||||
seen = Math.max(seen, R.debug.fx.pops.length);
|
||||
worst = Math.max(worst, b.width, b.height);
|
||||
}
|
||||
}
|
||||
return { bonds, worst: Math.round(worst), seen, loaded: bonds.map((b) => !!PIXI.utils.TextureCache[icons.bondIcon(b)]?.valid) };
|
||||
});
|
||||
await page.screenshot({ path: path.join(OUT, `flash-bond-pop-${w}.png`) });
|
||||
await page.close();
|
||||
assert.ok(r.bonds.length >= 2 && r.seen >= 2, `premise: bond icons popped for the first time (${JSON.stringify(r)})`);
|
||||
assert.ok(r.loaded.every(Boolean), `premise: their images loaded during the pop (${JSON.stringify(r)})`);
|
||||
assert.ok(r.worst > 20 && r.worst < 80, `the icon is ~46 px (× the pop's scale-in), never screen-sized: ${r.worst} px`);
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// test/render/fxflame.test.js — 炎佑's 祛恶之焰 as a continuous jet of fire (render/fx.js _flame; user playtest #4 item 12
|
||||
// "官方是一段持续时间的喷火"). The sim (server/sim/content/tokens.js yanyouKit) emits one fx 'yanyouFlame'
|
||||
// {x, y, id: dragon, target, r, n, dur: 1} per game second of the channel; the renderer keeps ONE jet per dragon that each
|
||||
// tick re-aims and extends, so the ticks join into a stream from the dragon onto its locked target with a burning disc
|
||||
// of radius r around the target. It used to be the generic 'buff' sparkle once per second.
|
||||
// Against the headless fake PIXI (test/render/fakepixi.js), like fxproj.test.js.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
let fake, FX;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const DT = 1 / 120;
|
||||
const cam = presetCamera('normal', { width: 1600, height: 900 });
|
||||
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
|
||||
|
||||
function makeFx(views, ts = 2) {
|
||||
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: 'high', damageNumbers: true },
|
||||
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
return { fx, map };
|
||||
}
|
||||
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
|
||||
/** One sim tick of the channel (the event tokens.js emits). */
|
||||
const tick = (fx, dragon, target, r = 1) => fx.simFx('yanyouFlame', target.x, target.y, { x: target.x, y: target.y, id: dragon.id, target: target.id, r, n: 1, dur: 1 });
|
||||
|
||||
describe('炎佑 祛恶之焰: one continuous jet of fire (user playtest #4 item 12)', () => {
|
||||
test('its own archetype (no longer the one-off buff sparkle)', () => {
|
||||
assert.equal(FX.FX_KINDS.yanyouFlame.a, 'flame');
|
||||
assert.equal(FX.fxSpec('yanyouFlame').a, 'flame');
|
||||
});
|
||||
|
||||
test('the 1 s ticks join into one stream that burns while they come and ends after the last one', () => {
|
||||
const dragon = unit(40, 6.2, 10.1, { hover: 0.32 }), foe = unit(20, 6, 10);
|
||||
const { fx } = makeFx([dragon, foe]);
|
||||
for (let s = 0; s < 6; s++) { // 6 game s of channel at the 2× clock: a tick every 0.5 real s
|
||||
tick(fx, dragon, foe);
|
||||
assert.equal(fx.flames.length, 1, `tick ${s}: still one jet`);
|
||||
run(fx, 0.5);
|
||||
assert.equal(fx.flames.length, 1, `between ticks ${s} and ${s + 1} the jet keeps burning`);
|
||||
const F = fx.flames[0];
|
||||
assert.ok(F.disc.alpha > 0.2 && F.edge.alpha > 0.2, 'the burning disc shows');
|
||||
}
|
||||
assert.ok(fx.counts.particles > 20, 'fire streams from the dragon and rises from the disc');
|
||||
run(fx, 0.3);
|
||||
assert.equal(fx.flames.length, 0, 'no tick came: the jet goes out within FLAME_TAIL');
|
||||
});
|
||||
|
||||
test('the disc follows the locked target; a new tick re-aims the same jet', () => {
|
||||
const dragon = unit(40, 3, 10, { hover: 0.32 }), a = unit(20, 3.2, 10), b = unit(21, 5, 11);
|
||||
const { fx } = makeFx([dragon, a, b]);
|
||||
tick(fx, dragon, a);
|
||||
run(fx, 0.1);
|
||||
const F = fx.flames[0];
|
||||
const at0 = { x: F.disc.position.x, y: F.disc.position.y };
|
||||
a.x = 4.2; // the target walks on: the disc goes with it
|
||||
run(fx, 0.1);
|
||||
assert.ok(F.disc.position.x > at0.x + 20, `disc follows the target (${at0.x} → ${F.disc.position.x})`);
|
||||
tick(fx, dragon, b); // the next tick aims at another enemy: the same jet, re-aimed
|
||||
run(fx, 0.1);
|
||||
assert.equal(fx.flames.length, 1);
|
||||
assert.equal(fx.flames[0], F);
|
||||
assert.equal(F.tgt, 21);
|
||||
assert.equal(F.r, 1);
|
||||
});
|
||||
|
||||
test('the dragon gone (knocked out, left the field) puts its jet out at once; clear() drops every jet', () => {
|
||||
const dragon = unit(40, 3, 10), foe = unit(20, 3.2, 10);
|
||||
const { fx, map } = makeFx([dragon, foe]);
|
||||
tick(fx, dragon, foe);
|
||||
run(fx, 0.05);
|
||||
assert.equal(fx.flames.length, 1);
|
||||
map.delete(40);
|
||||
run(fx, 0.05);
|
||||
assert.equal(fx.flames.length, 0, 'no dragon, no fire');
|
||||
map.set(40, dragon);
|
||||
tick(fx, dragon, foe);
|
||||
const disc = fx.flames[0].disc;
|
||||
fx.clear();
|
||||
assert.equal(fx.flames.length, 0);
|
||||
assert.ok(disc.destroyed, 'its sprites are freed');
|
||||
});
|
||||
|
||||
test('two dragons (9 炎) burn two jets; a target that died keeps the jet on its last spot until it fades', () => {
|
||||
const d1 = unit(40, 3, 10), d2 = unit(41, 3, 10.8), foe = unit(20, 3.2, 10);
|
||||
const { fx, map } = makeFx([d1, d2, foe]);
|
||||
tick(fx, d1, foe); tick(fx, d2, foe);
|
||||
assert.equal(fx.flames.length, 2);
|
||||
run(fx, 0.1);
|
||||
const x = fx.flames[0].x;
|
||||
map.delete(20); // killed
|
||||
run(fx, 0.1);
|
||||
assert.equal(fx.flames[0].x, x, 'stays where the target was');
|
||||
run(fx, 0.6);
|
||||
assert.equal(fx.flames.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// test/render/fxpop.test.js — user playtest #4 item 13 ("屏幕很偶尔的情况下会有个巨大的ui的一部分遮挡一两秒": once in a while a
|
||||
// huge piece of UI covered the screen for a second or two). The first in-battle bond layer gain of each bond (b.ev
|
||||
// 'layer' → render/app.js → FxSystem.pop) popped that bond's icon 46 times too big — ~5000 px, the whole screen, for
|
||||
// the pop's 1.4 s. The icon is PIXI.Texture.from(url): a 1×1 placeholder until its image has loaded (then the real size
|
||||
// and an 'update' event, Pixi 7.4), and pop() sized the sprite from the placeholder (46 / 1). Reproduced in headless
|
||||
// Chrome (test/render/flash.browser.test.js); here against a fake PIXI with the same Texture semantics.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FxSystem } from '../../public/js/render/fx.js';
|
||||
|
||||
class Emitter {
|
||||
constructor() { this._on = new Map(); }
|
||||
on(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: false }); return this; }
|
||||
once(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: true }); return this; }
|
||||
off(e, f) { const l = this._on.get(e); if (l) this._on.set(e, l.filter((x) => x.f !== f)); return this; }
|
||||
emit(e, ...a) { const l = this._on.get(e) || []; this._on.set(e, l.filter((x) => !x.once)); for (const x of l) x.f.apply(x.ctx, a); return true; }
|
||||
}
|
||||
/** Pixi 7.4 Texture of an image: 1×1 and invalid until its base texture has loaded, then its size + 'update'. */
|
||||
class Tex extends Emitter {
|
||||
constructor(w, h, loaded = true) {
|
||||
super();
|
||||
this._size = [w, h];
|
||||
this.baseTexture = { valid: loaded, width: loaded ? w : 1, height: loaded ? h : 1 };
|
||||
this.orig = { width: loaded ? w : 1, height: loaded ? h : 1 };
|
||||
this.valid = loaded;
|
||||
}
|
||||
get width() { return this.orig.width; }
|
||||
get height() { return this.orig.height; }
|
||||
load() {
|
||||
[this.orig.width, this.orig.height] = this._size;
|
||||
Object.assign(this.baseTexture, { valid: true, width: this._size[0], height: this._size[1] });
|
||||
this.valid = true;
|
||||
this.emit('update', this);
|
||||
}
|
||||
}
|
||||
class Point { constructor(x = 1, y = x) { this.x = x; this.y = y; } set(x, y = x) { this.x = x; this.y = y; } }
|
||||
class Sprite {
|
||||
constructor(tex) { this.texture = tex; this.visible = true; this.scale = new Point(1); this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; this.destroyed = false; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
class Container {
|
||||
constructor() { this.children = []; this.position = new Point(0); this.scale = new Point(1); this.alpha = 1; this.destroyed = false; }
|
||||
addChild(...c) { this.children.push(...c); return c[0]; }
|
||||
destroy() { this.destroyed = true; for (const c of this.children) c.destroy?.(); }
|
||||
}
|
||||
class BitmapText { constructor(text) { this.text = text; this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; } destroy() {} }
|
||||
|
||||
function fx() {
|
||||
const f = Object.create(FxSystem.prototype);
|
||||
Object.assign(f, {
|
||||
P: { Sprite, Container, BitmapText, BLEND_MODES: { ADD: 1 } },
|
||||
tex: { glow: new Tex(64, 64) }, pops: [],
|
||||
ctx: { screenSize: () => ({ width: 1920, height: 1080 }), fieldTop: () => 200, layers: { screen: { addChild() {} } } },
|
||||
});
|
||||
return f;
|
||||
}
|
||||
/** Screen px of the icon sprite's longer side as drawn (0 while hidden). */
|
||||
const drawn = (sp) => (sp.visible ? Math.max(sp.texture.width * Math.abs(sp.scale.x), sp.texture.height * Math.abs(sp.scale.y)) : 0);
|
||||
const iconOf = (f, icon) => f.pops.at(-1).c.children.find((c) => c.texture === icon);
|
||||
|
||||
describe('FxSystem.pop: the bond icon is 46 px whenever its image loads (user playtest #4 item 13)', () => {
|
||||
test('an icon still loading is sized when it has loaded — never from its 1×1 placeholder', () => {
|
||||
const f = fx();
|
||||
const icon = new Tex(110, 98, false); // bond/yanShip.png
|
||||
f.pop(icon, '+2', 0xffffff, 0);
|
||||
const sp = iconOf(f, icon);
|
||||
assert.ok(drawn(sp) <= 46.5, `nothing big while loading (${drawn(sp)} px)`);
|
||||
icon.load();
|
||||
assert.ok(Math.abs(drawn(sp) - 46) < 0.5, `46 px once loaded (was 110 × 46 = 5060 px): ${drawn(sp)}`);
|
||||
f._updatePops(0.5);
|
||||
assert.ok(drawn(sp) * f.pops[0].c.scale.x < 60, 'with the pop\'s own scale-in');
|
||||
});
|
||||
|
||||
test('an icon already loaded: 46 px along its longer side at once', () => {
|
||||
const f = fx();
|
||||
const icon = new Tex(102, 100);
|
||||
f.pop(icon, '+1', 0xffffff, 1);
|
||||
assert.ok(Math.abs(drawn(iconOf(f, icon)) - 46) < 0.5);
|
||||
});
|
||||
|
||||
test('a pop that ended before its image loaded stays gone', () => {
|
||||
const f = fx();
|
||||
const icon = new Tex(110, 98, false);
|
||||
f.pop(icon, '+3', 0xffffff, 0);
|
||||
const sp = iconOf(f, icon);
|
||||
f._updatePops(2);
|
||||
assert.equal(f.pops.length, 0);
|
||||
assert.doesNotThrow(() => icon.load());
|
||||
assert.ok(sp.destroyed);
|
||||
});
|
||||
});
|
||||
+257
-149
@@ -1,16 +1,19 @@
|
||||
// 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).
|
||||
// test/render/models.browser.test.js — user playtest #3 item 1 and user playtest #4 item 1 in headless Chrome, through the
|
||||
// in-match mock (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
|
||||
// #3.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).
|
||||
// #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
|
||||
// (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
|
||||
// nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
|
||||
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn
|
||||
// feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its
|
||||
// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit
|
||||
// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one
|
||||
// over the shop bar goes back.
|
||||
//
|
||||
// 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.
|
||||
@@ -169,7 +172,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
|
||||
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 from = await page.evaluate((u) => window.__t.at('p:' + u, 0.1), p.uid); // on its own tile
|
||||
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);
|
||||
@@ -184,177 +187,282 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
|
||||
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);
|
||||
/** Client point on a board-space tile's top face: (u, v) ∈ [−1, 1] from its centre towards its left / right and near /
|
||||
* far edges (view.tileScreen poly: far-left, far-right, near-right, near-left). */
|
||||
const tileSpot = (page, row, col, u = 0, v = 0) => page.evaluate((r, c, u, v) => {
|
||||
const t = globalThis.__SP_VIEW__.raw.tileScreen(r, c);
|
||||
if (!t) return null;
|
||||
const [fl, fr, nr, nl] = t.poly, a = (u + 1) / 2, b = (v + 1) / 2;
|
||||
const near = [nl[0] + (nr[0] - nl[0]) * a, nl[1] + (nr[1] - nl[1]) * a], far = [fl[0] + (fr[0] - fl[0]) * a, fl[1] + (fr[1] - fl[1]) * a];
|
||||
return { x: near[0] + (far[0] - near[0]) * b, y: near[1] + (far[1] - near[1]) * b };
|
||||
}, row, col, u, v);
|
||||
/** The render engine's own pick at a client point (render/pick.js through app.js pieceAt / battleUnitAt). */
|
||||
const pickAt = (page, pt, battle = false) => page.evaluate((x, y, b) => {
|
||||
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect();
|
||||
if (!b) return R.debug.pick.pieceAt(x - cv.left, y - cv.top)?.uid ?? null;
|
||||
return R.debug.pick.battleUnitAt(x - cv.left, y - cv.top)?.id ?? null;
|
||||
}, pt.x, pt.y, battle);
|
||||
const listenClicks = (page) => page.evaluate(() => {
|
||||
window.__picked = [];
|
||||
window.__offPick?.();
|
||||
window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.unitId ?? e.uid ?? null));
|
||||
});
|
||||
const clickAt = async (page, pt) => {
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await sleep(90);
|
||||
const got = await page.evaluate(() => window.__picked.splice(0));
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(40);
|
||||
return got;
|
||||
};
|
||||
// spots of a tile's top face that show on screen: its centre, 0.3 tile out to the sides and 0.4 to the far edge, 0.15
|
||||
// towards the near edge (a raised tile in front hides the strip of this one just behind it: a 0.3-high block the
|
||||
// nearest 0.17 tile, the 0.55 separator wall in front of the bench 0.22 — the press there is on the block's top)
|
||||
const SPOTS = [[0, 0], [-0.6, 0], [0.6, 0], [0, -0.3], [0, 0.8], [-0.6, 0.6], [0.6, 0.6], [-0.6, -0.3], [0.6, -0.3]];
|
||||
|
||||
test('#4.1 prep: a press anywhere on a tile selects the unit on it — board, high ground, bench, temp; an empty tile nothing', async () => {
|
||||
const { page, problems } = await open('phase=PREP&variant=temp');
|
||||
const pieces = await page.evaluate(() => window.__t.pieces());
|
||||
const on = (r, c) => pieces.find((p) => p.row === r && p.col === c)?.uid ?? null;
|
||||
assert.ok(on(9, 3) && on(9, 4) && on(9, 5) && on(10, 4) && on(11, 4), 'premise: neighbours in a row (9, 3–5) and a column (9–11, 4; rows 10–12 high ground)');
|
||||
assert.ok(pieces.filter((p) => p.row === 7).length >= 5 && pieces.filter((p) => p.row === 8).length >= 2, 'premise: bench and temp pieces');
|
||||
await listenClicks(page);
|
||||
for (const p of pieces) {
|
||||
for (const [u, v] of SPOTS) {
|
||||
const pt = await tileSpot(page, p.row, p.col, u, v);
|
||||
assert.equal(await pickAt(page, pt), p.uid, `${p.kind} ${p.uid} on (${p.row},${p.col}) at (${u}, ${v})`);
|
||||
}
|
||||
// the whole press path (canvas pointer events → the drag controller → pieceClick) at the centre and a corner
|
||||
for (const [u, v] of [[0, 0], [0.6, 0.6]]) {
|
||||
const got = await clickAt(page, await tileSpot(page, p.row, p.col, u, v));
|
||||
assert.deepEqual(got.slice(-1), [p.uid], `click on (${p.row},${p.col}) at (${u}, ${v})`);
|
||||
}
|
||||
};
|
||||
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})`);
|
||||
}
|
||||
// a press on a unit's head, drawn over the tile behind it: the unit on that tile — or nothing when it is empty
|
||||
const head = (uid) => page.evaluate((u) => window.__t.at('p:' + u, 1.0), uid);
|
||||
assert.equal(await pickAt(page, await head(on(9, 4))), on(10, 4), 'the head of (9,4) is drawn over (10,4)');
|
||||
assert.equal(on(10, 3), null, 'premise: (10,3) is a forbidden block');
|
||||
assert.equal(await pickAt(page, await head(on(9, 3))), null, 'the head of (9,3) over the empty (10,3)');
|
||||
assert.deepEqual(await clickAt(page, await head(on(9, 3))), [], 'no pieceClick');
|
||||
// the nearest strip of (9,3) is hidden by the forbidden block (8,3) in front of it: a press there is on that block
|
||||
const hidden = await tileSpot(page, 9, 3, 0, -0.9);
|
||||
assert.deepEqual(await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), g = R.debug.pick.groundTile(x - cv.left, y - cv.top); return g && [g.row, g.col]; }, hidden.x, hidden.y), [8, 3]);
|
||||
assert.equal(await pickAt(page, hidden), null);
|
||||
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
|
||||
// battle: the same presses on the battle views (ids → piece uids)
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('#4.1 battle: allies by the tile under the pointer, enemies by their ground position or drawn body', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
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 sleep(6000); // several enemies spawned (one every 0.9 s)
|
||||
// freeze the mock battle (one last snapshot): the enemies stand still for the presses
|
||||
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; }) });
|
||||
net._emit('b.snap', { ...last, gt: last.gt + 0.2 });
|
||||
});
|
||||
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 sleep(800);
|
||||
const units = await page.evaluate(() => {
|
||||
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), out = [];
|
||||
for (const [id, v] of R.debug.views) {
|
||||
if (!v.info || !v.alive || v.culled || v.info.kind === 'device') continue;
|
||||
const g = R.debug.cam.project(v.x, v.y, v.z);
|
||||
out.push({ id, side: v.info.side, x: v.x, y: v.y, row: Math.round(v.y), col: Math.round(v.x), fly: !!v.flying, feet: { x: cv.left + g.x, y: cv.top + g.y },
|
||||
body: { x: cv.left + v.screen.x, top: cv.top + v.screen.top, feet: cv.top + v.screen.y, s: v.screen.s } });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const allies = units.filter((u) => u.side === 'ally'), foes = units.filter((u) => u.side === 'enemy' && !u.fly);
|
||||
assert.ok(allies.length >= 7 && foes.length >= 3, `premise: allies ${allies.length}, walking enemies ${foes.length}`);
|
||||
await listenClicks(page);
|
||||
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
/** Tiles between a client point and an enemy's drawn body (the line from its feet up to its head). */
|
||||
const bodyDist = (b, pt) => Math.hypot(pt.x - b.x, pt.y - Math.max(b.top, Math.min(b.feet, pt.y))) / b.s;
|
||||
// the rule (render/pick.js), from the spot's own ground coordinates and client point: the ally on the tile, unless
|
||||
// an enemy's ground position or drawn body is nearer (within 0.6 tile)
|
||||
const expected = (a, g, pt) => {
|
||||
let best = a, bd = near(a, g);
|
||||
for (const f of foes) { const d = Math.min(near(f, g), bodyDist(f.body, pt)); if (d <= 0.6 && d < bd - 1e-6) { best = f; bd = d; } }
|
||||
return best.id;
|
||||
};
|
||||
let byEnemy = 0;
|
||||
for (const a of allies) {
|
||||
for (const [u, v] of SPOTS) {
|
||||
const pt = await tileSpot(page, a.row, a.col, u, v);
|
||||
const g = await page.evaluate((x, y) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(); return R.debug.pick.groundTile(x - cv.left, y - cv.top); }, pt.x, pt.y);
|
||||
const want = expected(a, g, pt);
|
||||
if (want !== a.id) byEnemy++;
|
||||
assert.equal(await pickAt(page, pt, true), want, `ally ${a.id} on (${a.row},${a.col}) at (${u}, ${v})`);
|
||||
}
|
||||
if (expected(a, { x: a.col, y: a.row }, await tileSpot(page, a.row, a.col)) === a.id) assert.deepEqual((await clickAt(page, await tileSpot(page, a.row, a.col))).slice(-1), [a.id], `click on ally ${a.id}`);
|
||||
}
|
||||
assert.ok(byEnemy < allies.length * SPOTS.length / 3, `most spots of the allies' tiles are the allies' (${byEnemy} went to an enemy standing nearer)`);
|
||||
for (const f of foes) {
|
||||
// at its feet, and on its drawn torso / head (a tall enemy's body is drawn over the ground a tile behind it)
|
||||
for (const [k, pt] of [['feet', f.feet], ['torso', { x: f.body.x, y: (f.body.top + f.body.feet) / 2 }], ['head', { x: f.body.x, y: f.body.top + 0.1 * f.body.s }]]) {
|
||||
const got = await pickAt(page, pt, true);
|
||||
const other = units.find((u) => u.id === got);
|
||||
assert.ok(got === f.id || (other && other.side === 'enemy' && near(other, f) < 0.3), `enemy ${f.id} at its ${k} (got ${got})`);
|
||||
}
|
||||
}
|
||||
const f = foes.find((x) => !foes.some((y) => y !== x && near(x, y) < 0.3)) || foes[0];
|
||||
const got = await clickAt(page, f.feet);
|
||||
assert.ok(got.length === 1 && units.find((u) => u.id === got[0])?.side === 'enemy', `a click at an enemy's feet selects an enemy (${got})`);
|
||||
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, []);
|
||||
});
|
||||
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
|
||||
const ghostAt = (page, uid) => page.evaluate((u) => {
|
||||
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
|
||||
const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
|
||||
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } };
|
||||
}, uid);
|
||||
/** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
|
||||
const assertHeld = (g, ptr, label) => {
|
||||
assert.ok(g.lift > 0, `${label}: lifted`);
|
||||
assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
|
||||
if (g.model) {
|
||||
const m = g.model, k = (ptr.y - m.y0) / (m.y1 - m.y0);
|
||||
assert.ok(ptr.x > m.x0 && ptr.x < m.x1 && k > 0.2 && k < 0.8, `${label}: the pointer on the drawn model (${JSON.stringify(m)}, k ${k.toFixed(2)})`);
|
||||
}
|
||||
};
|
||||
|
||||
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
|
||||
test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s 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
|
||||
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
|
||||
await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
|
||||
const from = await tileSpot(page, 7, hand.idx);
|
||||
const to = await tileSpot(page, 9, 4, 0.3, 0.4); // an occupied melee tile (a swap), off its centre
|
||||
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); }
|
||||
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); 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})`);
|
||||
assertHeld(await ghostAt(page, hand.uid), to, 'mouse');
|
||||
assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
|
||||
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
|
||||
// the whole way: the model stays under the pointer (sampled at every step of a second drag leg)
|
||||
const leg = await tileSpot(page, 10, 5);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
|
||||
await page.mouse.move(pt.x, pt.y);
|
||||
await sleep(40);
|
||||
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
|
||||
}
|
||||
await page.mouse.move(to.x, to.y);
|
||||
await sleep(60);
|
||||
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');
|
||||
const c = await tileSpot(page, 9, 4);
|
||||
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, `the wheel opens on the pointer's tile (${w.x},${w.y} vs ${c.x},${c.y})`);
|
||||
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 () => {
|
||||
test('#4.1 equipment dropped on a tile equips the unit on it (the one behind, a bench operator); an empty tile takes nothing', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const S = () => page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; return { board: p.board.map((x) => ({ uid: x.uid, row: x.row, col: x.col, items: x.items.map((i) => i.uid) })), hand: p.hand.map((x) => x && { uid: x.uid, kind: x.kind, items: (x.items || []).map((i) => i.uid) }) }; });
|
||||
// three items in the hand (slots 0, 1, 4 are free / an item in the mock)
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((M) => {
|
||||
const src = M.priv.hand.find((x) => x && x.kind === 'item');
|
||||
for (const i of [0, 1]) if (!M.priv.hand[i]) M.priv.hand[i] = { ...src, uid: 900 + i };
|
||||
}));
|
||||
await sleep(800);
|
||||
let st = await S();
|
||||
const items = st.hand.map((x, i) => x && x.kind === 'item' && { uid: x.uid, idx: i }).filter(Boolean);
|
||||
const target = st.board.find((b) => b.row === 10 && b.col === 4), front = st.board.find((b) => b.row === 9 && b.col === 4);
|
||||
const benchIdx = st.hand.findIndex((x) => x && x.kind === 'chess');
|
||||
assert.ok(items.length >= 3 && target && front && benchIdx >= 0, 'premise');
|
||||
const dragTo = async (it, to) => {
|
||||
const from = await tileSpot(page, 7, it.idx);
|
||||
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);
|
||||
await page.mouse.up();
|
||||
await sleep(700);
|
||||
};
|
||||
// (10,4) behind (9,4): the tile of the one behind — near its front edge, where the front one's head is drawn
|
||||
await dragTo(items[0], await tileSpot(page, 10, 4, 0, -0.6));
|
||||
st = await S();
|
||||
assert.ok(st.board.find((b) => b.uid === target.uid).items.includes(items[0].uid), 'equipped on the unit behind');
|
||||
assert.ok(!st.board.find((b) => b.uid === front.uid).items.includes(items[0].uid), 'not on the one in front');
|
||||
await dragTo(items[1], await tileSpot(page, 7, benchIdx, 0.4, 0.4));
|
||||
st = await S();
|
||||
assert.ok(st.hand[benchIdx].items.includes(items[1].uid), 'equipped on the bench operator');
|
||||
// the empty high-ground tile behind (12,4)? use a free deploy tile: the item stays in the hand
|
||||
const free = await page.evaluate(() => { const S = globalThis.__MOCK__.S(), used = new Set(S.priv.board.map((b) => `${b.row},${b.col}`)); return S.stage.deployTiles.normal.melee.find(([r, c]) => !used.has(`${r},${c}`)); });
|
||||
await dragTo(items[2], await tileSpot(page, free[0], free[1]));
|
||||
st = await S();
|
||||
assert.ok(st.hand.some((x) => x && x.uid === items[2].uid), 'an empty tile takes nothing: the item is back in the hand');
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
// a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the
|
||||
// bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar
|
||||
// goes back
|
||||
test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', 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(installHelpers);
|
||||
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');
|
||||
const touchDrag = async (from, to, { hold = null } = {}) => {
|
||||
await page.touchscreen.touchStart(from.x, from.y);
|
||||
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(100);
|
||||
if (hold) await hold();
|
||||
await page.touchscreen.touchEnd();
|
||||
await sleep(800);
|
||||
};
|
||||
// a tap on a unit's tile selects it — and only that: the tap's compatibility click used to land on the underframe
|
||||
// that had just opened over the tile (clamped under the top bar for rows 11–12) and pressed 撤退 (the unit left)
|
||||
for (const row of [9, 10, 11, 12]) {
|
||||
const u = await page.evaluate((r) => { const b = globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === 4); return b && { uid: b.uid, n: globalThis.__MOCK__.S().requests.length }; }, row);
|
||||
assert.ok(u, `premise: a unit on (${row},4)`);
|
||||
const t = await tileSpot(page, row, 4);
|
||||
await page.touchscreen.tap(t.x, t.y);
|
||||
await sleep(600);
|
||||
const s = await page.evaluate((u) => ({ onBoard: globalThis.__MOCK__.S().priv.board.some((p) => p.uid === u.uid), uframe: !!document.querySelector(`.uframe[data-uid="${u.uid}"]`), sent: globalThis.__MOCK__.S().requests.slice(u.n).map((r) => r[0]).filter((q) => q !== 'g.unitStats') }), u);
|
||||
assert.deepEqual(s, { onBoard: true, uframe: true, sent: [] }, `a tap on (${row},4) selects its unit, nothing else`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
}
|
||||
// a bench operator onto the board: held under the finger, the wheel on the finger's tile
|
||||
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
|
||||
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
|
||||
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') });
|
||||
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 }; });
|
||||
const c = await tileSpot(page, 9, 4);
|
||||
assert.ok(Math.hypot(w.x - c.x, w.y - c.y) < 2, 'the wheel opens on the finger\'s tile');
|
||||
await page.evaluate(() => document.querySelector('.fwheel__cancel')?.click());
|
||||
await sleep(500);
|
||||
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
|
||||
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
|
||||
const slot = await tileSpot(page, 7, 5);
|
||||
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
|
||||
const bar = await page.evaluate((p) => {
|
||||
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
|
||||
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
|
||||
return null;
|
||||
}, slot);
|
||||
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
|
||||
await touchDrag(await tileSpot(page, unit.row, unit.col), bar);
|
||||
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
|
||||
await touchDrag(await tileSpot(page, unit.row, unit.col), slot);
|
||||
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
|
||||
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
+130
-177
@@ -1,206 +1,159 @@
|
||||
// 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).
|
||||
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path (user
|
||||
// playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行" — a press on a tile selects the unit on
|
||||
// that tile). Checked on the geometry of the official cameras (render/projection.js presetCamera + pickTile: prep and
|
||||
// battle at 1920×1080 and 1280×720, raised tops first): units in adjacent rows and columns, on raised high ground, on
|
||||
// the bench (row 7) and the temporary bench (row 8); and the battle rule for walking / flying enemies (ground position,
|
||||
// drawn body).
|
||||
|
||||
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 { presetCamera, pickTile } from '../../public/js/render/projection.js';
|
||||
import { pickOnTile, pickBattle, ENEMY_REACH } 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 };
|
||||
}
|
||||
import { TILE_H } from '../../public/js/render/style.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
|
||||
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
|
||||
|
||||
describe('pickBody on the official cameras: B directly behind A', () => {
|
||||
/** A board on camera `kind`: tile heights (`raised` = [[r, c, z]…]), units standing on tiles, the ground under a point. */
|
||||
function board(kind, W, H, raised = []) {
|
||||
const cam = presetCamera(kind, { width: W, height: H });
|
||||
const hz = new Map(raised.map(([r, c, z]) => [`${r},${c}`, z]));
|
||||
const heightAt = (r, c) => hz.get(`${r},${c}`) || 0;
|
||||
const levels = [...new Set([0, ...raised.map((x) => x[2])])];
|
||||
const ground = (x, y) => { const t = pickTile(cam, x, y, heightAt, levels); return t && { row: t.row, col: t.col, x: t.x, y: t.y }; };
|
||||
const unit = (key, row, col) => ({ key, tile: { row, col }, x: col, y: row, depth: unitDepthKey(cam, col, row) });
|
||||
/** Screen point of a spot on the top face of tile (row, col): (u, v) ∈ [−0.5, 0.5] across / along the tile. */
|
||||
const spot = (row, col, u = 0, v = 0) => cam.project(col + u, row + v, heightAt(row, col));
|
||||
return { cam, heightAt, ground, unit, spot };
|
||||
}
|
||||
|
||||
/** Spots inside a tile's top face: the centre and near its four edges and corners (0.4 of a tile out). */
|
||||
const SPOTS = [[0, 0], [-0.4, 0], [0.4, 0], [0, -0.4], [0, 0.4], [-0.4, -0.4], [0.4, 0.4], [-0.4, 0.4], [0.4, -0.4]];
|
||||
|
||||
describe('pickOnTile: a press on a tile selects the unit on that tile (official cameras)', () => {
|
||||
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}: a 3×3 block of units, the back row on high ground — every spot of a tile is its unit's`, () => {
|
||||
// rows 9–11 × cols 3–5; row 11 raised like the ranged high ground (TILE_H.wall)
|
||||
const B = board(kind, W, H, [[11, 3, TILE_H.wall], [11, 4, TILE_H.wall], [11, 5, TILE_H.wall]]);
|
||||
const units = [];
|
||||
for (let r = 9; r <= 11; r++) for (let c = 3; c <= 5; c++) units.push(B.unit(`${r},${c}`, r, c));
|
||||
for (const u of units) {
|
||||
for (const [du, dv] of SPOTS) {
|
||||
const p = B.spot(u.tile.row, u.tile.col, du, dv);
|
||||
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `tile ${u.key} at (${du}, ${dv})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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(`${kind} ${W}×${H}: a press on a unit's head (drawn over the tile behind) is a press on that tile`, () => {
|
||||
const B = board(kind, W, H);
|
||||
const front = B.unit('front', 9, 4), behind = B.unit('behind', 10, 4);
|
||||
const p = B.spot(9, 4);
|
||||
const head = { x: p.x, y: p.y - 1.0 * p.s }; // a chibi is ~1.27 tiles tall, rows only ~0.85 tile apart on screen
|
||||
assert.deepEqual([B.ground(head.x, head.y).row, B.ground(head.x, head.y).col], [10, 4], 'premise: the head is over row 10');
|
||||
assert.equal(pickOnTile([front, behind], B.ground(head.x, head.y)).key, 'behind');
|
||||
assert.equal(pickOnTile([front], B.ground(head.x, head.y)), null, 'an empty tile selects nothing');
|
||||
const feet = B.spot(10, 4);
|
||||
assert.equal(pickOnTile([front, behind], B.ground(feet.x, feet.y)).key, 'behind', 'its own tile, where the front one\'s head is drawn');
|
||||
});
|
||||
}
|
||||
|
||||
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`);
|
||||
test('the bench (row 7) and the temporary bench (row 8): raised pads, one piece per slot', () => {
|
||||
const pads = [];
|
||||
for (let c = 0; c < GEO.HAND_SIZE; c++) pads.push([GEO.HAND_ROW, c, TILE_H.bench]);
|
||||
for (let i = 0; i < GEO.TEMP_SIZE; i++) pads.push([GEO.TEMP_ROW, GEO.TEMP_C0 + i, TILE_H.bench]);
|
||||
for (const [W, H] of [[1920, 1080], [1280, 720], [844, 390]]) {
|
||||
const B = board('prep', W, H, pads);
|
||||
const units = pads.map(([r, c]) => B.unit(`${r},${c}`, r, c));
|
||||
// a board unit right in front of the temp row and behind nothing
|
||||
units.push(B.unit('9,5', 9, 5));
|
||||
for (const u of units) {
|
||||
for (const [du, dv] of SPOTS) {
|
||||
const p = B.spot(u.tile.row, u.tile.col, du * 0.9, dv * 0.9);
|
||||
assert.equal(pickOnTile(units, B.ground(p.x, p.y))?.key, u.key, `${W}×${H} slot ${u.key} at (${du}, ${dv})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('several on one tile (pen clusters, a held piece): the nearest to the pointer\'s point, ties the front-most', () => {
|
||||
const t = { row: 15, col: 9 };
|
||||
const a = { key: 'a', tile: t, x: 8.8, y: 15.15, depth: 1 }, b = { key: 'b', tile: t, x: 9.2, y: 15.15, depth: 1 }, c = { key: 'c', tile: t, x: 9, y: 14.8, depth: 2 };
|
||||
assert.equal(pickOnTile([a, b, c], { ...t, x: 8.75, y: 15.2 }).key, 'a');
|
||||
assert.equal(pickOnTile([a, b, c], { ...t, x: 9.3, y: 15.1 }).key, 'b');
|
||||
assert.equal(pickOnTile([a, b, c], { ...t, x: 9, y: 14.7 }).key, 'c');
|
||||
const d = { key: 'd', tile: t, x: 9, y: 15, depth: 5 }, e = { key: 'e', tile: t, x: 9, y: 15, depth: 3 };
|
||||
assert.equal(pickOnTile([e, d], { ...t, x: 9.1, y: 15 }).key, 'd', 'same spot: the front-most');
|
||||
assert.equal(pickOnTile([e, d], t).key, 'd', 'no point on the tile: the front-most');
|
||||
});
|
||||
|
||||
test('bad input: nothing', () => {
|
||||
const u = { tile: { row: 1, col: 1 }, x: 1, y: 1 };
|
||||
assert.equal(pickOnTile([u], null), null);
|
||||
assert.equal(pickOnTile([u], { row: 1.5, col: 1 }), null);
|
||||
assert.equal(pickOnTile(null, { row: 1, col: 1 }), null);
|
||||
assert.equal(pickOnTile([null, { x: 1, y: 1 }, { tile: null, x: 1, y: 1 }], { row: 1, col: 1 }), null);
|
||||
assert.equal(pickOnTile([u], { row: 1, col: 2 }), null);
|
||||
});
|
||||
});
|
||||
|
||||
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 }; };
|
||||
describe('pickBattle: allies by their tile, walking enemies by their ground position or drawn body, flying ones by their body', () => {
|
||||
const ally = (key, row, col, depth = 0) => ({ key, tile: { row, col }, x: col, y: row, depth });
|
||||
const foe = (key, x, y, depth = 0) => ({ key, tile: null, x, y, depth });
|
||||
const at = (x, y) => ({ row: Math.round(y), col: Math.round(x), x, y });
|
||||
|
||||
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('an ally on the tile under the pointer; an empty tile with no enemy near: nothing', () => {
|
||||
const A = ally('A', 10, 4);
|
||||
assert.equal(pickBattle([A], at(4.3, 10.4), 0, 0).key, 'A');
|
||||
assert.equal(pickBattle([A], at(4.3, 10.6), 0, 0), null, 'the tile behind');
|
||||
assert.equal(pickBattle([A], null, 0, 0), null, 'off the grid');
|
||||
});
|
||||
|
||||
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(`an enemy within ${ENEMY_REACH} tile of the pointer's ground point, the nearest of several`, () => {
|
||||
const e1 = foe('e1', 6.3, 12.1), e2 = foe('e2', 6.9, 12.0);
|
||||
assert.equal(pickBattle([e1, e2], at(6.4, 12.2), 0, 0).key, 'e1');
|
||||
assert.equal(pickBattle([e1, e2], at(6.8, 11.9), 0, 0).key, 'e2');
|
||||
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH - 0.01, 12.1), 0, 0).key, 'e1');
|
||||
assert.equal(pickBattle([e1], at(6.3 + ENEMY_REACH + 0.01, 12.1), 0, 0), null, 'too far');
|
||||
});
|
||||
|
||||
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('an ally\'s tile and an enemy both there (a blocked enemy at the edge of the blocker\'s tile): the nearer, ties the ally', () => {
|
||||
const A = ally('A', 10, 5), e = foe('e', 5.45, 10);
|
||||
assert.equal(pickBattle([A, e], at(5.05, 10.05), 0, 0).key, 'A', 'the tile centre: the operator');
|
||||
assert.equal(pickBattle([A, e], at(5.42, 10.02), 0, 0).key, 'e', 'on the enemy');
|
||||
assert.equal(pickBattle([e, A], at(5.225, 10), 0, 0).key, 'A', 'halfway: the ally');
|
||||
assert.equal(pickBattle([A, e], at(5.8, 10), 0, 0).key, 'e', 'the next tile: only the enemy is near');
|
||||
});
|
||||
|
||||
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('a flying enemy: its drawn body (feet to head) within the reach × its px per tile, on screen', () => {
|
||||
const f = { key: 'f', tile: null, x: 7, y: 11, fly: true, body: { x: 500, top: 260, feet: 340, s: 80 } };
|
||||
const A = ally('A', 11, 7);
|
||||
assert.equal(pickBattle([f], null, 510, 330).key, 'f', 'off any tile under it');
|
||||
assert.equal(pickBattle([f], null, 505, 265).key, 'f', 'its head');
|
||||
assert.equal(pickBattle([f], at(7, 11), 500 + ENEMY_REACH * 80 + 2, 300), null, 'the ground position does not count');
|
||||
assert.equal(pickBattle([f], null, 500, 340 + ENEMY_REACH * 80 + 2), null, 'below its feet, beyond the reach');
|
||||
assert.equal(pickBattle([f, A], at(7.4, 11.3), 505, 305).key, 'f', 'on the flyer, over an ally\'s tile');
|
||||
assert.equal(pickBattle([f, A], at(7.02, 11), 500, 340 + 0.2 * 80).key, 'A', 'the ally\'s tile centre, the flyer 0.2 tile away: the ally is nearer');
|
||||
});
|
||||
|
||||
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('a walking enemy: its drawn body counts too — a press on a tall enemy\'s / a boss\'s torso or head picks it', () => {
|
||||
// a boss 2.4 tiles tall standing at (8, 12): its torso is drawn ~1.2 tiles above its feet, over the ground ~1 tile
|
||||
// behind it — beyond the ground reach, on its body
|
||||
const boss = { key: 'boss', tile: null, x: 8, y: 12, body: { x: 600, top: 600 - 2.4 * 90, feet: 600, s: 90 } };
|
||||
assert.equal(pickBattle([boss], at(8.05, 13.1), 605, 600 - 1.2 * 90).key, 'boss', 'its torso');
|
||||
assert.equal(pickBattle([boss], at(8, 13.8), 598, 600 - 2.3 * 90).key, 'boss', 'its head');
|
||||
assert.equal(pickBattle([boss], at(8.3, 12.2), 700, 610).key, 'boss', 'at its feet (the ground rule)');
|
||||
assert.equal(pickBattle([boss], at(8, 14.5), 600 + 0.7 * 90, 600 - 1.2 * 90), null, 'beside its body, beyond the reach');
|
||||
// an ally on the tile its head is drawn over: the ally's tile centre stays the ally's, the head the enemy's
|
||||
const e = { key: 'e', tile: null, x: 5, y: 10, body: { x: 400, top: 400 - 1.2 * 80, feet: 400, s: 80 } };
|
||||
const A = ally('A', 11, 5);
|
||||
assert.equal(pickBattle([A, e], at(5, 11), 400 + 0.3 * 80, 400 - 1.1 * 80).key, 'A', 'the ally\'s tile centre, 0.3 tile beside the head');
|
||||
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
|
||||
});
|
||||
|
||||
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), []);
|
||||
test('bad input: nothing', () => {
|
||||
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
|
||||
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
|
||||
});
|
||||
});
|
||||
@@ -195,65 +195,22 @@ 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 }));
|
||||
// user playtest #4 item 1: picking is by tile (render/pick.js); bounds() is the body's screen rect for tooltips / overlays
|
||||
describe('bounds (view.pieceScreenRect)', () => {
|
||||
test('a unit: 0.7 tile wide, from its head (UNIT.headroom) to just below its feet; an enemy by its model height', async () => {
|
||||
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true });
|
||||
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 { x, y, s } = v.screen;
|
||||
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');
|
||||
assert.ok(Math.abs(r.x - (x - 0.35 * s)) < 1e-6 && Math.abs(r.width - 0.7 * s) < 1e-6);
|
||||
assert.ok(Math.abs(r.y - (y - 1.18 * s)) < 1e-6 && Math.abs(r.y + r.height - (y + 0.1 * s)) < 1e-6);
|
||||
assert.equal(typeof v.pickShape, 'undefined', 'no hit shapes any more');
|
||||
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.actor.entry.bounds = { height: 640 }; // setup-pose bounds: 2 tiles (UNIT.modelScale 1/320) × 0.92
|
||||
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);
|
||||
const fr = foe.bounds();
|
||||
assert.ok(Math.abs(fr.y - (foe.screen.y - foe._headTiles * foe.screen.s)) < 1e-6 && foe._headTiles > 1.5, 'its own height');
|
||||
});
|
||||
|
||||
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
|
||||
@@ -262,16 +219,14 @@ describe('pickShape / bounds / hitTest', () => {
|
||||
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');
|
||||
let r = it.bounds();
|
||||
assert.ok(Math.abs(r.y + r.height - it.screen.y) < 1e-6, 'resting: the plate above its anchor');
|
||||
it.lift = 0.3;
|
||||
it.update(1 / 60, cam(), 0);
|
||||
b = it.pickShape();
|
||||
r = it.bounds();
|
||||
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.ok(Math.abs(r.x + r.width / 2 - g.x) < 1e-6 && Math.abs(r.y + r.height / 2 - 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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+54
-8
@@ -156,7 +156,7 @@ test('element bursts on enemies: apoptosis = 800 元素伤害/s + 50 % weaken re
|
||||
assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent');
|
||||
});
|
||||
|
||||
test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 true + stun 10 s, apoptosis no skill / −1 SP/s / 100 arts/s, erosion 800 phys + DEF −100', () => {
|
||||
test('element bursts on operators (enemy damage): burn 1200 arts, neural stun 10 s then 1000 true, apoptosis 阻回 + no skill / −1 SP/s / 100 arts/s, erosion DEF −100 then 800 phys (10 s cooldown)', () => {
|
||||
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } });
|
||||
const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 });
|
||||
h.step();
|
||||
@@ -164,31 +164,74 @@ test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 tr
|
||||
let hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.burn.ally.damage);
|
||||
h.run(ELEMENT.burn.ally.duration + 0.1); // the burn cooldown holds every gauge
|
||||
hp = u.hp;
|
||||
let stunned = null;
|
||||
h.b.on('damaged', (c) => { if (c.target === u && c.type === 'true' && stunned == null) stunned = !!u.s.flags.stun; });
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.neural.ally.damage);
|
||||
assert.ok(u.findBuff('stun'));
|
||||
assert.equal(stunned, true, 'PRTS: "立刻获得等时长的眩晕;随后受到1000点…真实伤害" — stunned before the hit');
|
||||
approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun);
|
||||
h.run(10.1);
|
||||
const sp0 = u.skill.sp;
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 });
|
||||
assert.ok(u.s.flags.silence, 'no skill activation');
|
||||
assert.ok(u.s.flags.noSp, '阻回: no SP recovery');
|
||||
h.run(3.02);
|
||||
approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps);
|
||||
approx(u.skill.sp, sp0 + 3 * u.s.spRecovery - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
|
||||
approx(u.skill.sp, sp0 - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
|
||||
const spHeld = u.skill.sp;
|
||||
assert.ok(spHeld < u.skill.spCost && !u.skill.active, 'room for SP (a 0 below is 阻回, not a full bar)');
|
||||
assert.equal(u.skill.gainSp(5, 'talent'), 0, '阻回 ("停止并阻止任意形式的技力回复"): no granted SP');
|
||||
assert.equal(u.skill.gainSp(1, 'hurt'), 0, '… nor SP from hits taken');
|
||||
assert.equal(u.skill.gainSp(1, 'attack'), 0, '… nor from attacks');
|
||||
assert.equal(u.skill.sp, spHeld);
|
||||
h.run(12.1);
|
||||
assert.ok(!u.s.flags.silence);
|
||||
assert.ok(!u.s.flags.silence && !u.s.flags.noSp);
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys');
|
||||
assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown);
|
||||
assert.equal(u.elem.erosion, 0, 'no lock: accumulates again at once');
|
||||
assert.equal(ELEMENT.erosion.ally.duration, 10, 'PRTS 元素: 侵蚀 爆发 10 s on operators');
|
||||
assert.equal(u.elem.erosion, 1000, 'held full during its cooldown');
|
||||
h.run(10.1);
|
||||
assert.equal(u.elem.erosion, 0, 'reset when the cooldown ends');
|
||||
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
assert.equal(u.findBuff('erosionDown').stacks, 2, 'the DEF cut stacks (permanent)');
|
||||
});
|
||||
|
||||
test('爆发冷却 (PRTS 元素): during a burst no element fills or recovers; its end resets every gauge; 损伤抵抗 = epResistance %', () => {
|
||||
const resRec = enemyRec({ key: 'enemy_res', hp: 1e6, speed: 0 });
|
||||
resRec.stats.elementRes = 10;
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy(), enemy_res: resRec } },
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_res', pos: [10, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 400 });
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
|
||||
assert.ok(e.findBuff('burnBurst') && e.s.flags.burstLock);
|
||||
assert.equal(h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 }), 0, 'another element cannot fill during the cooldown');
|
||||
approx(e.elem.neural, 400);
|
||||
assert.equal(h.b.reduceElement(e, 300), 0, 'nor be recovered');
|
||||
h.run(ELEMENT.burn.enemy.duration - 0.2);
|
||||
assert.equal(e.elem.burn, 1000);
|
||||
h.run(0.3);
|
||||
assert.deepEqual([e.elem.burn, e.elem.neural, e.elem.apoptosis, e.elem.erosion], [0, 0, 0, 0], 'every gauge restored');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 300 });
|
||||
approx(e.elem.neural, 300, 1e-6, 'fills again after the cooldown');
|
||||
// 损伤抵抗 (data elementRes → epResistance, a percentage): 受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)
|
||||
const r = h.enemy('enemy_res');
|
||||
assert.equal(r.def.epResistance, 10);
|
||||
h.b.dealDamage(null, r, { type: 'element', element: 'burn', amount: 500 });
|
||||
approx(r.elem.burn, 450);
|
||||
});
|
||||
|
||||
test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => {
|
||||
// two operators that spread every burst to each other (the 淤困 parasite pattern) — burn has a lock, erosion on
|
||||
// operators has none (duration 0), apoptosis locks with a ticker: each must burst exactly once per fill
|
||||
// two operators that spread every burst to each other (the 淤困 parasite pattern) — every burst has its cooldown
|
||||
// (erosion on operators too: 10 s), apoptosis locks with a ticker: each must burst exactly once per fill
|
||||
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null });
|
||||
for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) {
|
||||
const h = makeBattle({
|
||||
@@ -210,7 +253,10 @@ test('a resolving burst already locks its gauge: an elementBurst handler spreadi
|
||||
if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); }
|
||||
if (el === 'erosion') {
|
||||
for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut');
|
||||
assert.equal(A.elem.erosion, 0, 'no lock once resolved: accumulates again');
|
||||
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
assert.equal(A.findBuff('erosionDown').stacks, 1, 'locked during its 10 s cooldown');
|
||||
h.run(ELEMENT.erosion.ally.duration + 0.1);
|
||||
assert.equal(A.elem.erosion, 0, 'reset once the cooldown ended');
|
||||
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
|
||||
assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
|
||||
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
|
||||
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
|
||||
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
|
||||
import { elementView } from '../../server/sim/damage.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
|
||||
const TOKEN = { name: '测试召唤物', stats: { maxHp: 100, atk: 0, def: 0, res: 0, blockCnt: 0, cost: 0, respawnTime: 0 } };
|
||||
const downOf = (h, id) => (h.snapshot().down || []).find((d) => d[0] === id) || null;
|
||||
|
||||
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for its tile / the DP; then back', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } }, units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }],
|
||||
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit('t_a');
|
||||
assert.equal(h.snapshot().down, undefined, 'no entry while everyone stands');
|
||||
const t0 = h.b.time;
|
||||
h.b.kill(a);
|
||||
let d = downOf(h, a.id);
|
||||
assert.ok(d, 'down right after the knock-out');
|
||||
approx(d[1], Math.round((t0 + 6) * 100) / 100, 0.011);
|
||||
approx(d[2], 6, 0.011);
|
||||
assert.equal(d[3], DOWN_STATE.COUNTING);
|
||||
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining now gets its UnitInfo');
|
||||
h.run(6.5);
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, 6.5 DP < cost 12');
|
||||
const tok = h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN });
|
||||
assert.ok(tok && tok.alive, 'a summon stands on its tile');
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
|
||||
h.run(7);
|
||||
assert.ok(!a.alive, 'no redeploy while its tile is taken');
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
|
||||
h.b.retreat(tok, { reason: 'expired', permanent: true });
|
||||
h.step();
|
||||
assert.ok(a.alive && a.deployed, 'redeployed once the tile is free (DP 13.5 ≥ 12)');
|
||||
assert.equal(downOf(h, a.id), null);
|
||||
assert.equal(h.snapshot().down, undefined);
|
||||
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
|
||||
});
|
||||
|
||||
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
|
||||
});
|
||||
h.step();
|
||||
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
|
||||
h.b.kill(tok);
|
||||
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
|
||||
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
|
||||
h.b.kill(h.enemy('enemy_dummy'));
|
||||
assert.equal(h.snapshot().down, undefined);
|
||||
h.b.kill(h.unit('t_c'));
|
||||
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_c').id], 'only the knocked-out operator');
|
||||
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
|
||||
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
|
||||
});
|
||||
|
||||
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_lead', pos: [10, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.enemy('enemy_dummy'), L = h.enemy('enemy_lead');
|
||||
assert.equal(elementView(e, h.b.time), null);
|
||||
assert.equal(h.snapshot().elem, undefined, 'no `elem` key when nothing shows');
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 300 });
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 450 });
|
||||
assert.deepEqual(elementView(e, h.b.time), ['apoptosis', 0.45, 0, 0]);
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 150 });
|
||||
assert.deepEqual(elementView(e, h.b.time), ['burn', 0.45, 0, 0], 'a tie goes to the lower official id (灼燃 3 < 凋亡 4)');
|
||||
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
|
||||
h.b.dealDamage(null, L, { type: 'element', element: 'neural', amount: 500 });
|
||||
assert.deepEqual(elementView(L, h.b.time), ['neural', 0.25, 0, 0], 'leaders hold 2000');
|
||||
const snap = h.snapshot();
|
||||
assert.deepEqual(snap.elem.map((x) => x[0]).sort(), [e.id, L.id].sort());
|
||||
// burst: the bursting element, full, with the cooldown's end and length
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 600 });
|
||||
const t = h.b.time;
|
||||
const v = elementView(e, t);
|
||||
assert.equal(v[0], 'apoptosis');
|
||||
assert.equal(v[1], 1);
|
||||
approx(v[2], Math.round((t + ELEMENT.apoptosis.enemy.duration) * 100) / 100, 0.011);
|
||||
assert.equal(v[3], ELEMENT.apoptosis.enemy.duration);
|
||||
h.run(5);
|
||||
approx(elementView(e, h.b.time)[2], v[2], 0.05);
|
||||
h.run(ELEMENT.apoptosis.enemy.duration - 4.9);
|
||||
assert.equal(elementView(e, h.b.time), null, 'every gauge reset after the cooldown');
|
||||
// a dead or hidden unit has no entry
|
||||
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 100 });
|
||||
h.b.kill(e);
|
||||
assert.ok(!(h.snapshot().elem || []).some((x) => x[0] === e.id));
|
||||
});
|
||||
+95
-2
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager } from '../../public/js/audio.js';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx } from '../../public/js/audio.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -62,6 +62,16 @@ describe('SfxLimiter', () => {
|
||||
assert.ok(l.tryAcquire(120, 'u1', 'z'));
|
||||
assert.ok(l.tryAcquire(121, null, 'w'), 'no unit key ⇒ only url gap');
|
||||
});
|
||||
test('at most 2 overlapping copies of one sound (official banks: maxSoundAllowed 2)', () => {
|
||||
const l = new SfxLimiter({ maxVoices: 99, unitCooldownMs: 0, urlGapMs: 0 });
|
||||
assert.equal(l.maxPerUrl, 2);
|
||||
assert.ok(l.tryAcquire(0, 'a', 'heal'));
|
||||
assert.ok(l.tryAcquire(1, 'b', 'heal'));
|
||||
assert.equal(l.tryAcquire(2, 'c', 'heal'), false, 'a third copy waits');
|
||||
assert.ok(l.tryAcquire(2, 'c', 'other'), 'other sounds are not affected');
|
||||
l.release('heal');
|
||||
assert.ok(l.tryAcquire(3, 'c', 'heal'), 'one ended: room again');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- fake Web Audio -------------------------------------------------------------------------------------
|
||||
@@ -130,7 +140,7 @@ describe('AudioManager', () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(fw.made.started, before, 'prep → combat shares the loop');
|
||||
// battle events map to unit sounds (UnitInfo.spine = char id)
|
||||
const charId = Object.keys(manifest.audio.sfx.units).find((k) => manifest.audio.sfx.units[k].attack && k.startsWith('char_'));
|
||||
const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].attack));
|
||||
a.setFieldUnits([{ id: 1, side: 'ally', spine: charId }, { id: 2, side: 'enemy', spine: 'enemy_nope' }]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 100, 'phys'], ['die', 2], ['spawn', { id: 3, side: 'enemy', spine: 'x' }], ['bounty', 'p', 1]]);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -167,3 +177,86 @@ describe('AudioManager', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact
|
||||
// (p_imp_gtshpbrnch_s, the audio bank ON_ABILITY_HIT.attack.2) and every damage on an ally she had just healed was
|
||||
// attributed to her ('atk' healer → ally), so ordinary enemy hits on healed allies played it.
|
||||
describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
const AGOAT2 = 'char_1016_agoat2';
|
||||
async function rig(units) {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.setFieldUnits(units);
|
||||
const settle = () => new Promise((r) => setTimeout(r, 5));
|
||||
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
|
||||
test('an operator never plays a skill-mode file (_d / _h / _s) as its normal attack or impact; enemies keep their _h', () => {
|
||||
const u = manifest.audio.sfx.units;
|
||||
// her impact: the S3 file (built before tools/assets/audio.mjs preferred normal-mode banks) is refused, the normal
|
||||
// one (projectile_chr_agoat2: p_imp_gtshpbrnch_n) plays
|
||||
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_s.mp3'), false);
|
||||
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_n.mp3'), true);
|
||||
if (u[AGOAT2].hit) assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].hit), !/_s\.mp3$/.test(u[AGOAT2].hit));
|
||||
assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].attack), true, 'p_atk_gtshpbrnch_n');
|
||||
assert.equal(normalAttackSfx('char_1014_nearl2', '/x/p_atk_goldspear_s.mp3'), false);
|
||||
assert.equal(normalAttackSfx('char_1028_texas2', '/x/p_imp_reticentsword_h.mp3'), false);
|
||||
assert.equal(normalAttackSfx('char_1045_svash2', '/x/p_atk_snwlprdg_n1.mp3'), true);
|
||||
assert.equal(normalAttackSfx('enemy_1045_hammer', '/x/e_atk_bigaxe_h.mp3'), true, 'enemy _h = heavy weapon');
|
||||
assert.equal(normalAttackSfx('char_x', null), false);
|
||||
});
|
||||
|
||||
test('a heal never makes the healer the author of the next damage on the healed ally', async () => {
|
||||
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].hit);
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: AGOAT2 }, { id: 2, side: 'ally', kind: 'chess', spine: 'char_x' },
|
||||
{ id: 3, side: 'enemy', kind: 'enemy', spine: enemyId },
|
||||
]);
|
||||
try {
|
||||
const own = new Set(Object.values(manifest.audio.sfx.units[AGOAT2]).filter((x) => typeof x === 'string'));
|
||||
a.handleBattleEvents([['atk', 1, 2, 'orb'], ['heal', 2, 300], ['dmg', 2, 120, 'phys'], ['dmg', 2, 80, 'arts']]);
|
||||
await settle();
|
||||
assert.ok(urls.includes(manifest.audio.sfx.units[AGOAT2].attack), 'her cast sound');
|
||||
assert.ok(!urls.some((x) => x === manifest.audio.sfx.units[AGOAT2].hit), 'no impact sound of hers on the ally');
|
||||
assert.ok(!urls.some((x) => own.has(x) && /_s\.mp3$/.test(x)), 'nothing of her S3');
|
||||
// a hostile attack still authors its impact — once, and only for a real hit (not an element gauge fill)
|
||||
a.handleBattleEvents([['atk', 3, 2, 'none'], ['dmg', 2, 900, 'burn']]);
|
||||
await settle();
|
||||
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].hit), 'a gauge fill is no impact');
|
||||
a.handleBattleEvents([['dmg', 2, 200, 'phys']]);
|
||||
await settle();
|
||||
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'the impact');
|
||||
a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear();
|
||||
a.handleBattleEvents([['dmg', 2, 50, 'phys']]);
|
||||
await settle();
|
||||
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'a later tick is not the same attack\'s impact');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a chain bounce plays no attack sound of the previous target; a stale attack is no impact', async () => {
|
||||
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].hit);
|
||||
const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].hit) && manifest.audio.sfx.units[k].hit);
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: charId }, { id: 5, side: 'enemy', kind: 'enemy', spine: enemyId },
|
||||
{ id: 6, side: 'enemy', kind: 'enemy', spine: enemyId },
|
||||
]);
|
||||
const perf = globalThis.performance;
|
||||
let fakeNow = 1000;
|
||||
globalThis.performance = { now: () => fakeNow };
|
||||
try {
|
||||
a.handleBattleEvents([['atk', 5, 6, 'chain']]);
|
||||
await settle();
|
||||
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].attack), 'the bounce is not an enemy attack');
|
||||
a.handleBattleEvents([['atk', 1, 5, 'arrow']]);
|
||||
fakeNow += 4000;
|
||||
a.handleBattleEvents([['dmg', 5, 100, 'phys']]);
|
||||
await settle();
|
||||
assert.ok(!urls.includes(manifest.audio.sfx.units[charId].hit), '4 s later: not that attack\'s impact');
|
||||
} finally { globalThis.performance = perf; restore(); }
|
||||
});
|
||||
});
|
||||
+111
-9
@@ -1,14 +1,18 @@
|
||||
// Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be
|
||||
// chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the
|
||||
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (12 s turn timeout, 50 s cap, a departing
|
||||
// seat) give the official default 「华法琳」 only while no teammate holds it, else the first free strategy
|
||||
// (Match.js defaultBand; the UI names it: bandDraft.js timeoutBand).
|
||||
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (a turn that runs out, a departing seat) give
|
||||
// the official default 「华法琳」 only while no teammate holds it, else the first free strategy (Match.js defaultBand; the
|
||||
// UI names it: bandDraft.js timeoutBand) — a timed-out turn takes the highlighted band first (g.bandFocus, Match.js
|
||||
// timeoutBand; user playtest #4 item 4: one countdown, BAND_TURN_SECONDS per turn, no separate step cap).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch, DATA } from '../match/harness.js';
|
||||
import { teammateBands, timeoutBand, allowedBands } from '../../public/js/screens/bandDraft.js';
|
||||
import { teammateBands, timeoutBand, allowedBands, autoPickBand, draftClock, draftTip, draftSelection } from '../../public/js/screens/bandDraft.js';
|
||||
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
|
||||
|
||||
const TURN_MS = BAND_TURN_SECONDS * 1000;
|
||||
|
||||
function draftOf(o) {
|
||||
const h = makeMatch({ mode: 'coop', ...o }).start();
|
||||
@@ -98,34 +102,66 @@ describe('server: automatic assignments never duplicate a teammate\'s strategy',
|
||||
return ids;
|
||||
};
|
||||
|
||||
test('12 s turn timeout: 华法琳 when free, else the first free strategy (sortId order)', () => {
|
||||
test('a turn that runs out (no highlight): 华法琳 when free, else the first free strategy (sortId order)', () => {
|
||||
const h = draftOf({ humans: 3, seed: 5 });
|
||||
const m = h.m;
|
||||
const [first, second, third] = m.draft.order;
|
||||
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
|
||||
assert.equal(m.defaultBand(second), 'band_amiya', 'the next free strategy by sortId');
|
||||
h.sched.advance(12001);
|
||||
h.sched.advance(TURN_MS - 1);
|
||||
assert.equal(m.draft.picks[second], undefined, `the whole ${BAND_TURN_SECONDS} s`);
|
||||
h.sched.advance(2);
|
||||
assert.equal(m.draft.picks[second], 'band_amiya', 'timeout never assigns 队友已选 华法琳');
|
||||
assert.equal(h.ps(second).lp, DATA.bands.band_amiya.totalHp, 'the LP of the assigned strategy');
|
||||
assert.equal(m.draftTurn(), third);
|
||||
h.sched.advance(12001);
|
||||
h.sched.advance(TURN_MS + 1);
|
||||
assert.equal(m.draft.picks[third], 'band_duyaoy');
|
||||
distinct(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a turn that runs out takes the highlighted strategy (g.bandFocus) while it is free', () => {
|
||||
const h = draftOf({ humans: 3, seed: 5 });
|
||||
const m = h.m;
|
||||
const [first, second, third] = m.draft.order;
|
||||
// highlights may come before the turn; the last one counts; null clears it
|
||||
assert.deepEqual(m.handle(third, { t: 'g.bandFocus', bandId: 'band_lisa' }), { ok: true });
|
||||
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_sarkazb' }), { ok: true });
|
||||
assert.equal(m.handle(second, { t: 'g.bandFocus', bandId: 'band_nope' }).error, ERR.BAD_TARGET);
|
||||
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
|
||||
assert.equal(m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY, 'nothing to highlight after the pick');
|
||||
h.sched.advance(TURN_MS + 1);
|
||||
assert.equal(m.draft.picks[second], 'band_sarkazb', 'the highlighted strategy');
|
||||
assert.equal(h.ps(second).lp, DATA.bands.band_sarkazb.totalHp);
|
||||
// the third player highlighted 华法琳's neighbour band_lisa — a teammate took it meanwhile: the default instead
|
||||
assert.equal(m.timeoutBand(third), 'band_bldsk', '队友已选: never assigned');
|
||||
h.sched.advance(TURN_MS + 1);
|
||||
assert.equal(m.draft.picks[third], 'band_bldsk');
|
||||
distinct(m);
|
||||
m.dispose();
|
||||
const h2 = draftOf({ humans: 2, seed: 5 });
|
||||
const [a] = h2.m.draft.order;
|
||||
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus' }), { ok: true }, 'cleared');
|
||||
h2.sched.advance(TURN_MS + 1);
|
||||
assert.equal(h2.m.draft.picks[a], 'band_bldsk', 'no highlight: the default');
|
||||
assert.equal(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY);
|
||||
h2.m.dispose();
|
||||
});
|
||||
|
||||
test('the default stays 华法琳 while nobody holds it (also for a player whose teammates picked others)', () => {
|
||||
const h = draftOf({ humans: 2, seed: 5 });
|
||||
const m = h.m;
|
||||
const [first, second] = m.draft.order;
|
||||
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.equal(m.defaultBand(second), 'band_bldsk');
|
||||
h.sched.advance(12001);
|
||||
h.sched.advance(TURN_MS + 1);
|
||||
assert.equal(m.draft.picks[second], 'band_bldsk');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('every human idle: the turn timeouts and the 50 s step cap assign distinct strategies', () => {
|
||||
test('every human idle: the turn timeouts assign distinct strategies (no separate step cap)', () => {
|
||||
const h = draftOf({ humans: 4, seed: 9 });
|
||||
const m = h.m;
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
|
||||
@@ -195,3 +231,69 @@ describe('UI: timeoutBand (the tip names what a timeout gives me)', () => {
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI: one countdown and the highlighted band (user playtest #4 item 4)', () => {
|
||||
const bands = allowedBands([
|
||||
{ bandId: 'band_amiya', sortId: 2 }, { bandId: 'band_bldsk', sortId: 1 }, { bandId: 'band_duyaoy', sortId: 3 },
|
||||
], 'MULTI');
|
||||
|
||||
test('draftClock: the step header counts the current turn down (m.public.deadline = draft.turnDeadline); untimed ⇒ none', () => {
|
||||
assert.deepEqual(draftClock({ deadline: 5000, draft: { turnDeadline: 5000, turnSeconds: 30 } }), { deadline: 5000, total: 30 });
|
||||
assert.deepEqual(draftClock({ deadline: 0, draft: { turnDeadline: 7000, turnSeconds: 30 } }), { deadline: 7000, total: 30 }, 'the turn deadline stands in');
|
||||
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0, untimed: true, turnSeconds: 0 } }), null);
|
||||
assert.equal(draftClock({ deadline: 9000, draft: { untimed: true } }), null, 'an untimed draft shows no clock');
|
||||
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0 } }), null);
|
||||
assert.equal(draftClock(null), null);
|
||||
assert.deepEqual(draftClock({ deadline: 4000, draft: {} }), { deadline: 4000, total: null });
|
||||
});
|
||||
|
||||
test('the server publishes one clock: deadline = turnDeadline, turnSeconds = the turn length; AI seats pick at once', () => {
|
||||
const h = draftOf({ humans: 2, bots: 2, seed: 9 });
|
||||
const m = h.m;
|
||||
h.sched.advance(1);
|
||||
const turn = m.draftTurn();
|
||||
assert.ok(turn && !turn.startsWith('ai_'), `a human's turn after the AI seats picked at once (${m.draft.order})`);
|
||||
const pub = m.publicView();
|
||||
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
|
||||
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
|
||||
assert.ok(Math.abs(pub.deadline - h.sched.now() - TURN_MS) <= 2, 'a whole turn left');
|
||||
assert.deepEqual(draftClock(pub), { deadline: pub.deadline, total: BAND_TURN_SECONDS });
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('autoPickBand: the highlighted band while free, else timeoutBand; nothing after my pick', () => {
|
||||
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map() }), 'band_duyaoy');
|
||||
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map([['band_duyaoy', ['a']]]) }), 'band_bldsk', '队友已选 ⇒ the default');
|
||||
assert.equal(autoPickBand(null, { bands, taken: new Map() }), 'band_bldsk');
|
||||
assert.equal(autoPickBand('band_unknown', { bands, taken: new Map() }), 'band_bldsk', 'not a band of the mode');
|
||||
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map([['band_bldsk', ['a']], ['band_amiya', ['b']]]) }), 'band_duyaoy');
|
||||
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map(), myPick: 'band_amiya' }), null);
|
||||
});
|
||||
|
||||
test('the selection starts on what a timeout gives (华法琳 while free), so the tip names the highlighted band', () => {
|
||||
assert.equal(draftSelection(null, { bands, taken: new Map(), myPick: null, myTurn: false }), 'band_bldsk');
|
||||
assert.equal(draftSelection(null, { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya');
|
||||
assert.equal(draftSelection('band_bldsk', { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya', 'taken on my turn ⇒ the default');
|
||||
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '华法琳' }), /每位博士有 30 秒,超时将自动选择当前选中的「华法琳」/);
|
||||
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '阿米娅', selected: false }), /超时将自动选择「阿米娅」$/);
|
||||
assert.equal(draftTip({ timed: true, turnSeconds: 30, autoName: null }), '联合模拟在选择策略时可以进行一次跳过;每位博士有 30 秒');
|
||||
assert.equal(draftTip({ timed: false, autoName: '华法琳' }), '联合模拟在选择策略时可以进行一次跳过;本局不限时');
|
||||
});
|
||||
|
||||
test('a single human with AI teammates: the draft is untimed (soloUntimed), keeps the co-op order and its skip', () => {
|
||||
const h = draftOf({ humans: 1, bots: 3, seed: 4 });
|
||||
const m = h.m;
|
||||
h.run(() => m.draftTurn() === 'p_0' || m.phase !== PHASE.BAND_DRAFT, { maxTime: 1000 });
|
||||
assert.equal(m.draftTurn(), 'p_0');
|
||||
const pub = m.publicView();
|
||||
assert.equal(pub.deadline, 0);
|
||||
assert.equal(pub.draft.untimed, true);
|
||||
assert.equal(pub.draft.turnSeconds, 0);
|
||||
assert.equal(draftClock(pub), null);
|
||||
h.sched.advance(10 * 60_000);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT, 'waits for the player');
|
||||
assert.equal(m.draftTurn(), 'p_0');
|
||||
if (m.draft.order.length - m.draft.idx > 1) assert.deepEqual(m.handle('p_0', { t: 'g.bandSkip' }), { ok: true }, 'the co-op skip stays');
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
+20
-62
@@ -12,9 +12,8 @@ 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, pickPieceAt, equipRetarget,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
|
||||
} 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';
|
||||
|
||||
@@ -101,6 +100,9 @@ describe('countdown', () => {
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [], picks: {} }, players: [] }, config), config.timers.spFirst);
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [{}, {}], picks: { a: 0 } }, players: [{ playerId: 'a' }] }, config), config.timers.spTurn);
|
||||
assert.equal(phaseTotalSeconds(null, config), null);
|
||||
// the strategy draft: one countdown, the turn's (m.public.draft.turnSeconds; user playtest #4 item 4)
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT, draft: { turnSeconds: 30 } }, config), 30);
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT }, config), config.timers.bandTurn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -494,65 +496,21 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
|
||||
});
|
||||
});
|
||||
|
||||
// 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');
|
||||
// user playtest #4 item 1: a dragged item drops on the tile under the pointer (render/drag.js) and goes to the unit on
|
||||
// that tile — the highlighted tile; nothing is retargeted to a sprite drawn over another tile any more
|
||||
describe('equipment dropped on a tile goes to the unit on it', () => {
|
||||
test('the operator on the drop tile (board or bench), not the one whose head is drawn there; an empty tile says why', () => {
|
||||
const front = { ...piece(RANGED), row: 9, col: 3 }, behind = { ...piece(RANGED), row: 10, col: 3 };
|
||||
const onBench = piece(RANGED);
|
||||
const eq = item(EQUIP), art = item(MAGIC);
|
||||
const ctx = ctxFor(privWith({ board: [front, behind], hand: [{ idx: 0, piece: eq }, { idx: 1, piece: art }, { idx: 4, piece: onBench }] }));
|
||||
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 9, col: 3 }).fields, { itemUid: eq.uid, targetUid: front.uid });
|
||||
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 10, col: 3 }).fields, { itemUid: eq.uid, targetUid: behind.uid });
|
||||
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'hand', idx: 4 }).fields, { itemUid: eq.uid, targetUid: onBench.uid }, 'a bench operator');
|
||||
// the empty tile behind `behind` (where its head is drawn) takes nothing — and the release says why
|
||||
assert.equal(dropIntent(ctx, eq.uid, { area: 'board', row: 11, col: 3 }), null);
|
||||
assert.equal(dropFailureReason(ctx, eq.uid, { row: 11, col: 3, area: 'board' }), '请将装备拖拽至干员身上');
|
||||
// an Art is used on the tile under the pointer itself
|
||||
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
|
||||
});
|
||||
});
|
||||
@@ -256,9 +256,10 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 (
|
||||
|
||||
const itemsOf = () => c.page.evaluate((uid) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).map((x) => ({ uid: x.uid, id: x.id })), unit.uid);
|
||||
const handItem = async (id) => (await c.handPieces('item')).find((p) => p.id === id) || null;
|
||||
// an item goes to the unit on the tile it is dropped on (user playtest #4 item 1): the operator's own tile
|
||||
const dropItem = async (it) => {
|
||||
for (const at of [0.5, 0.35, 0.7]) {
|
||||
await c.drag(await c.piecePoint(it.uid, 0.5), await c.piecePoint(unit.uid, at));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await c.drag(await c.piecePoint(it.uid, 0.5), await c.tilePoint(tile.row, tile.col));
|
||||
await sleep(500);
|
||||
if (await c.exists('.eqr') || !(await handItem(it.id))) return;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,10 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
|
||||
module: document.querySelector('.dpanel .dmodule')?.textContent || '',
|
||||
none: !!document.querySelector('.dpanel .dmodule.is-none'),
|
||||
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent, el.querySelector('.dstat__v')?.textContent])),
|
||||
// the record (base) value: with live stats (user playtest #4 item 7: the start-of-battle preview) it is the
|
||||
// cell's title "基础 N", else the value itself
|
||||
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
|
||||
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
|
||||
trait: document.querySelector('.dpanel .dtrait')?.textContent || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
|
||||
@@ -142,11 +142,16 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' }));
|
||||
await waitSt(page, (s) => !!s.room, 'room');
|
||||
const { room } = await waitSt(page, (s) => !!s.room, 'room');
|
||||
// a second human joins: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
|
||||
const mate = await open();
|
||||
await mate.page.evaluate((code) => globalThis.__SP__.net.request('room.join', { code }), room);
|
||||
await waitSt(mate.page, (s) => s.room === room, 'the teammate in the room');
|
||||
await mate.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {}));
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
|
||||
assert.ok(brief.deadline > 0, 'co-op briefing is timed (25 s)');
|
||||
assert.ok(brief.deadline > 0, 'co-op briefing (two humans) is timed (25 s)');
|
||||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
|
||||
@@ -156,8 +161,9 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||
// edit, and the briefing ends before the debounced send (every human ready: here the request stands in for the timer)
|
||||
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await mate.page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 });
|
||||
@@ -166,6 +172,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||||
assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match');
|
||||
assert.deepEqual(problems, []);
|
||||
assert.deepEqual(mate.problems, []);
|
||||
await mate.ctx.close();
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -245,11 +245,37 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('机变 pick, band pick, briefing ready', async () => {
|
||||
test('机变 pick (two taps, user playtest #4 item 2), band pick, briefing ready', async () => {
|
||||
let { page, problems } = await open('phase=SP_DRAFT&variant=bounty');
|
||||
await page.click('.spcard.is-pickable');
|
||||
await sleep(200);
|
||||
// the first tap only selects: the card lifts with its 确认选择 strip, the header offers 确认选择, nothing was sent
|
||||
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'selected (armed)');
|
||||
assert.ok(await page.$('.spov__confirm'), 'the header confirm');
|
||||
assert.ok(!(await page.$('.spcard.is-mine')), 'not picked yet');
|
||||
assert.ok(!(await page.evaluate(() => globalThis.__MOCK__.S().requests.some(([t]) => t === 'g.choice'))), 'no g.choice after one tap');
|
||||
// another card moves the selection; Esc drops it
|
||||
const pickable = await page.$$('.spcard.is-pickable');
|
||||
await pickable[1].click();
|
||||
await sleep(150);
|
||||
assert.equal(await page.$$eval('.spcard.is-armed', (els) => els.length), 1, 'one selection');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(150);
|
||||
assert.ok(!(await page.$('.spcard.is-armed')), 'Esc drops the selection');
|
||||
// select, then tap the same card again: picked
|
||||
await page.click('.spcard.is-pickable');
|
||||
await sleep(150);
|
||||
await page.click('.spcard.is-armed');
|
||||
await sleep(300);
|
||||
assert.ok(await page.$('.spcard.is-mine'), 'picked card marked');
|
||||
// the header's 确认选择 confirms as well
|
||||
await page.close();
|
||||
({ page, problems } = await open('phase=SP_DRAFT&variant=supply'));
|
||||
await page.click('.spcard.is-pickable');
|
||||
await sleep(150);
|
||||
await page.click('.spov__confirm');
|
||||
await sleep(300);
|
||||
assert.ok(await page.$('.spcard.is-mine'), 'confirmed from the header');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
@@ -628,11 +654,12 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('engine: equipment released over an operator\'s upper body equips it', async (t) => {
|
||||
test('engine: equipment dropped on an operator\'s tile equips it — the tile under the pointer decides (user playtest #4 item 1)', async (t) => {
|
||||
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await sleep(1500); // spine models
|
||||
const at = (uid, fy) => page.evaluate((u, f) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * f }; }, uid, fy);
|
||||
const tileAt = (row, col) => page.evaluate((r, c) => { const x = globalThis.__SP_VIEW__?.raw?.tileScreen?.(r, c); return x && { x: x.x, y: x.y }; }, row, col);
|
||||
const drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
@@ -646,9 +673,9 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
const items = s.hand.filter((x) => x && x.kind === 'item');
|
||||
assert.ok(targets.length && items.length, 'mock board has an unequipped operator and a hand item');
|
||||
const unit = targets[0];
|
||||
await drag(await at(items[0].uid, 0.5), await at(unit.uid, 0.25));
|
||||
await drag(await at(items[0].uid, 0.5), await tileAt(unit.row, unit.col));
|
||||
s = await mockState(page);
|
||||
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped from the head');
|
||||
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped: dropped on its tile');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
@@ -227,24 +227,22 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
|
||||
test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => {
|
||||
for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) {
|
||||
const { page, problems } = await open('phase=PREP', { w, h });
|
||||
// the mock's row-12 unit (col 4, behind the row-11 one): its body stands under the bond strip at these sizes
|
||||
// the mock's row-12 unit (col 4, behind the row-11 one): its body reaches under the bond strip at these sizes; a
|
||||
// press on its tile selects it (picking is by tile, user playtest #4 item 1), so the tile must be free of the strip
|
||||
const top = await page.evaluate(() => {
|
||||
const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0];
|
||||
const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid);
|
||||
const t = globalThis.__SP_VIEW__.raw.tileScreen(b.row, b.col);
|
||||
const strip = document.querySelector('.gm__bonds').getBoundingClientRect();
|
||||
// points of the sprite's upper half (the lower half is behind the unit in front of it)
|
||||
const pts = [0.12, 0.25, 0.38].map((f) => ({ x: r.left + r.width / 2, y: r.top + r.height * f }));
|
||||
const pts = [-0.3, 0, 0.3].map((dx) => ({ x: t.x + dx * t.s, y: t.y }));
|
||||
const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; });
|
||||
return { uid: b.uid, pts, hits, underStrip: pts.some((p) => p.y >= strip.top && p.y <= strip.bottom) };
|
||||
return { uid: b.uid, pts, hits, bodyUnderStrip: r.top < strip.bottom };
|
||||
});
|
||||
assert.ok(top.underStrip, `${name}: the row-12 unit stands under the bond strip (test premise)`);
|
||||
// only the bond discs may take the pointer there — never the strip's empty box
|
||||
for (const hit of top.hits) assert.ok(hit === 'canvas' || hit === 'disc', `${name}: over the row-12 unit: ${hit}`);
|
||||
const free = top.pts[top.hits.indexOf('canvas')];
|
||||
assert.ok(free, `${name}: some of the unit's body is free of bond discs (${top.hits})`);
|
||||
await page.mouse.click(free.x, free.y);
|
||||
assert.ok(top.bodyUnderStrip, `${name}: the row-12 unit's body reaches under the bond strip (test premise)`);
|
||||
for (const hit of top.hits) assert.equal(hit, 'canvas', `${name}: the row-12 unit's tile is free of the bond strip`);
|
||||
await page.mouse.click(top.pts[1].x, top.pts[1].y);
|
||||
await sleep(350);
|
||||
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's body under the strip selects it`);
|
||||
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's tile selects it`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.click(5, Math.round(h / 2));
|
||||
await sleep(150);
|
||||
|
||||
@@ -185,6 +185,22 @@ function battleViewState(c) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a prep matching `pred`; a 机变 draft on the way is picked with its two taps (user playtest #4 item 2) —
|
||||
* a single human's 机变 is untimed (Match.soloUntimed, item 3): nothing picks it for them any more.
|
||||
*/
|
||||
async function untilPrep(c, pred, what, timeout = 90000) {
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const left = Math.max(2000, timeout - (Date.now() - t0));
|
||||
const s = await c.waitFor((x) => (x.phase === 'PREP' && pred(x)) || (x.phase === 'SP_DRAFT' && x.sp?.turn === x.me), what, left);
|
||||
if (s.phase === 'PREP') return s;
|
||||
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 4000 });
|
||||
await c.click('.spcard.is-armed', null, { optional: true, timeout: 4000 });
|
||||
await c.waitFor((x) => x.phase !== 'SP_DRAFT' || x.sp?.turn !== x.me, '机变 picked', 15000);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoPen(v, tag) {
|
||||
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
|
||||
assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
|
||||
@@ -216,7 +232,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
|
||||
}
|
||||
let observed = false;
|
||||
for (let tries = 0; tries < 3 && !observed; tries++) {
|
||||
const s0 = await c.waitFor((s) => s.phase === 'PREP' && !s.ready, 'prep', 90000);
|
||||
const s0 = await untilPrep(c, (s) => !s.ready, 'prep');
|
||||
await sleep(1500);
|
||||
// the own field fights with everything the deploy cap allows: it clears its wave long before the idle AI's
|
||||
// enemies have walked their whole path
|
||||
@@ -268,7 +284,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
|
||||
}
|
||||
assert.ok(observed, `observed a teammate's battle and came back (${c.log.join(' | ')})`);
|
||||
// and the next prep: the board without the pen, the pen only with 🔍▶▶
|
||||
const s1 = await c.waitFor((s) => s.phase === 'PREP', 'next prep', 90000);
|
||||
const s1 = await untilPrep(c, () => true, 'next prep');
|
||||
await sleep(1500);
|
||||
assertNoPen(await battleViewState(c), `R${s1.round} prep after observing`);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
@@ -344,6 +360,9 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
|
||||
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
// the draft first (a client still in the briefing would count as done without picking: its turn then runs out —
|
||||
// Match.BAND_TURN_SECONDS, 30 s each since user playtest #4 item 4)
|
||||
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
|
||||
// band draft: whoever's turn it is picks a free strategy
|
||||
const picked = new Set();
|
||||
const t0 = Date.now();
|
||||
|
||||
@@ -37,7 +37,7 @@ 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 { ChoiceView, 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');
|
||||
|
||||
@@ -366,14 +366,15 @@ describe('9: busy indicators and data loading', () => {
|
||||
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 });
|
||||
// (ChoiceView: the overlay's pure view — ChoiceOverlay keeps the two-tap selection, user playtest #4 item 2)
|
||||
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, 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 });
|
||||
const landed = ChoiceView({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, busyIdx: 1 });
|
||||
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Regression tests (Node) of user playtest #4, UI-flow items:
|
||||
// 2 机变 picks take two taps like the shop (ui/choiceOverlay.js): the first tap selects a card (gold frame, 确认选择 ·
|
||||
// 再次点击 strip, the header's 确认选择), the second — or 确认选择 — sends it; another card moves the selection; a card
|
||||
// that cannot be picked (not my turn, taken, a pick in flight) is never selected; the selection drops with it
|
||||
// 3 untimed phases show no timer at all: Countdown renders nothing without a deadline; the 机变 overlay of an untimed
|
||||
// draft (solo, a single human) shows no clock and says so
|
||||
// 7 the detail card's live stats (ui/detailPanel.js): battle / prep values coloured against the unit's base (green
|
||||
// up, red down, a shorter attack interval is up), the difference beside them, a 实时 / 开战时 tag; the battle HP
|
||||
// bar follows the live HP; the game screen asks g.unitStats for own board units in prep and reads the local sim in
|
||||
// battle
|
||||
// 1 (layout) on a notched phone the shop bar keeps its place at the bottom edge instead of rising by the bottom inset
|
||||
// over the bench pads — a bench piece is pressed / dropped on by its tile (css/devices.css)
|
||||
// 5 an effect-only item's card (the special 维式重锤, 突变细胞: items.json shopExcluded) says the shop never sells it
|
||||
// and where it comes from
|
||||
// (server side: test/match/unitStats.test.js, test/match/draft.test.js, test/ui/bandDraft.test.js; the runner:
|
||||
// test/match/runner.test.js)
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
// the browser data store reads the real data files from disk (the card 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 { ChoiceView, spTap, armedCard, cardPickable } = await import('../../public/js/ui/choiceOverlay.js');
|
||||
const { ChessDetail, ItemDetail, statTone, liveStat } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { Button } = await import('../../public/js/ui/components.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
|
||||
/** Every vnode of a preact tree (htm output), depth first; `expand` names components rendered in place (hook-free). */
|
||||
function* walk(v, expand = new Set()) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x, expand); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
if (typeof v.type === 'function' && expand.has(v.type.name)) { yield* walk(v.type(v.props), expand); return; }
|
||||
yield* walk(v.props?.children, expand);
|
||||
}
|
||||
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('');
|
||||
if (typeof v === 'object' && typeof v.type === 'function' && ['Stat', 'LiveTag'].includes(v.type.name)) return textOf(v.type(v.props));
|
||||
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: two-tap 机变 picks ----------------------------------------------------------------------------------------
|
||||
|
||||
describe('2: 机变 cards take two taps (select → confirm), like buying in the shop', () => {
|
||||
const sp = (o = {}) => ({
|
||||
family: 'bounty', name: '悬赏决策', desc: '', untimed: false, order: ['me', 'p2'], turnPid: 'me', pickOf: new Map(),
|
||||
cards: [{ idx: 0, name: 'A', desc: 'a', takenBy: null }, { idx: 1, name: 'B', desc: 'b', takenBy: null }, { idx: 2, name: 'C', desc: 'c', takenBy: 'p2' }],
|
||||
...o,
|
||||
});
|
||||
const me = { myId: 'me', solo: false };
|
||||
|
||||
test('spTap: the first tap selects, the second on the same card confirms, another card moves the selection', () => {
|
||||
assert.deepEqual(spTap(null, 0, true), { armed: 0, pick: null }, 'first tap: selected, nothing sent');
|
||||
assert.deepEqual(spTap(0, 0, true), { armed: null, pick: 0 }, 'second tap on it: sent');
|
||||
assert.deepEqual(spTap(0, 1, true), { armed: 1, pick: null }, 'another card: the selection moves');
|
||||
assert.deepEqual(spTap(1, 0, false), { armed: 1, pick: null }, 'a card that cannot be picked changes nothing');
|
||||
assert.deepEqual(spTap(null, 'x', true), { armed: null, pick: null });
|
||||
});
|
||||
|
||||
test('cardPickable / armedCard: only my turn, before my pick, a free card, nothing in flight — the selection drops otherwise', () => {
|
||||
const s = sp();
|
||||
assert.equal(cardPickable(s, s.cards[0], me), true);
|
||||
assert.equal(cardPickable(s, s.cards[2], me), false, 'taken');
|
||||
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], me), false, 'not my turn');
|
||||
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], { myId: 'me', solo: true }), true, 'solo: always my turn');
|
||||
assert.equal(cardPickable(sp({ pickOf: new Map([['me', 1]]) }), s.cards[0], me), false, 'after my pick');
|
||||
assert.equal(cardPickable(s, s.cards[0], { ...me, busyIdx: 1 }), false, 'a pick in flight');
|
||||
assert.equal(armedCard(0, s, me), 0);
|
||||
assert.equal(armedCard(2, s, me), null, 'a taken card is never selected');
|
||||
assert.equal(armedCard(0, sp({ turnPid: 'p2' }), me), null, 'the turn moved on (timeout)');
|
||||
assert.equal(armedCard(0, s, { ...me, busyIdx: 0 }), null);
|
||||
assert.equal(armedCard(7, s, me), null);
|
||||
assert.equal(armedCard(null, s, me), null);
|
||||
});
|
||||
|
||||
test('ChoiceView: the selected card lifts with a 确认选择 · 再次点击 strip, the header offers 确认选择; taps go through onTap', () => {
|
||||
const s = sp();
|
||||
const taps = [];
|
||||
let confirmed = 0;
|
||||
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }], deadline: 0 }, sp: s, ...me,
|
||||
armed: 1, onTap: (i) => taps.push(i), onConfirm: () => { confirmed++; } });
|
||||
assert.ok(hasClass(v, 'has-armed'));
|
||||
const nodes = [...walk(v)];
|
||||
const cards = nodes.filter((n) => hasClass(n, 'spcard'));
|
||||
assert.equal(cards.length, 3);
|
||||
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), [false, true, false]);
|
||||
assert.equal(cards[1].props['aria-pressed'], 'true');
|
||||
assert.match(cards[1].props['aria-label'], /已选中,再次点击确认/);
|
||||
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__confirm'))), '确认选择再次点击');
|
||||
const btn = nodes.find((n) => n.type === Button);
|
||||
assert.ok(btn && textOf(btn.props.children).includes('确认选择'), 'the header confirm');
|
||||
btn.props.onClick();
|
||||
assert.equal(confirmed, 1);
|
||||
cards[0].props.onClick();
|
||||
cards[1].props.onClick();
|
||||
cards[2].props.onClick(); // taken: disabled, never tapped
|
||||
assert.deepEqual(taps, [0, 1]);
|
||||
assert.equal(cards[2].props.disabled, true);
|
||||
// nothing selected: no strip, no confirm button, the hint says how it works
|
||||
const idle = [...walk(ChoiceView({ pub: { players: [] }, sp: s, ...me }))];
|
||||
assert.ok(!idle.some((n) => hasClass(n, 'spcard__confirm')));
|
||||
assert.ok(!idle.some((n) => n.type === Button));
|
||||
assert.match(textOf(idle.find((n) => hasClass(n, 'spov__sub'))), /点击卡牌选中,再次点击确认/);
|
||||
});
|
||||
|
||||
test('3: an untimed 机变 (solo, a single human) shows no clock and says so; a timed one keeps its countdown', () => {
|
||||
const timed = [...walk(ChoiceView({ pub: { players: [], deadline: Date.now() + 9000 }, sp: sp(), ...me }))];
|
||||
assert.ok(timed.some((n) => n.type?.name === 'Countdown'));
|
||||
assert.match(textOf(timed.find((n) => hasClass(n, 'spov__sub'))), /倒计时结束后仍未选定将自动分配/);
|
||||
const lone = [...walk(ChoiceView({ pub: { players: [], deadline: 0 }, sp: sp({ untimed: true }), ...me }))];
|
||||
assert.ok(!lone.some((n) => n.type?.name === 'Countdown'), 'a single human\'s co-op draft: untimed');
|
||||
assert.match(textOf(lone.find((n) => hasClass(n, 'spov__sub'))), /无时间限制/);
|
||||
});
|
||||
|
||||
test('the overlay keeps the selection itself and drops it on Esc / a tap elsewhere (source contract)', () => {
|
||||
const src = read('public/js/ui/choiceOverlay.js');
|
||||
assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/);
|
||||
assert.match(src, /e\.key === 'Escape'/);
|
||||
assert.match(src, /closest\('\.spcard, \.spov__confirm'\)/);
|
||||
const css = read('public/css/screens/game-panels.css');
|
||||
assert.match(css, /\.spcard\.is-armed/);
|
||||
assert.match(css, /\.spcard__confirm \{/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 3: no timer without a deadline --------------------------------------------------------------------------------
|
||||
|
||||
describe('3: untimed phases show no timer', () => {
|
||||
test('Countdown renders nothing without a deadline (the "--" placeholder read as a timer)', () => {
|
||||
const comp = read('public/js/ui/components.js');
|
||||
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('// ---- Modal'));
|
||||
assert.match(fn, /if \(remain == null\) return null;/);
|
||||
// after the hooks (a component may toggle between timed and untimed)
|
||||
assert.ok(fn.indexOf('useEffect(') < fn.indexOf('if (remain == null) return null;'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 7: live stats -------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('7: the detail card shows live stats against the base', () => {
|
||||
test('statTone: higher is up, a shorter attack interval is up, rounding noise is equal', () => {
|
||||
assert.equal(statTone('atk', 1042, 501), 'up');
|
||||
assert.equal(statTone('atk', 400, 501), 'down');
|
||||
assert.equal(statTone('atk', 501.3, 501), null);
|
||||
assert.equal(statTone('interval', 0.9, 1.05), 'up', 'faster attacks help');
|
||||
assert.equal(statTone('interval', 1.3, 1.05), 'down');
|
||||
assert.equal(statTone('interval', 1.052, 1.05), null);
|
||||
assert.equal(statTone('res', 12.3, 10), 'up');
|
||||
assert.equal(statTone('res', 10.02, 10), null);
|
||||
assert.equal(statTone('blockCnt', 2, 3), 'down');
|
||||
assert.equal(statTone('atk', null, 3), null);
|
||||
});
|
||||
|
||||
test('liveStat: the live value with its difference and the base in the title; the record value without live stats', () => {
|
||||
const live = { atk: 1042, interval: 0.95, res: 12.5, base: { atk: 501, interval: 1.05, res: 10 } };
|
||||
assert.deepEqual(liveStat(live, 'atk', 501), { v: '1,042', tone: 'up', sub: '+541', title: '基础 501' });
|
||||
const iv = liveStat(live, 'interval', 1.05, (v) => `${v.toFixed(2)}s`);
|
||||
assert.deepEqual(iv, { v: '0.95s', tone: 'up', sub: '−0.10', title: '基础 1.05s' });
|
||||
assert.equal(liveStat(live, 'res', 10, String).sub, '+2.5');
|
||||
assert.deepEqual(liveStat(null, 'atk', 501), { v: '501', tone: null, sub: null, title: undefined });
|
||||
assert.equal(liveStat(null, 'atk', null).v, '—');
|
||||
assert.deepEqual(liveStat({ atk: 501, base: { atk: 501 } }, 'atk', 501), { v: '501', tone: null, sub: null, title: '基础 501' });
|
||||
});
|
||||
|
||||
test('a rendered card: live values coloured, the 实时 / 开战时 tag, the battle HP bar from the live HP', async () => {
|
||||
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
|
||||
const c = data.lookup('chess', 'chess_char_1_01_a');
|
||||
assert.ok(c);
|
||||
const s = c.stats;
|
||||
const live = {
|
||||
src: 'battle', hp: 300, maxHp: Math.round(s.maxHp * 1.2), atk: Math.round(s.atk * 0.8), def: s.def, res: s.res ?? 0,
|
||||
interval: 0.5, blockCnt: s.blockCnt,
|
||||
base: { maxHp: s.maxHp, atk: s.atk, def: s.def, res: s.res ?? 0, interval: 1, blockCnt: s.blockCnt },
|
||||
};
|
||||
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live });
|
||||
const stats = blocks.find((b) => b.key === 'stats');
|
||||
assert.ok(hasClass(stats, 'is-live'));
|
||||
assert.equal(stats.props['data-live'], 'battle');
|
||||
const cells = [...walk(stats, new Set(['Stat', 'LiveTag']))].filter((n) => hasClass(n, 'dstat'));
|
||||
const cell = (k) => cells.find((n) => textOf(n).startsWith(k));
|
||||
assert.ok(hasClass(cell('生命上限'), 'is-up'));
|
||||
assert.ok(hasClass(cell('攻击'), 'is-down') && !hasClass(cell('攻击间隔'), 'is-down'));
|
||||
assert.ok(hasClass(cell('攻击间隔'), 'is-up'), 'a shorter interval is a buff');
|
||||
assert.ok(!hasClass(cell('防御'), 'is-up') && !hasClass(cell('防御'), 'is-down'), 'unchanged');
|
||||
assert.ok(textOf(stats).includes('实时'));
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
const hp = [...walk(head)].find((n) => hasClass(n, 'dhp'));
|
||||
assert.ok(textOf(hp).includes(`300 / ${live.maxHp.toLocaleString('en-US')}`), 'the live HP');
|
||||
// the prep preview: 开战时, no HP bar
|
||||
const prep = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live: { ...live, src: 'prep' } });
|
||||
assert.ok(textOf(prep.find((b) => b.key === 'stats')).includes('开战时'));
|
||||
assert.ok(![...walk(prep.find((b) => b.key === 'head'))].some((n) => hasClass(n, 'dhp')));
|
||||
// no live stats: the record's numbers, no tag
|
||||
const plain = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
|
||||
const pstats = plain.find((b) => b.key === 'stats');
|
||||
assert.ok(!hasClass(pstats, 'is-live'));
|
||||
assert.ok(!textOf(pstats).includes('实时') && !textOf(pstats).includes('开战时'));
|
||||
});
|
||||
|
||||
test('the game screen feeds it: g.unitStats for an own board unit in prep (newest answer only), the local sim in battle', () => {
|
||||
const src = read('public/js/screens/game.js');
|
||||
assert.match(src, /net\.on\('m\.unitStats'/);
|
||||
assert.match(src, /msg\.seq !== statsSeqRef\.current/, 'only the newest request\'s answer');
|
||||
assert.match(src, /net\.request\('g\.unitStats', \{ seq: statsSeqRef\.current \}\)/);
|
||||
assert.match(src, /battleRunner\.unitStats\(uid, fid\)/);
|
||||
assert.match(src, /live=\$\{liveStats\}/);
|
||||
const panel = read('public/js/ui/detailPanel.js');
|
||||
assert.match(panel, /useTicker\(detail && getter \? 250 : 0\)/, 'a getter is re-read 4× a second');
|
||||
// an own board piece's card opened in prep and left open into the battle turns live (its unit found by uid)
|
||||
assert.match(src, /battleRunner\.unitIdOf\(pieceUid, myId, fid\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 1 (layout) / 5 ------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('1 / 5: the bench stays reachable on a notched phone; effect-only items say where they come from', () => {
|
||||
test('devices.css: the shop bar steps back over the HUD\'s bottom inset (it covered ~3/4 of bench pads 2–9)', () => {
|
||||
const css = read('public/css/devices.css');
|
||||
assert.match(css, /\.gm__hud > \.shopbar \{ bottom: calc\(\.2rem - var\(--sa-b\)\); \}/);
|
||||
// the shop's own rule stays the desktop place (.2rem from the bottom): the inset rule only cancels the HUD inset
|
||||
assert.match(read('public/css/screens/game-shop.css'), /\.shopbar \{\n {2}position: absolute; right: \.26rem; bottom: \.2rem;/);
|
||||
});
|
||||
|
||||
test('ItemDetail: 调度中心不出售 + the source for the special 维式重锤 / 突变细胞, nothing for a shop item', async () => {
|
||||
await data.loadAll('items', 'assets');
|
||||
const special = data.lookup('items', 'chess_item_4_09_e_a'); // 灼燃维式重锤
|
||||
const cell = data.lookup('items', 'chess_item_5_08_e_a'); // 突变细胞
|
||||
assert.ok(special?.shopExcluded && cell?.shopExcluded, 'items.json marks them');
|
||||
const plain = data.list('items').find((i) => i.itemType === 'EQUIP' && !i.shopExcluded && !i.isGolden);
|
||||
const note = (item) => [...walk(ItemDetail({ item, piece: null, editable: false }))].find((n) => hasClass(n, 'dhint--source'));
|
||||
assert.match(textOf(note(special)), /调度中心不出售 · 获取途径:维多利亚盟约每25层 \/ 洛洛的定制品/);
|
||||
assert.match(textOf(note(cell)), /昆图斯/);
|
||||
assert.equal(note(plain), undefined, 'a shop item has no such line');
|
||||
});
|
||||
});
|
||||
@@ -542,7 +542,9 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
const s = await c.st();
|
||||
if (s.phase === 'SP_DRAFT' && s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
|
||||
if (!seen.has(`sp-shot-${s.round}`)) { seen.add(`sp-shot-${s.round}`); await sleep(500); await c.shot(`sp-r${s.round}-${c.label}`); }
|
||||
// two taps (user playtest #4 item 2): select, then tap the selected card again
|
||||
await c.click('.spcard.is-pickable');
|
||||
await c.click('.spcard.is-armed');
|
||||
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', 'sp pick', 8000);
|
||||
spPicked += 1;
|
||||
c.note(`机变 picked (round ${s.round})`);
|
||||
|
||||
@@ -23,7 +23,9 @@ const readCard = (c, timeout = 2500) => c.page.waitForSelector('.dpanel .dskill_
|
||||
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
|
||||
module: document.querySelector('.dpanel .dmodule')?.textContent || '',
|
||||
none: !!document.querySelector('.dpanel .dmodule.is-none'),
|
||||
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent, el.querySelector('.dstat__v')?.textContent])),
|
||||
// the record (base) value: a live card (user playtest #4 item 7) carries it as the cell's title "基础 N"
|
||||
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
|
||||
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
|
||||
}))).catch(() => null);
|
||||
|
||||
describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
@@ -80,6 +82,9 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se
|
||||
assert.deepEqual(await loOf(guest), { [BASE]: { skill: 0, module: 'none' } }, 'the guest\'s match loadout');
|
||||
assert.deepEqual(await loOf(host), {}, 'the host fights with the defaults');
|
||||
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
// the draft first (a client still in the briefing would count as done without picking: its turn then runs out —
|
||||
// Match.BAND_TURN_SECONDS, 30 s each since user playtest #4 item 4)
|
||||
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
|
||||
const picked = new Set();
|
||||
const t0 = Date.now();
|
||||
while (picked.size < 2 && Date.now() - t0 < 120000) {
|
||||
|
||||
+7
-22
@@ -1,6 +1,6 @@
|
||||
// Regression tests for in-match UI defects (round-1 hunt): terrain 机变 cards in the client placement mirror, the
|
||||
// result screen (hidden medal, rounds on a Hidden Core clear, remaining LP after the Final Assault), briefing bond
|
||||
// labels, watch targets, the view-switcher label, equipment dropped on an operator's sprite, illegal-drop reasons,
|
||||
// labels, watch targets, the view-switcher label, equipment dropped on an operator's tile, illegal-drop reasons,
|
||||
// full-hand shop cards, the solo exit text and htm's static vnode cache.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
@@ -10,7 +10,7 @@ import { buildDeployMap } from '../../server/match/board.js';
|
||||
import { applyCard } from '../../server/match/choices.js';
|
||||
import {
|
||||
placementContext, canPlace, deploySets, stageOverrides, deployMap, effectiveStage, normalizeResult, disabledBondSets,
|
||||
briefingBondTip, watchTarget, switcherLabel, pickPieceAt, equipRetarget, dropIntent, dropFailureReason, shopBlockReason,
|
||||
briefingBondTip, watchTarget, switcherLabel, dropIntent, dropFailureReason, shopBlockReason,
|
||||
completesMerge, handFull, tileKey,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
@@ -202,27 +202,12 @@ describe('equipment dropped on an operator', () => {
|
||||
};
|
||||
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
|
||||
|
||||
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
|
||||
];
|
||||
assert.equal(pickPieceAt(rects, 130, 450), 1, 'overlap: the nearer unit');
|
||||
assert.equal(pickPieceAt(rects, 130, 360), 2);
|
||||
assert.equal(pickPieceAt(rects, 10, 10), null);
|
||||
assert.equal(pickPieceAt(rects, NaN, 10), null);
|
||||
});
|
||||
|
||||
test('an item released over the upper body (tile behind the unit) equips the unit', () => {
|
||||
const elsewhere = { area: 'board', row: 10, col: 4 };
|
||||
test('an item dropped on the unit\'s own tile equips it; the tile behind it (where its head is drawn) does not (user playtest #4 item 1: the tile decides)', () => {
|
||||
const head = { area: 'board', row: 11, col: 3 }; // the empty tile behind unit 2 (10,3), where its head is drawn
|
||||
assert.equal(canPlace(ctx, 3, head).ok, false, 'the tile itself is empty');
|
||||
const t = equipRetarget(ctx, 3, head, 2);
|
||||
assert.deepEqual(t, { area: 'board', row: 10, col: 3 });
|
||||
assert.deepEqual(dropIntent(ctx, 3, t), { t: 'g.equip', fields: { itemUid: 3, targetUid: 2 }, confirmReplace: false });
|
||||
assert.deepEqual(equipRetarget(ctx, 3, elsewhere, null), elsewhere, 'no sprite under the pointer: unchanged');
|
||||
assert.deepEqual(equipRetarget(ctx, 1, head, 2), head, 'units are not retargeted');
|
||||
assert.deepEqual(equipRetarget(ctx, 3, null, 1), { area: 'board', row: 9, col: 3 }, 'a release off the grid over a unit');
|
||||
assert.equal(canPlace(ctx, 3, head).ok, false, 'the tile itself is empty: nothing to equip there');
|
||||
assert.equal(dropIntent(ctx, 3, head), null);
|
||||
assert.deepEqual(dropIntent(ctx, 3, { area: 'board', row: 10, col: 3 }), { t: 'g.equip', fields: { itemUid: 3, targetUid: 2 }, confirmReplace: false });
|
||||
assert.deepEqual(dropIntent(ctx, 3, { area: 'board', row: 9, col: 3 }), { t: 'g.equip', fields: { itemUid: 3, targetUid: 1 }, confirmReplace: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user