Files
sganggs 96d9441ff1 feedback7 R22-LOADOUT: the server takes each player's 潜能 / 练度 per operator and every owned operator fights at them (default 潜能 6, 精英2 Lv.60); golden: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 134/138 moved (+10 % 练度), standins unchanged
The owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」. Official: the account's potential (调度手册);
the 自持有 bonus to owned operators of the same name, initial and elite alike (activity_table cultivateEffectList).

- shared/protocol.js: room.loadout { entries, ops? } — `ops` = { [charId]: { potential?: 1–6, cultivate?: 0–3 } }
  (isLoadoutOps, ≤ 256), checkLoadoutOps (strict like checkLoadout: an operator of the 干员调配 roster or the 自选 owned
  pool — cultivationCharIds; defaults dropped, entries stored complete); absent = none set.
- server/lobby.js / net.js: checked with the entries (either refused ⇒ nothing stored), session.ops / seat.ops /
  seats[].ops, match.setLoadout(playerId, loadout, ops) in INFO_CHECK only.
- PlayerState: ops re-checked and frozen (bots none); cultivationFor (shared/potential.js cultivationOf: null for a
  补位 stand-in or a prototype 自选 pick); loadoutFor carries potential / cultivate; battleInput states `potential` /
  `cultivate` for every owned operator (the chess as itself, an owned 自选 pick) — an old seat without ops and every bot
  at 6 / 3; the 自选 summons' hand count follows the owner's potential (望's 棋子 6 below 潜能3); m.private.ops; the
  scouting UnitInfo carries potential (below 6) / cultivate.
- tools/golden.mjs: the direct-spec scenarios field the default player (owned operators at 潜能 6 / 练度 3; stand-ins
  and prototype picks neither), as PlayerState does.
- Golden: every battle with an owned operator moves — ATK / DEF / max HP ×1.1 from the first second (roster-001:
  角峰 ends at 3527 HP instead of 3115; the first differing snapshot window is 0–10 s in every moved battle); the
  matches part at round 1–8 (battle results, then the bots' boards). Unchanged: the 10 stand-in battles (a stand-in has
  no 练度) and diy-135…138 (the four prototype 自选 battles). No engine error.
- Docs: design/match.md §16 (潜能 / 练度), design/network.md (room.loadout ops).
- Tests: test/match/potential.test.js (new, 4): the ops wire / data check, battle input of a human (settings, defaults,
  stand-in none, elite shares the charId), an old seat and a bot at the defaults, spec → battle, scouting units; 自选
  owned pick vs prototype and 望's hand count; Match.setLoadout in INFO_CHECK only, a refusal changes nothing.
  test/lobby-loadout.test.js (+2): strict validation, storage beside the loadout, an older client's message, the real
  match's briefing (m.private.ops) and lock. test/match/runner.test.js / diy-shop.test.js expect the new fields.
2026-10-08 20:52:30 +08:00

147 lines
9.6 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/match/potential.test.js — the player's 潜能 / 练度 on the server (0.2.2, the owner's decision of 2026-10-08:
// 「调配干员里自己设置吧,默认满潜满加成」): room.loadout `ops` (shared/protocol.js checkLoadoutOps) → seats[].ops →
// PlayerState.ops (re-checked, frozen; bots none) → every owned operator's PlayerBattleInput entry states `potential` /
// `cultivate` (defaults 6 / 3 — also an old loadout without ops), never a 补位 stand-in or a prototype 自选 pick; the
// 自选 summons' hand count follows the owner's potential; m.private.ops; the scouting units; Match.setLoadout takes
// them in INFO_CHECK only.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, checkLoadoutOps, isLoadoutOps, cultivationCharIds, LOADOUT_LIMITS } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { DATA, makeMatch } from './harness.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET)
const VENDLA_CHAR = chess(VENDLA).charId;
const GRANI = Object.values(DATA.chess).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible && c.backup?.charId && c.backup.charId !== c.charId);
const T6A = 'chess_char_6_diy1_a';
const T5A = 'chess_char_5_diy1_a';
const KALTS = 'char_003_kalts';
const WANG = 'char_2027_wang';
const SHARP = 'char_609_acguad';
const IDS = cultivationCharIds(DATA.chess, DATA.backups);
function place(ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
test('room.loadout ops: structure (≤ 256 charIds → { potential 1–6?, cultivate 0–3? }) and the strict data check', () => {
const ok = (ops) => validateC2S({ t: 'room.loadout', entries: {}, ops }) === null;
assert.ok(validateC2S({ t: 'room.loadout', entries: {} }) === null, 'ops optional (an older client)');
assert.ok(ok({}));
assert.ok(ok({ [VENDLA_CHAR]: { potential: 1 } }));
assert.ok(ok({ [VENDLA_CHAR]: { cultivate: 0 } }));
assert.ok(ok({ [VENDLA_CHAR]: { potential: 6, cultivate: 3 } }));
for (const bad of [[], null, { [VENDLA_CHAR]: {} }, { [VENDLA_CHAR]: { potential: 0 } }, { [VENDLA_CHAR]: { potential: 6.5 } },
{ [VENDLA_CHAR]: { cultivate: -1 } }, { [VENDLA_CHAR]: { cultivate: 4 } }, { [VENDLA_CHAR]: { potential: '3' } }, { 'a b': { potential: 1 } },
{ [VENDLA_CHAR]: { potential: 1, trust: 100 } }]) assert.ok(!ok(bad), JSON.stringify(bad));
const many = Object.fromEntries(Array.from({ length: LOADOUT_LIMITS.ops + 1 }, (_, i) => [`char_${i}`, { potential: 1 }]));
assert.ok(!isLoadoutOps(many));
// the operators one may set: the 112-chess roster's charIds (hidden twins share them) and the 71 owned 自选 picks
assert.ok(IDS.has(VENDLA_CHAR) && IDS.has(KALTS) && IDS.has(WANG));
assert.ok(!IDS.has(SHARP) && !IDS.has(GRANI.backup.charId), 'a prototype / stand-in character is no setting');
assert.ok(IDS.size >= 180 && IDS.size <= 200, `${IDS.size}`);
const has = (id) => IDS.has(id);
assert.deepEqual(checkLoadoutOps(undefined, has), { ok: true, ops: {} });
assert.deepEqual(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 2 }, [KALTS]: { potential: 6, cultivate: 3 } }, has), { ok: true, ops: { [VENDLA_CHAR]: { potential: 2, cultivate: 3 } } });
assert.equal(checkLoadoutOps({ [SHARP]: { potential: 2 } }, has).error, 'BAD_TARGET');
assert.equal(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 9 } }, has).error, 'BAD_MSG');
});
test('battle input: every owned operator states 潜能 / 练度 — the human\'s settings, the defaults (6 / 3) for the rest, for a seat without ops and for bots', () => {
const ops = { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } };
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: null, ops, notOwned: [GRANI.chessId] },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: {} }, // an old seat: no ops
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, ops },
];
const h = makeMatch({ mode: 'coop', seats, seed: 3 }).start();
const m = h.m;
const [p0, p1, bot] = ['p_0', 'p_1', 'ai_0'].map((id) => h.ps(id));
assert.deepEqual(p0.ops, { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } });
assert.ok(Object.isFrozen(p0.ops) && Object.isFrozen(p0.ops[VENDLA_CHAR]));
assert.deepEqual([p1.ops, bot.ops], [{}, {}], 'no settings / a bot: the defaults');
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, p0.ops, 'm.private exposes the effective settings');
h.toPrep(1);
for (const ps of [p0, p1, bot]) { for (const p of [...ps.board.values()]) ps.returnCopies(p); ps.board.clear(); }
place(p0, VENDLA, 9, 3);
place(p0, chess(VENDLA).goldenId, 10, 3);
place(p0, 'chess_char_1_02_a', 11, 3);
place(p0, GRANI.chessId, 12, 3); // not owned: its stand-in
place(p1, VENDLA, 9, 3);
place(bot, VENDLA, 9, 3);
const by = (ps, id) => ps.battleInput().units.find((u) => u.chessId === id);
assert.deepEqual([by(p0, VENDLA).potential, by(p0, VENDLA).cultivate], [1, 0], 'the human\'s setting');
assert.deepEqual([by(p0, chess(VENDLA).goldenId).potential, by(p0, chess(VENDLA).goldenId).cultivate], [1, 0], 'the elite shares the charId');
assert.deepEqual([by(p0, 'chess_char_1_02_a').potential, by(p0, 'chess_char_1_02_a').cultivate], [6, 3], 'no entry: 满潜满加成');
assert.deepEqual(['potential' in by(p0, GRANI.chessId), 'cultivate' in by(p0, GRANI.chessId), by(p0, GRANI.chessId).standIn], [false, false, true], 'a stand-in has neither');
assert.deepEqual([by(p1, VENDLA).potential, by(p1, VENDLA).cultivate], [6, 3], 'an old loadout: the defaults');
assert.deepEqual([by(bot, VENDLA).potential, by(bot, VENDLA).cultivate], [6, 3], 'bots: the defaults');
// the spec keeps them and the battle fields them
const spec = buildBattleSpec(m._normalOpts(p0));
const b = createBattleFromSpec(spec, undefined, { quiet: true });
const u = b.allyUnits.find((x) => x.defId === VENDLA);
const e = b.allyUnits.find((x) => x.defId === 'chess_char_1_02_a');
assert.deepEqual([u.base.atk, u.base.cost, u.cultivate, u.s.atk], [413, 17, 0, 413]);
assert.deepEqual([e.cultivate, e.s.maxHp], [3, e.base.maxHp * 1.1]);
// the scouting units carry them (a teammate's card)
const scout = m.prepFieldMeta(p0).units;
const sv = scout.find((x) => x.defId === VENDLA);
assert.deepEqual([sv.potential, sv.cultivate], [1, 0]);
const sg = scout.find((x) => x.defId === GRANI.chessId);
assert.deepEqual([sg.potential, sg.cultivate], [undefined, undefined]);
assert.deepEqual([scout.find((x) => x.defId === 'chess_char_1_02_a').potential, scout.find((x) => x.defId === 'chess_char_1_02_a').cultivate], [undefined, 3]);
m.dispose();
});
test('自选: an owned pick states its operator\'s settings, a prototype neither; 望\'s 棋子 hand count follows her potential', () => {
const ops = { [KALTS]: { potential: 2, cultivate: 1 }, [WANG]: { potential: 2 } };
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, ops,
diy: { [T6A]: { charId: KALTS, skillIndex: 0 }, [T5A]: { charId: SHARP }, chess_char_6_diy2_a: { charId: WANG, skillIndex: 0 } } }];
const h = makeMatch({ mode: 'solo', seats, seed: 7 }).start();
const m = h.m;
const p0 = h.ps('p_0');
h.toPrep(1);
for (const p of [...p0.board.values()]) p0.returnCopies(p);
p0.board.clear();
place(p0, T6A, 10, 3);
place(p0, T5A, 10, 5);
const units = p0.battleInput().units;
const k = units.find((u) => u.chessId === T6A);
const s = units.find((u) => u.chessId === T5A);
assert.deepEqual([k.diy.charId, k.potential, k.cultivate], [KALTS, 2, 1]);
assert.deepEqual([s.diy.charId, 'potential' in s, 'cultivate' in s], [SHARP, false, false]);
// 望 (潜能 2): 6 棋子 to the hand, not 7
const lo = p0.loadoutFor(p0.gd.chess('chess_char_6_diy2_a'));
assert.equal(lo.potential, 2);
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', lo), [{ tokenId: 'token_10064_wang_stone1', count: 6 }]);
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', { ...lo, potential: 6 }), [{ tokenId: 'token_10064_wang_stone1', count: 7 }]);
m.dispose();
});
test('Match.setLoadout: the settings change with the loadout in INFO_CHECK only; re-checked; a refusal changes nothing', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
const m = h.m;
const ps = h.ps('p_0');
assert.deepEqual(ps.ops, {});
assert.deepEqual(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 3 } }), { ok: true });
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
assert.deepEqual(m.setLoadout('p_0', {}), { ok: true }, 'no ops argument: unchanged');
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
assert.equal(m.setLoadout('p_0', {}, { [SHARP]: { potential: 1 } }).error, ERR.BAD_TARGET);
assert.equal(m.setLoadout('p_0', { chess_char_1_01_a: { skill: 0 } }, { [VENDLA_CHAR]: { potential: 9 } }).error, ERR.BAD_TARGET);
assert.deepEqual([ps.ops, ps.loadout], [{ [VENDLA_CHAR]: { potential: 3, cultivate: 3 } }, {}], 'a refusal changes nothing');
assert.deepEqual(m.setLoadout('p_0', {}, null), { ok: true }, 'null: none set');
assert.deepEqual(ps.ops, {});
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 1 } }).error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
assert.deepEqual(ps.ops, {});
m.dispose();
});