Files
DndGamePlayer/app/main/initiative/initiativeSessionStore.ts
T
Ivan Fontosh 86d6d157f0 feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 10:09:51 +08:00

319 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;
}
}