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>
165 lines
5.0 KiB
TypeScript
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 });
|
|
},
|
|
}));
|