From d66dae37fe9c348528c46b00be9227559fdd58f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 18:48:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Phase=201b=20=E2=80=94=20Bi?= =?UTF-8?q?-Planetary=20game=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - movement.ts: pure helpers (getValidDestinations, resolveMovement, checkLanding) + 33-test suite covering coasting, thrust, gravity, win detection, and multi-turn integration - types/game.ts: expand Ship (maxFuel, cs, pendingGravity), add PlotEntry, flesh out TriplanetaryState (plots, turnNumber, winner, mapSnapshot, scenarioId) - TriplanetaryGame.ts: astrogation phase with simultaneous plotCourse / lockIn moves; onEnd calls resolveMovement; endIf detects winner - shared/index.ts: export movement lib - LobbyPage.tsx: fetch canonical map, create match with setupData, join endpoint, store playerID+credentials in sessionStorage - HexGrid.tsx: add overlays?: ReactNode prop for ship/vector overlays - GameRoom.tsx: wire boardgame.io/react Client with SocketIO transport - Board.tsx: game board with ship counters, vector arrows, valid-dest highlights, plot lines, and hex-click course-plotting flow - HUD.tsx: turn/fuel/phase display, Lock In button, win/loss banner - vite.config.ts: optimizeDeps for boardgame.io CJS modules https://claude.ai/code/session_01DeLbhCjrgM6A71jTPZCrsn --- .../client/src/components/map/HexGrid.tsx | 6 +- packages/client/src/pages/game/Board.tsx | 225 ++++++++++ packages/client/src/pages/game/HUD.tsx | 46 ++ packages/client/src/pages/lobby/GameRoom.tsx | 51 ++- packages/client/src/pages/lobby/LobbyPage.tsx | 108 ++++- packages/client/vite.config.ts | 3 + packages/shared/src/game/TriplanetaryGame.ts | 71 ++- packages/shared/src/index.ts | 1 + .../shared/src/lib/__tests__/movement.test.ts | 424 ++++++++++++++++++ packages/shared/src/lib/movement.ts | 165 +++++++ packages/shared/src/types/game.ts | 13 + 11 files changed, 1086 insertions(+), 27 deletions(-) create mode 100644 packages/client/src/pages/game/Board.tsx create mode 100644 packages/client/src/pages/game/HUD.tsx create mode 100644 packages/shared/src/lib/__tests__/movement.test.ts create mode 100644 packages/shared/src/lib/movement.ts diff --git a/packages/client/src/components/map/HexGrid.tsx b/packages/client/src/components/map/HexGrid.tsx index 9bef42c..3cb4eac 100644 --- a/packages/client/src/components/map/HexGrid.tsx +++ b/packages/client/src/components/map/HexGrid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from "react"; +import React, { useMemo, useCallback } from "react"; import { defineHex, Grid, rectangle, Orientation } from "honeycomb-grid"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import type { HexData, HexEntry } from "@triplanetary/shared"; @@ -20,6 +20,8 @@ interface Props { rRange: [number, number]; onHexClick: (q: number, r: number) => void; selectedHex?: string | null; + /** SVG elements rendered inside after the hex layer */ + overlays?: React.ReactNode; } export function HexGrid({ @@ -28,6 +30,7 @@ export function HexGrid({ rRange, onHexClick, selectedHex, + overlays, }: Props) { const [qMin, qMax] = qRange; const [rMin, rMax] = rRange; @@ -144,6 +147,7 @@ export function HexGrid({ ); })} + {overlays} diff --git a/packages/client/src/pages/game/Board.tsx b/packages/client/src/pages/game/Board.tsx new file mode 100644 index 0000000..82ef8ce --- /dev/null +++ b/packages/client/src/pages/game/Board.tsx @@ -0,0 +1,225 @@ +import { useState, useMemo, useCallback } from 'react'; +import type { BoardProps as BgioBoardProps } from 'boardgame.io/react'; +import { defineHex, Grid, rectangle, Orientation } from 'honeycomb-grid'; +import { + getValidDestinations, + type TriplanetaryState, + type HexData, +} from '@triplanetary/shared'; +import { HexGrid } from '../../components/map/HexGrid'; +import HUD from './HUD'; + +const Q_RANGE: [number, number] = [-12, 14]; +const R_RANGE: [number, number] = [-10, 12]; + +const FACTION_COLORS: Record = { + mars: '#cc4444', + venus: '#44aacc', + terra: '#44cc44', + none: '#888888', +}; + +// Fallback empty map used when G.mapSnapshot is null +const EMPTY_MAP: HexData = { + meta: { name: '', version: '1.0', hexSize: 30, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +type BoardProps = BgioBoardProps; + +export default function Board({ G, ctx, moves, playerID, isActive }: BoardProps) { + const typedMoves = moves as { + plotCourse: (dest: [number, number]) => void; + lockIn: () => void; + }; + const [selectedShip, setSelectedShip] = useState(null); + const [validDests, setValidDests] = useState>(new Set()); + + const mapData = G.mapSnapshot ?? EMPTY_MAP; + const hexSize = mapData.meta.hexSize; + + // Build the same hex grid as HexGrid uses to look up pixel centers + const hexLookup = useMemo(() => { + const [qMin, qMax] = Q_RANGE; + const [rMin, rMax] = R_RANGE; + const ProtoHex = defineHex({ + dimensions: hexSize, + orientation: Orientation.POINTY, + origin: 'topLeft', + }); + const grid = new Grid( + ProtoHex, + rectangle({ width: qMax - qMin + 1, height: rMax - rMin + 1, start: { q: qMin, r: rMin } }), + ); + const map = new Map(); + for (const hex of grid) { + map.set(`${hex.q},${hex.r}`, { x: hex.x, y: hex.y }); + } + return map; + }, [hexSize]); + + const center = useCallback( + (q: number, r: number) => hexLookup.get(`${q},${r}`) ?? { x: 0, y: 0 }, + [hexLookup], + ); + + const handleHexClick = useCallback( + (q: number, r: number) => { + if (!isActive || !playerID || ctx.phase !== 'astrogation') return; + const key = `${q},${r}`; + const ownShipIdx = Number(playerID); + + // If clicking own ship and haven't plotted yet, select it + if ( + G.ships[ownShipIdx]?.position[0] === q && + G.ships[ownShipIdx]?.position[1] === r && + !G.plots[playerID ?? ''] + ) { + setSelectedShip(ownShipIdx); + const ship = G.ships[ownShipIdx]; + if (ship) { + setValidDests(getValidDestinations(ship, mapData.hexes)); + } + return; + } + + // If a ship is selected and this is a valid destination, plot + if (selectedShip !== null && validDests.has(key)) { + typedMoves.plotCourse([q, r]); + setSelectedShip(null); + setValidDests(new Set()); + } + }, + [isActive, ctx.phase, playerID, G.ships, G.plots, selectedShip, validDests, typedMoves, mapData.hexes], + ); + + const overlays = useMemo(() => { + const elements: React.ReactNode[] = []; + const r = 10; // ship counter radius + + // Valid destination highlights + for (const key of validDests) { + const [qStr, rStr] = key.split(','); + const q = Number(qStr); + const rv = Number(rStr); + const c = center(q, rv); + // Hexagon highlight using 6 corners (same size as rendered hex) + const pts = []; + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 180) * (60 * i - 30); + pts.push(`${c.x + hexSize * Math.cos(angle)},${c.y + hexSize * Math.sin(angle)}`); + } + elements.push( + , + ); + } + + for (let i = 0; i < G.ships.length; i++) { + const ship = G.ships[i]; + if (!ship) continue; + const [sq, sr] = ship.position; + const c = center(sq, sr); + const color = FACTION_COLORS[ship.faction] ?? '#888'; + + // Vector arrow (from current pos toward predicted coast) + const [vq, vr] = ship.vector; + const grav = ship.pendingGravity ?? [0, 0]; + const coastQ = sq + vq + grav[0]; + const coastR = sr + vr + grav[1]; + const tc = center(coastQ, coastR); + if (vq !== 0 || vr !== 0 || grav[0] !== 0 || grav[1] !== 0) { + elements.push( + , + ); + } + + // Plot line (if a destination is plotted but not yet locked in) + const plot = G.plots[String(i)]; + if (plot) { + const pc = center(plot.destination[0], plot.destination[1]); + elements.push( + , + ); + } + + // Ship counter + const isSelected = selectedShip === i; + elements.push( + + + + {ship.faction.slice(0, 1).toUpperCase()} + + , + ); + } + + return {elements}; + }, [G.ships, G.plots, validDests, selectedShip, center, hexSize]); + + return ( +
+
+ +
+ typedMoves.lockIn()} + /> +
+ ); +} diff --git a/packages/client/src/pages/game/HUD.tsx b/packages/client/src/pages/game/HUD.tsx new file mode 100644 index 0000000..b89c993 --- /dev/null +++ b/packages/client/src/pages/game/HUD.tsx @@ -0,0 +1,46 @@ +import type { Ctx } from 'boardgame.io'; +import type { TriplanetaryState } from '@triplanetary/shared'; + +interface Props { + G: TriplanetaryState; + ctx: Ctx; + playerID: string; + isActive: boolean; + onLockIn: () => void; +} + +export default function HUD({ G, ctx, playerID, isActive, onLockIn }: Props) { + const shipIdx = Number(playerID); + const ship = G.ships[shipIdx]; + const plot = G.plots[playerID]; + + return ( +
+

+ Turn {G.turnNumber} — {ctx.phase ?? 'astrogation'} +

+ + {ship && ( +

+ Fuel: {ship.fuel} / {ship.maxFuel} +

+ )} + + {G.winner !== null ? ( +

+ {G.winner === playerID + ? 'You win!' + : G.winner === 'draw' + ? "It's a draw!" + : 'You lose.'} +

+ ) : isActive && !plot ? ( +

Select your ship, then click a destination to plot your course.

+ ) : isActive && plot && !plot.locked ? ( + + ) : plot?.locked ? ( +

Waiting for opponent…

+ ) : null} +
+ ); +} diff --git a/packages/client/src/pages/lobby/GameRoom.tsx b/packages/client/src/pages/lobby/GameRoom.tsx index 7877eba..673418e 100644 --- a/packages/client/src/pages/lobby/GameRoom.tsx +++ b/packages/client/src/pages/lobby/GameRoom.tsx @@ -1,21 +1,48 @@ -import { useParams } from 'react-router-dom'; +import { useMemo } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Client } from 'boardgame.io/react'; +import { SocketIO } from 'boardgame.io/multiplayer'; +import { TriplanetaryGame } from '@triplanetary/shared'; +import Board from '../game/Board'; -// Placeholder board component - Phase 1 will wire up boardgame.io Client -function Board() { - return ( -
-

