Files
spelljammer-ships/web/src/store/use-ship-store.ts
Bas van Rossem aceef65002 feat(web): optimistic UI updates with debounced server sync
Ship and weapon updates now apply locally immediately so stepper clicks
feel instant. Server sends are debounced (300ms) and batched so rapid
clicks produce a single network call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:44:15 +01:00

165 lines
5.0 KiB
TypeScript

import { create } from 'zustand';
import type { Ship } from '../types/ship';
import type { Weapon } from '../types/weapon';
import socket from '../socket';
interface ShipStore {
ship: Ship | null;
weapons: Weapon[];
loading: boolean;
currentShipId: string | null;
joinShip: (id: string) => void;
leaveShip: () => void;
updateShip: (patch: Partial<Ship>) => void;
createWeapon: (weapon: Record<string, unknown>) => void;
updateWeapon: (weaponId: string, patch: Partial<Weapon>) => void;
deleteWeapon: (weaponId: string) => void;
}
export const useShipStore = create<ShipStore>((set, get) => ({
ship: null,
weapons: [],
loading: false,
currentShipId: null,
joinShip: (id: string) => {
const current = get().currentShipId;
if (current === id) return;
if (current) {
// Leave previous room first
get().leaveShip();
}
set({ loading: true, currentShipId: id, ship: null, weapons: [] });
// Register socket listeners
const onState = (data: { ship: Ship; weapons: Weapon[] }) => {
set({ ship: data.ship, weapons: data.weapons, loading: false });
};
const onPatched = (data: { shipId: string; patch: Partial<Ship>; updated_at: number }) => {
const state = get();
if (state.ship && state.ship.id === data.shipId) {
set({ ship: { ...state.ship, ...data.patch, updated_at: data.updated_at } });
}
};
const onWeaponCreated = (data: { shipId: string; weapon: Weapon }) => {
set({ weapons: [...get().weapons, data.weapon] });
};
const onWeaponPatched = (data: {
shipId: string;
weaponId: string;
patch: Partial<Weapon>;
updated_at: number;
}) => {
set({
weapons: get().weapons.map((w) =>
w.id === data.weaponId ? { ...w, ...data.patch, updated_at: data.updated_at } : w,
),
});
};
const onWeaponDeleted = (data: { shipId: string; weaponId: string }) => {
set({ weapons: get().weapons.filter((w) => w.id !== data.weaponId) });
};
socket.on('ship:state', onState);
socket.on('ship:patched', onPatched);
socket.on('weapon:created', onWeaponCreated);
socket.on('weapon:patched', onWeaponPatched);
socket.on('weapon:deleted', onWeaponDeleted);
// Join the room
socket.emit('ship:join', { shipId: id });
// Re-join on reconnect
const onReconnect = () => {
if (get().currentShipId === id) {
socket.emit('ship:join', { shipId: id });
}
};
socket.on('connect', onReconnect);
// Store cleanup references
(socket as any).__shipCleanup = () => {
socket.off('ship:state', onState);
socket.off('ship:patched', onPatched);
socket.off('weapon:created', onWeaponCreated);
socket.off('weapon:patched', onWeaponPatched);
socket.off('weapon:deleted', onWeaponDeleted);
socket.off('connect', onReconnect);
};
},
leaveShip: () => {
const id = get().currentShipId;
if (id) {
socket.emit('ship:leave', { shipId: id });
}
// Clean up listeners
if ((socket as any).__shipCleanup) {
(socket as any).__shipCleanup();
delete (socket as any).__shipCleanup;
}
set({ ship: null, weapons: [], currentShipId: null, loading: false });
},
updateShip: (patch) => {
const id = get().currentShipId;
const ship = get().ship;
if (!id || !ship) return;
// Optimistic: apply locally immediately
set({ ship: { ...ship, ...patch } });
// Accumulate patches and debounce the server send
const pending = (socket as any).__pendingPatch ?? {};
Object.assign(pending, patch);
(socket as any).__pendingPatch = pending;
if ((socket as any).__patchTimer) clearTimeout((socket as any).__patchTimer);
(socket as any).__patchTimer = setTimeout(() => {
const merged = (socket as any).__pendingPatch;
delete (socket as any).__pendingPatch;
delete (socket as any).__patchTimer;
if (merged) socket.emit('ship:update', { shipId: id, patch: merged });
}, 300);
},
createWeapon: (weapon) => {
const id = get().currentShipId;
if (!id) return;
socket.emit('weapon:create', { shipId: id, weapon });
},
updateWeapon: (weaponId, patch) => {
// Optimistic: apply locally immediately
set({
weapons: get().weapons.map((w) =>
w.id === weaponId ? { ...w, ...patch } : w,
),
});
// Debounce per-weapon server sends
const timerKey = `__weaponTimer_${weaponId}`;
const patchKey = `__weaponPatch_${weaponId}`;
const pending = (socket as any)[patchKey] ?? {};
Object.assign(pending, patch);
(socket as any)[patchKey] = pending;
if ((socket as any)[timerKey]) clearTimeout((socket as any)[timerKey]);
(socket as any)[timerKey] = setTimeout(() => {
const merged = (socket as any)[patchKey];
delete (socket as any)[patchKey];
delete (socket as any)[timerKey];
if (merged) socket.emit('weapon:update', { weaponId, patch: merged });
}, 300);
},
deleteWeapon: (weaponId) => {
socket.emit('weapon:delete', { weaponId });
},
}));