feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text

- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
  voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
  docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
  now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
  (stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
  lint 0 errors; typecheck; i18n strict clean (1152 msgids).
This commit is contained in:
sganggs committed 2026-10-08 22:35:33 +08:00
commit 0ced21d321
82 files changed
+9156 -6840

No files matched your search

+8 -3
View File
@@ -17,12 +17,16 @@
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
// 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
@@ -56,9 +60,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.
+9 -1
View File
@@ -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)
+5 -3
View File
@@ -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;
+14 -1
View File
@@ -21,6 +21,17 @@ const finiteOrNull = (v, cap = Infinity) => {
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
};
/**
* 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';
@@ -238,7 +249,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,
@@ -252,6 +263,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;
@@ -315,6 +327,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++) {
+59 -8
View File
@@ -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 };
}
/**
+4 -2
View File
@@ -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 : [];
+4
View File
@@ -119,6 +119,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') {
+3
View File
@@ -96,6 +96,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)