Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
@@ -5,7 +5,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { ELEMENT_GAUGE_MAX } from '../../server/sim/constants.js';
|
||||
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
|
||||
import { sortEnemyTargets } from '../../server/sim/targeting.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
@@ -301,6 +301,33 @@ test('1_09 跃跃: 乐趣加倍 throws cnt boomerangs per attack, ATK +atk; 戏
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 tiles/s); faster than its flight she waits for the catch; 乐趣加倍 hits land together', () => {
|
||||
const id = 'chess_char_1_09_a', bb = bbOf(id);
|
||||
const flight = (d) => d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
|
||||
// ASPD +200 (interval 0.333 s) at 3 tiles: the out-and-back flight (1.0 s) sets the pace
|
||||
const h = run({
|
||||
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 7] }],
|
||||
setup(b) { b.on('deploy', ({ unit }) => { if (unit.kind === 'op') b.addBuff(unit, { key: 'test:aspd', mods: { aspd: 200 } }); }); },
|
||||
});
|
||||
const u = h.unit(id);
|
||||
h.run(6);
|
||||
assert.ok(u.s.interval < flight(3) / 2);
|
||||
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
|
||||
assert.ok(at.length >= 4 && at.length <= 7, `${at.length} attacks in 6 s`);
|
||||
for (let i = 1; i < at.length; i++) assert.ok(Math.abs(at[i] - at[i - 1] - flight(3)) <= 2 * h.TICK, `gap ${(at[i] - at[i - 1]).toFixed(3)} ≈ ${flight(3)}`);
|
||||
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
|
||||
done(h);
|
||||
// 乐趣加倍: cnt boomerangs on the same path — cnt hits at the same moment, then one catch
|
||||
const h2 = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }] });
|
||||
const u2 = h2.unit(id);
|
||||
h2.run(5);
|
||||
assert.ok(u2.skill.active);
|
||||
const byT = new Map();
|
||||
for (const c of dealt(h2, u2, (x) => x.dmg.isSkill)) byT.set(c.t, (byT.get(c.t) ?? 0) + 1);
|
||||
assert.ok(byT.size >= 3 && [...byT.values()].every((n) => n === bb.cnt), `hits per landing ${[...byT.values()]}`);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
|
||||
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
|
||||
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
|
||||
|
||||
@@ -72,7 +72,11 @@ test('盟约·辅助干员 (elite): element damage vs elite enemies uses the mod
|
||||
assert.ok(near(el.amount, u.s.atk * u.def.talents[0].bb.ep_damage_ratio_boss, 1e-3));
|
||||
});
|
||||
|
||||
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 % centre)', () => {
|
||||
/** 蕾缪安 S3 fx of the battle: [{ kind, x, y, ...extra }] for 'lock' / 'bombardShell' / 'bombard'. */
|
||||
const lemFx = (h, kind) => h.eventsOf('fx').filter((e) => e[1] === kind).map((e) => ({ x: e[2], y: e[3], ...e[4] }));
|
||||
const SHELL_IV = 0.3; // PRTS S3 note: "以0.3s为间隔" (not in the blackboard)
|
||||
|
||||
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s, then the shells land one by one every 0.3 s (360 % centre)', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
@@ -85,13 +89,94 @@ test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 %
|
||||
const start = h.b.time;
|
||||
assert.ok(u.findBuff('lemuen:extradition'), 'ATK +10 % after 20 s');
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 20));
|
||||
const end = h.b.time;
|
||||
const ammo = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
|
||||
assert.equal(ammo.length, bb['attack@trigger_time'] + t1.add_count);
|
||||
assert.ok(Math.abs(h.b.time - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
|
||||
const n = bb['attack@trigger_time'] + t1.add_count;
|
||||
assert.equal(ammo.length, n);
|
||||
assert.ok(Math.abs(end - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
|
||||
assert.equal(lemFx(h, 'lock').length, n, 'one lock mark per bullet');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 0, 'nothing lands all at once at the end');
|
||||
const atk = u.s.atk;
|
||||
h.run(SHELL_IV * (n + 1));
|
||||
// one shell every 0.3 s in lock order, each on a random point within ±emit_offset of its (stationary) target
|
||||
const shells = lemFx(h, 'bombardShell');
|
||||
assert.deepEqual(shells.map((s) => s.i), [...Array(n).keys()], 'shells fired one by one, in lock order');
|
||||
const spread = bb['attack@emit_offset'] + 0.01;
|
||||
for (const s of shells) {
|
||||
assert.ok(Math.abs(s.x - 5) <= spread && Math.abs(s.y - 10) <= spread, `aimed within ±${bb['attack@emit_offset']} of the lock (${s.x}, ${s.y})`);
|
||||
assert.equal(s.id, u.id);
|
||||
assert.equal(s.r, bb['attack@dist_2']);
|
||||
assert.ok(s.t > 0 && s.t <= SHELL_IV + 1e-9, `flight ${s.t} s`);
|
||||
}
|
||||
assert.ok(new Set(shells.map((s) => `${s.x},${s.y}`)).size > 1, 'the aim points are spread (seeded rng)');
|
||||
const bombs = h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard'));
|
||||
assert.equal(bombs.length, ammo.length, 'one bomb per lock');
|
||||
assert.ok(near(bombs[0].amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 %');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < h.b.time - 0.1).length, 0, 'no normal attacks while locking');
|
||||
assert.equal(bombs.length, n, 'one hit per shell on the single enemy (limited_hit_time 1)');
|
||||
bombs.forEach((b, i) => assert.ok(Math.abs(b.t - end - SHELL_IV * (i + 1)) <= 2 / 30 + 1e-9, `shell ${i} lands at +${(b.t - end).toFixed(3)} s`));
|
||||
assert.equal(lemFx(h, 'bombard').length, n, 'an explosion fx per shell');
|
||||
for (const b of bombs) assert.ok(near(b.amount, atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 % (the ATK when the skill ended)');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < end - 0.1).length, 0, 'no normal attacks while locking');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('蕾缪安: S3 lock marks follow a walking target and stay where it died; the outer ring deals 240 %', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
|
||||
enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [9, 6], end: [9, 2], checkpoints: [] } }, { key: 'enemy_dummy', pos: [10, 6] }],
|
||||
});
|
||||
const u = h.unit('chess_char_6_01_a');
|
||||
const bb = u.def.skill.bb;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 10));
|
||||
const walker = enemyAt(h, 'enemy_walker'), dummyE = enemyAt(h, 'enemy_dummy');
|
||||
const locks = lemFx(h, 'lock');
|
||||
assert.deepEqual(locks.slice(0, 2).map((l) => l.id).sort(), [walker.id, dummyE.id].sort(), 'unlocked targets first, then a new round');
|
||||
// the walker's shells aim at where it is when each is fired (it keeps walking), not where it was locked
|
||||
const firstLock = locks.find((l) => l.id === walker.id);
|
||||
const at0 = { x: walker.x, y: walker.y };
|
||||
assert.ok(Math.abs(at0.x - firstLock.x) > 0.6, `the walker moved on since its lock (${firstLock.x} → ${at0.x})`);
|
||||
const order = locks.map((l) => l.id);
|
||||
const spread = bb['attack@emit_offset'] + 0.01;
|
||||
const shell0 = lemFx(h, 'bombardShell')[0];
|
||||
if (order[0] === walker.id) assert.ok(Math.abs(shell0.x - at0.x) <= spread && Math.abs(shell0.y - at0.y) <= spread, 'first shell on the walker\'s current spot');
|
||||
// it dies before its later shells are fired: they fall where it died
|
||||
h.b.kill(walker);
|
||||
const died = { x: walker.x, y: walker.y };
|
||||
h.run(SHELL_IV * (order.length + 1));
|
||||
const shells = lemFx(h, 'bombardShell');
|
||||
assert.equal(shells.length, order.length);
|
||||
shells.forEach((s, i) => {
|
||||
if (order[i] !== walker.id || i === 0) return;
|
||||
assert.ok(Math.abs(s.x - died.x) <= spread && Math.abs(s.y - died.y) <= spread, `shell ${i} on the spot the walker left (${s.x}, ${s.y})`);
|
||||
});
|
||||
// the dummy one row below the walker's line: centre (≤ dist_1) or outer ring (≤ dist_2) damage per shell
|
||||
const onDummy = h.dmg.filter((d) => d.src === u.id && d.tgt === dummyE.id && d.tags.includes('bombard'));
|
||||
assert.ok(onDummy.every((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3) || near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)));
|
||||
assert.ok(onDummy.some((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)), 'a shell on the walker line reaches it with the outer ring (240 %)');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('蕾缪安: knocked out after S3 ended, the shell in the air still lands and the rest are dropped', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit('chess_char_6_01_a');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 10));
|
||||
assert.equal(lemFx(h, 'bombardShell').length, 1, 'the first shell is fired as the skill ends');
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.equal(u.alive, false);
|
||||
h.run(3);
|
||||
assert.equal(lemFx(h, 'bombardShell').length, 1, 'no shell after she left the field');
|
||||
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 1, 'the fired shell landed');
|
||||
// knocked out while locking: no bombardment at all
|
||||
const h2 = battle({ units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] });
|
||||
const u2 = h2.unit('chess_char_6_01_a');
|
||||
assert.ok(h2.runUntil(() => u2.skill.active && lemFx(h2, 'lock').length >= 2, 10));
|
||||
h2.b.dealDamage(null, u2, { amount: 1e9, type: 'true' });
|
||||
h2.run(3);
|
||||
assert.equal(lemFx(h2, 'bombardShell').length, 0);
|
||||
assert.equal(h2.dmg.filter((d) => d.src === u2.id && d.tags.includes('bombard')).length, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user