import { createInitialInitiativeState, initiativeManualLetter, initiativeNpcEntryId, initiativePlayerEntryId, isInitiativeConditionId, newInitiativeManualId, pickInitiativeManualGradient, type InitiativeEntry, type InitiativeEvent, type InitiativePresenceNpc, type InitiativePresencePlayer, type InitiativeState, } from '../../shared/types/initiative'; function withoutId(ids: readonly string[], id: string): string[] { return ids.filter((x) => x !== id); } function ensureActiveValid(state: InitiativeState): InitiativeState { if (state.activeId != null && state.order.includes(state.activeId)) return state; const nextActive = state.order[0] ?? null; if (state.activeId === nextActive) return state; return { ...state, activeId: nextActive }; } export class InitiativeSessionStore { private state: InitiativeState = createInitialInitiativeState(); getState(): InitiativeState { return this.state; } clear(): InitiativeState { const enabled = this.state.enabled; this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 }; return this.state; } dispatch(event: InitiativeEvent): InitiativeState { switch (event.kind) { case 'clear': { this.state = { ...createInitialInitiativeState(), enabled: this.state.enabled, revision: this.state.revision + 1, }; return this.state; } case 'setEnabled': { if (this.state.enabled === event.enabled) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, enabled: event.enabled, }; return this.state; } case 'syncPresence': { return this.syncPresence(event.npcs, event.players, event.playersVisible); } case 'reorder': { const allowed = new Set(this.state.order); const nextOrder = event.order.filter((id) => allowed.has(id)); for (const id of this.state.order) { if (!nextOrder.includes(id)) nextOrder.push(id); } if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) { return this.state; } this.state = ensureActiveValid({ ...this.state, revision: this.state.revision + 1, order: nextOrder, }); return this.state; } case 'setActive': { if (event.id != null && !this.state.order.includes(event.id)) return this.state; if (this.state.activeId === event.id) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, activeId: event.id, }; return this.state; } case 'next': { if (this.state.order.length === 0) { if (this.state.activeId == null) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, activeId: null }; return this.state; } const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1; const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length; const nextId = this.state.order[nextIdx] ?? null; if (nextId === this.state.activeId) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, activeId: nextId, }; return this.state; } case 'addManual': { const name = event.name.trim(); if (!name) return this.state; const id = newInitiativeManualId(); const entry: InitiativeEntry = { id, kind: 'manual', name, letter: initiativeManualLetter(name), gradient: pickInitiativeManualGradient(), }; this.state = { ...this.state, revision: this.state.revision + 1, entries: { ...this.state.entries, [id]: entry }, order: [...this.state.order, id], activeId: this.state.activeId ?? id, }; return this.state; } case 'moveToReserve': { if (!this.state.order.includes(event.id)) return this.state; if (!this.state.entries[event.id]) return this.state; const order = withoutId(this.state.order, event.id); const reserve = this.state.reserve.includes(event.id) ? this.state.reserve : [...this.state.reserve, event.id]; this.state = ensureActiveValid({ ...this.state, revision: this.state.revision + 1, order, reserve, }); return this.state; } case 'restoreFromReserve': { if (!this.state.reserve.includes(event.id)) return this.state; if (!this.state.entries[event.id]) return this.state; const reserve = withoutId(this.state.reserve, event.id); const order = this.state.order.includes(event.id) ? this.state.order : [...this.state.order, event.id]; this.state = { ...this.state, revision: this.state.revision + 1, order, reserve, activeId: this.state.activeId ?? event.id, }; return this.state; } case 'deleteEntry': { if (!this.state.entries[event.id]) return this.state; const { [event.id]: _removed, ...entries } = this.state.entries; const { [event.id]: _c, ...conditions } = this.state.conditions; this.state = ensureActiveValid({ ...this.state, revision: this.state.revision + 1, entries, conditions, order: withoutId(this.state.order, event.id), reserve: withoutId(this.state.reserve, event.id), }); return this.state; } case 'addCondition': { if (!this.state.entries[event.id]) return this.state; if (!isInitiativeConditionId(event.conditionId)) return this.state; const cur = this.state.conditions[event.id] ?? []; if (cur.includes(event.conditionId)) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, conditions: { ...this.state.conditions, [event.id]: [...cur, event.conditionId], }, }; return this.state; } case 'removeCondition': { const cur = this.state.conditions[event.id]; if (!cur?.includes(event.conditionId)) return this.state; const next = cur.filter((c) => c !== event.conditionId); const conditions = { ...this.state.conditions }; if (next.length === 0) delete conditions[event.id]; else conditions[event.id] = next; this.state = { ...this.state, revision: this.state.revision + 1, conditions, }; return this.state; } default: { const _exhaustive: never = event; return _exhaustive; } } } private syncPresence( npcs: readonly InitiativePresenceNpc[], players: readonly InitiativePresencePlayer[], playersVisible: boolean, ): InitiativeState { const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n])); const playerById = new Map(players.map((p) => [p.playerId, p])); let entries: Record = { ...this.state.entries }; let order = [...this.state.order]; let reserve = [...this.state.reserve]; let conditions = { ...this.state.conditions }; let changed = false; for (const [id, entry] of Object.entries(entries)) { if (entry.kind === 'manual') continue; if (entry.kind === 'npc') { const live = npcByPlacement.get(entry.placementId); if (!live) { delete entries[id]; delete conditions[id]; order = withoutId(order, id); reserve = withoutId(reserve, id); changed = true; continue; } if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) { entries[id] = { ...entry, npcId: live.npcId, name: live.name, avatarAssetId: live.avatarAssetId, }; changed = true; } continue; } const live = playerById.get(entry.playerId); if (!live) { delete entries[id]; delete conditions[id]; order = withoutId(order, id); reserve = withoutId(reserve, id); changed = true; continue; } if (entry.name !== live.name) { entries[id] = { ...entry, name: live.name }; changed = true; } } for (const npc of npcs) { const id = initiativeNpcEntryId(npc.placementId); if (entries[id]) continue; entries[id] = { id, kind: 'npc', placementId: npc.placementId, npcId: npc.npcId, name: npc.name, avatarAssetId: npc.avatarAssetId, }; order.push(id); changed = true; } for (const player of players) { const id = initiativePlayerEntryId(player.playerId); if (entries[id]) continue; entries[id] = { id, kind: 'player', playerId: player.playerId, name: player.name, }; order.push(id); changed = true; } const wasVisible = this.state.playersVisible; if (wasVisible && !playersVisible) { const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player'); if (playerIdsInOrder.length > 0) { for (const id of playerIdsInOrder) { order = withoutId(order, id); if (!reserve.includes(id)) reserve.push(id); } changed = true; } } if (this.state.playersVisible !== playersVisible) changed = true; if (!changed) { // Still update playersVisible if somehow same structure if (this.state.playersVisible === playersVisible) return this.state; } let next: InitiativeState = { ...this.state, revision: this.state.revision + 1, entries, order, reserve, conditions, playersVisible, }; next = ensureActiveValid(next); this.state = next; return this.state; } load(next: Omit): InitiativeState { this.state = ensureActiveValid({ ...next, revision: this.state.revision + 1, entries: { ...next.entries }, order: [...next.order], reserve: [...next.reserve], conditions: { ...next.conditions }, }); return this.state; } }