feedback5: merge W5E — a full hand refuses buys and reward picks, temp pieces fill freed slots (#82); 联防 plays on the official escaped maps (#41)

# Conflicts:
#	test/golden/fields.json
#	test/golden/matches.json
This commit is contained in:
sganggs committed 2026-10-05 18:19:36 +08:00
commit ece75b6853
37 files changed
+732 -265

No files matched your search

+6 -3
View File
@@ -2,9 +2,12 @@
//
// Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they
// return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2):
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys and reward picks —
// also one that would complete a merge at once (PRTS 卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作";
// GitHub #82) — and withdrawals, except one whose own summon stack frees a slot. Passive gains (merge results,
// grants, returned equipment) overflow into temp, and a free hand slot pulls them back in at once, right→left
// (_fillHandFromTemp at every recompute: "常规手牌区出现空位时自动移入"); temp blocks Ready ("直到溢出情况排除才可开始
// 进行作战").
// A temp piece is resolved (chess sold back to the pool, items destroyed, summon stacks removed — they come back at
// the next round start, grantTokensFor) at the deadline of the
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
+26 -15
View File
@@ -1432,6 +1432,11 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
// shed is pre-emptive (one slot is still free), so with only fresh singles it waits for the next prep.
const used = ps.hand.filter(Boolean).length;
if (used >= gd.benchSize - 1) sellWeakestHand(m, ps, { keepFresh: true });
// a full hand refuses every purchase, the third copy of a held pair too (PlayerState.buy, PRTS 卫戍协议/帮助 §手牌区):
// an affordable one sheds the weakest hand single first (like the shed above, never one of this prep)
if (freeSlot(ps.hand) < 0 && ps.shop.slots.some((s) => s && !s.sold && s.kind === 'chess' && ps.priceOf(s) <= ps.funds && ps.completesChessMerge(s.id))) {
sellWeakestHand(m, ps, { handOnly: true, keepFresh: true });
}
let best = -1;
let bestS = 0;
let bestMerges = false;
@@ -1443,7 +1448,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
let sc;
if (s.kind === 'chess') {
const merges = ps.completesChessMerge(s.id);
if ((freeSlot(ps.hand) < 0 || mergesOnly) && !merges) return;
if (freeSlot(ps.hand) < 0 || (mergesOnly && !merges)) return;
const base = gd.baseIdOf(s.id);
if (boardFull && !merges && ps.funds - price < reserve) return; // banked (坎诺特)
sc = buyScore(m, ps, s.id, ctx) - price;
@@ -1466,7 +1471,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
} else {
if (fillOnly) return;
if (!canUseItem(m, ps, { id: s.id })) return;
if (freeSlot(ps.hand) < 0 && !ps.completesItemMerge(s.id)) return;
if (freeSlot(ps.hand) < 0) return;
if (ps.funds - price < reserve && !ps.completesItemMerge(s.id)) return;
const carriers = [...ps.board.values()].filter((p) => p.kind === 'chess' && (p.items || []).length < gd.equipPerChess).length;
if (!carriers) return;
@@ -1805,34 +1810,40 @@ function equipItems(m, ps) {
/**
* Free a hand slot for an item worth keeping (突变细胞): sell the weakest single hand chess, else (on a bot's own seat
* only — a human's items under AI 托管 are never destroyed to make room, like freeHandSlot) destroy the cheapest other
* hand item, else sell the weakest hand chess even of a pair. false: the hand stays full (summon cards only).
* hand item, else sell the weakest hand chess even of a pair. `piece` (in temp): done once the freed slot pulled it in
* (PlayerState._fillHandFromTemp — resolveTemp asks for the rightmost temp piece, the next one a slot pulls in). false:
* the hand stays full (summon cards only).
*/
function makeHandRoom(m, ps) {
if (freeSlot(ps.hand) >= 0) return true;
if (sellWeakestHand(m, ps, { handOnly: true }) && freeSlot(ps.hand) >= 0) return true;
function makeHandRoom(m, ps, piece = null) {
const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece));
if (room()) return true;
if (sellWeakestHand(m, ps, { handOnly: true }) && room()) return true;
const gd = m.gd;
const junk = ps.isBot && !ps.autoplay ? ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id))
.sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0] : null;
if (junk && tryDo(() => ps.destroy(junk.uid)) && freeSlot(ps.hand) >= 0) return true;
return sellWeakestHand(m, ps, { handOnly: true, keepPairs: false }) && freeSlot(ps.hand) >= 0;
if (junk && tryDo(() => ps.destroy(junk.uid)) && room()) return true;
return sellWeakestHand(m, ps, { handOnly: true, keepPairs: false }) && room();
}
/**
* Empty the temp row before Ready. A freed hand slot pulls the temp pieces in by itself, rightmost first
* (PlayerState._fillHandFromTemp), so the pieces are decided right → left: the one at hand is always the next a freed
* slot takes. A chess: the weakest bench single is sold to make room (it may be the temp chess itself), else the temp
* chess is sold; an item: see below, else destroyed.
*/
function resolveTemp(m, ps) {
for (let i = 0; i < ps.temp.length; i++) {
for (let i = ps.temp.length - 1; i >= 0; i--) {
const p = ps.temp[i];
if (!p) continue;
const idx = freeSlot(ps.hand);
const idx = freeSlot(ps.hand); // (a recompute pulls it in; a state set up without one)
if (idx >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx }))) continue;
if (p.kind === 'chess') {
if (!sellWeakestHand(m, ps)) tryDo(() => ps.sell(p.uid));
else {
const j = freeSlot(ps.hand);
if (j >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }));
}
} else if (p.kind === 'item') {
// 突变细胞 comes back after every transformation (昆图斯's strategy item), and a human's item under AI 托管 is theirs:
// make room in the hand (sell a bench operator) rather than lose it — only a hand of nothing but items still drops it
if ((isMutationCell(m.gd, p.id) || ps.autoplay) && makeHandRoom(m, ps)) {
if ((isMutationCell(m.gd, p.id) || ps.autoplay) && makeHandRoom(m, ps, p)) {
if (!ps.temp.includes(p)) continue;
const j = freeSlot(ps.hand);
if (j >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }))) continue;
}
+6 -3
View File
@@ -4,9 +4,10 @@
// collectViolations(m) → string[] (empty when every invariant holds):
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
// pieces unique uids; hand 10 / temp 5 slots; chess carry ≤ equipPerChess known items; a normal piece holds ≤ 1
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
// two copies of a mergeable normal item); every token's owner chess is deployed
// pieces unique uids; hand 10 / temp 5 slots; temp holds pieces only while the hand is full (a free hand slot pulls
// a temp piece in, PlayerState._fillHandFromTemp); chess carry ≤ equipPerChess known items; a normal piece
// holds ≤ 1 copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one
// chess, never two copies of a mergeable normal item); every token's owner chess is deployed
// board tiles inside the own region and legal for the piece (a range-bound summon inside its owner's attack
// range); no items on the board; chess count ≤ deploy cap
// (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
@@ -52,6 +53,8 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (ps.alive && m.phase !== PHASE.SETTLE && ps.lp <= 0 && m.teamLp == null && m.round > 0 && ps.bandId) fail(`${id}: alive with lp ${ps.lp}`);
if (ps.hand.length !== gd.benchSize) fail(`${id}: hand has ${ps.hand.length} slots`);
if (ps.temp.length !== gd.tempSize) fail(`${id}: temp has ${ps.temp.length} slots`);
// 临时整备区 = overflow only (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入", GitHub #82)
if (ps.temp.some(Boolean) && ps.hand.some((x) => x == null)) fail(`${id}: a temp piece waits while a hand slot is free`);
if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`);
if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`);
if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`);
+7 -3
View File
@@ -5,7 +5,7 @@
import { PHASE, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { uniteBattleOpts, uniteSurvivors } from '../unite.js';
import { uniteBattleOpts, uniteSurvivors, uniteStageId } from '../unite.js';
import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
import { uniteLeft } from '../../sim/spec.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
@@ -38,7 +38,11 @@ export class MatchUnite {
this.runner.start();
}
/** Battle options of the 联防 field (helpers' carried end state, the leakers' enemies). */
/**
* Battle options of the 联防 field (helpers' carried end state, the leakers' enemies) on its own map — the escaped
* template's (unite.js uniteStageId; GitHub #41), the round's stage only when the data lacks it. The field meta and the
* client-run spec carry that stageId, so every viewer draws the 联防 map.
*/
_uniteOpts(plan, limit) {
const { wave, players } = uniteBattleOpts(this, plan, limit);
return {
@@ -46,7 +50,7 @@ export class MatchUnite {
kind: 'unite',
modeId: this.modeId,
round: this.round,
stageId: this.stageId,
stageId: uniteStageId(this.gd, plan.helpers.length) ?? this.stageId,
rect: { ...GEO.UNITE_RECT },
timeLimit: limit,
players,
+8 -2
View File
@@ -97,6 +97,12 @@ export class PlayerEconomy {
this.dirty();
}
/**
* g.buy {slot}. A full regular hand refuses every purchase, also one whose copy would complete a merge at once (PRTS
* 卫戍协议/帮助 §手牌区 "当常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入等通常情况下会增加手牌的
* 操作"; BWIKI 盟约 "当整备区达到上限后将无法进行涉及到整备区的操作(购买、撤回等)"; GitHub #82: the reporter's first-hand
* check in the official game, "哪怕是已经有了两个相同干员情况"). Until 0.1.3 such a purchase was let through.
*/
buy(slotIdx) {
const g = this._gate(); if (g) return g;
if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET);
@@ -113,13 +119,13 @@ export class PlayerEconomy {
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT);
if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL);
if (handFull) return fail(ERR.HAND_FULL);
this.spend(price);
slot.sold = true;
piece = this.acquireChess(slot.id, { source: 'buy' });
} else {
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL);
if (handFull) return fail(ERR.HAND_FULL);
this.spend(price);
slot.sold = true;
piece = this.acquireItem(slot.id, { source: 'buy' });
+30 -4
View File
@@ -1,7 +1,7 @@
// server/match/player/pieces.js — PlayerState methods: piece bookkeeping — new pieces, lookup by uid (board / hand /
// temp / equipped), the chess locations, detach and stow (hand first, overflow temp), the per-piece round counters
// (拉普兰德), pool copies back to the shared pool, and summon stacks (removal, lifting, counts, the stack granted for an
// owner).
// temp / equipped), the chess locations, detach and stow (hand first, overflow temp; a free hand slot pulls a temp piece
// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool, and summon stacks
// (removal, lifting, counts, the stack granted for an owner).
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
@@ -68,7 +68,8 @@ export class PlayerPieces {
/**
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null.
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null. A piece put into temp while a hand
* slot is free (`toTemp`) moves into the hand at the next recompute (_fillHandFromTemp).
*/
stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) {
if (!toTemp) {
@@ -85,6 +86,31 @@ export class PlayerPieces {
return null;
}
/**
* PRTS 卫戍协议/帮助 §手牌区 "溢出单位会自动进入临时手牌区,常规手牌区出现空位时自动移入" (GitHub #82; until 0.1.3 the
* player had to drag them back): every free regular hand slot takes a temp piece at once. Order [ASSUMED] (PRTS names
* none): the temp row empties in the order it fills — right→left, the way stow fills it (freeSlot), so the piece that
* overflowed first moves first — and each piece takes the hand's next free slot right→left ("被发送至手牌区的物资优先
* 从右到左填充空位"). recompute() runs it, so it follows every change that frees a slot: a sale, a deployment from the
* hand, a merge that consumed hand copies, an item equipped / destroyed / used from the hand, a summon stack removed
* with its owner, an effect's destroyPiece… A piece that leaves temp is no longer due (tempDue). Returns the number
* moved.
* @returns {number}
*/
_fillHandFromTemp() {
let moved = 0;
for (let j = this.temp.length - 1; j >= 0; j--) {
const p = this.temp[j];
if (!p) continue;
const i = freeSlot(this.hand);
if (i < 0) break;
this._detach({ piece: p, area: 'temp', idx: j });
this.hand[i] = p;
moved++;
}
return moved;
}
/**
* Per-piece counter of the current round (`piece.meta.round` = { r, n: { key: count } }): 0 for a key not counted yet
* this round. The counters belong to the operator: a move keeps them, a new piece (bought, granted, transformed)
+7 -2
View File
@@ -16,6 +16,11 @@ export class PlayerPrep {
return null;
}
/**
* g.reward {idx}: the pick of the first queued offer (promotion reward, a strategy's special refresh, an item offer).
* A pick is a 招募 like a purchase: a full regular hand refuses it, also when it would complete a merge (PRTS
* 卫戍协议/帮助 §手牌区, see buy; GitHub #82).
*/
pickReward(idx) {
const g = this._gate(); if (g) return g;
const offer = this.offers[0];
@@ -26,14 +31,14 @@ export class PlayerPrep {
const handFull = freeSlot(this.hand) < 0;
if (slot.kind === 'item') {
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL);
if (handFull) return fail(ERR.HAND_FULL);
} else {
const rec = this.gd.chess(slot.id);
if (!rec) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT);
if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL);
if (handFull) return fail(ERR.HAND_FULL);
}
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
if (price > this.funds) return fail(ERR.NO_FUNDS);
+4 -2
View File
@@ -1,7 +1,7 @@
// server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending
// coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality,
// out-of-range summons, bonds), the bond views — and battleInput (the player's PlayerBattleInput: board units with
// their loadout, carried 联防 state, the reached layers).
// out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's
// PlayerBattleInput: board units with their loadout, carried 联防 state, the reached layers).
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
@@ -74,6 +74,8 @@ export class PlayerRound {
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
if (this._legalityStale) this._evictIllegal();
this._liftOutOfRange();
// a free regular hand slot pulls a temp piece in (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入")
this._fillHandFromTemp();
this.bonds = computeBonds(this.gd, this);
this.dirty();
}
+21 -1
View File
@@ -5,7 +5,11 @@
// players chosen by most units on the field (downed included) > has an active bond (存疑) > most undowned units, then
// seat; with 2 helpers the one ranked first by most units > active bond > Σ active bond layers (存疑) > most undowned
// units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep the HP ratio and
// left half (colOffset 0); a lone helper plays escaped_single on its own field. The field is the template's own map
// (uniteStageId: data/stages.json act1autochess_escaped_single / _multi, kind 'unite' — two road halves joined at col 10,
// the right half's gates at col 18; GitHub #41: until 0.1.3 it was the round's stage, its water, crates and devices
// included), every helper's pieces on their prep tiles ("按休整期位置部署在场"), the right-hand one shifted 8 columns
// (the official maps' halves, = the stage config's player_map_lr_offset 8). Their operators keep the HP ratio and
// the SP (技力, stored charges included) from the end of their own combat, nothing else — a skill still running then
// enters switched off (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState `{ hpPct, sp }`, "阵地以其当前状态";
// community report #34 / GitHub #82: it used to restart for free). An operator knocked out at the end of its own combat (alive false) is fielded with
@@ -115,6 +119,22 @@ export function helperOrder(m, perfects, results) {
return select.sort((a, b) => S(b).units - S(a).units || (S(b).active - S(a).active) || S(b).layers - S(a).layers || S(b).standing - S(a).standing || a.seat - b.seat);
}
/**
* The map of the 联防 field (GitHub #41): act2autochess constData escapedBattleTemplateMapSinglePlayer (1 helper) /
* MultiPlayer (2 helpers) name one level for both the 联防 wave and its map — config.unite.templates[n], built into
* data/stages.json as the stage of the same id (kind 'unite'). null when the data has no such stage (degraded data:
* the caller keeps the round's stage).
* @param {import('./gamedata.js').GameData} gd
* @param {number} helperCount
* @returns {string|null}
*/
export function uniteStageId(gd, helperCount) {
const templates = gd.unite.templates || {};
const id = templates[String(helperCount)] ?? templates[helperCount] ?? null;
const st = id ? gd.stage(id) : null;
return st && st.kind === 'unite' ? id : null;
}
/** Battle options for the unite field (without data/logger, added by the match). */
export function uniteBattleOpts(m, plan, timeLimit) {
const wave = buildUniteWave(m.gd, plan.leaked, plan.helpers.length, timeLimit);