Files
Dune/lib/action-notice-queue.ts
Quintine f29d05620c Keep popup instructions readable until continued
Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision.

Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
2026-09-22 09:51:55 +10:00

79 lines
3.0 KiB
TypeScript

import type { FactionId } from '@/game/catalog';
export type AutomaticActionEvent = {
id: string;
seq: number;
faction: FactionId;
name: string;
};
export type QueuedActionNotice = AutomaticActionEvent & { count: number };
export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] };
export type ActionNoticePresentation = ActionNoticeCursor & { active: QueuedActionNotice | null };
type ActionNoticeUpdate =
| { type: 'events'; events: readonly AutomaticActionEvent[]; enabled: boolean; visible: boolean }
| { type: 'continue' }
| { type: 'hide' }
| { type: 'clear' };
/** Existing history establishes a silent baseline, including after refresh. */
export function initialActionNotices(events: readonly AutomaticActionEvent[]): ActionNoticePresentation {
return { seenSeq: events.reduce((last, event) => Math.max(last, event.seq), -1), pending: [], active: null };
}
/** Local presentation only: reading or dismissing a notice never advances the game. */
export function updateActionNotices(state: ActionNoticePresentation, update: ActionNoticeUpdate): ActionNoticePresentation {
if (update.type === 'hide') return { ...state, pending: [] };
if (update.type === 'clear') return { ...state, active: null, pending: [] };
if (update.type === 'continue') return { ...state, active: state.pending[0] ?? null, pending: state.pending.slice(1) };
const active = update.enabled ? state.active : null;
const cursor = advanceActionNotices(state, update.events, {
active: !!active,
show: update.enabled && update.visible,
});
// New updates never replace text that the player has not dismissed.
return active
? { ...cursor, active }
: { ...cursor, active: cursor.pending[0] ?? null, pending: cursor.pending.slice(1) };
}
/** Consume every observed sequence, even when presentation is disabled. */
export function advanceActionNotices(
cursor: ActionNoticeCursor,
events: readonly AutomaticActionEvent[],
options: { active: boolean; show: boolean },
): ActionNoticeCursor {
let seenSeq = cursor.seenSeq;
const fresh: AutomaticActionEvent[] = [];
for (const event of events.filter(event => event.seq > seenSeq).toSorted((a, b) => a.seq - b.seq)) {
if (event.seq <= seenSeq) continue;
seenSeq = event.seq;
fresh.push(event);
}
return {
seenSeq,
pending: options.show ? appendActionNotices(cursor.pending, fresh, options.active) : [],
};
}
/** Keep at most three notices, including the one awaiting local dismissal. */
export function appendActionNotices(
pending: readonly QueuedActionNotice[],
incoming: readonly AutomaticActionEvent[],
active: boolean,
): QueuedActionNotice[] {
const notices = [...pending, ...incoming.map(event => ({ ...event, count: 1 }))];
const capacity = active ? 2 : 3;
if (notices.length <= capacity) return notices;
const mergedLength = notices.length - capacity + 1;
const merged = notices.slice(0, mergedLength);
return [
{ ...merged[merged.length - 1], count: merged.reduce((sum, notice) => sum + notice.count, 0) },
...notices.slice(mergedLength),
];
}