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 atae22335c): 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:
commit
2de2feee3c
57 files changed
+1339
-276
No files matched your search
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user