feedback1c WJ: Hidden Core damage broadcasts count only the hidden leader
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).
This commit is contained in:
1 parent
7f134737ce
commit
7b1964707d
7 files changed
+210
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user