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:
1 parent
ec0a2fc669
commit
96d9441ff1
23 files changed
+6889
-6528
No files matched your search
@@ -95,6 +95,8 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
|
||||
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
|
||||
|
||||
**潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings.
|
||||
|
||||
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
||||
|
||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||
|
||||
@@ -7,7 +7,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||
|
||||
### 8.1 Session & lobby
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
||||
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
|
||||
|
||||
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
||||
|
||||
+35
-7
@@ -64,6 +64,9 @@
|
||||
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
|
||||
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
|
||||
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
|
||||
// ▸ Its `ops` (0.2.2: the per-operator 潜能 / 练度, shared/protocol.js checkLoadoutOps — an operator of the 干员调配
|
||||
// roster or the 自选 owned pool, strict like the entries) travel with it: session.ops / seat.ops / seats[].ops and
|
||||
// match.setLoadout(playerId, loadout, ops); a message without `ops` sets none (every operator 潜能 6, 精英2 Lv.60).
|
||||
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
|
||||
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
|
||||
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
|
||||
@@ -96,7 +99,7 @@
|
||||
|
||||
import { randomBytes, randomInt } from 'node:crypto';
|
||||
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
|
||||
import { checkLoadout, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
|
||||
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
|
||||
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
|
||||
import { getData as defaultGetData, lookup } from './data.js';
|
||||
import { Match as DefaultMatch } from './match/Match.js';
|
||||
@@ -128,6 +131,7 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
/**
|
||||
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
|
||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
|
||||
* ops?: Readonly<Record<string, { potential: number, cultivate: number }>> | null,
|
||||
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
|
||||
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
|
||||
*/
|
||||
@@ -139,6 +143,22 @@ function freezeLoadout(loadout) {
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** Deep-frozen copy of checked operator settings (0.2.2 潜能 / 练度; shared by the session, the seat and the match). */
|
||||
function freezeOps(ops) {
|
||||
const out = {};
|
||||
for (const [id, e] of Object.entries(ops || {})) out[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** The charIds a player may set a potential / 练度 for, per data object (shared/protocol.js cultivationCharIds). */
|
||||
const OPS_IDS = new WeakMap();
|
||||
function opsCharIds(data) {
|
||||
if (!data || typeof data !== 'object') return new Set();
|
||||
let ids = OPS_IDS.get(data);
|
||||
if (!ids) { ids = cultivationCharIds(data.chess, data.backups); OPS_IDS.set(data, ids); }
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
|
||||
function freezeDiy(picks) {
|
||||
const out = {};
|
||||
@@ -576,24 +596,30 @@ export class Lobby {
|
||||
}
|
||||
|
||||
/**
|
||||
* room.loadout (DESIGN §16): check the operator loadout against the game data, store it on the session and the seat,
|
||||
* and — while a match runs — hand it to the match (accepted only during INFO_CHECK, see the header).
|
||||
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
|
||||
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
|
||||
* during INFO_CHECK, see the header). Either part refused: nothing is stored.
|
||||
*/
|
||||
loadout(session, { entries }) {
|
||||
loadout(session, { entries, ops }) {
|
||||
const data = this.safeData();
|
||||
const res = checkLoadout(entries, (id) => lookup('chess', id, data));
|
||||
if (!res || res.error) return fail(res && isErrCode(res.error) ? res.error : ERR.BAD_MSG, res && res.detail);
|
||||
const ids = opsCharIds(data);
|
||||
const resOps = checkLoadoutOps(ops, (id) => ids.has(id));
|
||||
if (!resOps || resOps.error) return fail(resOps && isErrCode(resOps.error) ? resOps.error : ERR.BAD_MSG, resOps && resOps.detail);
|
||||
const loadout = freezeLoadout(res.loadout);
|
||||
const opsSet = freezeOps(resOps.ops);
|
||||
session.loadout = loadout;
|
||||
session.ops = opsSet;
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return OK;
|
||||
const seat = room.seatOf(session.playerId);
|
||||
if (seat) seat.loadout = loadout;
|
||||
if (seat) { seat.loadout = loadout; seat.ops = opsSet; }
|
||||
if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match
|
||||
if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match');
|
||||
let r;
|
||||
try {
|
||||
r = room.match.setLoadout(session.playerId, loadout);
|
||||
r = room.match.setLoadout(session.playerId, loadout, opsSet);
|
||||
} catch (e) {
|
||||
this.log.error(`[lobby] ${room.code} match.setLoadout threw`, e);
|
||||
return fail(ERR.INTERNAL);
|
||||
@@ -655,8 +681,9 @@ export class Lobby {
|
||||
if (host) host.ready = true;
|
||||
const seats = room.seats.filter(Boolean).map((s) => ({
|
||||
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
|
||||
// DESIGN §16: the human's checked operator loadout (bots fight with the defaults)
|
||||
// DESIGN §16: the human's checked operator loadout and (0.2.2) per-operator 潜能 / 练度 (bots fight with the defaults)
|
||||
loadout: s.isBot ? null : s.loadout || null,
|
||||
ops: s.isBot ? null : s.ops || null,
|
||||
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
|
||||
notOwned: s.isBot ? null : s.notOwned || null,
|
||||
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
|
||||
@@ -919,6 +946,7 @@ export class Lobby {
|
||||
return {
|
||||
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
|
||||
loadout: session.loadout || null,
|
||||
ops: session.ops || null,
|
||||
notOwned: session.notOwned || null,
|
||||
diy: session.diy || null,
|
||||
};
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
||||
// ops?: { [charId]: { potential: 1–6, cultivate: 0–3 } } | null,
|
||||
// notOwned?: string[] | null,
|
||||
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }>
|
||||
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
|
||||
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
|
||||
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
|
||||
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
|
||||
// `ops` (0.2.2, optional): the human's per-operator 潜能 / 练度 (checkLoadoutOps; missing = 潜能 6, 精英2
|
||||
// Lv.60 — the owner's decision of 2026-10-08), re-checked and changed like the loadout (INFO_CHECK);
|
||||
// bots fight with the defaults.
|
||||
// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed
|
||||
// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything).
|
||||
// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for
|
||||
@@ -53,9 +57,10 @@
|
||||
// (a resync request). May be called without a preceding onDisconnect. Resend full
|
||||
// state: m.public, m.private and, if a battle is on (client-side combat), the
|
||||
// b.start of the field the player is on / watching (server-run mode: m.field + b.snap).
|
||||
// setLoadout(playerId, loadout) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new checked
|
||||
// operator loadout from room.loadout. Accepted only during INFO_CHECK (the briefing's
|
||||
// 干员调配 entry); afterwards the match's loadout is locked (WRONG_PHASE).
|
||||
// setLoadout(playerId, loadout, ops?) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new
|
||||
// checked operator loadout (and its 潜能 / 练度 `ops`, 0.2.2) from room.loadout. Accepted only
|
||||
// during INFO_CHECK (the briefing's 干员调配 entry); afterwards the match's loadout is locked
|
||||
// (WRONG_PHASE).
|
||||
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
|
||||
// window expired). They will never return under this playerId in this match;
|
||||
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
||||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
||||
// * 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08 「调配干员里自己设置吧,默认满潜满加成」): the human's checked
|
||||
// `seat.ops` ({ [charId]: { potential, cultivate } }, defaults dropped) is re-checked (checkLoadoutOps) and changed
|
||||
// with the loadout; a missing entry — and every operator of a bot — is 潜能 6, 精英2 Lv.60. battleInput() states both
|
||||
// for every operator the player owns (a chess fielded as itself, an owned 自选 pick: `cultivationFor`), never for a
|
||||
// 补位 stand-in or a prototype pick (shared/potential.js cultivationOf); the 自选 summons' hand count follows the
|
||||
// owner's potential (望's 棋子). m.private exposes `ops`; the scouting views carry each unit's `potential` / `cultivate`.
|
||||
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
|
||||
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
|
||||
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
|
||||
@@ -152,7 +158,9 @@ export class PlayerState {
|
||||
this.lastEmoteAt = -Infinity;
|
||||
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
|
||||
this.loadout = Object.freeze({});
|
||||
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
|
||||
// 0.2.2: the per-operator 潜能 / 练度 ({} = every operator 潜能 6, 精英2 Lv.60 — bots always)
|
||||
this.ops = Object.freeze({});
|
||||
if (!this.isBot && (seat.loadout || seat.ops)) this.setLoadout(seat.loadout ?? null, seat.ops ?? null);
|
||||
/**
|
||||
* 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match
|
||||
* started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always)
|
||||
|
||||
@@ -58,18 +58,20 @@ export class MatchPlatform {
|
||||
|
||||
/**
|
||||
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
|
||||
* already checked it against the data (PlayerState.setLoadout re-checks it).
|
||||
* already checked it against the data (PlayerState.setLoadout re-checks it). `ops` (0.2.2): the per-operator 潜能 /
|
||||
* 练度 that come with it (undefined = unchanged).
|
||||
* @param {string} playerId
|
||||
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
|
||||
* @param {Record<string, { potential: number, cultivate: number }> | null} [ops]
|
||||
* @returns {{ ok: true } | { error: string, detail?: string }}
|
||||
*/
|
||||
setLoadout(playerId, loadout) {
|
||||
setLoadout(playerId, loadout, ops = undefined) {
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
||||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
|
||||
let res = OK;
|
||||
this.guard(() => {
|
||||
if (!ps.setLoadout(loadout)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
||||
if (!ps.setLoadout(loadout, ops)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
||||
this.markPrivate(ps);
|
||||
});
|
||||
return res;
|
||||
|
||||
@@ -13,6 +13,17 @@ import { timelineAt } from '../fields.js';
|
||||
import { bossFieldPlacement } from '../finalAssault.js';
|
||||
import { battleProgress } from '../../sim/spec.js';
|
||||
|
||||
/**
|
||||
* A scouted operator's potential (below 6) and 练度 (0.2.2), like the sim's UnitInfo: PlayerState.loadoutFor's
|
||||
* `potential` / `cultivate` (null for a stand-in or a prototype 自选 pick: neither field).
|
||||
*/
|
||||
function cultivationInfo(lo) {
|
||||
const out = {};
|
||||
if (lo && Number.isInteger(lo.potential) && lo.potential < 6) out.potential = lo.potential;
|
||||
if (lo && Number.isInteger(lo.cultivate)) out.cultivate = lo.cultivate;
|
||||
return out;
|
||||
}
|
||||
|
||||
export class MatchViews {
|
||||
statusOf(ps) {
|
||||
if (ps.left) return 'left';
|
||||
@@ -209,7 +220,7 @@ export class MatchViews {
|
||||
const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess);
|
||||
const assets = (rec && rec.assets) || {};
|
||||
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
|
||||
// sim's UnitInfo in a shared field); moduleId only for an elite
|
||||
// sim's UnitInfo in a shared field); moduleId only for an elite; 0.2.2 its potential (below 6) and 练度
|
||||
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
@@ -222,6 +233,7 @@ export class MatchViews {
|
||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
|
||||
diy: this._diyInfo(ps, piece),
|
||||
...cultivationInfo(lo),
|
||||
});
|
||||
}
|
||||
return units;
|
||||
@@ -281,6 +293,7 @@ export class MatchViews {
|
||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
|
||||
diy: this._diyInfo(ps, piece),
|
||||
...cultivationInfo(lo),
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < ps.hand.length; i++) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
|
||||
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
|
||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the not-owned operators fielded
|
||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor; 0.2.2 潜能 / 练度: cultivationFor), the not-owned operators fielded
|
||||
// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the
|
||||
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
|
||||
// they may no longer occupy (_evictIllegal), dirty.
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { msg, dn } from '../../../shared/i18n.js';
|
||||
import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { cultivationOf } from '../../../shared/potential.js';
|
||||
import { tileKey, boardOrder } from '../board.js';
|
||||
|
||||
/** The charIds a player may set a potential / 练度 for, per game-data object (shared/protocol.js cultivationCharIds). */
|
||||
const OPS_IDS = new WeakMap();
|
||||
function opsCharIds(raw) {
|
||||
if (!raw || typeof raw !== 'object') return new Set();
|
||||
let ids = OPS_IDS.get(raw);
|
||||
if (!ids) { ids = cultivationCharIds(raw.chess, raw.backups); OPS_IDS.set(raw, ids); }
|
||||
return ids;
|
||||
}
|
||||
|
||||
export class PlayerBasics {
|
||||
get isHumanActive() { return !this.isBot && !this.left; }
|
||||
/** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */
|
||||
@@ -46,14 +56,35 @@ export class PlayerBasics {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
|
||||
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
|
||||
* not fit the data; bots keep the defaults.
|
||||
* Replace the operator loadout (DESIGN §16) — and, when `ops` is given, the per-operator 潜能 / 练度 (0.2.2; null = none
|
||||
* set, undefined = unchanged) — after re-checking them against this match's data. Accepts the checked form
|
||||
* `{ id: { skill, module|null } }` or raw `room.loadout` entries, and the checked or raw `ops`. Returns false (nothing
|
||||
* changes) when either does not fit the data; bots keep the defaults.
|
||||
* @param {any} loadout
|
||||
* @param {any} [ops]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setLoadout(loadout) {
|
||||
setLoadout(loadout, ops = undefined) {
|
||||
if (this.isBot) return false;
|
||||
let opsRes = null;
|
||||
if (ops !== undefined) {
|
||||
const raw = {};
|
||||
if (ops && typeof ops === 'object' && !Array.isArray(ops)) {
|
||||
for (const [id, e] of Object.entries(ops)) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const x = {};
|
||||
if (Number.isInteger(e.potential)) x.potential = e.potential;
|
||||
if (Number.isInteger(e.cultivate)) x.cultivate = e.cultivate;
|
||||
if (Object.keys(x).length) raw[id] = x;
|
||||
}
|
||||
}
|
||||
const ids = opsCharIds(this.gd.raw);
|
||||
opsRes = checkLoadoutOps(raw, (id) => ids.has(id));
|
||||
if (!opsRes || !opsRes.ok) {
|
||||
this.m.log?.warn?.(`[match ${this.m.roomCode}] operator settings of ${this.playerId} ignored: ${opsRes && opsRes.detail}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const entries = {};
|
||||
if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) {
|
||||
for (const [id, e] of Object.entries(loadout)) {
|
||||
@@ -72,9 +103,27 @@ export class PlayerBasics {
|
||||
const out = {};
|
||||
for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
|
||||
this.loadout = Object.freeze(out);
|
||||
if (opsRes) {
|
||||
const o = {};
|
||||
for (const [id, e] of Object.entries(opsRes.ops)) o[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
|
||||
this.ops = Object.freeze(o);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The potential / 练度 this player's piece of chess record `chessRecord` fights at (0.2.2; shared/potential.js
|
||||
* cultivationOf over `this.ops` — defaults 潜能 6, 精英2 Lv.60): the operator the player owns — the chess fielded as
|
||||
* itself, an owned 自选 pick (its charId) — or null for a 补位 stand-in and a prototype 自选 pick.
|
||||
* @param {object|null} chessRecord
|
||||
* @returns {{ potential: number, cultivate: number } | null}
|
||||
*/
|
||||
cultivationFor(chessRecord) {
|
||||
if (!chessRecord || this.fieldsStandIn(chessRecord)) return null;
|
||||
const rec = chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
|
||||
return cultivationOf(rec, this.ops);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this
|
||||
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted
|
||||
@@ -83,10 +132,12 @@ export class PlayerBasics {
|
||||
*/
|
||||
loadoutFor(chessRecord) {
|
||||
if (this.fieldsStandIn(chessRecord)) {
|
||||
return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id));
|
||||
return { ...resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)), potential: null, cultivate: null };
|
||||
}
|
||||
const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
|
||||
return resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id));
|
||||
// 0.2.2: with the operator's potential / 练度 (null for a prototype 自选 pick) — the 自选 summons' hand count reads it
|
||||
const cv = this.cultivationFor(chessRecord);
|
||||
return { ...resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id)), potential: cv ? cv.potential : null, cultivate: cv ? cv.cultivate : null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
|
||||
import { checkDiyPicks } from '../../../shared/protocol.js';
|
||||
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
|
||||
import { atPotential } from '../../../shared/potential.js';
|
||||
|
||||
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
@@ -102,7 +103,8 @@ export function diyGameData(gd, records) {
|
||||
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
|
||||
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
|
||||
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
|
||||
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影.
|
||||
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影 —
|
||||
* at the owner's potential (`loadout.potential`, PlayerState.loadoutFor; 0.2.2: 望's 棋子 6 below 潜能3, 7 from it).
|
||||
*/
|
||||
placeableTokens: {
|
||||
value: (chessId, loadout = null) => {
|
||||
@@ -113,7 +115,7 @@ export function diyGameData(gd, records) {
|
||||
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
|
||||
const t = tokenOf(tid);
|
||||
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
||||
const v = isObj(t.variants) ? t.variants[owner] ?? null : null;
|
||||
const v = isObj(t.variants) ? atPotential(t.variants[owner] ?? null, loadout?.potential) : null;
|
||||
if (v) {
|
||||
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
||||
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
||||
|
||||
@@ -118,6 +118,10 @@ export class PlayerRound {
|
||||
u.skillIndex = lo.skillIndex;
|
||||
u.moduleId = lo.moduleId;
|
||||
}
|
||||
// 0.2.2: the potential / 练度 of an operator the player owns (the chess as itself, an owned 自选 pick — the player's
|
||||
// 干员调配 settings, 潜能 6 / 精英2 Lv.60 by default and for bots; never a stand-in's nor a prototype pick's)
|
||||
const cv = u.standIn ? null : this.cultivationFor(this.gd.chess(piece.id));
|
||||
if (cv) { u.potential = cv.potential; u.cultivate = cv.cultivate; }
|
||||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
|
||||
units.push(u);
|
||||
} else if (piece.kind === 'token') {
|
||||
|
||||
@@ -90,6 +90,9 @@ export class PlayerViews {
|
||||
nextEnemies: this.m.nextEnemiesFor(this),
|
||||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||||
loadout: this.loadout,
|
||||
// 0.2.2: the effective per-operator 潜能 / 练度 ({ [charId]: { potential, cultivate } }; operators not listed: 潜能 6,
|
||||
// 精英2 Lv.60) — the client's cards compose the numbers from it (shared/potential.js)
|
||||
ops: this.ops,
|
||||
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
|
||||
// at the match start; [] = every operator owned) — the client shows these as their stand-ins (cards, pieces, the
|
||||
// detail card, with a small 「替补」 mark)
|
||||
|
||||
+4
-1
@@ -62,7 +62,8 @@ export const NET_DEFAULTS = Object.freeze({
|
||||
|
||||
/**
|
||||
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
|
||||
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership
|
||||
* room.loadout (a ≤ 160-entry map and ≤ 256 operator settings validated against the game data; the client debounces its
|
||||
* edits), room.ownership
|
||||
* (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, the same way) and room.spectate
|
||||
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
|
||||
*/
|
||||
@@ -109,6 +110,8 @@ export class Session {
|
||||
this.resyncAt = -Infinity;
|
||||
/** @type {Record<string, { skill: number, module: string|null }> | null} checked operator loadout (lobby-owned, DESIGN §16) */
|
||||
this.loadout = null;
|
||||
/** @type {Readonly<Record<string, { potential: number, cultivate: number }>> | null} checked per-operator 潜能 / 练度 (lobby-owned, 0.2.2; room.loadout `ops`) */
|
||||
this.ops = null;
|
||||
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
|
||||
this.notOwned = null;
|
||||
/** @type {Readonly<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
|
||||
|
||||
+58
-8
@@ -4,7 +4,7 @@
|
||||
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
|
||||
import { isDroppableChess } from './standIn.js';
|
||||
import { diySlotIds, validateDiyPicks } from './diy.js';
|
||||
import { cultivatedStats } from './potential.js';
|
||||
import { cultivatedStats, isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from './potential.js';
|
||||
|
||||
// ---- tiny validators -------------------------------------------------------
|
||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
@@ -65,12 +65,15 @@ export function isBattleResult(v) {
|
||||
// ---- operator loadout (DESIGN §16): room.loadout { entries } -------------------------------------------------
|
||||
|
||||
/**
|
||||
* `room.loadout { entries }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||
* (the per-browser loadout of the 干员调配 screen). Structural limits below; the semantic check against the game data
|
||||
* (known visible chess, legal skill index for the normal AND the elite status, legal module of the elite) is
|
||||
* `checkLoadout` — used by the server (lobby, match) and by the client to sanitise a stored loadout before sending.
|
||||
* `room.loadout { entries, ops? }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||
* (the per-browser loadout of the 干员调配 screen); `ops` (0.2.2) = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }`,
|
||||
* the player's per-operator 潜能 and 练度 (shared/potential.js; absent / missing = 潜能 6, 精英2 Lv.60 — the owner's
|
||||
* decision of 2026-10-08). Structural limits below; the semantic checks against the game data — known visible chess,
|
||||
* legal skill index for the normal AND the elite status, legal module of the elite (`checkLoadout`); an operator of the
|
||||
* 干员调配 roster or the 自选 owned pool (`checkLoadoutOps`) — are used by the server (lobby, match) and by the client to
|
||||
* sanitise a stored loadout before sending.
|
||||
*/
|
||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9 });
|
||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9, ops: 256 });
|
||||
/** The "no module" choice of an elite (模组: 不装备). */
|
||||
export const MODULE_NONE = 'none';
|
||||
const isLoadoutEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'skill' || k === 'module')
|
||||
@@ -174,6 +177,52 @@ export function resolveLoadout(loadout, chess, getChess) {
|
||||
return { skillIndex, moduleId };
|
||||
}
|
||||
|
||||
/** One `room.loadout.ops` entry: `{ potential?: 1–6, cultivate?: 0–3 }`, at least one of them. */
|
||||
const isOpsEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'potential' || k === 'cultivate')
|
||||
&& optional(isPotential)(e.potential) && optional(isCultivate)(e.cultivate);
|
||||
/** Structural check of `room.loadout.ops` (0.2.2): a map of ≤ 256 charIds → `{ potential?, cultivate? }`. */
|
||||
export const isLoadoutOps = (v) => isMap(v, LOADOUT_LIMITS.ops, isId, isOpsEntry);
|
||||
|
||||
/**
|
||||
* The operators a player sets a potential / 练度 for (0.2.2): the charIds of the 干员调配 roster — the visible normal chess
|
||||
* (checkLoadout's targets; a hidden chess shares its visible twin's charId: 锡人, 耶拉 …) — and the owned 6★ 自选 picks
|
||||
* (data/backups.json `diy.ownedPool`). A PRESET (特许) operator the player does not own is set by hand (潜能 1, 未精英化:
|
||||
* the official 「未持有的特许按1潜」 and +0 %); a 补位 stand-in or a prototype pick takes neither.
|
||||
* @param {Record<string, any>|null|undefined} chess data/chess.json
|
||||
* @param {any} [backups] data/backups.json
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function cultivationCharIds(chess, backups = null) {
|
||||
const out = new Set();
|
||||
for (const c of Object.values(chess && typeof chess === 'object' ? chess : {})) {
|
||||
if (c && !c.isGolden && !c.isDiy && c.visible !== false && !c.isHidden && (!c.baseId || c.baseId === c.chessId) && isId(c.charId)) out.add(c.charId);
|
||||
}
|
||||
for (const id of Array.isArray(backups?.diy?.ownedPool) ? backups.diy.ownedPool : []) if (isId(id)) out.add(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic check + normalisation of `room.loadout.ops` (0.2.2) — strict like checkLoadout: an operator `isOperator` does
|
||||
* not know (cultivationCharIds) rejects the whole message. Entries equal to the defaults (潜能 6, 练度 3) are dropped, the
|
||||
* rest stored complete `{ potential, cultivate }`. `undefined` / `null` = no settings (`{}`).
|
||||
* @param {any} ops
|
||||
* @param {(charId: string) => boolean} isOperator
|
||||
* @returns {{ ok: true, ops: Record<string, { potential: number, cultivate: number }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
|
||||
*/
|
||||
export function checkLoadoutOps(ops, isOperator) {
|
||||
if (ops == null) return { ok: true, ops: {} };
|
||||
if (!isLoadoutOps(ops)) return { error: 'BAD_MSG', detail: 'bad operator settings' };
|
||||
const out = {};
|
||||
for (const id of Object.keys(ops)) {
|
||||
if (typeof isOperator !== 'function' || !isOperator(id)) return { error: 'BAD_TARGET', detail: `unknown operator ${id}` };
|
||||
const potential = ops[id].potential ?? POTENTIAL_DEFAULT;
|
||||
const cultivate = ops[id].cultivate ?? CULTIVATE_DEFAULT;
|
||||
if (potential === POTENTIAL_DEFAULT && cultivate === CULTIVATE_DEFAULT) continue;
|
||||
out[id] = { potential, cultivate };
|
||||
}
|
||||
return { ok: true, ops: out };
|
||||
}
|
||||
|
||||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -334,8 +383,9 @@ export const C2S = {
|
||||
// host confirmed — a seat that changed hands meanwhile is refused
|
||||
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
||||
'room.start': {},
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||||
'room.loadout': { entries: isLoadoutEntries },
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
|
||||
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
|
||||
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
|
||||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||||
'room.ownership': { notOwned: isNotOwnedList },
|
||||
|
||||
+749
-751
File diff suppressed because it is too large.
Load diff
+3034
-3040
File diff suppressed because it is too large.
Load diff
+790
-797
File diff suppressed because it is too large.
Load diff
+375
-358
File diff suppressed because it is too large.
Load diff
+1525
-1541
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
|
||||
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
|
||||
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
|
||||
// the heavy-intent rate limit.
|
||||
// the heavy-intent rate limit; its per-operator 潜能 / 练度 `ops` (0.2.2): validated with it, stored and handed over beside it.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -111,6 +111,39 @@ describe('room.loadout (lobby, stub match)', () => {
|
||||
await back.terminate();
|
||||
});
|
||||
|
||||
test('潜能 / 练度 (ops, 0.2.2): strict validation, defaults dropped, stored per session and seat beside the loadout; a message without ops sets none', async () => {
|
||||
const VENDLA = C('chess_char_1_06_a').charId; // a 特许 operator of the roster
|
||||
const KALTS = 'char_003_kalts'; // an owned-6★ 自选 pick
|
||||
const a = await pool.player('Ops');
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: [] }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: {} } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 0 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 7 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { cultivate: 4 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, elite: 1 } } }, ERR.BAD_MSG);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { char_609_acguad: { potential: 1 } } }, ERR.BAD_TARGET); // a prototype
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||
// a refusal of the ops refuses the entries too (nothing stored)
|
||||
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { cultivate: 2 }, [C(INSIDE).charId]: { potential: 6, cultivate: 3 } } });
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seat = RecordingStub.instances.at(-1).opts.seats[0];
|
||||
assert.deepEqual(seat.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
|
||||
assert.deepEqual(seat.ops, { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 2 } }, 'complete entries, defaults dropped');
|
||||
assert.ok(Object.isFrozen(seat.ops) && Object.isFrozen(seat.ops[VENDLA]));
|
||||
// an older client's message (no ops): none set — the next match fields every operator at 潜能 6 / 精英2 Lv.60
|
||||
await err(a, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
|
||||
const b = await pool.player('Old');
|
||||
await ok(b, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
||||
await createRoom(b, 'solo');
|
||||
await ok(b, { t: 'room.start' });
|
||||
await b.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
assert.deepEqual(RecordingStub.instances.at(-1).opts.seats[0].ops, {});
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
|
||||
const a = await pool.player('Spam');
|
||||
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
|
||||
@@ -146,4 +179,19 @@ describe('room.loadout (lobby + real match)', () => {
|
||||
assert.match(r.detail || '', /locked/);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('潜能 / 练度 (0.2.2): taken with the loadout in the briefing (m.private.ops), stated in the battle input; locked afterwards', async () => {
|
||||
const VENDLA = C('chess_char_1_06_a').charId;
|
||||
const a = await pool.player('Solo2');
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||
await ok(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, cultivate: 1 } } });
|
||||
const priv = await a.waitFor('m.private', (p) => p.ops && p.ops[VENDLA], 3000);
|
||||
assert.deepEqual(priv.ops, { [VENDLA]: { potential: 2, cultivate: 1 } });
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||
await err(a, { t: 'room.loadout', entries: {}, ops: {} }, ERR.WRONG_PHASE);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
+9
-2
@@ -39,7 +39,9 @@
|
||||
// each of its skills, then every prototype pick with a kit at its locked selection, both forms of each tier;
|
||||
// 12 pieces per battle on a real stage against the round's real wave three times over (diyScenarios)
|
||||
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
|
||||
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
|
||||
// path browsers and the server's headless fields use) with every option explicit — every operator the player owns at
|
||||
// the default player's settings, 潜能 6 and 练度 精英2 Lv.60 (PlayerBattleInput `potential` / `cultivate`, as
|
||||
// PlayerState.battleInput states them, 0.2.2; a stand-in and a prototype 自选 pick neither); matches construct Match directly
|
||||
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
|
||||
//
|
||||
// Digest (battle): end time, ticks, reason, kills / total / leaks, per player (kills, leaks, coins, damage, boss
|
||||
@@ -75,10 +77,13 @@ import { VirtualScheduler } from '../server/match/scheduler.js';
|
||||
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js';
|
||||
import { diyRecordOf, DIY_TIERS } from '../shared/diy.js';
|
||||
import { unitForm } from '../shared/standIn.js';
|
||||
import { POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../shared/potential.js';
|
||||
import { OPERATOR_KITS, KITTED_CHARS } from '../server/sim/content/kits/index.js';
|
||||
import { GEO } from '../shared/constants.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** The default player's 潜能 / 练度 of every owned operator (shared/potential.js; the owner's decision of 2026-10-08). */
|
||||
const DEFAULT_CULTIVATION = Object.freeze({ potential: POTENTIAL_DEFAULT, cultivate: CULTIVATE_DEFAULT });
|
||||
export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
|
||||
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins', 'diy']);
|
||||
|
||||
@@ -351,6 +356,7 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
|
||||
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
|
||||
if (w.moduleId != null) u.moduleId = w.moduleId;
|
||||
if (w.standIn) u.standIn = true;
|
||||
else Object.assign(u, DEFAULT_CULTIVATION); // an owned operator: the default player's 潜能 / 练度 (0.2.2)
|
||||
if (w.carryState) u.carryState = w.carryState;
|
||||
units.push(u);
|
||||
}
|
||||
@@ -953,7 +959,8 @@ function diyLayout(stageId, wanted) {
|
||||
const [r, c] = free[0];
|
||||
used.add(tileKey(r, c));
|
||||
const dir = units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT';
|
||||
units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [] });
|
||||
// an owned pick at the default player's 潜能 / 练度 (0.2.2); a prototype has neither
|
||||
units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [], ...(w.rec.diyProto ? null : DEFAULT_CULTIVATION) });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user