QR code & clickable URL on TV lobby, BOOST card-only, remove METEOR_STRIKE/TELEPORT, fix vite base paths for production, Dockerfile with frontend builds, Makefile with podman

This commit is contained in:
2026-06-24 20:32:34 +02:00
parent 5e2ab43ca3
commit 8531864b25
30 changed files with 3036 additions and 629 deletions
+15 -5
View File
@@ -1,10 +1,20 @@
FROM node:22-alpine AS server-build
FROM node:22-alpine AS 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
COPY tv/ ./tv/
COPY controller/ ./controller/
RUN npm ci
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/shared ./shared
COPY --from=build /app/server ./server
COPY --from=build /app/tv/dist ./tv/dist
COPY --from=build /app/controller/dist ./controller/dist
EXPOSE 8080
CMD ["npx", "tsx", "src/index.ts"]
CMD ["npx", "tsx", "server/src/index.ts"]
+129 -78
View File
@@ -4,88 +4,139 @@ import { Ship } from './Ship.js';
export interface CardContext {
ship: Ship;
target?: Position;
grid: Grid;
allShips: Ship[];
shipStates: Map<string, Ship>;
}
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: [] };
export type CardEvent =
| { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string }
| { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' };
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: [] };
}
export interface CardResult {
success: boolean;
message: string;
gridUpdates: { position: Position; tile: string }[];
events: CardEvent[];
}
function cardEvent(cardType: CardType, ctx: CardContext, position: Position, targetId?: string): CardEvent {
return { type: 'card_played', shipId: ctx.ship.id, card: cardType, position, targetId };
}
function moveEvent(ctx: CardContext, from: Position, to: Position, source: 'walk' | 'boost' | 'jump'): CardEvent {
return { type: 'move', shipId: ctx.ship.id, from, to, source };
}
type CardHandler = (ctx: CardContext) => CardResult;
const SHIELD: CardHandler = (ctx) => {
ctx.ship.shielded = true;
return {
success: true,
message: `${ctx.ship.playerId} activated Shield`,
gridUpdates: [],
events: [cardEvent('SHIELD', ctx, { ...ctx.ship.position })],
};
};
const EMP: CardHandler = (ctx) => {
ctx.ship.empActive = true;
return {
success: true,
message: `${ctx.ship.playerId} activated EMP`,
gridUpdates: [],
events: [cardEvent('EMP', ctx, { ...ctx.ship.position })],
};
};
const JUMP: CardHandler = (ctx) => {
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: [], events: [] };
}
const fromPos = { ...ctx.ship.position };
ctx.ship.position = jumpPos;
return {
success: true,
message: `${ctx.ship.playerId} jumped 2 tiles forward`,
gridUpdates: [],
events: [
cardEvent('JUMP', ctx, { ...jumpPos }, `${fromPos.x},${fromPos.y}`),
moveEvent(ctx, fromPos, jumpPos, 'jump'),
],
};
};
const MINE: CardHandler = (ctx) => {
ctx.grid.setTile(ctx.ship.position, 'mine');
return {
success: true,
message: `${ctx.ship.playerId} dropped a mine`,
gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }],
events: [cardEvent('MINE', ctx, { ...ctx.ship.position })],
};
};
const BOOST: CardHandler = (ctx) => {
const dir = ctx.ship.direction;
const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
const boostPos: Position = {
x: ctx.ship.position.x + dx * 3,
y: ctx.ship.position.y + dy * 3,
};
if (!ctx.grid.isInBounds(boostPos)) {
return { success: false, message: 'Boost out of bounds', gridUpdates: [], events: [] };
}
const fromPos = { ...ctx.ship.position };
ctx.ship.position = boostPos;
return {
success: true,
message: `${ctx.ship.playerId} boosted forward`,
gridUpdates: [],
events: [
cardEvent('BOOST', ctx, { ...boostPos }, `${fromPos.x},${fromPos.y}`),
moveEvent(ctx, fromPos, boostPos, 'boost'),
],
};
};
const PHASE_SHIFT: CardHandler = (ctx) => {
ctx.ship.phaseShifting = true;
return {
success: true,
message: `${ctx.ship.playerId} activated Phase Shift`,
gridUpdates: [],
events: [cardEvent('PHASE_SHIFT', ctx, { ...ctx.ship.position })],
};
};
const HANDLERS: Record<CardType, CardHandler> = {
SHIELD,
EMP,
JUMP,
MINE,
BOOST,
PHASE_SHIFT,
};
export function executeCard(cardType: CardType, ctx: CardContext): CardResult {
const handler = HANDLERS[cardType];
if (!handler) {
return { success: false, message: 'Unknown card', gridUpdates: [], events: [] };
}
return handler(ctx);
}
+6 -10
View File
@@ -76,9 +76,11 @@ export class Executor {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
// Stash action on the ship so CardHandler can read .target for METEOR_STRIKE
(ship as any).lastAction = action;
const ctx = {
ship,
target: action.target,
grid: this.grid,
allShips: Array.from(this.ships.values()),
shipStates: this.ships,
@@ -90,6 +92,9 @@ export class Executor {
for (const gu of cardResult.gridUpdates) {
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
}
for (const ev of cardResult.events) {
result.shipUpdates.push(ev);
}
} else {
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
}
@@ -118,15 +123,6 @@ export class Executor {
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);
+8
View File
@@ -1,5 +1,6 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { createServer } from 'http';
import { WsServer } from './ws/WsServer.js';
import { GAME_CONFIG } from './config.js';
@@ -8,6 +9,13 @@ const app = express();
app.use(cors());
app.use(express.json());
// Serve controller frontend
app.use('/controller', express.static(path.join(__dirname, '../../controller/dist')));
// Serve TV frontend at root
app.use('/', express.static(path.join(__dirname, '../../tv/dist')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../tv/dist/index.html')));
const httpServer = createServer(app);
const wsServer = new WsServer(httpServer);