feedback3: merge WJ — 高台 only for elite 歌蕾蒂娅 with HOK-Y, live prep watch, silenced 折射, grant toasts, emote timeout glyph

This commit is contained in:
sganggs committed 2026-10-04 21:26:46 +08:00
commit c9dd2a6392
41 files changed
+700 -216

No files matched your search

+119
View File
@@ -351,6 +351,125 @@ describe('public/js/ui/emotes.js helpers', () => {
assert.match(readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'), /href="\/css\/emotes\.css"/);
});
test('a hung emote manifest is missing when the clock fires (glyph), and a later retry replaces it', async () => {
const { createDataStore } = await import('../../public/js/data.js');
const timers = [];
let calls = 0;
let releaseWait;
const store = createDataStore({
timeoutMs: 1000,
retryDelays: [0],
wait: () => new Promise((r) => { releaseWait = r; }),
setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
clearTimeout: () => {},
fetch: () => {
calls++;
if (calls === 1) return new Promise(() => {});
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true, groups: { a: { pic: { path: '/p.png' } } } }) });
},
});
const states = [];
store.subscribe(() => states.push(store.status('local')));
const p = store.load('local');
assert.equal(store.status('local'), 'loading');
assert.equal(timers.length, 1);
assert.equal(timers[0].ms, 1000);
timers[0].fn();
await new Promise((r) => setImmediate(r));
assert.equal(store.status('local'), 'missing', 'glyph as soon as the attempt times out, before the retry');
assert.equal(calls, 1, 'still on the first attempt while the retry waits');
releaseWait();
const value = await p;
assert.equal(calls, 2);
assert.equal(store.status('local'), 'ready');
assert.equal(value.ok, true);
assert.equal(store.get('local').groups.a.pic.path, '/p.png');
assert.deepEqual(states, ['missing', 'ready']);
for (const t of timers) t.fn();
assert.equal(store.status('local'), 'ready', 'a timer that fires after success does not demote the file');
});
test('a failed emote manifest (no clock) is missing before the retry, then ready; a 404 stays missing', async () => {
const { createDataStore } = await import('../../public/js/data.js');
let calls = 0;
const store = createDataStore({
timeoutMs: 1000,
retryDelays: [0],
wait: async () => {},
setTimeout: () => 1,
clearTimeout: () => {},
fetch: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
return { ok: true, status: 200, json: async () => ({ groups: { emoticon: {} } }) };
},
});
const states = [];
store.subscribe(() => states.push(store.status('assets')));
const value = await store.load('assets');
assert.equal(value.groups.emoticon !== undefined, true);
assert.deepEqual(states, ['missing', 'ready']);
const warn = console.warn;
console.warn = () => {};
try {
const timers = [];
const gone = createDataStore({
timeoutMs: 5,
retryDelays: [],
setTimeout: (fn) => { timers.push(fn); return timers.length; },
clearTimeout: () => {},
fetch: () => new Promise(() => {}),
});
const p = gone.load('local');
assert.equal(timers.length, 1);
timers[0]();
assert.equal(await p, null);
assert.equal(gone.status('local'), 'missing');
let n = 0;
const missing = createDataStore({
timeoutMs: 5,
retryDelays: [0],
wait: async () => {},
setTimeout: () => 1,
clearTimeout: () => {},
fetch: async () => { n++; return { ok: false, status: 404, json: async () => ({}) }; },
});
assert.equal(await missing.load('assets'), null);
assert.equal(missing.status('assets'), 'missing');
assert.equal(n, 1, 'a 404 is not retried');
} finally { console.warn = warn; }
});
test('the art-manifest clock is not armed for other files; the default wait is ART_MANIFEST_TIMEOUT_MS', async () => {
const { createDataStore, ART_MANIFEST_TIMEOUT_MS } = await import('../../public/js/data.js');
assert.equal(ART_MANIFEST_TIMEOUT_MS, 8000);
const arms = [];
let release;
const store = createDataStore({
setTimeout: (fn, ms) => { arms.push(ms); return arms.length; },
clearTimeout: () => {},
fetch: (url) => new Promise((resolve) => { release = () => resolve(String(url).includes('chess')
? { ok: false, status: 404, json: async () => ({}) }
: { ok: true, status: 200, json: async () => ({ ok: 1 }) }); }),
});
const local = store.load('local');
assert.deepEqual(arms, [8000]);
release();
assert.equal((await local).ok, 1);
arms.length = 0;
const warn = console.warn;
console.warn = () => {};
try {
const chess = store.load('chess');
await new Promise((r) => setImmediate(r));
assert.deepEqual(arms, [], 'chess is not on the emote clock');
assert.equal(store.status('chess'), 'loading');
release();
await chess;
assert.equal(store.status('chess'), 'missing');
} finally { console.warn = warn; }
});
test('emote UI never renders an emote label as text (aria-label only)', () => {
const src = readFileSync(path.join(ROOT, 'public/js/ui/emotes.js'), 'utf8');
assert.doesNotMatch(src, /EMOTE_TEXT|EMOTE_LABEL/, 'no text table in the UI');
+22 -11
View File
@@ -39,13 +39,13 @@ let uid = 0;
const piece = (id, extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: false, tier: chess[id]?.tier ?? 1, items: [], ...extra });
const item = (id) => ({ uid: ++uid, kind: 'item', id, golden: false, tier: 1 });
function privWith({ board = [], hand = [], temp = [], deployCap = 8, ready = false } = {}) {
function privWith({ board = [], hand = [], temp = [], deployCap = 8, ready = false, loadout = null } = {}) {
const h = new Array(GEO.HAND_SIZE).fill(null);
hand.forEach((p, i) => { if (p) h[p.idx ?? i] = p.piece ?? p; });
const t = new Array(GEO.TEMP_SIZE).fill(null);
temp.forEach((p, i) => { t[i] = p; });
return { alive: true, ready, funds: 10, board, hand: h, temp: t, deployCap, deployCount: board.filter((p) => p.kind === 'chess').length, canReady: !t.some(Boolean),
shop: { level: 3, maxLevel: 6, upgradePrice: 8, refreshPrice: 1, freeRefreshes: 0, frozen: false, slots: [] } };
loadout, shop: { level: 3, maxLevel: 6, upgradePrice: 8, refreshPrice: 1, freeRefreshes: 0, frozen: false, slots: [] } };
}
const ctxFor = (priv, editable = true) => placementContext({ priv, stage: STAGE, editable, getChess, getItem, getToken });
@@ -245,17 +245,28 @@ describe('placement mirror (canPlace)', () => {
assert.equal(canPlace(ctx, m.uid, null).ok, false);
assert.equal(canPlace(ctx, m.uid, { area: 'temp', idx: 0 }).ok, false, 'no temp target');
});
test('a 钩索师 / 推击手 (chess.json placement all: "可以放置于远程位") may also use the high ground; a plain melee keeps the refusal', () => {
const glad = piece('chess_char_4_12_a'); // 歌蕾蒂娅 (钩索师)
const forcer = piece('chess_char_3_07_b'); // 见行者 (推击手), elite
test('only elite 歌蕾蒂娅 carrying HOK-Y lights the 高台; 崖心, 见行者, a normal record and other modules stay on the ground', () => {
const HOK_Y = 'uniequip_003_glady';
const gladE = piece('chess_char_4_12_b');
const gladN = piece('chess_char_4_12_a');
const cliff = piece('chess_char_2_03_b');
const forcer = piece('chess_char_3_07_b');
const m = piece(MELEE);
const ctx = ctxFor(privWith({ hand: [glad, forcer, m] }));
for (const p of [glad, forcer]) {
for (const [row, col] of [[10, 4], [11, 4], [12, 4], [9, 3]]) assert.equal(canPlace(ctx, p.uid, { area: 'board', row, col }).ok, true, `${p.id} on ${row},${col}`);
const lit = boardTargets(ctx, p.uid).legal.map(([a, b]) => tileKey(a, b));
assert.equal(lit.length, STAGE.deployTiles.normal.melee.length + STAGE.deployTiles.normal.rangedOnly.length, `${p.id}: every deploy tile lit`);
const loadout = { chess_char_4_12_a: { module: HOK_Y } };
const ctx = ctxFor(privWith({ hand: [gladE, gladN, cliff, forcer, m], loadout }));
for (const [row, col] of [[10, 4], [11, 4], [12, 4], [9, 3]]) assert.equal(canPlace(ctx, gladE.uid, { area: 'board', row, col }).ok, true, `elite HOK-Y on ${row},${col}`);
const lit = boardTargets(ctx, gladE.uid).legal.map(([a, b]) => tileKey(a, b));
assert.equal(lit.length, STAGE.deployTiles.normal.melee.length + STAGE.deployTiles.normal.rangedOnly.length, 'every deploy tile lit');
for (const p of [gladN, cliff, forcer, m]) {
assert.deepEqual(canPlace(ctx, p.uid, { area: 'board', row: 10, col: 4 }), { ok: false, code: 'BAD_TILE', reason: '近战单位只能部署在地面' }, p.id);
assert.equal(canPlace(ctx, p.uid, { area: 'board', row: 9, col: 3 }).ok, true, `${p.id} on the ground`);
}
assert.deepEqual(canPlace(ctx, m.uid, { area: 'board', row: 10, col: 4 }), { ok: false, code: 'BAD_TILE', reason: '近战单位只能部署在地面' });
const none = ctxFor(privWith({ hand: [gladE], loadout: { chess_char_4_12_a: { module: 'none' } } }));
assert.equal(canPlace(none, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'no module');
const xmod = ctxFor(privWith({ hand: [gladE], loadout: { chess_char_4_12_a: { module: 'uniequip_002_glady' } } }));
assert.equal(canPlace(xmod, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'HOK-X');
const bare = ctxFor(privWith({ hand: [gladE] }));
assert.equal(canPlace(bare, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'default module is HOK-X');
});
test('not editable ⇒ nothing is legal', () => {
const m = piece(MELEE);
+26
View File
@@ -0,0 +1,26 @@
// 折射 while silenced: the enemy card greys the SILENCE-format line; the status icon is mapped and not drawn
// under silence. The sim already drops the RES bonus (enemies.js) — not asserted here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { abilityRows } from '../../public/js/ui/abilityLines.js';
import { statusIconKey, statusIconSuppressed } from '../../public/js/render/style.js';
const scout = JSON.parse(readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')).enemy_1166_dusbr;
test('折射: the card line greys only while silenced; the icon key exists and is suppressed under silence', () => {
const on = abilityRows(scout.abilities, false);
assert.equal(on.length, 1);
assert.match(on[0].text, /折射/);
assert.equal(on[0].off, false);
const off = abilityRows(scout.abilities, true);
assert.equal(off[0].off, true, 'grey the 折射 line');
assert.equal(off[0].text, on[0].text, 'the words stay');
assert.equal(abilityRows([{ text: '普通攻击', format: 'NONE' }], true)[0].off, false);
assert.equal(abilityRows(['plain'], true)[0].off, false);
assert.equal(statusIconKey('ab:refraction'), 'refraction');
const both = new Set(['ab:refraction', 'silence']);
assert.equal(statusIconSuppressed('ab:refraction', both), true, 'drop the icon while silenced');
assert.equal(statusIconSuppressed('silence', both), false, 'the silence icon itself stays');
assert.equal(statusIconSuppressed('ab:refraction', new Set(['ab:refraction'])), false, 'the icon shows while 折射 is on');
});