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:
+15
-5
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user