diff --git a/client/src/game/__tests__/diceContest.test.ts b/client/src/game/__tests__/diceContest.test.ts index 240ee7f9be..221bec7dc4 100644 --- a/client/src/game/__tests__/diceContest.test.ts +++ b/client/src/game/__tests__/diceContest.test.ts @@ -153,6 +153,29 @@ describe("flashCompletedScry", () => { expect(useUiStore.getState().scryOutcome).toBeNull(); }); + it("queues every completed scry in an event batch in order", () => { + flashCompletedScry([scry(1, 1, 2), scry(2, 3, 0)]); + + expect(useUiStore.getState().scryOutcome).toEqual({ + playerId: 1, + topCount: 1, + bottomCount: 2, + }); + expect(useUiStore.getState().scryOutcomeQueue).toEqual([ + { playerId: 2, topCount: 3, bottomCount: 0 }, + ]); + + vi.advanceTimersByTime(4_000); + expect(useUiStore.getState().scryOutcome).toEqual({ + playerId: 2, + topCount: 3, + bottomCount: 0, + }); + + vi.advanceTimersByTime(4_000); + expect(useUiStore.getState().scryOutcome).toBeNull(); + }); + it("does not show a result for an incomplete or unrelated player action", () => { flashCompletedScry([ { diff --git a/client/src/game/diceContest.ts b/client/src/game/diceContest.ts index 5b2c0c077a..d518de936f 100644 --- a/client/src/game/diceContest.ts +++ b/client/src/game/diceContest.ts @@ -4,14 +4,6 @@ import { useUiStore } from "../stores/uiStore"; type DieRolledEvent = Extract; type CoinFlippedEvent = Extract; type StartingPlayerContestEvent = Extract; -type ScryEvent = Extract; -type CompletedScryEvent = ScryEvent & { - data: ScryEvent["data"] & { - action: "Scry"; - scry_top_count: number; - scry_bottom_count: number; - }; -}; /** * Fire the starting-player contest overlay from a game-start event batch. @@ -90,17 +82,19 @@ export function flashInGameRolls(events: GameEvent[]): void { * no display effect. */ export function flashCompletedScry(events: GameEvent[]): void { - const scry = events.find( - (event): event is CompletedScryEvent => - event.type === "PlayerPerformedAction" && - event.data.action === "Scry" && - event.data.scry_top_count !== undefined && - event.data.scry_bottom_count !== undefined, - ); - if (!scry) return; - useUiStore.getState().flashScryOutcome({ - playerId: scry.data.player_id, - topCount: scry.data.scry_top_count, - bottomCount: scry.data.scry_bottom_count, - }); + for (const scry of events) { + if ( + scry.type !== "PlayerPerformedAction" || + scry.data.action !== "Scry" || + scry.data.scry_top_count === undefined || + scry.data.scry_bottom_count === undefined + ) { + continue; + } + useUiStore.getState().flashScryOutcome({ + playerId: scry.data.player_id, + topCount: scry.data.scry_top_count, + bottomCount: scry.data.scry_bottom_count, + }); + } } diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 5c0c69718c..83d394a9e9 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -837,6 +837,9 @@ async function processRemoteUpdateInner( useUiStore.getState().flashTurnBanner(bannerText, turnNumber); } + flashInGameRolls(events); + flashCompletedScry(events); + // 3. Normalize events into animation steps const pacingMultipliers = usePreferencesStore.getState().pacingMultipliers; const steps = normalizeEvents(events, { pacingMultipliers }); diff --git a/client/src/stores/uiStore.ts b/client/src/stores/uiStore.ts index a666f88b2f..748763eb87 100644 --- a/client/src/stores/uiStore.ts +++ b/client/src/stores/uiStore.ts @@ -121,11 +121,11 @@ function flushPendingShow(): void { apply(); } -// Serial FIFO for dice/coin overlays. Full-screen "moment" overlays are mutually -// exclusive (you can't show two rolls at once), so simultaneous/back-to-back -// rolls play one after another rather than clobbering. `diceRoll` is the active -// payload; `diceRollQueue` holds the pending ones. Distinct from the board-event -// step queue (animationStore) — that coordinates spatial per-object effects. +// Serial FIFOs for transient game outcomes. Full-screen dice/coin overlays and +// board-visible scry notices each show one payload at a time, so simultaneous +// outcomes play in event order instead of clobbering one another. The queues are +// distinct from the board-event step queue (animationStore), which coordinates +// spatial per-object effects. let diceAdvanceTimer: ReturnType | null = null; let scryOutcomeTimer: ReturnType | null = null; @@ -167,6 +167,25 @@ function advanceDiceQueue(): void { scheduleDiceAdvance(next); } +function scheduleScryOutcomeAdvance(): void { + if (scryOutcomeTimer) { + clearTimeout(scryOutcomeTimer); + } + scryOutcomeTimer = setTimeout(advanceScryOutcomeQueue, 4_000); +} + +function advanceScryOutcomeQueue(): void { + const queue = useUiStore.getState().scryOutcomeQueue; + if (queue.length === 0) { + useUiStore.setState({ scryOutcome: null }); + scryOutcomeTimer = null; + return; + } + const next = queue[0]; + useUiStore.setState({ scryOutcome: next, scryOutcomeQueue: queue.slice(1) }); + scheduleScryOutcomeAdvance(); +} + interface UiStoreState { selectedObjectId: ObjectId | null; hoveredObjectId: ObjectId | null; @@ -200,8 +219,10 @@ interface UiStoreState { /** Pending dice/coin overlays behind the active one. Simultaneous or * back-to-back rolls play serially instead of clobbering. */ diceRollQueue: DiceRollPayload[]; - /** Latest engine-authored public scry result, temporarily shown on board. */ + /** Active engine-authored public scry result, temporarily shown on board. */ scryOutcome: ScryOutcomePayload | null; + /** Pending public scry notices, shown FIFO after the active outcome. */ + scryOutcomeQueue: ScryOutcomePayload[]; focusedOpponent: number | null; pendingAbilityChoice: { objectId: ObjectId; actions: ObjectAction[] } | null; /** When non-null, the AttachmentsDialog is open showing every Aura @@ -313,9 +334,9 @@ interface UiStoreActions { /** Dismiss the current dice/coin overlay immediately (user tap-to-skip), * advancing to the next queued roll if any. */ skipDiceRoll: () => void; - /** Surface one public scry outcome for a short, non-interactive board notice. */ + /** Queue one public scry outcome for a short, non-interactive board notice. */ flashScryOutcome: (payload: ScryOutcomePayload) => void; - /** Clear a visible scry result on a game-session boundary. */ + /** Clear the active and queued scry results on a game-session boundary. */ resetScryOutcome: () => void; setFocusedOpponent: (id: number | null) => void; setPendingAbilityChoice: (choice: { objectId: ObjectId; actions: ObjectAction[] } | null) => void; @@ -377,6 +398,7 @@ export const useUiStore = create()((set, get) => ({ diceRoll: null, diceRollQueue: [], scryOutcome: null, + scryOutcomeQueue: [], focusedOpponent: null, pendingAbilityChoice: null, enchantmentsDialogPlayer: null, @@ -687,19 +709,19 @@ export const useUiStore = create()((set, get) => ({ advanceDiceQueue(); }, flashScryOutcome: (payload) => { - if (scryOutcomeTimer) clearTimeout(scryOutcomeTimer); - set({ scryOutcome: payload }); - scryOutcomeTimer = setTimeout(() => { - scryOutcomeTimer = null; - set({ scryOutcome: null }); - }, 4_000); + if (get().scryOutcome === null) { + set({ scryOutcome: payload }); + scheduleScryOutcomeAdvance(); + } else { + set({ scryOutcomeQueue: [...get().scryOutcomeQueue, payload] }); + } }, resetScryOutcome: () => { if (scryOutcomeTimer) { clearTimeout(scryOutcomeTimer); scryOutcomeTimer = null; } - set({ scryOutcome: null }); + set({ scryOutcome: null, scryOutcomeQueue: [] }); }, setFocusedOpponent: (id) => set({ focusedOpponent: id }), setPendingAbilityChoice: (choice) => set({ pendingAbilityChoice: choice }), diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index c7851761c1..d51d74cdd9 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -2072,6 +2072,7 @@ async fn handle_socket( loop { tokio::select! { + biased; Some(msg) = rx.recv() => { if let Ok(json) = serde_json::to_string(&msg) { if socket.send(Message::text(json)).await.is_err() { @@ -3801,6 +3802,7 @@ async fn handle_full_game_submission( db: &SharedDb, draft_state: &SharedDraftState, connections: &SharedConnections, + tx: &mpsc::UnboundedSender, game_db: &SharedGameDb, game_spectators: &SharedGameSpectators, // Read-only: this handler reads `game_code`, `player_token`, and `player_id` @@ -3864,9 +3866,7 @@ async fn handle_full_game_submission( Ok(human_result) => { if is_zero_count_debug_create { drop(mgr); - if let Ok(json) = serde_json::to_string(&ServerMessage::ActionNoOp) { - let _ = socket.send(Message::text(json)).await; - } + let _ = tx.send(ServerMessage::ActionNoOp); return; } let human_revision = mgr @@ -4478,6 +4478,7 @@ async fn handle_client_message( db, draft_state, connections, + tx, game_db, game_spectators, identity, @@ -4493,6 +4494,7 @@ async fn handle_client_message( db, draft_state, connections, + tx, game_db, game_spectators, identity,