From 7b1964707d0a3e7aa464a7331439f5161160beb3 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:22:17 +0800 Subject: [PATCH] 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'); +});