- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
104 lines
5.6 KiB
JavaScript
104 lines
5.6 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); }
|
|
|
|
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
|
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
|
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
|
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
|
|
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
|
|
let refreshPending = false;
|
|
const refreshBusyListeners = new Set();
|
|
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
|
|
|
/**
|
|
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
|
* @param {(busy: boolean) => void} fn
|
|
* @returns {() => void} unsubscribe
|
|
*/
|
|
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.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: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
|
|
rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
|
|
rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
|
|
cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
|
|
band: (bandId) => act('g.band', { bandId }),
|
|
bandSkip: () => act('g.bandSkip'),
|
|
buy: (slot) => act('g.buy', { slot }),
|
|
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
|
refresh: () => {
|
|
if (refreshPending) return Promise.resolve(false);
|
|
refreshPending = true;
|
|
emitRefreshBusy();
|
|
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
|
},
|
|
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' }),
|
|
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
|
|
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
|
|
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
|
|
};
|