From 5e2ab43ca3a1c061dc99936776e53da95153c809 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 24 Jun 2026 16:41:13 +0200 Subject: [PATCH] SpaceRace: Full-width TV field, death line scrolling, camera following leader, jump fix, path never fully blocked, player color on controller --- .gitignore | 2 + controller/index.html | 104 + controller/package.json | 19 + controller/src/components/MiniMap.ts | 74 + controller/src/main.ts | 89 + controller/src/network/ControllerSocket.ts | 12 + controller/src/screens/JoinScreen.ts | 50 + controller/src/screens/LobbyScreen.ts | 105 + controller/src/screens/PlanningScreen.ts | 237 ++ controller/src/screens/SpectatorScreen.ts | 14 + controller/src/screens/WaitingScreen.ts | 7 + controller/src/style.css | 335 +++ controller/tsconfig.json | 15 + controller/vite.config.ts | 16 + docker-compose.yml | 8 + package-lock.json | 2922 ++++++++++++++++++++ package.json | 16 + server/Dockerfile | 10 + server/package.json | 24 + server/src/config.ts | 13 + server/src/game/ActionQueue.ts | 48 + server/src/game/CardHandler.ts | 91 + server/src/game/Executor.ts | 309 +++ server/src/game/Game.ts | 290 ++ server/src/game/Grid.ts | 156 ++ server/src/game/Ship.ts | 45 + server/src/index.ts | 41 + server/src/lobby/LobbyManager.ts | 127 + server/src/ws/WsServer.ts | 276 ++ server/tsconfig.json | 15 + shared/package.json | 13 + shared/src/index.ts | 1 + shared/src/types.ts | 247 ++ shared/tsconfig.json | 17 + tv/index.html | 17 + tv/package.json | 19 + tv/src/main.ts | 20 + tv/src/network/TvSocket.ts | 11 + tv/src/objects/GridRenderer.ts | 160 ++ tv/src/objects/ShipSprite.ts | 52 + tv/src/scenes/BootScene.ts | 69 + tv/src/scenes/GameScene.ts | 252 ++ tv/src/scenes/LobbyScene.ts | 115 + tv/src/scenes/ResultScene.ts | 89 + tv/tsconfig.json | 13 + tv/vite.config.ts | 16 + 46 files changed, 6581 insertions(+) create mode 100644 .gitignore create mode 100644 controller/index.html create mode 100644 controller/package.json create mode 100644 controller/src/components/MiniMap.ts create mode 100644 controller/src/main.ts create mode 100644 controller/src/network/ControllerSocket.ts create mode 100644 controller/src/screens/JoinScreen.ts create mode 100644 controller/src/screens/LobbyScreen.ts create mode 100644 controller/src/screens/PlanningScreen.ts create mode 100644 controller/src/screens/SpectatorScreen.ts create mode 100644 controller/src/screens/WaitingScreen.ts create mode 100644 controller/src/style.css create mode 100644 controller/tsconfig.json create mode 100644 controller/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/Dockerfile create mode 100644 server/package.json create mode 100644 server/src/config.ts create mode 100644 server/src/game/ActionQueue.ts create mode 100644 server/src/game/CardHandler.ts create mode 100644 server/src/game/Executor.ts create mode 100644 server/src/game/Game.ts create mode 100644 server/src/game/Grid.ts create mode 100644 server/src/game/Ship.ts create mode 100644 server/src/index.ts create mode 100644 server/src/lobby/LobbyManager.ts create mode 100644 server/src/ws/WsServer.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 shared/src/types.ts create mode 100644 shared/tsconfig.json create mode 100644 tv/index.html create mode 100644 tv/package.json create mode 100644 tv/src/main.ts create mode 100644 tv/src/network/TvSocket.ts create mode 100644 tv/src/objects/GridRenderer.ts create mode 100644 tv/src/objects/ShipSprite.ts create mode 100644 tv/src/scenes/BootScene.ts create mode 100644 tv/src/scenes/GameScene.ts create mode 100644 tv/src/scenes/LobbyScene.ts create mode 100644 tv/src/scenes/ResultScene.ts create mode 100644 tv/tsconfig.json create mode 100644 tv/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/controller/index.html b/controller/index.html new file mode 100644 index 0000000..08d5cb7 --- /dev/null +++ b/controller/index.html @@ -0,0 +1,104 @@ + + + + + + SpaceRace - Controller + + + +
+
+

🚀 SpaceRace

+
+ + + +

+
+
+
+ +
+
+
+ + Round 1 +
+
+ 45s +
+
+ AP: 0/4 +
+
+ + +
+
+
+ + +
+ + + + +
+ + +
+
+
+ + +
+
+
+ + +
+ + +
+
+ +
+

Executing...

+

Watch the TV!

+
+ +
+

💀 Eliminated

+

You're out! Watch the TV to see who wins.

+
+ +
+

Lobby

+

Room: ----

+
+

Players

