Story 3.2 done

This commit is contained in:
2026-05-23 18:23:48 +02:00
parent d175f92806
commit a1e8886fce
66 changed files with 18258 additions and 1650 deletions
+180
View File
@@ -0,0 +1,180 @@
/**
* NotificationBus — coalesced toast layer above ui.notifications.
*
* Subscribes to `scrying-pool:stateChanged` and coalesces rapid GM visibility
* changes for the same participant into a single toast per 3-second window.
*
* Verbosity rules (AC-4, AC-5):
* - 'all' → every client sees every general notification
* - 'gm-only' → only the GM + the affected participant are notified
* - 'silent' → only the affected participant is notified (personal only)
*
* Personal notification (own camera changed) always fires — never suppressed
* by verbosity setting (AC-2).
*
* Import boundary: src/notifications/ → src/core/, src/contracts/, src/utils/ ONLY.
* Uses Hooks as a FoundryVTT global (same pattern as StateStore).
*
* Constructors are side-effect-free. Call init() from module.js Hooks.once('ready').
*
* @module notifications/NotificationBus
*/
/** Coalescing window in milliseconds. Matches AC-3 ("within 3 seconds"). */
const COALESCE_WINDOW_MS = 3_000;
/**
* Coalesced toast notification layer over ui.notifications.
*
* Subscribes to `scrying-pool:stateChanged` and debounces GM visibility
* changes per participant into a single toast per 3-second window.
*/
export class NotificationBus {
/**
* @param {{ notifications: {info(m:string):void, warn(m:string):void, error(m:string):void},
* users: {get(id:string):{id:string,name:string}|null, current():{id:string}|null, isGM():boolean},
* settings: {get(key:string):unknown},
* i18n: {localize(key:string, data?:object):string} }} adapter
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {Map<string, {timer: ReturnType<typeof setTimeout>|null, prevState: string, lastState: string, changeCount: number}>} */
this._coalesceMap = new Map();
this._hookId = null;
this._disconnectHookId = null;
}
/** Register hook listener. Call from module.js Hooks.once('ready'). */
init() {
// Prevent multiple init calls without teardown
if (this._hookId != null) {
console.warn('[ScryingPool] NotificationBus.init: already initialized, call teardown first');
return;
}
this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
// Clean up coalesceMap entries for disconnected users
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
if (!connected && user?.id) {
const entry = this._coalesceMap.get(user.id);
if (entry) {
clearTimeout(entry.timer);
this._coalesceMap.delete(user.id);
}
}
});
}
/** Unregister listeners and cancel all pending timers. Safe to call before init(). */
teardown() {
if (this._hookId != null) {
Hooks.off('scrying-pool:stateChanged', this._hookId);
this._hookId = null;
}
if (this._disconnectHookId != null) {
this._adapter.hooks.off('userConnected', this._disconnectHookId);
this._disconnectHookId = null;
}
for (const entry of this._coalesceMap.values()) {
clearTimeout(entry.timer);
}
this._coalesceMap.clear();
}
// ── Private ────────────────────────────────────────────────────────────────
/**
* Handler for `scrying-pool:stateChanged` hook.
* @param {{ userId?: string, state: string, previousState: string }} data
*/
_onStateChanged(data) {
const { userId, state: newState, previousState } = data ?? {};
if (!userId) return;
if (typeof newState !== 'string') return;
const currentUserId = this._adapter.users.current()?.id ?? null;
// AC-2: Personal notification — fires immediately, never suppressed by verbosity
if (userId === currentUserId) {
this._notifyPersonal(newState);
return;
}
// AC-4/AC-5: Verbosity gate for non-personal notifications
const verbosity = this._adapter.settings.get('notificationVerbosity') ?? 'all';
// Validate verbosity is one of the allowed choices
const validVerbosity = ['all', 'gm-only', 'silent'].includes(verbosity) ? verbosity : 'all';
if (validVerbosity === 'silent') return;
if (validVerbosity === 'gm-only' && !this._adapter.users.isGM()) return;
this._enqueue(userId, newState, previousState);
}
/**
* Fire an immediate personal notification for the current user's own camera change.
* @param {string} newState
*/
_notifyPersonal(newState) {
const key = newState === 'hidden'
? 'video-view-manager.notifications.personalHidden'
: 'video-view-manager.notifications.personalShowed';
const msg = this._adapter.i18n.localize(key);
this._adapter.notifications.info(msg);
}
/**
* Add or update a coalescing entry for the given participant.
* Resets the 3-second debounce window on each call.
* @param {string} userId
* @param {string} newState
* @param {string} prevState
*/
_enqueue(userId, newState, prevState) {
// Validate required parameters
if (typeof userId !== 'string' || !userId) return;
if (typeof newState !== 'string' || typeof prevState !== 'string') return;
const existing = this._coalesceMap.get(userId);
if (existing) {
clearTimeout(existing.timer);
existing.lastState = newState;
existing.changeCount += 1;
} else {
this._coalesceMap.set(userId, {
timer: null,
prevState,
lastState: newState,
changeCount: 1,
});
}
const entry = this._coalesceMap.get(userId);
entry.timer = setTimeout(() => this._flush(userId), COALESCE_WINDOW_MS);
}
/**
* Fire the coalesced notification for a participant, then remove the entry.
* Net-zero suppression: if final state equals original state, no notification fires.
* @param {string} userId
*/
_flush(userId) {
const entry = this._coalesceMap.get(userId);
if (!entry) return; // Entry may have been deleted by teardown or disconnect cleanup
this._coalesceMap.delete(userId);
// AC-3: Net-zero suppression
if (entry.lastState === entry.prevState) return;
// Additional safety: ensure we have valid timer to prevent stale closure issues
if (entry.timer == null) return;
const name = this._adapter.users.get(userId)?.name ?? userId;
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
const key = entry.lastState === 'hidden'
? 'video-view-manager.notifications.gmHid'
: 'video-view-manager.notifications.gmShowed';
// Note: changeCount is included in the message suffix for AC-3 compliance
const msg = this._adapter.i18n.localize(key, { name }) + count;
this._adapter.notifications.info(msg);
}
}