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).
76 lines
3.5 KiB
JavaScript
76 lines
3.5 KiB
JavaScript
// In-match intents (`g.*`, DESIGN §8.2) with uniform error handling: every request goes through
|
|
// `act()`, which plays a UI sound on success, shows the ERR_TEXT toast on failure and never throws.
|
|
// The UI never mutates state optimistically — it waits for the next m.private / m.public push.
|
|
// `net.request` is looked up at call time so the dev mock harness can stub it.
|
|
|
|
import { net } from '../net.js';
|
|
import { toastError } from './toasts.js';
|
|
import { audio } from '../audio.js';
|
|
|
|
const SUCCESS_SFX = {
|
|
'g.buy': 'buy', 'g.sell': 'sell', 'g.refresh': 'refresh', 'g.freeze': 'freeze', 'g.levelUp': 'levelup',
|
|
'g.move': 'drop', 'g.equip': 'equip', 'g.art': 'artPlace', 'g.reward': 'pick', 'g.choice': 'pick',
|
|
'g.band': 'confirm', 'g.bandSkip': 'back', 'g.infoReady': 'ready', 'g.emote': 'emote', 'g.destroy': 'back',
|
|
};
|
|
|
|
let inflight = 0;
|
|
const busyListeners = new Set();
|
|
const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(inflight); } catch { /* ignore */ } } };
|
|
|
|
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
|
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
|
|
|
/**
|
|
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
|
* @param {string} t
|
|
* @param {object} [fields]
|
|
* @param {{ sfx?: string|false, quiet?: boolean }} [opts]
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function act(t, fields = {}, opts = {}) {
|
|
inflight += 1;
|
|
emitBusy();
|
|
try {
|
|
await net.request(t, fields);
|
|
const s = opts.sfx === undefined ? SUCCESS_SFX[t] : opts.sfx;
|
|
if (s) audio.sfx(s);
|
|
return true;
|
|
} catch (err) {
|
|
if (!opts.quiet) {
|
|
toastError(err);
|
|
audio.sfx('error', { volume: 0.6 });
|
|
}
|
|
return false;
|
|
} finally {
|
|
inflight = Math.max(0, inflight - 1);
|
|
emitBusy();
|
|
}
|
|
}
|
|
|
|
export const actions = {
|
|
infoReady: () => act('g.infoReady'),
|
|
band: (bandId) => act('g.band', { bandId }),
|
|
bandSkip: () => act('g.bandSkip'),
|
|
buy: (slot) => act('g.buy', { slot }),
|
|
refresh: () => act('g.refresh'),
|
|
freeze: () => act('g.freeze'),
|
|
levelUp: () => act('g.levelUp'),
|
|
sell: (uid) => act('g.sell', { uid }),
|
|
// `dir` (UP|RIGHT|DOWN|LEFT): the direction chosen with the deploy wheel (research 09 §6.1 — g.move / g.art carry it;
|
|
// the server defaults to RIGHT when absent)
|
|
move: (uid, to, dir) => act('g.move', dir ? { uid, to, dir } : { uid, to }),
|
|
// `replaceUid`: the equipped item the replace dialog picked (ui/equipReplace.js) — it is destroyed; absent ⇒ none needed
|
|
equip: (itemUid, targetUid, replaceUid) => act('g.equip', Number.isInteger(replaceUid) ? { itemUid, targetUid, replaceUid } : { itemUid, targetUid }),
|
|
art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
|
|
destroy: (uid) => act('g.destroy', { uid }),
|
|
reward: (idx) => act('g.reward', { idx }),
|
|
choice: (idx, choiceId) => act('g.choice', choiceId === undefined ? { idx } : { idx, choiceId }),
|
|
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
|
|
emote: (id) => act('g.emote', { id }, { quiet: true }),
|
|
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
|
|
watch: (fieldId, playerId = null) => act('g.watch', typeof playerId === 'string' && playerId ? { fieldId, playerId } : { fieldId }, { sfx: 'tab' }),
|
|
autoplay: (on) => act('g.autoplay', { on }),
|
|
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
|
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
|
};
|