Files
ark-stronghold-protocol/test/full-potential.test.js
T
sganggs 806a047717 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)
2026-10-07 18:35:39 +08:00

170 lines
12 KiB
JavaScript
Raw 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 — 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]);
});