feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)

This commit is contained in:
sganggs committed 2026-10-07 18:35:39 +08:00
1 parent 783bd30afd
commit 806a047717
178 files changed
+7616 -7268

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
// 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), 自选
// 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, 256);
// 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]);
});