Merge upstream 0.2.2 and resolve reroll conflicts (#408)
This commit is contained in:
commit
be095d8638
447 files changed
+33376
-8843
No files matched your search
+16
-5
@@ -12,14 +12,21 @@
|
||||
// opts.mode 'solo' | 'coop'
|
||||
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
|
||||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||||
// opts.aiPicksLast boolean (optional) the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338):
|
||||
// the strategy and 机变 drafts put every human seat before every AI seat
|
||||
// (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
|
||||
@@ -53,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.
|
||||
@@ -106,7 +114,7 @@
|
||||
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
|
||||
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
|
||||
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
|
||||
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
|
||||
// ('sample': ~1 in 8, in later slices, mismatches logged; 'all': before accepting — the
|
||||
// server's result wins on a mismatch)
|
||||
//
|
||||
// Engine-only extra options (tests / tools; the lobby never passes them):
|
||||
@@ -121,7 +129,7 @@
|
||||
// opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded
|
||||
// with a virtual one); the rest runs in later callbacks (scheduleBotPrep)
|
||||
// opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots,
|
||||
// takeovers; default 8 with a real scheduler, at once with a virtual one)
|
||||
// takeovers, result verification; default 8 with a real scheduler, at once with a virtual one)
|
||||
// Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human.
|
||||
//
|
||||
// Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors
|
||||
@@ -151,6 +159,7 @@
|
||||
// intents.js the g.* / b.* router, emotes, AI 托管, g.unitStats
|
||||
// pause.js the solo pause
|
||||
// phases.js INFO_CHECK, BAND_DRAFT (the strategy draft), BATTLE_CHECK, ROUND_START
|
||||
// setupVote.js unanimous INFO_CHECK setup reroll, suspended confirmation, preserved player settings
|
||||
// spDraft.js SP_DRAFT (机变) and the rolls the meta effects ask for (bounties, item ids, pools)
|
||||
// prep.js PREP: its start, the AI seats' sliced preps, Ready, the deadline, its end
|
||||
// combat.js battle construction, COMBAT (both modes), the per-field results, then 联防 or SETTLE
|
||||
@@ -237,6 +246,8 @@ export class Match {
|
||||
this.gd = new GameData(this.data, this.modeId);
|
||||
if (!this.difficulty) this.difficulty = this.gd.difficulty;
|
||||
this.isSolo = this.mode === 'solo' || this.gd.isSolo;
|
||||
/** 「AI 队友最后选择」 (opts.aiPicksLast, GitHub #338): humans draft before AI seats (MatchPhases.humansFirst) */
|
||||
this.aiPicksLast = !this.isSolo && opts.aiPicksLast === true;
|
||||
this.ownsScheduler = !opts.scheduler;
|
||||
this.sched = opts.scheduler || new RealScheduler({ now: opts.now || Date.now, onError: (e) => this.reportError('timer', e) });
|
||||
this.registry = opts.registry || getDefaultRegistry();
|
||||
|
||||
@@ -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)
|
||||
@@ -201,6 +209,13 @@ export class PlayerState {
|
||||
this.effects = [];
|
||||
/** active bounties: { id, card, roundsLeft, chooser } */
|
||||
this.bounties = [];
|
||||
/**
|
||||
* The open personal choice of the Art 教鞭 (Match.offerBountyChoice): up to three cards.bounty entries the owner picks one
|
||||
* of with `g.choice { idx, choiceId }` — m.private only; Ready is refused while it is open, the prep's deadline (or the
|
||||
* bot) resolves it
|
||||
* @type {null | { id: string, round: number, sourceItemId: string, cards: any[] }}
|
||||
*/
|
||||
this.personalChoice = null;
|
||||
/** free-form counters for content (ctx.counter / setCounter) */
|
||||
this.counters = {};
|
||||
/** per-round counters (reset at round start) */
|
||||
|
||||
@@ -229,7 +229,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
|
||||
const next = gd.upgradeBase(ps.shop.level) ?? 0;
|
||||
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
|
||||
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place
|
||||
// the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
|
||||
const { chess, item } = gd.shopSlots(ps.shop.level);
|
||||
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
|
||||
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
|
||||
|
||||
+19
-5
@@ -37,7 +37,8 @@
|
||||
// tiles, where it blocks — except one whose trait reads 「可以放置于远程位」 (歌蕾蒂娅, 崖心, 见行者, any module:
|
||||
// placeClass 'all', shared/highGround.js), which takes a 高台 whose range covers the enemy road, else the ground
|
||||
// (owner 2026-10-04 for the 高台 preference, 2026-10-05 for who may use one). It stays a blocker in the lineup
|
||||
// (basePositionClass). Each unit's range grid — the one it is
|
||||
// (basePositionClass). A blocker whose range grid is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱,
|
||||
// 塞雷娅, 余) is planned on a free tile of the enemy road first: off it nothing is blocked or hit. Each unit's range grid — the one it is
|
||||
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first
|
||||
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
|
||||
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
|
||||
@@ -978,6 +979,12 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
||||
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
|
||||
let bestHigh = null;
|
||||
let bestHighV = -Infinity;
|
||||
// a blocker whose range is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) neither blocks nor
|
||||
// hits anything off the enemy road — the official range "0-1" (excel range_table) is the one grid (0,0) — so a free road tile
|
||||
// comes first (the kill estimate saturates in a full layout and then cannot tell the tiles apart)
|
||||
const preferRoad = isBlocker(r0) && r0.rangeGrid?.length === 1 && r0.rangeGrid[0].every((v) => v === 0);
|
||||
let bestRoad = null;
|
||||
let bestRoadV = -Infinity;
|
||||
for (const [r, c] of legalTiles(map, cls)) {
|
||||
const k = tileKey(r, c);
|
||||
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
|
||||
@@ -993,13 +1000,15 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
||||
const v = layout.value() + noise;
|
||||
layout.units.pop();
|
||||
if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
|
||||
// (the best road tile by the same value)
|
||||
if (preferRoad && model.ground.has(k) && v > bestRoadV) { bestRoadV = v; bestRoad = [k, r, c, dir]; }
|
||||
if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) {
|
||||
bestHighV = v;
|
||||
bestHigh = [k, r, c, dir];
|
||||
}
|
||||
}
|
||||
}
|
||||
const pick = bestHigh || best;
|
||||
const pick = bestHigh || bestRoad || best;
|
||||
if (!pick) continue;
|
||||
taken.add(pick[0]);
|
||||
layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model));
|
||||
@@ -1329,6 +1338,7 @@ export function botPrepBegin(m, ps) {
|
||||
*/
|
||||
export function* botPrepBeginSteps(m, ps) {
|
||||
if (!ps.alive || ps.ready) return null;
|
||||
m.autoPickPersonalChoice(ps, 'bot');
|
||||
// 1. reward offers (free)
|
||||
takeOffers(m, ps);
|
||||
yield;
|
||||
@@ -1366,6 +1376,7 @@ export function* botPrepEndSteps(m, ps, job = null) {
|
||||
if (freeSlot(ps.hand) < 0) freeHandSlot(m, ps);
|
||||
// what the next prep's bench shed may judge by value: what is owned now (later gains wait for a placement step)
|
||||
rememberOwned(ps);
|
||||
m.autoPickPersonalChoice(ps, 'bot');
|
||||
tryDo(() => ps.setReady(true));
|
||||
}
|
||||
|
||||
@@ -1807,8 +1818,8 @@ const isBountyArt = (gd, itemId) => { const rec = gd.item(itemId); return !!rec
|
||||
|
||||
/**
|
||||
* Use an Art: 画卷 copies the operator on its tile (its range is the tile + the one in front) — the most valuable
|
||||
* deployed operator, with a free hand slot for the copy; 教鞭 / “神秘顾客” add a bounty to the next battle (one the engine
|
||||
* draws, not chosen) — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
|
||||
* deployed operator, with a free hand slot for the copy; 教鞭 offers a scored personal bounty choice, “神秘顾客” adds a
|
||||
* random bounty — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
|
||||
* (destroying 教鞭 gives nothing; a full hand at the prep end: freeHandSlot).
|
||||
*/
|
||||
function useArt(m, ps, item, ctx) {
|
||||
@@ -1825,7 +1836,10 @@ function useArt(m, ps, item, ctx) {
|
||||
if (keys.includes('trap_create_self_choice')) {
|
||||
if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept
|
||||
const [key] = [...ps.board.keys()];
|
||||
if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); }
|
||||
if (key) {
|
||||
const [r, c] = parseKey(key);
|
||||
if (tryDo(() => ps.useArt(item.uid, r, c))) m.autoPickPersonalChoice(ps, 'bot');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const [key] = [...ps.board.keys()];
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
// operator gained into the hand
|
||||
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front
|
||||
// trap_create_self_choice {choice_event} 教鞭 / 神秘顾客 (Art) add a random bounty to your next battle
|
||||
// [ASSUMED simplification, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
|
||||
// a personal choice overlay.]
|
||||
// This builtin picks a random bounty by default; content/items/meta.js overrides Pointing Stick with a private choice.
|
||||
|
||||
import { getData } from '../data.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { msg, dn } from '../../shared/i18n.js';
|
||||
|
||||
const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d);
|
||||
|
||||
@@ -39,6 +39,15 @@ const paramsOf = (ctx, item) => {
|
||||
return rec && rec.params && typeof rec.params === 'object' ? rec.params : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* The item gave nothing — the copies are all owned (the shared pool, research 06 §7) or no operator shares the bond — but
|
||||
* it is still destroyed ("装备时销毁"): say why instead of swallowing it (GitHub #401, the owner's OK of 2026-10-09).
|
||||
*/
|
||||
function toastNothing(ctx, ev, chessName = null) {
|
||||
const who = ctx.gd.item(ev.item?.id)?.name || '';
|
||||
ctx.toast(chessName ? msg('{who}:卡池中已没有{name}', { who: dn(who), name: dn(chessName) }) : msg('{who}:没有可获得的同盟约干员', { who: dn(who) }), 'warn');
|
||||
}
|
||||
|
||||
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
|
||||
function rollSameBond(ctx, bonds, maxTier, exclude = null) {
|
||||
const set = new Set(bonds);
|
||||
@@ -101,10 +110,12 @@ const ITEM_HANDLERS = {
|
||||
onEquip(ctx, ev) {
|
||||
const n = Math.max(1, int(paramsOf(ctx, ev.item).count, 1));
|
||||
const bonds = ctx.pieceBonds(ev.target.uid);
|
||||
let got = 0;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const id = rollSameBond(ctx, bonds, ctx.shopLevel());
|
||||
if (id) ctx.grantChess(id);
|
||||
if (id && ctx.grantChess(id)) got++;
|
||||
}
|
||||
if (!got) toastNothing(ctx, ev);
|
||||
},
|
||||
},
|
||||
use_equip_gain_coin_when_next_round_start: {
|
||||
@@ -139,9 +150,12 @@ const ITEM_HANDLERS = {
|
||||
onEquip(ctx, ev) {
|
||||
const base = ctx.gd.baseIdOf(ev.target.id);
|
||||
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
|
||||
if (owned >= 2) { ctx.grantChess(base); return; }
|
||||
if (owned >= 2) {
|
||||
if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null);
|
||||
return;
|
||||
}
|
||||
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
|
||||
if (id) ctx.grantChess(id);
|
||||
if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev);
|
||||
},
|
||||
},
|
||||
use_equip_reward_special_goods_char_chess: {
|
||||
@@ -152,6 +166,7 @@ const ITEM_HANDLERS = {
|
||||
// a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out
|
||||
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); }
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item' });
|
||||
else toastNothing(ctx, ev);
|
||||
},
|
||||
},
|
||||
// 信标 (act2autochess eff_acarm109 / eff_acgarm109 "装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的
|
||||
|
||||
@@ -674,6 +674,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
|
||||
// ---- bounties / choices
|
||||
addBounty: (card) => m.addBounty(ps, card),
|
||||
offerBountyChoice: (cards, sourceItemId) => m.offerBountyChoice(ps, cards, sourceItemId),
|
||||
|
||||
// ---- messaging
|
||||
// a string or a shared/i18n.js msg(msgid, params)
|
||||
|
||||
+52
-18
@@ -17,9 +17,11 @@
|
||||
// Client-side combat (DESIGN §14) helpers:
|
||||
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
|
||||
// under a real scheduler) or at once (verification, virtual time) — and keep a
|
||||
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
|
||||
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
|
||||
// enemies still standing, for their live counter; user playtest #6 item 7)
|
||||
// progress timeline [[gt, killed, total, resolved]] for the teammates' waiting UI
|
||||
// (联防: a 5th element — left = spec.js uniteLeft, the leakers'
|
||||
// enemies still standing, for their live counter; user playtest #6 item 7).
|
||||
// `resolved` = the HUD capsule's numerator (Battle.resolved), sampled on the
|
||||
// field clock: a server-run / bot field has no authority reporting b.progress
|
||||
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
|
||||
// (boss fields that share the pool while other fields run on clients); a
|
||||
// takeover's fast-forward is spread over the pacing intervals
|
||||
@@ -56,7 +58,7 @@ export function snapFrame(fieldId, snap) {
|
||||
|
||||
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
|
||||
export function emptyPerPlayer() {
|
||||
return { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
|
||||
return { killed: 0, total: 0, resolved: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
|
||||
}
|
||||
|
||||
/** A finished stand-in for a battle that failed to construct. */
|
||||
@@ -72,13 +74,13 @@ export class DeadBattle {
|
||||
this.errorCount = 1;
|
||||
const perPlayer = {};
|
||||
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
|
||||
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
||||
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
|
||||
this.errors = [];
|
||||
}
|
||||
step() {}
|
||||
forceEnd() {}
|
||||
result() { return this._result; }
|
||||
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0 }; }
|
||||
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0, resolved: 0 }; }
|
||||
drainEvents() { return []; }
|
||||
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
|
||||
on() { return null; }
|
||||
@@ -238,7 +240,7 @@ export class FieldRunner {
|
||||
}
|
||||
const perPlayer = {};
|
||||
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
|
||||
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
||||
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,11 +261,15 @@ export const CATCHUP_TICKS_PER_INTERVAL = 240;
|
||||
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||
|
||||
/**
|
||||
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
|
||||
* (spec.js uniteLeft; omitted when unknown).
|
||||
* A timeline sample of a battle: [gt, killed, total, resolved], plus — 联防 — the leakers' enemies still standing at
|
||||
* index 4 (spec.js uniteLeft; omitted when unknown). `resolved` is the HUD capsule's numerator of that game second
|
||||
* (Battle.resolved: the field's own scheduled enemies knocked out or leaked) — the server-run / bot fields have no
|
||||
* authority that reports b.progress, so the teammates' capsule reads it from here, never from `progress.leaks`.
|
||||
*/
|
||||
export function timelineSample(b) {
|
||||
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
|
||||
const total = Number(b && b.total) || 0;
|
||||
const resolved = Number.isFinite(b && b.resolved) ? Math.max(0, Math.min(total, b.resolved)) : null;
|
||||
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, total, resolved];
|
||||
if (b && b.kind === 'unite') {
|
||||
let left = null;
|
||||
try { left = uniteLeft(b); } catch { left = null; }
|
||||
@@ -276,7 +282,7 @@ export function timelineSample(b) {
|
||||
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
|
||||
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
|
||||
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
|
||||
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
||||
* runs (timelineSample: [gt, killed, total, resolved(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
||||
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
|
||||
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
|
||||
*/
|
||||
@@ -345,9 +351,9 @@ export function runHeadless(battle, opts = {}) {
|
||||
return job.output();
|
||||
}
|
||||
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total, resolved(, left)] of the last sample ≤ gt. */
|
||||
export function timelineAt(timeline, gt) {
|
||||
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
|
||||
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0, null];
|
||||
let lo = 0, hi = timeline.length - 1;
|
||||
if (gt >= timeline[hi][0]) return timeline[hi];
|
||||
while (lo < hi) {
|
||||
@@ -734,7 +740,9 @@ const sameMods = (a, b) => {
|
||||
/**
|
||||
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
|
||||
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
|
||||
* nobody else; killed ≤ total ≤ bound; each leaked enemy key exists in the spawns (multiset bound; keys content may
|
||||
* nobody else; killed ≤ bound and total ≤ bound (maxTotal: `killed` counts every counted knock-out — runtime splits
|
||||
* and summons included — and may exceed `total`, which counts only the round's own scheduled enemies; PR #157 capsule
|
||||
* semantics); each leaked enemy key exists in the spawns (multiset bound; keys content may
|
||||
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
|
||||
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
|
||||
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
|
||||
@@ -742,7 +750,12 @@ const sameMods = (a, b) => {
|
||||
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
|
||||
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
|
||||
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
|
||||
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
|
||||
* perfect consistent with the counted leaks; unit states only for the player's own units, within range. `resolved` (the
|
||||
* HUD capsule's numerator): the result's own — the FIELD's, what the client's capsule showed — is kept as reported,
|
||||
* clamped to the field's `total`, and is what m.public publishes (never the sum of the players': an enemy that crosses
|
||||
* the midline of a two-half field is billed to one player's `total` and to the other's leak, so their own numbers
|
||||
* clamp it to 0); the players' own (each clamped to their `total`) are the fallback of a result with none; absent when
|
||||
* neither is complete.
|
||||
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
|
||||
*/
|
||||
export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
@@ -781,7 +794,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
const p = raw.perPlayer[pid];
|
||||
if (!p || typeof p !== 'object') return bad('player');
|
||||
const own = B.players.get(pid);
|
||||
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > p.total || p.total > B.maxTotal) return bad('counts');
|
||||
// `killed` counts every counted knock-out — a runtime split child / summon too — while `total` counts only the
|
||||
// enemies the round scheduled, so `killed ≤ total` no longer holds (PR #157 capsule semantics): bound it by
|
||||
// maxTotal (the spec's content-spawn allowance) instead, exactly like `total`
|
||||
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > B.maxTotal || p.total > B.maxTotal) return bad('counts');
|
||||
const leaked = [];
|
||||
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
|
||||
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
|
||||
@@ -820,7 +836,9 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
leaked.push(e);
|
||||
}
|
||||
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
|
||||
if (!bossLike && countedLeaks > p.total + B.spawnCount) return bad('leaks > total');
|
||||
// `total` is the round's own scheduled enemies; the leaks may include content-spawned children (a split / summon),
|
||||
// which the spec bounds by maxTotal (the same allowance `killed` is checked against above)
|
||||
if (!bossLike && countedLeaks > p.total + B.maxTotal) return bad('leaks > total');
|
||||
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
|
||||
const layerGains = {};
|
||||
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
|
||||
@@ -868,10 +886,26 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
|
||||
unitsEnd, unitStats,
|
||||
};
|
||||
// the HUD capsule's numerator: kept only when the client reported one (an absent value must stay absent — the
|
||||
// teammate UI then falls back to `killed`, never to a fabricated 0); clamped to the denominator
|
||||
if (Number.isInteger(p.resolved) && p.resolved >= 0) perPlayer[pid].resolved = Math.min(p.total, p.resolved);
|
||||
}
|
||||
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
|
||||
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
|
||||
for (const pid of pids) { result.killed += perPlayer[pid].killed; result.total += perPlayer[pid].total; }
|
||||
let ownResolved = 0, ownKnown = pids.length > 0;
|
||||
for (const pid of pids) {
|
||||
result.killed += perPlayer[pid].killed;
|
||||
result.total += perPlayer[pid].total;
|
||||
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
|
||||
}
|
||||
// The FIELD's capsule numerator (Battle.resolved, what the client's own capsule showed), kept as reported and clamped
|
||||
// to the field's total — NOT the sum of the players' own numbers: an enemy that spawns on one half and leaks on the
|
||||
// other (the 联防 lane, the boss pair's crossing routes) is billed to one player's `total` and to the other's
|
||||
// `leakedInTotal`, so each player's own min(total, …) clamps it away (0 and 0 for an enemy the field resolved). The sum
|
||||
// is only the fallback of a result with no field-level number; none of either stays absent (the teammate UI then
|
||||
// falls back to `killed`, never to a fabricated 0).
|
||||
if (Number.isInteger(raw.resolved) && raw.resolved >= 0) result.resolved = Math.min(result.total, raw.resolved);
|
||||
else if (ownKnown) result.resolved = Math.min(result.total, ownResolved);
|
||||
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
|
||||
const unspawned = [];
|
||||
for (const u of raw.unspawned) {
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
|
||||
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
|
||||
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
|
||||
// choice a player's open 教鞭 choice (`personalChoice`) is one of an alive, not-Ready player in the PREP of its own round,
|
||||
// with one to three different cards
|
||||
// match phase known; teamLp / boss pool within range; combat fields match the alive players
|
||||
|
||||
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
@@ -75,6 +77,12 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
if (ps.shop.slots.length || ps.offers.length || ps.bounties.length) fail(`${id}: eliminated but keeps shop/offers/bounties`);
|
||||
if (ps.funds || ps.pendingFunds) fail(`${id}: eliminated with funds ${ps.funds}+${ps.pendingFunds}`);
|
||||
}
|
||||
// 教鞭's personal choice (Match.offerBountyChoice): resolved before the prep ends — by its owner, the deadline or the bot
|
||||
const pc = ps.personalChoice;
|
||||
if (pc) {
|
||||
if (!ps.alive || ps.ready || m.phase !== PHASE.PREP || pc.round !== m.round) fail(`${id}: a personal choice outside its own prep (${m.phase} R${m.round}, offered in R${pc.round})`);
|
||||
if (!Array.isArray(pc.cards) || !pc.cards.length || pc.cards.length > 3 || new Set(pc.cards.map((c) => c && c.effectId)).size !== pc.cards.length) fail(`${id}: personal choice of ${pc.cards && pc.cards.length} cards`);
|
||||
}
|
||||
|
||||
// pieces
|
||||
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
|
||||
|
||||
@@ -231,6 +231,35 @@ export class MatchBoss {
|
||||
if (credit > 0) this._teamLpLoss(credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The authority's per-player split of the LP its field's enemy leaks cost (b.progress `leaksBy`: cumulative, per player,
|
||||
* the leader's own LP effects not in it) — the highest value seen per player. The field's perfect-payout bounties are
|
||||
* paid only where the final result agrees with it (`_bossLeaksAgree`).
|
||||
*/
|
||||
_noteLeaksBy(f, by) {
|
||||
if (!by || typeof by !== 'object') return;
|
||||
if (!f.leaksBy) f.leaksBy = {};
|
||||
for (const pid of f.players) {
|
||||
const v = Number(by[pid]);
|
||||
if (Number.isFinite(v) && v > (f.leaksBy[pid] || 0)) f.leaksBy[pid] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a boss field's b.result may be believed about one player's leaks before a perfect-payout bounty pays: the
|
||||
* result comes from a client (a server run is the truth itself) and does not contradict what that client reported while
|
||||
* it fought — LP it asked the team to pay must have been split into the players' own leaks and the leader's effects
|
||||
* (b.progress `leaksBy`; reports without the split explain nothing), and the player's leaked list must show at least the
|
||||
* leak LP reported for it (a leak is never moved to the other seat or dropped). A modified authority can still lie in
|
||||
* both; what the server cannot see it cannot check (the boss path never re-simulates).
|
||||
*/
|
||||
_bossLeaksAgree(f, res, pid) {
|
||||
if (!f.cc || f.resultSource !== 'client') return true;
|
||||
if (f.lpReported > 1e-9 && !f.leaksBy) return false;
|
||||
const shown = ((res.perPlayer[pid] && res.perPlayer[pid].leaked) || []).reduce((n, l) => n + (Number.isFinite(l.lpr) && l.lpr >= 0 ? l.lpr : 1), 0);
|
||||
return shown + 1e-6 >= ((f.leaksBy && f.leaksBy[pid]) || 0);
|
||||
}
|
||||
|
||||
/** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */
|
||||
_bossServerRun(f) {
|
||||
const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy });
|
||||
@@ -372,11 +401,15 @@ export class MatchBoss {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every
|
||||
* bounty used one of its battles, exactly like SETTLE does for normal rounds.
|
||||
* Bounties after a boss field: kill-bounty coins and — when `perfect` — the coins of every perfect-payout bounty (a
|
||||
* 战术特训 card: PRTS "若各自行动阶段就达成完美作战,获得N资金", the same sum SETTLE pays after a normal battle) go to
|
||||
* pending funds (spent in the Hidden Core's prep), and every bounty used one of its battles, exactly like SETTLE does
|
||||
* for normal rounds.
|
||||
* @param {boolean} perfect the player's own field was perfect and the team won (`_finishFinal`)
|
||||
*/
|
||||
_settleBossBounties(ps, pp) {
|
||||
const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
|
||||
_settleBossBounties(ps, pp, perfect) {
|
||||
let coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
|
||||
if (perfect) for (const b of ps.bounties) if (b.card.payout === 'perfect') coins += b.card.coin;
|
||||
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
|
||||
if (!ps.bounties.length) return;
|
||||
for (const b of ps.bounties) b.roundsLeft--;
|
||||
@@ -471,6 +504,9 @@ export class MatchBoss {
|
||||
_finishFinal(hidden, resultOf) {
|
||||
if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return;
|
||||
this._stopClientCombat();
|
||||
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
|
||||
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
|
||||
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
|
||||
for (const f of this.fields) {
|
||||
const res = resultOf(f);
|
||||
this._collectSimErrors(f, res);
|
||||
@@ -485,13 +521,17 @@ export class MatchBoss {
|
||||
ps.dirty(); // m.private.stats
|
||||
this._charDamageTickers(ps, pp);
|
||||
}
|
||||
if (pp && ps) this._settleBossBounties(ps, pp);
|
||||
if (pp && ps) {
|
||||
// the own battle counts as perfect when the team won, the result is a real one (not the stand-in of a field that
|
||||
// never reported) and the player's own field let no counted enemy through [ASSUMED: the boss battle is a battle
|
||||
// of the player's own — "下场作战" — so a perfect-payout card pays as after a normal one]
|
||||
const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false)
|
||||
&& this._bossLeaksAgree(f, res, pid);
|
||||
this._settleBossBounties(ps, pp, perfect);
|
||||
}
|
||||
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
|
||||
}
|
||||
}
|
||||
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
|
||||
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
|
||||
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
|
||||
this._syncTeamLp();
|
||||
this.deadline = 0;
|
||||
this.overtimeAt = 0;
|
||||
|
||||
@@ -24,8 +24,9 @@ export class MatchClientCombat {
|
||||
|
||||
_clearFieldTimers(f) {
|
||||
if (!f || !f.cc) return;
|
||||
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
|
||||
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer', 'verifyTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
|
||||
f.job = null;
|
||||
f.verifyJob = null;
|
||||
}
|
||||
|
||||
/** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */
|
||||
@@ -35,16 +36,22 @@ export class MatchClientCombat {
|
||||
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
|
||||
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
|
||||
let total = 0;
|
||||
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
|
||||
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part' && x.countInTotal !== false) total += Math.max(1, Math.floor(Number(x.count) || 1));
|
||||
return {
|
||||
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
|
||||
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
|
||||
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
|
||||
// progress: the field's own numbers as far as they are known before its first b.progress / result (`total` = the
|
||||
// spec's scheduled enemies — the capsule's denominator). `resolved` starts at **null**: it is only adopted from a
|
||||
// real report, so `resolved ?? killed` fallbacks (an unreported field, a synthetic result) keep working — a 0 here
|
||||
// would read as "nothing resolved yet" and could never be told apart from a reported 0.
|
||||
progress: { gt: 0, killed: 0, total, leaks: 0, resolved: null, done: false }, lastProgressAt: this.sched.now(),
|
||||
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
|
||||
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
|
||||
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
|
||||
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
|
||||
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
|
||||
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult); `leaksBy`: the authority's
|
||||
// per-player split of the LP its enemy leaks cost (b.progress, the highest value seen; null until one carried it),
|
||||
// which a result must not contradict before a perfect-payout bounty pays (_bossLeaksAgree)
|
||||
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, leaksBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +91,7 @@ export class MatchClientCombat {
|
||||
return {
|
||||
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind,
|
||||
spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec,
|
||||
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
|
||||
authoritative: !!(!f.done && !f.verifyJob && f.mode === 'client' && f.authority === pid && !watch),
|
||||
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
|
||||
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
|
||||
};
|
||||
@@ -121,6 +128,7 @@ export class MatchClientCombat {
|
||||
/** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */
|
||||
_armDeadline(f) {
|
||||
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
|
||||
if (f.verifyJob) return;
|
||||
const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60;
|
||||
const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS;
|
||||
f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => {
|
||||
@@ -225,7 +233,7 @@ export class MatchClientCombat {
|
||||
/** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */
|
||||
_authorityLost(ps, why) {
|
||||
for (const f of this.fields) {
|
||||
if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue;
|
||||
if (!f.cc || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) continue;
|
||||
if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why);
|
||||
else this._runOnServer(f, why);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export class MatchIntents {
|
||||
case 'g.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
|
||||
case 'g.destroy': return ps.destroy(msg.uid);
|
||||
case 'g.reward': return ps.pickReward(msg.idx);
|
||||
case 'g.choice': return this.pickCard(ps, msg.idx);
|
||||
case 'g.choice': return msg.choiceId !== undefined
|
||||
? this.pickPersonalChoice(ps, msg.idx, msg.choiceId)
|
||||
: this.pickCard(ps, msg.idx);
|
||||
case 'g.ready': return ps.setReady(!!msg.ready);
|
||||
case 'g.emote': return this.emote(ps, msg.id);
|
||||
// playerId: the player tapped (a shared field names two) — the watch preference (item 56)
|
||||
|
||||
@@ -43,8 +43,9 @@ export class MatchPhases {
|
||||
enterBandDraft() {
|
||||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||||
this.phase = PHASE.BAND_DRAFT;
|
||||
const order = this.order.map((p) => p.playerId);
|
||||
let order = this.order.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
order = this.humansFirst(order);
|
||||
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
|
||||
const untimed = this.soloUntimed;
|
||||
this.draft = {
|
||||
@@ -57,6 +58,22 @@ export class MatchPhases {
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/**
|
||||
* The co-op room option 「AI 队友最后选择」 (this.aiPicksLast, GitHub #338): every human seat before every AI seat, each
|
||||
* group in the order the draft drew (a stable partition applied AFTER the shuffle — no extra random draw: the order
|
||||
* with the option off is unchanged, and with it on every random stream stands where it would without it; only the
|
||||
* picks made in the new order can differ). A human is any seat that is not an AI seat (room.addBot): under AI 托管,
|
||||
* disconnected or departed it still counts as a human. Used by the strategy draft and the 机变 draft
|
||||
* (MatchSpDraft.enterSpDraft).
|
||||
* @param {string[]} order playerIds in drawn order
|
||||
* @returns {string[]}
|
||||
*/
|
||||
humansFirst(order) {
|
||||
if (!this.aiPicksLast) return order;
|
||||
const bot = (pid) => !!this.players.get(pid)?.isBot;
|
||||
return [...order.filter((pid) => !bot(pid)), ...order.filter(bot)];
|
||||
}
|
||||
|
||||
draftTurn() {
|
||||
const d = this.draft;
|
||||
if (!d) return null;
|
||||
@@ -197,7 +214,16 @@ export class MatchPhases {
|
||||
if (d.order.length - d.idx <= 1) return fail(ERR.BAD_TARGET, 'nobody to pass to');
|
||||
d.skipsLeft[ps.playerId]--;
|
||||
d.order.splice(d.idx, 1);
|
||||
d.order.push(ps.playerId);
|
||||
// the skipper goes to the end; with 「AI 队友最后选择」 to the end of the humans still to pick — behind them, ahead of
|
||||
// the AI seats — and to the very end only when no other human is left to pass to [ASSUMED: the option's intent,
|
||||
// humans before AI, kept through a skip; no source, a remake option]
|
||||
let at = d.order.length;
|
||||
if (this.aiPicksLast) {
|
||||
for (let j = d.order.length - 1; j >= d.idx; j--) {
|
||||
if (!this.players.get(d.order[j])?.isBot) { at = j + 1; break; }
|
||||
}
|
||||
}
|
||||
d.order.splice(at, 0, ps.playerId);
|
||||
this.startDraftTurn();
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,6 +62,7 @@ export class MatchPrep {
|
||||
};
|
||||
const ready = () => {
|
||||
if (!ps.ready) {
|
||||
this.autoPickPersonalChoice(ps, 'random');
|
||||
ps.resolveTemp();
|
||||
ps.setReady(true);
|
||||
}
|
||||
@@ -113,6 +114,7 @@ export class MatchPrep {
|
||||
if (this.phase !== PHASE.PREP) return;
|
||||
for (const ps of this.alivePlayers()) {
|
||||
if (ps.ready) continue;
|
||||
this.autoPickPersonalChoice(ps, 'random');
|
||||
ps.resolveTemp();
|
||||
ps.ready = true;
|
||||
ps.dirty();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { ERR } from '../../../shared/constants.js';
|
||||
import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
|
||||
import { HeadlessJob, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
|
||||
import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -18,17 +18,23 @@ export class MatchReports {
|
||||
_onProgress(ps, msg) {
|
||||
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
|
||||
const f = this._fieldByBattle(msg.battleId);
|
||||
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
if (!f || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
const p = f.progress;
|
||||
const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400);
|
||||
p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS));
|
||||
// the latest total (spawns never reached before the limit leave it at the end)
|
||||
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
|
||||
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
|
||||
// the HUD capsule's numerator as the authority reported it (shared/protocol.js b.progress `resolved`). Only a real
|
||||
// integer is adopted — an absent field leaves the null placeholder alone, so "not reported" and a reported 0 differ
|
||||
// (a `Number(null) === 0` here would defeat every `resolved ?? killed` fallback); a lower value is kept (the final
|
||||
// report drops never-spawned enemies from `total`, and a takeover may report a different number)
|
||||
if (Number.isInteger(msg.resolved)) p.resolved = Math.max(0, Math.min(1e5, msg.resolved));
|
||||
f.lastProgressAt = this.sched.now();
|
||||
if (f.kind === 'boss' || f.kind === 'hidden') {
|
||||
this._creditBoss(f, msg.bossDmg, msg.by);
|
||||
this._creditLp(f, msg.leaks, true);
|
||||
this._noteLeaksBy(f, msg.leaksBy);
|
||||
this._checkFinalEnd();
|
||||
this._broadcastPool(false);
|
||||
// 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz
|
||||
@@ -46,7 +52,7 @@ export class MatchReports {
|
||||
_onResult(ps, msg) {
|
||||
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
|
||||
const f = this._fieldByBattle(msg.battleId);
|
||||
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
if (!f || f.done || f.heldResult || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
|
||||
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
|
||||
if (!v.ok) {
|
||||
@@ -57,7 +63,7 @@ export class MatchReports {
|
||||
else this._runOnServer(f, 'invalid');
|
||||
return OK;
|
||||
}
|
||||
let result = v.result;
|
||||
const result = v.result;
|
||||
if (bossLike) {
|
||||
// the final b.progress normally carried everything; the result's per-player damage is a lower bound
|
||||
const by = {};
|
||||
@@ -91,13 +97,15 @@ export class MatchReports {
|
||||
return OK;
|
||||
}
|
||||
this._bossResultDamage(f, result);
|
||||
} else {
|
||||
result = this._verifyResult(f, result);
|
||||
}
|
||||
f.result = result;
|
||||
f.resultSource = 'client';
|
||||
this._fieldDone(f);
|
||||
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
|
||||
const accept = (verified) => {
|
||||
f.result = verified;
|
||||
f.resultSource = 'client';
|
||||
this._fieldDone(f);
|
||||
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
|
||||
};
|
||||
if (bossLike) accept(result);
|
||||
else this._verifyResult(f, result, accept);
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -107,27 +115,59 @@ export class MatchReports {
|
||||
}
|
||||
|
||||
/**
|
||||
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
|
||||
* ~1 battle in 8 in a later callback, mismatches are only logged).
|
||||
* SP_VERIFY: re-simulate in headless slices. 'all' waits before accepting (the server wins on a mismatch);
|
||||
* 'sample' accepts immediately and only logs differences for ~1 battle in 8, even after the field's phase ends.
|
||||
*/
|
||||
_verifyResult(f, result) {
|
||||
if (this.verifyMode === 'off') return result;
|
||||
const check = () => {
|
||||
const run = runHeadless(this._specBattle(f.spec), { players: f.players });
|
||||
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
|
||||
const server = mine.ok ? mine.result : run.result;
|
||||
this.verifyStats.checked++;
|
||||
if (resultDigest(server).hash !== resultDigest(result).hash) {
|
||||
this.verifyStats.mismatches++;
|
||||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
|
||||
return server;
|
||||
_verifyResult(f, result, accept) {
|
||||
if (this.verifyMode === 'off') { accept(result); return; }
|
||||
const required = this.verifyMode === 'all';
|
||||
const start = () => {
|
||||
const job = new HeadlessJob(this._specBattle(f.spec), { players: f.players, onError: (e) => this.reportError('verify', e) });
|
||||
if (required) {
|
||||
// The result has arrived: duplicate reports, disconnects and the old deadline must not start another run.
|
||||
f.verifyJob = job;
|
||||
this.cancel(f.deadlineTimer);
|
||||
f.deadlineTimer = null;
|
||||
f.rearmDeadline = false;
|
||||
}
|
||||
return null;
|
||||
const schedule = () => {
|
||||
const timer = this.later(0, slice);
|
||||
if (required) f.verifyTimer = timer;
|
||||
};
|
||||
const slice = () => {
|
||||
if (required) f.verifyTimer = null;
|
||||
if (this.disposed || this.ended) return;
|
||||
if (required && (f.verifyJob !== job || f.done || f.mode !== 'client' || !this.fields.includes(f))) return;
|
||||
let verified = result;
|
||||
try {
|
||||
if (!job.run(this.headlessSliceMs)) { schedule(); return; }
|
||||
const run = job.output();
|
||||
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
|
||||
const server = mine.ok ? mine.result : run.result;
|
||||
this.verifyStats.checked++;
|
||||
if (resultDigest(server).hash !== resultDigest(result).hash) {
|
||||
this.verifyStats.mismatches++;
|
||||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
|
||||
verified = server;
|
||||
}
|
||||
} catch (e) {
|
||||
this.reportError('verify', e);
|
||||
if (required) {
|
||||
this._clearFieldTimers(f);
|
||||
this._runOnServer(f, 'verify-error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (required) { f.verifyJob = null; accept(verified); }
|
||||
};
|
||||
if (Number.isFinite(this.headlessSliceMs)) schedule();
|
||||
else slice(); // Virtual schedulers retain their synchronous fast path.
|
||||
};
|
||||
if (this.verifyMode === 'all') return check() || result;
|
||||
if (required) { start(); return; }
|
||||
let h = 0;
|
||||
for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0;
|
||||
if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } });
|
||||
return result;
|
||||
// Sample jobs use only match-level timers: field completion must not cancel the diagnostic work.
|
||||
if (h % 8 === 0) this.later(0, start);
|
||||
accept(result);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
// server/match/match/spDraft.js — Match methods: the 机变 draft (SP_DRAFT: turn order, timers, AI picks, the card applied
|
||||
// by choices.js applyCard) and the rolls the meta effects ask for — bounties (addBounty), item ids (rollItemId) and
|
||||
// choices.json pools (rollPool).
|
||||
// by choices.js applyCard), the personal bounty choice of the Art 教鞭 (offerBountyChoice / pickPersonalChoice /
|
||||
// autoPickPersonalChoice: held by the player inside PREP — no phase, no timer of its own) and the rolls the meta effects
|
||||
// ask for — bounties (addBounty), item ids (rollItemId) and choices.json pools (rollPool).
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty } from '../choices.js';
|
||||
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
|
||||
import { weightedPick } from '../waves.js';
|
||||
import { botPickCard } from '../bot.js';
|
||||
import { OK, fail, DELAYS } from './common.js';
|
||||
|
||||
/** Cards of the personal choice (PRTS 卫戍协议:盟约 下半 §法术 教鞭: "于3个战术特训的悬赏任务中选择一项"). */
|
||||
const PERSONAL_OFFER_SIZE = 3;
|
||||
|
||||
export class MatchSpDraft {
|
||||
enterSpDraft() {
|
||||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
|
||||
const alive = this.alivePlayers();
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
const order = alive.map((p) => p.playerId);
|
||||
let order = alive.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
// 「AI 队友最后选择」 (GitHub #338): humans before AI seats, after the same shuffle (MatchPhases.humansFirst)
|
||||
order = this.humansFirst(order);
|
||||
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
||||
const untimed = this.soloUntimed;
|
||||
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
||||
@@ -106,6 +112,58 @@ export class MatchSpDraft {
|
||||
this.enterPrep();
|
||||
}
|
||||
|
||||
/**
|
||||
* 教鞭 (content/items/meta.js, ctx.offerBountyChoice): the Art opens a PERSONAL choice (act2autochess choice event
|
||||
* `hunter_band_1`, choiceType PERSONAL_CHOOSE) between up to three of `candidates` — drawn once from the meta rng, kept in
|
||||
* `ps.personalChoice` (m.private only) until the owner confirms one with `g.choice { idx, choiceId }`. It lives inside
|
||||
* PREP: no phase of its own and no timer — the prep's deadline resolves it (prepDeadline), Ready is refused while it is
|
||||
* pending (PlayerPrep.setReady). Refused — before an rng draw or an id is used, so the Art stays in the hand — when it
|
||||
* is not the player's PREP, a choice is already open or no card is left to offer.
|
||||
* @param {import('../PlayerState.js').PlayerState} ps @param {any[]} candidates cards.bounty entries
|
||||
* @param {string} sourceItemId the Art
|
||||
*/
|
||||
offerBountyChoice(ps, candidates, sourceItemId) {
|
||||
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||||
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
|
||||
if (ps.personalChoice) return fail(ERR.BAD_TARGET, '请先完成当前教鞭选择'); // i18n-ignore: developer error detail
|
||||
if (!candidates.length) return fail(ERR.BAD_TARGET, '当前没有可用的战术特训'); // i18n-ignore: developer error detail
|
||||
const cards = this.rngMeta.shuffle(candidates.slice()).slice(0, PERSONAL_OFFER_SIZE);
|
||||
ps.personalChoice = { id: `${this.battlePrefix}.choice.${this.nextUid()}`, round: this.round, sourceItemId, cards };
|
||||
ps.dirty();
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** g.choice { idx, choiceId }: the owner's pick of its open personal choice — the card becomes a bounty on its next battles. */
|
||||
pickPersonalChoice(ps, idx, choiceId) {
|
||||
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||||
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
|
||||
const pending = ps.personalChoice;
|
||||
if (!pending || pending.id !== choiceId || pending.round !== this.round) return fail(ERR.BAD_TARGET);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= pending.cards.length) return fail(ERR.BAD_TARGET);
|
||||
if (!this.addBounty(ps, pending.cards[idx])) return fail(ERR.BAD_TARGET);
|
||||
ps.personalChoice = null;
|
||||
ps.dirty();
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pick made for a player who did not make it. 'bot': an AI seat or a human under AI 托管 — the bounty scorer of the
|
||||
* 机变 draft (bot.js botPickCard: the expected pay minus the expected LP lost against the own board, the bots' seeded rng
|
||||
* only breaks ties); 'random': the prep's deadline and a seat the engine finishes by other means — one of the cards at
|
||||
* random from the meta rng, like a 机变 turn that runs out. Nothing pending: nothing happens, no rng draw.
|
||||
*/
|
||||
autoPickPersonalChoice(ps, mode) {
|
||||
const pending = ps.personalChoice;
|
||||
if (!pending) return undefined;
|
||||
const indices = pending.cards.map((c, i) => i);
|
||||
const idx = mode === 'bot'
|
||||
? botPickCard(this, ps, pending.cards.map((c) => bountyCard(this.gd, c)), indices)
|
||||
: this.rngMeta.pick(indices);
|
||||
return this.pickPersonalChoice(ps, idx, pending.id);
|
||||
}
|
||||
|
||||
addBounty(ps, card) {
|
||||
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
|
||||
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
|
||||
|
||||
@@ -126,8 +126,9 @@ export class MatchUnite {
|
||||
let live = null;
|
||||
if (f && f.cc) {
|
||||
if (f.mode === 'server' && f.timeline) {
|
||||
// the sample is [gt, killed, total, resolved(, left)]: a 联防 sample's `left` is its 5th element
|
||||
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
|
||||
live = sample && sample[4] && typeof sample[4] === 'object' ? sample[4] : null;
|
||||
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
||||
} else if (f && f.battle) {
|
||||
try { live = uniteLeft(f.battle); } catch { live = null; }
|
||||
|
||||
+62
-15
@@ -13,6 +13,25 @@ import { timelineAt } from '../fields.js';
|
||||
import { bossFieldPlacement } from '../finalAssault.js';
|
||||
import { battleProgress } from '../../sim/spec.js';
|
||||
|
||||
/** A reported capsule numerator clamped to its denominator, else null (unknown — never a fabricated 0). */
|
||||
const finiteOrNull = (v, cap = Infinity) => {
|
||||
if (v == null) return null; // Number(null) === 0: an unreported value must not read as "0 resolved"
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
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';
|
||||
@@ -134,25 +153,45 @@ export class MatchViews {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** m.public.fields[].progress: { killed, total, done } (teammates' waiting UI). */
|
||||
/**
|
||||
* m.public.fields[].progress: { killed, resolved, total, done } (teammates' waiting UI). `resolved` is the HUD
|
||||
* capsule's numerator — the field's own scheduled enemies knocked out or leaked (Battle.resolved / the reported
|
||||
* b.progress `resolved`); it is `null` (never 0) while unknown, so the client's `resolved ?? killed` fallback holds.
|
||||
* `total` is the capsule's denominator: only the enemies the round scheduled (runtime splits / summons — boss summons
|
||||
* included — are in neither part).
|
||||
*/
|
||||
_fieldProgress(f) {
|
||||
if (!f) return null;
|
||||
if (!f.cc) {
|
||||
const b = f.battle;
|
||||
if (!b) return null;
|
||||
return { killed: Number(b.killed) || 0, total: Number(b.total) || 0, done: !f.live };
|
||||
const total = Number(b.total) || 0;
|
||||
return { killed: Number(b.killed) || 0, resolved: finiteOrNull(b.resolved, total), total, done: !f.live };
|
||||
}
|
||||
if (f.done && f.result) {
|
||||
let killed = 0, total = 0;
|
||||
for (const pp of Object.values(f.result.perPlayer || {})) { killed += Number(pp && pp.killed) || 0; total += Number(pp && pp.total) || 0; }
|
||||
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; }
|
||||
return { killed, total, done: true };
|
||||
let killed = 0, total = 0, own = 0, ownKnown = true;
|
||||
for (const pp of Object.values(f.result.perPlayer || {})) {
|
||||
killed += Number(pp && pp.killed) || 0;
|
||||
total += Number(pp && pp.total) || 0;
|
||||
if (Number.isFinite(pp && pp.resolved)) own += Number(pp.resolved); else ownKnown = false;
|
||||
}
|
||||
// the FIELD's numerator (the validated client result / Battle.result(): what the capsule showed) — not the sum of the
|
||||
// players' own: an enemy that spawns on one half and leaks on the other (a 联防 lane, the boss pair's crossing routes)
|
||||
// is billed to one player's `total` and to the other's leak, so their own min(total, …) clamp it to 0
|
||||
let resolved = finiteOrNull(f.result.resolved, total);
|
||||
if (resolved == null && ownKnown) resolved = Math.min(total, own);
|
||||
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = finiteOrNull(f.progress.resolved, total); }
|
||||
return { killed, resolved, total, done: true };
|
||||
}
|
||||
if (f.mode === 'server' && f.timeline) {
|
||||
const [, killed, total] = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||
return { killed, total, done: false };
|
||||
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
|
||||
// the timeline sample carries `resolved` (Battle.resolved: knocked out + leaked among the field's own enemies),
|
||||
// never the report-driven `progress.leaks`, which would leave such a field at 0 forever
|
||||
const [, killed, total, resolved] = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||
return { killed, resolved: finiteOrNull(resolved, total), total, done: false };
|
||||
}
|
||||
return { killed: f.progress.killed, total: f.progress.total, done: false };
|
||||
const total = f.progress.total;
|
||||
return { killed: f.progress.killed, resolved: finiteOrNull(f.progress.resolved, total), total, done: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +243,8 @@ export class MatchViews {
|
||||
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
|
||||
}
|
||||
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). */
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
|
||||
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
|
||||
_prepBoardUnits(ps) {
|
||||
const units = [];
|
||||
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
|
||||
@@ -216,10 +256,11 @@ 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,
|
||||
area: 'board',
|
||||
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id,
|
||||
x: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
|
||||
@@ -229,6 +270,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;
|
||||
@@ -271,7 +313,11 @@ export class MatchViews {
|
||||
// (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44).
|
||||
// (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries
|
||||
// `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in)
|
||||
const benchUnit = (piece, i, y) => {
|
||||
// Each unit says where it waits (`area` 'hand' | 'temp'; a board unit 'board'): the bond popup of a watched player
|
||||
// counts it like the player's own (ui/watchBonds.js ownerBoard — a hand operator is owned and counts for 投资人 远见
|
||||
// 奇迹, a temp one counts for nothing; GitHub #385, PR #387). The tag survives the boss-half remap (_onBossHalf), the
|
||||
// row does not.
|
||||
const benchUnit = (piece, i, y, area) => {
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
|
||||
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
|
||||
const body = standIn || rec;
|
||||
@@ -279,7 +325,7 @@ export class MatchViews {
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op',
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id, area,
|
||||
name: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id,
|
||||
x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.stats.maxHp : 1,
|
||||
@@ -288,13 +334,14 @@ 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++) {
|
||||
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW);
|
||||
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW, 'hand');
|
||||
}
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW);
|
||||
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW, 'temp');
|
||||
}
|
||||
// `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
|
||||
// §2.2 "Teammates"; render/app.js enterBattle({ prep: true, nextEnemies }))
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { boardOrder, pieceDir } from '../board.js';
|
||||
import { snapFrame } from '../fields.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -238,38 +237,9 @@ export class MatchWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change).
|
||||
* Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a
|
||||
* change (review of PR #129). In a boss round's prep the pair partner's board is part of the view (item 51). */
|
||||
_prepScoutSig(ps) {
|
||||
const parts = [];
|
||||
const boardSig = (q) => {
|
||||
for (const { r, c, piece } of boardOrder(q.board)) {
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
|
||||
}
|
||||
};
|
||||
boardSig(ps);
|
||||
const m = this._bossMateOf(ps);
|
||||
if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); }
|
||||
for (let i = 0; i < ps.hand.length; i++) {
|
||||
const piece = ps.hand[i];
|
||||
if (!piece) continue;
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`);
|
||||
}
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
const piece = ps.temp[i];
|
||||
if (!piece) continue;
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
|
||||
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
|
||||
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current view
|
||||
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when that view changed.
|
||||
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
|
||||
*/
|
||||
_notifyPrepScouts(ps, { to = null } = {}) {
|
||||
@@ -277,12 +247,13 @@ export class MatchWatch {
|
||||
const fid = `n:${ps.playerId}`;
|
||||
const watchers = this.watchersOf(fid);
|
||||
if (!watchers.length) return;
|
||||
const sig = this._prepScoutSig(ps);
|
||||
const meta = this.prepFieldMeta(ps);
|
||||
// Deduplicate the public payload itself: effects and enemy previews can change without any piece moving (#346).
|
||||
const sig = JSON.stringify(meta);
|
||||
const changed = sig !== ps._prepScoutSig;
|
||||
ps._prepScoutSig = sig;
|
||||
const dest = changed ? watchers : (to ? [to] : []);
|
||||
if (!dest.length) return;
|
||||
const meta = this.prepFieldMeta(ps);
|
||||
for (const pid of dest) this.sendTo(pid, meta);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -93,11 +93,14 @@ export class PlayerEconomy {
|
||||
}
|
||||
|
||||
/**
|
||||
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the
|
||||
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
|
||||
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
|
||||
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
|
||||
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
|
||||
* The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
|
||||
* The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
|
||||
* stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
|
||||
* upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
|
||||
* the texts only name the slots — the tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」 —,
|
||||
* never a card that comes with them. 0.2.0 drew a card into each new slot instead (item 19 of the community report of
|
||||
* 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark; its follow-on, that the new card
|
||||
* follows the freeze toggle, was [ASSUMED]); until then the extra slots waited for the next roll as well.
|
||||
*/
|
||||
_openLevelSlots() {
|
||||
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
||||
@@ -106,9 +109,8 @@ export class PlayerEconomy {
|
||||
if (nChess <= layout.chess && nItem <= layout.item) return;
|
||||
const chess = old.slice(0, layout.chess);
|
||||
const items = old.slice(layout.chess);
|
||||
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; };
|
||||
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot()));
|
||||
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
|
||||
while (chess.length < nChess) chess.push(null);
|
||||
while (items.length < nItem) items.push(null);
|
||||
this.shop.slots = [...chess, ...items];
|
||||
this.shop.layout = { chess: chess.length, item: items.length };
|
||||
}
|
||||
@@ -209,7 +211,9 @@ export class PlayerEconomy {
|
||||
if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold');
|
||||
const piece = loc.piece;
|
||||
// its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room
|
||||
const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||||
// Removing the owner's summon stacks frees one slot per stack, regardless of its summon count.
|
||||
const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid);
|
||||
const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||||
if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment');
|
||||
this._detach(loc);
|
||||
this.removeTokensOf(piece.uid);
|
||||
|
||||
@@ -56,6 +56,7 @@ export class PlayerPrep {
|
||||
setReady(on) {
|
||||
if (!this.alive) return fail(ERR.ELIMINATED);
|
||||
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||||
if (on && this.personalChoice) return fail(ERR.BAD_TARGET, '请先完成教鞭选择'); // i18n-ignore: developer error detail
|
||||
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
|
||||
if (this.ready === !!on) return OK;
|
||||
this.ready = !!on;
|
||||
|
||||
@@ -65,6 +65,7 @@ export class PlayerRound {
|
||||
this._tempDue.clear();
|
||||
this.offers = [];
|
||||
this.bounties = [];
|
||||
this.personalChoice = null;
|
||||
this.shop.slots = [];
|
||||
this.funds = 0;
|
||||
this.pendingFunds = 0;
|
||||
@@ -118,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') {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { boardOrder, pieceDir } from '../board.js';
|
||||
import { bondList, offBondCounts } from '../bondsMeta.js';
|
||||
import { bountyText } from '../choices.js';
|
||||
import { bountyText, bountyCard } from '../choices.js';
|
||||
|
||||
export class PlayerViews {
|
||||
pieceView(p, rc = null) {
|
||||
@@ -66,7 +66,13 @@ export class PlayerViews {
|
||||
funds: this.funds,
|
||||
bandId: this.bandId,
|
||||
ready: this.ready,
|
||||
canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP,
|
||||
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,
|
||||
@@ -90,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)
|
||||
|
||||
Reference in new issue
Block a user