feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text
- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
(stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
lint 0 errors; typecheck; i18n strict clean (1152 msgids).
This commit is contained in:
commit
0ced21d321
82 files changed
+9156
-6840
No files matched your search
@@ -178,6 +178,19 @@ test('圣约送葬人 module REA-Y (已知悉): ASPD +12 with ≥ 2 enemies in r
|
||||
}
|
||||
});
|
||||
|
||||
test('圣约送葬人 heals 50 per enemy hit with REA-Y or no module, 60 with REA-X (the REA-Y line\'s 12 is its ASPD, GitHub #400)', () => {
|
||||
for (const [moduleId, want] of [['uniequip_003_excu2', 50], ['uniequip_002_excu2', 60], ['none', 50]]) {
|
||||
const h = run({
|
||||
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||
units: [{ chessId: 'chess_char_5_01_b', row: 10, col: 4, moduleId }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit('chess_char_5_01_b');
|
||||
assert.equal(u.profile.selfHeal, want, moduleId);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// 缇缇
|
||||
|
||||
|
||||
+29
-1
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { stripPotential } from '../shared/potential.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
// DATA_DIR lets the suite validate an alternative build output (e.g. `--out /tmp/x`).
|
||||
@@ -558,7 +559,8 @@ test('chess: golden modules[] (+ statsBase/traitBase/talentsBase) compose back t
|
||||
// default loadout = the record's own stats / trait / talents
|
||||
assert.deepEqual(composeStats(c.statsBase, dm?.attr), c.stats, `${c.chessId}: statsBase + default attr = stats`);
|
||||
assert.deepEqual(dm?.traitOverride ?? c.traitBase, c.trait, `${c.chessId}: trait`);
|
||||
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), c.talents, `${c.chessId}: talents`);
|
||||
// (the record's own lists chain their lower-potential entries — shared/potential.js; a composed list carries none)
|
||||
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), stripPotential(c.talents), `${c.chessId}: talents`);
|
||||
assert.equal(new Set(c.modules.map((m) => m.uniEquipId)).size, c.modules.length);
|
||||
for (const m of c.modules) {
|
||||
nMods++;
|
||||
@@ -727,6 +729,32 @@ test('official spot checks (hard-coded values from the zh_CN client data)', () =
|
||||
assert.equal(config.modes.mode_multi_abyss.enemyScale['6'].hp, 2.239488); // 1.2^4 × 1.08
|
||||
});
|
||||
|
||||
test('a module trait part that only adds a display line writes that line only (GitHub #400): 圣约送葬人 REA-Y heals 50 per enemy hit, its line ASPD +12', () => {
|
||||
// official battle_equip_table uniequip_003_excu2: a DISPLAY part whose blackboard `value` 12 belongs to its own
|
||||
// 「攻击速度+{value}」; the class trait's 「回复自身{value}生命」 stays the base 50 (REA-X's TRAIT_DATA_ONLY part rewrites it: 60)
|
||||
const g = chess.chess_char_5_01_b;
|
||||
const y = g.modules.find((m) => m.uniEquipId === 'uniequip_003_excu2').traitOverride;
|
||||
assert.equal(y.desc, g.traitBase.desc, 'the class trait as without a module');
|
||||
assert.match(y.desc, /每攻击到一个敌人回复自身50生命/);
|
||||
assert.match(y.descRaw, /回复自身<@ba\.kw>50<\/>生命/);
|
||||
assert.equal(y.moduleDesc, '攻击范围内存在2名及以上敌人时攻击速度+12');
|
||||
assert.match(g.modules.find((m) => m.uniEquipId === 'uniequip_002_excu2').traitOverride.desc, /回复自身60生命/, 'REA-X');
|
||||
// no other module record carries a trait line that differs from its owner's class trait only in a number its own
|
||||
// display line uses (the class of this bug)
|
||||
const forms = [...Object.values(chess), ...Object.values(D.backups.units || {}).flatMap((u) => Object.values(u.forms || {}))];
|
||||
for (const f of forms) {
|
||||
for (const m of f.modules || []) {
|
||||
const o = m.traitOverride;
|
||||
if (!o?.moduleDesc || !f.traitBase?.desc || o.desc === f.traitBase.desc) continue;
|
||||
const nums = (t) => String(t).match(/\d+(?:\.\d+)?/g) || [];
|
||||
const sameWords = o.desc.replace(/\d+(?:\.\d+)?/g, '#') === f.traitBase.desc.replace(/\d+(?:\.\d+)?/g, '#');
|
||||
if (!sameWords) continue;
|
||||
const moved = nums(o.desc).filter((n, i) => n !== nums(f.traitBase.desc)[i]);
|
||||
assert.ok(!moved.every((n) => nums(o.moduleDesc).includes(n)), `${f.chessId || f.charId} ${m.uniEquipId}: ${o.desc} / ${o.moduleDesc}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Read an official cache file (only called when HAS_CACHE). */
|
||||
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// test/full-potential.test.js — every operator fights at FULL potential (潜能 6), the owner's decision of 2026-10-07
|
||||
// (GitHub #252, PR #255; tools/build-data.mjs OPERATOR_POTENTIAL, docs/DATA.md §2): every chess (normal and elite), 自选
|
||||
// test/full-potential.test.js — the data records are built at FULL potential (潜能 6: the default — the owner's decisions of
|
||||
// 2026-10-07, GitHub #252 / PR #255, and 2026-10-08: a lower potential is the player's 干员调配 setting, test/potential.test.js;
|
||||
// tools/build-data.mjs FULL_RANK, docs/DATA.md §2 / §2.3): every chess (normal and elite), 自选
|
||||
// pick and 补位 stand-in adds its character_table `potentialRanks` attribute steps and takes its talent / module-talent
|
||||
// candidates up to `requiredPotentialRank` 5; a summon picks its talent candidates at its owner's potential but takes
|
||||
// none of the owner's attribute steps. The evidence: a tournament video shows 刺玫 (chess_char_1_06_a) at ATK 435 and
|
||||
|
||||
+748
-750
File diff suppressed because it is too large.
Load diff
+3026
-3032
File diff suppressed because it is too large.
Load diff
+789
-792
File diff suppressed because it is too large.
Load diff
+338
-333
File diff suppressed because it is too large.
Load diff
+1529
-1532
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
|
||||
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
|
||||
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
|
||||
// the heavy-intent rate limit.
|
||||
// the heavy-intent rate limit; its per-operator 潜能 / 练度 `ops` (0.2.2): validated with it, stored and handed over beside it.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -111,6 +111,39 @@ describe('room.loadout (lobby, stub match)', () => {
|
||||
await back.terminate();
|
||||
});
|
||||
|
||||
test('潜能 / 练度 (ops, 0.2.2): strict validation, defaults dropped, stored per session and seat beside the loadout; a message without ops sets none', async () => {
|
||||
const VENDLA = C('chess_char_1_06_a').charId; // a 特许 operator of the roster
|
||||
const KALTS = 'char_003_kalts'; // an owned-6★ 自选 pick
|
||||
const a = await pool.player('Ops');
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: [] }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: {} } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 0 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 7 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { cultivate: 4 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, elite: 1 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { char_609_acguad: { potential: 1 } } }, ERR.BAD_TARGET); // a prototype
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||
// a refusal of the ops refuses the entries too (nothing stored)
|
||||
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { cultivate: 2 }, [C(INSIDE).charId]: { potential: 6, cultivate: 3 } } });
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seat = RecordingStub.instances.at(-1).opts.seats[0];
|
||||
assert.deepEqual(seat.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
|
||||
assert.deepEqual(seat.ops, { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 2 } }, 'complete entries, defaults dropped');
|
||||
assert.ok(Object.isFrozen(seat.ops) && Object.isFrozen(seat.ops[VENDLA]));
|
||||
// an older client's message (no ops): none set — the next match fields every operator at 潜能 6 / 精英2 Lv.60
|
||||
await err(a, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
|
||||
const b = await pool.player('Old');
|
||||
await ok(b, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
||||
await createRoom(b, 'solo');
|
||||
await ok(b, { t: 'room.start' });
|
||||
await b.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
assert.deepEqual(RecordingStub.instances.at(-1).opts.seats[0].ops, {});
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
|
||||
const a = await pool.player('Spam');
|
||||
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
|
||||
@@ -146,4 +179,19 @@ describe('room.loadout (lobby + real match)', () => {
|
||||
assert.match(r.detail || '', /locked/);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('潜能 / 练度 (0.2.2): taken with the loadout in the briefing (m.private.ops), stated in the battle input; locked afterwards', async () => {
|
||||
const VENDLA = C('chess_char_1_06_a').charId;
|
||||
const a = await pool.player('Solo2');
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||
await ok(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, cultivate: 1 } } });
|
||||
const priv = await a.waitFor('m.private', (p) => p.ops && p.ops[VENDLA], 3000);
|
||||
assert.deepEqual(priv.ops, { [VENDLA]: { potential: 2, cultivate: 1 } });
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: {} }, ERR.WRONG_PHASE);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -88,8 +88,10 @@ test('终极 Final Assault (+200 layers per active bond): one pool (bloodPoint
|
||||
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
|
||||
}
|
||||
const { add, mul } = aggregateMods(u.buffs);
|
||||
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1));
|
||||
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes)`);
|
||||
// the 练度 multiplier (0.2.2, the unit's own cultMul: default 精英2 Lv.60 = ×1.1) is a separate factor, after 直接乘算
|
||||
const cult = u.cultMul ? u.cultMul.atk : 1;
|
||||
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1) * cult);
|
||||
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes) × 练度`);
|
||||
}
|
||||
}
|
||||
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
|
||||
|
||||
@@ -96,7 +96,8 @@ test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the playe
|
||||
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
|
||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
|
||||
assert.equal(rec.skill.skillId, 'skchr_siege_3');
|
||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
|
||||
// (0.2.2: an owned pick at the player's 潜能 / 练度 — none set: 6 / 3)
|
||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null, potential: 6, cultivate: 3 }, 'the pick, never the loadout');
|
||||
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
|
||||
}
|
||||
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// test/match/potential.test.js — the player's 潜能 / 练度 on the server (0.2.2, the owner's decision of 2026-10-08:
|
||||
// 「调配干员里自己设置吧,默认满潜满加成」): room.loadout `ops` (shared/protocol.js checkLoadoutOps) → seats[].ops →
|
||||
// PlayerState.ops (re-checked, frozen; bots none) → every owned operator's PlayerBattleInput entry states `potential` /
|
||||
// `cultivate` (defaults 6 / 3 — also an old loadout without ops), never a 补位 stand-in or a prototype 自选 pick; the
|
||||
// 自选 summons' hand count follows the owner's potential; m.private.ops; the scouting units; Match.setLoadout takes
|
||||
// them in INFO_CHECK only.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S, checkLoadoutOps, isLoadoutOps, cultivationCharIds, LOADOUT_LIMITS } from '../../shared/protocol.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { DATA, makeMatch } from './harness.js';
|
||||
|
||||
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET)
|
||||
const VENDLA_CHAR = chess(VENDLA).charId;
|
||||
const GRANI = Object.values(DATA.chess).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible && c.backup?.charId && c.backup.charId !== c.charId);
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const KALTS = 'char_003_kalts';
|
||||
const WANG = 'char_2027_wang';
|
||||
const SHARP = 'char_609_acguad';
|
||||
const IDS = cultivationCharIds(DATA.chess, DATA.backups);
|
||||
|
||||
function place(ps, chessId, row, col) {
|
||||
const p = ps.newPiece('chess', chessId);
|
||||
ps.board.set(`${row},${col}`, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
test('room.loadout ops: structure (≤ 256 charIds → { potential 1–6?, cultivate 0–3? }) and the strict data check', () => {
|
||||
const ok = (ops) => validateC2S({ t: 'room.loadout', entries: {}, ops }) === null;
|
||||
assert.ok(validateC2S({ t: 'room.loadout', entries: {} }) === null, 'ops optional (an older client)');
|
||||
assert.ok(ok({}));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { potential: 1 } }));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { cultivate: 0 } }));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { potential: 6, cultivate: 3 } }));
|
||||
for (const bad of [[], null, { [VENDLA_CHAR]: {} }, { [VENDLA_CHAR]: { potential: 0 } }, { [VENDLA_CHAR]: { potential: 6.5 } },
|
||||
{ [VENDLA_CHAR]: { cultivate: -1 } }, { [VENDLA_CHAR]: { cultivate: 4 } }, { [VENDLA_CHAR]: { potential: '3' } }, { 'a b': { potential: 1 } },
|
||||
{ [VENDLA_CHAR]: { potential: 1, trust: 100 } }]) assert.ok(!ok(bad), JSON.stringify(bad));
|
||||
const many = Object.fromEntries(Array.from({ length: LOADOUT_LIMITS.ops + 1 }, (_, i) => [`char_${i}`, { potential: 1 }]));
|
||||
assert.ok(!isLoadoutOps(many));
|
||||
// the operators one may set: the 112-chess roster's charIds (hidden twins share them) and the 71 owned 自选 picks
|
||||
assert.ok(IDS.has(VENDLA_CHAR) && IDS.has(KALTS) && IDS.has(WANG));
|
||||
assert.ok(!IDS.has(SHARP) && !IDS.has(GRANI.backup.charId), 'a prototype / stand-in character is no setting');
|
||||
assert.ok(IDS.size >= 180 && IDS.size <= 200, `${IDS.size}`);
|
||||
const has = (id) => IDS.has(id);
|
||||
assert.deepEqual(checkLoadoutOps(undefined, has), { ok: true, ops: {} });
|
||||
assert.deepEqual(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 2 }, [KALTS]: { potential: 6, cultivate: 3 } }, has), { ok: true, ops: { [VENDLA_CHAR]: { potential: 2, cultivate: 3 } } });
|
||||
assert.equal(checkLoadoutOps({ [SHARP]: { potential: 2 } }, has).error, 'BAD_TARGET');
|
||||
assert.equal(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 9 } }, has).error, 'BAD_MSG');
|
||||
});
|
||||
|
||||
test('battle input: every owned operator states 潜能 / 练度 — the human\'s settings, the defaults (6 / 3) for the rest, for a seat without ops and for bots', () => {
|
||||
const ops = { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } };
|
||||
const seats = [
|
||||
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: null, ops, notOwned: [GRANI.chessId] },
|
||||
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: {} }, // an old seat: no ops
|
||||
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, ops },
|
||||
];
|
||||
const h = makeMatch({ mode: 'coop', seats, seed: 3 }).start();
|
||||
const m = h.m;
|
||||
const [p0, p1, bot] = ['p_0', 'p_1', 'ai_0'].map((id) => h.ps(id));
|
||||
assert.deepEqual(p0.ops, { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } });
|
||||
assert.ok(Object.isFrozen(p0.ops) && Object.isFrozen(p0.ops[VENDLA_CHAR]));
|
||||
assert.deepEqual([p1.ops, bot.ops], [{}, {}], 'no settings / a bot: the defaults');
|
||||
h.flushAll();
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, p0.ops, 'm.private exposes the effective settings');
|
||||
h.toPrep(1);
|
||||
for (const ps of [p0, p1, bot]) { for (const p of [...ps.board.values()]) ps.returnCopies(p); ps.board.clear(); }
|
||||
place(p0, VENDLA, 9, 3);
|
||||
place(p0, chess(VENDLA).goldenId, 10, 3);
|
||||
place(p0, 'chess_char_1_02_a', 11, 3);
|
||||
place(p0, GRANI.chessId, 12, 3); // not owned: its stand-in
|
||||
place(p1, VENDLA, 9, 3);
|
||||
place(bot, VENDLA, 9, 3);
|
||||
const by = (ps, id) => ps.battleInput().units.find((u) => u.chessId === id);
|
||||
assert.deepEqual([by(p0, VENDLA).potential, by(p0, VENDLA).cultivate], [1, 0], 'the human\'s setting');
|
||||
assert.deepEqual([by(p0, chess(VENDLA).goldenId).potential, by(p0, chess(VENDLA).goldenId).cultivate], [1, 0], 'the elite shares the charId');
|
||||
assert.deepEqual([by(p0, 'chess_char_1_02_a').potential, by(p0, 'chess_char_1_02_a').cultivate], [6, 3], 'no entry: 满潜满加成');
|
||||
assert.deepEqual(['potential' in by(p0, GRANI.chessId), 'cultivate' in by(p0, GRANI.chessId), by(p0, GRANI.chessId).standIn], [false, false, true], 'a stand-in has neither');
|
||||
assert.deepEqual([by(p1, VENDLA).potential, by(p1, VENDLA).cultivate], [6, 3], 'an old loadout: the defaults');
|
||||
assert.deepEqual([by(bot, VENDLA).potential, by(bot, VENDLA).cultivate], [6, 3], 'bots: the defaults');
|
||||
// the spec keeps them and the battle fields them
|
||||
const spec = buildBattleSpec(m._normalOpts(p0));
|
||||
const b = createBattleFromSpec(spec, undefined, { quiet: true });
|
||||
const u = b.allyUnits.find((x) => x.defId === VENDLA);
|
||||
const e = b.allyUnits.find((x) => x.defId === 'chess_char_1_02_a');
|
||||
assert.deepEqual([u.base.atk, u.base.cost, u.cultivate, u.s.atk], [413, 17, 0, 413]);
|
||||
assert.deepEqual([e.cultivate, e.s.maxHp], [3, e.base.maxHp * 1.1]);
|
||||
// the scouting units carry them (a teammate's card)
|
||||
const scout = m.prepFieldMeta(p0).units;
|
||||
const sv = scout.find((x) => x.defId === VENDLA);
|
||||
assert.deepEqual([sv.potential, sv.cultivate], [1, 0]);
|
||||
const sg = scout.find((x) => x.defId === GRANI.chessId);
|
||||
assert.deepEqual([sg.potential, sg.cultivate], [undefined, undefined]);
|
||||
assert.deepEqual([scout.find((x) => x.defId === 'chess_char_1_02_a').potential, scout.find((x) => x.defId === 'chess_char_1_02_a').cultivate], [undefined, 3]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('自选: an owned pick states its operator\'s settings, a prototype neither; 望\'s 棋子 hand count follows her potential', () => {
|
||||
const ops = { [KALTS]: { potential: 2, cultivate: 1 }, [WANG]: { potential: 2 } };
|
||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, ops,
|
||||
diy: { [T6A]: { charId: KALTS, skillIndex: 0 }, [T5A]: { charId: SHARP }, chess_char_6_diy2_a: { charId: WANG, skillIndex: 0 } } }];
|
||||
const h = makeMatch({ mode: 'solo', seats, seed: 7 }).start();
|
||||
const m = h.m;
|
||||
const p0 = h.ps('p_0');
|
||||
h.toPrep(1);
|
||||
for (const p of [...p0.board.values()]) p0.returnCopies(p);
|
||||
p0.board.clear();
|
||||
place(p0, T6A, 10, 3);
|
||||
place(p0, T5A, 10, 5);
|
||||
const units = p0.battleInput().units;
|
||||
const k = units.find((u) => u.chessId === T6A);
|
||||
const s = units.find((u) => u.chessId === T5A);
|
||||
assert.deepEqual([k.diy.charId, k.potential, k.cultivate], [KALTS, 2, 1]);
|
||||
assert.deepEqual([s.diy.charId, 'potential' in s, 'cultivate' in s], [SHARP, false, false]);
|
||||
// 望 (潜能 2): 6 棋子 to the hand, not 7
|
||||
const lo = p0.loadoutFor(p0.gd.chess('chess_char_6_diy2_a'));
|
||||
assert.equal(lo.potential, 2);
|
||||
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', lo), [{ tokenId: 'token_10064_wang_stone1', count: 6 }]);
|
||||
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', { ...lo, potential: 6 }), [{ tokenId: 'token_10064_wang_stone1', count: 7 }]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Match.setLoadout: the settings change with the loadout in INFO_CHECK only; re-checked; a refusal changes nothing', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
assert.deepEqual(ps.ops, {});
|
||||
assert.deepEqual(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 3 } }), { ok: true });
|
||||
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||
assert.deepEqual(m.setLoadout('p_0', {}), { ok: true }, 'no ops argument: unchanged');
|
||||
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||
assert.equal(m.setLoadout('p_0', {}, { [SHARP]: { potential: 1 } }).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.setLoadout('p_0', { chess_char_1_01_a: { skill: 0 } }, { [VENDLA_CHAR]: { potential: 9 } }).error, ERR.BAD_TARGET);
|
||||
assert.deepEqual([ps.ops, ps.loadout], [{ [VENDLA_CHAR]: { potential: 3, cultivate: 3 } }, {}], 'a refusal changes nothing');
|
||||
assert.deepEqual(m.setLoadout('p_0', {}, null), { ok: true }, 'null: none set');
|
||||
assert.deepEqual(ps.ops, {});
|
||||
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
assert.equal(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 1 } }).error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
|
||||
assert.deepEqual(ps.ops, {});
|
||||
m.dispose();
|
||||
});
|
||||
@@ -438,8 +438,10 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
|
||||
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
||||
assert.equal(got.blockCnt, s.blockCnt);
|
||||
assert.equal(got.hp, Math.round(ally.hp));
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
|
||||
// the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1)
|
||||
assert.equal(ally.cultivate, 3);
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp));
|
||||
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
||||
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
||||
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// test/potential.test.js — potential (潜能 1–6) is a per-player runtime input (0.2.2, the owner's decision of 2026-10-08:
|
||||
// 「调配干员里自己设置吧,默认满潜满加成」): the data records are built at full potential and carry what every lower
|
||||
// potential changes (shared/potential.js; docs/DATA.md §2.3 — `potDown` leaves, chained talent entries). These tests
|
||||
// check the composition at every potential against the raw official tables (character_table keyframes plus the
|
||||
// `potentialRanks` steps up to the potential, talent candidates up to `requiredPotentialRank`), the summons at their
|
||||
// owner's potential, and the data model (full potential = the record itself; a resolved record carries no annotation).
|
||||
// With the official-data cache (.cache/gamedata) the re-derivations run; the spot checks always do.
|
||||
// Run: node --test test/potential.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
atPotential, atRank, talentAtRank, stripPotential, hasPotentialData, rankOf, isPotential, isCultivate, cultivateMul,
|
||||
cultivatedStats, cultivationOf, CULTIVATE_EFFECTS, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, FULL_RANK,
|
||||
} from '../shared/potential.js';
|
||||
import { composeTalents } from '../shared/loadoutRecord.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DATA = process.env.DATA_DIR || join(ROOT, 'data');
|
||||
const CACHE = join(ROOT, '.cache', 'gamedata');
|
||||
const HAS_CACHE = ['activity_table', 'character_table'].every((f) => existsSync(join(CACHE, 'excel', `${f}.json`)));
|
||||
const load = (name) => JSON.parse(readFileSync(join(DATA, `${name}.json`), 'utf8'));
|
||||
const chess = load('chess');
|
||||
const tokens = load('tokens');
|
||||
const backups = load('backups');
|
||||
const effects = load('effects');
|
||||
const raw = (f) => JSON.parse(readFileSync(join(CACHE, 'excel', `${f}.json`), 'utf8'));
|
||||
const talent = (rec, name) => rec.talents.find((t) => t.name === name);
|
||||
|
||||
test('刺玫 at every potential: 潜能1 ATK 413 / cost 17 / 70 s and 土壤基肥改良 8%, each step at its rank, 潜能6 = the record itself', () => {
|
||||
const v = chess.chess_char_1_06_a;
|
||||
const at = (p) => atPotential(v, p);
|
||||
const row = (p) => [at(p).stats.atk, at(p).stats.cost, at(p).stats.respawnTime, talent(at(p), '土壤基肥改良').bb.heal_scale];
|
||||
assert.deepEqual(row(1), [413, 17, 70, 1.08]);
|
||||
assert.deepEqual(row(2), [413, 16, 70, 1.08], '潜能2 部署费用-1');
|
||||
assert.deepEqual(row(3), [413, 16, 66, 1.08], '潜能3 再部署时间-4秒');
|
||||
assert.deepEqual(row(4), [435, 16, 66, 1.08], '潜能4 攻击力+22');
|
||||
assert.deepEqual(row(5), [435, 16, 66, 1.11], '潜能5 天赋效果增强');
|
||||
assert.deepEqual(row(6), [435, 15, 66, 1.11], '潜能6 部署费用-1');
|
||||
assert.equal(at(6), v, 'full potential is the record itself');
|
||||
assert.equal(at(null), v);
|
||||
assert.equal(talent(at(1), '土壤基肥改良').desc, '攻击范围内生命上限最高的友方角色受到的治疗效果提升8%');
|
||||
// the elite: statsBase and stats move together, the talent from its own E2 candidates
|
||||
const e = chess.chess_char_1_06_b;
|
||||
assert.deepEqual([atPotential(e, 1).stats.atk, atPotential(e, 1).statsBase.atk, atPotential(e, 1).stats.cost], [531, 508, 17]);
|
||||
assert.equal(talent(atPotential(e, 4), '土壤基肥改良').bb.heal_scale, 1.15);
|
||||
});
|
||||
|
||||
test('a resolved record carries no annotation; the full record minus them is what 0.2.1 shipped (the stats and talents of 潜能6)', () => {
|
||||
let n = 0;
|
||||
for (const c of Object.values(chess)) {
|
||||
if (c.isDiy || !c.charId) continue;
|
||||
for (let p = 1; p <= 5; p++) {
|
||||
const r = atPotential(c, p);
|
||||
assert.notEqual(r, c);
|
||||
assert.equal(JSON.stringify(r).match(/"pot(Down|Min|Below)"/), null, `${c.chessId} @${p}`);
|
||||
}
|
||||
if (hasPotentialData(c)) n++;
|
||||
}
|
||||
assert.ok(n >= 250, `chess records that change below full potential: ${n}`);
|
||||
// composed lists never carry chains (shared/loadoutRecord.js composeTalents)
|
||||
const e = chess.chess_char_1_06_b;
|
||||
assert.equal(JSON.stringify(composeTalents(e.talentsBase, e.modules[0].talentChanges)).includes('potBelow'), false);
|
||||
assert.deepEqual(stripPotential({ a: [{ potMin: 4, potBelow: { x: 1 }, x: 2 }], potDown: { 0: {} } }), { a: [{ x: 2 }] });
|
||||
});
|
||||
|
||||
test('every chess and 自选 form at every potential: the raw keyframes plus the potential steps up to it (character_table)', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const CT = raw('character_table');
|
||||
const KEY = { MAX_HP: 'maxHp', ATK: 'atk', DEF: 'def', MAGIC_RESISTANCE: 'res', COST: 'cost', RESPAWN_TIME: 'respawnTime', ATTACK_SPEED: 'aspd' };
|
||||
const steps = (charId, rank) => {
|
||||
const out = {};
|
||||
for (const r of (CT[charId].potentialRanks || []).slice(0, rank)) {
|
||||
for (const m of r.buff?.attributes?.attributeModifiers || []) out[KEY[m.attributeType]] = (out[KEY[m.attributeType]] || 0) + m.value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const frame = (charId, phase, level) => {
|
||||
const P = CT[charId].phases[phase];
|
||||
const k0 = P.attributesKeyFrames[0], k1 = P.attributesKeyFrames[P.attributesKeyFrames.length - 1];
|
||||
const t = (level - k0.level) / (k1.level - k0.level || 1);
|
||||
const f = (key) => k0.data[key] + (k1.data[key] - k0.data[key]) * t;
|
||||
return { maxHp: f('maxHp'), atk: f('atk'), def: f('def'), res: f('magicResistance'), cost: f('cost'), respawnTime: f('respawnTime'), aspd: f('attackSpeed') };
|
||||
};
|
||||
const check = (label, charId, status, stats, rank) => {
|
||||
const exp = frame(charId, status.phase, status.level);
|
||||
for (const [k, v] of Object.entries(steps(charId, rank))) exp[k] += v;
|
||||
for (const [k, v] of Object.entries(exp)) assert.ok(Math.abs(v - stats[k]) <= 0.5 + 1e-9, `${label} rank ${rank}: ${k} ${stats[k]} vs official ${v}`);
|
||||
};
|
||||
let n = 0;
|
||||
for (const c of Object.values(chess)) {
|
||||
if (c.isDiy || !c.charId) continue;
|
||||
for (let rank = 0; rank <= FULL_RANK; rank++) { const r = atRank(c, rank); check(c.chessId, c.charId, c.status, r.statsBase ?? r.stats, rank); n++; }
|
||||
}
|
||||
assert.equal(n, 258 * 6);
|
||||
let forms = 0;
|
||||
for (const [charId, u] of Object.entries(backups.units)) {
|
||||
for (const f of Object.values(u.forms)) {
|
||||
for (let rank = 0; rank <= FULL_RANK; rank++) check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, atRank(f, rank).stats, rank);
|
||||
forms++;
|
||||
}
|
||||
}
|
||||
assert.equal(forms, 256);
|
||||
});
|
||||
|
||||
test('talent candidates at every potential: each talent is the last one unlocked with requiredPotentialRank ≤ the rank', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const CT = raw('character_table');
|
||||
const PH = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
|
||||
const ok = (c, phase, level, rank) => {
|
||||
const u = c.unlockCondition;
|
||||
const p = PH[u?.phase ?? 'PHASE_0'];
|
||||
return (c.requiredPotentialRank || 0) <= rank && (p < phase || (p === phase && (u?.level || 1) <= level));
|
||||
};
|
||||
let moved = 0;
|
||||
const checkList = (label, charId, status, list) => {
|
||||
for (let rank = 0; rank <= FULL_RANK; rank++) {
|
||||
(CT[charId].talents || []).forEach((t, index) => {
|
||||
const cands = (t.candidates || []).filter((x) => ok(x, status.phase, status.level, rank));
|
||||
const best = cands[cands.length - 1];
|
||||
if (!best || (!best.name && !best.description && !best.blackboard.length && !best.tokenKey)) return;
|
||||
const rec = talentAtRank(list.find((x) => x.index === index), rank);
|
||||
assert.ok(rec, `${label} talent ${index}`);
|
||||
for (const b of best.blackboard) if (!(b.valueStr && b.value === 0)) assert.ok(Math.abs(rec.bb[b.key] - b.value) < 1e-9, `${label} talent ${index} rank ${rank} ${b.key}: ${rec.bb[b.key]} vs ${b.value}`);
|
||||
if (rank === 0 && (best.requiredPotentialRank || 0) === 0 && (CT[charId].talents[index].candidates || []).some((x) => (x.requiredPotentialRank || 0) > 0 && ok(x, status.phase, status.level, FULL_RANK))) moved++;
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const c of Object.values(chess)) {
|
||||
if (c.isDiy || !c.charId) continue;
|
||||
checkList(c.chessId, c.charId, c.status, c.talentsBase ?? c.talents);
|
||||
}
|
||||
for (const [charId, u] of Object.entries(backups.units)) for (const f of Object.values(u.forms)) checkList(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.talents);
|
||||
assert.ok(moved >= 150, `talents whose potential step moves them: ${moved}`);
|
||||
});
|
||||
|
||||
test('summons follow their owner\'s potential: Mon3tr 不毁重构 3 s / 1200 below 潜能5, 望\'s 棋子 6 deployed / 7 held below 潜能3, “小自在” 15 层', () => {
|
||||
const mon = backups.tokens.token_10002_kalts_mon3tr.variants['char_003_kalts@2/1/4/0'];
|
||||
assert.deepEqual([atPotential(mon, 4).talents[1].bb.stun, atPotential(mon, 4).talents[1].bb.value], [3, 1200]);
|
||||
assert.deepEqual([atPotential(mon, 5).talents[1].bb.stun, atPotential(mon, 5).talents[1].bb.value], [3.5, 1400]);
|
||||
const stone = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/1/4/0'];
|
||||
assert.deepEqual([atPotential(stone, 2).stats.deployLimit, atPotential(stone, 2).stats.deckStack], [6, 7]);
|
||||
assert.deepEqual([atPotential(stone, 3).stats.deployLimit, atPotential(stone, 3).stats.deckStack], [7, 8]);
|
||||
const tvar = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/60/7/1'];
|
||||
assert.equal(atPotential(tvar, 1).byModule.uniequip_002_wang.stats.deployLimit, 7, 'TRP-X: 6 + 1 below 潜能3');
|
||||
assert.equal(tvar.byModule.uniequip_002_wang.stats.deployLimit, 8);
|
||||
// tokens.json: 夕's “小自在” 化境 18 → 15 层 below 潜能5
|
||||
const dusk = tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a;
|
||||
assert.deepEqual([atPotential(dusk, 4).talents[0].bb.max_stack_cnt, dusk.talents[0].bb.max_stack_cnt], [15, 18]);
|
||||
// the owner's own talent text follows too (望's kit reads 「最多拥有N枚」 from it)
|
||||
assert.match(atPotential(backups.units.char_2027_wang.forms['2/60/7/1'], 1).talents[0].desc, /最多拥有7枚/);
|
||||
});
|
||||
|
||||
test('练度 (自持有): the CHAR_MAP effects in cultivateEffectList order, their char_attribute_mul as ×ATK / ×DEF / ×HP', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const list = raw('activity_table').autoChessData.cultivateEffectList.slice().sort((a, b) => a.cultivateNum - b.cultivateNum);
|
||||
assert.deepEqual(list.map((x) => x.effectId), [...CULTIVATE_EFFECTS]);
|
||||
list.forEach((row, tier) => {
|
||||
const m = cultivateMul(effects, tier);
|
||||
assert.equal(effects[row.effectId].effectType, 'CHAR_MAP');
|
||||
assert.deepEqual([m.atk, m.def, m.hp].map((x) => Math.round((x - 1) * 100)), [row.atkPer, row.defPer, row.hpPer], row.effectId);
|
||||
});
|
||||
});
|
||||
|
||||
test('练度 helpers: the four tiers, the default, the products, who has neither (补位 / 原型 自选)', () => {
|
||||
assert.deepEqual([0, 1, 2, 3].map((n) => cultivateMul(effects, n)), [
|
||||
{ atk: 1, def: 1, hp: 1 }, { atk: 1.05, def: 1.05, hp: 1 }, { atk: 1.1, def: 1.05, hp: 1.05 }, { atk: 1.1, def: 1.1, hp: 1.1 },
|
||||
]);
|
||||
assert.equal(cultivateMul(effects, 4), null);
|
||||
assert.equal(cultivateMul(effects, null), null);
|
||||
assert.deepEqual(cultivatedStats({ atk: 435, def: 77, maxHp: 1137, cost: 15 }, cultivateMul(effects, 3)), { atk: 435 * 1.1, def: 77 * 1.1, maxHp: 1137 * 1.1, cost: 15 });
|
||||
assert.deepEqual([POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, rankOf(1), rankOf(6), rankOf(0), rankOf('6')], [6, 3, 0, 5, 5, 5]);
|
||||
assert.deepEqual([isPotential(0), isPotential(1), isPotential(6), isPotential(7), isPotential(2.5)], [false, true, true, false, false]);
|
||||
assert.deepEqual([isCultivate(-1), isCultivate(0), isCultivate(3), isCultivate(4)], [false, true, true, false]);
|
||||
const v = chess.chess_char_1_06_a;
|
||||
assert.deepEqual(cultivationOf(v, null), { potential: 6, cultivate: 3 }, 'no setting: 满潜满加成');
|
||||
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 1 } }), { potential: 1, cultivate: 3 });
|
||||
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 9, cultivate: 0 } }), { potential: 6, cultivate: 0 }, 'a bad value is the default');
|
||||
assert.equal(cultivationOf({ ...v, standInFor: 'char_x' }, null), null, '补位');
|
||||
assert.equal(cultivationOf({ ...v, diyProto: true }, null), null, '原型 自选');
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const WTRMAN = 'token_10030_mlyss_wtrman';
|
||||
|
||||
test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => {
|
||||
const n = C[INSIDE];
|
||||
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, skillIsDefault: true, moduleIsDefault: true, isDefault: true });
|
||||
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, potential: 6, skillIsDefault: true, moduleIsDefault: true, potentialIsDefault: true, isDefault: true });
|
||||
assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0);
|
||||
assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted');
|
||||
assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default');
|
||||
@@ -80,7 +80,7 @@ test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats,
|
||||
assert.equal(s1.skill.id, 'skchr_inside_1');
|
||||
assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb);
|
||||
assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost);
|
||||
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, skillIsDefault: false, moduleIsDefault: true, isDefault: false });
|
||||
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, potential: 6, skillIsDefault: false, moduleIsDefault: true, potentialIsDefault: true, isDefault: false });
|
||||
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
|
||||
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
|
||||
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// test/sim/potential-battle.test.js — potential and 练度 on a battle unit (0.2.2, the owner's decision of 2026-10-08:
|
||||
// 「调配干员里自己设置吧,默认满潜满加成」): a PlayerBattleInput operator entry's `potential` (潜能 1–6) composes its def at
|
||||
// that potential (stats, talents, its summons' talents at the owner's potential), its `cultivate` (练度 0–3: effects.json
|
||||
// aceffect_char_1…4 char_attribute_mul) multiplies ATK / DEF / max HP on top of every other bonus (Πmul, never summed
|
||||
// with the 直接乘算 percentages: PRTS 盟约记录 「…与自持有干员的属性加成独立」); a stand-in and a prototype 自选 pick take
|
||||
// neither. The spec keeps both when well-formed, so the browser and the server field the same unit (docs/SIM.md §12).
|
||||
// Run: node --test test/sim/potential-battle.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
import { unitStatsEntry } from '../../shared/protocol.js';
|
||||
import { tokenStat } from '../../server/sim/content/kits/shared/summoner.js';
|
||||
|
||||
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET): E1 Lv55, 潜能 6 = ATK 435 / cost 15 / 66 s
|
||||
const T6 = 'chess_char_6_diy1_a';
|
||||
const unit = (h, id) => h.b.allyUnits.find((u) => u.defId === id && u.kind === 'op');
|
||||
const talentBb = (u, name) => u.def.talents.find((t) => t.name === name)?.bb;
|
||||
|
||||
test('potential on a battle unit: the def at that potential (stats, redeploy, cost, talent), 潜能 6 / absent = the data record', () => {
|
||||
const at = (potential) => {
|
||||
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(potential != null ? { potential } : null) }], timeLimit: 1 });
|
||||
const u = unit(h, VENDLA);
|
||||
return [u.base.atk, u.base.cost, u.base.respawnTime, talentBb(u, '土壤基肥改良').heal_scale, u.def.loadout.potential];
|
||||
};
|
||||
assert.deepEqual(at(1), [413, 17, 70, 1.08, 1]);
|
||||
assert.deepEqual(at(3), [413, 16, 66, 1.08, 3]);
|
||||
assert.deepEqual(at(5), [435, 16, 66, 1.11, 5]);
|
||||
assert.deepEqual(at(6), [435, 15, 66, 1.11, 6]);
|
||||
assert.deepEqual(at(null), [435, 15, 66, 1.11, 6], 'absent: full potential');
|
||||
// the elite with another module: statsBase at the potential + the module's attr
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_1_06_b', moduleId: 'none', potential: 1, row: 10, col: 5 }], timeLimit: 1 });
|
||||
assert.deepEqual([unit(h, 'chess_char_1_06_b').base.atk, unit(h, 'chess_char_1_06_b').base.cost], [508, 17]);
|
||||
});
|
||||
|
||||
test('练度 on a battle unit: ×ATK / ×DEF / ×max HP of its tier, a multiplier of its own on top of the percentages', () => {
|
||||
const field = (cultivate, extra = {}) => {
|
||||
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(cultivate != null ? { cultivate } : null), ...extra }], timeLimit: 1 });
|
||||
return unit(h, VENDLA);
|
||||
};
|
||||
const raw = field(null);
|
||||
assert.deepEqual([raw.s.atk, raw.s.def, raw.s.maxHp, raw.cultivate, raw.cultMul], [435, 77, 1137, null, null], 'absent: no 练度');
|
||||
const t = (c) => { const u = field(c); return [u.s.atk, u.s.def, u.s.maxHp]; };
|
||||
assert.deepEqual(t(0), [435, 77, 1137], '未精英化 +0%');
|
||||
assert.deepEqual(t(1), [435 * 1.05, 77 * 1.05, 1137], '精英1: ATK / DEF +5%');
|
||||
assert.deepEqual(t(2), [435 * 1.1, 77 * 1.05, 1137 * 1.05], '精英2: ATK +10%, DEF / HP +5%');
|
||||
assert.deepEqual(t(3), [435 * 1.1, 77 * 1.1, 1137 * 1.1], '精英2 Lv.60: +10% each');
|
||||
// with a 直接乘算 bonus (+20 % ATK) the tier multiplies the result — never 1 + 0.2 + 0.1
|
||||
const u = field(3);
|
||||
u.buffs.push({ key: 'test:atk', mods: { atkPct: 0.2 }, timeLeft: Infinity, duration: Infinity, stacks: 1, maxStacks: 1 });
|
||||
u.markDirty();
|
||||
assert.equal(u.s.atk, 435 * 1.2 * 1.1);
|
||||
// the deployed unit starts at its multiplied max HP; the card's own numbers include the tier
|
||||
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, cultivate: 3 }], timeLimit: 3 });
|
||||
h.run(1);
|
||||
const d = unit(h, VENDLA);
|
||||
assert.ok(d.alive && Math.abs(d.hp - 1137 * 1.1) < 1e-9, `HP ${d.hp}`);
|
||||
const e = unitStatsEntry(d, d.s);
|
||||
assert.deepEqual([e.atk, e.base.atk, e.maxHp, e.base.maxHp, e.def, e.base.def], [479, 479, 1251, 1251, 85, 85], 'no green delta for the tier');
|
||||
assert.deepEqual([unitInfo(d).cultivate, unitInfo(d).potential], [3, undefined]);
|
||||
});
|
||||
|
||||
test('a 补位 stand-in and a prototype 自选 pick take neither; an owned 自选 pick takes both', () => {
|
||||
const h = makeBattle({
|
||||
units: [
|
||||
{ chessId: 'chess_char_3_21_a', standIn: true, potential: 1, cultivate: 3, row: 10, col: 3 },
|
||||
{ diy: { slot: 6, charId: 'char_609_acguad' }, potential: 1, cultivate: 3, row: 10, col: 5 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_003_kalts', skillIndex: 0 }, potential: 1, cultivate: 3, row: 11, col: 5 },
|
||||
],
|
||||
timeLimit: 1,
|
||||
});
|
||||
const ops = h.b.allyUnits.filter((u) => u.kind === 'op');
|
||||
const st = ops.find((u) => u.def.standInFor);
|
||||
const proto = ops.find((u) => u.def.charId === 'char_609_acguad');
|
||||
const kalts = ops.find((u) => u.def.charId === 'char_003_kalts');
|
||||
assert.deepEqual([st.cultMul, st.def.loadout.potentialIsDefault ?? true], [null, true], 'stand-in');
|
||||
assert.deepEqual([proto.cultMul, proto.def.loadout.potential], [null, 6], 'prototype');
|
||||
// 凯尔希 潜能1: E2 Lv1 ATK 392 / cost 20 / 70 s (潜能 6: 417 / 18 / 66), ×1.1
|
||||
assert.deepEqual([kalts.base.atk, kalts.base.cost, kalts.base.respawnTime, kalts.def.loadout.potential], [392, 20, 70, 1]);
|
||||
assert.equal(kalts.s.atk, 392 * 1.1);
|
||||
// the spec drops both on a stand-in entry (and on summons)
|
||||
const spec = buildBattleSpec({ players: [{ playerId: 'p1', units: [
|
||||
{ uid: 1, kind: 'chess', chessId: 'chess_char_3_21_a', standIn: true, potential: 2, cultivate: 1, row: 10, col: 3 },
|
||||
{ uid: 2, kind: 'token', tokenId: 'token_10028_vigil_wolf', potential: 2, cultivate: 1, row: 10, col: 4, ownerUid: 1 },
|
||||
] }] });
|
||||
for (const u of spec.players[0].units) assert.deepEqual(['potential' in u, 'cultivate' in u], [false, false], u.kind);
|
||||
});
|
||||
|
||||
test('summons follow their owner\'s potential: Mon3tr\'s 不毁重构 and 望\'s 棋子 deploy limit / holding', () => {
|
||||
const ds = getDefaultSource();
|
||||
const owner = (potential) => ds.getChess(T6, { diy: { charId: 'char_003_kalts', skillIndex: 0 }, potential });
|
||||
const mon = (p) => ds.getToken('token_10002_kalts_mon3tr', T6, owner(p).loadout).talents.find((t) => t.name === '不毁重构').bb;
|
||||
assert.deepEqual([mon(4).stun, mon(4).value], [3, 1200]);
|
||||
assert.deepEqual([mon(5).stun, mon(5).value], [3.5, 1400]);
|
||||
assert.deepEqual([mon(null).stun, mon(null).value], [3.5, 1400]);
|
||||
// 望 (tier-6 slot, normal form E2 Lv1): her 棋子 hold 7 / deploy 6 below 潜能3, 8 / 7 from it (the kit's tokenStat)
|
||||
const wang = (potential) => {
|
||||
const h = makeBattle({ units: [{ diy: { slot: 6, charId: 'char_2027_wang', skillIndex: 0 }, potential, row: 10, col: 5 }], timeLimit: 1 });
|
||||
const u = h.b.allyUnits.find((x) => x.kind === 'op');
|
||||
return [tokenStat(h.b, u, 'token_10064_wang_stone1', 'deployLimit'), tokenStat(h.b, u, 'token_10064_wang_stone1', 'deckStack')];
|
||||
};
|
||||
assert.deepEqual(wang(2), [6, 7]);
|
||||
assert.deepEqual(wang(3), [7, 8]);
|
||||
// a chess owner (tokens.json): 夕's “小自在” 化境 15 层 below 潜能5
|
||||
const dusk = (p) => ds.getToken('token_10015_dusk_drgn', 'chess_char_5_12_a', ds.getChess('chess_char_5_12_a', { potential: p }).loadout).talents[0].bb.max_stack_cnt;
|
||||
assert.deepEqual([dusk(4), dusk(5)], [15, 18]);
|
||||
});
|
||||
|
||||
test('spec round trip: potential / 练度 kept when well-formed, dropped otherwise; the JSON spec fields the same battle', () => {
|
||||
const units = [
|
||||
{ uid: 1, kind: 'chess', chessId: VENDLA, potential: 2, cultivate: 1, row: 10, col: 5 },
|
||||
{ uid: 2, kind: 'chess', chessId: 'chess_char_1_02_a', potential: 7, cultivate: 4, row: 10, col: 3 },
|
||||
{ uid: 3, kind: 'chess', chessId: 'chess_char_1_14_a', potential: '3', cultivate: 2.5, row: 11, col: 3 },
|
||||
];
|
||||
const spec = buildBattleSpec({ battleId: 'b1', fieldId: 'n:p1', kind: 'normal', seed: 7, stageId: 'act2autochess_m01', timeLimit: 30,
|
||||
players: [{ playerId: 'p1', units }], spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 3, interval: 1 }] });
|
||||
const [a, b, c] = spec.players[0].units;
|
||||
assert.deepEqual([a.potential, a.cultivate], [2, 1]);
|
||||
assert.deepEqual(['potential' in b, 'cultivate' in b, 'potential' in c, 'cultivate' in c], [false, false, false, false]);
|
||||
const run = (s) => {
|
||||
const battle = createBattleFromSpec(s, undefined, { quiet: true });
|
||||
battle.start();
|
||||
const u = battle.allyUnits.find((x) => x.uid === 1);
|
||||
const out = [u.base.atk, u.base.cost, u.s.atk, u.s.def];
|
||||
while (!battle.finished) battle.step();
|
||||
return { out, digest: resultDigest(battle.result()).hash };
|
||||
};
|
||||
const direct = run(spec);
|
||||
const wire = run(JSON.parse(JSON.stringify(spec)));
|
||||
assert.deepEqual(direct.out, [413, 16, 413 * 1.05, 77 * 1.05]);
|
||||
assert.deepEqual(wire, direct);
|
||||
});
|
||||
|
||||
test('two players field the same chess at different potentials and 练度 on one field: each unit is exact', () => {
|
||||
const players = [
|
||||
{ playerId: 'p1', side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: VENDLA, potential: 1, cultivate: 0, row: 10, col: 5 }] },
|
||||
{ playerId: 'p2', side: 'L', colOffset: 8, units: [{ uid: 2, kind: 'chess', chessId: VENDLA, potential: 6, cultivate: 3, row: 10, col: 5 }] },
|
||||
];
|
||||
const battle = createBattleFromSpec(buildBattleSpec({ kind: 'unite', seed: 3, stageId: 'act2autochess_m01', players, timeLimit: 10 }), undefined, { quiet: true });
|
||||
battle.start();
|
||||
const [u1, u2] = [1, 2].map((uid) => battle.allyUnits.find((x) => x.uid === uid));
|
||||
assert.deepEqual([u1.base.atk, u1.s.atk, u1.base.cost], [413, 413, 17]);
|
||||
assert.deepEqual([u2.base.atk, u2.s.atk, u2.base.cost], [435, 435 * 1.1, 15]);
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
await c.click('.lo-search input');
|
||||
await c.page.keyboard.type('野鬃');
|
||||
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await c.click('.lo-card');
|
||||
await c.click('.lo-card__pick');
|
||||
await c.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await c.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Quick loadout previews use the same generated records as the in-match detail, without changing a loadout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { quickSkillTags } from '../../public/js/ui/loadoutModel.js';
|
||||
|
||||
const chess = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||
const operator = (name) => Object.values(chess).find((c) => c.name === name && !c.isGolden && !c.isDiy);
|
||||
|
||||
test('quick skill previews show the generated normal/elite SP and duration', () => {
|
||||
const base = operator('空弦');
|
||||
const elite = chess[base.goldenId];
|
||||
for (const c of [base, elite]) {
|
||||
for (const s of c.skills) {
|
||||
const tags = quickSkillTags(s);
|
||||
assert.equal(tags.recovery, '攻回');
|
||||
assert.equal(tags.init, s.initSp);
|
||||
assert.equal(tags.cost, s.spCost);
|
||||
assert.equal(tags.duration, s.duration > 0 ? `${s.duration}s` : '瞬发');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment skills have no SP cycle, while permanent passives remain passive (in either language)', () => {
|
||||
for (const name of ['缄默德克萨斯', '砾', '斯卡蒂', '野鬃']) {
|
||||
const c = operator(name);
|
||||
for (const form of [c, chess[c.goldenId]]) {
|
||||
for (const s of form.skills.filter((s) => s.spType === 'ON_DEPLOY')) {
|
||||
const tags = quickSkillTags({ ...s, desc: 'localized text', descRaw: '' });
|
||||
assert.equal(tags.recovery, '—', `${name} S${s.index + 1}`);
|
||||
assert.equal(tags.init, null);
|
||||
assert.equal(tags.cost, null);
|
||||
assert.equal(tags.duration, `${s.duration > 0 ? s.duration : s.bb.duration}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const passive = quickSkillTags(operator('星熊').skills[1]);
|
||||
assert.equal(passive.recovery, '被动');
|
||||
assert.equal(passive.duration, '常驻');
|
||||
assert.equal(passive.cost, null);
|
||||
});
|
||||
|
||||
test('ammo and infinite skills keep their duration semantics', () => {
|
||||
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', durationType: 'AMMO' }).duration, '弹药');
|
||||
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', duration: -1 }).duration, '∞');
|
||||
assert.equal(quickSkillTags(null).recovery, '—');
|
||||
});
|
||||
|
||||
test('loadout attributes use the in-match block and keep obtain/rest triggers for both forms', async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
try {
|
||||
const value = JSON.parse(readFileSync(new URL(`../../data/${String(url).split('/').pop()}`, import.meta.url), 'utf8'));
|
||||
return { ok: true, json: async () => value };
|
||||
} catch { return { ok: false, status: 404 }; }
|
||||
};
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { LoadoutGarrisons } = await import('../../public/js/screens/loadout.js');
|
||||
const { GarrisonBlock } = await import('../../public/js/ui/detailPanel.js');
|
||||
await data.loadAll('chess', 'garrisons');
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const n of v) yield* walk(n); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
for (const [name, trigger] of [['空弦', '<进入休整期时>'], ['星熊', '<获得时>'], ['缄默德克萨斯', '<进入休整期时>']]) {
|
||||
const base = operator(name);
|
||||
for (const rec of [base, chess[base.goldenId]]) {
|
||||
const nodes = [...walk(LoadoutGarrisons({ chess: rec, m: null }))].filter((n) => n.type === GarrisonBlock);
|
||||
assert.equal(nodes.length, rec.garrisonIds.length);
|
||||
for (const node of nodes) {
|
||||
const original = data.lookup('garrisons', rec.garrisonIds[0]);
|
||||
assert.deepEqual(node.props.garrison, original);
|
||||
const raw = [...walk(GarrisonBlock(node.props))].find((n) => n.type?.name === 'RichText').props.text;
|
||||
assert.ok(raw.startsWith(trigger), `${name}: ${raw}`);
|
||||
assert.equal(raw, original.descRaw || original.desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// test/ui/loadout-rows.test.js — the 干员调配 roster as one list (0.2.2): the quick choices of PR #301 (by @farridge) laid
|
||||
// out as one row per operator with aligned columns instead of three columns of cards (the owner's decision of 2026-10-08:
|
||||
// 「三个竖排有点乱影响观感」), and the per-operator 潜能 / 练度 controls (「调配干员里自己设置吧,默认满潜满加成」): the row's skill
|
||||
// / module buttons and selects (state, labels, what a tap sets), the detail's section, a 自选 slot's selects, the 局内数值 at
|
||||
// the settings, and the css that keeps the columns aligned and the detail sliding over the list below 1000 px. Hook-free
|
||||
// views drawn as vnode trees. (The browser side: test/ui/loadout.e2e.test.js.)
|
||||
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';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try { const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); return { ok: true, status: 200, json: async () => JSON.parse(body) }; } catch { return { ok: false, status: 404, json: async () => ({}) }; }
|
||||
};
|
||||
const { RosterRow, RosterHead, statsPreview, LoadoutStats, ModuleInfo } = await import('../../public/js/screens/loadout.js');
|
||||
const { CultivationSelects, CultivationSection, cultivateName } = await import('../../public/js/screens/cultivation.js');
|
||||
const { DiyPanelView } = await import('../../public/js/screens/diy.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'effects', 'backups');
|
||||
|
||||
const get = (id) => data.lookup('chess', id);
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), MAR-X
|
||||
const VENDLA = 'chess_char_1_06_a'; // 刺玫
|
||||
const CHAR = (id) => get(id).charId;
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
if (typeof v.type === 'function' && ['QuickSkill', 'QuickModule', 'CultivationSelects', 'SlotCard', 'KindTag', 'Bonds'].includes(v.type.name)) { yield* walk(v.type(v.props)); return; }
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const find = (tree, pred) => [...walk(tree)].filter(pred);
|
||||
const textOf = (v) => (v == null || typeof v === 'boolean' ? '' : typeof v !== 'object' ? String(v) : Array.isArray(v) ? v.map(textOf).join('') : textOf(v.props?.children));
|
||||
|
||||
const row = (props) => RosterRow({ m: null, chess: get(INSIDE), golden: get(get(INSIDE).goldenId), entries: {}, ops: {}, selected: false, onPick: () => {}, onChange: () => {}, onOps: () => {}, ...props });
|
||||
|
||||
test('a row: portrait / name pick, three skill slots (an empty one for a two-skill chess), 不装备 first, the equipped ones pressed', () => {
|
||||
const picks = [];
|
||||
const changes = [];
|
||||
const r = row({ onPick: (id) => picks.push(id), onChange: (id, p) => changes.push([id, p]) });
|
||||
assert.ok(hasClass(r, 'lo-card') && hasClass(r, 'lo-card--t1'));
|
||||
assert.equal(r.props['data-chess'], INSIDE);
|
||||
const pick = find(r, (v) => hasClass(v, 'lo-card__pick'))[0];
|
||||
pick.props.onClick();
|
||||
assert.deepEqual(picks, [INSIDE]);
|
||||
const skills = find(r, (v) => hasClass(v, 'lo-q--skill'));
|
||||
assert.deepEqual(skills.map((b) => [b.props['data-skill'], b.props['aria-pressed']]), [[0, 'false'], [1, 'true']], 'S2 is the default');
|
||||
assert.equal(find(r, (v) => hasClass(v, 'lo-q--empty')).length, 1, 'the third slot stays empty');
|
||||
assert.match(skills[0].props['aria-label'], /^S1 · “不惹麻烦” · /);
|
||||
const mods = find(r, (v) => hasClass(v, 'lo-q--mod'));
|
||||
assert.deepEqual(mods.map((b) => [b.props['data-module'], b.props['aria-pressed']]), [['none', 'false'], ['uniequip_002_inside', 'true']]);
|
||||
skills[0].props.onClick();
|
||||
mods[0].props.onClick();
|
||||
assert.deepEqual(changes, [[INSIDE, { skill: 0 }], [INSIDE, { module: 'none' }]]);
|
||||
assert.equal(find(r, (v) => hasClass(v, 'lo-card__flag')).length, 0, 'nothing changed: no flag');
|
||||
// the shared preview: 精锐 shows the Lv.7 SP numbers of the same skill
|
||||
const elite = get(get(INSIDE).goldenId).skills.find((s) => s.index === 0);
|
||||
const e = find(row({ level: 'elite' }), (v) => hasClass(v, 'lo-q--skill'))[0];
|
||||
assert.match(e.props['aria-label'], new RegExp(`消耗 ${elite.spCost}`));
|
||||
assert.equal(row({ level: 'elite' }).props['data-variant'], 'elite');
|
||||
});
|
||||
|
||||
test('a row\'s 潜能 / 练度 selects: the defaults quiet, a changed one marked; a change sets the operator\'s settings', () => {
|
||||
const sets = [];
|
||||
const r = row({ onOps: (id, p) => sets.push([id, p]) });
|
||||
const sel = find(r, (v) => v.type === 'select');
|
||||
assert.deepEqual(sel.map((s) => [s.props['data-cult'], s.props.value]), [['potential', '6'], ['cultivate', '3']]);
|
||||
assert.equal(find(r, (v) => hasClass(v, 'is-off')).length, 0, 'the defaults are not marked');
|
||||
sel[0].props.onChange({ currentTarget: { value: '2' } });
|
||||
sel[1].props.onChange({ currentTarget: { value: '0' } });
|
||||
assert.deepEqual(sets, [[CHAR(INSIDE), { potential: 2 }], [CHAR(INSIDE), { cultivate: 0 }]]);
|
||||
const options = find(sel[0], (v) => v.type === 'option').map((o) => textOf(o));
|
||||
assert.deepEqual(options, ['潜能 1', '潜能 2', '潜能 3', '潜能 4', '潜能 5', '潜能 6']);
|
||||
assert.deepEqual(find(sel[1], (v) => v.type === 'option').map((o) => textOf(o)), ['未精英化', '精英1', '精英2', '精英2 Lv.60']);
|
||||
// changed: the select is marked, the row flagged and counted as changed
|
||||
const c = row({ ops: { [CHAR(INSIDE)]: { potential: 2 } } });
|
||||
assert.ok(hasClass(c, 'is-changed'));
|
||||
assert.equal(find(c, (v) => hasClass(v, 'lo-card__flag')).length, 1);
|
||||
assert.deepEqual(find(c, (v) => hasClass(v, 'is-off')).map((v) => v.props.class.includes('lo-cult__pot')), [true]);
|
||||
// a chess marked not owned: the 替补 tag, the settings say they do not apply now
|
||||
const s = row({ notOwned: true });
|
||||
assert.ok(hasClass(s, 'is-standin'));
|
||||
assert.equal(textOf(find(s, (v) => hasClass(v, 'lo-card__sub'))[0]), '替补');
|
||||
assert.match(find(s, (v) => hasClass(v, 'lo-cult'))[0].props.title, /替补干员没有潜能与练度/);
|
||||
});
|
||||
|
||||
test('the head names the four columns; CultivationSelects without an operator draws nothing', () => {
|
||||
assert.deepEqual(find(RosterHead(), (v) => hasClass(v, 'lo-list__h')).map((v) => textOf(v)), ['干员', '技能', '模组精锐', '潜能 · 练度']);
|
||||
assert.equal(CultivationSelects({ charId: null, ops: {}, onSet: () => {} }), null);
|
||||
});
|
||||
|
||||
test('the detail\'s section: 潜能 1–6 and the four official 练度 tiers, the tier\'s effect, the stand-in note', () => {
|
||||
const sets = [];
|
||||
const id = CHAR(VENDLA);
|
||||
const sec = CultivationSection({ charId: id, ops: { [id]: { potential: 3 } }, onSet: (c, p) => sets.push([c, p]) });
|
||||
const pots = find(sec, (v) => v.props && v.props['data-potential'] != null);
|
||||
assert.deepEqual(pots.map((b) => [b.props['data-potential'], b.props['aria-checked']]), [[1, 'false'], [2, 'false'], [3, 'true'], [4, 'false'], [5, 'false'], [6, 'false']]);
|
||||
const tiers = find(sec, (v) => v.type === 'button' && v.props['data-cultivate'] != null);
|
||||
assert.deepEqual(tiers.map((b) => textOf(b)), ['未精英化', '精英阶段1', '精英阶段2', '精英阶段2-60级'], 'effects.json names');
|
||||
assert.equal(tiers[3].props['aria-checked'], 'true', 'the default tier');
|
||||
assert.match(textOf(find(sec, (v) => hasClass(v, 'lo-cult__eff'))[0]), /精英阶段2-60级 攻击力、防御力和最大生命值\+10%/);
|
||||
pots[0].props.onClick();
|
||||
tiers[1].props.onClick();
|
||||
assert.deepEqual(sets, [[id, { potential: 1 }], [id, { cultivate: 1 }]]);
|
||||
assert.equal(cultivateName(0), '未精英化');
|
||||
// the rule line says what an operator with nothing set fights at — the default, 满潜能 / 精英2 Lv.60 — and that an unowned
|
||||
// 特许 operator at the official 潜能1 / 未精英化 is the player's own setting (Grok review of the branch: it read as automatic)
|
||||
const note = textOf(find(sec, (v) => hasClass(v, 'lo-cult__note'))[0]);
|
||||
assert.match(note, /^默认满潜能、精英2 Lv\.60(满加成)。/);
|
||||
assert.match(note, /未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。$/);
|
||||
const st = CultivationSection({ charId: id, ops: {}, onSet: () => {}, standIn: true });
|
||||
assert.ok(hasClass(st, 'is-moot'));
|
||||
assert.match(textOf(find(st, (v) => hasClass(v, 'lo-cult__note'))[0]), /替补干员没有潜能与练度/);
|
||||
});
|
||||
|
||||
test('自选编队: an owned pick\'s card carries its operator\'s selects, a prototype says it has neither', () => {
|
||||
const sets = [];
|
||||
const picks = { chess_char_6_diy1_a: { charId: 'char_003_kalts', skillIndex: 0 }, chess_char_5_diy1_a: { charId: 'char_609_acguad' } };
|
||||
const tree = DiyPanelView({ m: null, picks, legal: picks, kitted: null, onSet: () => {}, picking: null, onPicking: () => {}, ops: { char_003_kalts: { cultivate: 1 } }, onOps: (c, p) => sets.push([c, p]) });
|
||||
const cult = find(tree, (v) => hasClass(v, 'diy-slot__cult'));
|
||||
assert.equal(cult.length, 2);
|
||||
const sel = find(tree, (v) => v.type === 'select');
|
||||
assert.deepEqual(sel.map((s) => s.props.value), ['6', '1'], '凯尔希: 潜能 6, 精英1');
|
||||
sel[0].props.onChange({ currentTarget: { value: '4' } });
|
||||
assert.deepEqual(sets, [['char_003_kalts', { potential: 4 }]]);
|
||||
assert.ok(cult.some((v) => textOf(v) === '原型干员没有潜能与练度'));
|
||||
});
|
||||
|
||||
test('局内数值 at the operator\'s settings: 刺玫 at 潜能1 / 未精英化 and at the defaults (×1.1)', () => {
|
||||
const v = get(VENDLA);
|
||||
const g = get(v.goldenId);
|
||||
const pv = (ops, level = 'normal') => statsPreview(v, g, {}, level, get, ops).record.stats;
|
||||
assert.deepEqual([pv({ [v.charId]: { potential: 1, cultivate: 0 } }).atk, pv({ [v.charId]: { potential: 1, cultivate: 0 } }).cost], [413, 17]);
|
||||
assert.equal(pv({}).atk, 435 * 1.1, 'none set: 满潜 + 精英2 Lv.60');
|
||||
assert.equal(pv({ [v.charId]: { potential: 1, cultivate: 0 } }, 'elite').atk, 508 + 23, 'the elite: statsBase at 潜能1 + INC-X');
|
||||
assert.ok(statsPreview(v, g, {}, 'normal', get, {}).lo.cultivation);
|
||||
// the caption says the numbers carry 潜能 and 练度 (an owned operator; effects.json loaded)
|
||||
const cap = (tree) => textOf(find(tree, (n) => hasClass(n, 'lo-stats__cap'))[0]);
|
||||
assert.match(cap(LoadoutStats({ base: v, golden: g, entries: {}, level: 'elite', onLevel() {}, getChess: get, ops: {} })), /^数值含所选模组;含潜能与练度;不含技能发动/);
|
||||
});
|
||||
|
||||
test('the module card reads its talent changes at the operator\'s 潜能, as 局内数值 and the battle do (余 闲云隐市: 2%(+0.5%) / 1.5%)', () => {
|
||||
// (Grok review of the branch: the card printed the full-potential line whatever the setting)
|
||||
const g = get('chess_char_6_03_b');
|
||||
const mod = g.modules.find((x) => x.uniEquipId === 'uniequip_002_yu');
|
||||
const opt = { id: mod.uniEquipId, rec: mod, isDefault: !!mod.isDefault };
|
||||
const texts = (tree) => find(tree, (v) => typeof v.type === 'function' && v.type.name === 'RichText').map((v) => String(v.props.text));
|
||||
const full = texts(ModuleInfo({ m: null, golden: g, opt }));
|
||||
assert.ok(full.some((x) => x.includes('生命上限2%<@ba.talpu>(+0.5%)</>')), 'full potential (none given)');
|
||||
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt, potential: 6 })), full);
|
||||
const p1 = texts(ModuleInfo({ m: null, golden: g, opt, potential: 1 }));
|
||||
assert.ok(p1.some((x) => x.includes('生命上限1.5%的生命')), '潜能1');
|
||||
assert.ok(!p1.some((x) => x.includes('(+0.5%)')));
|
||||
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt: { id: 'none', rec: null }, potential: 1 })), [g.traitBase.descRaw], '不装备: the base 特性');
|
||||
// 圣约送葬人 REA-Y (GitHub #400): 特性 heals 50 per enemy hit as in battle; 特性追加 is the module's ASPD line
|
||||
const ex = get('chess_char_5_01_b');
|
||||
const rea = ex.modules.find((x) => x.uniEquipId === 'uniequip_003_excu2');
|
||||
const lines = texts(ModuleInfo({ m: null, golden: ex, opt: { id: rea.uniEquipId, rec: rea }, potential: 6 }));
|
||||
assert.ok(lines.some((x) => x.includes('每攻击到一个敌人回复自身<@ba.kw>50</>生命')), lines.join(' | '));
|
||||
assert.ok(lines.some((x) => x === '攻击范围内存在2名及以上敌人时攻击速度<@ba.kw>+12</>'));
|
||||
// the detail hands the operator's 潜能 to it
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
|
||||
assert.match(src, /<\$\{ModuleInfo\} m=\$\{m\} golden=\$\{golden\} opt=\$\{modOpt\} potential=\$\{opsOf\(ops, chess\.charId\)\.potential\} \/>/);
|
||||
});
|
||||
|
||||
test('css: the selected skill\'s frame is square around its icon (GitHub #398 / PR #399): no img max-width clamp, fill beats `.lo-sicon img`', () => {
|
||||
// theme.css `img { max-width: 100% }` held the frame's width to the icon's while its height grew by .1rem (36 × 40 px
|
||||
// on a phone, 70 × 80 at 1920 px), and the more specific `.lo-sicon img { object-fit: cover }` cropped its side lines
|
||||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||
const rule = css.match(/^([^{}\n]*\.lo-sicon__outline) \{([^}]*)\}/m);
|
||||
assert.ok(rule, 'the frame rule');
|
||||
assert.equal(rule[1], '.lo-sicon .lo-sicon__outline', 'more specific than `.lo-sicon img`');
|
||||
assert.match(rule[2], /inset: -\.05rem; width: calc\(100% \+ \.1rem\) !important; height: calc\(100% \+ \.1rem\) !important;/);
|
||||
assert.match(rule[2], /max-width: none;/);
|
||||
assert.match(rule[2], /object-fit: fill;/);
|
||||
assert.match(css, /\.lo-sicon img \{ width: 100%; height: 100%; object-fit: cover;/, 'the icon itself still covers');
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/css/theme.css'), 'utf8'), /\bimg \{ max-width: 100%; \}/, 'the global clamp the frame opts out of');
|
||||
});
|
||||
|
||||
test('css: the head and the rows share one grid (columns aligned), quick targets grow on a coarse pointer, the detail slides over below 1000 px', () => {
|
||||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||
assert.match(css, /\.lo-list__head, \.lo \.lo-card \{ display: grid; grid-template-columns: var\(--cols\);/);
|
||||
assert.match(css, /\.sp-coarse \.lo-list \{ --q: max\(\.44rem, 40px\); \}/);
|
||||
const block = css.slice(css.indexOf('@media (max-width: 1000px) {\n .lo-body'));
|
||||
assert.match(block, /\.lo-body \{ grid-template-columns: minmax\(0, 1fr\); \}/);
|
||||
assert.match(block, /\.lo-detail-wrap \{ display: none; \}/);
|
||||
assert.ok(!/\.lo-grid\b/.test(css), 'the old card grid is gone');
|
||||
});
|
||||
@@ -256,7 +256,7 @@ test('place in the detail: after the skills, before the modules; the first .lo-s
|
||||
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
|
||||
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
|
||||
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
|
||||
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\.\/ui\/detailPanel\.js';/);
|
||||
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents, GarrisonBlock \} from '\.\.\/ui\/detailPanel\.js';/);
|
||||
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
|
||||
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
|
||||
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
||||
|
||||
+72
-28
@@ -101,7 +101,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// search → one card
|
||||
await page.type('.lo-search input', '隐现');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
@@ -199,7 +199,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// one edit, so there is something to export
|
||||
await page.type('.lo-search input', '隐现');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||||
@@ -268,7 +268,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.type('.lo-search input', '隐现');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||
// choose S1, close and confirm at once — well inside the sync's debounce
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
@@ -331,7 +331,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
await page.type('.lo-search input', '隐现');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
@@ -355,7 +355,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.type('.lo-search input', '烛煌');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
|
||||
// wait for every module tile to settle (image loaded or lettered fallback)
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
|
||||
@@ -378,36 +378,50 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
|
||||
test('phone 844×390 (touch): one list with the quick choices (0.2.2), the detail slides over it, tier / class filters, tap to choose', async () => {
|
||||
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
|
||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await assertBgLayer(page);
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
|
||||
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
|
||||
// and tappable (返回, 全部恢复默认, the detail's 恢复默认, the search input)
|
||||
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
|
||||
// and tappable (返回, 全部恢复默认, the search input; the detail's 恢复默认 below)
|
||||
const measure = (sels) => page.evaluate((list) => Object.fromEntries(list.map((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const b = el.getBoundingClientRect();
|
||||
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
|
||||
})));
|
||||
for (const [sel, v] of Object.entries(ctl)) {
|
||||
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
||||
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
||||
}
|
||||
})), sels);
|
||||
const check = (ctl) => {
|
||||
for (const [sel, v] of Object.entries(ctl)) {
|
||||
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
||||
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
||||
}
|
||||
};
|
||||
check(await measure(['.lo-back', '.lo-top__right .btn', '.lo-search .field__input']));
|
||||
// the list takes the whole width (no detail beside it below 1000 px); the quick choices are 40 px touch targets
|
||||
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||||
const q = await page.$eval('.lo-card .lo-q--skill', (el) => { const b = el.getBoundingClientRect(); return [Math.round(b.width), Math.round(b.height)]; });
|
||||
assert.ok(q[0] >= 40 && q[1] >= 40, `quick targets ${q}`);
|
||||
assert.equal(await page.evaluate(() => document.querySelector('.lo').scrollWidth > innerWidth + 1), false, 'no horizontal overflow');
|
||||
await page.tap('.lo-chip--t6');
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
|
||||
await page.tap('.lo-chip--prof[title="狙击"]');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
|
||||
await page.tap('.lo-card');
|
||||
// a quick choice keeps the list
|
||||
await page.tap('.lo-card .lo-q--skill[data-skill="0"]');
|
||||
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'a quick choice keeps the list');
|
||||
await page.tap('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
|
||||
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'the detail covers the list');
|
||||
check(await measure(['.lo-dhead__reset']));
|
||||
// every skill option is reachable inside the viewport (the detail body scrolls)
|
||||
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
|
||||
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
|
||||
await page.tap('.lo-detail .lo-skill:last-child');
|
||||
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
|
||||
await page.tap('.lo-detail-back');
|
||||
await page.waitForSelector('.lo-roster', { visible: true });
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
@@ -417,7 +431,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||||
await page.tap('.lo-card');
|
||||
await page.tap('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
|
||||
@@ -430,6 +444,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// ---- 局内数值 (GitHub issue #64) -------------------------------------------------------------------------------------
|
||||
const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const fmt = (n) => Math.round(n).toLocaleString('en-US');
|
||||
// 0.2.2: the numbers carry the 练度 — none set: 精英2 Lv.60, effects.json aceffect_char_4 (×1.1 ATK / DEF / max HP)
|
||||
const T4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb;
|
||||
/** The section's eight stats: label → shown value. */
|
||||
const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent])));
|
||||
const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length);
|
||||
@@ -438,7 +454,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.lo-search input', name);
|
||||
await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name);
|
||||
await page.click('.lo-card');
|
||||
await page.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
||||
}
|
||||
|
||||
@@ -452,25 +468,25 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await pickChess(page, '隐现');
|
||||
// between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range
|
||||
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['技能', '局内数值', '模组']);
|
||||
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['潜能与练度', '技能', '局内数值', '模组']);
|
||||
const stats = await shownStats(page);
|
||||
assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
|
||||
assert.equal(stats['生命上限'], fmt(golden.statsBase.maxHp + mod.attr.maxHp));
|
||||
assert.equal(stats['攻击'], fmt(golden.statsBase.atk + mod.attr.atk));
|
||||
assert.equal(stats['生命上限'], fmt((golden.statsBase.maxHp + mod.attr.maxHp) * T4.max_hp));
|
||||
assert.equal(stats['攻击'], fmt((golden.statsBase.atk + mod.attr.atk) * T4.atk));
|
||||
assert.equal(await rangeTiles(page), base.rangeGrid.length);
|
||||
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite');
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') });
|
||||
// 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent
|
||||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk), 'a skill is not a stat');
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk), 'a skill is not a stat');
|
||||
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp));
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp * T4.max_hp));
|
||||
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal');
|
||||
assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0');
|
||||
assert.equal(await page.$eval('.lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
|
||||
assert.equal(await page.$eval('.lo-sec:not(.lo-sec--cult) .lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
|
||||
// the layout: no sideways overflow, the range box beside the numbers
|
||||
const box = await page.evaluate(() => {
|
||||
const body = document.querySelector('.lo-detail__body');
|
||||
@@ -501,7 +517,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.type('.lo-search input', '隐现');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.tap('.lo-card');
|
||||
await page.tap('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
||||
await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' }));
|
||||
const m = await page.evaluate(() => {
|
||||
@@ -529,8 +545,36 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') });
|
||||
// a choice made further down updates it (touch: the module card, 不装备)
|
||||
await page.tap('.lo-detail .lo-mod[data-module="none"]');
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
|
||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('潜能 / 练度 (0.2.2): a row\'s selects and the detail\'s buttons set the operator\'s settings — stored, synced, the numbers follow', async () => {
|
||||
const chess = CHESS();
|
||||
const v = chess.chess_char_1_06_a; // 刺玫: 潜能1 ATK 413 / cost 17; elite statsBase 508 at 潜能1
|
||||
const { ctx, page, problems } = await open();
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await pickChess(page, '刺玫');
|
||||
// a row's select: 潜能 1
|
||||
await page.select('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="potential"]', '1');
|
||||
await page.waitForFunction((id) => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id]?.potential === 1, {}, v.charId);
|
||||
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
|
||||
await page.waitForFunction((x) => [...document.querySelectorAll('.lo-sec--stats .dstat')].some((e) => e.querySelector('.dstat__k').textContent === '部署费用' && e.querySelector('.dstat__v').textContent === x), {}, '17');
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(413 * T4.atk), '潜能1 at 精英2 Lv.60');
|
||||
// the detail's 练度 buttons: 未精英化 → ×1
|
||||
await page.click('.lo-sec--cult [data-cultivate="0"]');
|
||||
await page.waitForFunction(() => document.querySelector('.lo-sec--cult [data-cultivate="0"]').getAttribute('aria-checked') === 'true');
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(413));
|
||||
assert.equal(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="cultivate"]', (el) => el.value), '0', 'the row follows');
|
||||
assert.ok(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"]', (el) => el.classList.contains('is-changed')), 'a changed operator');
|
||||
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||
// 恢复默认 resets them with the skill / module
|
||||
await page.click('.lo-dhead__reset');
|
||||
await page.waitForFunction((id) => !JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id], {}, v.charId);
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(v.stats.atk * T4.atk));
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,8 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
|
||||
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
||||
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
||||
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'v'], 'exactly the envelope, no unused field');
|
||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'ops', 'v'], 'exactly the envelope (0.2.2: + the 潜能 / 练度 ops), no unused field');
|
||||
assert.deepEqual(p.ops, {}, 'no settings: an empty map (an import then resets the settings to the defaults)');
|
||||
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||||
assert.equal(p.v, LOADOUT_VERSION);
|
||||
assert.equal(p.count, 2);
|
||||
@@ -262,7 +263,7 @@ test('sync: welcome sends the sanitised loadout; edits are debounced; identical
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: {} }], 'stale entry dropped (no 潜能 / 练度 set: ops {})');
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||||
@@ -480,7 +481,7 @@ test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB downl
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: {} }]);
|
||||
assert.equal(loads, 0);
|
||||
await net.reply();
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se
|
||||
await guest.click('.lo-search input');
|
||||
await guest.page.keyboard.type('野鬃');
|
||||
await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await guest.click('.lo-card');
|
||||
await guest.click('.lo-card__pick');
|
||||
await guest.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await guest.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||
|
||||
Reference in new issue
Block a user