Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/game-api/src/geographic-direction.test.ts
Original file line number Diff line number Diff line change
@@ -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());
},
);
});
71 changes: 71 additions & 0 deletions apps/game-api/src/geographic-direction.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
}
21 changes: 20 additions & 1 deletion apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand All @@ -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' ||
Expand Down
10 changes: 5 additions & 5 deletions apps/game-api/src/simulation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading