Files

171 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// cost 15 — her E1 Lv55 413 / 17 plus 攻击力+22 and two 部署费用-1. These numbers reach the battle, the 干员调配 局内数值
// and the detail card through the records (shared/loadoutRecord.js, simdata getChess).
// With the official-data cache (.cache/gamedata) the potential steps are re-derived from the raw character_table.
// Run: node --test test/full-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 { resolveLoadout, loadoutRecord } from '../server/sim/simdata.js';
import { diyRecordOf } from '../shared/diy.js';
import { standInRecord } from '../shared/standIn.js';
import { makeBattle } from './helpers/battleHarness.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 raw = (f) => JSON.parse(readFileSync(join(CACHE, 'excel', `${f}.json`), 'utf8'));
const talent = (rec, name) => rec.talents.find((t) => t.name === name);
test('刺玫 at full potential: ATK 435 / cost 15 (the tournament video), 再部署 66 s, 土壤基肥改良 at its 「天赋效果增强」 step', () => {
const v = chess.chess_char_1_06_a;
assert.equal(v.name, '刺玫');
assert.deepEqual([v.stats.atk, v.stats.cost, v.stats.respawnTime, v.stats.maxHp, v.stats.def], [435, 15, 66, 1137, 77]);
assert.equal(talent(v, '土壤基肥改良').bb.heal_scale, 1.11, 'E1: 1.08 + the potential-5 step');
assert.match(talent(v, '土壤基肥改良').desc, /111%|11%/);
const e = chess.chess_char_1_06_b; // the elite: its own E2 Lv50 record plus the same five steps
assert.deepEqual([e.stats.atk, e.stats.cost, e.stats.respawnTime, e.statsBase.atk, e.statsBase.cost], [553, 15, 66, 530, 15]);
assert.equal(talent(e, '土壤基肥改良').bb.heal_scale, 1.18);
});
test('the other potential kinds: 攻击速度 (格雷伊 +6, 崖心 / 伊内丝 +8), 生命 / 防御 / 法抗, and the redeploy times of GitHub #252', () => {
const s = (id) => chess[id].stats;
assert.deepEqual([s('chess_char_1_14_a').aspd, s('chess_char_1_14_b').aspd], [106, 109], '格雷伊 攻击速度+6');
assert.deepEqual([s('chess_char_2_03_a').aspd, s('chess_char_4_04_a').aspd], [108, 108], '崖心 / 伊内丝 攻击速度+8');
assert.equal(s('chess_char_1_02_a').maxHp, 2466, '角峰 生命上限 +250');
assert.equal(s('chess_char_1_20_a').def, 516, '雷蛇 防御力 +27');
assert.equal(s('chess_char_3_09_a').res, 33, '海霓 法术抗性 +8');
// GitHub #252: 砾 18 → 16 s, 至简 80 → 70 s; 野鬃's two 再部署时间-10秒 steps: 80 → 60 s
assert.deepEqual([s('chess_char_2_12_a').respawnTime, s('chess_char_2_12_a').cost], [16, 6]);
assert.deepEqual([s('chess_char_3_13_a').respawnTime, s('chess_char_3_13_a').cost], [70, 20]);
assert.deepEqual([s('chess_char_1_19_a').respawnTime, s('chess_char_1_19_a').cost], [60, 11]);
});
test('every chess, 自选 form and stand-in form: the raw keyframes plus every potential step (re-derived from 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) => {
const out = {};
for (const r of CT[charId].potentialRanks || []) {
for (const m of r.buff?.attributes?.attributeModifiers || []) {
assert.equal(m.formulaItem, 'ADDITION', `${charId}: ${m.attributeType}`);
assert.ok(KEY[m.attributeType], `${charId}: unmapped potential attribute ${m.attributeType}`);
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) => {
const exp = frame(charId, status.phase, status.level);
for (const [k, v] of Object.entries(steps(charId))) exp[k] += v;
for (const [k, v] of Object.entries(exp)) assert.ok(Math.abs(v - stats[k]) <= 0.5 + 1e-9, `${label}: ${k} ${stats[k]} vs official ${v}`);
};
let n = 0, withSteps = 0;
for (const c of Object.values(chess)) {
if (c.isDiy || !c.charId) continue;
check(c.chessId, c.charId, c.status, c.statsBase ?? c.stats);
n++;
if (Object.keys(steps(c.charId)).length) withSteps++;
}
assert.equal(n, 258);
assert.ok(withSteps >= 220, `chess with potential steps: ${withSteps}`);
let forms = 0;
for (const [charId, u] of Object.entries(backups.units)) {
for (const f of Object.values(u.forms)) { check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.stats); forms++; }
}
assert.equal(forms, 259);
// the 原型干员 stand-ins have no potential ranks: their numbers are the plain keyframes
for (const id of ['char_600_cpione', 'char_602_cdfend', 'char_608_acpion', 'char_617_sharp2']) assert.equal((CT[id].potentialRanks || []).length, 0, id);
});
test('talent candidates: every chess talent is the last one unlocked at its status with requiredPotentialRank ≤ 5', { 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) => {
const u = c.unlockCondition;
const p = PH[u?.phase ?? 'PHASE_0'];
return (c.requiredPotentialRank || 0) <= 5 && (p < phase || (p === phase && (u?.level || 1) <= level));
};
let potentialTalents = 0;
for (const c of Object.values(chess)) {
if (c.isDiy || !c.charId) continue;
const base = c.talentsBase ?? c.talents;
(CT[c.charId].talents || []).forEach((t, index) => {
const cands = (t.candidates || []).filter((x) => ok(x, c.status.phase, c.status.level));
const best = cands[cands.length - 1];
const rec = base.find((x) => x.index === index);
// a fully empty placeholder (no name / text / blackboard / token) is dropped from the record (mergeTalentChanges)
if (!best || (!best.name && !best.description && !best.blackboard.length && !best.tokenKey)) return;
assert.ok(rec, `${c.chessId} 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, `${c.chessId} talent ${index} ${b.key}: ${rec.bb[b.key]} vs ${b.value}`);
if ((best.requiredPotentialRank || 0) > 0) potentialTalents++;
});
}
assert.ok(potentialTalents >= 150, `chess talents at a potential step: ${potentialTalents}`);
});
test('summons: talent candidates at the owner\'s potential, none of the owner\'s attribute steps', () => {
// 夕's “小自在” 化境: 15 → 18 层 (夕 潜能 5 「第一天赋效果增强」)
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.talents[0].bb.max_stack_cnt, 18);
assert.equal(talent(chess.chess_char_5_12_a, '化境').bb.max_stack_cnt, 18);
// 浊心斯卡蒂's 海嗣, 伺夜's 狼群, 凯瑟琳's 防护单元, 风丸's 纸偶
assert.deepEqual(tokens.token_10017_skadi2_dedant.variants.chess_char_6_04_a.talents[1].bb, { 'skadi2_t_2[atk][1].atk': 0.09, 'skadi2_t_2[atk][2].atk': 0.18 });
assert.equal(tokens.token_10028_vigil_wolf.variants.chess_char_3_19_a.talents[1].bb.def_penetrate_fixed, 200);
assert.equal(tokens.token_10041_cathy_catsld.variants.chess_char_4_11_a.talents[0].bb.max_shield_ratio, 0.22);
assert.equal(tokens.token_10022_kazema_shadow.variants.chess_char_2_11_a.talents[0].bb.damage_scale, 2.3);
// 自选: 凯尔希's Mon3tr 不毁重构 — PRTS 凯尔希 潜能增强=5 "晕眩3.5(+0.5)秒…1400(+200)点真实伤害", X 模组 3 级 4 s / 1700
const mon = backups.tokens.token_10002_kalts_mon3tr.variants;
assert.deepEqual([mon['char_003_kalts@2/1/4/0'].talents[1].bb.stun, mon['char_003_kalts@2/1/4/0'].talents[1].bb.value], [3.5, 1400]);
const x3 = mon['char_003_kalts@2/60/7/3'].byModule.uniequip_002_kalts.talents[1].bb;
assert.deepEqual([x3.stun, x3.value], [4, 1700]);
// 望's 棋子: its rank-2 candidate (望 潜能 3 「第一天赋效果增强」) holds and deploys one more, and 铸子 sends 7
const stone = backups.tokens.token_10064_wang_stone1;
assert.deepEqual([stone.deployLimit, stone.count, stone.stats.deckStack], [7, 7, 8]);
assert.equal(backups.units.char_2027_wang.forms['2/1/4/0'].talents[0].bb.cnt, 7);
// the token's own numbers: 凯尔希's 攻击力+25 / 部署费用-1 steps stay hers (Mon3tr's frame is its own)
assert.equal(backups.units.char_003_kalts.forms['2/1/4/0'].stats.cost, 18);
for (const v of Object.values(mon)) assert.equal(v.stats.cost, backups.tokens.token_10002_kalts_mon3tr.stats.cost);
});
test('the numbers reach the loadout record (局内数值 / detail card), the 自选 and 补位 compositions and the battle', () => {
// a non-default module on the elite: statsBase (full potential) + the module's attr
const e = chess.chess_char_1_06_b;
const other = (e.modules || []).find((m) => !m.isDefault);
const none = loadoutRecord(e, resolveLoadout(e, { moduleId: 'none' }));
assert.deepEqual([none.stats.atk, none.stats.cost, none.stats.respawnTime], [530, 15, 66]);
if (other) {
const r = loadoutRecord(e, resolveLoadout(e, { moduleId: other.uniEquipId }));
assert.equal(r.stats.atk, 530 + (other.attr.atk || 0));
}
// 自选 凯尔希 (an owned 6★): her form at full potential
const diy = diyRecordOf(chess.chess_char_6_diy1_a, { charId: 'char_003_kalts', skillIndex: 0 }, { chess, backups });
assert.deepEqual([diy.stats.atk, diy.stats.cost, diy.stats.respawnTime], [417, 18, 66]);
// a 补位 stand-in is unchanged by the rule (预备干员 / 原型干员 have no potential ranks)
const st = standInRecord(chess.chess_char_3_21_a, backups);
assert.equal(st.stats.cost, backups.units[st.charId].forms[`${chess.chess_char_3_21_a.status.phase}/${chess.chess_char_3_21_a.status.level}/${chess.chess_char_3_21_a.status.skillLevel}/0`].stats.cost);
// the battle fields 刺玫 with ATK 435 / cost 15 / 66 s
const h = makeBattle({ units: [{ chessId: 'chess_char_1_06_a', row: 10, col: 5 }], timeLimit: 5 });
const u = h.unit('chess_char_1_06_a');
assert.deepEqual([u.base.atk, u.base.cost, u.base.respawnTime], [435, 15, 66]);
});