0.2.5-dev: merge claude/036-ui2
# Conflicts: # docs/history/0.2.5.md
This commit is contained in:
commit
537b1ec517
26 files changed
+555
-43
No files matched your search
@@ -0,0 +1,79 @@
|
||||
// Damage-number rendering: the default path still pops every hit; official mode shows only a marked red integer
|
||||
// or a yellow MISS, and does not merge those into the typed running totals.
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
let fake, FX;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
});
|
||||
after(() => fake?.restore());
|
||||
|
||||
const cam = presetCamera('normal', { width: 1600, height: 900 });
|
||||
|
||||
function unit(id, x, y) {
|
||||
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {} };
|
||||
}
|
||||
|
||||
function makeFx(views) {
|
||||
const map = new Map(views.map((v) => [v.id, v]));
|
||||
const settings = { quality: 'high', damageNumbers: true, officialDamageNumbers: false };
|
||||
const fx = new FX.FxSystem({
|
||||
...fakeViewCtx(fake.P), settings, cam: () => cam, heightAt: () => 0, timeScale: () => 2,
|
||||
loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
return { fx, settings };
|
||||
}
|
||||
|
||||
test('official mode hides ordinary hits and heals, and draws a marked red number without merging', () => {
|
||||
const v = unit(1, 4, 9);
|
||||
const { fx, settings } = makeFx([v]);
|
||||
fx.damage(v, 40, 'phys', null);
|
||||
fx.damage(v, 60, 'phys', null);
|
||||
assert.equal(fx.nums.length, 1, 'default mode still merges same-style hits');
|
||||
assert.equal(fx.nums[0].style, 'phys');
|
||||
assert.equal(fx.nums[0].value, 100);
|
||||
assert.equal(fx.nums[0].text.text, '100');
|
||||
|
||||
fx.heal(v, 15);
|
||||
assert.ok(fx.nums.some((n) => n.style === 'heal' && n.text.text === '+15'));
|
||||
|
||||
for (const n of fx.nums.splice(0)) fx._releaseNum(n);
|
||||
settings.officialDamageNumbers = true;
|
||||
fx.damage(v, 40, 'phys', null);
|
||||
fx.heal(v, 15);
|
||||
assert.equal(fx.nums.length, 0, 'an unmarked hit and a heal draw no digits');
|
||||
fx.damage(v, 40, 'phys', null, 'r');
|
||||
fx.damage(v, 25, 'true', null, 'r');
|
||||
assert.equal(fx.nums.length, 2, 'official reds are separate popups');
|
||||
assert.deepEqual(fx.nums.map((n) => n.text.text), ['40', '25']);
|
||||
assert.ok(fx.nums.every((n) => n.style === 'crit'));
|
||||
|
||||
fx.damage(v, 0, 'phys', null, 'r');
|
||||
assert.equal(fx.nums.at(-1).text.text, '0');
|
||||
assert.equal(fx.nums.at(-1).style, 'crit');
|
||||
|
||||
settings.damageNumbers = false;
|
||||
const held = fx.nums.length;
|
||||
fx.damage(v, 90, 'phys', null, 'r');
|
||||
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
|
||||
assert.equal(fx.nums.length, held, 'turning damage numbers off hides official digits too');
|
||||
});
|
||||
|
||||
test('a dodge marked num:m shows MISS only while official numbers are on', () => {
|
||||
const v = unit(3, 6, 10);
|
||||
const { fx, settings } = makeFx([v]);
|
||||
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
|
||||
assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0);
|
||||
settings.officialDamageNumbers = true;
|
||||
fx.simFx('dodge', v.x, v.y, { id: v.id });
|
||||
assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0);
|
||||
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
|
||||
const miss = fx.nums.filter((n) => n.style === 'miss');
|
||||
assert.equal(miss.length, 1);
|
||||
assert.equal(miss[0].text.text, 'MISS');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -722,7 +722,7 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Settings, wiring and the title-page repository link for the official damage-number option (#412) and #543.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js';
|
||||
import { fallbackFloat } from '../../public/js/ui/fallbackField.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
test('official damage numbers default off and only a real boolean is kept', () => {
|
||||
assert.equal(sanitizeSettings(null).officialDamageNumbers, false);
|
||||
assert.equal(sanitizeSettings({}).officialDamageNumbers, false, 'a profile saved before the setting stays on today\'s numbers');
|
||||
assert.equal(sanitizeSettings({ officialDamageNumbers: true }).officialDamageNumbers, true);
|
||||
assert.equal(sanitizeSettings({ officialDamageNumbers: true, damageNumbers: false }).damageNumbers, false);
|
||||
for (const bad of ['yes', 1, null, undefined]) {
|
||||
assert.equal(sanitizeSettings({ officialDamageNumbers: bad }).officialDamageNumbers, false, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('the settings screen, the field view and the simplified board follow the same flag', () => {
|
||||
const settings = read('public/js/ui/settings.js');
|
||||
assert.match(settings, /t\('官方伤害数字'\)/);
|
||||
assert.match(settings, /officialDamageNumbers/);
|
||||
const app = read('public/js/render/app.js');
|
||||
assert.match(app, /officialDamageNumbers/);
|
||||
assert.match(app, /fx\.damage\(v, amt, e\[3\], src, e\[4\]\)/);
|
||||
assert.deepEqual(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true }), { id: 7, text: '40', type: 'phys' });
|
||||
assert.deepEqual(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: false }), { id: 7, text: '+12', type: 'heal' });
|
||||
assert.equal(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true, officialDamageNumbers: true }), null);
|
||||
assert.equal(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: true }), null);
|
||||
assert.deepEqual(fallbackFloat(['dmg', 7, 0, 'phys', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '0', type: 'crit' });
|
||||
assert.deepEqual(fallbackFloat(['dmg', 7, 80, 'arts', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '80', type: 'crit' });
|
||||
assert.deepEqual(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: 'MISS', type: 'miss' });
|
||||
assert.equal(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true }), null);
|
||||
assert.equal(fallbackFloat(['dmg', 7, 80, 'phys', 'r'], { damageNumbers: false, officialDamageNumbers: true }), null);
|
||||
});
|
||||
|
||||
test('every interface pack translates the new settings and title strings', () => {
|
||||
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
|
||||
const pack = JSON.parse(read(`public/i18n/${lang}.json`));
|
||||
for (const id of ['官方伤害数字', '只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。', '项目仓库']) {
|
||||
assert.equal(typeof pack[id], 'string', `${lang} ${id}`);
|
||||
assert.ok(pack[id].length > 0, `${lang} ${id}`);
|
||||
if (lang !== 'zh-TW') assert.notEqual(pack[id], id, `${lang} still has the Chinese source`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the title page links the project repository in a new tab', () => {
|
||||
const src = read('public/js/screens/title.js');
|
||||
assert.match(src, /class="title-repo"/);
|
||||
assert.match(src, /href="https:\/\/github\.com\/sganggs\/Stronghold-Protocol"/);
|
||||
assert.match(src, /target="_blank"/);
|
||||
assert.match(src, /rel="noopener noreferrer"/);
|
||||
assert.match(src, /t\('项目仓库'\)/);
|
||||
});
|
||||
Reference in new issue
Block a user