feedback1 integrate: WJ BOSS_HIT share per boss round; stale-round ticker lines dropped
This commit is contained in:
commit
c59b569dc7
7 files changed
+407
-11
No files matched your search
+6
-2
@@ -633,8 +633,12 @@ 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, 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-1
@@ -217,7 +217,11 @@ 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, 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;
|
||||
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() }] }));
|
||||
|
||||
+98
-5
@@ -1,5 +1,12 @@
|
||||
// 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%伤害播报"). 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';
|
||||
@@ -10,13 +17,85 @@ 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 {TickerLine|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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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 : [];
|
||||
@@ -28,22 +107,36 @@ 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(() => {
|
||||
const r = pruneTickerLines(queue.current, cur, round);
|
||||
queue.current = r.queue;
|
||||
if (r.dropCur) next();
|
||||
}, [round]);
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
function next() {
|
||||
const it = queue.current.shift() || null;
|
||||
function show(it) {
|
||||
setCur(it);
|
||||
clearTimeout(timer.current);
|
||||
if (it) {
|
||||
audio.sfx('broadcast', { volume: 0.5 });
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(next, TICKER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
const r = nextTickerLine(queue.current, roundRef.current);
|
||||
queue.current = r.queue;
|
||||
show(r.line);
|
||||
}
|
||||
|
||||
if (!cur) return null;
|
||||
return html`<div class="ticker" role="status" aria-live="polite">
|
||||
<div key=${cur.id} class="ticker__line"><${GIcon} name="flag" class="ticker__icon" /><${RichText} text=${cur.text} /></div>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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 — 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']);
|
||||
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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user