feedback1 integrate: WJ BOSS_HIT share per boss round; stale-round ticker lines dropped

This commit is contained in:
sganggs committed 2026-10-03 06:54:47 +08:00
commit c59b569dc7
7 files changed
+407 -11

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
// Player report after 0.1.0, client part: "隐藏boss还没打就出了造成50%伤害播报". Besides the server's share (fixed in
// server/match/Match.js, test/match/bosshit-ticker.test.js) the ticker strip plays its queue in order, TICKER_MS (5.2 s)
// per line and up to QUEUE_MAX (4) lines behind: in a headless-Chrome run the Final Assault's "超过20%" / "超过80%"
// lines played 2–8 s into the Hidden Core with its leader at 96 % / 53 %. A leader-damage line (BOSS_HIT) now plays
// only during the round it came in, and a player's newer BOSS_HIT line of the round supersedes their older one, queued
// or on screen (ui/ticker.js: tickerLineLive, tickerSupersedes, enqueueTickerLines, pruneTickerLines, nextTickerLine;
// main.js stamps each line with its type, player and round). The strip component is a thin shell over these helpers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const { tickerLineLive, tickerSupersedes, enqueueTickerLines, pruneTickerLines, nextTickerLine } = await import('../../public/js/ui/ticker.js');
let ids = 0;
/** A BOSS_HIT line of `playerId` for threshold `pct` that came in during `round`. */
const hit = (playerId, pct, round) => ({ id: ++ids, type: 'BOSS_HIT', playerId, round, text: `${playerId}博士对敌方领袖造成的伤害超过${pct}%!` });
const line = (type, round, text = type) => ({ id: ++ids, type, playerId: null, round, text });
const texts = (q) => q.map((t) => t.text);
test('tickerLineLive: a BOSS_HIT line plays only in the round it came in; every other line always plays', () => {
const fa = hit('P', 50, 14);
assert.equal(tickerLineLive(fa, 14), true, 'during the Final Assault');
assert.equal(tickerLineLive(fa, 15), false, 'the Hidden Core\'s round: the Final Assault\'s line is stale');
assert.equal(tickerLineLive({ ...fa, round: 15 }, 15), true, 'the Hidden Core\'s own line');
// solo FUNNY's boss round 9
assert.equal(tickerLineLive({ ...fa, round: 9 }, 9), true);
// other lines are not about the leader in play
for (const type of ['SHOP_LEVEL', 'GOLDEN_CHAR', 'CHAR_DAMAGE', 'CHAR_GIFT', 'CUSTOM', null, undefined]) {
assert.equal(tickerLineLive({ type, round: 14, text: 'x' }, 15), true, String(type));
}
// no round known (an old line, a reconnect before m.public): plays
assert.equal(tickerLineLive({ type: 'BOSS_HIT', round: null }, 15), true);
assert.equal(tickerLineLive(fa, null), true);
assert.equal(tickerLineLive(null, 15), true);
});
test('round 14 → 15: the Final Assault\'s leader-damage lines leave the queue and the strip; other lines stay, in order', () => {
const queue = [hit('A', 50, 14), line('GOLDEN_CHAR', 14, 'A博士的X干员晋升为精锐'), hit('B', 20, 14), hit('A', 80, 14), line('CUSTOM', 15, '隐秘核心已解锁')];
// a Final Assault line on screen when the Hidden Core's round starts
const r = pruneTickerLines(queue, hit('B', 50, 14), 15);
assert.equal(r.dropCur, true, 'the Final Assault line on screen leaves');
assert.deepEqual(texts(r.queue), ['A博士的X干员晋升为精锐', '隐秘核心已解锁']);
const n = nextTickerLine(r.queue, 15);
assert.equal(n.line.text, 'A博士的X干员晋升为精锐');
assert.deepEqual(texts(n.queue), ['隐秘核心已解锁']);
// a CUSTOM / CHAR_DAMAGE line on screen stays
assert.equal(pruneTickerLines(queue, line('CUSTOM', 14), 15).dropCur, false);
assert.equal(pruneTickerLines(queue, line('CHAR_DAMAGE', 14), 15).dropCur, false);
assert.equal(pruneTickerLines(queue, null, 15).dropCur, false);
// the same round (a field's prep → battle, a reconnect re-sending m.public): nothing leaves
assert.equal(pruneTickerLines(queue, hit('B', 50, 14), 14).dropCur, false);
assert.equal(pruneTickerLines(queue, null, 14).queue.length, queue.length);
// the Hidden Core's own lines live in its round
const hidden = pruneTickerLines([hit('A', 20, 15)], null, 15);
assert.equal(hidden.queue.length, 1);
});
test('nextTickerLine skips stale leader-damage lines (the line timer firing after a round change)', () => {
const queue = [hit('A', 20, 14), hit('B', 50, 14), line('SHOP_LEVEL', 14, 'B博士将调度中心等级提升为6级'), hit('A', 20, 15)];
const a = nextTickerLine(queue, 15);
assert.equal(a.line.text, 'B博士将调度中心等级提升为6级');
const b = nextTickerLine(a.queue, 15);
assert.equal(b.line.text, 'A博士对敌方领袖造成的伤害超过20%!');
assert.equal(b.line.round, 15);
assert.deepEqual(nextTickerLine(b.queue, 15), { line: null, queue: [] });
assert.deepEqual(nextTickerLine([hit('A', 20, 14)], 15), { line: null, queue: [] }, 'only stale lines: nothing to show');
// the queue passed in is not changed
assert.equal(queue.length, 4);
});
test('a player\'s newer leader-damage line of the round supersedes their older one, on screen or queued', () => {
assert.equal(tickerSupersedes(hit('A', 50, 14), hit('A', 20, 14)), true);
assert.equal(tickerSupersedes(hit('A', 50, 14), hit('B', 20, 14)), false, 'another player');
assert.equal(tickerSupersedes(hit('A', 20, 15), hit('A', 80, 14)), false, 'another round (the Hidden Core after the Final Assault)');
assert.equal(tickerSupersedes(hit(null, 50, 14), hit(null, 20, 14)), false, 'player unknown');
assert.equal(tickerSupersedes(line('CHAR_DAMAGE', 14), line('CHAR_DAMAGE', 14)), false, 'other line types queue as before');
assert.equal(tickerSupersedes(hit('A', 50, 14), line('CUSTOM', 14)), false);
// on screen: A's 20 % is replaced at once by A's 50 %, then 80 % (one batch)
const a20 = hit('A', 20, 14);
const onScreen = enqueueTickerLines([], a20, [hit('A', 50, 14), hit('A', 80, 14)]);
assert.equal(onScreen.cur.text, 'A博士对敌方领袖造成的伤害超过80%!');
assert.deepEqual(onScreen.queue, []);
// queued: B's 20 % gives way to B's 50 %, which takes its turn at the back; other lines keep their order
const shop = line('SHOP_LEVEL', 14, 'A博士将调度中心等级提升为6级');
const queued = enqueueTickerLines([hit('B', 20, 14), shop], a20, [hit('B', 50, 14)]);
assert.equal(queued.cur, a20, 'the line on screen is another player\'s: it stays');
assert.deepEqual(texts(queued.queue), ['A博士将调度中心等级提升为6级', 'B博士对敌方领袖造成的伤害超过50%!']);
// nothing on screen: lines queue (the strip then shows the first)
const idle = enqueueTickerLines([], null, [hit('A', 20, 14), line('CUSTOM', 14, 'x')]);
assert.equal(idle.cur, null);
assert.equal(idle.queue.length, 2);
// only the newest QUEUE_MAX (4) stay queued
const many = enqueueTickerLines([], a20, [1, 2, 3, 4, 5, 6].map((i) => line('CUSTOM', 14, `c${i}`)));
assert.deepEqual(texts(many.queue), ['c3', 'c4', 'c5', 'c6']);
});
/**
* The strip's timeline, composed from the helpers the way ui/ticker.js composes them: each event is
* { at (s), round } with `lines` arriving (stamped with that round) or a round change. Returns what is on screen when.
*/
function playStrip(events, until) {
const TICKER_S = 5.2;
let queue = [], cur = null, curUntil = Infinity, round = null;
const shown = [];
const show = (it, at) => { cur = it; curUntil = it ? at + TICKER_S : Infinity; if (it) shown.push({ at, round, text: it.text }); };
const advance = (to) => {
while (cur && curUntil <= to) { const at = curUntil; const n = nextTickerLine(queue, round); queue = n.queue; show(n.line, at); }
};
for (const ev of [...events, { at: until }]) {
advance(ev.at);
if (ev.round != null && ev.round !== round) {
round = ev.round;
const r = pruneTickerLines(queue, cur, round);
queue = r.queue;
if (r.dropCur) { const n = nextTickerLine(queue, round); queue = n.queue; show(n.line, ev.at); }
}
if (ev.lines) {
const fresh = ev.lines.map((l) => ({ ...l, round }));
const r = enqueueTickerLines(queue, cur, fresh);
queue = r.queue;
if (r.cur !== cur) show(r.cur, ev.at);
else if (!cur) { const n = nextTickerLine(queue, round); queue = n.queue; show(n.line, ev.at); }
}
}
return shown;
}
test('timeline: a Final Assault burst shows its top milestone before the round ends, and none of it plays over the Hidden Core', () => {
// the reviewer's headless run: the Final Assault ends in a burst (20 → 50 → 80 % within 1.5 s), a shop-level line is
// still queued, the Hidden Core's round starts 3 s later (SETTLE) with "隐秘核心已解锁"
const shown = playStrip([
{ at: 0, round: 14, lines: [line('SHOP_LEVEL', null, 'B博士将调度中心等级提升为6级')] },
{ at: 1, round: 14, lines: [hit('A', 20)] },
{ at: 2, round: 14, lines: [hit('A', 50)] },
{ at: 2.5, round: 14, lines: [hit('A', 80)] },
{ at: 5.5, round: 15, lines: [line('CUSTOM', null, '隐秘核心已解锁')] },
{ at: 30, round: 15, lines: [hit('A', 20)] },
], 60);
const r14 = shown.filter((s) => s.round === 14).map((s) => s.text);
const r15 = shown.filter((s) => s.round === 15).map((s) => s.text);
// the shop line plays first (0 – 5.2 s); A's lines collapse into the 80 % line, which plays at 5.2 s until the round change
assert.deepEqual(r14, ['B博士将调度中心等级提升为6级', 'A博士对敌方领袖造成的伤害超过80%!']);
assert.ok(!r15.some((t) => t.includes('超过80%') || t.includes('超过50%')), `no Final Assault line in the Hidden Core: ${r15.join(' | ')}`);
assert.deepEqual(r15, ['隐秘核心已解锁', 'A博士对敌方领袖造成的伤害超过20%!']);
assert.equal(shown.find((s) => s.text === '隐秘核心已解锁').at, 5.5, 'the Hidden Core\'s unlock line shows as its round starts');
});
test('main.js stamps every ticker line with its type, player and round; the strip is built on the helpers', () => {
const main = readFileSync(new URL('../../public/js/main.js', import.meta.url), 'utf8');
const handler = main.slice(main.indexOf("net.on('m.ticker'"), main.indexOf("net.on('m.emote'"));
assert.match(handler, /type, playerId, round:\s*s\.match\?\.public\?\.round/, 'the m.ticker handler keeps type, player and round');
const strip = readFileSync(new URL('../../public/js/ui/ticker.js', import.meta.url), 'utf8');
const comp = strip.slice(strip.indexOf('export function Ticker('));
assert.match(comp, /useStore\(\(s\) => s\.match\?\.public\?\.round/, 'the strip follows m.public.round');
for (const fn of ['enqueueTickerLines(', 'pruneTickerLines(', 'nextTickerLine(']) assert.ok(comp.includes(fn), `the strip uses ${fn})`);
assert.match(comp, /\}, \[round\]\);/, 'a round change re-filters the queue and the line on screen');
});