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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user