Files
spelljammer-ships/web/src/store/use-ship-store.ts
Bas van Rossem 06428f79cd feat(web): add zustand stores and Socket.IO client
Implements ship list store (REST-based) and ship detail store (Socket.IO-based)
with room join/leave, reconnection handling, and real-time event listeners.

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

129 lines
3.7 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;
if (!id) return;
socket.emit('ship:update', { shipId: id, patch });
},
createWeapon: (weapon) => {
const id = get().currentShipId;
if (!id) return;
socket.emit('weapon:create', { shipId: id, weapon });
},
updateWeapon: (weaponId, patch) => {
socket.emit('weapon:update', { weaponId, patch });
},
deleteWeapon: (weaponId) => {
socket.emit('weapon:delete', { weaponId });
},
}));