Game board placeholder — Phase 1 will render the hex map here.

-
- ); +// Module-level singleton — avoids re-creating the client class on every render. +const GameClient = Client({ + game: TriplanetaryGame, + board: Board, + multiplayer: SocketIO({ server: '' }), // same origin — Vite proxies /socket.io → :8000 + debug: false, +}); + +interface StoredSeat { + playerID: string; + credentials: string; } export default function GameRoom() { const { matchID } = useParams<{ matchID: string }>(); + const stored = useMemo(() => { + if (!matchID) return null; + const raw = sessionStorage.getItem(`bgio-${matchID}`); + if (!raw) return null; + return JSON.parse(raw) as StoredSeat; + }, [matchID]); + + if (!matchID) return

Missing match ID.

; + + if (!stored) { + return ( +

+ No seat found for this match — return to lobby +

+ ); + } + return ( -
-

Game {matchID}

- -
+ ); } diff --git a/packages/client/src/pages/lobby/LobbyPage.tsx b/packages/client/src/pages/lobby/LobbyPage.tsx index 1bf0a4b..b3eef38 100644 --- a/packages/client/src/pages/lobby/LobbyPage.tsx +++ b/packages/client/src/pages/lobby/LobbyPage.tsx @@ -2,6 +2,19 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { SCENARIOS } from '@triplanetary/shared'; +import { apiClient } from '../../lib/apiClient'; +import type { HexData } from '@triplanetary/shared'; + +interface MapMeta { + id: string; + name: string; + isCanonical: boolean; +} + +interface MapRow { + id: string; + data: HexData; +} interface Match { matchID: string; @@ -17,15 +30,78 @@ async function fetchMatches(): Promise { return data.matches ?? []; } -async function createMatch(numPlayers: number): Promise<{ matchID: string }> { - const res = await fetch('/games/triplanetary/create', { +async function fetchCanonicalMap(): Promise { + const maps = await apiClient.get('/maps'); + const canonical = maps.find((m) => m.isCanonical); + if (!canonical) return null; + const row = await apiClient.get(`/maps/${canonical.id}`); + return row.data; +} + +async function createAndJoin( + numPlayers: number, + mapData: HexData | null, + playerName: string, +): Promise<{ matchID: string }> { + // 1. Create match with canonical map as setupData + const createRes = await fetch('/games/triplanetary/create', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ numPlayers, setupData: { mapData } }), + }); + if (!createRes.ok) throw new Error('Failed to create match'); + const { matchID } = (await createRes.json()) as { matchID: string }; + + // 2. Join as player 0 + const joinRes = await fetch(`/games/triplanetary/${matchID}/join`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ numPlayers }), + body: JSON.stringify({ playerID: '0', playerName }), }); - if (!res.ok) throw new Error('Failed to create match'); - return res.json() as Promise<{ matchID: string }>; + if (!joinRes.ok) throw new Error('Failed to join match'); + const { playerCredentials } = (await joinRes.json()) as { playerCredentials: string }; + + // 3. Store credentials in sessionStorage + sessionStorage.setItem( + `bgio-${matchID}`, + JSON.stringify({ playerID: '0', credentials: playerCredentials }), + ); + + return { matchID }; +} + +async function joinExistingMatch( + matchID: string, + playerName: string, +): Promise { + // Determine free seat + const matchRes = await fetch(`/games/triplanetary/${matchID}`, { + credentials: 'include', + }); + if (!matchRes.ok) throw new Error('Failed to fetch match'); + const matchData = (await matchRes.json()) as { + players: Array<{ id: number; name?: string }>; + }; + + const freeSeat = matchData.players.find((p) => !p.name); + if (!freeSeat) throw new Error('Match is full'); + const playerID = String(freeSeat.id); + + const joinRes = await fetch(`/games/triplanetary/${matchID}/join`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playerID, playerName }), + }); + if (!joinRes.ok) throw new Error('Failed to join match'); + const { playerCredentials } = (await joinRes.json()) as { playerCredentials: string }; + + sessionStorage.setItem( + `bgio-${matchID}`, + JSON.stringify({ playerID, credentials: playerCredentials }), + ); } export default function LobbyPage() { @@ -42,13 +118,26 @@ export default function LobbyPage() { const scenario = SCENARIOS.find((s) => s.id === selectedScenario); const createMutation = useMutation({ - mutationFn: () => createMatch(scenario?.minPlayers ?? 2), + mutationFn: async () => { + const mapData = await fetchCanonicalMap(); + return createAndJoin(scenario?.minPlayers ?? 2, mapData, 'Player 1'); + }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['matches'] }).catch(() => undefined); navigate(`/game/${data.matchID}`); }, }); + const joinMutation = useMutation({ + mutationFn: async (matchID: string) => { + await joinExistingMatch(matchID, 'Player 2'); + return matchID; + }, + onSuccess: (matchID) => { + navigate(`/game/${matchID}`); + }, + }); + return (

Lobby

@@ -70,7 +159,12 @@ export default function LobbyPage() {
  • Match {match.matchID.slice(0, 8)} {' · '} - +
  • ))} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index ff3f841..9cab078 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -4,6 +4,9 @@ import { resolve } from "node:path"; export default defineConfig({ plugins: [react()], + optimizeDeps: { + include: ['boardgame.io/react', 'boardgame.io/multiplayer'], + }, resolve: { alias: { "@triplanetary/shared": resolve("../shared/src/index.ts"), diff --git a/packages/shared/src/game/TriplanetaryGame.ts b/packages/shared/src/game/TriplanetaryGame.ts index 2b10bb6..4103346 100644 --- a/packages/shared/src/game/TriplanetaryGame.ts +++ b/packages/shared/src/game/TriplanetaryGame.ts @@ -1,19 +1,76 @@ +import { Stage, INVALID_MOVE } from 'boardgame.io/core'; import type { Game } from 'boardgame.io'; import type { TriplanetaryState } from '../types/game'; +import { getValidDestinations, resolveMovement } from '../lib/movement'; export const TriplanetaryGame: Game = { name: 'triplanetary', - setup: (): TriplanetaryState => ({ - ships: [], - phase: 'lobby', + setup: (_ctx, setupData): TriplanetaryState => ({ + ships: [ + { + id: 'ship-0', + faction: 'mars', + position: [5, -3], + vector: [0, 0], + fuel: 6, + maxFuel: 6, + cs: 2, + pendingGravity: null, + }, + { + id: 'ship-1', + faction: 'venus', + position: [-4, 2], + vector: [0, 0], + fuel: 6, + maxFuel: 6, + cs: 2, + pendingGravity: null, + }, + ], + plots: {}, + phase: 'astrogation', + turnNumber: 1, + winner: null, + mapSnapshot: (setupData as { mapData?: TriplanetaryState['mapSnapshot'] } | undefined)?.mapData ?? null, + scenarioId: 'bi-planetary', }), - moves: { - // Phase 1 will add plotCourse, lockIn, etc. + phases: { + astrogation: { + start: true, + turn: { + activePlayers: { all: Stage.NULL }, + }, + moves: { + plotCourse: ({ G, ctx }, destination: [number, number]) => { + const ship = G.ships[Number(ctx.currentPlayer)]; + if (!ship) return INVALID_MOVE; + const valid = getValidDestinations(ship, G.mapSnapshot?.hexes ?? {}); + if (!valid.has(`${destination[0]},${destination[1]}`)) return INVALID_MOVE; + G.plots[ctx.currentPlayer] = { destination, locked: false }; + }, + + lockIn: ({ G, ctx, events }) => { + const plot = G.plots[ctx.currentPlayer]; + if (!plot) return INVALID_MOVE; + plot.locked = true; + const allLocked = Object.keys(ctx.activePlayers ?? {}) + .every(pid => G.plots[pid]?.locked === true); + if (allLocked) events.endPhase(); + }, + }, + + onEnd: ({ G }) => { + resolveMovement(G); + }, + + next: 'astrogation', + }, }, - turn: { - minMoves: 0, + endIf: ({ G }) => { + if (G.winner !== null) return { winner: G.winner }; }, }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 64bc7f2..f4471bc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from "./types/map"; export * from "./constants/scenarios"; export * from "./game/TriplanetaryGame"; export * from "./lib/gravity"; +export * from "./lib/movement"; diff --git a/packages/shared/src/lib/__tests__/movement.test.ts b/packages/shared/src/lib/__tests__/movement.test.ts new file mode 100644 index 0000000..ec9c5f9 --- /dev/null +++ b/packages/shared/src/lib/__tests__/movement.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect } from "vitest"; +import { + getValidDestinations, + checkLanding, + resolveMovement, + type MovementState, +} from "../movement"; +import type { Ship } from "../../types/game"; +import type { HexData } from "../../types/map"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeShip(overrides: Partial = {}): Ship { + return { + id: "ship-0", + faction: "mars", + position: [0, 0], + vector: [0, 0], + fuel: 6, + maxFuel: 6, + cs: 2, + pendingGravity: null, + ...overrides, + }; +} + +const MARS_HEX: [number, number] = [5, -3]; +const VENUS_HEX: [number, number] = [-4, 2]; + +/** Minimal map with just the two planet hexes for landing tests. */ +const MINIMAL_MAP: HexData = { + meta: { name: "test", version: "1.0", hexSize: 48, orientation: "pointy" }, + bodies: { + mars: { center: "5,-3", gravityRings: 1 }, + venus: { center: "-4,2", gravityRings: 1 }, + }, + hexes: { + "5,-3": { type: "planet", body: "mars" }, + "-4,2": { type: "planet", body: "venus" }, + }, + bases: {}, +}; + +/** Map with a single gravity hex for testing gravity accumulation. */ +function mapWithGravity(hexKey: string, offset: [number, number]): HexData { + return { + ...MINIMAL_MAP, + hexes: { + ...MINIMAL_MAP.hexes, + [hexKey]: { type: "gravity", offset, manual: false }, + }, + }; +} + +function makeState(overrides: Partial = {}): MovementState { + return { + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [5, -3] }), + makeShip({ id: "ship-1", faction: "venus", position: [-4, 2] }), + ], + plots: {}, + mapSnapshot: MINIMAL_MAP, + turnNumber: 1, + winner: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// getValidDestinations +// --------------------------------------------------------------------------- + +describe("getValidDestinations", () => { + it("stationary ship with full fuel: coast = (0,0) + 6 neighbors", () => { + const ship = makeShip({ position: [0, 0], vector: [0, 0] }); + const valid = getValidDestinations(ship, {}); + expect(valid.size).toBe(7); // coast + 6 neighbors + expect(valid.has("0,0")).toBe(true); + expect(valid.has("1,0")).toBe(true); + expect(valid.has("0,1")).toBe(true); + expect(valid.has("-1,1")).toBe(true); + expect(valid.has("-1,0")).toBe(true); + expect(valid.has("0,-1")).toBe(true); + expect(valid.has("1,-1")).toBe(true); + }); + + it("ship with vector [1,0]: coast is (1,0), valid includes coast + neighbors", () => { + const ship = makeShip({ position: [0, 0], vector: [1, 0] }); + const valid = getValidDestinations(ship, {}); + expect(valid.has("1,0")).toBe(true); // coast + expect(valid.has("2,0")).toBe(true); // neighbor of coast + expect(valid.has("1,1")).toBe(true); + expect(valid.size).toBe(7); + }); + + it("ship with 0 fuel: only coast destination is valid", () => { + const ship = makeShip({ position: [0, 0], vector: [2, -1], fuel: 0 }); + const valid = getValidDestinations(ship, {}); + expect(valid.size).toBe(1); + expect(valid.has("2,-1")).toBe(true); + }); + + it("pendingGravity shifts the effective vector before computing coast", () => { + const ship = makeShip({ + position: [0, 0], + vector: [1, 0], + pendingGravity: [0, 1], + }); + const valid = getValidDestinations(ship, {}); + // effective vector = [1+0, 0+1] = [1,1], coast = [1,1] + expect(valid.has("1,1")).toBe(true); + // [1,-1] is a neighbor of old coast [1,0] but NOT of new coast [1,1] + expect(valid.has("1,-1")).toBe(false); + }); + + it("null pendingGravity treated as [0,0]", () => { + const ship = makeShip({ position: [0, 0], vector: [0, 0], pendingGravity: null }); + const valid = getValidDestinations(ship, {}); + expect(valid.has("0,0")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// checkLanding +// --------------------------------------------------------------------------- + +describe("checkLanding", () => { + it("player 0 at venus hex returns true", () => { + expect(checkLanding(VENUS_HEX, "0", MINIMAL_MAP)).toBe(true); + }); + + it("player 0 at mars hex returns false (wrong target)", () => { + expect(checkLanding(MARS_HEX, "0", MINIMAL_MAP)).toBe(false); + }); + + it("player 1 at mars hex returns true", () => { + expect(checkLanding(MARS_HEX, "1", MINIMAL_MAP)).toBe(true); + }); + + it("player 1 at venus hex returns false (wrong target)", () => { + expect(checkLanding(VENUS_HEX, "1", MINIMAL_MAP)).toBe(false); + }); + + it("neither player at a random hex returns false", () => { + expect(checkLanding([0, 0], "0", MINIMAL_MAP)).toBe(false); + expect(checkLanding([0, 0], "1", MINIMAL_MAP)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveMovement +// --------------------------------------------------------------------------- + +describe("resolveMovement — coasting", () => { + it("coasting ship moves by its vector, fuel unchanged", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [5, 5], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, // coast + "1": { destination: [5, 5], locked: true }, // coast (stationary) + }, + }); + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([1, 0]); + expect(state.ships[0]!.fuel).toBe(6); + expect(state.ships[1]!.position).toEqual([5, 5]); + expect(state.ships[1]!.fuel).toBe(6); + }); + + it("coasting ship: vector updated to new velocity", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [2, -1], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 3 }), + ], + plots: { + "0": { destination: [2, -1], locked: true }, + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + // new velocity = dest − old position = [2,-1] − [0,0] = [2,-1] + expect(state.ships[0]!.vector).toEqual([2, -1]); + }); +}); + +describe("resolveMovement — thrusting", () => { + it("thrusting to non-coast hex costs 1 fuel", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [0, 0], fuel: 4 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 4 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, // thrust (coast was [0,0]) + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([1, 0]); + expect(state.ships[0]!.fuel).toBe(3); + }); + + it("new velocity = destination − previous position", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [2, 3], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [4, 3], locked: true }, // thrust to [4,3] (coast was [3,3]) + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.ships[0]!.vector).toEqual([2, 0]); // [4,3] − [2,3] + }); +}); + +describe("resolveMovement — gravity", () => { + it("pendingGravity is applied to vector before thrust, then cleared", () => { + const state = makeState({ + ships: [ + makeShip({ + id: "ship-0", + faction: "mars", + position: [0, 0], + vector: [1, 0], + fuel: 6, + pendingGravity: [0, 1], + }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + // effective vector after gravity = [1,1]; coast = [1,1]; player coasts + "0": { destination: [1, 1], locked: true }, + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.ships[0]!.pendingGravity).toBeNull(); + expect(state.ships[0]!.position).toEqual([1, 1]); + expect(state.ships[0]!.fuel).toBe(6); // coasting + }); + + it("ship arriving in gravity hex has pendingGravity set for next turn", () => { + const gravMap = mapWithGravity("1,0", [0, 1]); + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, // coast into gravity hex + "1": { destination: [9, 9], locked: true }, + }, + mapSnapshot: gravMap, + }); + resolveMovement(state); + expect(state.ships[0]!.pendingGravity).toEqual([0, 1]); + }); + + it("ship leaving gravity hex clears pendingGravity", () => { + const state = makeState({ + ships: [ + makeShip({ + id: "ship-0", + faction: "mars", + position: [0, 0], + vector: [1, 0], + fuel: 6, + pendingGravity: [-1, 0], + }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [0, 0], locked: true }, // lands on non-gravity hex + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.ships[0]!.pendingGravity).toBeNull(); + }); +}); + +describe("resolveMovement — win condition", () => { + it("player 0 landing on Venus wins", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [-5, 2], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [-4, 2], locked: true }, // Venus hex + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.winner).toBe("0"); + }); + + it("player 1 landing on Mars wins", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [0, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [4, -3], vector: [1, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [0, 0], locked: true }, + "1": { destination: [5, -3], locked: true }, // Mars hex + }, + }); + resolveMovement(state); + expect(state.winner).toBe("1"); + }); + + it("both players landing simultaneously results in draw", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [-5, 2], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [4, -3], vector: [1, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [-4, 2], locked: true }, // Venus + "1": { destination: [5, -3], locked: true }, // Mars + }, + }); + resolveMovement(state); + expect(state.winner).toBe("draw"); + }); + + it("no winner yet: winner remains null and turnNumber increments", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.winner).toBeNull(); + expect(state.turnNumber).toBe(2); + }); + + it("plots are cleared after resolution", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [0, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [0, 0], locked: true }, + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(Object.keys(state.plots)).toHaveLength(0); + }); +}); + +describe("resolveMovement — integration (multi-turn)", () => { + it("ship coasts correctly over two turns with constant velocity", () => { + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, + "1": { destination: [9, 9], locked: true }, + }, + }); + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([1, 0]); + expect(state.ships[0]!.vector).toEqual([1, 0]); + + state.plots = { + "0": { destination: [2, 0], locked: true }, + "1": { destination: [9, 9], locked: true }, + }; + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([2, 0]); + expect(state.ships[0]!.fuel).toBe(6); // still no thrust + }); + + it("gravity applied on turn N+1 when ship entered gravity hex on turn N", () => { + const gravMap = mapWithGravity("1,0", [0, 1]); + const state = makeState({ + ships: [ + makeShip({ id: "ship-0", faction: "mars", position: [0, 0], vector: [1, 0], fuel: 6 }), + makeShip({ id: "ship-1", faction: "venus", position: [9, 9], vector: [0, 0], fuel: 6 }), + ], + plots: { + "0": { destination: [1, 0], locked: true }, // enter gravity hex + "1": { destination: [9, 9], locked: true }, + }, + mapSnapshot: gravMap, + }); + + // Turn 1: ship moves to gravity hex [1,0], pendingGravity set to [0,1] + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([1, 0]); + expect(state.ships[0]!.pendingGravity).toEqual([0, 1]); + + // Turn 2: gravity [0,1] is applied to vector [1,0] → effective [1,1]; coast = [2,1] + state.plots = { + "0": { destination: [2, 1], locked: true }, // coasting with gravity + "1": { destination: [9, 9], locked: true }, + }; + resolveMovement(state); + expect(state.ships[0]!.position).toEqual([2, 1]); + expect(state.ships[0]!.vector).toEqual([1, 1]); // [2,1] − [1,0] + expect(state.ships[0]!.pendingGravity).toBeNull(); // [2,1] is not a gravity hex + expect(state.ships[0]!.fuel).toBe(6); + }); +}); diff --git a/packages/shared/src/lib/movement.ts b/packages/shared/src/lib/movement.ts new file mode 100644 index 0000000..95a7c3f --- /dev/null +++ b/packages/shared/src/lib/movement.ts @@ -0,0 +1,165 @@ +import type { Ship } from "../types/game"; +import type { HexEntry, HexData } from "../types/map"; + +// The 6 axial unit directions for pointy-top hexes. +// Duplicated from gravity.ts to avoid a circular dependency refactor in Phase 1b. +const AXIAL_DIRS: readonly [number, number][] = [ + [1, 0], + [0, 1], + [-1, 1], + [-1, 0], + [0, -1], + [1, -1], +] as const; + +/** + * Returns the set of valid destination hexes for a ship this turn. + * + * The ship can coast for free (predicted endpoint = position + effective vector) + * or burn 1 fuel to reach any of the 6 neighbors of the predicted endpoint. + * No hex-type filtering — all hexes are reachable in Bi-Planetary. + * + * @param ship The ship being plotted. + * @param hexes Current hex entries (used to determine gravity; may be empty in tests). + */ +export function getValidDestinations( + ship: Ship, + _hexes: Record, +): Set { + // Effective vector = current velocity + pending gravity (already queued from last turn) + const grav = ship.pendingGravity ?? [0, 0]; + const vq = ship.vector[0] + grav[0]; + const vr = ship.vector[1] + grav[1]; + + // Predicted coast destination + const coastQ = ship.position[0] + vq; + const coastR = ship.position[1] + vr; + + const valid = new Set(); + valid.add(`${coastQ},${coastR}`); + + if (ship.fuel > 0) { + for (const [dq, dr] of AXIAL_DIRS) { + valid.add(`${coastQ + dq},${coastR + dr}`); + } + } + + return valid; +} + +/** + * Returns the target planet hex key for a given playerID in Bi-Planetary. + * Player '0' (Mars) must reach Venus; Player '1' (Venus) must reach Mars. + */ +function targetBody(playerID: string): string { + return playerID === "0" ? "venus" : "mars"; +} + +/** + * Returns true if the given position matches the planet hex of the target body + * for the specified player. Scans map.hexes dynamically so map edits propagate. + */ +export function checkLanding( + position: [number, number], + playerID: string, + map: HexData, +): boolean { + const target = targetBody(playerID); + for (const [key, entry] of Object.entries(map.hexes)) { + if (entry.type === "planet" && entry.body === target) { + const [qStr, rStr] = key.split(","); + if ( + position[0] === Number(qStr) && + position[1] === Number(rStr) + ) { + return true; + } + } + } + return false; +} + +export interface PlotMap { + [playerID: string]: { destination: [number, number]; locked: boolean }; +} + +export interface MovementState { + ships: Ship[]; + plots: PlotMap; + mapSnapshot: HexData | null; + turnNumber: number; + winner: string | null; +} + +/** + * Resolves one full movement turn for all players in order '0', '1'. + * Mutates the state object in-place (compatible with Immer drafts). + * + * Resolution order per ship: + * 1. Apply pendingGravity to velocity; clear pendingGravity. + * 2. Compute fuel cost (0 if coasting, 1 if thrusting). + * 3. Update velocity to the new vector (destination − position). + * 4. Move ship to destination. + * 5. Decrement fuel. + * 6. Record new pendingGravity if ship is now in a gravity hex. + * + * After all ships move, check for landings and update winner/turnNumber. + */ +export function resolveMovement(state: MovementState): void { + const winners: string[] = []; + + for (const playerID of ["0", "1"] as const) { + const ship = state.ships[Number(playerID)]; + const plot = state.plots[playerID]; + if (!ship || !plot) continue; + + // Step 1 — apply pending gravity to velocity + const grav = ship.pendingGravity ?? [0, 0]; + ship.vector[0] += grav[0]; + ship.vector[1] += grav[1]; + ship.pendingGravity = null; + + // Step 2 — cost: coast (no thrust) is free; any other destination costs 1 fuel + const coastQ = ship.position[0] + ship.vector[0]; + const coastR = ship.position[1] + ship.vector[1]; + const [destQ, destR] = plot.destination; + const isCoasting = destQ === coastQ && destR === coastR; + const fuelCost = isCoasting ? 0 : 1; + + // Step 3 — new velocity = destination − current position + ship.vector[0] = destQ - ship.position[0]; + ship.vector[1] = destR - ship.position[1]; + + // Step 4 — move + ship.position[0] = destQ; + ship.position[1] = destR; + + // Step 5 — consume fuel + ship.fuel = Math.max(0, ship.fuel - fuelCost); + + // Step 6 — record gravity in new hex (applied next turn) + const newKey = `${destQ},${destR}`; + const hexEntry = state.mapSnapshot?.hexes[newKey]; + if (hexEntry?.type === "gravity" && hexEntry.offset) { + ship.pendingGravity = [hexEntry.offset[0], hexEntry.offset[1]]; + } else { + ship.pendingGravity = null; + } + + // Step 7 — check landing + if (state.mapSnapshot && checkLanding([destQ, destR], playerID, state.mapSnapshot)) { + winners.push(playerID); + } + } + + // Determine winner + if (winners.length === 1) { + state.winner = winners[0]!; + } else if (winners.length === 2) { + state.winner = "draw"; + } + + // Advance turn and clear plots + state.plots = {}; + state.turnNumber += 1; +} diff --git a/packages/shared/src/types/game.ts b/packages/shared/src/types/game.ts index 8835d22..89d30c9 100644 --- a/packages/shared/src/types/game.ts +++ b/packages/shared/src/types/game.ts @@ -8,9 +8,22 @@ export interface Ship { position: [number, number]; vector: [number, number]; fuel: number; + maxFuel: number; + cs: number; + pendingGravity: [number, number] | null; +} + +export interface PlotEntry { + destination: [number, number]; + locked: boolean; } export interface TriplanetaryState { ships: Ship[]; + plots: Record; phase: Phase; + turnNumber: number; + winner: string | null; + mapSnapshot: import('./map').HexData | null; + scenarioId: string; }