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).
120 lines
6.7 KiB
JavaScript
120 lines
6.7 KiB
JavaScript
// server/match/player/views.js — PlayerState methods: the views — pieceView, effectsView (the effects column: 策略 / 机变 /
|
|
// 悬赏 …) and m.private (privateView).
|
|
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
|
// the player state).
|
|
|
|
import { PHASE } from '../../../shared/constants.js';
|
|
import { boardOrder, pieceDir } from '../board.js';
|
|
import { bondList, offBondCounts } from '../bondsMeta.js';
|
|
import { bountyText, bountyCard } from '../choices.js';
|
|
|
|
export class PlayerViews {
|
|
pieceView(p, rc = null) {
|
|
const rec = p.kind === 'item' ? this.gd.item(p.id) : p.kind === 'token' ? this.gd.token(p.id) : this.gd.chess(p.id);
|
|
const v = {
|
|
uid: p.uid,
|
|
kind: p.kind,
|
|
id: p.id,
|
|
golden: !!(rec && rec.isGolden),
|
|
tier: rec && Number.isInteger(rec.tier) ? rec.tier : null,
|
|
items: p.kind === 'chess' ? (p.items || []).map((it) => ({ uid: it.uid, id: it.id })) : [],
|
|
count: p.kind === 'token' ? (p.count || 1) : 1,
|
|
ownerUid: p.kind === 'token' ? p.ownerUid ?? null : null,
|
|
};
|
|
if (rc) { v.row = rc[0]; v.col = rc[1]; v.dir = pieceDir(p); }
|
|
return v;
|
|
}
|
|
|
|
effectsView() {
|
|
const out = [];
|
|
const band = this.bandId ? this.gd.band(this.bandId) : null;
|
|
if (band) out.push({ id: band.effectId || band.bandId, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId });
|
|
for (const e of this.effects) {
|
|
if (e.hidden) continue;
|
|
const v = { id: e.id, name: e.name || e.id, desc: e.desc || '', iconKind: e.iconKind || 'choice', iconId: e.iconId || e.id };
|
|
if (e.counter != null) v.counter = e.counter;
|
|
out.push(v);
|
|
}
|
|
for (const b of this.bounties) {
|
|
// the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and
|
|
// the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it
|
|
// lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4
|
|
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
|
|
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
|
|
out.push({
|
|
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', // i18n-ignore: a data-less card's fallback name
|
|
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, // i18n-ignore: older clients (ui/effectsList.js builds it from counter)
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
privateView() {
|
|
const slots = this.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, price: this.priceOf(s), basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null));
|
|
const offer = this.offers[0] || null;
|
|
const free = this.shop.freeRefreshes > 0;
|
|
const board = [];
|
|
for (const { r, c, piece } of boardOrder(this.board)) board.push(this.pieceView(piece, [r, c]));
|
|
// a boss round's prep: the partner's board on its half of the boss field (Match.bossMateView, item 51)
|
|
const bossMate = typeof this.m.bossMateView === 'function' ? this.m.bossMateView(this) : null;
|
|
return {
|
|
t: 'm.private',
|
|
playerId: this.playerId,
|
|
seat: this.seat,
|
|
alive: this.alive,
|
|
lp: this.lp,
|
|
funds: this.funds,
|
|
bandId: this.bandId,
|
|
ready: this.ready,
|
|
canReady: this.alive && this.tempEmpty && !this.personalChoice && this.m.phase === PHASE.PREP,
|
|
personalChoice: this.personalChoice ? {
|
|
id: this.personalChoice.id,
|
|
round: this.personalChoice.round,
|
|
sourceItemId: this.personalChoice.sourceItemId,
|
|
cards: this.personalChoice.cards.map((c) => bountyCard(this.gd, c)),
|
|
} : null,
|
|
shop: {
|
|
level: this.shop.level,
|
|
maxLevel: this.gd.maxShopLevel,
|
|
upgradePrice: this.shop.level >= this.gd.maxShopLevel ? 0 : this.shop.upgradePrice,
|
|
refreshPrice: free ? 0 : this.gd.refreshPrice,
|
|
freeRefreshes: this.shop.freeRefreshes,
|
|
frozen: this.shop.frozen,
|
|
slots,
|
|
// `source` 'merge' = the promotion reward (晋升奖励); any other offer (a strategy, an item, a 特质) carries the
|
|
// `label` the bar shows instead; `queued` = offers waiting behind it (player report #6 after 0.1.0)
|
|
rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, queued: this.offers.length - 1, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null,
|
|
},
|
|
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
|
|
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
|
|
board,
|
|
deployCap: this.deployCap,
|
|
deployCount: this.deployCount,
|
|
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
|
|
bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }),
|
|
effects: this.effectsView(),
|
|
nextEnemies: this.m.nextEnemiesFor(this),
|
|
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
|
loadout: this.loadout,
|
|
// 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)
|
|
standIns: this.standIns,
|
|
// 0.2.0 自选编队: this player's 自选 picks in this match ({ [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's
|
|
// when the match started; {} = none) — the client composes the operator's record for its own cards and pieces
|
|
// (shared/diy.js diyRecord); `diyBanned`: the slotted slots out of the shop this match (every bond of the operator
|
|
// switched off)
|
|
diy: this.diy,
|
|
diyBanned: this.diyBanned,
|
|
// 最终攻势 / 隐秘核心 prep (community report of 2026-10-06, item 51): `{ playerId, side, units }` — the pair partner's
|
|
// board pieces as UnitInfo on its half of the boss field (mirrored on the right half, as the battle places them);
|
|
// the own prep view draws them beside the own half, read-only. Absent otherwise.
|
|
...(bossMate ? { bossMate } : {}),
|
|
stats: {
|
|
dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold,
|
|
refreshes: this.stats.refreshes, merges: this.stats.merges,
|
|
},
|
|
};
|
|
}
|
|
}
|