From 7b1964707d0a3e7aa464a7331439f5161160beb3 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:22:17 +0800 Subject: [PATCH 1/2] feedback1c WJ: Hidden Core damage broadcasts count only the hidden leader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report: "隐藏boss还没打就出了造成50%伤害播报". The BOSS_HIT ticker ("{0}博士对敌方领袖造成的伤害超过20/50/80%!") divided the player's boss damage of the whole match (stats.bossDamage, Final Assault + Hidden Core) by the hidden leader's pool, so the Hidden Core's first hit already announced 50 % or 80 %. The share is now the player's damage to the current round's pool (SharedBossPool.byPlayer) over its size; 领袖伤害 on the result keeps both rounds. The browser's ticker strip plays its queue 5.2 s per line, so a Final Assault line could still play over the fresh hidden leader: a BOSS_HIT line is now dropped once the round it came in is over (ui/ticker.js tickerLineLive; lines carry their type and round from main.js). --- docs/META.md | 7 +- public/js/main.js | 4 +- public/js/ui/ticker.js | 29 ++++++- server/match/Match.js | 8 +- server/match/finalAssault.js | 10 ++- test/match/bosshit-ticker.test.js | 123 ++++++++++++++++++++++++++++++ test/ui/bosshit-ticker.test.js | 37 +++++++++ 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 test/match/bosshit-ticker.test.js create mode 100644 test/ui/bosshit-ticker.test.js diff --git a/docs/META.md b/docs/META.md index 62ae4972..85bee1f9 100644 --- a/docs/META.md +++ b/docs/META.md @@ -592,8 +592,11 @@ is called without protocol validation); 1 per second per player (`RATE`). Tickers (`m.ticker { text, id, type, priority, playerId }`) from `config.broadcasts`: SHOP_LEVEL, GOLDEN_CHAR, CHAR_DAMAGE (per board unit per battle, highest threshold; summons created in battle once per unit type — a client result may only name the unit types its lineup can field, fields.js validateClientResult), BOSS_HIT (20/50/80 % of the -pool per player), CHAR_GIFT (to the -receiver only), plus CUSTOM texts (eliminations, 联防, hidden core). +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 +CUSTOM texts (eliminations, 联防, hidden core). --- diff --git a/public/js/main.js b/public/js/main.js index 727a3cdb..785974f8 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -217,7 +217,9 @@ function wireNet() { }); net.on('m.ticker', (msg) => { if (typeof msg.text !== 'string') return; - store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now() }] })); + // type + the round it came in: a BOSS_HIT line is dropped once its boss round is over (ui/ticker.js tickerLineLive) + 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 }] })); }); 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 c392e2ad..95091b0d 100644 --- a/public/js/ui/ticker.js +++ b/public/js/ui/ticker.js @@ -1,5 +1,9 @@ // Broadcast ticker (m.ticker): a strip under the top bar; each line slides in from the right, stays // TICKER_MS and leaves; queued lines play in order (only the newest QUEUE_MAX are kept). +// 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). import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { html } from './components.js'; @@ -10,13 +14,27 @@ import { audio } from '../audio.js'; const TICKER_MS = 5200; const QUEUE_MAX = 4; +/** + * 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 {number|null} round + */ +export function tickerLineLive(line, round) { + if (!line || line.type !== 'BOSS_HIT') return true; + return line.round == null || round == null || line.round === round; +} + /** Ticker strip bound to store.ticker. */ export function Ticker() { const items = useStore((s) => s.ticker); + const round = useStore((s) => s.match?.public?.round ?? null); const seen = useRef(null); const queue = useRef([]); + const roundRef = useRef(round); const [cur, setCur] = useState(null); const timer = useRef(null); + roundRef.current = round; useEffect(() => { const list = Array.isArray(items) ? items : []; @@ -32,14 +50,21 @@ export function Ticker() { 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(); + }, [round]); + useEffect(() => () => clearTimeout(timer.current), []); function next() { - const it = queue.current.shift() || null; + let it = queue.current.shift() || null; + while (it && !tickerLineLive(it, roundRef.current)) it = queue.current.shift() || null; setCur(it); + clearTimeout(timer.current); if (it) { audio.sfx('broadcast', { volume: 0.5 }); - clearTimeout(timer.current); timer.current = setTimeout(next, TICKER_MS); } } diff --git a/server/match/Match.js b/server/match/Match.js index 1181bf42..abf7a2ad 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -2794,13 +2794,16 @@ export class Match { for (const ps of alive) ps.lpAtFinal = Math.max(0, ps.lp); } const bossId = hidden ? this.hiddenBossId : this.bossId; + // BOSS_HIT tickers ("对敌方领袖造成的伤害超过20% / 50% / 80%"): the player's damage to THIS leader over its pool — + // the pool's own per-player tally, one pool per boss round. stats.bossDamage (the result's 领袖伤害) adds up both + // rounds, so it would credit the Final Assault's damage to the hidden leader ("隐藏boss还没打就出了50%播报"). const hitSteps = new Map(); - this.bossPool = new SharedBossPool(bossPoolHp(this.gd, bossId, alive.length), { + const pool = new SharedBossPool(bossPoolHp(this.gd, bossId, alive.length), { onHit: (pid, dmg) => { const ps = this.players.get(pid); if (!ps) return; ps.stats.bossDamage += dmg; - const share = ps.stats.bossDamage / this.bossPool.maxHp; + const share = (pool.byPlayer.get(pid) || 0) / pool.maxHp; const done = hitSteps.get(pid) || 0; let reached = done; BOSS_HIT_STEPS.forEach((s, i) => { if (share >= s) reached = Math.max(reached, i + 1); }); @@ -2810,6 +2813,7 @@ export class Match { } }, }); + this.bossPool = pool; const groups = pairPlayers(alive); const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join()); this.fields = groups.map((g, i) => { diff --git a/server/match/finalAssault.js b/server/match/finalAssault.js index c63a3386..5eaaea0b 100644 --- a/server/match/finalAssault.js +++ b/server/match/finalAssault.js @@ -26,6 +26,9 @@ // follows the same rule, server/sim/spec.js). Before, the per-player crediting of a report could leave 3.6e-12 that // the browser could never deal (user playtest #6 item 5). // * No IN_BATTLE layer gains (flags.layerGainsEnabled = false). +// * BOSS_HIT tickers (BOSS_HIT_STEPS 20 / 50 / 80 %): a player's damage to the round's pool (SharedBossPool.byPlayer) +// over its size, each threshold once per boss round — the Hidden Core never counts the Final Assault's damage +// (player report after 0.1.0: "隐藏boss还没打就出了造成50%伤害播报"). // * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round, // Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 / // co-op 1200) and team LP > minTeamLpExclusive (1). @@ -33,6 +36,11 @@ import { BOSS_ROW_OFFSET, COLS, BOSS_POOL_MIN_HP } from '../sim/constants.js'; import { mirrorDir, normDir } from '../sim/dir.js'; +/** + * BOSS_HIT ticker thresholds (activity_table autoChessData.broadcastList comment_boss_hit_1..3, paramList 0.2 / 0.5 / + * 0.8: "{0}博士对敌方领袖造成的伤害超过20%!"): a player's damage to the current leader's pool over that pool + * (SharedBossPool.byPlayer — one pool per boss round, so the Final Assault and the Hidden Core count apart). + */ export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8]; /** @@ -80,7 +88,7 @@ export class SharedBossPool { constructor(hp, { onHit = null } = {}) { this.maxHp = Math.max(1, hp); this.hp = this.maxHp; - /** playerId → damage dealt */ + /** playerId → damage dealt to this pool (this boss round only; the BOSS_HIT tickers' share) */ this.byPlayer = new Map(); this.onHit = onHit; } diff --git a/test/match/bosshit-ticker.test.js b/test/match/bosshit-ticker.test.js new file mode 100644 index 00000000..1f5b8c47 --- /dev/null +++ b/test/match/bosshit-ticker.test.js @@ -0,0 +1,123 @@ +// BOSS_HIT tickers ("{0}博士对敌方领袖造成的伤害超过20% / 50% / 80%!", activity_table autoChessData.broadcastList +// comment_boss_hit_1..3, paramList 0.2 / 0.5 / 0.8) — player report after 0.1.0: "隐藏boss还没打就出了造成50%伤害播报". +// +// Reproduced with real bot matches (real sim) that reach the 隐秘核心: the Hidden Core's very first hit (a few dozen +// HP of a 900000 pool) already announced "超过50%". The ticker divided the player's damage of the WHOLE match +// (stats.bossDamage, Final Assault + Hidden Core: the result screen's 领袖伤害) by the hidden leader's pool, so the +// Final Assault's damage counted as damage to the hidden leader. The share is now the player's damage to the current +// leader's pool (SharedBossPool.byPlayer, one pool per boss round) over that pool: each boss round announces its own +// 20 / 50 / 80 % — on server-run fields, under client-side combat and after a server takeover alike. (The browser's +// strip also played a Final Assault line late, into the Hidden Core: test/ui/bosshit-ticker.test.js.) + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PHASE } from '../../shared/constants.js'; +import { BOSS_HIT_STEPS } from '../../server/match/finalAssault.js'; +import { makeMatch, checkInvariants } from './harness.js'; + +/** + * A match (fake battles) driven through the Final Assault (one hit clears it) into the Hidden Core, whose fields deal + * `hiddenDps` (a share of the hidden pool per game second). Every BOSS_HIT ticker is recorded with the phase and the + * player's real share of the leader in play at that moment (the current pool). + */ +function toHiddenCore({ mode = 'coop', difficulty = 'NORMAL', humans = 1, clientCombat = false, seed = 61, bossId, hiddenBossId, hiddenDps = 0.02, onHidden = null }) { + const h = makeMatch({ mode, difficulty, humans, seed, fake: true, clientCombat, instant: !clientCombat, + script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : b.kind === 'hidden' ? { bossDps: b.sharedBoss.maxHp * hiddenDps } : {}) }).start(); + const m = h.m; + m.bossId = bossId; + m.hiddenBossId = hiddenBossId; + const hits = []; + h.onBroadcast.push((msg) => { + if (msg.t !== 'm.ticker' || msg.type !== 'BOSS_HIT') return; + const pool = m.bossPool; + hits.push({ phase: m.phase, playerId: msg.playerId, text: msg.text, id: msg.id, + share: pool ? (pool.byPlayer.get(msg.playerId) || 0) / pool.maxHp : null, + leaderHpPct: pool ? pool.hp / pool.maxHp : null }); + }); + if (clientCombat) h.autoHumans(); + h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound); + // Σ activated layers above the hidden-core threshold (solo 350 / co-op 1200), every player + for (const p of m.players.values()) { p.bondCountBonus.yanShip = 3; p.layers.yanShip = 601; p.recompute(); } + h.drive(() => m.phase === PHASE.HIDDEN_CORE || h.ended != null); + assert.equal(m.phase, PHASE.HIDDEN_CORE, 'reached the Hidden Core'); + if (onHidden) onHidden(h); + const end = h.runToEnd({ maxSteps: 6e6 }); + return { h, m, end, hits }; +} + +/** The threshold a BOSS_HIT ticker announces (its config param, from the text: "超过N%"). */ +const stepOf = (hit) => Number(/超过(\d+)%/.exec(hit.text)[1]) / 100; + +/** Every ticker of `phase` announces at most what the player really dealt to that leader, in rising order, once each. */ +function assertTruthful(hits, phase, players) { + const inPhase = hits.filter((x) => x.phase === phase); + for (const x of inPhase) { + assert.ok(x.share + 1e-9 >= stepOf(x), `${phase}: "${x.text}" while ${x.playerId} had dealt ${(x.share * 100).toFixed(2)} % of this leader (leader at ${(x.leaderHpPct * 100).toFixed(2)} %)`); + } + for (const pid of players) { + const steps = inPhase.filter((x) => x.playerId === pid).map(stepOf); + assert.deepEqual(steps, [...steps].sort((a, b) => a - b), `${phase} ${pid}: rising thresholds`); + assert.equal(new Set(steps).size, steps.length, `${phase} ${pid}: each threshold once`); + } + 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 (假想敌:铳) + 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']); + assert.deepEqual(fa.map(stepOf), [0.8], 'the Final Assault cleared by one hit: 80 %'); + const hidden = assertTruthful(hits, PHASE.HIDDEN_CORE, ['p_0']); + assert.deepEqual(hidden.map(stepOf), BOSS_HIT_STEPS, 'the Hidden Core announces its own 20 / 50 / 80 % as they are reached'); + assert.ok(hidden[0].leaderHpPct <= 0.8 + 1e-9, `the first hidden ticker comes after 20 % of the hidden leader (leader at ${(hidden[0].leaderHpPct * 100).toFixed(2)} %)`); + // the result screen's 领袖伤害 keeps the whole match: the Final Assault's pool plus the hidden one + assert.ok(end.players[0].stats.bossDamage > m.bossPool.maxHp, `领袖伤害 ${end.players[0].stats.bossDamage}`); + checkInvariants(m); + m.dispose(); +}); + +for (const humans of [2, 4]) { + test(`C1 (${humans} players, client-side combat): every BOSS_HIT of the Hidden Core matches the player's damage to the hidden leader`, () => { + const { m, end, hits } = toHiddenCore({ mode: 'coop', difficulty: 'NORMAL', humans, clientCombat: true, seed: 62 + humans, bossId: 'boss_4', hiddenBossId: 'boss_8', hiddenDps: 0.02 }); + assert.equal(end.hiddenReached, true); + const ids = [...m.players.keys()]; + assertTruthful(hits, PHASE.FINAL_ASSAULT, ids); + const hidden = assertTruthful(hits, PHASE.HIDDEN_CORE, ids); + assert.ok(hidden.length > 0, 'the Hidden Core still announces real damage'); + // the stats keep the whole match: the first field player's 领袖伤害 covers both rounds + const row = end.players.find((p) => p.playerId === ids[0]); + assert.ok(row.stats.bossDamage > 0); + checkInvariants(m); + m.dispose(); + }); +} + +test('C1 (2 players, server-run fields — SP_COMBAT=server): the hidden pool\'s tickers follow the hidden-core damage only', () => { + const { m, end, hits } = toHiddenCore({ mode: 'coop', difficulty: 'HARD', humans: 2, clientCombat: false, seed: 66, bossId: 'boss_4', hiddenBossId: 'boss_8', hiddenDps: 0.02 }); + assert.equal(end.hiddenReached, true); + assertTruthful(hits, PHASE.FINAL_ASSAULT, ['p_0', 'p_1']); + const hidden = assertTruthful(hits, PHASE.HIDDEN_CORE, ['p_0', 'p_1']); + // the even split: both players pass 20 % of the hidden leader + for (const pid of ['p_0', 'p_1']) assert.ok(hidden.some((x) => x.playerId === pid && stepOf(x) === 0.2), `${pid}: 20 %`); + checkInvariants(m); + m.dispose(); +}); + +test('C1 (solo, client-side combat, server takeover mid-fight): the re-simulated hidden field\'s credits announce the hidden-core share only', () => { + let tookOver = false; + const { m, end, hits } = toHiddenCore({ mode: 'solo', difficulty: 'HARD', clientCombat: true, seed: 67, bossId: 'boss_4', hiddenBossId: 'boss_9', hiddenDps: 0.02, + onHidden: (h) => { + h.sched.advance(3000); // the browser reported a few seconds of the fight + h.m.onDisconnect('p_0'); + tookOver = h.m.fields[0].mode === 'server'; + h.m.onReconnect('p_0'); + } }); + assert.ok(tookOver, 'the server took the hidden field over (CreditPool)'); + assert.equal(end.hiddenReached, true); + assertTruthful(hits, PHASE.FINAL_ASSAULT, ['p_0']); + const hidden = assertTruthful(hits, PHASE.HIDDEN_CORE, ['p_0']); + assert.deepEqual(hidden.map(stepOf), BOSS_HIT_STEPS); + checkInvariants(m); + m.dispose(); +}); diff --git a/test/ui/bosshit-ticker.test.js b/test/ui/bosshit-ticker.test.js new file mode 100644 index 00000000..99960ad2 --- /dev/null +++ b/test/ui/bosshit-ticker.test.js @@ -0,0 +1,37 @@ +// 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 (ui/ticker.js tickerLineLive; main.js stamps each line with its type and round). + +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'); + +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%!' }; + 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('main.js stamps every ticker line with its type and the round it came in; the strip filters on a round change', () => { + 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'); + 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'); +}); From 93afe2ccf2cf4b27eccff40db9374ddf49de0b1f Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:16:44 +0800 Subject: [PATCH 2/2] feedback1c WJ: ticker queue helpers; a newer leader-damage line replaces the older one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for the Hidden Core broadcast fix. The strip's queue rules are now pure, exported helpers (tickerSupersedes, enqueueTickerLines, pruneTickerLines, nextTickerLine) with unit tests across a round 14 -> 15 change, instead of source regex checks only. A player's newer BOSS_HIT line of the same round replaces their older one, queued or on screen (it names a higher threshold), so a burst finish shows its highest milestone before the round ends and the last round's lines are dropped. Lines now carry playerId from main.js. The solo match test is labelled 绝境 (HARD), the difficulty it runs. --- docs/META.md | 3 +- public/js/main.js | 6 +- public/js/ui/ticker.js | 86 +++++++++++++++++-- test/match/bosshit-ticker.test.js | 5 +- test/ui/bosshit-ticker.test.js | 136 ++++++++++++++++++++++++++++-- 5 files changed, 215 insertions(+), 21 deletions(-) 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'); });