feedback7: merge fb7-dobermann — 教鞭 is the official three-choice (PR #353 by @Sukvii, the owner's decision), the Final Assault / Hidden Core pay a 战术特训 card's perfect coins (guarded by b.progress leaksBy), the bots put zero-range heavy guards on the road first (PR #339 by @IceCodeNew, placement only)

- Conflicts (the branch started at ae22335c): spec.js / protocol.js b.progress (resolved from fb7-ui-backlog + leaksBy),
  gameLogic.js / game.js exports (device tips + normalizePersonalChoice), docs/PLAYING.md / design/network.md lines
  merged against the base, META.md (画卷 note + 教鞭), test/docs-consistency.test.js pin (both fields).
- Grok review: MERGE AFTER FIXES — the HIGH (a forged boss result paid the cards) fixed on the branch (66a228c6), the
  META wording (1ab12763).
- Golden: matches 10 (the 杜宾 bot picks and the guard placement); union: coop3-ABYSS-5 differs from HEAD only in ai_2's
  dmgDealt (1238011 → 1186269), no outcome change — HEAD's side had moved it, the new placement applies on that path.
- Broad node --test 5623 (5621 pass, 0 fail, 2 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
sganggs committed 2026-10-08 22:11:49 +08:00
commit 2de2feee3c
57 files changed
+1339 -276

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) */
+19 -5
View File
@@ -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()];
+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)];
+48 -8
View File
@@ -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;
+4 -2
View File
@@ -48,8 +48,10 @@ export class MatchClientCombat {
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,
};
}
+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();
+1
View File
@@ -34,6 +34,7 @@ export class MatchReports {
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
+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) });
@@ -108,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)
+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) and its equipment. The copied items
// are gained unequipped, like any gained item — the hand, overflow temp, destroyed with the usual toast when both are
+14 -3
View File
@@ -264,7 +264,9 @@ export function uniteLeft(battle) {
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, the capsule's
* `resolved` (this field's own scheduled enemies knocked out or leaked — Battle.resolved), counted leaks
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* (normal / unite), the boss pool damage of this field, `leaksBy` of a boss / hidden field — per player, the LP the
* enemies that reached its goal cost (every leak entry the result will show, × its `lpr`; the field's LP meter minus
* their sum is the leader's own "扣除目标生命" effects) — and, unite fields, `left` (uniteLeft: each leaker's enemies
* still standing).
*/
export function battleProgress(battle) {
@@ -290,6 +292,11 @@ export function battleProgress(battle) {
const left = uniteLeft(battle);
if (left) out.left = left;
}
if (battle && (battle.kind === 'boss' || battle.kind === 'hidden')) {
const leaksBy = {};
for (const pid of Object.keys(pp).slice(0, 4)) leaksBy[pid] = Math.min(1e6, leakEntries(pp[pid]).reduce((n, l) => n + leakLpr(l), 0));
out.leaksBy = leaksBy;
}
return out;
}
@@ -335,6 +342,10 @@ const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null);
const keyOr = (v) => (isKey(v) ? v : null);
/** A player's leak entries as the b.result carries them (compactResult: a valid enemy key, at most 400). */
const leakEntries = (p) => cap(p && p.leaked, 400).filter((l) => l && isKey(l.enemyKey));
/** What one leak entry costs the team on the wire: its `lpr` (0 … 1000), 1 when absent. */
const leakLpr = (l) => Math.max(0, Math.min(1000, fnum(l && l.lpr, 1)));
function compactMods(m) {
if (!m || typeof m !== 'object') return null;
@@ -365,8 +376,8 @@ export function compactResult(res) {
// NOT clamped to `total`: the denominator counts only the round's own scheduled enemies (Battle.resolved)
killed: Math.max(0, Math.trunc(fnum(p.killed))),
total,
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
leaked: leakEntries(p).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: leakLpr(l), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
if (l.boss) o.boss = true;
if (l.spawned) o.spawned = true;
return o;