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:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+80 -2
View File
@@ -4,7 +4,9 @@
// m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a
// synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items
// locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from
// local art, 标准 = 战场#01 only.
// local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the shared picking
// rule / drag target, the live LP, the detail card order and the static game data.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -16,7 +18,12 @@ import { NET_DEFAULTS } from '../server/net.js';
import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR } from '../shared/constants.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../server/sim/constants.js';
import { SUB } from '../server/sim/professions.js';
import { BODY_H, PROBE_MARGIN } from '../public/js/render/pick.js';
import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js';
import { RETRY_DELAYS_MS } from '../public/js/data.js';
import { DATA, makeMatch } from './match/harness.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -173,3 +180,74 @@ test('solo pause: g.pause {on} is solo-only and m.public.paused follows (code)
assert.match(README, /暂停(独立模拟)/);
assert.match(PLAYING, /同盟模拟的作战不能暂停/);
});
test('temp overflow (user playtest #3 item 3): kept through the round start, resolved at the deadline of the first prep its player can act in (code) — every doc says so', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 4343, fake: true }).start();
const m = h.m;
const ps = h.ps('p_0');
// distinct plain equipment (two identical ones would merge)
const plain = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id);
assert.ok(plain.length > ps.hand.length);
h.toPrep(1);
assert.ok(h.drive(() => m.phase === PHASE.SETTLE && m.round === 1), 'SETTLE of R1');
ps.hand.fill(null);
plain.slice(0, ps.hand.length).forEach((id, i) => { ps.hand[i] = ps.newPiece('item', id); });
const late = ps.newPiece('item', plain[ps.hand.length]);
assert.equal(ps.stow(late), 'temp', 'a gain after the battle with a full hand overflows into temp');
h.toPrep(2);
assert.ok(ps.temp.includes(late), 'not wiped at the round start');
assert.equal(ps.tempDue(late), ps.prepsEnded, 'due at the end of this prep — the first one its player can act in');
assert.equal(ps.privateView().canReady, false, 'it blocks Ready');
assert.ok(h.drive(() => m.phase === PHASE.COMBAT && m.round === 2, { ready: false }), 'the prep deadline passes');
assert.ok(!ps.temp.includes(late), 'resolved at that deadline');
m.dispose();
assert.ok(!/temp hand wiped/.test(DESIGN), 'DESIGN §6.1: the round start no longer wipes temp');
assert.match(DESIGN, /PlayerState\.tempDue/);
assert.ok(!/temp wiped \(reward offers/.test(META) && !/wiped at the round start/.test(META), 'META §1 / rules');
assert.match(META, /PlayerState\.tempDue/);
assert.ok(!/下一回合开始时自动销毁/.test(PLAYING), 'PLAYING §3: no round-start destruction');
assert.match(PLAYING, /保留到\*\*下一个休整期\*\*/);
});
test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4–5): code and SIM / DESIGN agree', () => {
assert.equal(PROJECTILE_SPEEDS.boomerang, 15, 'PRTS 跃跃: out 15');
assert.equal(BOOMERANG_RETURN_SPEED, 3.75, 'PRTS 跃跃: back 3.75');
assert.equal(SUB.loopshooter.projectile, 'boomerang');
assert.equal(typeof SUB.loopshooter.canAttack, 'function', 'attacks only while holding the boomerang');
assert.match(SIM, /boomerang 15 out,\s*3\.75 back/);
assert.ok(!/next attack waits for the boomerang \(2 × distance \/ 10 s\)/.test(SIM), 'SIM §8: the old lob rule is gone');
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|chain\|chainHeal`/);
assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/);
// 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants
const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8');
assert.match(kit, /const LEMUEN_SHELL_INTERVAL = 0\.3;/);
assert.match(kit, /battle\.fx\('bombardShell'/);
assert.match(DESIGN, /'bombardShell' \{x, y, id: shooter, r, t: flight game s, i\}/);
assert.match(DESIGN, /ONE shell every 0\.3 s in lock order/);
assert.match(SIM, /S3 礼炮·强制追思/);
});
test('picking, drag target, lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => {
assert.equal(BODY_H, 1.27, 'median chibi height (tiles)');
assert.equal(PROBE_MARGIN, 0.5);
assert.match(DESIGN, /`PROBE_MARGIN` 0\.5 tile/);
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(app, /export const TOUCH_LIFT_TILES = 0\.6;/);
assert.match(DESIGN, /`TOUCH_LIFT_TILES` = 0\.6 tile above the finger/);
assert.match(DESIGN, /render\/pick\.js/);
assert.equal(SPINE_EVICT_DELAY_MS, 1000);
assert.equal(SPINE_QUIET_DELAY_MS, 3000);
assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/);
assert.match(DESIGN, /`SPINE_QUIET_DELAY_MS` \(3 s\)/);
assert.deepEqual([...RETRY_DELAYS_MS], [600, 2000]);
assert.match(DESIGN, /after 600 ms and 2 s \(`RETRY_DELAYS_MS`\)/);
const panel = readFileSync(join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
assert.match(panel, /CHESS_SECTIONS = Object\.freeze\(\['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents'/);
assert.match(DESIGN, /`detailPanel\.js CHESS_SECTIONS`\): header .* → \*\*特质\*\*/);
assert.match(panel, /export function garrisonTypeIconKey\(garrison\)/);
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
});