0.2.5-dev: an optional 「官方伤害数字」 setting (default off) shows only the numbers the official game shows per the PRTS red-number rule (#412, the owner's decision of 2026-10-12); a small link to the project's GitHub repository on the title page (#543); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 14:59:20 +08:00
1 parent 7fd0478dc0
commit a3e8460bb0
26 files changed
+555 -43

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
// Official red damage numbers (PRTS 作战机制 「伤害红字(暴击)」, https://prts.wiki/w/作战机制).
// The mark is display-only: HP, return values and event counts stay the same. A qualifying hit appends 'r' on the
// dmg tuple that already fires; a dodge of such a hit sets num:'m' on the dodge fx that already fires. No new event.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { officialDamageMark } from '../../server/sim/damage.js';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
function field() {
const h = makeBattle({
defs: {
chess: { t_a: chessRec({ id: 't_a', stats: { atk: 1000 } }) },
enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100000, def: 400, res: 0, speed: 0 }) },
},
units: [{ chessId: 't_a', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
content: 'none',
});
h.step();
return h;
}
function added(h, kind, fn) {
const before = h.eventsOf(kind).length;
const result = fn();
return { result, ev: h.eventsOf(kind).slice(before) };
}
test('officialDamageMark is the PRTS 1.5× test, including a 0-damage invulnerable hit', () => {
assert.equal(officialDamageMark(150, 100), 'r');
assert.equal(officialDamageMark(149.9, 100), null);
assert.equal(officialDamageMark(0, 0), 'r', '0 ≥ 0 × 1.5 (PRTS 无敌: 弑君者 flash still shows red 0)');
assert.equal(officialDamageMark(Number.NaN, 10), null);
});
test('a landed hit is marked only when the pre-shield total reaches 1.5× the penetration-free expected damage', () => {
const h = field();
const e = h.enemy('enemy_dummy');
const op = h.unit('t_a');
const plain = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(plain.result, 100);
assert.deepEqual(plain.ev[0], ['dmg', e.id, 100, 'true'], 'below 1.5×: the tuple stays four elements');
h.b.addBuff(e, { key: 'fr12', mods: { dmgTakenMul: 1.2 } });
const mild = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(mild.result, 120);
assert.equal(mild.ev[0].length, 4, '脆弱 ×1.2 is below 1.5× the penetration-free expected damage');
h.b.removeBuff(e, e.findBuff('fr12'));
h.b.addBuff(e, { key: 'fr15', mods: { dmgTakenMul: 1.5 } });
const edge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(edge.result, 150);
assert.deepEqual(edge.ev[0], ['dmg', e.id, 150, 'true', 'r'], 'exactly 1.5× is red');
h.b.removeBuff(e, e.findBuff('fr15'));
h.b.addBuff(op, { key: 'dealt', mods: { dmgDealtMul: 2 } });
const dealt = added(h, 'dmg', () => h.b.dealDamage(op, e, { amount: 100, type: 'true' }));
assert.equal(dealt.result, 200);
assert.equal(e.hp, 100000 - 100 - 120 - 150 - 200);
assert.equal(dealt.ev[0][4], 'r', 'source damage-dealt multipliers are excluded from expected damage');
h.b.removeBuff(op, op.findBuff('dealt'));
const mul = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true', mul: 2 }));
assert.equal(mul.result, 200);
assert.equal(mul.ev[0].length, 4, 'dmg.mul is the instance scale and stays on both sides of the comparison');
const pen = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 }));
assert.equal(pen.result, 1000);
assert.equal(pen.ev[0][4], 'r', 'DEF penetration is excluded from expected damage (600) but counted in the final (1000)');
const blocked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys' }));
assert.equal(blocked.result, 600);
assert.equal(blocked.ev[0].length, 4, 'the same hit without penetration is not red');
});
test('shields, hit-count husks, sub-threshold reds and element fills do not invent events', () => {
const h = field();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 'fr', mods: { dmgTakenMul: 2 } });
h.b.addBuff(e, { key: 'sh', shield: 100000 });
const soaked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(soaked.result, 0);
assert.equal(soaked.ev.length, 0, 'a barrier that removes the hit emits nothing');
h.b.removeBuff(e, 'sh');
h.b.removeBuff(e, 'fr');
h.b.addBuff(e, { key: 'hc', flags: { hitCount: true } });
const husk = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'true' }));
assert.equal(husk.result, 1);
assert.deepEqual(husk.ev[0], ['dmg', e.id, 1, 'true'], 'a hit-count rewrite to 1 is not red against a large expected value');
h.b.removeBuff(e, 'hc');
h.b.addBuff(e, { key: 'tiny', mods: { dmgTakenMul: 4 } });
const tiny = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 0.1, type: 'true' }));
approx(tiny.result, 0.4);
assert.equal(tiny.ev.length, 0, 'a red total under 0.5 still emits no dmg event');
h.b.removeBuff(e, 'tiny');
const gauge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 80, type: 'element', element: 'burn' }));
assert.equal(gauge.ev.length, 1);
assert.equal(gauge.ev[0].length, 4, 'an element gauge fill is not an HP crit');
assert.equal(gauge.ev[0][3], 'burn');
const rewritten = field();
const t = rewritten.enemy('enemy_dummy');
rewritten.b.addBuff(t, { key: 'fr', mods: { dmgTakenMul: 2 } });
rewritten.b.on('hpDamage', (c) => { c.amount = 1; });
const cut = added(rewritten, 'dmg', () => rewritten.b.dealDamage(null, t, { amount: 100, type: 'true' }));
assert.equal(cut.result, 1);
assert.deepEqual(cut.ev[0], ['dmg', t.id, 1, 'true', 'r'], 'the 1.5× test uses the pre-rewrite total; the shown integer is what reaches HP');
});
test('invulnerable and sleeping hits stay event-free; a dodge marks MISS only for a would-be red hit', () => {
const h = field();
const e = h.enemy('enemy_dummy');
const hp = e.hp;
h.b.addBuff(e, { key: 'inv', duration: 5, flags: { invulnerable: true } });
const inv = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' }));
assert.equal(inv.result, 0);
assert.equal(inv.ev.length, 0);
assert.equal(e.hp, hp);
h.b.removeBuff(e, e.findBuff('inv'));
h.b.addBuff(e, { key: 'sleep', duration: 5, flags: { sleep: true } });
const sleep = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' }));
assert.equal(sleep.result, 0);
assert.equal(sleep.ev.length, 0);
h.b.removeBuff(e, e.findBuff('sleep'));
let rolls = 0;
h.b.rng = () => { rolls++; return 0; };
h.b.addBuff(e, { key: 'dodge', mods: { dodgePhys: 1 } });
const miss = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 }));
assert.equal(miss.result, 0);
assert.equal(rolls, 1, 'the hypothetical red test does not roll again');
assert.equal(e.hp, hp);
const redFx = miss.ev.filter((ev) => ev[1] === 'dodge');
assert.equal(redFx.length, 1);
assert.equal(redFx[0][4].id, e.id);
assert.equal(redFx[0][4].num, 'm');
rolls = 0;
const plain = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 100, type: 'phys' }));
assert.equal(plain.result, 0);
assert.equal(rolls, 1);
const quiet = plain.ev.filter((ev) => ev[1] === 'dodge');
assert.equal(quiet.length, 1);
assert.equal(Object.hasOwn(quiet[0][4], 'num'), false);
});