diff --git a/apps/game-api/src/geographic-direction.test.ts b/apps/game-api/src/geographic-direction.test.ts new file mode 100644 index 0000000..18c20b9 --- /dev/null +++ b/apps/game-api/src/geographic-direction.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { gridDisk, latLngToCell } from 'h3-js'; +import { h3CellSchema } from '@hexzero/shared'; +import { + directionFromInitialBearing, + geographicDirectionBetweenCells, + initialBearingDegrees, +} from './geographic-direction'; + +describe('geographic move directions', () => { + it('uses explicit clockwise sector boundaries with north wraparound', () => { + expect(directionFromInitialBearing(0)).toBe('N'); + expect(directionFromInitialBearing(29.999)).toBe('N'); + expect(directionFromInitialBearing(30)).toBe('NE'); + expect(directionFromInitialBearing(90)).toBe('SE'); + expect(directionFromInitialBearing(150)).toBe('S'); + expect(directionFromInitialBearing(210)).toBe('SW'); + expect(directionFromInitialBearing(270)).toBe('NW'); + expect(directionFromInitialBearing(329.999)).toBe('NW'); + expect(directionFromInitialBearing(330)).toBe('N'); + expect(directionFromInitialBearing(360)).toBe('N'); + expect(directionFromInitialBearing(-30)).toBe('N'); + }); + + it('calculates geographic initial bearings across longitude wraparound', () => { + expect(initialBearingDegrees([0, 179.9], [0, -179.9])).toBeCloseTo(90, 8); + expect(initialBearingDegrees([0, -179.9], [0, 179.9])).toBeCloseTo(270, 8); + expect(initialBearingDegrees([41, -83], [42, -83])).toBeCloseTo(0, 8); + expect(initialBearingDegrees([41, -83], [40, -83])).toBeCloseTo(180, 8); + }); + + it('rejects same-cell and same-coordinate bearings deliberately', () => { + const cell = h3CellSchema.parse(latLngToCell(41.6528, -83.5379, 9)); + expect(() => geographicDirectionBetweenCells(cell, cell)).toThrow( + 'Move direction requires distinct H3 cells.', + ); + expect(() => initialBearingDegrees([1, 2], [1, 2])).toThrow( + 'Initial bearing is undefined for the same coordinate.', + ); + }); + + it.each([ + ['Toledo', 41.6528, -83.5379, 8], + ['Toledo', 41.6528, -83.5379, 11], + ['Sydney', -33.8688, 151.2093, 9], + ['near the date line', 0.1, 179.999, 10], + ] as const)( + 'labels neighbors by center bearing independent of traversal position in %s', + (_name, latitude, longitude, resolution) => { + const origin = h3CellSchema.parse( + latLngToCell(latitude, longitude, resolution), + ); + const neighbors = gridDisk(origin, 1) + .filter((cell) => cell !== origin) + .map((cell) => h3CellSchema.parse(cell)); + + const labels = new Map( + neighbors.map((destination) => [ + destination, + geographicDirectionBetweenCells(origin, destination), + ]), + ); + const reversedLabels = new Map( + neighbors + .toReversed() + .map((destination) => [ + destination, + geographicDirectionBetweenCells(origin, destination), + ]), + ); + + expect(labels.size).toBe(6); + expect(new Set(labels.values())).toEqual( + new Set(['N', 'NE', 'SE', 'S', 'SW', 'NW']), + ); + expect([...reversedLabels].toSorted()).toEqual([...labels].toSorted()); + }, + ); +}); diff --git a/apps/game-api/src/geographic-direction.ts b/apps/game-api/src/geographic-direction.ts new file mode 100644 index 0000000..00a4db8 --- /dev/null +++ b/apps/game-api/src/geographic-direction.ts @@ -0,0 +1,71 @@ +import { cellToLatLng } from 'h3-js'; +import type { H3Cell } from '@hexzero/shared'; + +export type GeographicDirection = 'N' | 'NE' | 'SE' | 'S' | 'SW' | 'NW'; + +const FULL_CIRCLE_DEGREES = 360; + +function normalizeDegrees(degrees: number): number { + return ( + ((degrees % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) % + FULL_CIRCLE_DEGREES + ); +} + +/** + * Classifies an initial bearing into six equal 60-degree sectors. Boundaries + * belong to the clockwise sector: N wraps across [330, 360) and [0, 30), NE + * is [30, 90), SE is [90, 150), S is [150, 210), SW is [210, 270), and NW + * is [270, 330). + */ +export function directionFromInitialBearing( + bearingDegrees: number, +): GeographicDirection { + if (!Number.isFinite(bearingDegrees)) + throw new Error('Initial bearing must be finite.'); + + const normalized = normalizeDegrees(bearingDegrees); + if (normalized < 30 || normalized >= 330) return 'N'; + if (normalized < 90) return 'NE'; + if (normalized < 150) return 'SE'; + if (normalized < 210) return 'S'; + if (normalized < 270) return 'SW'; + return 'NW'; +} + +/** Returns the great-circle initial bearing from one coordinate to another. */ +export function initialBearingDegrees( + from: readonly [latitude: number, longitude: number], + to: readonly [latitude: number, longitude: number], +): number { + if (from[0] === to[0] && from[1] === to[1]) + throw new Error('Initial bearing is undefined for the same coordinate.'); + + const toRadians = (degrees: number) => (degrees * Math.PI) / 180; + const fromLatitude = toRadians(from[0]); + const toLatitude = toRadians(to[0]); + const longitudeDelta = toRadians( + ((to[1] - from[1] + 540) % FULL_CIRCLE_DEGREES) - 180, + ); + const y = Math.sin(longitudeDelta) * Math.cos(toLatitude); + const x = + Math.cos(fromLatitude) * Math.sin(toLatitude) - + Math.sin(fromLatitude) * Math.cos(toLatitude) * Math.cos(longitudeDelta); + + if (x === 0 && y === 0) + throw new Error('Initial bearing is undefined for coincident coordinates.'); + + return normalizeDegrees((Math.atan2(y, x) * 180) / Math.PI); +} + +export function geographicDirectionBetweenCells( + fromCell: H3Cell, + toCell: H3Cell, +): GeographicDirection { + if (fromCell === toCell) + throw new Error('Move direction requires distinct H3 cells.'); + + return directionFromInitialBearing( + initialBearingDegrees(cellToLatLng(fromCell), cellToLatLng(toCell)), + ); +} diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 70fe344..7cb8733 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { gridDistance } from 'h3-js'; +import { gridDisk, gridDistance } from 'h3-js'; import { AgentProviderError, ScriptedAgentProvider, @@ -51,6 +51,7 @@ import { calculateExperimentMetrics, serializeExperimentExport, } from './experiment-export'; +import { geographicDirectionBetweenCells } from './geographic-direction'; const now = () => '2026-08-13T12:00:01.000Z'; const createEventId = () => '67aa21b9-fc78-4b04-9f92-9862bf346f96'; @@ -1689,6 +1690,9 @@ describe('SimulationService', () => { { worldAction: { type: 'infect' }, summary: 'Infect.' }, ]), ); + const worldCells = new Set( + simulation.getSnapshot().world.hexes.map(({ cell }) => cell), + ); const turn = await simulation.executeNextTurn(); expect(turn.observation.actionAvailability).toMatchObject({ moveTargetCellIds: turn.observation.adjacentCells.map(({ cell }) => cell), @@ -1699,6 +1703,21 @@ describe('SimulationService', () => { expect(turn.observation.actionAvailability.moveOptions).toHaveLength( turn.observation.adjacentCells.length, ); + const legalTargets = gridDisk(turn.observation.currentCell.cell, 1) + .filter((cell) => cell !== turn.observation.currentCell.cell) + .map((cell) => h3CellSchema.parse(cell)) + .filter((cell) => worldCells.has(cell)); + expect( + new Set(turn.observation.actionAvailability.moveTargetCellIds), + ).toEqual(new Set(legalTargets)); + for (const option of turn.observation.actionAvailability.moveOptions) { + expect(option.direction).toBe( + geographicDirectionBetweenCells( + turn.observation.currentCell.cell, + option.targetCell, + ), + ); + } expect(turn.outcome).toBe('accepted'); if ( turn.outcome === 'provider-error' || diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index b8164d2..3b69549 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -102,6 +102,7 @@ import { type ExperimentSource, ExperimentMetricAccumulator, } from './experiment-export'; +import { geographicDirectionBetweenCells } from './geographic-direction'; const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; const MAX_TURN_HISTORY = 120; @@ -2279,13 +2280,12 @@ export class SimulationService { : actingAlliance && controllerAlliance?.id === actingAlliance.id ? ('allied' as const) : ('other' as const); - const directions = ['N', 'NE', 'SE', 'S', 'SW', 'NW'] as const; - const canonicalIndex = gridDisk(agent.currentCell, 1) - .filter((cell) => cell !== agent.currentCell) - .indexOf(destination.cell); return { targetCell: destination.cell, - direction: directions[Math.max(0, canonicalIndex)]!, + direction: geographicDirectionBetweenCells( + agent.currentCell, + destination.cell, + ), destinationState: destination.state, controllerRelationship: relationship, recentlyOccupied: recentMovements.some( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cc7934c..b3363a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,7 +80,7 @@ Model summaries remain explicitly self-reported evidence rather than causal proof. Cell highlighting reuses browser selection state and creates no server mutation, new telemetry, retention, provider field, or world authority. -Communication resolves against the authoritative pre-action snapshot. Public chat is globally observable and future-player-visible. Direct messages use H3-center great-circle distance and the scenario's bounded kilometer range. Alliance messages are private to current members regardless of distance. World Lab may inspect private traffic; player-facing contracts must not include that omniscient feed. Equivalent legal moves are ordered reproducibly from world seed, stable agent ID, and logical turn without process randomness. +Communication resolves against the authoritative pre-action snapshot. Public chat is globally observable and future-player-visible. Direct messages use H3-center great-circle distance and the scenario's bounded kilometer range. Alliance messages are private to current members regardless of distance. World Lab may inspect private traffic; player-facing contracts must not include that omniscient feed. Equivalent legal moves are ordered reproducibly from world seed, stable agent ID, and logical turn without process randomness. Their six-value compass labels are derived independently from the geographic initial bearing between H3 cell centers using equal 60-degree sectors; H3 traversal order never determines direction. `apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, monotonic completed-turn count, turn cursor, bounded histories, per-agent strategic goals and compact memory ledgers, and overlap lock. Goals and memories are not part of world-engine `Agent` ownership and grant no engine authority. It exposes: diff --git a/docs/TESTING.md b/docs/TESTING.md index 1adf323..99e14cd 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -20,8 +20,9 @@ cost warnings, cancellation reconciliation, and lost-tick status. Focused deterministic coverage includes physical-distance messaging across H3 resolutions, the eight-agent observation cap, alliance long-range visibility and -delivery, channel privacy, seeded move-affordance ordering, effective -neutral/alliance colors, and operator-only private-feed filters. Provider tests +delivery, channel privacy, seeded move-affordance ordering, H3-center-bearing +direction labels across locations and resolutions independent of traversal +order, effective neutral/alliance colors, and operator-only private-feed filters. Provider tests remain offline and verify the flat `text-flat-json-v8` contract, its selective-communication, bounded goal and memory, and diplomacy-affordance policy text, unchanged wire parsing, and legacy v3-v7 attribution compatibility.