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.
This commit is contained in:
sganggs committed 2026-10-08 20:52:30 +08:00
1 parent ec0a2fc669
commit 96d9441ff1
23 files changed
+6889 -6528

No files matched your search

+2 -1
View File
@@ -96,7 +96,8 @@ test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the playe
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
assert.equal(rec.skill.skillId, 'skchr_siege_3');
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
// (0.2.2: an owned pick at the player's 潜能 / 练度 — none set: 6 / 3)
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null, potential: 6, cultivate: 3 }, 'the pick, never the loadout');
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
}
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
+146
View File
@@ -0,0 +1,146 @@
// 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();
});
+4 -2
View File
@@ -432,8 +432,10 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
assert.equal(got.blockCnt, s.blockCnt);
assert.equal(got.hp, Math.round(ally.hp));
assert.equal(got.base.atk, Math.round(ally.base.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
// the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1)
assert.equal(ally.cultivate, 3);
assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp));
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');