- ui/loadoutModel.js: `ops` beside the entries (sp.pref.loadout { v, entries, ops }; an older stored loadout has none =
潜能 6, 精英2 Lv.60): parseStoredOps, opsOf / setOps / resetOps (defaults never stored), sanitizeOps (only what the
server keeps: the roster and the 自选 owned pool); 导出 carries `ops`, 导入 replaces the settings when the payload has
them (an older export leaves them alone; settings alone import); the 已调整 count and 仅看已调整 count them.
- ui/loadoutSync.js: the store's `ops` (setOpsMap persists both); the loadout sync watches entries and ops and sends
room.loadout { entries, ops } (the ops sanitised against chess.json + backups.json; nothing set: no download);
openLoadout loads effects.json (the 练度 multipliers).
- Cards: gameLogic chessLoadout(chess, loadout, getChess, { ops | cultivation, effects }) composes the record at the
operator's potential and multiplies its stats by the 练度 tier (the player's m.private.ops — shop / hand / board cards
and the detail panel; a teammate's unit its UnitInfo potential / cultivate: unitCultivation; a teammate's bond-popup
card the defaults, never the viewer's settings; a stand-in neither); a 自选 card is composed at its pick's potential
(ownDiyPick from m.private.ops, unitPick from UnitInfo).
- Tests: test/ui/potential-ui.test.js (new, 7): storage, helpers, 导出 / 导入, the 已调整 count, the sync (sanitised ops,
settings-only edits, no download when none), the cards (刺玫 413 / 17 at 潜能1, ×1.05 at 精英1, a teammate's unit, a
stand-in), 自选 cards at 潜能1 (凯尔希 392 / 20) and the 练度 on top; test/ui/loadout.test.js expects the envelope's
`ops` and room.loadout's `ops: {}`.
184 lines
12 KiB
JavaScript
184 lines
12 KiB
JavaScript
// test/ui/potential-ui.test.js — the client side of the per-operator 潜能 / 练度 (0.2.2, the owner's decision of
|
|
// 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」): the stored settings (`sp.pref.loadout` { v, entries, ops } — an
|
|
// older stored loadout has none = the defaults), their helpers (set / reset / sanitise against the operators the server
|
|
// knows), 导出 / 导入 with them, the sync (`room.loadout { entries, ops }`), and the cards: chessLoadout composes the record
|
|
// at the operator's potential with its 练度 multiplier (the player's settings, a teammate's unit's own, never a stand-in's),
|
|
// a 自选 card at its pick's potential.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { cultivationCharIds } from '../../shared/protocol.js';
|
|
import {
|
|
parseStored, parseStoredOps, toStored, opsOf, setOps, resetOps, sanitizeOps, exportPayload, parseImport, changedCount,
|
|
rosterOf, filterRoster,
|
|
} from '../../public/js/ui/loadoutModel.js';
|
|
import { installLoadoutSync, SYNC_DEBOUNCE_MS, applyLoadoutEntries, loadoutStore, setEntries, setOpsMap } from '../../public/js/ui/loadoutSync.js';
|
|
import { createStore } from '../../public/js/store.js';
|
|
import { chessLoadout, unitCultivation, standInLoadout, standInOf, ownDiyPick, ownDiyRecord, cardDiy } from '../../public/js/ui/gameLogic.js';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const load = (n) => JSON.parse(readFileSync(path.join(ROOT, `data/${n}.json`), 'utf8'));
|
|
const CHESS = load('chess');
|
|
const BACKUPS = load('backups');
|
|
const EFFECTS = load('effects');
|
|
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
|
const VENDLA = 'chess_char_1_06_a';
|
|
const V = CHESS[VENDLA].charId;
|
|
const KALTS = 'char_003_kalts';
|
|
const IDS = cultivationCharIds(CHESS, BACKUPS);
|
|
const isOp = (id) => IDS.has(id);
|
|
|
|
test('storage: ops beside the entries; junk / defaults dropped; an older stored loadout has none (the defaults)', () => {
|
|
assert.deepEqual(parseStoredOps({ v: 1, entries: { [VENDLA]: { skill: 0 } } }), {}, 'a 0.2.1 stored loadout');
|
|
assert.deepEqual(parseStoredOps(null), {});
|
|
const raw = { v: 1, entries: {}, ops: { [V]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 3 }, x: 'junk', 'a b': { potential: 1 }, char_y: { potential: 9 }, __proto__: { potential: 1 } } };
|
|
assert.deepEqual(parseStoredOps(raw), { [V]: { potential: 1, cultivate: 0 } });
|
|
assert.deepEqual(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }), { v: 1, entries: { [VENDLA]: { skill: 0 } }, ops: { [V]: { potential: 2 } } });
|
|
assert.deepEqual(parseStored(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } })), { [VENDLA]: { skill: 0 } }, 'the entries read as before');
|
|
});
|
|
|
|
test('helpers: opsOf / setOps / resetOps keep only non-default values; sanitizeOps keeps what the server would', () => {
|
|
assert.deepEqual(opsOf({}, V), { potential: 6, cultivate: 3, changed: false });
|
|
let o = setOps({}, V, { potential: 1 });
|
|
assert.deepEqual(o, { [V]: { potential: 1 } });
|
|
o = setOps(o, V, { cultivate: 0 });
|
|
assert.deepEqual(opsOf(o, V), { potential: 1, cultivate: 0, changed: true });
|
|
o = setOps(o, V, { potential: 6, cultivate: 3 });
|
|
assert.deepEqual(o, {}, 'back to the defaults: no entry');
|
|
o = setOps(o, V, { potential: 0 });
|
|
assert.deepEqual(o, {}, 'an illegal value is ignored');
|
|
assert.deepEqual(resetOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, V), { [KALTS]: { cultivate: 1 } });
|
|
assert.deepEqual(sanitizeOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 }, char_609_acguad: { potential: 1 }, char_999_x: { potential: 1 }, [CHESS.chess_char_1_01_a.charId]: { potential: 6 } }, isOp),
|
|
{ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, 'a prototype / unknown operator / defaults dropped');
|
|
});
|
|
|
|
test('导出 / 导入: the envelope carries ops; an older export keeps the current settings; settings alone import', () => {
|
|
const p = exportPayload({ [VENDLA]: { skill: 0 } }, { ops: { [V]: { potential: 2 } } });
|
|
assert.deepEqual(p.ops, { [V]: { potential: 2 } });
|
|
const back = parseImport(JSON.stringify(p));
|
|
assert.deepEqual([back.ok, back.entries, back.ops], [true, { [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }]);
|
|
const old = parseImport({ kind: 'stronghold.loadout', v: 1, entries: { [VENDLA]: { skill: 0 } } });
|
|
assert.equal('ops' in old, false, 'an older export: no ops');
|
|
assert.equal(parseImport({ v: 1, entries: {}, ops: { [V]: { cultivate: 1 } } }).ok, true, 'settings only');
|
|
assert.equal(parseImport({ v: 1, entries: {}, ops: {} }).ok, false, 'nothing at all');
|
|
const before = { entries: loadoutStore.get().entries, ops: loadoutStore.get().ops };
|
|
try {
|
|
setOpsMap({ [KALTS]: { cultivate: 1 } });
|
|
const r1 = applyLoadoutEntries({ [VENDLA]: { skill: 0 } }, get);
|
|
assert.deepEqual(r1, { applied: 1, dropped: 0 });
|
|
assert.deepEqual(loadoutStore.get().ops, { [KALTS]: { cultivate: 1 } }, 'no ops in the import: the settings stay');
|
|
const r2 = applyLoadoutEntries({}, get, { ops: { [V]: { potential: 3 }, char_609_acguad: { potential: 1 } }, isOperator: isOp });
|
|
assert.deepEqual(r2, { applied: 0, dropped: 1, ops: 1 });
|
|
assert.deepEqual(loadoutStore.get().ops, { [V]: { potential: 3 } }, 'the imported settings replace the current ones');
|
|
} finally {
|
|
setEntries(before.entries);
|
|
setOpsMap(before.ops);
|
|
}
|
|
});
|
|
|
|
test('the 已调整 count and 仅看已调整 include an operator whose 潜能 / 练度 differ', () => {
|
|
const roster = rosterOf(Object.values(CHESS));
|
|
assert.equal(changedCount({}, get, { [V]: { potential: 2 } }, roster), 1);
|
|
assert.equal(changedCount({ [VENDLA]: { skill: 0 } }, get, { [V]: { potential: 2 } }, roster), 1, 'one operator');
|
|
assert.equal(changedCount({}, get, {}, roster), 0);
|
|
const only = filterRoster(roster, { changedOnly: true }, {}, get, () => null, { [V]: { cultivate: 0 } });
|
|
assert.deepEqual(only.map((c) => c.chessId), [VENDLA]);
|
|
});
|
|
|
|
function fakeNet() {
|
|
const listeners = new Map();
|
|
const net = {
|
|
status: 'online', sent: [], replies: [],
|
|
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
|
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
|
request(t, fields) { net.sent.push({ t, ...fields }); return Promise.resolve({ t: 'ok' }); },
|
|
};
|
|
return net;
|
|
}
|
|
function fakeTimers() {
|
|
let now = 0; let seq = 0;
|
|
const q = new Map();
|
|
return {
|
|
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
|
clearTimeout: (id) => q.delete(id),
|
|
async advance(ms) {
|
|
const end = now + ms;
|
|
for (;;) {
|
|
const next = [...q.entries()].filter(([, x]) => x.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
|
if (!next) break;
|
|
q.delete(next[0]); now = next[1].at; next[1].fn();
|
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
}
|
|
now = end;
|
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
},
|
|
};
|
|
}
|
|
|
|
test('sync: room.loadout carries the sanitised ops; an edit of the settings alone is sent; none set ⇒ ops {} without a download', async () => {
|
|
const net = fakeNet();
|
|
const T = fakeTimers();
|
|
let backups = 0;
|
|
const target = createStore({ entries: {}, ops: { [V]: { potential: 1 }, char_609_acguad: { potential: 1 } }, open: false, sync: 'idle' });
|
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, getBackupsReady: async () => { backups++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
|
net.emit('welcome', {});
|
|
await T.advance(100);
|
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1 } } }], 'a prototype dropped');
|
|
assert.equal(backups, 1, 'the 自选 pool needs backups.json');
|
|
target.set({ ops: { [V]: { potential: 1, cultivate: 2 } } });
|
|
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
|
assert.deepEqual(net.sent[1], { t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1, cultivate: 2 } } });
|
|
s.dispose();
|
|
// nothing set: nothing to sanitise, no data loaded
|
|
const net2 = fakeNet();
|
|
let loads = 0;
|
|
const t2 = createStore({ entries: {}, ops: {}, open: false, sync: 'idle' });
|
|
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => { loads++; return CHESS; }, getBackupsReady: async () => { loads++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
|
net2.emit('welcome', {});
|
|
await T.advance(100);
|
|
assert.deepEqual([net2.sent, loads], [[{ t: 'room.loadout', entries: {}, ops: {} }], 0]);
|
|
s2.dispose();
|
|
});
|
|
|
|
test('cards: the record at the player\'s 潜能 with its 练度 multiplier; a teammate\'s unit its own; a stand-in neither', () => {
|
|
const v = CHESS[VENDLA];
|
|
const fx = { effects: EFFECTS };
|
|
const at = (opts) => chessLoadout(v, null, get, { ...fx, ...opts }).record.stats;
|
|
assert.deepEqual([at({}).atk, at({}).cost], [435 * 1.1, 15], 'no settings: 满潜 + 精英2 Lv.60');
|
|
assert.deepEqual([at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).atk, at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).cost], [413, 17]);
|
|
assert.deepEqual([at({ ops: { [V]: { cultivate: 1 } } }).atk, at({ ops: { [V]: { cultivate: 1 } } }).def], [435 * 1.05, 77 * 1.05]);
|
|
assert.equal(chessLoadout(v, null, get, { ops: { [V]: { potential: 4 } }, ...fx }).record.talents.find((t) => t.name === '土壤基肥改良').bb.heal_scale, 1.08);
|
|
assert.deepEqual(chessLoadout(v, null, get, { ops: { [V]: { potential: 2 } }, ...fx }).cultivation, { potential: 2, cultivate: 3 });
|
|
// a teammate's unit: its UnitInfo
|
|
assert.deepEqual(unitCultivation({ potential: 3, cultivate: 1 }), { potential: 3, cultivate: 1 });
|
|
assert.equal(unitCultivation({}), null, 'neither: a raw / stand-in unit');
|
|
assert.equal(at({ cultivation: unitCultivation({ cultivate: 0 }) }).atk, 435);
|
|
assert.equal(at({ cultivation: null }).atk, 435, 'none');
|
|
// a stand-in (补位): neither, whatever the settings say
|
|
const normal = Object.values(CHESS).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible);
|
|
const st = standInOf(normal, BACKUPS);
|
|
const si = standInLoadout(st, get, BACKUPS);
|
|
assert.ok(st && st.standInFor === normal.charId);
|
|
assert.equal(si.cultivation, null);
|
|
assert.deepEqual(si.record.stats, st.stats, 'the stand-in\'s own numbers');
|
|
assert.equal(chessLoadout(st, null, get, { ops: { [normal.charId]: { potential: 1, cultivate: 0 }, [st.charId]: { potential: 1 } }, ...fx }).cultivation, null);
|
|
});
|
|
|
|
test('自选 cards: the player\'s own pick at its 潜能 (m.private.ops), another player\'s unit at its UnitInfo potential', () => {
|
|
const T6 = 'chess_char_6_diy1_a';
|
|
const data = { chess: CHESS, backups: BACKUPS };
|
|
const priv = { diy: { [T6]: { charId: KALTS, skillIndex: 0, uniEquipId: null } }, ops: { [KALTS]: { potential: 1 } } };
|
|
assert.deepEqual(ownDiyPick(priv, CHESS[T6]), { charId: KALTS, skillIndex: 0, uniEquipId: null, potential: 1 });
|
|
const rec = ownDiyRecord(CHESS[T6], priv, data);
|
|
assert.deepEqual([rec.stats.atk, rec.stats.cost], [392, 20], '凯尔希 潜能1');
|
|
const full = ownDiyRecord(CHESS[T6], { ...priv, ops: {} }, data);
|
|
assert.deepEqual([full.stats.atk, full.stats.cost], [417, 18]);
|
|
const mate = cardDiy(CHESS[T6], { unit: { diy: { charId: KALTS, skillIndex: 0, uniEquipId: null }, potential: 1, cultivate: 3 }, data });
|
|
assert.equal(mate.stats.atk, 392);
|
|
// the card multiplies the 练度 on top (the composed record is already at its potential)
|
|
assert.equal(chessLoadout(rec, null, get, { ops: priv.ops, effects: EFFECTS }).record.stats.atk, 392 * 1.1);
|
|
});
|