Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
145 lines
7.3 KiB
JavaScript
145 lines
7.3 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.personalChoice = null;
|
||
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;
|
||
}
|
||
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 },
|
||
};
|
||
}
|
||
}
|