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.
148 lines
7.7 KiB
JavaScript
148 lines
7.7 KiB
JavaScript
// server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending
|
||
// coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality,
|
||
// out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's
|
||
// PlayerBattleInput: board units with their loadout, their 补位 mark or their 自选 pick, carried 联防 state, the reached
|
||
// layers).
|
||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||
// the player state).
|
||
|
||
import { boardOrder, pieceDir } from '../board.js';
|
||
import { computeBonds, bondSnapshot, activatedLayers, bondsWithGains } from '../bondsMeta.js';
|
||
|
||
export class PlayerRound {
|
||
startRound(r) {
|
||
this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 };
|
||
this.pendingLayerGains = null; // settled (or lapsed) at the last SETTLE
|
||
if (r > 1) this.shop.upgradePrice = Math.max(0, this.shop.upgradePrice - 1);
|
||
// onIncome handlers may rewrite ev.income / ev.pending (e.g. 老鲤 withholds R1–R2 income until R3)
|
||
const ev = { round: r, income: this.gd.income(r), pending: this.pendingFunds };
|
||
this.pendingFunds = 0;
|
||
this.m.dispatch(this, 'onIncome', ev);
|
||
const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
|
||
this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' });
|
||
// temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed
|
||
// after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue).
|
||
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
|
||
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
|
||
this.ready = false;
|
||
// summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp
|
||
for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p);
|
||
this.rollShop({ keepFrozen: true });
|
||
this.shop.frozen = false;
|
||
for (const s of this.shop.slots) if (s) s.frozen = false;
|
||
this.recompute();
|
||
}
|
||
|
||
/**
|
||
* Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are
|
||
* resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names.
|
||
*/
|
||
endPrep() {
|
||
this.resolveTemp();
|
||
this.prepsEnded++;
|
||
for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid);
|
||
this.offers = [];
|
||
this.clearUnfrozenShop();
|
||
if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0;
|
||
this.ready = true;
|
||
this.dirty();
|
||
}
|
||
|
||
// `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is
|
||
// flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched.
|
||
eliminate(round) {
|
||
this.alive = false;
|
||
this.ready = false;
|
||
this.eliminatedRound = round;
|
||
const all = [];
|
||
for (const p of this.board.values()) all.push(p);
|
||
for (const p of this.hand) if (p) all.push(p);
|
||
for (const p of this.temp) if (p) all.push(p);
|
||
for (const p of all) this.returnCopies(p);
|
||
this.board.clear();
|
||
this.hand.fill(null);
|
||
this.temp.fill(null);
|
||
this._tempDue.clear();
|
||
this.offers = [];
|
||
this.bounties = [];
|
||
this.shop.slots = [];
|
||
this.funds = 0;
|
||
this.pendingFunds = 0;
|
||
this.recompute();
|
||
}
|
||
|
||
recompute() {
|
||
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
|
||
if (this._legalityStale) this._evictIllegal();
|
||
this._liftOutOfRange();
|
||
// a free regular hand slot pulls a temp piece in (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入")
|
||
this._fillHandFromTemp();
|
||
this.bonds = computeBonds(this.gd, this);
|
||
this.dirty();
|
||
}
|
||
|
||
activatedLayers() { return activatedLayers(this.bonds); }
|
||
|
||
/**
|
||
* The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending
|
||
* in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). The 联防 field fights with them too
|
||
* (battleInput `reached`); no other rule reads them.
|
||
*/
|
||
bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); }
|
||
|
||
/**
|
||
* `reached`: the bonds carry the layers this round's own combat reached (bondsView: the pending in-battle gains, capped
|
||
* like settle()) — the 联防 field (unite.js; PRTS 卫戍协议/帮助 §联防阶段 "将以其阵地当前的状态", [ASSUMED] the current
|
||
* state includes those layers, as the strip shows them). The gains stay pending: settle() adds them once.
|
||
*/
|
||
battleInput({ side = 'L', colOffset = 0, carry = null, reached = false } = {}) {
|
||
// a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board
|
||
this.deployMap();
|
||
if (this._legalityStale) this.recompute();
|
||
const units = [];
|
||
for (const { r, c, piece } of boardOrder(this.board)) {
|
||
if (piece.kind === 'chess') {
|
||
const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) };
|
||
const pick = this.diyPickOf(piece.id);
|
||
if (pick) {
|
||
// 0.2.0 自选编队: a slotted DIY slot fights as its pick (sim getChess(id, { diy }): the operator's body, the pick's
|
||
// skill and module — no loadout fields, docs/SIM.md §12)
|
||
u.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId };
|
||
} else if (this.fieldsStandIn(piece.id)) {
|
||
// 0.2.0 补位: a chess this player does not own fights as its stand-in (sim getChess(id, { standIn: true }): the
|
||
// backup skill / module — no loadout fields, docs/SIM.md §12)
|
||
u.standIn = true;
|
||
} else {
|
||
// DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent)
|
||
const lo = this.loadoutFor(this.gd.chess(piece.id));
|
||
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') {
|
||
const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid };
|
||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js)
|
||
units.push(u);
|
||
}
|
||
}
|
||
return {
|
||
playerId: this.playerId,
|
||
seat: this.seat,
|
||
side,
|
||
colOffset,
|
||
units,
|
||
bonds: bondSnapshot(reached ? this.bondsView() : this.bonds),
|
||
bandId: this.bandId,
|
||
playerEffects: this.effects.filter((e) => e.battle !== false).map((e) => ({
|
||
id: e.id, key: e.key ?? null, source: e.iconKind ?? null, params: e.params ?? null, counter: e.counter ?? null, data: e.data ?? null,
|
||
})),
|
||
deviceOverrides: { ...this.deviceOverrides },
|
||
};
|
||
}
|
||
}
|