Files
ark-stronghold-protocol/server/match/player/round.js
T
Sukvii 56a7b918a9 feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move
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).
2026-10-08 20:45:54 +08:00

145 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 },
};
}
}