diff --git a/docs/META.md b/docs/META.md
index 85bee1f9..113cc497 100644
--- a/docs/META.md
+++ b/docs/META.md
@@ -595,7 +595,8 @@ result may only name the unit types its lineup can field, fields.js validateClie
current leader per player, each threshold once per boss round: the player's damage to that round's shared pool,
`SharedBossPool.byPlayer`, over its size — the Final Assault and the Hidden Core count apart, while `stats.bossDamage`,
the result's 领袖伤害, adds both up; the browser's strip, which plays its queue 5.2 s per line, drops a BOSS_HIT line
-once the round it came in is over — public/js/ui/ticker.js `tickerLineLive`), CHAR_GIFT (to the receiver only), plus
+once the round it came in is over, and a player's newer BOSS_HIT line of the round replaces their older one, queued or
+on screen — public/js/ui/ticker.js `tickerLineLive`, `tickerSupersedes`), CHAR_GIFT (to the receiver only), plus
CUSTOM texts (eliminations, 联防, hidden core).
---
diff --git a/public/js/main.js b/public/js/main.js
index 785974f8..75fafcc3 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -217,9 +217,11 @@ function wireNet() {
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
- // type + the round it came in: a BOSS_HIT line is dropped once its boss round is over (ui/ticker.js tickerLineLive)
+ // type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
+ // same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
const type = typeof msg.type === 'string' ? msg.type : null;
- store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, round: s.match?.public?.round ?? null }] }));
+ const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
+ store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
diff --git a/public/js/ui/ticker.js b/public/js/ui/ticker.js
index 95091b0d..f44c08d1 100644
--- a/public/js/ui/ticker.js
+++ b/public/js/ui/ticker.js
@@ -3,7 +3,10 @@
// A leader-damage line (BOSS_HIT "{0}博士对敌方领袖造成的伤害超过X%!") is news about the leader in play: it is dropped,
// queued or on screen, once the round it came in is over — the queue can lag a line by up to QUEUE_MAX × TICKER_MS, and
// a Final Assault line must never play over the Hidden Core's fresh leader (player report after 0.1.0: "隐藏boss还没打
-// 就出了造成50%伤害播报"). Lines carry `type` and `round` from main.js (the m.ticker handler).
+// 就出了造成50%伤害播报"). A player's newer BOSS_HIT line of the same round supersedes their older one, queued or on
+// screen (it names a higher threshold: the server announces each player's thresholds once each, rising), so a burst
+// finish shows its highest milestone before the round ends. Lines carry `type`, `playerId` and `round` from main.js
+// (the m.ticker handler). The queue rules are the pure helpers below (test/ui/bosshit-ticker.test.js).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html } from './components.js';
@@ -14,10 +17,14 @@ import { audio } from '../audio.js';
const TICKER_MS = 5200;
const QUEUE_MAX = 4;
+/**
+ * @typedef {{ id: number, text: string, at?: number, type?: string|null, playerId?: string|null, round?: number|null }} TickerLine
+ */
+
/**
* Whether a ticker line may still play in `round` (m.public.round): every line except a BOSS_HIT line from another
* round (the Final Assault's lines once the Hidden Core's round started). Lines without a round always play.
- * @param {{ type?: string|null, round?: number|null }|null} line
+ * @param {TickerLine|null} line
* @param {number|null} round
*/
export function tickerLineLive(line, round) {
@@ -25,6 +32,60 @@ export function tickerLineLive(line, round) {
return line.round == null || round == null || line.round === round;
}
+/**
+ * Whether `line` makes `old` redundant: both BOSS_HIT lines of the same (known) player from the same round. The newer one
+ * names the higher threshold.
+ * @param {TickerLine|null} line
+ * @param {TickerLine|null} old
+ */
+export function tickerSupersedes(line, old) {
+ return !!line && !!old && line.type === 'BOSS_HIT' && old.type === 'BOSS_HIT'
+ && line.playerId != null && line.playerId === old.playerId && line.round === old.round;
+}
+
+/**
+ * Takes `fresh` lines in: a line that supersedes the one on screen replaces it there, one that supersedes a queued line
+ * takes that line's place at the back of the queue; the rest queue behind (only the newest `max` are kept).
+ * @param {TickerLine[]} queue
+ * @param {TickerLine|null} cur the line on screen
+ * @param {TickerLine[]} fresh
+ * @returns {{ queue: TickerLine[], cur: TickerLine|null }} `cur` is a fresh line when it replaced the one on screen
+ */
+export function enqueueTickerLines(queue, cur, fresh, max = QUEUE_MAX) {
+ let q = [...queue];
+ let c = cur;
+ for (const t of fresh) {
+ if (!t) continue;
+ if (c && tickerSupersedes(t, c)) { c = t; continue; }
+ q = q.filter((o) => !tickerSupersedes(t, o));
+ q.push(t);
+ }
+ return { queue: q.slice(-max), cur: c };
+}
+
+/**
+ * A round change: BOSS_HIT lines from another round leave the queue, and the line on screen goes if it is one.
+ * @param {TickerLine[]} queue
+ * @param {TickerLine|null} cur
+ * @param {number|null} round
+ */
+export function pruneTickerLines(queue, cur, round) {
+ return { queue: queue.filter((t) => tickerLineLive(t, round)), dropCur: !!cur && !tickerLineLive(cur, round) };
+}
+
+/**
+ * The next line to show in `round` and the queue after it (stale BOSS_HIT lines are skipped).
+ * @param {TickerLine[]} queue
+ * @param {number|null} round
+ * @returns {{ line: TickerLine|null, queue: TickerLine[] }}
+ */
+export function nextTickerLine(queue, round) {
+ const q = [...queue];
+ let line = q.shift() || null;
+ while (line && !tickerLineLive(line, round)) line = q.shift() || null;
+ return { line, queue: q };
+}
+
/** Ticker strip bound to store.ticker. */
export function Ticker() {
const items = useStore((s) => s.ticker);
@@ -46,21 +107,22 @@ export function Ticker() {
const fresh = list.filter((t) => t && t.id > seen.current && Date.now() - t.at < 30000);
if (!fresh.length) return;
seen.current = fresh[fresh.length - 1].id;
- queue.current = [...queue.current, ...fresh].slice(-QUEUE_MAX);
- if (!cur) next();
+ const r = enqueueTickerLines(queue.current, cur, fresh);
+ queue.current = r.queue;
+ if (r.cur !== cur) show(r.cur); // a player's newer leader-damage line replaces their older one on screen
+ else if (!cur) next();
}, [items]);
// a new round: the last round's leader-damage lines leave the queue and the strip
useEffect(() => {
- queue.current = queue.current.filter((t) => tickerLineLive(t, round));
- if (cur && !tickerLineLive(cur, round)) next();
+ const r = pruneTickerLines(queue.current, cur, round);
+ queue.current = r.queue;
+ if (r.dropCur) next();
}, [round]);
useEffect(() => () => clearTimeout(timer.current), []);
- function next() {
- let it = queue.current.shift() || null;
- while (it && !tickerLineLive(it, roundRef.current)) it = queue.current.shift() || null;
+ function show(it) {
setCur(it);
clearTimeout(timer.current);
if (it) {
@@ -69,6 +131,12 @@ export function Ticker() {
}
}
+ function next() {
+ const r = nextTickerLine(queue.current, roundRef.current);
+ queue.current = r.queue;
+ show(r.line);
+ }
+
if (!cur) return null;
return html`
<${GIcon} name="flag" class="ticker__icon" /><${RichText} text=${cur.text} />
diff --git a/test/match/bosshit-ticker.test.js b/test/match/bosshit-ticker.test.js
index 1f5b8c47..12132971 100644
--- a/test/match/bosshit-ticker.test.js
+++ b/test/match/bosshit-ticker.test.js
@@ -62,8 +62,9 @@ function assertTruthful(hits, phase, players) {
return inPhase;
}
-test('C1 (solo 险境, server-run fields): the Hidden Core\'s first hit announces nothing — the Final Assault\'s damage is not damage to the hidden leader', () => {
- // the reported case: a Final Assault pool (盐风主教昆图斯, 险境) larger than half the hidden leader's (假想敌:铳)
+test('C1 (solo 绝境, server-run fields): the Hidden Core\'s first hit announces nothing — the Final Assault\'s damage is not damage to the hidden leader', () => {
+ // the reported case: a Final Assault pool larger than half the hidden leader's — solo 绝境 (HARD): 盐风主教昆图斯 525000,
+ // 75 % of 假想敌:铳's 700000, so the old share announced 超过50% at the hidden leader's first hit
const { m, end, hits } = toHiddenCore({ mode: 'solo', difficulty: 'HARD', bossId: 'boss_4', hiddenBossId: 'boss_9', hiddenDps: 0.01 });
assert.equal(end.hiddenReached, true);
const fa = assertTruthful(hits, PHASE.FINAL_ASSAULT, ['p_0']);
diff --git a/test/ui/bosshit-ticker.test.js b/test/ui/bosshit-ticker.test.js
index 99960ad2..a64afc83 100644
--- a/test/ui/bosshit-ticker.test.js
+++ b/test/ui/bosshit-ticker.test.js
@@ -2,16 +2,24 @@
// 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 (ui/ticker.js tickerLineLive; main.js stamps each line with its type and round).
+// 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 } = await import('../../public/js/ui/ticker.js');
+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 = { type: 'BOSS_HIT', round: 14, text: 'P博士对敌方领袖造成的伤害超过50%!' };
+ 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');
@@ -27,11 +35,125 @@ test('tickerLineLive: a BOSS_HIT line plays only in the round it came in; every
assert.equal(tickerLineLive(null, 15), true);
});
-test('main.js stamps every ticker line with its type and the round it came in; the strip filters on a round change', () => {
+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,\s*round:\s*s\.match\?\.public\?\.round/, 'the m.ticker handler keeps type and round');
+ 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');
- assert.match(strip, /useStore\(\(s\) => s\.match\?\.public\?\.round/, 'the strip follows m.public.round');
- assert.match(strip, /\}, \[round\]\);/, 'a round change re-filters the queue and the line on screen');
+ 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');
});