feedback7 R22-LOADOUT: the data carries every potential (潜能 1–6), not only full potential; golden: unchanged

Potential becomes a per-player setting (the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」;
official: 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」). 0.2.1 baked full potential into the data (§26.14).

- tools/build-data.mjs runs the chess / backups / tokens builders once per potential rank (ctx.potRank, 0–5) and keeps
  the full-potential build annotated with what each lower rank changes: `potDown` { rank: { dotted.path: value } } on
  chess records, backups.json forms and token variants (stats, statsBase; 望's 棋子 deployLimit / deckStack, 多萝西's
  共振装置 count), and chained talent entries (`potMin` / `potBelow`: the candidates by requiredPotentialRank, the
  fields a lower rank changes) in talents / talentsBase / modules[].talentChanges / variant talents. A self-check proves
  shared/potential.js atRank rebuilds every rank exactly; the unit-form parity runs at every rank.
- shared/potential.js (new): atPotential / atRank, talentAtRank, stripPotential, and the 练度 helpers used next
  (cultivateMul from the effects.json CHAR_MAP records aceffect_char_1…4, cultivationOf, cultivatedStats).
- composeTalents / composeUnitRecord return potential-resolved lists (no chains): a composed record is built at one
  potential (its form resolved first).
- JSON compare: the new chess / backups / tokens minus the annotations equal the old files byte for byte; every other
  data file identical; data/i18n/* rebuilt (the chained talent texts translated at the old rates: en 95.4 %, ja 95.2 %,
  ko 95.2 %, zh-TW 90 %). Data 5.21 → 5.51 MB.
- Docs: DATA.md §2.3 (new), §2, §7, §14, §16, §18; research/03-operators.md; kits/README.md.
- Tests: test/potential.test.js (new, 7): every chess and 自选 form at every potential = the raw keyframes plus the
  potential steps up to it, every talent = the last candidate with requiredPotentialRank ≤ the rank, 刺玫 at each
  potential, summons at the owner's potential (Mon3tr 3 s / 1200, 望's 棋子 6 / 7, “小自在” 15), the 练度 table =
  cultivateEffectList; data.test.js compares the composed talents without annotations.
This commit is contained in:
sganggs committed 2026-10-08 20:29:06 +08:00
1 parent ae22335cfe
commit 7e09c63da8
17 files changed
+614 -69

No files matched your search

+3 -1
View File
@@ -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++;
+3 -2
View File
@@ -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
+181
View File
@@ -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, '原型 自选');
});