feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move

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

No files matched your search

+7
View File
@@ -201,6 +201,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) */
+8 -3
View File
@@ -1329,6 +1329,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 +1367,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 +1809,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 +1827,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()];
+1 -2
View File
@@ -25,8 +25,7 @@
// 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';
+1
View File
@@ -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)
+8
View File
@@ -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)];
+3 -1
View File
@@ -31,7 +31,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)
+2
View File
@@ -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();
+59 -3
View File
@@ -1,14 +1,18 @@
// 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) });
@@ -106,6 +110,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)
+1
View File
@@ -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;
+1
View File
@@ -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;
+8 -2
View File
@@ -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,
+32 -24
View File
@@ -13,17 +13,19 @@
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
// the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
// funds").
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1, choiceType PERSONAL_CHOOSE}: a personal choice of
// one of THREE 战术特训 cards (choices.json cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就
// 达成完美作战,获得1资金") whose enemy the mode can field — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭
// "使用后销毁,于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", and 杜宾 加练!
// "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金". onArt → ctx.offerBountyChoice
// (Match.offerBountyChoice): the three cards go to the owner alone, who confirms one inside the PREP
// (g.choice with the offer's id); the prep's deadline or an AI seat resolves it (docs/META.md §2.5). Until
// 0.2.2 the engine took one of the three at random (user playtest #6 item 4 review; the built-in drew any
// tier ≤ II kill bounty, and research 04 §7 [ASSUMED] the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04, and PRTS
// lists no pool): still [ASSUMED simplification] 3 band bounties `enemyeffect_b_*` (research 04 §7 [ASSUMED]:
// "adds 1 enemy to your next battle, killer gets `coin` funds") whose enemy can appear in the mode, one of them
// taken at random; the built-in runs when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
@@ -48,7 +50,7 @@ function pieceIsMember(ctx, piece, bondId) {
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
/** Cards drawn before “神秘顾客” takes one at random. */
const OFFER_SIZE = 3;
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) {
@@ -103,18 +105,24 @@ export function registerMeta(registry) {
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, family);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
}
// 教鞭 — trap_create_self_choice {choice_event}: the server keeps the offered 战术特训 cards until confirmed.
wrap(registry, 'chess_item_6_03_m', () => ({
onArt(ctx, ev) {
const result = ctx.offerBountyChoice(bountyCards(ctx, isTraining), ev.item.id);
if (!result.ok) { ev.error = result.error; ev.detail = result.detail; }
},
}));
// “神秘顾客” — a random band bounty (enemyeffect_b_*).
wrap(registry, 'chess_item_6_01_m', (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, isBandBounty);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied
// normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04