+
    +
    +

    Waiting for host to start...

    +
    +
    + + + diff --git a/controller/package.json b/controller/package.json new file mode 100644 index 0000000..f71f822 --- /dev/null +++ b/controller/package.json @@ -0,0 +1,19 @@ +{ + "name": "@spacerace/controller", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite --port 3001", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@spacerace/shared": "*", + "socket.io-client": "^4.7.5", + "qr-code-styling": "^1.6.0-rc.1" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^5.3.0" + } +} diff --git a/controller/src/components/MiniMap.ts b/controller/src/components/MiniMap.ts new file mode 100644 index 0000000..eb5bd49 --- /dev/null +++ b/controller/src/components/MiniMap.ts @@ -0,0 +1,74 @@ +import { GameGridState, Ship, GRID_WIDTH, TileType } from '@spacerace/shared'; + +export class MiniMap { + private container: HTMLElement; + private ships: Ship[]; + + constructor(container: HTMLElement, ships: Ship[]) { + this.container = container; + this.ships = ships; + } + + update(grid: GameGridState, ships: Ship[]): void { + this.ships = ships; + this.container.innerHTML = ''; + + const deathLineY = grid.deathLineY; + const visibleRows = 8; + const cellSize = this.container.clientHeight / visibleRows; + const cellW = this.container.clientWidth / GRID_WIDTH; + + // Render grid cells + for (let dy = 0; dy < visibleRows; dy++) { + const gridY = deathLineY - dy; + const row = grid.rows[gridY] || Array(GRID_WIDTH).fill('space'); + + for (let x = 0; x < GRID_WIDTH; x++) { + const tile = (row[x] as TileType) || 'space'; + const cell = document.createElement('div'); + cell.className = 'minimap-cell'; + if (tile !== 'space') cell.classList.add(tile); + cell.style.left = `${x * cellW}px`; + cell.style.top = `${dy * cellSize}px`; + cell.style.width = `${cellW}px`; + cell.style.height = `${cellSize}px`; + this.container.appendChild(cell); + } + } + + // Render ships + const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44']; + for (let i = 0; i < ships.length; i++) { + const ship = ships[i]; + if (!ship.alive) continue; + + const relativeY = deathLineY - ship.position.y; + if (relativeY < 0 || relativeY >= visibleRows) continue; + + const shipEl = document.createElement('div'); + shipEl.className = 'minimap-ship'; + shipEl.style.left = `${ship.position.x * cellW}px`; + shipEl.style.top = `${relativeY * cellSize}px`; + shipEl.style.width = `${cellW}px`; + shipEl.style.height = `${cellSize}px`; + shipEl.style.backgroundColor = colors[i % colors.length]; + shipEl.style.borderRadius = '50%'; + shipEl.style.fontSize = '8px'; + shipEl.textContent = ship.playerId.substring(0, 3); + this.container.appendChild(shipEl); + } + + // Death line + const dl = document.createElement('div'); + dl.style.cssText = ` + position: absolute; + left: 0; + top: ${visibleRows * cellSize}px; + width: 100%; + height: 2px; + background: #ff0000; + opacity: 0.6; + `; + this.container.appendChild(dl); + } +} diff --git a/controller/src/main.ts b/controller/src/main.ts new file mode 100644 index 0000000..984d9b4 --- /dev/null +++ b/controller/src/main.ts @@ -0,0 +1,89 @@ +import { connectControllerSocket, ControllerSocket } from './network/ControllerSocket.js'; +import { JoinScreen } from './screens/JoinScreen.js'; +import { PlanningScreen } from './screens/PlanningScreen.js'; +import { WaitingScreen } from './screens/WaitingScreen.js'; +import { SpectatorScreen } from './screens/SpectatorScreen.js'; +import { LobbyScreen } from './screens/LobbyScreen.js'; +import { Action } from '@spacerace/shared'; + +class App { + socket: ControllerSocket; + private currentScreen: string = 'join'; + isHost: boolean = false; + roomCode: string = ''; + playerId: string = ''; + + constructor() { + this.socket = connectControllerSocket(); + this.initScreens(); + this.initSocketEvents(); + } + + private initScreens(): void { + new JoinScreen(this); + new LobbyScreen(this); + new PlanningScreen(this); + new WaitingScreen(this); + new SpectatorScreen(this); + } + + private initSocketEvents(): void { + this.socket.on('disconnect', () => { + this.clearAllScreens(); + }); + + this.socket.on('roomJoined', (data) => { + this.isHost = data.isHost; + this.roomCode = data.roomCode; + this.playerId = data.playerId; + this.showScreen('lobby'); + }); + + this.socket.on('gameStarting', () => { + this.showScreen('planning'); + }); + + this.socket.on('planningRequest', (data) => { + this.showScreen('planning'); + // Pass data to planning screen + document.dispatchEvent(new CustomEvent('planningRequest', { detail: data })); + }); + + this.socket.on('executionTick', (data) => { + this.showScreen('waiting'); + const msgEl = document.getElementById('waiting-message'); + if (msgEl && data.messages.length > 0) { + msgEl.textContent = data.messages.join(' | '); + } + }); + + this.socket.on('executionComplete', (data) => { + if (data.playerView.alive) { + this.showScreen('planning'); + } else { + this.showScreen('spectator'); + } + }); + + this.socket.on('gameOver', (data) => { + this.showScreen('spectator'); + }); + } + + showScreen(name: string): void { + document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active')); + const el = document.getElementById(`screen-${name}`); + if (el) el.classList.add('active'); + this.currentScreen = name; + } + + clearAllScreens(): void { + document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active')); + document.getElementById('screen-join')?.classList.add('active'); + } +} + +// Boot +document.addEventListener('DOMContentLoaded', () => { + new App(); +}); diff --git a/controller/src/network/ControllerSocket.ts b/controller/src/network/ControllerSocket.ts new file mode 100644 index 0000000..6f61651 --- /dev/null +++ b/controller/src/network/ControllerSocket.ts @@ -0,0 +1,12 @@ +import { io, Socket } from 'socket.io-client'; +import { ServerToMobileEvents, MobileToServerEvents } from '@spacerace/shared'; + +export type ControllerSocket = Socket; + +export function connectControllerSocket(): ControllerSocket { + const socket: ControllerSocket = io('/', { + transports: ['websocket', 'polling'], + reconnection: true, + }); + return socket; +} diff --git a/controller/src/screens/JoinScreen.ts b/controller/src/screens/JoinScreen.ts new file mode 100644 index 0000000..dc358ca --- /dev/null +++ b/controller/src/screens/JoinScreen.ts @@ -0,0 +1,50 @@ +import { App } from '../main.js'; + +export class JoinScreen { + private app: App; + + constructor(app: App) { + this.app = app; + this.init(); + } + + private init(): void { + const joinBtn = document.getElementById('join-btn')!; + const roomInput = document.getElementById('room-input') as HTMLInputElement; + const nameInput = document.getElementById('name-input') as HTMLInputElement; + const errorEl = document.getElementById('join-error')!; + + // Pre-fill room code from URL if present + const params = new URLSearchParams(window.location.search); + const roomParam = params.get('room'); + if (roomParam) { + roomInput.value = roomParam.toUpperCase(); + } + + joinBtn.addEventListener('click', () => { + const roomCode = roomInput.value.trim().toUpperCase(); + const playerName = nameInput.value.trim(); + + if (!roomCode || roomCode.length !== 4) { + errorEl.textContent = 'Enter a 4-character room code'; + return; + } + if (!playerName) { + errorEl.textContent = 'Enter your name'; + return; + } + + errorEl.textContent = ''; + joinBtn.textContent = 'Joining...'; + joinBtn.disabled = true; + + this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => { + joinBtn.textContent = 'Join Race'; + joinBtn.disabled = false; + if (!res.ok) { + errorEl.textContent = res.error || 'Failed to join'; + } + }); + }); + } +} diff --git a/controller/src/screens/LobbyScreen.ts b/controller/src/screens/LobbyScreen.ts new file mode 100644 index 0000000..f4b8252 --- /dev/null +++ b/controller/src/screens/LobbyScreen.ts @@ -0,0 +1,105 @@ +import { App } from '../main.js'; + +export class LobbyScreen { + private app: App; + private startBtn: HTMLButtonElement | null = null; + + constructor(app: App) { + this.app = app; + + this.createStartButton(); + + app.socket.on('roomJoined', (data) => { + document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`; + this.updatePlayerList(data.players); + this.updateHostUI(); + }); + + app.socket.on('playerJoined', (data) => { + this.appendPlayer(data.playerId, data.name); + this.updateHostUI(); + }); + + app.socket.on('playerLeft', (data) => { + const li = document.getElementById(`player-${data.playerId}`); + if (li) li.remove(); + this.updateHostUI(); + }); + + app.socket.on('hostChanged', (data) => { + this.app.isHost = data.hostPlayerId === this.app.playerId; + this.updateHostUI(); + }); + } + + private createStartButton(): void { + const div = document.createElement('div'); + div.id = 'host-controls'; + div.style.cssText = 'margin-top: 16px; text-align: center; display: none;'; + + this.startBtn = document.createElement('button'); + this.startBtn.textContent = '🚀 Start Race'; + this.startBtn.style.cssText = 'padding: 14px 32px; font-size: 20px; background: #00ff88; color: #000; border: none; border-radius: 8px; font-weight: bold; cursor: pointer;'; + this.startBtn.addEventListener('click', () => { + if (!this.startBtn) return; + this.startBtn.textContent = 'Starting...'; + this.startBtn.disabled = true; + + this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => { + if (!res.ok) { + this.startBtn!.textContent = '🚀 Start Race'; + this.startBtn!.disabled = false; + alert(res.error || 'Cannot start'); + } + }); + }); + + div.appendChild(this.startBtn); + + // Insert before the waiting text + const lobbyScreen = document.getElementById('screen-lobby')!; + const waitingEl = document.getElementById('lobby-waiting')!; + lobbyScreen.insertBefore(div, waitingEl); + } + + private updateHostUI(): void { + const div = document.getElementById('host-controls')!; + const waitingEl = document.getElementById('lobby-waiting')!; + + if (this.app.isHost) { + div.style.display = 'block'; + waitingEl.style.display = 'none'; + const playerCount = document.getElementById('lobby-player-list')!.children.length; + if (this.startBtn) { + this.startBtn.disabled = playerCount < 2; + if (playerCount < 2) { + this.startBtn.style.opacity = '0.5'; + } else { + this.startBtn.style.opacity = '1'; + } + } + } else { + div.style.display = 'none'; + waitingEl.style.display = 'block'; + waitingEl.textContent = 'Waiting for host to start...'; + } + } + + private updatePlayerList(players: { id: string; name: string }[]): void { + const list = document.getElementById('lobby-player-list')!; + list.innerHTML = ''; + for (const p of players) { + this.appendPlayer(p.id, p.name); + } + } + + private appendPlayer(id: string, name: string): void { + const list = document.getElementById('lobby-player-list')!; + const li = document.createElement('li'); + const hostId = this.app.socket.data?.hostPlayerId; + const crown = (this.app.isHost && id === this.app.playerId) ? ' 👑' : ''; + li.textContent = name + crown; + li.id = `player-${id}`; + list.appendChild(li); + } +} diff --git a/controller/src/screens/PlanningScreen.ts b/controller/src/screens/PlanningScreen.ts new file mode 100644 index 0000000..33a8877 --- /dev/null +++ b/controller/src/screens/PlanningScreen.ts @@ -0,0 +1,237 @@ +import { App } from '../main.js'; +import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared'; + +const CARD_ICONS: Record = { + METEOR_STRIKE: '☄️', + SHIELD: '🛡️', + BOOST: '🚀', + EMP: '⚡', + JUMP: '🦘', + MINE: '💣', + TELEPORT: '🌀', + PHASE_SHIFT: '👻', +}; + +export class PlanningScreen { + private app: App; + private plannedActions: Action[] = []; + private playerView: PlayerView | null = null; + private apTotal: number = 4; + private selectedCard: CardType | null = null; + private timerInterval: ReturnType | null = null; + private timerSeconds: number = 0; + + constructor(app: App) { + this.app = app; + + document.addEventListener('planningRequest', ((e: CustomEvent) => { + this.onPlanningRequest(e.detail); + }) as EventListener); + + this.initActionButtons(); + this.initBottomButtons(); + } + + private onPlanningRequest(data: { + round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number; + }): void { + this.playerView = data.playerView; + this.plannedActions = []; + this.selectedCard = null; + this.apTotal = data.playerView.ap; + this.timerSeconds = data.timer; + + this.setPlayerColor(data.playerView.colorIndex); + + document.getElementById('round-label')!.textContent = `Round ${data.round}`; + const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement; + submitBtn.disabled = true; + submitBtn.textContent = 'GO!'; + this.updateApDisplay(); + this.renderQueue(); + this.renderCards(data.playerView.hand); + this.updateTimer(); + + if (this.timerInterval) clearInterval(this.timerInterval); + this.timerInterval = setInterval(() => { + this.timerSeconds--; + this.updateTimer(); + if (this.timerSeconds <= 0 && this.timerInterval) { + clearInterval(this.timerInterval); + } + }, 1000); + } + + private setPlayerColor(colorIndex: number): void { + const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44']; + const dot = document.getElementById('color-dot'); + if (dot) dot.style.backgroundColor = colors[colorIndex % colors.length]; + } + + private updateTimer(): void { + const el = document.getElementById('timer-label')!; + el.textContent = `${this.timerSeconds}s`; + el.style.color = this.timerSeconds <= 10 ? '#ff4444' : this.timerSeconds <= 20 ? '#ffcc00' : '#00ff88'; + } + + private initActionButtons(): void { + const grid = document.getElementById('action-grid')!; + grid.querySelectorAll('.act-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const actionType = (btn as HTMLElement).dataset.action!; + this.addAction({ type: actionType as Action['type'] }); + }); + }); + } + + private initBottomButtons(): void { + document.getElementById('clear-plan-btn')!.addEventListener('click', () => { + this.plannedActions = []; + this.selectedCard = null; + this.renderQueue(); + this.updateApDisplay(); + this.renderCards(this.playerView?.hand || []); + }); + + document.getElementById('submit-plan-btn')!.addEventListener('click', () => { + if (this.plannedActions.length === 0) return; + const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement; + btn.disabled = true; + btn.textContent = '...'; + + this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => { + if (!res.ok) { + btn.disabled = false; + btn.textContent = 'GO!'; + alert(res.error || 'Invalid plan'); + } + }); + }); + } + + private addAction(action: Action): void { + if (!this.playerView || !this.playerView.alive) return; + + // If we have a selected card, add it as a CARD action first + if (this.selectedCard) { + const cardCost = CARD_DEFS[this.selectedCard].apCost; + const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); + if (used + cardCost > this.apTotal) return; + + this.plannedActions.push({ type: 'CARD', card: this.selectedCard }); + this.selectedCard = null; + this.renderCards(this.playerView.hand); + this.renderQueue(); + this.updateApDisplay(); + return; + } + + const cost = actionApCost(action); + const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); + if (used + cost > this.apTotal) return; + + this.plannedActions.push(action); + this.renderQueue(); + this.updateApDisplay(); + + const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement; + submitBtn.disabled = this.plannedActions.length === 0; + } + + private renderQueue(): void { + const list = document.getElementById('queue-list')!; + list.innerHTML = ''; + + for (let i = 0; i < this.plannedActions.length; i++) { + const action = this.plannedActions[i]; + const div = document.createElement('div'); + div.className = 'queue-item'; + + let icon = ''; + switch (action.type) { + case 'MOVE_FORWARD': icon = '↑'; break; + case 'TURN_LEFT': icon = '↰'; break; + case 'TURN_RIGHT': icon = '↱'; break; + case 'TURN_180': icon = '↻'; break; + case 'BOOST': icon = '⚡'; break; + case 'CARD': icon = action.card ? CARD_ICONS[action.card] : '?'; break; + } + div.textContent = icon; + div.title = action.type + (action.card ? ` ${action.card}` : ''); + + // X button to remove + const remove = document.createElement('span'); + remove.className = 'remove-hint'; + remove.textContent = '×'; + div.appendChild(remove); + + div.addEventListener('click', () => { + this.plannedActions.splice(i, 1); + this.renderQueue(); + this.updateApDisplay(); + this.renderCards(this.playerView?.hand || []); + }); + + list.appendChild(div); + } + } + + private renderCards(hand: CardType[]): void { + const list = document.getElementById('card-list')!; + list.innerHTML = ''; + + // If a card was already used in the plan, dim it + const usedCards = new Set(); + for (const a of this.plannedActions) { + if (a.type === 'CARD' && a.card) usedCards.add(a.card); + } + + const availableCards = hand.filter((c) => !usedCards.has(c)); + + for (const card of availableCards) { + const def = CARD_DEFS[card]; + const div = document.createElement('div'); + div.className = 'card-item'; + if (this.selectedCard === card) div.classList.add('selected'); + + div.innerHTML = ` +
    ${CARD_ICONS[card]}
    +
    ${def.name}
    +
    ${def.apCost} AP
    + `; + + div.addEventListener('click', () => { + const wasSelected = this.selectedCard === card; + this.selectedCard = wasSelected ? null : card; + this.renderCards(hand); + }); + + list.appendChild(div); + } + + if (availableCards.length === 0) { + list.innerHTML = '
    No cards available
    '; + } + } + + private updateApDisplay(): void { + const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); + const remaining = this.apTotal - used; + + document.getElementById('ap-label')!.textContent = `AP: ${remaining}/${this.apTotal}`; + + const fill = document.getElementById('ap-fill')!; + fill.style.width = `${(remaining / this.apTotal) * 100}%`; + + if (remaining === 0) { + fill.style.background = '#ff4444'; + document.getElementById('ap-label')!.style.color = '#ff4444'; + } else if (remaining === this.apTotal) { + fill.style.background = '#00ff88'; + document.getElementById('ap-label')!.style.color = '#00ff88'; + } else { + fill.style.background = '#ffcc00'; + document.getElementById('ap-label')!.style.color = '#ffcc00'; + } + } +} diff --git a/controller/src/screens/SpectatorScreen.ts b/controller/src/screens/SpectatorScreen.ts new file mode 100644 index 0000000..0b4020a --- /dev/null +++ b/controller/src/screens/SpectatorScreen.ts @@ -0,0 +1,14 @@ +import { App } from '../main.js'; + +export class SpectatorScreen { + constructor(app: App) { + app.socket.on('gameOver', (data) => { + const h2 = document.querySelector('#screen-spectator h2')!; + if (data.winnerId === app.socket.data?.playerId) { + h2.textContent = '🏆 You Win!'; + } else { + h2.textContent = `💀 ${data.winnerName} Wins!`; + } + }); + } +} diff --git a/controller/src/screens/WaitingScreen.ts b/controller/src/screens/WaitingScreen.ts new file mode 100644 index 0000000..0482838 --- /dev/null +++ b/controller/src/screens/WaitingScreen.ts @@ -0,0 +1,7 @@ +import { App } from '../main.js'; + +export class WaitingScreen { + constructor(app: App) { + // Mostly handled by main.ts socket events + } +} diff --git a/controller/src/style.css b/controller/src/style.css new file mode 100644 index 0000000..f6fa2f2 --- /dev/null +++ b/controller/src/style.css @@ -0,0 +1,335 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: monospace; + background: #0a0a2e; + color: #ffffff; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; +} + +#app { + width: 100%; + height: 100%; + position: relative; +} + +.screen { + display: none; + width: 100%; + height: 100%; + padding: 16px; + overflow-y: auto; + flex-direction: column; + position: absolute; + top: 0; + left: 0; +} + +.screen.active { + display: flex; +} + +h1 { font-size: 24px; text-align: center; margin: 20px 0; color: #00ccff; } +h2 { font-size: 20px; text-align: center; margin: 16px 0; color: #00ccff; } +h3 { font-size: 14px; color: #8888aa; margin: 8px 0 4px; } + +/* Join Screen */ +#screen-join { align-items: center; justify-content: flex-start; padding-top: 40px; } + +.join-form { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 320px; +} + +input { + padding: 14px; + font-size: 20px; + font-family: monospace; + border: 2px solid #334466; + background: #111133; + color: #ffffff; + border-radius: 8px; + text-align: center; + text-transform: uppercase; +} + +input:focus { + border-color: #00ccff; + outline: none; +} + +#name-input { text-transform: none; } + +button { + padding: 14px; + font-size: 18px; + font-family: monospace; + font-weight: bold; + border: none; + border-radius: 8px; + cursor: pointer; + background: #00ccff; + color: #000; +} + +button:active { opacity: 0.8; } +button:disabled { background: #334466; color: #666688; cursor: not-allowed; } + +.error { color: #ff4444; font-size: 13px; text-align: center; min-height: 18px; } + +/* Planning Screen */ +#screen-planning { + gap: 0; + padding: 0; +} + +.planning-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #0d0d35; + border-bottom: 2px solid #1a1a55; +} +.header-left, .header-right { flex: 1; } +.header-left { display: flex; align-items: center; gap: 8px; } +.color-dot { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid rgba(255,255,255,0.4); + flex-shrink: 0; +} +.header-center { text-align: center; } +#round-label { font-size: 14px; font-weight: bold; color: #00ccff; text-transform: uppercase; } +#timer-label { font-size: 22px; color: #ffcc00; font-weight: bold; } +#ap-label { font-size: 14px; font-weight: bold; color: #00ff88; text-align: right; } + +/* AP bar */ +.ap-bar { + height: 4px; + background: #112233; +} +.ap-fill { + height: 100%; + background: #00ff88; + transition: width 0.2s; +} + +/* Action grid */ +.action-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 8px; + padding: 12px; + flex: 1; +} + +.act-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 16px 8px; + background: #111144; + border: 2px solid #223366; + border-radius: 16px; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} +.act-btn:active { + background: #1a1a66; + border-color: #00ccff; + transform: scale(0.95); +} +.act-btn.primary { + background: #112244; + border-color: #3366aa; +} +.act-btn.primary:active { + background: #1a3366; + border-color: #00ccff; + transform: scale(0.95); +} +.act-btn.accent { + border-color: #886600; + background: #221a08; +} +.act-btn.accent:active { + border-color: #ffaa00; + background: #332a10; + transform: scale(0.95); +} +.act-icon { + font-size: 36px; + line-height: 1; +} +.act-label { + font-size: 14px; + font-weight: bold; + color: #ccccff; +} +.act-cost { + font-size: 11px; + color: #6666aa; +} + +/* Action queue */ +.action-queue { + padding: 8px 12px; + border-top: 1px solid #1a1a44; + background: #080820; +} +.queue-list { + display: flex; + gap: 6px; + overflow-x: auto; + min-height: 44px; + align-items: center; +} +.queue-item { + flex-shrink: 0; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: #1a3355; + border: 2px solid #3366aa; + border-radius: 10px; + font-size: 13px; + font-weight: bold; + position: relative; + cursor: pointer; +} +.queue-item:active { + background: #442222; + border-color: #ff4444; +} +.queue-item .remove-hint { + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; + background: #ff4444; + color: #fff; + border-radius: 50%; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Card hand */ +.card-hand { + padding: 8px 12px; + border-top: 1px solid #1a1a44; + background: #0a0a28; +} +.card-list { + display: flex; + gap: 8px; + overflow-x: auto; +} +.card-item { + flex-shrink: 0; + width: 110px; + min-height: 60px; + padding: 8px 10px; + background: #151535; + border: 2px solid #334466; + border-radius: 12px; + cursor: pointer; + touch-action: manipulation; +} +.card-item.selected { + border-color: #ffaa00; + background: #221a10; + box-shadow: 0 0 12px #ffaa0044; +} +.card-item .card-icon { + font-size: 22px; + text-align: center; +} +.card-item .card-name { + font-size: 11px; + font-weight: bold; + color: #ffaa00; + text-align: center; +} +.card-item .card-cost { + font-size: 10px; + color: #8888aa; + text-align: center; +} + +/* Bottom row */ +.planning-bottom { + display: flex; + gap: 8px; + padding: 8px 12px 16px; + border-top: 1px solid #1a1a44; + background: #0d0d35; +} +.btn-clear { + flex: 1; + padding: 14px; + font-size: 16px; + font-family: monospace; + font-weight: bold; + border: 2px solid #553333; + border-radius: 12px; + background: #221111; + color: #ff6666; + cursor: pointer; +} +.btn-submit { + flex: 2; + padding: 14px; + font-size: 20px; + font-family: monospace; + font-weight: bold; + border: none; + border-radius: 12px; + cursor: pointer; + background: #00cc44; + color: #000; +} +.btn-submit:disabled { + background: #223344; + color: #556677; + cursor: not-allowed; +} + +/* Waiting Screen */ +#screen-waiting { align-items: center; justify-content: center; } +#waiting-message { font-size: 16px; color: #8888aa; margin-top: 12px; } + +/* Spectator Screen */ +#screen-spectator { align-items: center; justify-content: center; } +#screen-spectator p { color: #8888aa; margin-top: 8px; } + +/* Lobby Screen */ +#screen-lobby { align-items: center; padding-top: 40px; } +#lobby-room { font-size: 32px; font-weight: bold; color: #00ccff; margin: 8px 0; letter-spacing: 8px; } +#lobby-players { width: 100%; max-width: 320px; margin: 16px 0; } +#lobby-player-list { list-style: none; } +#lobby-player-list li { padding: 8px; border-bottom: 1px solid #223355; font-size: 16px; } +#lobby-waiting { color: #8888aa; margin-top: 16px; font-size: 14px; } diff --git a/controller/tsconfig.json b/controller/tsconfig.json new file mode 100644 index 0000000..3074566 --- /dev/null +++ b/controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["src"] +} diff --git a/controller/vite.config.ts b/controller/vite.config.ts new file mode 100644 index 0000000..7f77c23 --- /dev/null +++ b/controller/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3001, + proxy: { + '/socket.io': { + target: 'http://localhost:8080', + ws: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d518df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + server: + build: + context: . + dockerfile: server/Dockerfile + ports: + - "8080:8080" + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e9055f6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2922 @@ +{ + "name": "spacerace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spacerace", + "workspaces": [ + "shared", + "server", + "tv", + "controller" + ] + }, + "controller": { + "name": "@spacerace/controller", + "version": "1.0.0", + "dependencies": { + "@spacerace/shared": "*", + "qr-code-styling": "^1.6.0-rc.1", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@spacerace/controller": { + "resolved": "controller", + "link": true + }, + "node_modules/@spacerace/server": { + "resolved": "server", + "link": true + }, + "node_modules/@spacerace/shared": { + "resolved": "shared", + "link": true + }, + "node_modules/@spacerace/tv": { + "resolved": "tv", + "link": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz", + "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/phaser": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", + "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qr-code-styling": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "server": { + "name": "@spacerace/server", + "version": "1.0.0", + "dependencies": { + "@spacerace/shared": "*", + "cors": "^2.8.5", + "express": "^4.19.2", + "nanoid": "^5.0.7", + "socket.io": "^4.7.5" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.14.0", + "tsx": "^4.15.0", + "typescript": "^5.5.0" + } + }, + "shared": { + "name": "@spacerace/shared", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "tv": { + "name": "@spacerace/tv", + "version": "1.0.0", + "dependencies": { + "@spacerace/shared": "*", + "phaser": "^3.80.1", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^5.3.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cec70c4 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "spacerace", + "private": true, + "workspaces": [ + "shared", + "server", + "tv", + "controller" + ], + "scripts": { + "dev:server": "npm -w server run dev", + "dev:tv": "npm -w tv run dev", + "dev:controller": "npm -w controller run dev", + "build": "npm -w shared run build && npm -w server run build && npm -w tv run build && npm -w controller run build" + } +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3313995 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS server-build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY shared/ ./shared/ +COPY server/ ./server/ +RUN npm ci --workspace=server --workspace=shared +RUN npm -w shared run build 2>/dev/null || true +WORKDIR /app/server +EXPOSE 8080 +CMD ["npx", "tsx", "src/index.ts"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..18421a5 --- /dev/null +++ b/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@spacerace/server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --noEmit", + "start": "tsx src/index.ts" + }, + "dependencies": { + "@spacerace/shared": "*", + "socket.io": "^4.7.5", + "cors": "^2.8.5", + "express": "^4.19.2", + "nanoid": "^5.0.7" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/node": "^20.14.0", + "tsx": "^4.15.0", + "typescript": "^5.5.0" + } +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..24312a1 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,13 @@ +export const GAME_CONFIG = { + GRID_WIDTH: 8, + VISIBLE_ROWS: 10, + AP_PER_ROUND: 4, + DEATH_LINE_ADVANCE: 1, + MAX_HAND_SIZE: 3, + PLANNING_TIME_SECONDS: 45, + EXECUTION_TICK_MS: 500, + MIN_PLAYERS: 2, + MAX_PLAYERS: 6, + TRACK_DENSITY: 0.2, // probability of asteroid per tile + SERVE_PORT: 8080, +} as const; diff --git a/server/src/game/ActionQueue.ts b/server/src/game/ActionQueue.ts new file mode 100644 index 0000000..f25521c --- /dev/null +++ b/server/src/game/ActionQueue.ts @@ -0,0 +1,48 @@ +import { Action, actionApCost, CardType, CARD_DEFS } from '@spacerace/shared'; +import { GAME_CONFIG } from '../config.js'; + +export function validatePlan(actions: Action[], hand: CardType[]): { valid: boolean; error?: string } { + if (actions.length === 0) { + return { valid: false, error: 'Plan cannot be empty' }; + } + + let totalAp = 0; + const usedCards = new Set(); + + for (const action of actions) { + const cost = actionApCost(action); + totalAp += cost; + + if (totalAp > GAME_CONFIG.AP_PER_ROUND) { + return { valid: false, error: `Exceeds ${GAME_CONFIG.AP_PER_ROUND} AP limit (used ${totalAp})` }; + } + + if (action.type === 'CARD') { + if (!action.card) { + return { valid: false, error: 'Card action requires a card type' }; + } + if (!CARD_DEFS[action.card]) { + return { valid: false, error: `Unknown card: ${action.card}` }; + } + if (!hand.includes(action.card)) { + return { valid: false, error: `Card ${action.card} not in hand` }; + } + if (usedCards.has(action.card)) { + return { valid: false, error: `Card ${action.card} used twice` }; + } + usedCards.add(action.card); + } + } + + return { valid: true }; +} + +export function removeUsedCards(actions: Action[], hand: CardType[]): CardType[] { + const usedCards = new Set(); + for (const action of actions) { + if (action.type === 'CARD' && action.card) { + usedCards.add(action.card); + } + } + return hand.filter((c) => !usedCards.has(c)); +} diff --git a/server/src/game/CardHandler.ts b/server/src/game/CardHandler.ts new file mode 100644 index 0000000..47f1074 --- /dev/null +++ b/server/src/game/CardHandler.ts @@ -0,0 +1,91 @@ +import { CardType, Position, CARD_DEFS } from '@spacerace/shared'; +import { Grid } from './Grid.js'; +import { Ship } from './Ship.js'; + +export interface CardContext { + ship: Ship; + target?: Position; + grid: Grid; + allShips: Ship[]; + shipStates: Map; +} + +export function executeCard(cardType: CardType, ctx: CardContext): { success: boolean; message: string; gridUpdates: { position: Position; tile: string }[] } { + const def = CARD_DEFS[cardType]; + if (!def) return { success: false, message: 'Unknown card', gridUpdates: [] }; + + switch (cardType) { + case 'METEOR_STRIKE': { + if (!ctx.target) return { success: false, message: 'No target for Meteor Strike', gridUpdates: [] }; + // Check range: within 3 tiles of ship + const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y); + if (dist > 3) return { success: false, message: 'Target out of range', gridUpdates: [] }; + ctx.grid.setTile(ctx.target, 'meteor'); + return { success: true, message: `${ctx.ship.playerId} summoned a meteor at (${ctx.target.x},${ctx.target.y})`, gridUpdates: [{ position: ctx.target, tile: 'meteor' }] }; + } + + case 'SHIELD': { + ctx.ship.shielded = true; + return { success: true, message: `${ctx.ship.playerId} activated shield`, gridUpdates: [] }; + } + + case 'EMP': { + ctx.ship.empActive = true; + return { success: true, message: `${ctx.ship.playerId} activated EMP`, gridUpdates: [] }; + } + + case 'JUMP': { + const dir = ctx.ship.direction; + const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0; + const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0; + + const jumpPos: Position = { + x: ctx.ship.position.x + dx * 2, + y: ctx.ship.position.y + dy * 2, + }; + + if (!ctx.grid.isInBounds(jumpPos)) return { success: false, message: 'Jump out of bounds', gridUpdates: [] }; + + ctx.ship.position = jumpPos; + return { success: true, message: `${ctx.ship.playerId} jumped 2 tiles forward`, gridUpdates: [] }; + } + + case 'MINE': { + ctx.grid.setTile(ctx.ship.position, 'mine'); + return { success: true, message: `${ctx.ship.playerId} dropped a mine`, gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }] }; + } + + case 'TELEPORT': { + if (!ctx.target) return { success: false, message: 'No target for Teleport', gridUpdates: [] }; + const targetX = ctx.target!.x; + const targetY = ctx.target!.y; + const targetShip = ctx.allShips.find( + (s) => s.alive && s.id !== ctx.ship.id && + s.position.x === targetX && s.position.y === targetY + ); + if (!targetShip) return { success: false, message: 'No ship at target location', gridUpdates: [] }; + const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y); + if (dist > 5) return { success: false, message: 'Target out of range for Teleport', gridUpdates: [] }; + + // Check EMP on target ship + const targetState = ctx.shipStates.get(targetShip.id); + if (targetState?.empActive) { + targetState.empActive = false; + return { success: false, message: `Teleport blocked by ${targetShip.playerId}'s EMP`, gridUpdates: [] }; + } + + const myPos = { ...ctx.ship.position }; + ctx.ship.position = { ...targetShip.position }; + targetShip.position = myPos; + return { success: true, message: `${ctx.ship.playerId} teleported with ${targetShip.playerId}`, gridUpdates: [] }; + } + + case 'PHASE_SHIFT': { + ctx.ship.phaseShifting = true; + return { success: true, message: `${ctx.ship.playerId} activated Phase Shift`, gridUpdates: [] }; + } + + default: + return { success: false, message: 'Unknown card', gridUpdates: [] }; + } +} diff --git a/server/src/game/Executor.ts b/server/src/game/Executor.ts new file mode 100644 index 0000000..4ddf371 --- /dev/null +++ b/server/src/game/Executor.ts @@ -0,0 +1,309 @@ +import { + Action, + PlayerPlan, + Position, + Direction, + DIRECTION_DELTA, + isBlocked as isTileBlocked, + ExecutionResult, + ShipUpdate, + GridUpdate, + CardType, +} from '@spacerace/shared'; +import { Grid } from './Grid.js'; +import { Ship } from './Ship.js'; +import { executeCard } from './CardHandler.js'; + +interface TickPlan { + shipId: string; + actionIndex: number; + action: Action; +} + +export class Executor { + private ships: Map; + private grid: Grid; + private deadPlayers: Set = new Set(); + + constructor(ships: Map, grid: Grid) { + this.ships = ships; + this.grid = grid; + } + + execute(plans: PlayerPlan[]): ExecutionResult[] { + const results: ExecutionResult[] = []; + + // Build tick-based action queue + const planMap = new Map(); + for (const plan of plans) { + const ship = Array.from(this.ships.values()).find((s) => s.playerId === plan.playerId); + if (ship && ship.alive) { + planMap.set(ship.id, [...plan.actions]); + } + } + + // Determine max ticks + let maxTicks = 0; + for (const actions of planMap.values()) { + maxTicks = Math.max(maxTicks, actions.length); + } + + // Execute tick by tick + for (let tick = 0; tick < maxTicks; tick++) { + const result: ExecutionResult = { + tick, + shipUpdates: [], + gridUpdates: [], + messages: [], + }; + + // Phase 1: Resolve card actions first + const cardActions: { shipId: string; action: Action }[] = []; + const nonCardActions: { shipId: string; action: Action }[] = []; + + for (const [shipId, actions] of planMap) { + if (tick >= actions.length) continue; + const action = actions[tick]; + if (action.type === 'CARD') { + cardActions.push({ shipId, action }); + } else { + nonCardActions.push({ shipId, action }); + } + } + + // Execute card actions + for (const { shipId, action } of cardActions) { + const ship = this.ships.get(shipId); + if (!ship || !ship.alive) continue; + + const ctx = { + ship, + target: action.target, + grid: this.grid, + allShips: Array.from(this.ships.values()), + shipStates: this.ships, + }; + + const cardResult = executeCard(action.card as CardType, ctx); + if (cardResult.success) { + result.messages.push(cardResult.message); + for (const gu of cardResult.gridUpdates) { + result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any }); + } + } else { + result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`); + } + } + + // Phase 2: Determine movement intentions + interface MoveIntent { + shipId: string; + from: Position; + to: Position | null; + } + + const intents: MoveIntent[] = []; + + for (const { shipId, action } of nonCardActions) { + const ship = this.ships.get(shipId); + if (!ship || !ship.alive) continue; + + switch (action.type) { + case 'MOVE_FORWARD': { + const delta = DIRECTION_DELTA[ship.direction]; + const to: Position = { + x: ship.position.x + delta.x, + y: ship.position.y + delta.y, + }; + intents.push({ shipId, from: { ...ship.position }, to }); + break; + } + case 'BOOST': { + const delta = DIRECTION_DELTA[ship.direction]; + const to: Position = { + x: ship.position.x + delta.x * 3, + y: ship.position.y + delta.y * 3, + }; + intents.push({ shipId, from: { ...ship.position }, to }); + break; + } + case 'TURN_LEFT': { + const dirs: Direction[] = ['N', 'W', 'S', 'E']; + const idx = dirs.indexOf(ship.direction); + ship.direction = dirs[(idx + 1) % 4]; + result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction }); + result.messages.push(`[${ship.playerId}] turned left`); + intents.push({ shipId, from: { ...ship.position }, to: null }); + break; + } + case 'TURN_RIGHT': { + const dirs: Direction[] = ['N', 'E', 'S', 'W']; + const idx = dirs.indexOf(ship.direction); + ship.direction = dirs[(idx + 1) % 4]; + result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction }); + result.messages.push(`[${ship.playerId}] turned right`); + intents.push({ shipId, from: { ...ship.position }, to: null }); + break; + } + case 'TURN_180': { + const dirs: Direction[] = ['N', 'S', 'E', 'W']; + const map: Record = { N: 'S', S: 'N', E: 'W', W: 'E' }; + ship.direction = map[ship.direction]; + result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction }); + result.messages.push(`[${ship.playerId}] turned 180°`); + intents.push({ shipId, from: { ...ship.position }, to: null }); + break; + } + } + } + + // Phase 3: Resolve movement conflicts + // Count how many ships want to go to each tile + const destinationCount = new Map(); + for (const intent of intents) { + if (!intent.to) continue; + const key = `${intent.to.x},${intent.to.y}`; + if (!destinationCount.has(key)) { + destinationCount.set(key, []); + } + destinationCount.get(key)!.push(intent.shipId); + } + + // Track which ships are leaving their current tile + const leavingPositions = new Set(); + const shipsMovedThisTick = new Set(); + + for (const intent of intents) { + if (!intent.to) continue; + const ship = this.ships.get(intent.shipId); + if (!ship || !ship.alive) continue; + + const destKey = `${intent.to.x},${intent.to.y}`; + const shipsToDest = destinationCount.get(destKey) || []; + + // Conflict: multiple ships want same tile + if (shipsToDest.length > 1) { + result.messages.push(`[${ship.playerId}] collision conflict at (${intent.to.x},${intent.to.y})`); + result.shipUpdates.push({ type: 'collision', shipId: intent.shipId, position: { ...ship.position } }); + continue; + } + + // Check bounds + if (!this.grid.isInBounds(intent.to)) { + result.messages.push(`[${ship.playerId}] blocked by track boundary`); + continue; + } + + // Check obstacles (phase shift ignores them) + if (!ship.phaseShifting && this.grid.isBlocked(intent.to)) { + if (ship.shielded) { + ship.shielded = false; + result.messages.push(`[${ship.playerId}] shield absorbed obstacle`); + result.shipUpdates.push({ type: 'shield_used', shipId: intent.shipId }); + // Still move there with shield + } else { + result.messages.push(`[${ship.playerId}] blocked by obstacle at (${intent.to.x},${intent.to.y})`); + continue; + } + } + + // Check if a ship is already at destination and not moving away + const to = intent.to!; + const occupyingShip = Array.from(this.ships.values()).find( + (s) => s.alive && s.id !== intent.shipId && + s.position.x === to.x && s.position.y === to.y + ); + + if (occupyingShip) { + // Check if occupying ship is also leaving this tick + const occupierLeaving = intents.find( + (i) => i.shipId === occupyingShip.id && i.to !== null + ); + if (!occupierLeaving) { + result.messages.push(`[${ship.playerId}] blocked by ${occupyingShip.playerId}'s ship`); + continue; + } + } + + // Move is valid! + const fromPos = { ...ship.position }; + ship.position = { ...to }; + shipsMovedThisTick.add(intent.shipId); + leavingPositions.add(`${fromPos.x},${fromPos.y}`); + + if (ship.phaseShifting) { + ship.phaseShifting = false; + } + + result.shipUpdates.push({ + type: 'move', + shipId: intent.shipId, + from: fromPos, + to: { ...to }, + }); + result.messages.push(`[${ship.playerId}] moved to (${to.x},${to.y})`); + } + + // Check mine explosions on ships that just moved + for (const shipId of shipsMovedThisTick) { + const ship = this.ships.get(shipId); + if (!ship || !ship.alive) continue; + + if (this.grid.getTile(ship.position) === 'mine') { + ship.alive = false; + this.deadPlayers.add(ship.playerId); + this.grid.setTile(ship.position, 'space'); // mine exploded + result.shipUpdates.push({ type: 'eliminated', shipId, position: { ...ship.position }, reason: 'mine' }); + result.gridUpdates.push({ type: 'tile_change', position: { ...ship.position }, tile: 'space' }); + result.messages.push(`[${ship.playerId}] stepped on a mine and was destroyed!`); + } + } + + results.push(result); + } + + // After all ticks: advance death line and check eliminations + const deathLineResult: ExecutionResult = { + tick: -1, + shipUpdates: [], + gridUpdates: [], + messages: [], + }; + + let leadingY = 0; + for (const ship of this.ships.values()) { + if (ship.alive && ship.position.y > leadingY) { + leadingY = ship.position.y; + } + } + + const newDeathLine = this.grid.advanceDeathLine(leadingY); + deathLineResult.gridUpdates.push({ type: 'death_line', y: newDeathLine }); + + const deathLineX = this.grid.deathLineY; + for (const ship of this.ships.values()) { + if (!ship.alive) continue; + if (ship.position.y < deathLineX) { + ship.alive = false; + this.deadPlayers.add(ship.playerId); + deathLineResult.shipUpdates.push({ + type: 'eliminated', + shipId: ship.id, + position: { ...ship.position }, + reason: 'death_line', + }); + deathLineResult.messages.push(`[${ship.playerId}] fell behind and was eliminated!`); + } + } + + if (deathLineResult.shipUpdates.length > 0 || deathLineResult.gridUpdates.length > 0) { + results.push(deathLineResult); + } + + return results; + } + + getDeadPlayerIds(): Set { + return this.deadPlayers; + } +} diff --git a/server/src/game/Game.ts b/server/src/game/Game.ts new file mode 100644 index 0000000..c145991 --- /dev/null +++ b/server/src/game/Game.ts @@ -0,0 +1,290 @@ +import { + GamePhase, + GameGridState, + GameState as GameStateType, + PlayerPlan, + CardType, + CARD_DEFS, + ExecutionResult, + actionApCost, +} from '@spacerace/shared'; +import { Grid } from './Grid.js'; +import { Ship } from './Ship.js'; +import { Executor } from './Executor.js'; +import { validatePlan, removeUsedCards } from './ActionQueue.js'; +import { GAME_CONFIG } from '../config.js'; + +const ALL_CARDS: CardType[] = Object.keys(CARD_DEFS) as CardType[]; + +function drawCard(): CardType { + return ALL_CARDS[Math.floor(Math.random() * ALL_CARDS.length)]; +} + +function drawHand(): CardType[] { + return Array.from({ length: GAME_CONFIG.MAX_HAND_SIZE }, () => drawCard()); +} + +export interface GamePlayer { + id: string; + name: string; + shipId: string; + hand: CardType[]; + ap: number; + apUsed: number; + actions: import('@spacerace/shared').Action[]; + planSubmitted: boolean; + ready: boolean; + alive: boolean; +} + +export class Game { + roomCode: string; + phase: GamePhase = 'LOBBY'; + round: number = 0; + hostPlayerId: string = ''; + grid: Grid; + ships: Map = new Map(); + players: Map = new Map(); + playerOrder: string[] = []; + + // Callbacks + onPhaseChange?: (phase: GamePhase) => void; + onPlanningStart?: (players: GamePlayer[], grid: GameGridState) => void; + onExecutionTick?: (results: ExecutionResult) => void; + onGameOver?: (winner: GamePlayer) => void; + + // Timer + private planningTimer: ReturnType | null = null; + private planningSecondsLeft: number = 0; + + constructor(roomCode: string) { + this.roomCode = roomCode; + this.grid = new Grid(Date.now()); + } + + get alivePlayers(): number { + let count = 0; + for (const p of this.players.values()) { + if (p.alive) count++; + } + return count; + } + + addPlayer(playerId: string, name: string): GamePlayer { + const usedPositions = Array.from(this.ships.values()).map((s) => s.position); + const startPos = this.grid.findSafeStart(usedPositions, 2, 0) || { x: 3 + this.ships.size, y: 2 }; + + const ship = new Ship(`ship_${playerId}`, playerId, name, startPos, 'E'); + this.ships.set(ship.id, ship); + + const player: GamePlayer = { + id: playerId, + name, + shipId: ship.id, + hand: drawHand(), + ap: GAME_CONFIG.AP_PER_ROUND, + apUsed: 0, + actions: [], + planSubmitted: false, + ready: false, + alive: true, + }; + + this.players.set(playerId, player); + this.playerOrder.push(playerId); + return player; + } + + removePlayer(playerId: string): void { + const player = this.players.get(playerId); + if (!player) return; + + const ship = this.ships.get(player.shipId); + if (ship) { + ship.alive = false; + } + + player.alive = false; + this.playerOrder = this.playerOrder.filter((id) => id !== playerId); + } + + startGame(): boolean { + if (this.phase !== 'LOBBY') return false; + const alive = Array.from(this.players.values()).filter((p) => p.alive); + if (alive.length < GAME_CONFIG.MIN_PLAYERS) return false; + if (alive.length > GAME_CONFIG.MAX_PLAYERS) return false; + + this.phase = 'PLANNING'; + this.round = 1; + this.startPlanningPhase(); + this.onPhaseChange?.('PLANNING'); + return true; + } + + private startPlanningPhase(): void { + // Reset round state for all players + for (const player of this.players.values()) { + if (!player.alive) continue; + player.ap = GAME_CONFIG.AP_PER_ROUND; + player.apUsed = 0; + player.actions = []; + player.planSubmitted = false; + player.ready = false; + + // Reset ship effects + const ship = this.ships.get(player.shipId); + if (ship) ship.resetRoundEffects(); + } + + // Ensure grid is generated ahead of leading ships + let leadingX = -Infinity; + for (const ship of this.ships.values()) { + if (ship.alive && ship.position.y > leadingX) { + leadingX = ship.position.y; + } + } + if (leadingX > -Infinity) { + this.grid.ensureAheadOf(leadingX); + } + + // Start countdown + this.planningSecondsLeft = GAME_CONFIG.PLANNING_TIME_SECONDS; + + this.planningTimer = setInterval(() => { + this.planningSecondsLeft--; + if (this.planningSecondsLeft <= 0) { + this.executeRound(); + } + }, 1000); + } + + submitPlan(playerId: string, actions: import('@spacerace/shared').Action[]): { ok: boolean; error?: string } { + const player = this.players.get(playerId); + if (!player || !player.alive) return { ok: false, error: 'Player not found' }; + if (this.phase !== 'PLANNING') return { ok: false, error: 'Not in planning phase' }; + if (player.planSubmitted) return { ok: false, error: 'Plan already submitted' }; + + const result = validatePlan(actions, player.hand); + if (!result.valid) return { ok: false, error: result.error }; + + player.actions = actions; + player.apUsed = actions.reduce((sum, a) => sum + actionApCost(a), 0); + player.planSubmitted = true; + + // Check if all alive players have submitted + this.checkAllPlansSubmitted(); + return { ok: true }; + } + + private checkAllPlansSubmitted(): void { + const alive = Array.from(this.players.values()).filter((p) => p.alive); + const allSubmitted = alive.every((p) => p.planSubmitted); + if (allSubmitted) { + this.executeRound(); + } + } + + private executeRound(): void { + if (this.planningTimer) { + clearInterval(this.planningTimer); + this.planningTimer = null; + } + + this.phase = 'EXECUTING'; + this.onPhaseChange?.('EXECUTING'); + + // Build plans from submitted players + // Players who didn't submit get an empty plan (they stand still) + const plans: PlayerPlan[] = []; + for (const player of this.players.values()) { + if (!player.alive) continue; + plans.push({ + playerId: player.id, + actions: player.planSubmitted ? [...player.actions] : [], + }); + // Remove used cards from hand + if (player.planSubmitted) { + player.hand = removeUsedCards(player.actions, player.hand); + // Draw new cards to maintain hand size + while (player.hand.length < GAME_CONFIG.MAX_HAND_SIZE) { + player.hand.push(drawCard()); + } + } + } + + // Execute! + const executor = new Executor(this.ships, this.grid); + const results = executor.execute(plans); + + // Emit results one by one + for (const result of results) { + this.onExecutionTick?.(result); + } + + // Mark dead players + const deadIds = executor.getDeadPlayerIds(); + for (const [id, player] of this.players) { + if (deadIds.has(id)) { + player.alive = false; + } + } + + // Check game over + const alivePlayers = Array.from(this.players.values()).filter((p) => p.alive); + if (alivePlayers.length <= 1) { + this.phase = 'FINISHED'; + this.onPhaseChange?.('FINISHED'); + const winner = alivePlayers[0] || Array.from(this.players.values()).find((p) => !p.alive); + if (winner) { + this.onGameOver?.(winner); + } + return; + } + + // Next round + this.round++; + this.phase = 'PLANNING'; + this.onPhaseChange?.('PLANNING'); + this.startPlanningPhase(); + } + + getGridState(): GameGridState { + return { + rows: this.grid.toState(), + deathLineY: this.grid.deathLineY, + generatedUpToY: this.grid.generatedUpToY, + }; + } + + getShipsState(): import('@spacerace/shared').Ship[] { + return Array.from(this.ships.values()).map((s) => s.toState()); + } + + getPlayerState(playerId: string): import('@spacerace/shared').PlayerView | null { + const player = this.players.get(playerId); + if (!player) return null; + + let colorIndex = 0; + for (const [sid, ship] of this.ships) { + if (ship.playerId === playerId) break; + colorIndex++; + } + + return { + playerId: player.id, + name: player.name, + ap: player.ap, + apUsed: player.apUsed, + hand: [...player.hand], + alive: player.alive, + actions: [...player.actions], + colorIndex, + }; + } + + destroy(): void { + if (this.planningTimer) { + clearInterval(this.planningTimer); + } + } +} diff --git a/server/src/game/Grid.ts b/server/src/game/Grid.ts new file mode 100644 index 0000000..483706d --- /dev/null +++ b/server/src/game/Grid.ts @@ -0,0 +1,156 @@ +import { + Position, + TileType, + GRID_WIDTH, +} from '@spacerace/shared'; +import { GAME_CONFIG } from '../config.js'; + +export class Grid { + private cols: Map = new Map(); + private _deathLineX: number; + private _generatedUpToX: number; + private seed: number; + + constructor(seed: number = 42) { + this.seed = seed; + this._deathLineX = 0; + this._generatedUpToX = 12; + this.generateCols(0, 25); + this.clearRunway(0, 20); + } + + private clearRunway(fromX: number, toX: number): void { + const start = Math.min(fromX, toX); + const end = Math.max(fromX, toX); + for (let x = start; x <= end; x++) { + this.cols.set(x, Array(GRID_WIDTH).fill('space')); + } + } + + get deathLineY(): number { + return this._deathLineX; + } + + get generatedUpToY(): number { + return this._generatedUpToX; + } + + private randFor(seed: number): number { + let h = ((this.seed + seed) * 31 + seed * 7 + seed * seed * 13) % 2147483647; + return (h & 0x7fffffff) / 0x7fffffff; + } + + getTile(pos: Position): TileType { + this.ensureCol(pos.y); + const col = this.cols.get(pos.y); + if (!col) return 'space'; + return col[pos.x] ?? 'space'; + } + + private ensureCol(y: number): void { + if (this.cols.has(y)) return; + if (y > this._generatedUpToX) { + this.generateCols(this._generatedUpToX, y); + } else { + this.generateCols(0, y); + } + } + + setTile(pos: Position, tile: TileType): void { + this.ensureCol(pos.y); + const col = this.cols.get(pos.y)!; + col[pos.x] = tile; + } + + isBlocked(pos: Position): boolean { + const tile = this.getTile(pos); + return tile !== 'space'; + } + + isInBounds(pos: Position): boolean { + return pos.x >= 0 && pos.x < GRID_WIDTH; + } + + advanceDeathLine(leadingY?: number): number { + let advance: number = GAME_CONFIG.DEATH_LINE_ADVANCE; + if (leadingY !== undefined) { + const maxBehind = 10; + const distance = leadingY - this._deathLineX; + if (distance > maxBehind) { + advance = Math.max(advance, distance - maxBehind); + } + } + this._deathLineX += advance; + return this._deathLineX; + } + + generateCols(fromX: number, toX: number): void { + const start = Math.min(fromX, toX); + const end = Math.max(fromX, toX); + + for (let x = start; x <= end; x++) { + if (this.cols.has(x)) continue; + + const col: TileType[] = []; + const r = this.randFor(x * 7919); + + const pattern = Math.abs((x * 13 + this.seed * 7) % 5); + + for (let lane = 0; lane < GRID_WIDTH; lane++) { + const tileR = this.randFor(x * 1000 + lane); + switch (pattern) { + case 0: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 0.5 ? 'asteroid' : 'space'); break; + case 1: col.push(lane < 3 && tileR < 0.6 ? 'asteroid' : 'space'); break; + case 2: col.push(lane >= 5 && tileR < 0.6 ? 'asteroid' : 'space'); break; + case 3: col.push((lane < 2 || lane > 5) && tileR < 0.7 ? 'asteroid' : 'space'); break; + case 4: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 1.5 ? 'asteroid' : 'space'); break; + } + } + // Ensure at least 1 lane is open + const blocked = col.every((t) => t !== 'space'); + if (blocked) { + const openLane = Math.floor(this.randFor(x * 31337) * GRID_WIDTH); + (col as any)[openLane] = 'space'; + } + this.cols.set(x, col); + } + + if (toX > this._generatedUpToX) { + this._generatedUpToX = Math.max(this._generatedUpToX, toX); + } + } + + ensureAheadOf(leadingX: number): number { + const neededX = leadingX + 5; + if (neededX > this._generatedUpToX) { + this.generateCols(this._generatedUpToX + 1, neededX); + this._generatedUpToX = neededX; + } + return this._generatedUpToX; + } + + toState(): Record { + const state: Record = {}; + for (const [x, col] of this.cols) { + state[x] = [...col]; + } + return state; + } + + findSafeStart(usedPositions: Position[], startX: number, columnSpread: number = 2): Position | null { + const candidates: Position[] = []; + for (let lane = 0; lane < GRID_WIDTH; lane++) { + for (let colX = startX; colX <= startX + columnSpread; colX++) { + const pos = { x: lane, y: colX }; + if ( + !this.isBlocked(pos) && + !usedPositions.some((p) => p.y === pos.y && p.x === pos.x) + ) { + candidates.push(pos); + } + } + } + if (candidates.length === 0) return null; + return candidates[Math.floor(this.randFor(startX * 7) * candidates.length)]; + } +} diff --git a/server/src/game/Ship.ts b/server/src/game/Ship.ts new file mode 100644 index 0000000..673cc34 --- /dev/null +++ b/server/src/game/Ship.ts @@ -0,0 +1,45 @@ +import { Ship as ShipType, Position, Direction, posEqual } from '@spacerace/shared'; + +export class Ship { + id: string; + playerId: string; + playerName: string; + position: Position; + direction: Direction; + alive: boolean; + shielded: boolean; + phaseShifting: boolean; + empActive: boolean; + boostSteps: number; + + constructor(id: string, playerId: string, playerName: string, position: Position, direction: Direction) { + this.id = id; + this.playerId = playerId; + this.playerName = playerName; + this.position = { ...position }; + this.direction = direction; + this.alive = true; + this.shielded = false; + this.phaseShifting = false; + this.empActive = false; + this.boostSteps = 0; + } + + resetRoundEffects(): void { + this.shielded = false; + this.phaseShifting = false; + this.empActive = false; + this.boostSteps = 0; + } + + toState(): ShipType { + return { + id: this.id, + playerId: this.playerId, + playerName: this.playerName, + position: { ...this.position }, + direction: this.direction, + alive: this.alive, + }; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..4abae3b --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,41 @@ +import express from 'express'; +import cors from 'cors'; +import { createServer } from 'http'; +import { WsServer } from './ws/WsServer.js'; +import { GAME_CONFIG } from './config.js'; + +const app = express(); +app.use(cors()); +app.use(express.json()); + +const httpServer = createServer(app); + +const wsServer = new WsServer(httpServer); + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', uptime: process.uptime() }); +}); + +httpServer.listen(GAME_CONFIG.SERVE_PORT, () => { + console.log(`[Server] SpaceRace server running on port ${GAME_CONFIG.SERVE_PORT}`); + console.log(`[Server] TV: http://localhost:${GAME_CONFIG.SERVE_PORT}`); + console.log(`[Server] Mobile: http://localhost:${GAME_CONFIG.SERVE_PORT}`); +}); + +// ── Graceful shutdown ── +function shutdown(signal: string) { + console.log(`\n[Server] Received ${signal}, shutting down...`); + wsServer.destroy(); + httpServer.close(() => { + console.log('[Server] HTTP server closed'); + process.exit(0); + }); + // Force exit after 3s + setTimeout(() => { + console.log('[Server] Force exiting'); + process.exit(1); + }, 3000); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/server/src/lobby/LobbyManager.ts b/server/src/lobby/LobbyManager.ts new file mode 100644 index 0000000..1a60cec --- /dev/null +++ b/server/src/lobby/LobbyManager.ts @@ -0,0 +1,127 @@ +import { Game } from '../game/Game.js'; +import { GAME_CONFIG } from '../config.js'; + +function generateRoomCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 4; i++) { + code += chars[Math.floor(Math.random() * chars.length)]; + } + return code; +} + +function generatePlayerId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +export class LobbyManager { + private rooms: Map = new Map(); + private playerRooms: Map = new Map(); // playerId -> roomCode + private hostPlayers: Map = new Map(); // roomCode -> hostPlayerId + + createRoom(): { roomCode: string; game: Game } { + let code: string; + do { + code = generateRoomCode(); + } while (this.rooms.has(code)); + + const game = new Game(code); + this.rooms.set(code, game); + return { roomCode: code, game }; + } + + joinRoom(roomCode: string, playerName: string): { ok: boolean; playerId?: string; error?: string; game?: Game; isHost?: boolean } { + const game = this.rooms.get(roomCode.toUpperCase()); + if (!game) return { ok: false, error: 'Room not found' }; + if (game.phase !== 'LOBBY') return { ok: false, error: 'Game already started' }; + + const aliveCount = Array.from(game.players.values()).filter((p) => p.alive).length; + if (aliveCount >= GAME_CONFIG.MAX_PLAYERS) return { ok: false, error: 'Room is full' }; + + const playerId = generatePlayerId(); + game.addPlayer(playerId, playerName); + this.playerRooms.set(playerId, roomCode.toUpperCase()); + + // First player becomes host + const uc = roomCode.toUpperCase(); + let isHost = false; + if (!this.hostPlayers.has(uc)) { + this.hostPlayers.set(uc, playerId); + game.hostPlayerId = playerId; + isHost = true; + } + + return { ok: true, playerId, game, isHost }; + } + + getGame(roomCode: string): Game | undefined { + return this.rooms.get(roomCode.toUpperCase()); + } + + getGameForPlayer(playerId: string): Game | undefined { + const roomCode = this.playerRooms.get(playerId); + if (!roomCode) return undefined; + return this.rooms.get(roomCode); + } + + getHostPlayerId(roomCode: string): string | undefined { + return this.hostPlayers.get(roomCode.toUpperCase()); + } + + private reassignHost(roomCode: string, game: Game): void { + const uc = roomCode.toUpperCase(); + this.hostPlayers.delete(uc); + // First alive player becomes new host + for (const player of game.players.values()) { + if (player.alive) { + this.hostPlayers.set(uc, player.id); + game.hostPlayerId = player.id; + return; + } + } + } + + removePlayer(playerId: string): Game | undefined { + const roomCode = this.playerRooms.get(playerId); + if (!roomCode) return undefined; + const game = this.rooms.get(roomCode); + if (!game) return undefined; + + const wasHost = this.hostPlayers.get(roomCode) === playerId; + + game.removePlayer(playerId); + this.playerRooms.delete(playerId); + + if (game.alivePlayers === 0) { + game.destroy(); + this.rooms.delete(roomCode); + this.hostPlayers.delete(roomCode); + } else if (wasHost) { + this.reassignHost(roomCode, game); + } + + return game; + } + + removeRoom(roomCode: string): void { + const game = this.rooms.get(roomCode.toUpperCase()); + if (game) { + game.destroy(); + this.rooms.delete(roomCode.toUpperCase()); + } + } + + destroyAll(): void { + for (const [code, game] of this.rooms) { + game.destroy(); + } + this.rooms.clear(); + this.playerRooms.clear(); + this.hostPlayers.clear(); + } +} diff --git a/server/src/ws/WsServer.ts b/server/src/ws/WsServer.ts new file mode 100644 index 0000000..ebb1434 --- /dev/null +++ b/server/src/ws/WsServer.ts @@ -0,0 +1,276 @@ +import { Server as HttpServer } from 'http'; +import { Server, Socket } from 'socket.io'; +import { LobbyManager } from '../lobby/LobbyManager.js'; +import { Game } from '../game/Game.js'; +import { GamePlayer } from '../game/Game.js'; +import { + ServerToTvEvents, + TvToServerEvents, + ServerToMobileEvents, + MobileToServerEvents, + ExecutionResult, + GameGridState, + PlayerView, + Ship, +} from '@spacerace/shared'; + +export class WsServer { + private io: Server; + private lobby: LobbyManager; + + constructor(httpServer: HttpServer) { + this.lobby = new LobbyManager(); + + this.io = new Server(httpServer, { + cors: { + origin: '*', + methods: ['GET', 'POST'], + }, + pingTimeout: 5000, + pingInterval: 10000, + }); + + this.io.on('connection', (socket: Socket) => { + this.handleConnection(socket); + }); + + console.log('[WS] Server initialized'); + } + + destroy(): void { + console.log('[WS] Shutting down...'); + this.io.close(); + // Clear all game timers by destroying rooms + this.lobby.destroyAll(); + } + + private handleConnection(socket: Socket): void { + console.log(`[WS] New connection: ${socket.id}`); + + socket.on('disconnect', () => { + this.handleDisconnect(socket); + }); + + // ── TV Events ── + socket.on('tv:createRoom', (data: {}, callback: (res: { roomCode: string }) => void) => { + const { roomCode, game } = this.lobby.createRoom(); + socket.join(`room:${roomCode}`); + socket.data.roomCode = roomCode; + socket.data.isTv = true; + + this.bindGameEvents(game, roomCode); + callback({ roomCode }); + console.log(`[WS] Room created: ${roomCode}`); + }); + + socket.on('tv:startGame', (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => { + const game = this.lobby.getGame(data.roomCode); + if (!game) { + callback({ ok: false }); + return; + } + + const ok = game.startGame(); + callback({ ok }); + if (ok) { + console.log(`[WS] Game started in room ${data.roomCode}`); + } + }); + + // ── Mobile Events ── + socket.on('mobile:joinRoom', ( + data: { roomCode: string; playerName: string }, + callback: (res: { ok: boolean; playerId?: string; error?: string }) => void + ) => { + const result = this.lobby.joinRoom(data.roomCode, data.playerName); + if (!result.ok || !result.playerId || !result.game) { + callback({ ok: false, error: result.error }); + return; + } + + socket.join(`room:${result.game.roomCode}`); + socket.data.roomCode = result.game.roomCode; + socket.data.playerId = result.playerId; + socket.data.isTv = false; + + // Notify TV + this.io.to(`room:${result.game.roomCode}`).emit('playerJoined', { + playerId: result.playerId, + name: data.playerName, + }); + + // Send back player info + callback({ ok: true, playerId: result.playerId }); + + // Send current player list + const players = Array.from(result.game.players.values()) + .filter((p) => p.alive) + .map((p) => ({ id: p.id, name: p.name })); + + socket.emit('roomJoined', { + playerId: result.playerId, + roomCode: result.game.roomCode, + players, + isHost: result.isHost ?? false, + }); + + console.log(`[WS] ${data.playerName} joined room ${result.game.roomCode}`); + }); + + socket.on('mobile:submitPlan', ( + data: { actions: import('@spacerace/shared').Action[] }, + callback: (res: { ok: boolean; error?: string }) => void + ) => { + const playerId = socket.data.playerId; + const roomCode = socket.data.roomCode; + if (!playerId || !roomCode) { + callback({ ok: false, error: 'Not in a room' }); + return; + } + + const game = this.lobby.getGame(roomCode); + if (!game) { + callback({ ok: false, error: 'Game not found' }); + return; + } + + const result = game.submitPlan(playerId, data.actions); + callback(result); + }); + + socket.on('mobile:setReady', ( + data: {}, + callback: (res: { ok: boolean }) => void + ) => { + callback({ ok: true }); + }); + + // Mobile host can start game + socket.on('mobile:startGame', ( + data: { roomCode: string }, + callback: (res: { ok: boolean; error?: string }) => void + ) => { + const playerId = socket.data.playerId; + const roomCode = socket.data.roomCode || data.roomCode; + if (!roomCode) { + callback({ ok: false, error: 'No room code' }); + return; + } + + const game = this.lobby.getGame(roomCode); + if (!game) { + callback({ ok: false, error: 'Game not found' }); + return; + } + + if (game.phase !== 'LOBBY') { + callback({ ok: false, error: 'Game already started' }); + return; + } + + // Only host can start + const hostId = this.lobby.getHostPlayerId(roomCode); + if (hostId && playerId && hostId !== playerId) { + callback({ ok: false, error: 'Only the host can start the game' }); + return; + } + + const ok = game.startGame(); + callback({ ok: ok ? true : false, error: ok ? undefined : `Need at least ${2} players` }); + if (ok) { + console.log(`[WS] Game started by mobile host in room ${roomCode}`); + } + }); + } + + private handleDisconnect(socket: Socket): void { + console.log(`[WS] Disconnected: ${socket.id}`); + + if (socket.data.isTv && socket.data.roomCode) { + // TV disconnected - remove room + this.io.to(`room:${socket.data.roomCode}`).emit('roomClosed', {}); + this.lobby.removeRoom(socket.data.roomCode); + } else if (socket.data.playerId) { + const game = this.lobby.removePlayer(socket.data.playerId); + if (game) { + this.io.to(`room:${game.roomCode}`).emit('playerLeft', { + playerId: socket.data.playerId, + }); + // Notify about new host + const newHost = this.lobby.getHostPlayerId(game.roomCode); + if (newHost) { + this.io.to(`room:${game.roomCode}`).emit('hostChanged', { + hostPlayerId: newHost, + }); + } + } + } + } + + private bindGameEvents(game: Game, roomCode: string): void { + game.onPhaseChange = (phase) => { + if (phase === 'PLANNING') { + const gridState = game.getGridState(); + const ships = game.getShipsState(); + + // First planning phase = game just started, notify TV to switch scenes + if (game.round === 1) { + const players = Array.from(game.players.values()) + .filter((p) => p.alive) + .map((p) => ({ id: p.id, name: p.name })); + this.io.to(`room:${roomCode}`).emit('gameStarting', { + players, + round: game.round, + grid: gridState, + ships, + }); + } + + this.io.to(`room:${roomCode}`).emit('planningStarted', { + round: game.round, + timer: 45, + grid: gridState, + ships, + }); + + // Send individual planning requests to each mobile client + for (const [playerId, player] of game.players) { + if (!player.alive) continue; + const playerView = game.getPlayerState(playerId); + if (!playerView) continue; + + // Find the mobile socket for this player + const sockets = this.io.sockets.adapter.rooms.get(`room:${roomCode}`); + if (!sockets) continue; + + for (const socketId of sockets) { + const sock = this.io.sockets.sockets.get(socketId); + if (sock && sock.data.playerId === playerId) { + sock.emit('planningRequest', { + round: game.round, + playerView, + grid: gridState, + ships, + timer: 45, + }); + break; + } + } + } + } + }; + + game.onExecutionTick = (result: ExecutionResult) => { + this.io.to(`room:${roomCode}`).emit('executionTick', result); + }; + + game.onGameOver = (winner) => { + const ships = game.getShipsState(); + this.io.to(`room:${roomCode}`).emit('gameOver', { + winnerId: winner.id, + winnerName: winner.name, + ships, + }); + }; + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..fc24970 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..361fcf5 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,13 @@ +{ + "name": "@spacerace/shared", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..d470296 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1 @@ +export * from './types.js'; diff --git a/shared/src/types.ts b/shared/src/types.ts new file mode 100644 index 0000000..9106ada --- /dev/null +++ b/shared/src/types.ts @@ -0,0 +1,247 @@ +// ── Grid & Position ── +export const GRID_WIDTH = 8; +export const VISIBLE_ROWS = 10; +export const AP_PER_ROUND = 4; +export const DEATH_LINE_ADVANCE = 1; +export const MAX_HAND_SIZE = 3; + +export interface Position { + x: number; // 0..GRID_WIDTH-1 + y: number; // infinite positive direction (track goes up) +} + +export type Direction = 'N' | 'E' | 'S' | 'W'; + +export const DIRECTION_DELTA: Record = { + N: { x: -1, y: 0 }, // up on screen = decrease lane + E: { x: 0, y: 1 }, // right on screen = increase track position + S: { x: 1, y: 0 }, // down on screen = increase lane + W: { x: 0, y: -1 }, // left on screen = decrease track position +}; + +export function turnLeft(dir: Direction): Direction { + const order: Direction[] = ['N', 'W', 'S', 'E']; + return order[(order.indexOf(dir) + 1) % 4]; +} + +export function turnRight(dir: Direction): Direction { + const order: Direction[] = ['N', 'E', 'S', 'W']; + return order[(order.indexOf(dir) + 1) % 4]; +} + +export function turn180(dir: Direction): Direction { + const order: Direction[] = ['N', 'S', 'E', 'W']; + const map: Record = { N: 'S', S: 'N', E: 'W', W: 'E' }; + return map[dir]; +} + +export function posEqual(a: Position, b: Position): boolean { + return a.x === b.x && a.y === b.y; +} + +// ── Tiles ── +export type TileType = 'space' | 'asteroid' | 'meteor' | 'mine' | 'debris'; + +export function isBlocked(tile: TileType): boolean { + return tile !== 'space'; +} + +// ── Ships ── +export interface Ship { + id: string; + playerId: string; + playerName: string; + position: Position; + direction: Direction; + alive: boolean; +} + +// ── Cards ── +export type CardType = + | 'METEOR_STRIKE' + | 'SHIELD' + | 'BOOST' + | 'EMP' + | 'JUMP' + | 'MINE' + | 'TELEPORT' + | 'PHASE_SHIFT'; + +export interface CardDef { + type: CardType; + name: string; + description: string; + apCost: number; +} + +export const CARD_DEFS: Record = { + METEOR_STRIKE: { + type: 'METEOR_STRIKE', + name: 'Meteor Strike', + description: 'Summon a meteor on any tile within 3 tiles of your ship', + apCost: 2, + }, + SHIELD: { + type: 'SHIELD', + name: 'Shield', + description: 'Ignore the next obstacle collision this round', + apCost: 1, + }, + BOOST: { + type: 'BOOST', + name: 'Boost', + description: 'Move 3 tiles forward instead of 1', + apCost: 2, + }, + EMP: { + type: 'EMP', + name: 'EMP', + description: 'Cancel the next enemy action that targets you', + apCost: 2, + }, + JUMP: { + type: 'JUMP', + name: 'Jump', + description: 'Jump over 1 obstacle tile directly in front of you', + apCost: 2, + }, + MINE: { + type: 'MINE', + name: 'Mine', + description: 'Drop a mine on your current tile. Explodes next round.', + apCost: 1, + }, + TELEPORT: { + type: 'TELEPORT', + name: 'Teleport', + description: 'Swap positions with any player within 5 tiles', + apCost: 3, + }, + PHASE_SHIFT: { + type: 'PHASE_SHIFT', + name: 'Phase Shift', + description: 'Ignore all obstacles during your next move', + apCost: 2, + }, +}; + +// ── Actions ── +export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | 'CARD'; + +export interface Action { + type: ActionType; + card?: CardType; // when type === 'CARD' + target?: Position; // for targeted cards (METEOR_STRIKE, TELEPORT) +} + +export function actionApCost(action: Action): number { + switch (action.type) { + case 'MOVE_FORWARD': return 1; + case 'TURN_LEFT': return 1; + case 'TURN_RIGHT': return 1; + case 'TURN_180': return 2; + case 'BOOST': return 2; + case 'CARD': + return action.card ? CARD_DEFS[action.card].apCost : 0; + default: return 0; + } +} + +// ── Plan ── +export interface PlayerPlan { + playerId: string; + actions: Action[]; +} + +// ── Game Phases ── +export type GamePhase = 'LOBBY' | 'PLANNING' | 'EXECUTING' | 'FINISHED'; + +// ── Game State ── +export interface GameGridState { + rows: Record; // y -> row array + deathLineY: number; // any ship with y > deathLineY is eliminated + generatedUpToY: number; // highest y generated so far (negative = upward) +} + +export interface GameState { + phase: GamePhase; + round: number; + grid: GameGridState; + ships: Ship[]; + playerPlans: PlayerPlan[]; + planningTimer: number; // seconds left in planning + alivePlayers: number; +} + +// ── Player (mobile view) ── +export interface PlayerView { + playerId: string; + name: string; + ap: number; + apUsed: number; + hand: CardType[]; + alive: boolean; + actions: Action[]; + colorIndex: number; +} + +// ── WebSocket Events ── +// TV <-> Server +export interface ServerToTvEvents { + roomCreated: (data: { roomCode: string }) => void; + playerJoined: (data: { playerId: string; name: string }) => void; + playerLeft: (data: { playerId: string }) => void; + gameStarting: (data: { players: { id: string; name: string }[]; round: number; grid: GameGridState; ships: Ship[] }) => void; + planningStarted: (data: { round: number; timer: number; grid: GameGridState; ships: Ship[] }) => void; + executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void; + executionComplete: (data: { round: number; ships: Ship[]; grid: GameGridState }) => void; + gameOver: (data: { winnerId: string; winnerName: string; ships: Ship[] }) => void; +} + +export interface TvToServerEvents { + createRoom: (data: {}, callback: (res: { roomCode: string }) => void) => void; + startGame: (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => void; +} + +// Mobile <-> Server +export interface ServerToMobileEvents { + roomJoined: (data: { playerId: string; roomCode: string; players: { id: string; name: string }[]; isHost: boolean }) => void; + playerJoined: (data: { playerId: string; name: string }) => void; + playerLeft: (data: { playerId: string }) => void; + gameStarting: (data: {}) => void; + planningRequest: (data: { round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number }) => void; + executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void; + executionComplete: (data: { round: number; playerView: PlayerView }) => void; + planRejected: (data: { reason: string }) => void; + gameOver: (data: { winnerId: string; winnerName: string }) => void; + hostChanged: (data: { hostPlayerId: string }) => void; + error: (data: { message: string }) => void; +} + +export interface MobileToServerEvents { + joinRoom: (data: { roomCode: string; playerName: string }, callback: (res: { ok: boolean; playerId?: string; error?: string }) => void) => void; + submitPlan: (data: { actions: Action[] }, callback: (res: { ok: boolean; error?: string }) => void) => void; + setReady: (data: {}, callback: (res: { ok: boolean }) => void) => void; + startGame: (data: { roomCode: string }, callback: (res: { ok: boolean; error?: string }) => void) => void; +} + +// Execution updates +export type ShipUpdate = + | { type: 'move'; shipId: string; from: Position; to: Position } + | { type: 'turn'; shipId: string; direction: Direction } + | { type: 'eliminated'; shipId: string; position: Position; reason: string } + | { type: 'collision'; shipId: string; position: Position } + | { type: 'shield_used'; shipId: string }; + +export type GridUpdate = + | { type: 'tile_change'; position: Position; tile: TileType } + | { type: 'death_line'; y: number } + | { type: 'generation'; rows: Record }; + +// ── Result ── +export interface ExecutionResult { + tick: number; + shipUpdates: ShipUpdate[]; + gridUpdates: GridUpdate[]; + messages: string[]; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..1371b94 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/tv/index.html b/tv/index.html new file mode 100644 index 0000000..468f314 --- /dev/null +++ b/tv/index.html @@ -0,0 +1,17 @@ + + + + + + SpaceRace - TV + + + +
    + + + diff --git a/tv/package.json b/tv/package.json new file mode 100644 index 0000000..4c8574b --- /dev/null +++ b/tv/package.json @@ -0,0 +1,19 @@ +{ + "name": "@spacerace/tv", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite --port 3000", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@spacerace/shared": "*", + "phaser": "^3.80.1", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^5.3.0" + } +} diff --git a/tv/src/main.ts b/tv/src/main.ts new file mode 100644 index 0000000..9cb7582 --- /dev/null +++ b/tv/src/main.ts @@ -0,0 +1,20 @@ +import Phaser from 'phaser'; +import { BootScene } from './scenes/BootScene.js'; +import { LobbyScene } from './scenes/LobbyScene.js'; +import { GameScene } from './scenes/GameScene.js'; +import { ResultScene } from './scenes/ResultScene.js'; + +const config: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a0a1a', + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene: [BootScene, LobbyScene, GameScene, ResultScene], +}; + +new Phaser.Game(config); diff --git a/tv/src/network/TvSocket.ts b/tv/src/network/TvSocket.ts new file mode 100644 index 0000000..336058a --- /dev/null +++ b/tv/src/network/TvSocket.ts @@ -0,0 +1,11 @@ +import { io, Socket } from 'socket.io-client'; +import { ServerToTvEvents, TvToServerEvents, ExecutionResult, Ship, GameGridState } from '@spacerace/shared'; + +export type TvSocket = Socket; + +export function connectTvSocket(): TvSocket { + const socket: TvSocket = io('/', { + transports: ['websocket', 'polling'], + }); + return socket; +} diff --git a/tv/src/objects/GridRenderer.ts b/tv/src/objects/GridRenderer.ts new file mode 100644 index 0000000..8773e8f --- /dev/null +++ b/tv/src/objects/GridRenderer.ts @@ -0,0 +1,160 @@ +import Phaser from 'phaser'; +import { + GameGridState, + TileType, + Position, + GRID_WIDTH, +} from '@spacerace/shared'; + +const TILE_SIZE = 64; +const GRID_HEIGHT = GRID_WIDTH; +const VISIBLE_COLS = 20; +const DEATH_LINE_SCREEN_X = TILE_SIZE / 2; + +export class GridRenderer { + private scene: Phaser.Scene; + private offsetY: number; + private tileSize: number; + private fieldContainer: Phaser.GameObjects.Container; + private shipLayer: Phaser.GameObjects.Container; + private deathLineRect: Phaser.GameObjects.Rectangle | null = null; + private deathLineY: number = 0; + + constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) { + this.scene = scene; + this.offsetY = offsetY; + this.tileSize = tileSize; + this.fieldContainer = scene.add.container(0, 0); + this.fieldContainer.setDepth(0); + this.shipLayer = scene.add.container(0, 0); + this.shipLayer.setDepth(10); + } + + get viewOffset(): number { + return this.deathLineY; + } + + get field(): Phaser.GameObjects.Container { + return this.fieldContainer; + } + + get ships(): Phaser.GameObjects.Container { + return this.shipLayer; + } + + gridXToScreen(gridY: number): number { + const relativeX = gridY - this.deathLineY; + return relativeX * this.tileSize + this.tileSize / 2; + } + + gridYToScreen(gridX: number): number { + return this.offsetY + gridX * this.tileSize + this.tileSize / 2; + } + + renderGrid(grid: GameGridState, _ships: any[], scrollInPx: number = 0): void { + this.fieldContainer.removeAll(true); + this.deathLineY = grid.deathLineY; + + const startCol = this.deathLineY; + const endCol = this.deathLineY + VISIBLE_COLS; + + for (let col = startCol; col < endCol; col++) { + const column = grid.rows[col] || Array(GRID_HEIGHT).fill('space'); + const screenX = this.gridXToScreen(col); + + for (let lane = 0; lane < GRID_HEIGHT; lane++) { + const tile = (column[lane] as TileType) || 'space'; + const screenY = this.gridYToScreen(lane); + + this.renderCell(col, lane, tile, screenX, screenY); + } + } + + if (scrollInPx > 0) { + this.fieldContainer.x = -scrollInPx; + this.shipLayer.x = -scrollInPx; + this.scene.tweens.add({ + targets: [this.fieldContainer, this.shipLayer], + x: 0, + duration: 600, + ease: 'Sine.easeInOut', + }); + } else { + this.fieldContainer.x = 0; + this.shipLayer.x = 0; + } + + this.renderDeathLine(); + } + + private renderCell(col: number, lane: number, tile: TileType, screenX: number, screenY: number): void { + const bgAlpha = tile === 'space' ? 0.12 : 0.35; + const bgColor = + tile === 'asteroid' ? 0x555555 : + tile === 'meteor' ? 0x884400 : + tile === 'mine' ? 0x885500 : + 0x1a1a4e; + + const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha); + bg.setStrokeStyle(1, 0x223366, 0.2); + this.fieldContainer.add(bg); + + if (tile !== 'space') { + const textureName = + tile === 'asteroid' ? 'asteroid' : + tile === 'meteor' ? 'meteor' : + 'mine'; + + const sprite = this.scene.add.sprite(screenX, screenY, textureName); + sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12); + this.fieldContainer.add(sprite); + } + } + + updateTile(pos: Position, tile: TileType): void { + const screenX = this.gridXToScreen(pos.y); + const screenY = this.gridYToScreen(pos.x); + + this.renderCell(pos.y, pos.x, tile, screenX, screenY); + } + + private renderDeathLine(): void { + if (this.deathLineRect) this.deathLineRect.destroy(); + + this.deathLineRect = this.scene.add.rectangle( + DEATH_LINE_SCREEN_X, + this.offsetY + (GRID_HEIGHT * this.tileSize) / 2, + 6, + GRID_HEIGHT * this.tileSize, + 0xff0000, + 0.9 + ); + this.deathLineRect.setDepth(20); + + this.scene.tweens.add({ + targets: this.deathLineRect, + alpha: 0.3, + duration: 600, + yoyo: true, + repeat: -1, + }); + } + + animateDeathLine(deathLineY: number, duration: number, onComplete?: () => void): void { + const shiftPx = (deathLineY - this.deathLineY) * this.tileSize; + this.deathLineY = deathLineY; + + if (shiftPx <= 0) { + onComplete?.(); + return; + } + + this.scene.tweens.add({ + targets: [this.fieldContainer, this.shipLayer], + x: this.fieldContainer.x - shiftPx, + duration, + ease: 'Sine.easeInOut', + onComplete: () => onComplete?.(), + }); + } +} diff --git a/tv/src/objects/ShipSprite.ts b/tv/src/objects/ShipSprite.ts new file mode 100644 index 0000000..357a8f0 --- /dev/null +++ b/tv/src/objects/ShipSprite.ts @@ -0,0 +1,52 @@ +import Phaser from 'phaser'; +import { Ship as ShipType } from '@spacerace/shared'; + +export class ShipSprite extends Phaser.GameObjects.Container { + private shipData: ShipType; + private label: Phaser.GameObjects.Text; + + constructor( + scene: Phaser.Scene, + x: number, + y: number, + ship: ShipType, + color: number, + ) { + super(scene, x, y); + this.shipData = ship; + + const angles: Record = { N: 0, E: 90, S: 180, W: 270 }; + const baseAngle = angles[ship.direction] || 0; + + // Ship body + const body = scene.add.rectangle(0, 0, 40, 36, color); + body.setStrokeStyle(2, 0xffffff); + + // Direction indicator (small triangle) + const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff); + + this.add([body, indicator]); + this.setAngle(baseAngle); + + // Player name label + this.label = scene.add.text(0, 28, ship.playerName || ship.playerId.substring(0, 6), { + fontSize: '11px', + color: '#ffffff', + fontFamily: 'monospace', + backgroundColor: '#00000088', + padding: { x: 2, y: 1 }, + }).setOrigin(0.5); + this.add(this.label); + + scene.add.existing(this); + this.setDepth(10); + } + + updateState(ship: ShipType): void { + this.shipData = ship; + + if (!ship.alive) { + this.setAlpha(0.3); + } + } +} diff --git a/tv/src/scenes/BootScene.ts b/tv/src/scenes/BootScene.ts new file mode 100644 index 0000000..dfc3d08 --- /dev/null +++ b/tv/src/scenes/BootScene.ts @@ -0,0 +1,69 @@ +import Phaser from 'phaser'; +import { connectTvSocket, TvSocket } from '../network/TvSocket.js'; + +export class BootScene extends Phaser.Scene { + socket!: TvSocket; + + constructor() { + super({ key: 'BootScene' }); + } + + preload(): void { + // Generate placeholder assets as textures + this.createPlaceholderTextures(); + } + + create(): void { + this.socket = connectTvSocket(); + this.scene.start('LobbyScene', { socket: this.socket }); + } + + private createPlaceholderTextures(): void { + // Ship placeholder (triangle pointing up) + const shipGfx = this.make.graphics({ x: 0, y: 0, add: false }); + shipGfx.fillStyle(0x00ccff); + shipGfx.fillTriangle(20, 0, 0, 36, 40, 36); + shipGfx.generateTexture('ship', 40, 36); + shipGfx.destroy(); + + // Asteroid placeholder (rough circle) + const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false }); + asteroidGfx.fillStyle(0x888888); + asteroidGfx.fillCircle(20, 20, 18); + asteroidGfx.generateTexture('asteroid', 40, 40); + asteroidGfx.destroy(); + + // Meteor placeholder (red circle) + const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false }); + meteorGfx.fillStyle(0xff4400); + meteorGfx.fillCircle(20, 20, 18); + meteorGfx.generateTexture('meteor', 40, 40); + meteorGfx.destroy(); + + // Mine placeholder (orange circle with X) + const mineGfx = this.make.graphics({ x: 0, y: 0, add: false }); + mineGfx.fillStyle(0xff8800); + mineGfx.fillCircle(20, 20, 16); + mineGfx.lineStyle(3, 0x000000); + mineGfx.lineBetween(10, 10, 30, 30); + mineGfx.lineBetween(30, 10, 10, 30); + mineGfx.generateTexture('mine', 40, 40); + mineGfx.destroy(); + + // Background tile + const bgGfx = this.make.graphics({ x: 0, y: 0, add: false }); + bgGfx.fillStyle(0x0a0a2e); + bgGfx.fillRect(0, 0, 64, 64); + bgGfx.lineStyle(1, 0x1a1a4e); + bgGfx.strokeRect(0, 0, 64, 64); + bgGfx.generateTexture('bg_tile', 64, 64); + bgGfx.destroy(); + + // Death line texture + const dlGfx = this.make.graphics({ x: 0, y: 0, add: false }); + dlGfx.fillStyle(0xff0000, 0.6); + dlGfx.fillRect(0, 0, 512, 4); + dlGfx.generateTexture('death_line', 512, 4); + dlGfx.destroy(); + } +} diff --git a/tv/src/scenes/GameScene.ts b/tv/src/scenes/GameScene.ts new file mode 100644 index 0000000..46130bd --- /dev/null +++ b/tv/src/scenes/GameScene.ts @@ -0,0 +1,252 @@ +import Phaser from 'phaser'; +import { TvSocket } from '../network/TvSocket.js'; +import { + Ship as ShipType, + GameGridState, + ExecutionResult, + GRID_WIDTH, + ShipUpdate, + GridUpdate, +} from '@spacerace/shared'; +import { GridRenderer } from '../objects/GridRenderer.js'; +import { ShipSprite } from '../objects/ShipSprite.js'; + +const TILE_SIZE = 64; +const GRID_OFFSET_Y = (720 - GRID_WIDTH * TILE_SIZE) / 2; +const ANIM_DURATION = 900; +const DEATH_LINE_ANIM = 1800; + +export class GameScene extends Phaser.Scene { + private socket!: TvSocket; + private gridRenderer!: GridRenderer; + private shipSprites: Map = new Map(); + private gridState!: GameGridState; + private roundText!: Phaser.GameObjects.Text; + private statusText!: Phaser.GameObjects.Text; + private messageText!: Phaser.GameObjects.Text; + private initialGrid: GameGridState | null = null; + private initialShips: ShipType[] | null = null; + private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = []; + private animating = false; + private pendingDeathLineY: number | null = null; + private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null; + + constructor() { + super({ key: 'GameScene' }); + } + + init(data: { socket: TvSocket; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void { + this.socket = data.socket; + this.initialGrid = data.grid ?? null; + this.initialShips = data.ships ?? null; + } + + create(): void { + this.cameras.main.setBackgroundColor('#050515'); + this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE); + + this.roundText = this.add.text(16, 16, 'Round 0', { + fontSize: '24px', color: '#ffffff', fontFamily: 'monospace', + }); + this.statusText = this.add.text(1280 / 2, 16, 'PLANNING PHASE', { + fontSize: '24px', color: '#ffcc00', fontFamily: 'monospace', fontStyle: 'bold', + }).setOrigin(0.5, 0); + this.messageText = this.add.text(16, 700, '', { + fontSize: '17px', color: '#cccccc', fontFamily: 'monospace', wordWrap: { width: 1248 }, + }); + + if (this.initialGrid && this.initialShips) { + this.applyPlanning(1, this.initialGrid, this.initialShips); + } + + this.socket.on('planningStarted', (data) => { + if (data.round === 1 && this.initialGrid) { + this.roundText.setText(`Round ${data.round}`); + return; + } + this.onPlanningStarted(data.round, data.grid, data.ships); + }); + + this.socket.on('executionTick', (data: ExecutionResult) => { + this.onExecutionTick(data); + }); + + this.socket.on('gameOver', (data) => { + this.scene.start('ResultScene', { + socket: this.socket, + winnerId: data.winnerId, + winnerName: data.winnerName, + ships: data.ships, + }); + }); + } + + private onPlanningStarted(round: number, grid: GameGridState, ships: ShipType[]): void { + if (this.animating || this.execQueue.length > 0) { + this.pendingPlanning = { round, grid, ships }; + return; + } + this.applyPlanning(round, grid, ships); + } + + private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void { + this.gridState = grid; + this.roundText.setText(`Round ${round}`); + this.statusText.setText('PLANNING PHASE').setColor('#ffcc00'); + this.messageText.setText('Players are planning their moves...'); + + const oldDeathLineY = this.gridRenderer.viewOffset; + const newDeathLineY = grid.deathLineY; + const scrollInPx = (newDeathLineY - oldDeathLineY) * TILE_SIZE; + this.gridRenderer.renderGrid(grid, ships, scrollInPx); + + this.updateShipSprites(ships); + } + + // ── Execution animation: queue-based, sequential ── + + private onExecutionTick(data: ExecutionResult): void { + this.statusText.setText('EXECUTING').setColor('#00ff88'); + this.execQueue.push({ + updates: data.shipUpdates, + gridUpdates: data.gridUpdates, + msgs: data.messages, + }); + if (!this.animating) this.playNext(); + } + + private playNext(): void { + if (this.execQueue.length === 0) { + this.animating = false; + if (this.pendingDeathLineY !== null) { + const deathLineAnim = this.pendingDeathLineY; + this.pendingDeathLineY = null; + this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => { + this.finishExecution(); + }); + } else { + this.finishExecution(); + } + return; + } + this.animating = true; + const batch = this.execQueue.shift()!; + + this.messageText.setText(batch.msgs.slice(0, 3).join(' | ')); + + for (const u of batch.gridUpdates) { + if (u.type === 'tile_change') this.gridRenderer.updateTile(u.position, u.tile); + if (u.type === 'death_line') this.pendingDeathLineY = u.y; + } + + for (const u of batch.updates) { + const sprite = this.shipSprites.get(u.shipId); + if (!sprite) continue; + + switch (u.type) { + case 'move': { + const tx = this.gridRenderer.gridXToScreen(u.to.y); + const ty = this.gridRenderer.gridYToScreen(u.to.x); + this.tweens.add({ + targets: sprite, x: tx, y: ty, + duration: ANIM_DURATION, ease: 'Sine.easeInOut', + }); + break; + } + case 'turn': { + const angles: Record = { N: 0, E: 90, S: 180, W: 270 }; + this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION); + break; + } + case 'eliminated': { + this.shipSprites.delete(u.shipId); + const px = this.add.particles(sprite.x, sprite.y, 'asteroid', { + speed: { min: 40, max: 180 }, scale: { start: 0.3, end: 0 }, + lifespan: 800, quantity: 20, emitting: false, + }); + px.explode(); + this.tweens.add({ + targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1, + duration: 700, delay: 200, + onComplete: () => { sprite.destroy(); px.destroy(); }, + }); + break; + } + case 'collision': { + this.tweens.add({ + targets: sprite, + alpha: 0.2, + duration: 100, + yoyo: true, + repeat: 3, + }); + break; + } + case 'shield_used': { + this.tweens.add({ + targets: sprite, + alpha: 0.2, + duration: 150, + yoyo: true, + repeat: 2, + }); + break; + } + } + } + + this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext()); + } + + private finishExecution(): void { + if (this.pendingPlanning) { + const p = this.pendingPlanning; + this.pendingPlanning = null; + this.applyPlanning(p.round, p.grid, p.ships); + } + } + + private rotateSpriteSlow(sprite: ShipSprite, targetAngle: number, duration: number): void { + const startAngle = sprite.angle; + let delta = targetAngle - startAngle; + delta = ((delta + 180) % 360 + 360) % 360 - 180; + + this.tweens.addCounter({ + from: 0, to: 1, + duration, + ease: 'Sine.easeInOut', + onUpdate: (tween) => { + const progress = tween.getValue(); + sprite.setAngle(startAngle + delta * progress); + }, + }); + } + + // ── Ship sprite management ── + + private updateShipSprites(ships: ShipType[]): void { + for (const [id, sprite] of this.shipSprites) { + if (!ships.find((s) => s.id === id && s.alive)) { + sprite.destroy(); + this.shipSprites.delete(id); + } + } + const colors = [0x00ccff, 0xff4444, 0x44ff44, 0xffaa00, 0xff44ff, 0xffff44]; + for (let i = 0; i < ships.length; i++) { + const ship = ships[i]; + if (!ship.alive) continue; + const x = this.gridRenderer.gridXToScreen(ship.position.y); + const y = this.gridRenderer.gridYToScreen(ship.position.x); + let sprite = this.shipSprites.get(ship.id); + if (!sprite) { + sprite = new ShipSprite(this, x, y, ship, colors[i % colors.length]); + this.children.remove(sprite); + this.gridRenderer.ships.add(sprite); + this.shipSprites.set(ship.id, sprite); + } else { + sprite.setPosition(x, y); + sprite.updateState(ship); + } + } + } +} diff --git a/tv/src/scenes/LobbyScene.ts b/tv/src/scenes/LobbyScene.ts new file mode 100644 index 0000000..624e84b --- /dev/null +++ b/tv/src/scenes/LobbyScene.ts @@ -0,0 +1,115 @@ +import Phaser from 'phaser'; +import { TvSocket } from '../network/TvSocket.js'; + +export class LobbyScene extends Phaser.Scene { + private socket!: TvSocket; + private roomCodeText!: Phaser.GameObjects.Text; + private playerListText!: Phaser.GameObjects.Text; + private players: { id: string; name: string }[] = []; + private roomCode: string = ''; + + constructor() { + super({ key: 'LobbyScene' }); + } + + init(data: { socket: TvSocket }): void { + this.socket = data.socket; + this.players = []; + } + + create(): void { + const { width, height } = this.scale; + + // Background + this.cameras.main.setBackgroundColor('#0a0a2e'); + + // Title + this.add.text(width / 2, 60, '🚀 SPACE RACE 🚀', { + fontSize: '48px', + color: '#00ccff', + fontFamily: 'monospace', + fontStyle: 'bold', + }).setOrigin(0.5); + + // Create room button + this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', { + fontSize: '28px', + color: '#ffffff', + fontFamily: 'monospace', + }).setOrigin(0.5); + + // Player list + this.playerListText = this.add.text(width / 2, 240, 'Players: 0', { + fontSize: '22px', + color: '#aaaaaa', + fontFamily: 'monospace', + align: 'center', + }).setOrigin(0.5, 0); + + // QR Code hint + this.add.text(width / 2, height - 100, 'Scan QR code or enter room code on your phone', { + fontSize: '18px', + color: '#666688', + fontFamily: 'monospace', + }).setOrigin(0.5); + + // Start button (hidden until players join) + const startBtn = this.add.text(width / 2, height - 160, '[ START GAME ]', { + fontSize: '32px', + color: '#00ff88', + fontFamily: 'monospace', + backgroundColor: '#115533', + padding: { x: 20, y: 10 }, + }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setVisible(false); + + startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' })); + startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' })); + startBtn.on('pointerdown', () => { + this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => { + if (res.ok) { + console.log('Game starting...'); + } + }); + }); + + // Socket events + this.socket.emit('tv:createRoom', {}, (res) => { + this.roomCode = res.roomCode; + this.roomCodeText.setText(`Room: ${res.roomCode}`); + this.updateQRHint(); + }); + + this.socket.on('playerJoined', (data) => { + this.players.push({ id: data.playerId, name: data.name }); + this.updatePlayerList(); + startBtn.setVisible(this.players.length >= 2); + }); + + this.socket.on('playerLeft', (data) => { + this.players = this.players.filter((p) => p.id !== data.playerId); + this.updatePlayerList(); + startBtn.setVisible(this.players.length >= 2); + }); + + this.socket.on('gameStarting', (data) => { + this.scene.start('GameScene', { + socket: this.socket, + roomCode: this.roomCode, + players: data.players, + grid: data.grid, + ships: data.ships, + }); + }); + } + + private updatePlayerList(): void { + const names = this.players.map((p, i) => ` ${i + 1}. ${p.name}`).join('\n'); + this.playerListText.setText(`Players (${this.players.length}/6):\n${names}`); + } + + private updateQRHint(): void { + // We'll use a canvas-based QR in the controller app + const url = `${window.location.origin}?room=${this.roomCode}`; + console.log('Join URL:', url); + } +} diff --git a/tv/src/scenes/ResultScene.ts b/tv/src/scenes/ResultScene.ts new file mode 100644 index 0000000..a6e1f01 --- /dev/null +++ b/tv/src/scenes/ResultScene.ts @@ -0,0 +1,89 @@ +import Phaser from 'phaser'; +import { TvSocket } from '../network/TvSocket.js'; +import { Ship } from '@spacerace/shared'; + +export class ResultScene extends Phaser.Scene { + private socket!: TvSocket; + + constructor() { + super({ key: 'ResultScene' }); + } + + init(data: { socket: TvSocket; winnerId: string; winnerName: string; ships: Ship[] }): void { + this.socket = data.socket; + + // We'll use data directly in create + this.registry.set('resultData', data); + } + + create(): void { + const data = this.registry.get('resultData') as { + winnerId: string; + winnerName: string; + ships: Ship[]; + }; + + const { width, height } = this.scale; + this.cameras.main.setBackgroundColor('#0a0a2e'); + + this.add.text(width / 2, 100, '🏆 RACE OVER 🏆', { + fontSize: '52px', + color: '#ffcc00', + fontFamily: 'monospace', + fontStyle: 'bold', + }).setOrigin(0.5); + + this.add.text(width / 2, 220, `${data.winnerName} wins!`, { + fontSize: '40px', + color: '#00ff88', + fontFamily: 'monospace', + }).setOrigin(0.5); + + // Show final positions + const aliveShips = data.ships.filter((s) => s.alive); + const deadShips = data.ships.filter((s) => !s.alive); + let yPos = 320; + + this.add.text(width / 2, yPos, 'Final Standings:', { + fontSize: '24px', + color: '#aaaaaa', + fontFamily: 'monospace', + }).setOrigin(0.5); + yPos += 40; + + for (let i = 0; i < aliveShips.length; i++) { + const ship = aliveShips[i]; + this.add.text(width / 2, yPos, `${i + 1}. ${ship.playerName}`, { + fontSize: '20px', + color: '#ffffff', + fontFamily: 'monospace', + }).setOrigin(0.5); + yPos += 30; + } + + for (const ship of deadShips) { + this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, { + fontSize: '20px', + color: '#666666', + fontFamily: 'monospace', + }).setOrigin(0.5); + yPos += 30; + } + + // Play again button + const playAgainBtn = this.add.text(width / 2, height - 100, '[ BACK TO LOBBY ]', { + fontSize: '28px', + color: '#00ccff', + fontFamily: 'monospace', + backgroundColor: '#112244', + padding: { x: 20, y: 10 }, + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + + playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' })); + playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' })); + playAgainBtn.on('pointerdown', () => { + this.socket.disconnect(); + window.location.reload(); + }); + } +} diff --git a/tv/tsconfig.json b/tv/tsconfig.json new file mode 100644 index 0000000..dfb8b28 --- /dev/null +++ b/tv/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/tv/vite.config.ts b/tv/vite.config.ts new file mode 100644 index 0000000..67765a1 --- /dev/null +++ b/tv/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + proxy: { + '/socket.io': { + target: 'http://localhost:8080', + ws: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +});