877fbaa8e7
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates. Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
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<string, InitiativeEntry> = { ...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, 'revision'>): 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;
|
|
}
|
|
}
|