A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.
- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
(shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
148 lines
9.9 KiB
JavaScript
148 lines
9.9 KiB
JavaScript
// 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]);
|
||
});
|