diff --git a/.claude/settings.json b/.claude/settings.json index 3196fcf..77eec05 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,32 +1,34 @@ { - "permissions": { - "allow": [ - "Bash(pnpm --filter @triplanetary/server db:migrate)", - "Bash(DATABASE_URL=postgresql://triplanetary:triplanetary@localhost:5432/triplanetary pnpm db:migrate)", - "Bash(DATABASE_URL=postgresql://triplanetary:triplanetary@localhost:5432/triplanetary ./node_modules/.bin/drizzle-kit migrate)", - "Bash(docker exec:*)", - "Bash(pnpm --filter @triplanetary/server test)", - "Bash(pnpm view:*)", - "Bash(node -e \"import\\('boardgame.io/server'\\).then\\(m => console.log\\(Object.keys\\(m\\)\\)\\)\")", - "Bash(node -e \"import\\('boardgame.io/server'\\).then\\(m => { const s = m.Server\\({ games: [] }\\); console.log\\(Object.keys\\(s\\)\\); }\\)\")", - "Bash(node -e \"const m = require\\('boardgame.io/server'\\); const s = m.Server\\({ games: [] }\\); console.log\\(Object.keys\\(s\\)\\);\")", - "Bash(node -e \"const m = require\\('boardgame.io/server'\\); const s = m.Server\\({ games: [], origins: ['http://localhost:3000'] }\\); console.log\\(s.app.constructor.name\\); console.log\\(typeof s.app.use\\);\")", - "Bash(node -e \"const m = require\\('./packages/server/node_modules/boardgame.io/dist/cjs/server.js'\\); const s = m.Server\\({ games: [], origins: ['http://localhost:3000'] }\\); console.log\\(s.app.constructor.name\\); console.log\\(typeof s.app.use\\); console.log\\(typeof s.router\\);\")", - "Bash(node -e \"const m = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/boardgame.io@0.50.2/node_modules/boardgame.io/dist/cjs/server.js'\\); const s = m.Server\\({ games: [], origins: ['http://localhost:3000'] }\\); console.log\\('app type:', s.app.constructor.name\\); console.log\\('router type:', typeof s.router\\); console.log\\('router keys:', s.router && Object.keys\\(s.router\\).slice\\(0,5\\)\\);\")", - "Bash(timeout 8 tsx src/index.ts)", - "Bash(pnpm --filter @triplanetary/server exec timeout 8 tsx src/index.ts)", - "Bash(curl -s localhost:8000/api/health)", - "Bash(curl -s localhost:8000/games)", - "Bash(node -e \"const p = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/boardgame.io@0.50.2/node_modules/boardgame.io/package.json'\\); console.log\\(JSON.stringify\\(p.exports, null, 2\\)\\)\")", - "Bash(node -e \"const p = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/boardgame.io@0.50.2/node_modules/boardgame.io/package.json'\\); console.log\\(p.main, p.module, p.types\\)\")", - "Read(//private/tmp/**)", - "Bash(node -e \"const f = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/is-generator-function@1.1.2/node_modules/generator-function/index.js'\\); console.log\\(typeof f, f\\(\\)\\)\")", - "Bash(node -e \"const f = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/is-generator-function@1.1.2/node_modules/is-generator-function/index.js'\\); console.log\\(f\\(async function\\(\\){}\\), f\\(function*\\(\\){}\\)\\)\")", - "Bash(node -e \"const cjs = require\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/boardgame.io@0.50.2/node_modules/boardgame.io/dist/cjs/server.js'\\); const s = cjs.Server\\({ games: [], origins: ['http://localhost:3000'] }\\); s.app.use\\(function\\(ctx, next\\) { return next\\(\\); }\\); console.log\\('koa app.use works'\\)\")", - "Bash(node -e ':*)", - "Bash(node -e \"const src = require\\('fs'\\).readFileSync\\('/Users/sam/Code/triplanetary/node_modules/.pnpm/boardgame.io@0.50.2/node_modules/boardgame.io/dist/cjs/server.js', 'utf8'\\); const idx = src.indexOf\\('run\\('\\); console.log\\(src.slice\\(idx, idx+600\\)\\);\")", - "Bash(pnpm add:*)", - "Bash(pnpm --filter @triplanetary/client test)" + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "./scripts/post-edit-lint.sh" + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "./scripts/post-edit-lint.sh" + } + ] + }, + { + "matcher": "MultiEdit", + "hooks": [ + { + "type": "command", + "command": "./scripts/post-edit-lint.sh" + } + ] + } ] - } + }, + "permissions": {} } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5deb340 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared + run: pnpm --filter @triplanetary/shared build + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: triplanetary + POSTGRES_PASSWORD: triplanetary + POSTGRES_DB: triplanetary + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U triplanetary -d triplanetary -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgresql://triplanetary:triplanetary@localhost:5433/triplanetary + SESSION_SECRET: ci-test-secret + NODE_ENV: test + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared + run: pnpm --filter @triplanetary/shared build + + - name: Wait for Postgres + run: | + for i in {1..30}; do + (echo > /dev/tcp/localhost/5433) >/dev/null 2>&1 && exit 0 + echo "Postgres not ready yet ($i/30)..." + sleep 2 + done + echo "Postgres did not become ready in time" >&2 + exit 1 + + - name: Run migrations + run: pnpm --filter @triplanetary/server db:migrate + + - name: Test + run: pnpm test + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore index 4cc7d3c..9550bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .turbo/ *.tsbuildinfo .worktrees/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 6a56ba3..f84110c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,3 +115,52 @@ pnpm --filter @triplanetary/shared build - **Phase 2** — Game engine (vector movement, gravity, combat) - **Phase 3** — Lobby refinements, multiplayer polish - **Phase 4** — AI bot + +--- + +## Decision Log + +Append entries here whenever an architectural, convention, or tooling decision is made. + +### Single-port Koa server (boardgame.io + custom routes) +boardgame.io's built-in Koa server is the only backend process. Custom `/api/*` routes mount directly on its Koa app rather than a separate server. Avoids a second port and proxying complexity. All custom middleware must be Koa-compatible; boardgame.io's internal routing conventions must be respected. + +### CommonJS for `shared` and `server` packages +boardgame.io 0.50.x ships CJS-only with no ESM exports map — any ESM import fails. Do not add `"type": "module"` to `packages/shared` or `packages/server`. `packages/client` is ESM (Vite handles bundling). + +### ts-node --transpile-only as server dev runner (not tsx) +tsx v4's ESM loader pollutes the CJS module cache, causing `TypeError: getGeneratorFunction is not a function` in koa-passport. Do not switch back to `tsx watch`. Note: transpile-only skips type checking at dev-server startup — run `pnpm typecheck` separately. + +### bodyParser scoped to /api router +`koa-bodyparser` at app-level consumes the request stream before boardgame.io's `co-body` parser runs → `InternalServerError: stream is not readable` on match creation. Scope it to the `/api` Router only. + +### PostgreSQL on port 5433 +Port 5432 conflicts with a locally installed PostgreSQL. All connection strings and CI service definitions target `:5433`. + +### Custom pg session store (not connect-pg-simple) +connect-pg-simple has CJS/ESM resolution issues in this module setup. Hand-rolled store in `session.ts` using plain `pool.query`. Expire column uses `NOW() + ($n * INTERVAL '1 millisecond')` — passing a JS `Date` into a `timestamp WITHOUT TIME ZONE` column strips timezone and causes `expire > NOW()` comparisons to fail. + +### Custom migration runner (not drizzle-kit migrate) +drizzle-kit's `migrate` CLI fails with CJS/ESM resolution errors. Migrations are applied via `packages/server/src/db/migrate.ts` (run with `tsx`). New migration files must be manually registered in `meta/_journal.json`. CI calls `pnpm --filter @triplanetary/server db:migrate` (bypasses turbo) so `DATABASE_URL` reaches the subprocess. + +### Turbo env passthrough: declare env vars in turbo.json +Turbo v2 does not forward env vars to task subprocesses unless declared under `"env": [...]` in the task config. Any task that reads env vars at runtime must declare them there. + +### Vite alias: @triplanetary/shared → TypeScript source +Vite/Rollup cannot statically analyse `__exportStar` from tsc's CJS output — named exports appear missing at bundle time. `vite.config.ts` aliases `@triplanetary/shared` to `../shared/src/index.ts`. The server still imports from the compiled `dist/`. + +### Vitest fork isolation for server integration tests +`pool: 'forks'` + `poolOptions: { forks: { isolate: true } }` in `packages/server/vitest.config.ts`. Without this, `pool.end()` in one test file's `afterAll` terminates the shared pg Pool used by concurrently-running test files. + +### Per-file email namespacing for server integration tests +Test files run concurrently. A broad `DELETE FROM users` in one file's `beforeAll` races with another file's user registration. Each file owns a unique email prefix and cleans up only its own rows: + +| File | Namespace | +|---|---| +| `auth.test.ts` | `test@example.com` | +| `auth-validation.test.ts` | `auth-val-*@test.com` | +| `maps.test.ts` | `mapeditor-*@test.com` | +| `maps-export.test.ts` | `export-test@test.com` | +| `session.test.ts` | `test-session-*` (session table only) | + +New integration test files must choose a unique prefix and clean up only that prefix. diff --git a/docs/superpowers/plans/2026-04-10-phase-1a-map-editor.md b/docs/superpowers/plans/2026-04-10-phase-1a-map-editor.md new file mode 100644 index 0000000..b941381 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-phase-1a-map-editor.md @@ -0,0 +1,2225 @@ +# Phase 1a: Map Editor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an admin-only map editor at `/admin/map-editor` that lets a logged-in admin author, save, and export the `HEX_DATA` JSON that drives every game map — starting with the canonical Triplanetary Inner Solar System map. + +**Architecture:** Gravity computation lives in `@triplanetary/shared` as a pure function (no external deps, CJS-compatible). The client renders an SVG hex grid via `honeycomb-grid` (ESM, client-only). Editor state is managed locally in a `useMapEditor` hook and synced to the server on explicit Save. Maps are stored as versioned `jsonb` blobs in a Postgres `maps` table. + +**Tech Stack:** honeycomb-grid v4, react-zoom-pan-pinch, Drizzle ORM (jsonb), Vitest, MSW, Supertest. + +--- + +## Background: Coordinate System + +- **Axial coordinates** `(q, r)` throughout. Stored as string keys `"q,r"` in HexData records. +- **Pointy-top** hex orientation (matches original Triplanetary board). +- **HEX_SIZE = 48px** default. +- The 6 axial unit directions (and their indices used in ring traversal): + ``` + 0: [1, 0] (E) + 1: [0, 1] (SE) + 2: [-1, 1] (SW) + 3: [-1, 0] (W) + 4: [0, -1] (NW) + 5: [1, -1] (NE) + ``` +- **Gravity offset** = the axial direction FROM hex H TOWARD body center B, i.e., `normalize(B - H)`. Ships passing through a gravity hex have this vector added to their velocity. +- **Ring traversal** at radius R from center `(cq, cr)`: start at `(cq, cr - R)` (NW neighbor × R), walk 6 sides of R steps each in order `dirs[0..5]`. + +## Canonical Map Coordinates (seed data) + +From PRD schema example (subject to post-authoring audit pass against physical board): + +| Body | Center | gravityRings | Notes | +|---------|---------|--------------|------------------| +| sol | 0,0 | 2 | Large body | +| terra | 0,4 | 1 | | +| luna | 1,5 | 1 | weakGravity:true | +| venus | -4,2 | 1 | | +| mars | 5,-3 | 1 | | +| ceres | 9,1 | 0 | asteroidBase | + +Bases, asteroids, and clandestine hex are authored in the editor and seeded from the script. + +--- + +## File Structure + +### `packages/shared/src/` +| File | Status | Responsibility | +|------|--------|----------------| +| `lib/gravity.ts` | **Create** | `computeGravity()` — pure axial coordinate gravity computation | +| `lib/__tests__/gravity.test.ts` | **Create** | Unit tests for gravity computation | +| `index.ts` | **Modify** | Re-export `lib/gravity` | +| `package.json` | **Modify** | Add vitest devDep + test script | +| `vitest.config.ts` | **Create** | Vitest config for shared package | + +### `packages/server/src/` +| File | Status | Responsibility | +|------|--------|----------------| +| `db/schema.ts` | **Modify** | Add `maps` pgTable | +| `db/migrations/0002_add_maps_table.sql` | **Create** | Generated by drizzle-kit | +| `api/middleware/requireAdmin.ts` | **Create** | Koa middleware: 403 if !ctx.state.user?.isAdmin | +| `api/routes/maps.ts` | **Create** | GET /api/maps, GET /api/maps/:id, POST, PUT, GET .../export | +| `index.ts` | **Modify** | Mount mapsRouter under /api | +| `db/seed/canonicalMap.ts` | **Create** | Seeds Inner Solar System map using computeGravity | +| `__tests__/maps.test.ts` | **Create** | Supertest integration tests for maps API | +| `package.json` | **Modify** | Add `db:seed` script | + +### `packages/client/src/` +| File | Status | Responsibility | +|------|--------|----------------| +| `components/AdminGuard.tsx` | **Create** | Redirects non-admins to / | +| `App.tsx` | **Modify** | Add `/admin/map-editor` route behind AdminGuard | +| `pages/admin/MapEditorPage.tsx` | **Create** | Main editor page: assembles toolbar + grid + inspector | +| `pages/admin/EditorToolbar.tsx` | **Create** | Mode radio buttons (Select/Space/Asteroid/Planet/Base/Gravity/WeakGravity/Clandestine/Erase) | +| `pages/admin/HexInspector.tsx` | **Create** | Sidebar: selected hex data, gravity rosette override, Mark Weak, Erase | +| `pages/admin/BodyPicker.tsx` | **Create** | Modal/dropdown for choosing astral body when in Planet mode | +| `components/map/HexGrid.tsx` | **Create** | SVG hex grid: renders all hexes in bounds, colors by type, pan/zoom | +| `hooks/useMapEditor.ts` | **Create** | Editor state: mode, selectedHex, hexData (immutable), dirty flag | +| `hooks/useMaps.ts` | **Create** | TanStack Query: list maps, get map, create map, update map | +| `__tests__/AdminGuard.test.tsx` | **Create** | Renders admin route when isAdmin=true, redirects otherwise | +| `__tests__/HexGrid.test.tsx` | **Create** | Renders correct polygon count from fixture HexData | +| `__tests__/useMapEditor.test.ts` | **Create** | Mode changes, hex click actions, immutable state updates | + +--- + +## Task 1: Gravity Computation Lib (shared) + +**Files:** +- Create: `packages/shared/src/lib/gravity.ts` +- Create: `packages/shared/src/lib/__tests__/gravity.test.ts` +- Create: `packages/shared/vitest.config.ts` +- Modify: `packages/shared/package.json` +- Modify: `packages/shared/src/index.ts` + +- [ ] **Step 1: Add vitest to shared package** + +```bash +cd packages/shared +pnpm add -D vitest +``` + +Create `packages/shared/vitest.config.ts`: +```typescript +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { environment: 'node' }, +}); +``` + +Add to `packages/shared/package.json` scripts: +```json +"test": "vitest run" +``` + +- [ ] **Step 2: Write failing tests** + +Create `packages/shared/src/lib/__tests__/gravity.test.ts`: +```typescript +import { describe, it, expect } from 'vitest'; +import { computeGravity, hexRing } from '../gravity'; +import type { BodyEntry } from '../../types/map'; + +describe('hexRing', () => { + it('returns 6 hexes at radius 1 from origin', () => { + const ring = hexRing(0, 0, 1); + expect(ring).toHaveLength(6); + // All must be at axial distance 1 from center + for (const [q, r] of ring) { + const dist = (Math.abs(q) + Math.abs(r) + Math.abs(-q - r)) / 2; + expect(dist).toBe(1); + } + }); + + it('returns 12 hexes at radius 2 from origin', () => { + expect(hexRing(0, 0, 2)).toHaveLength(12); + }); + + it('works from a non-origin center', () => { + const ring = hexRing(3, -2, 1); + expect(ring).toHaveLength(6); + }); +}); + +describe('computeGravity', () => { + const terra: BodyEntry = { center: '0,4', radius: 1, gravityRings: 1 }; + + it('generates gravity hexes around a body', () => { + const result = computeGravity({ terra }); + // Ring at radius 1 around (0,4): 6 hexes + expect(Object.keys(result)).toHaveLength(6); + // All entries should be gravity type pointing at terra + for (const hex of Object.values(result)) { + expect(hex.type).toBe('gravity'); + expect(hex.body).toBe('terra'); + expect(hex.manual).toBe(false); + } + }); + + it('hex north of body (0,3) pulls southward toward (0,4)', () => { + // (0,3) is at r=3, terra center is r=4; pull = +r direction = [0,+1] + const result = computeGravity({ terra }); + expect(result['0,3']).toMatchObject({ type: 'gravity', offset: [0, 1] }); + }); + + it('preserves manual overrides across recompute', () => { + const existingHexes = { + '0,3': { type: 'gravity' as const, body: 'terra', offset: [-1, 0] as [number, number], manual: true }, + }; + const result = computeGravity({ terra }, existingHexes); + // Manual override preserved + expect(result['0,3']).toMatchObject({ offset: [-1, 0], manual: true }); + // Other ring hexes still computed + expect(Object.keys(result).length).toBe(6); + }); + + it('does not generate gravity for a body with gravityRings=0', () => { + const ceres: BodyEntry = { center: '9,1', gravityRings: 0, asteroidBase: true }; + const result = computeGravity({ ceres }); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('generates 18 gravity hexes for a body with gravityRings=2', () => { + // Ring 1: 6, Ring 2: 12 → 18 total + const sol: BodyEntry = { center: '0,0', radius: 3, gravityRings: 2 }; + const result = computeGravity({ sol }); + expect(Object.keys(result)).toHaveLength(18); + }); +}); +``` + +- [ ] **Step 3: Run tests — verify FAIL** + +```bash +pnpm --filter @triplanetary/shared test +``` +Expected: FAIL (`Cannot find module '../gravity'`) + +- [ ] **Step 4: Implement gravity.ts** + +Create `packages/shared/src/lib/gravity.ts`: +```typescript +import type { BodyEntry, HexEntry } from '../types/map'; + +// The 6 axial unit directions for pointy-top hexes. +// Index order matches the standard hex ring traversal (E, SE, SW, W, NW, NE). +const AXIAL_DIRS: readonly [number, number][] = [ + [1, 0], [0, 1], [-1, 1], [-1, 0], [0, -1], [1, -1], +] as const; + +/** + * Returns all hexes on the ring at `radius` distance from `(cq, cr)`. + * Algorithm: start at (cq, cr-radius), walk 6 sides of `radius` steps each. + */ +export function hexRing(cq: number, cr: number, radius: number): [number, number][] { + if (radius === 0) return [[cq, cr]]; + const results: [number, number][] = []; + let hq = cq; + let hr = cr - radius; // start: direction [0,-1] * radius from center + for (let i = 0; i < 6; i++) { + const [dq, dr] = AXIAL_DIRS[i]!; + for (let j = 0; j < radius; j++) { + results.push([hq, hr]); + hq += dq; + hr += dr; + } + } + return results; +} + +/** + * Returns the axial unit direction closest to the vector (dq, dr). + * Uses the cube-coordinate dot product to find the best match. + */ +function nearestAxialDir(dq: number, dr: number): [number, number] { + let bestIdx = 0; + let bestScore = -Infinity; + for (let i = 0; i < AXIAL_DIRS.length; i++) { + const [aq, ar] = AXIAL_DIRS[i]!; + // Cube dot product: accounts for all 3 cube axes (q, r, s=-q-r) + const score = 2 * aq * dq + 2 * ar * dr + aq * dr + ar * dq; + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + return AXIAL_DIRS[bestIdx]!; +} + +/** + * Computes gravity hexes for all bodies. Existing manual overrides are preserved. + * Non-manual gravity hexes are dropped and recomputed from scratch. + * + * @param bodies Body definitions with center coordinates and gravityRings radius. + * @param existing Existing hex entries (may include manual gravity overrides). + * @returns New hex record containing only gravity hexes (caller merges with non-gravity hexes). + */ +export function computeGravity( + bodies: Record, + existing: Record = {}, +): Record { + // Carry forward manual overrides only; drop non-manual gravity (will recompute) + const result: Record = Object.fromEntries( + Object.entries(existing).filter( + ([, h]) => !(h.type === 'gravity' && !h.manual), + ), + ); + + for (const [bodyName, body] of Object.entries(bodies)) { + const parts = body.center.split(','); + const cq = Number(parts[0]); + const cr = Number(parts[1]); + + for (let radius = 1; radius <= body.gravityRings; radius++) { + for (const [hq, hr] of hexRing(cq, cr, radius)) { + const key = `${hq},${hr}`; + if (result[key]?.manual) continue; // preserve manual override + // Do not overwrite planet, asteroid, or clandestine hexes with gravity + const existing = result[key]; + if (existing && existing.type !== 'gravity') continue; + const offset = nearestAxialDir(cq - hq, cr - hr); + result[key] = { type: 'gravity', body: bodyName, offset, manual: false }; + } + } + } + + return result; +} +``` + +- [ ] **Step 5: Run tests — verify PASS** + +```bash +pnpm --filter @triplanetary/shared test +``` +Expected: All tests PASS. + +- [ ] **Step 6: Fix HexEntry type to support weakGravity on planet hexes** + +The PRD specifies `"type": "planet", "body": "luna", "weakGravity": true` as valid. Add the field to `packages/shared/src/types/map.ts`: + +```typescript +export interface HexEntry { + type: HexType; + body?: string; + offset?: [number, number]; + manual?: boolean; + weak?: boolean; // gravity hexes: weak gravity rule applies (Luna/Io adjacent hexes) + weakGravity?: boolean; // planet hexes: the body itself is a weak-gravity body (Luna, Io) + coloredAsteroids?: string[]; +} +``` + +Also add a test for this in `gravity.test.ts`: +```typescript +it('does not overwrite planet hexes with gravity', () => { + // Sol at (0,0) with gravityRings=2 — but terra is at (0,1) which is in Sol's ring + const sol: BodyEntry = { center: '0,0', radius: 3, gravityRings: 2 }; + const withPlanet = { '0,1': { type: 'planet' as const, body: 'terra' } }; + const result = computeGravity({ sol }, withPlanet); + // The planet hex should NOT be overwritten by gravity + expect(result['0,1']?.type).toBe('planet'); +}); +``` + +- [ ] **Step 7: Re-export from shared index** + +Add to `packages/shared/src/index.ts`: +```typescript +export * from './lib/gravity'; +``` + +- [ ] **Step 8: Rebuild shared** + +```bash +pnpm --filter @triplanetary/shared build +``` +Expected: `dist/` regenerated, zero TS errors. + +- [ ] **Step 9: Commit** + +```bash +git add packages/shared/ +git commit -m "feat(shared): gravity auto-computation lib with unit tests" +``` + +--- + +## Task 2: Maps DB Table + Server API + +**Files:** +- Modify: `packages/server/src/db/schema.ts` +- Create: `packages/server/src/db/migrations/0002_add_maps_table.sql` (generated) +- Create: `packages/server/src/api/middleware/requireAdmin.ts` +- Create: `packages/server/src/api/routes/maps.ts` +- Modify: `packages/server/src/index.ts` +- Create: `packages/server/src/__tests__/maps.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `packages/server/src/__tests__/maps.test.ts`: +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import supertest from 'supertest'; +import { bgioServer } from '../bgio/server'; +import '../index'; // ensure middleware is registered + +// We'll make a test admin user for these tests +const app = bgioServer.app.callback(); +const request = supertest(app); + +// Helper: register + login a user and return cookie +async function loginUser( + email: string, + password: string, + displayName: string, + isAdmin = false, +): Promise { + await request.post('/api/auth/register').send({ email, password, displayName }); + // Promote to admin directly via DB if isAdmin=true + if (isAdmin) { + const { pool } = await import('../db/client'); + await pool.query('UPDATE users SET is_admin = TRUE WHERE email = $1', [email]); + } + const res = await request.post('/api/auth/login').send({ email, password }); + return res.headers['set-cookie']?.[0] ?? ''; +} + +let adminCookie: string; +let userCookie: string; + +beforeAll(async () => { + adminCookie = await loginUser('mapeditor-admin@test.com', 'pass', 'Admin', true); + userCookie = await loginUser('mapeditor-user@test.com', 'pass', 'User', false); +}); + +afterAll(async () => { + const { pool } = await import('../db/client'); + await pool.query( + "DELETE FROM users WHERE email IN ('mapeditor-admin@test.com','mapeditor-user@test.com')", + ); + await pool.query("DELETE FROM maps WHERE name LIKE 'Test Map%'"); +}); + +describe('GET /api/maps', () => { + it('returns 200 with array for any authenticated user', async () => { + const res = await request.get('/api/maps').set('Cookie', userCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('returns 401 for unauthenticated requests', async () => { + const res = await request.get('/api/maps'); + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/maps', () => { + const payload = { + name: 'Test Map 1', + version: '1.0', + data: { + meta: { name: 'Test Map 1', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: { '0,0': { type: 'space' } }, + bases: {}, + }, + }; + + it('creates a map as admin — 201 with id', async () => { + const res = await request + .post('/api/maps') + .set('Cookie', adminCookie) + .send(payload); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe('Test Map 1'); + }); + + it('returns 403 for non-admin user', async () => { + const res = await request + .post('/api/maps') + .set('Cookie', userCookie) + .send(payload); + expect(res.status).toBe(403); + }); +}); + +describe('GET /api/maps/:id', () => { + let createdId: string; + + beforeAll(async () => { + const res = await request.post('/api/maps').set('Cookie', adminCookie).send({ + name: 'Test Map 2', + version: '1.0', + data: { + meta: { name: 'Test Map 2', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it('returns full map data by id', async () => { + const res = await request.get(`/api/maps/${createdId}`).set('Cookie', userCookie); + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(res.body.data.meta.name).toBe('Test Map 2'); + }); + + it('returns 404 for unknown id', async () => { + const res = await request + .get('/api/maps/00000000-0000-0000-0000-000000000000') + .set('Cookie', userCookie); + expect(res.status).toBe(404); + }); +}); + +describe('PUT /api/maps/:id', () => { + let createdId: string; + + beforeAll(async () => { + const res = await request.post('/api/maps').set('Cookie', adminCookie).send({ + name: 'Test Map 3', + version: '1.0', + data: { + meta: { name: 'Test Map 3', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it('updates map data as admin', async () => { + const updated = { + name: 'Test Map 3 Updated', + version: '1.1', + data: { + meta: { name: 'Test Map 3 Updated', version: '1.1', hexSize: 48, orientation: 'pointy' }, + bodies: { terra: { center: '0,4', radius: 1, gravityRings: 1 } }, + hexes: {}, + bases: {}, + }, + }; + const res = await request + .put(`/api/maps/${createdId}`) + .set('Cookie', adminCookie) + .send(updated); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Test Map 3 Updated'); + }); + + it('returns 403 for non-admin', async () => { + const res = await request + .put(`/api/maps/${createdId}`) + .set('Cookie', userCookie) + .send({}); + expect(res.status).toBe(403); + }); +}); + +describe('GET /api/maps/:id/export', () => { + let createdId: string; + + beforeAll(async () => { + const res = await request.post('/api/maps').set('Cookie', adminCookie).send({ + name: 'Test Map Export', + version: '1.0', + data: { + meta: { name: 'Test Map Export', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it('returns JSON file download', async () => { + const res = await request.get(`/api/maps/${createdId}/export`).set('Cookie', userCookie); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.headers['content-disposition']).toMatch(/attachment/); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify FAIL** + +```bash +pnpm --filter @triplanetary/server test -- --reporter=verbose maps +``` +Expected: FAIL (`Cannot GET /api/maps`) + +- [ ] **Step 3: Add maps table to schema** + +Add to `packages/server/src/db/schema.ts`: +```typescript +import { pgTable, uuid, varchar, boolean, timestamp, smallint, unique, jsonb } from 'drizzle-orm/pg-core'; +import type { HexData } from '@triplanetary/shared'; + +// ... (keep existing tables) ... + +export const maps = pgTable('maps', { + id: uuid('id').defaultRandom().primaryKey(), + name: varchar('name', { length: 200 }).notNull(), + version: varchar('version', { length: 20 }).notNull().default('1.0'), + data: jsonb('data').notNull().$type(), + isCanonical: boolean('is_canonical').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +- [ ] **Step 4: Generate and run migration** + +```bash +pnpm --filter @triplanetary/server db:generate +pnpm --filter @triplanetary/server db:migrate +``` +Expected: New migration file `0002_add_maps_table.sql` created; `maps` table appears in psql. + +Verify: +```bash +docker exec -it triplanetary-postgres-1 psql -U triplanetary -c '\dt' +``` +Expected: `maps` table listed. + +- [ ] **Step 5: Create requireAdmin middleware** + +Create `packages/server/src/api/middleware/requireAdmin.ts`: +```typescript +import type { Context, Next } from 'koa'; + +export async function requireAdmin(ctx: Context, next: Next): Promise { + const user = ctx.state['user'] as { isAdmin?: boolean } | undefined; + if (!user?.isAdmin) { + ctx.status = 403; + ctx.body = { error: 'Admin access required' }; + return; + } + await next(); +} +``` + +- [ ] **Step 6: Create maps routes** + +Create `packages/server/src/api/routes/maps.ts`: +```typescript +import Router from '@koa/router'; +import { eq } from 'drizzle-orm'; +import { db } from '../../db/client'; +import { maps } from '../../db/schema'; +import { requireAdmin } from '../middleware/requireAdmin'; +import type { HexData } from '@triplanetary/shared'; + +const router = new Router(); + +// GET /api/maps — list all maps (no data, just metadata) — any authed user +router.get('/maps', async (ctx) => { + if (!ctx.state['user']) { + ctx.status = 401; + ctx.body = { error: 'Unauthenticated' }; + return; + } + const rows = await db + .select({ + id: maps.id, + name: maps.name, + version: maps.version, + isCanonical: maps.isCanonical, + createdAt: maps.createdAt, + updatedAt: maps.updatedAt, + }) + .from(maps) + .orderBy(maps.name); + ctx.body = rows; +}); + +// GET /api/maps/:id — full map with data — any authed user +router.get('/maps/:id', async (ctx) => { + if (!ctx.state['user']) { + ctx.status = 401; + ctx.body = { error: 'Unauthenticated' }; + return; + } + const [row] = await db + .select() + .from(maps) + .where(eq(maps.id, ctx.params['id'] ?? '')); + if (!row) { + ctx.status = 404; + ctx.body = { error: 'Map not found' }; + return; + } + ctx.body = row; +}); + +// POST /api/maps — create map — admin only +router.post('/maps', requireAdmin, async (ctx) => { + const body = ctx.request.body as { name: string; version?: string; data: HexData }; + if (!body.name || !body.data) { + ctx.status = 400; + ctx.body = { error: 'name and data required' }; + return; + } + const [row] = await db + .insert(maps) + .values({ + name: body.name, + version: body.version ?? '1.0', + data: body.data, + }) + .returning(); + ctx.status = 201; + ctx.body = row; +}); + +// PUT /api/maps/:id — update map — admin only +router.put('/maps/:id', requireAdmin, async (ctx) => { + const body = ctx.request.body as Partial<{ name: string; version: string; data: HexData }>; + const id = ctx.params['id'] ?? ''; + const [existing] = await db + .select({ id: maps.id }) + .from(maps) + .where(eq(maps.id, id)); + if (!existing) { + ctx.status = 404; + ctx.body = { error: 'Map not found' }; + return; + } + const [updated] = await db + .update(maps) + .set({ + ...(body.name !== undefined && { name: body.name }), + ...(body.version !== undefined && { version: body.version }), + ...(body.data !== undefined && { data: body.data }), + updatedAt: new Date(), + }) + .where(eq(maps.id, id)) + .returning(); + ctx.body = updated; +}); + +// GET /api/maps/:id/export — download as JSON file — any authed user +router.get('/maps/:id/export', async (ctx) => { + if (!ctx.state['user']) { + ctx.status = 401; + ctx.body = { error: 'Unauthenticated' }; + return; + } + const [row] = await db + .select() + .from(maps) + .where(eq(maps.id, ctx.params['id'] ?? '')); + if (!row) { + ctx.status = 404; + ctx.body = { error: 'Map not found' }; + return; + } + const filename = `${row.name.replace(/\s+/g, '-').toLowerCase()}-v${row.version}.json`; + ctx.set('Content-Disposition', `attachment; filename="${filename}"`); + ctx.set('Content-Type', 'application/json'); + ctx.body = JSON.stringify(row.data, null, 2); +}); + +export default router; +``` + +> **Import note:** The server package is CJS (`ts-node --transpile-only`). Use standard `import` statements — they work correctly. Avoid `require()` unless ts-node throws a CJS resolution error for a specific package (it won't here). + +- [ ] **Step 7: Mount mapsRouter in index.ts** + +In `packages/server/src/index.ts`, add after the existing router imports: +```typescript +import mapsRouter from './api/routes/maps'; +// ... +apiRouter.use(mapsRouter.routes()); +``` + +- [ ] **Step 8: Run tests — verify PASS** + +```bash +pnpm --filter @triplanetary/server test -- --reporter=verbose maps +``` +Expected: All maps tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add packages/server/src/db/schema.ts packages/server/src/db/migrations/ \ + packages/server/src/api/ packages/server/src/index.ts \ + packages/server/src/__tests__/maps.test.ts +git commit -m "feat(server): maps table, CRUD API routes, requireAdmin middleware" +``` + +--- + +## Task 3: AdminGuard + Route Wiring (client) + +**Files:** +- Create: `packages/client/src/components/AdminGuard.tsx` +- Modify: `packages/client/src/App.tsx` +- Create: `packages/client/src/pages/admin/MapEditorPage.tsx` (placeholder) +- Create: `packages/client/src/__tests__/AdminGuard.test.tsx` + +- [ ] **Step 1: Write failing test** + +Create `packages/client/src/__tests__/AdminGuard.test.tsx`: +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { AdminGuard } from '../components/AdminGuard'; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function renderWithAdmin(isAdmin: boolean, path = '/admin/map-editor') { + mswServer.use( + http.get('/api/auth/me', () => + HttpResponse.json({ id: '1', email: 'a@a.com', displayName: 'A', isAdmin }), + ), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + +
Admin Content
} /> + Home} /> +
+
+
, + ); +} + +describe('AdminGuard', () => { + it('renders children when user is admin', async () => { + renderWithAdmin(true); + await waitFor(() => expect(screen.getByText('Admin Content')).toBeInTheDocument()); + }); + + it('redirects to / when user is not admin', async () => { + renderWithAdmin(false); + await waitFor(() => expect(screen.getByText('Home')).toBeInTheDocument()); + }); + + it('renders nothing while loading auth', () => { + mswServer.use(http.get('/api/auth/me', () => new Promise(() => {}))); // never resolves + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + +
Admin Content
} /> +
+
+
, + ); + expect(screen.queryByText('Admin Content')).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test — verify FAIL** + +```bash +pnpm --filter @triplanetary/client test -- AdminGuard +``` +Expected: FAIL (`Cannot find module '../components/AdminGuard'`) + +- [ ] **Step 3: Create AdminGuard** + +Create `packages/client/src/components/AdminGuard.tsx`: +```typescript +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface Props { + children: React.ReactNode; +} + +export function AdminGuard({ children }: Props) { + const { data, isPending } = useAuth(); + if (isPending) return null; + if (!data?.isAdmin) return ; + return <>{children}; +} +``` + +- [ ] **Step 4: Create MapEditorPage placeholder** + +Create `packages/client/src/pages/admin/MapEditorPage.tsx`: +```typescript +export default function MapEditorPage() { + return ( +
+

Map Editor

+

Loading…

+
+ ); +} +``` + +- [ ] **Step 5: Add route to App.tsx** + +In `packages/client/src/App.tsx`, add import and route: +```typescript +import MapEditorPage from './pages/admin/MapEditorPage'; +import { AdminGuard } from './components/AdminGuard'; + +// Inside : + + + + } +/> +``` + +- [ ] **Step 6: Run test — verify PASS** + +```bash +pnpm --filter @triplanetary/client test -- AdminGuard +``` +Expected: All 3 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/client/src/components/AdminGuard.tsx \ + packages/client/src/pages/admin/ \ + packages/client/src/App.tsx \ + packages/client/src/__tests__/AdminGuard.test.tsx +git commit -m "feat(client): AdminGuard component and /admin/map-editor route" +``` + +--- + +## Task 4: HexGrid SVG Component (client) + +This component renders the full hex grid as SVG. It is used by both the map editor (with click handlers) and eventually the game renderer (read-only). It is intentionally decoupled from editor state — it receives HexData + callbacks as props. + +**Files:** +- Create: `packages/client/src/components/map/HexGrid.tsx` +- Create: `packages/client/src/__tests__/HexGrid.test.tsx` + +- [ ] **Step 1: Install dependencies** + +```bash +pnpm --filter @triplanetary/client add honeycomb-grid react-zoom-pan-pinch +``` + +- [ ] **Step 2: Write failing test** + +Create `packages/client/src/__tests__/HexGrid.test.tsx`: +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HexGrid } from '../components/map/HexGrid'; +import type { HexData } from '@triplanetary/shared'; + +const emptyData: HexData = { + meta: { name: 'Test', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +const dataWithPlanet: HexData = { + ...emptyData, + hexes: { '0,0': { type: 'planet', body: 'terra' } }, +}; + +describe('HexGrid', () => { + it('renders an SVG element', () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector('svg')).not.toBeNull(); + }); + + it('renders a polygon for each hex in the range', () => { + // q: -1..1 (3), r: -1..1 (3) — but not all combos are in a hex grid. + // With a rectangular range, we get 3*3 = 9 polygons. + const { container } = render( + {}} />, + ); + const polygons = container.querySelectorAll('polygon'); + expect(polygons.length).toBe(9); + }); + + it('calls onHexClick with the axial coordinate when a hex is clicked', async () => { + const handler = vi.fn(); + const { container } = render( + , + ); + const polygon = container.querySelector('polygon')!; + await userEvent.click(polygon); + expect(handler).toHaveBeenCalledWith(0, 0); + }); + + it('applies planet CSS class to planet hexes', () => { + const { container } = render( + {}} />, + ); + const polygon = container.querySelector('polygon')!; + expect(polygon.getAttribute('data-type')).toBe('planet'); + }); +}); +``` + +- [ ] **Step 3: Run test — verify FAIL** + +```bash +pnpm --filter @triplanetary/client test -- HexGrid +``` +Expected: FAIL (`Cannot find module '../components/map/HexGrid'`) + +- [ ] **Step 4: Implement HexGrid** + +Create `packages/client/src/components/map/HexGrid.tsx`: +```typescript +import { useMemo, useCallback } from 'react'; +import { defineHex, Grid, rectangle } from 'honeycomb-grid'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import type { HexData, HexEntry } from '@triplanetary/shared'; + +export const HEX_SIZE = 48; + +// Hex type → fill color +const HEX_COLORS: Record = { + space: '#0a0a1a', + gravity: '#0d1a3a', + planet: '#4a7c59', + asteroid: '#5a4a2a', + clandestine: '#3a0a3a', +}; + +const ProtoHex = defineHex({ + dimensions: HEX_SIZE, + orientation: 'pointy', + origin: 'topLeft', +}); + +interface Props { + data: HexData; + /** Inclusive q range [min, max] to render */ + qRange: [number, number]; + /** Inclusive r range [min, max] to render */ + rRange: [number, number]; + onHexClick: (q: number, r: number) => void; + selectedHex?: string | null; +} + +export function HexGrid({ data, qRange, rRange, onHexClick, selectedHex }: Props) { + const [qMin, qMax] = qRange; + const [rMin, rMax] = rRange; + + // Build honeycomb Grid for the given rectangular range + const grid = useMemo(() => { + return new Grid(ProtoHex, rectangle({ + width: qMax - qMin + 1, + height: rMax - rMin + 1, + start: { q: qMin, r: rMin }, + })); + }, [qMin, qMax, rMin, rMax]); + + const hexes = useMemo(() => [...grid], [grid]); + + // SVG viewport size: bounding box of all hex centers + margin + const svgWidth = (qMax - qMin + 2) * HEX_SIZE * Math.sqrt(3); + const svgHeight = (rMax - rMin + 2) * HEX_SIZE * 1.5; + + const handleClick = useCallback( + (q: number, r: number) => () => onHexClick(q, r), + [onHexClick], + ); + + return ( + + + + + {/* Gravity arrow marker */} + + + + + + {hexes.map((hex) => { + const { q, r } = hex; + const key = `${q},${r}`; + const entry: HexEntry | undefined = data.hexes[key]; + const type = entry?.type ?? 'space'; + const corners = hex.corners; + const points = corners.map((c) => `${c.x},${c.y}`).join(' '); + const center = hex.toPoint(); + const isSelected = selectedHex === key; + + return ( + + + {/* Gravity arrow */} + {entry?.type === 'gravity' && entry.offset && (() => { + const [dq, dr] = entry.offset; + // Convert axial delta to pixel delta + const dx = HEX_SIZE * (Math.sqrt(3) * dq + Math.sqrt(3) / 2 * dr); + const dy = HEX_SIZE * (3 / 2 * dr); + const len = Math.sqrt(dx * dx + dy * dy); + const scale = (HEX_SIZE * 0.5) / len; + return ( + + ); + })()} + {/* Body label */} + {entry?.type === 'planet' && ( + + {entry.body ?? ''} + + )} + + ); + })} + + + + + ); +} +``` + +> **honeycomb-grid v4 API notes:** +> - `defineHex({ dimensions, orientation, origin })` creates a Hex class +> - `new Grid(HexClass, rectangle({ width, height, start }))` creates a rectangular grid +> - `hex.corners` returns the 6 corner points as `{ x, y }` objects +> - `hex.toPoint()` returns the center `{ x, y }` +> - `hex.q` and `hex.r` are the axial coordinates +> - If the API differs slightly, check the honeycomb-grid v4 docs: the key concepts are the same + +- [ ] **Step 5: Run tests — verify PASS** + +```bash +pnpm --filter @triplanetary/client test -- HexGrid +``` +Expected: All 4 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/src/components/map/ packages/client/src/__tests__/HexGrid.test.tsx +git commit -m "feat(client): HexGrid SVG component with honeycomb-grid, pan/zoom, gravity arrows" +``` + +--- + +## Task 5: Editor State Management (client) + +`useMapEditor` manages all local editor state. It is a pure React hook with no server calls — those are handled separately in `useMaps`. + +**Files:** +- Create: `packages/client/src/hooks/useMapEditor.ts` +- Create: `packages/client/src/__tests__/useMapEditor.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `packages/client/src/__tests__/useMapEditor.test.ts`: +```typescript +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMapEditor } from '../hooks/useMapEditor'; +import type { HexData } from '@triplanetary/shared'; + +const BLANK: HexData = { + meta: { name: 'New Map', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +describe('useMapEditor', () => { + it('initializes with select mode and no selection', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + expect(result.current.mode).toBe('select'); + expect(result.current.selectedHex).toBeNull(); + expect(result.current.dirty).toBe(false); + }); + + it('setMode changes the current mode', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode('asteroid')); + expect(result.current.mode).toBe('asteroid'); + }); + + it('clicking a hex in select mode sets selectedHex', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.handleHexClick(3, -2)); + expect(result.current.selectedHex).toBe('3,-2'); + }); + + it('clicking a hex in erase mode resets it to space', () => { + const initial: HexData = { + ...BLANK, + hexes: { '0,0': { type: 'planet', body: 'terra' } }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setMode('erase')); + act(() => result.current.handleHexClick(0, 0)); + expect(result.current.hexData.hexes['0,0']).toMatchObject({ type: 'space' }); + expect(result.current.dirty).toBe(true); + }); + + it('erase mode does not mutate original HexData', () => { + const initial: HexData = { + ...BLANK, + hexes: { '0,0': { type: 'planet', body: 'terra' } }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setMode('erase')); + act(() => result.current.handleHexClick(0, 0)); + // Original still has planet + expect(initial.hexes['0,0']?.type).toBe('planet'); + }); + + it('asteroid mode marks hex as asteroid', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode('asteroid')); + act(() => result.current.handleHexClick(5, -3)); + expect(result.current.hexData.hexes['5,-3']).toMatchObject({ type: 'asteroid' }); + expect(result.current.dirty).toBe(true); + }); + + it('placeBody places a planet and auto-computes gravity', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.placeBody('terra', '0,4', { center: '0,4', radius: 1, gravityRings: 1 })); + expect(result.current.hexData.hexes['0,4']).toMatchObject({ type: 'planet', body: 'terra' }); + // At least some gravity hexes around (0,4) should have appeared + const gravHexes = Object.values(result.current.hexData.hexes).filter(h => h.type === 'gravity'); + expect(gravHexes.length).toBeGreaterThan(0); + expect(result.current.dirty).toBe(true); + }); + + it('setGravityOverride marks a hex with manual:true', () => { + const initial: HexData = { + ...BLANK, + hexes: { '0,3': { type: 'gravity', body: 'terra', offset: [0, 1], manual: false } }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setGravityOverride('0,3', [-1, 0])); + expect(result.current.hexData.hexes['0,3']).toMatchObject({ offset: [-1, 0], manual: true }); + }); + + it('resetDirty clears the dirty flag', () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode('asteroid')); + act(() => result.current.handleHexClick(0, 0)); + expect(result.current.dirty).toBe(true); + act(() => result.current.resetDirty()); + expect(result.current.dirty).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify FAIL** + +```bash +pnpm --filter @triplanetary/client test -- useMapEditor +``` +Expected: FAIL (`Cannot find module '../hooks/useMapEditor'`) + +- [ ] **Step 3: Implement useMapEditor** + +Create `packages/client/src/hooks/useMapEditor.ts`: +```typescript +import { useState, useCallback } from 'react'; +import { computeGravity } from '@triplanetary/shared'; +import type { HexData, HexEntry, BodyEntry } from '@triplanetary/shared'; + +export type EditorMode = + | 'select' + | 'space' + | 'asteroid' + | 'planet' + | 'base' + | 'gravity' + | 'weakGravity' + | 'clandestine' + | 'erase'; + +interface UseMapEditorResult { + mode: EditorMode; + setMode: (mode: EditorMode) => void; + hexData: HexData; + selectedHex: string | null; + dirty: boolean; + handleHexClick: (q: number, r: number) => void; + placeBody: (bodyName: string, centerKey: string, body: BodyEntry) => void; + setGravityOverride: (hexKey: string, offset: [number, number]) => void; + resetDirty: () => void; + loadHexData: (data: HexData) => void; +} + +export function useMapEditor(initial: HexData): UseMapEditorResult { + const [mode, setMode] = useState('select'); + const [hexData, setHexData] = useState(initial); + const [selectedHex, setSelectedHex] = useState(null); + const [dirty, setDirty] = useState(false); + + const applyHexChange = useCallback((key: string, entry: HexEntry) => { + setHexData((prev) => ({ + ...prev, + hexes: { ...prev.hexes, [key]: entry }, + })); + setDirty(true); + }, []); + + const handleHexClick = useCallback( + (q: number, r: number) => { + const key = `${q},${r}`; + switch (mode) { + case 'select': + setSelectedHex(key); + break; + case 'space': + applyHexChange(key, { type: 'space' }); + break; + case 'asteroid': + applyHexChange(key, { type: 'asteroid' }); + break; + case 'erase': + applyHexChange(key, { type: 'space' }); + break; + case 'gravity': + applyHexChange(key, { type: 'gravity', offset: [0, 1], manual: false }); + break; + case 'weakGravity': + setHexData((prev) => ({ + ...prev, + hexes: { + ...prev.hexes, + [key]: { ...prev.hexes[key], type: 'gravity', weak: true } as HexEntry, + }, + })); + setDirty(true); + break; + case 'clandestine': + applyHexChange(key, { type: 'clandestine' }); + break; + // 'planet' and 'base' modes require additional UI interaction (body picker), + // so handleHexClick just sets the selected hex; placeBody is called separately. + case 'planet': + case 'base': + setSelectedHex(key); + break; + } + }, + [mode, applyHexChange], + ); + + const placeBody = useCallback((bodyName: string, centerKey: string, body: BodyEntry) => { + setHexData((prev) => { + // Add body to bodies record + const newBodies = { ...prev.bodies, [bodyName]: body }; + // Add planet hex + const hexesWithPlanet = { + ...prev.hexes, + [centerKey]: { type: 'planet' as const, body: bodyName }, + }; + // Auto-compute gravity (preserves manual overrides) + const gravityHexes = computeGravity(newBodies, hexesWithPlanet); + // Merge: non-gravity hexes from hexesWithPlanet + new gravity hexes + const nonGravity = Object.fromEntries( + Object.entries(hexesWithPlanet).filter(([, h]) => h.type !== 'gravity'), + ); + return { ...prev, bodies: newBodies, hexes: { ...nonGravity, ...gravityHexes } }; + }); + setDirty(true); + }, []); + + const setGravityOverride = useCallback((hexKey: string, offset: [number, number]) => { + setHexData((prev) => ({ + ...prev, + hexes: { + ...prev.hexes, + [hexKey]: { ...prev.hexes[hexKey], offset, manual: true } as HexEntry, + }, + })); + setDirty(true); + }, []); + + const resetDirty = useCallback(() => setDirty(false), []); + + const loadHexData = useCallback((data: HexData) => { + setHexData(data); + setDirty(false); + setSelectedHex(null); + }, []); + + const assignBase = useCallback((bodyName: string, side: number) => { + setHexData((prev) => { + const existing = prev.bases[bodyName] ?? { hexSides: [] }; + const sides = existing.hexSides.includes(side) + ? existing.hexSides.filter((s) => s !== side) // toggle off + : [...existing.hexSides, side]; // toggle on + return { + ...prev, + bases: { ...prev.bases, [bodyName]: { ...existing, hexSides: sides } }, + }; + }); + setDirty(true); + }, []); + + return { + mode, + setMode, + hexData, + selectedHex, + dirty, + handleHexClick, + placeBody, + setGravityOverride, + assignBase, + resetDirty, + loadHexData, + }; +} +``` + +- [ ] **Step 4: Run tests — verify PASS** + +```bash +pnpm --filter @triplanetary/client test -- useMapEditor +``` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/hooks/useMapEditor.ts \ + packages/client/src/__tests__/useMapEditor.test.ts +git commit -m "feat(client): useMapEditor hook — immutable state, all editor modes, body placement" +``` + +--- + +## Task 6: Map Editor UI Assembly (client) + +Wire the editor together: toolbar, hex grid, and inspector panel. + +**Files:** +- Create: `packages/client/src/pages/admin/EditorToolbar.tsx` +- Create: `packages/client/src/pages/admin/HexInspector.tsx` +- Create: `packages/client/src/pages/admin/BodyPicker.tsx` +- Modify: `packages/client/src/pages/admin/MapEditorPage.tsx` + +No new tests for this task — the components are pure rendering wired to already-tested hooks. Visual verification via `pnpm dev`. + +- [ ] **Step 1: Create EditorToolbar** + +Create `packages/client/src/pages/admin/EditorToolbar.tsx`: +```typescript +import type { EditorMode } from '../../hooks/useMapEditor'; + +const MODES: { value: EditorMode; label: string }[] = [ + { value: 'select', label: 'Select' }, + { value: 'space', label: 'Space' }, + { value: 'asteroid', label: 'Asteroid' }, + { value: 'planet', label: 'Planet ▾' }, + { value: 'base', label: 'Base' }, + { value: 'gravity', label: 'Gravity' }, + { value: 'weakGravity', label: 'Weak Gravity' }, + { value: 'clandestine', label: 'Clandestine' }, + { value: 'erase', label: 'Erase' }, +]; + +interface Props { + mode: EditorMode; + onModeChange: (mode: EditorMode) => void; + onRecomputeGravity: () => void; +} + +export function EditorToolbar({ mode, onModeChange, onRecomputeGravity }: Props) { + return ( +
+ {MODES.map(({ value, label }) => ( + + ))} +
+ +
+ ); +} +``` + +- [ ] **Step 2: Create BodyPicker** + +Create `packages/client/src/pages/admin/BodyPicker.tsx`: +```typescript +import type { BodyEntry } from '@triplanetary/shared'; + +const BODIES: Array<{ name: string; entry: BodyEntry }> = [ + { name: 'sol', entry: { center: '', radius: 3, gravityRings: 2 } }, + { name: 'terra', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'luna', entry: { center: '', radius: 0, gravityRings: 1, weakGravity: true } }, + { name: 'venus', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'mars', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'ceres', entry: { center: '', gravityRings: 0, asteroidBase: true } }, + { name: 'jupiter', entry: { center: '', radius: 2, gravityRings: 2 } }, +]; + +interface Props { + onPick: (bodyName: string, bodyDef: BodyEntry) => void; + onCancel: () => void; +} + +export function BodyPicker({ onPick, onCancel }: Props) { + return ( +
+
+

Choose Body

+
    + {BODIES.map(({ name, entry }) => ( +
  • + +
  • + ))} +
+ +
+
+ ); +} +``` + +- [ ] **Step 3: Create BasePicker** + +Base mode lets the admin assign a base to a specific side (0–5) of the currently selected body's planet hex. The assignment is stored in `hexData.bases` keyed by body name. + +Create `packages/client/src/pages/admin/BasePicker.tsx`: +```typescript +import type { HexData } from '@triplanetary/shared'; + +// Hex side labels: sides 0-5 correspond to the 6 edges of the planet hex +const SIDE_LABELS = ['Side 0 (E)', 'Side 1 (SE)', 'Side 2 (SW)', 'Side 3 (W)', 'Side 4 (NW)', 'Side 5 (NE)']; + +interface Props { + selectedHex: string; + hexData: HexData; + onAssign: (bodyName: string, side: number) => void; + onCancel: () => void; +} + +export function BasePicker({ selectedHex, hexData, onAssign, onCancel }: Props) { + // Find which body (if any) occupies the selected hex + const entry = hexData.hexes[selectedHex]; + const bodyName = entry?.body; + + if (!bodyName) { + return ( +
+
+

Select a planet hex first, then switch to Base mode.

+ +
+
+ ); + } + + const existing = hexData.bases[bodyName]?.hexSides ?? []; + + return ( +
+
+

Assign Base to {bodyName}

+

Currently assigned sides: {existing.length > 0 ? existing.join(', ') : 'none'}

+
+ {SIDE_LABELS.map((label, side) => ( + + ))} +
+ +
+
+ ); +} +``` + +`assignBase` is already included in `useMapEditor.ts` above (in the return statement). Add it to the `UseMapEditorResult` interface: +```typescript +interface UseMapEditorResult { + // ... existing fields ... + assignBase: (bodyName: string, side: number) => void; +} +``` + +Wire `BasePicker` into `MapEditorPage.tsx`. Add alongside the BodyPicker wiring: +```typescript +import { BasePicker } from './BasePicker'; + +// State: +const [showBasePicker, setShowBasePicker] = useState(false); + +// In handleHexClickWithBodyPicker: +if (editor.mode === 'base') { + setShowBasePicker(true); +} + +// Handler: +const handleBaseAssign = useCallback( + (bodyName: string, side: number) => { + editor.assignBase(bodyName, side); + // Keep picker open for multi-side assignment; user clicks Done to close + }, + [editor], +); + +// In JSX, alongside the BodyPicker modal: +{showBasePicker && editor.selectedHex && ( + setShowBasePicker(false)} + /> +)} +``` + +- [ ] **Step 5: Create HexInspector** + +Create `packages/client/src/pages/admin/HexInspector.tsx`: +```typescript +import type { HexData } from '@triplanetary/shared'; +import type { UseMapEditorResult } from '../../hooks/useMapEditor'; + +// The 6 axial directions with labels +const GRAVITY_DIRS: Array<{ label: string; offset: [number, number] }> = [ + { label: '→', offset: [1, 0] }, + { label: '↘', offset: [0, 1] }, + { label: '↙', offset: [-1, 1] }, + { label: '←', offset: [-1, 0] }, + { label: '↖', offset: [0, -1] }, + { label: '↗', offset: [1, -1] }, +]; + +interface Props { + selectedHex: string | null; + hexData: HexData; + setGravityOverride: UseMapEditorResult['setGravityOverride']; + handleHexClick: UseMapEditorResult['handleHexClick']; +} + +export function HexInspector({ selectedHex, hexData, setGravityOverride, handleHexClick }: Props) { + if (!selectedHex) { + return
Click a hex to inspect
; + } + const [qStr, rStr] = selectedHex.split(','); + const q = Number(qStr); + const r = Number(rStr); + const entry = hexData.hexes[selectedHex]; + + return ( +
+
+ q: {q} r: {r} +
+ {entry ? ( + <> +
type: {entry.type}
+ {entry.body &&
body: {entry.body}
} + {entry.type === 'gravity' && entry.offset && ( +
+
offset: [{entry.offset[0]}, {entry.offset[1]}]
+
manual: {entry.manual ? 'yes' : 'no'}
+
+ Override: +
+ {GRAVITY_DIRS.map(({ label, offset }) => ( + + ))} +
+
+ {entry.weak &&
⚠ Weak gravity
} +
+ )} + + + ) : ( +
Empty space
+ )} +
+ ); +} +``` + +> **Note:** `UseMapEditorResult` needs to be exported from `useMapEditor.ts`. Add `export type { UseMapEditorResult }` or just `export interface UseMapEditorResult { ... }` there. + +- [ ] **Step 6: Assemble MapEditorPage** + +Replace `packages/client/src/pages/admin/MapEditorPage.tsx`: +```typescript +import { useState, useCallback } from 'react'; +import { HexGrid } from '../../components/map/HexGrid'; +import { EditorToolbar } from './EditorToolbar'; +import { HexInspector } from './HexInspector'; +import { BodyPicker } from './BodyPicker'; +import { useMapEditor } from '../../hooks/useMapEditor'; +import { computeGravity } from '@triplanetary/shared'; +import type { HexData, BodyEntry } from '@triplanetary/shared'; + +const BLANK_MAP: HexData = { + meta: { name: 'New Map', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +// Grid bounds: covers the full Triplanetary solar system with margin +const Q_RANGE: [number, number] = [-12, 14]; +const R_RANGE: [number, number] = [-10, 12]; + +export default function MapEditorPage() { + const editor = useMapEditor(BLANK_MAP); + const [showBodyPicker, setShowBodyPicker] = useState(false); + + const handleHexClickWithBodyPicker = useCallback( + (q: number, r: number) => { + editor.handleHexClick(q, r); + if (editor.mode === 'planet') { + setShowBodyPicker(true); + } + }, + [editor], + ); + + const handleBodyPick = useCallback( + (bodyName: string, bodyDef: BodyEntry) => { + if (!editor.selectedHex) return; + editor.placeBody(bodyName, editor.selectedHex, { + ...bodyDef, + center: editor.selectedHex, + }); + setShowBodyPicker(false); + }, + [editor], + ); + + const handleRecomputeGravity = useCallback(() => { + // Re-run computeGravity with all current bodies, preserving manual overrides + const gravityHexes = computeGravity(editor.hexData.bodies, editor.hexData.hexes); + const nonGravity = Object.fromEntries( + Object.entries(editor.hexData.hexes).filter(([, h]) => h.type !== 'gravity'), + ); + editor.loadHexData({ + ...editor.hexData, + hexes: { ...nonGravity, ...gravityHexes }, + }); + }, [editor]); + + return ( +
+ {/* Header */} +
+

Map Editor

+ {editor.hexData.meta.name} + {editor.dirty && ● unsaved} +
+ + +
+
+ + {/* Body */} +
+ {/* Left: toolbar */} +
+ +
+ + {/* Center: hex grid */} +
+ +
+ + {/* Right: inspector */} +
+ +
+
+ + {/* Body picker modal */} + {showBodyPicker && ( + setShowBodyPicker(false)} + /> + )} +
+ ); +} +``` + +- [ ] **Step 7: Smoke test in browser** + +```bash +pnpm dev +``` +1. Log in as admin (or manually set `is_admin = TRUE` in DB) +2. Navigate to `http://localhost:3000/admin/map-editor` +3. Verify the hex grid renders +4. Click a hex in Select mode → inspector shows coordinates +5. Switch to Asteroid mode, click hexes → they turn brown +6. Switch to Planet mode, click a hex → BodyPicker modal opens → choose "terra" → planet hex + gravity ring appears + +- [ ] **Step 8: Commit** + +```bash +git add packages/client/src/pages/admin/ +git commit -m "feat(client): map editor UI — toolbar, body/base pickers, hex inspector" +``` + +--- + +## Task 7: useMaps Hook + Save/Export Flow (client) + +**Files:** +- Create: `packages/client/src/hooks/useMaps.ts` +- Modify: `packages/client/src/pages/admin/MapEditorPage.tsx` + +No new Vitest tests for this task — the API is tested server-side. Manual verification via browser. + +- [ ] **Step 1: Create useMaps hook** + +Create `packages/client/src/hooks/useMaps.ts`: +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiRequest } from '../lib/apiClient'; +import type { HexData } from '@triplanetary/shared'; + +export interface MapSummary { + id: string; + name: string; + version: string; + isCanonical: boolean; + createdAt: string; + updatedAt: string; +} + +export interface MapDetail extends MapSummary { + data: HexData; +} + +export function useMaps() { + return useQuery({ + queryKey: ['maps'], + queryFn: () => apiRequest('/maps'), + }); +} + +export function useMap(id: string | null) { + return useQuery({ + queryKey: ['maps', id], + queryFn: () => apiRequest(`/maps/${id}`), + enabled: id !== null, + }); +} + +export function useCreateMap() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: { name: string; version: string; data: HexData }) => + apiRequest('/maps', { method: 'POST', body: payload }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['maps'] }), + }); +} + +export function useUpdateMap(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: { name?: string; version?: string; data?: HexData }) => + apiRequest(`/maps/${id}`, { method: 'PUT', body: payload }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['maps', id] }), + }); +} +``` + +> `apiRequest` needs to accept a method + body. Check `packages/client/src/lib/apiClient.ts` and update if needed: +> ```typescript +> export async function apiRequest( +> path: string, +> options?: { method?: string; body?: unknown }, +> ): Promise { +> const res = await fetch(`/api${path}`, { +> method: options?.method ?? 'GET', +> credentials: 'include', +> headers: { 'Content-Type': 'application/json' }, +> body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, +> }); +> if (!res.ok) throw new Error(`${res.status}`); +> return res.json() as Promise; +> } +> ``` + +- [ ] **Step 2: Wire save and export into MapEditorPage** + +Update the header section of `MapEditorPage.tsx` to use the hooks: +```typescript +import { useParams, useSearchParams } from 'react-router-dom'; +import { useCreateMap, useUpdateMap, useMap } from '../../hooks/useMaps'; + +// Inside MapEditorPage: +const [searchParams] = useSearchParams(); +const mapId = searchParams.get('id'); // e.g., /admin/map-editor?id= + +const { data: existingMap } = useMap(mapId); +const createMap = useCreateMap(); +const updateMap = useUpdateMap(mapId ?? ''); + +// Load existing map when data arrives +useEffect(() => { + if (existingMap) editor.loadHexData(existingMap.data); +}, [existingMap?.id]); + +const handleSave = async () => { + if (mapId) { + await updateMap.mutateAsync({ data: editor.hexData, name: editor.hexData.meta.name }); + } else { + const created = await createMap.mutateAsync({ + name: editor.hexData.meta.name, + version: editor.hexData.meta.version, + data: editor.hexData, + }); + // Navigate to the new map's URL + window.history.replaceState(null, '', `/admin/map-editor?id=${created.id}`); + } + editor.resetDirty(); +}; + +const handleExport = () => { + const json = JSON.stringify(editor.hexData, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${editor.hexData.meta.name.replace(/\s+/g, '-').toLowerCase()}.json`; + a.click(); + URL.revokeObjectURL(url); +}; +``` + +Update the Save and Export buttons in the header: +```tsx + + +``` + +- [ ] **Step 3: Manual verification** + +1. `pnpm dev` +2. Log in as admin, navigate to `/admin/map-editor` +3. Place some hexes, click Save → observe URL gains `?id=` +4. Reload page → hexes persist +5. Click Export → JSON file downloads +6. Inspect the JSON — verify `hexes`, `bodies`, `bases` are correct + +- [ ] **Step 4: Commit** + +```bash +git add packages/client/src/hooks/useMaps.ts \ + packages/client/src/lib/apiClient.ts \ + packages/client/src/pages/admin/MapEditorPage.tsx +git commit -m "feat(client): useMaps hook and save/export flow in map editor" +``` + +--- + +## Task 8: Canonical Map Seed (server) + +Seeds the Triplanetary Inner Solar System map into the database using `computeGravity`. + +**Files:** +- Create: `packages/server/src/db/seed/canonicalMap.ts` +- Modify: `packages/server/package.json` + +No automated test — verified by querying the DB and checking the seeded map loads in the editor. + +- [ ] **Step 1: Create seed script** + +Create `packages/server/src/db/seed/canonicalMap.ts`: +```typescript +import path from 'node:path'; +import { config } from 'dotenv'; +config({ path: path.resolve(__dirname, '../../../../.env') }); + +import { db, pool } from '../client'; +import { maps } from '../schema'; +import { computeGravity } from '@triplanetary/shared'; +import type { HexData, BodyEntry } from '@triplanetary/shared'; + +const BODIES: Record = { + sol: { center: '0,0', radius: 3, gravityRings: 2 }, + terra: { center: '0,4', radius: 1, gravityRings: 1 }, + luna: { center: '1,5', radius: 0, gravityRings: 1, weakGravity: true }, + venus: { center: '-4,2', radius: 1, gravityRings: 1 }, + mars: { center: '5,-3', radius: 1, gravityRings: 1 }, + ceres: { center: '9,1', gravityRings: 0, asteroidBase: true }, +}; + +// Planet hexes (body centers) +const PLANET_HEXES: Record = { + '0,0': { body: 'sol' }, + '0,4': { body: 'terra' }, + '1,5': { body: 'luna' }, + '-4,2': { body: 'venus' }, + '5,-3': { body: 'mars' }, + '9,1': { body: 'ceres' }, +}; + +// Known asteroids (adjust coordinates after physical board audit) +const ASTEROID_HEXES: string[] = ['7,2', '-2,-3', '3,6', '-5,5']; + +// Bases (hexSides reference the 6 sides of the planet hex, 0-indexed) +// These are approximate — audit against physical board after seeding. +const BASES: HexData['bases'] = { + terra: { hexSides: [0, 1, 2, 3, 4, 5] }, + venus: { hexSides: [0, 1, 2, 3, 4, 5] }, + mars: { hexSides: [0, 1, 2, 3] }, + luna: { hexSides: [2] }, + ceres: { hexSides: [0], asteroidBase: true, torpedo: true }, +}; + +async function seed() { + // Build initial hex entries from planets + const planetHexes: HexData['hexes'] = Object.fromEntries( + Object.entries(PLANET_HEXES).map(([key, { body }]) => [key, { type: 'planet', body }]), + ); + + // Add asteroids + const withAsteroids: HexData['hexes'] = { + ...planetHexes, + ...Object.fromEntries(ASTEROID_HEXES.map((k) => [k, { type: 'asteroid' }])), + }; + + // Auto-compute gravity + const gravityHexes = computeGravity(BODIES, withAsteroids); + + // Merge: planet/asteroid hexes + gravity hexes (gravity does not overwrite planets) + const nonGravity = Object.fromEntries( + Object.entries(withAsteroids).filter(([, h]) => h.type !== 'gravity'), + ); + const allHexes: HexData['hexes'] = { ...nonGravity, ...gravityHexes }; + + const hexData: HexData = { + meta: { + name: 'Triplanetary — Inner Solar System', + version: '1.0', + hexSize: 48, + orientation: 'pointy', + }, + bodies: BODIES, + hexes: allHexes, + bases: BASES, + }; + + // Upsert: delete existing canonical map and re-insert + await db.delete(maps).where( + // Drizzle: eq(maps.isCanonical, true) + // Use raw pool query to avoid circular import issues in seed scripts: + undefined as unknown as ReturnType['where'] extends (...args: infer A) => unknown ? A[0] : never, + ); + // Simpler: use pool directly for the seed + await pool.query('DELETE FROM maps WHERE is_canonical = TRUE'); + + await db.insert(maps).values({ + name: hexData.meta.name, + version: hexData.meta.version, + data: hexData, + isCanonical: true, + }); + + const count = Object.keys(allHexes).length; + console.log(`Seeded canonical map with ${count} hexes (${Object.keys(gravityHexes).length} gravity).`); + await pool.end(); +} + +seed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); +``` + +> **Note on the delete:** The seed script uses `pool.query` for the delete to avoid Drizzle `eq` import resolution issues in a CJS seed script run via tsx. Use whichever approach works — if `import { eq } from 'drizzle-orm'` works cleanly, use `db.delete(maps).where(eq(maps.isCanonical, true))` instead. + +- [ ] **Step 2: Add seed script to package.json** + +In `packages/server/package.json`, add to scripts: +```json +"db:seed": "tsx src/db/seed/canonicalMap.ts" +``` + +- [ ] **Step 3: Run the seed** + +```bash +pnpm --filter @triplanetary/server db:seed +``` +Expected: +``` +Seeded canonical map with N hexes (M gravity). +``` + +- [ ] **Step 4: Verify in editor** + +1. `pnpm dev` +2. Log in as admin, navigate to `/admin/map-editor` +3. Add `?id=` to the URL (get UUID from DB: `psql triplanetary -c "SELECT id FROM maps WHERE is_canonical = TRUE"`) +4. Verify planets appear at correct positions, gravity arrows point toward bodies +5. Check that Sol has a 2-ring gravity field, planets have 1-ring fields + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/db/seed/ packages/server/package.json +git commit -m "feat(server): canonical Triplanetary Inner Solar System map seed" +``` + +--- + +## Verification + +After all tasks are complete: + +```bash +pnpm test +# Expected: all server + client tests pass + +pnpm --filter @triplanetary/shared test +# Expected: gravity unit tests pass + +pnpm --filter @triplanetary/server test +# Expected: auth tests + maps API tests pass + +pnpm --filter @triplanetary/client test +# Expected: AdminGuard + HexGrid + useMapEditor + LobbyPage tests pass +``` + +Manual browser checklist: +- [ ] `/admin/map-editor` redirects non-admins to `/` +- [ ] Admin can view the hex grid (pan + zoom work) +- [ ] Select mode → inspector shows hex data +- [ ] Asteroid mode → hexes change color on click +- [ ] Planet mode → BodyPicker opens, placing Terra creates planet hex + gravity ring +- [ ] Erase mode → hexes reset to space +- [ ] Gravity rosette override → sets `manual: true`, arrow direction changes +- [ ] Recompute All Gravity → preserves manual overrides, recomputes rest +- [ ] Save → persists to DB, URL updates with `?id=` +- [ ] Reload → map loads from DB +- [ ] Export → JSON file downloads with correct schema +- [ ] Canonical map loads with planets at expected positions diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..45c9fcc --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,63 @@ +// @ts-check +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import globals from 'globals'; + +export default tseslint.config( + // Ignore generated/build output everywhere + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + 'packages/server/src/db/migrations/**', + ], + }, + + // Base TypeScript rules for all packages + ...tseslint.configs.recommended, + + // Node packages (shared + server) — no React + { + files: ['packages/shared/**/*.ts', 'packages/server/**/*.ts'], + languageOptions: { + globals: { ...globals.node }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + + // Client package — React + browser globals + { + files: ['packages/client/**/*.{ts,tsx}'], + ...reactPlugin.configs.flat.recommended, + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + }, + languageOptions: { + globals: { ...globals.browser }, + }, + settings: { + react: { version: 'detect' }, + }, + rules: { + ...reactPlugin.configs.flat.recommended.rules, + ...reactHooksPlugin.configs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + + // Test files — relax some rules + { + files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +); diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..d30b087 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,10 @@ +pre-commit: + commands: + lint: + run: pnpm lint + typecheck: + run: pnpm typecheck + test: + # Server integration tests require Postgres — run those in CI only. + # Pre-commit runs shared and client tests, which are self-contained. + run: pnpm --filter @triplanetary/shared test && pnpm --filter @triplanetary/client test diff --git a/package.json b/package.json index faccf0f..8faafb5 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,31 @@ { "name": "triplanetary", "private": true, + "type": "module", + "packageManager": "pnpm@10.28.2", "scripts": { "dev": "turbo run dev", "build": "turbo run build", "test": "turbo run test", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", "db:generate": "turbo run db:generate --filter=@triplanetary/server", - "db:migrate": "turbo run db:migrate --filter=@triplanetary/server" + "db:migrate": "turbo run db:migrate --filter=@triplanetary/server", + "prepare": "if [ -d .git ]; then lefthook install; fi" }, "devDependencies": { - "turbo": "^2.9.5" + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^17.4.0", + "lefthook": "^2.1.5", + "turbo": "^2.9.5", + "typescript-eslint": "^8.58.1" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild", "msw"] + "onlyBuiltDependencies": [ + "esbuild", + "msw" + ] } } diff --git a/packages/client/package.json b/packages/client/package.json index 9686a23..5e25e80 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite --port 3000", "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", "preview": "vite preview", "test": "vitest run" }, @@ -13,9 +15,11 @@ "@tanstack/react-query": "^5.75.0", "@triplanetary/shared": "workspace:*", "boardgame.io": "^0.50.2", + "honeycomb-grid": "^4.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^7.5.0" + "react-router-dom": "^7.5.0", + "react-zoom-pan-pinch": "^4.0.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.0", diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 4611880..6d3725b 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,10 +1,12 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { AuthGuard } from './components/AuthGuard'; -import HomePage from './pages/HomePage'; -import LoginPage from './pages/LoginPage'; -import RegisterPage from './pages/RegisterPage'; -import LobbyPage from './pages/lobby/LobbyPage'; -import GameRoom from './pages/lobby/GameRoom'; +import { Routes, Route, Navigate } from "react-router-dom"; +import { AuthGuard } from "./components/AuthGuard"; +import { AdminGuard } from "./components/AdminGuard"; +import HomePage from "./pages/HomePage"; +import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import LobbyPage from "./pages/lobby/LobbyPage"; +import GameRoom from "./pages/lobby/GameRoom"; +import MapEditorPage from "./pages/admin/MapEditorPage"; export default function App() { return ( @@ -28,6 +30,14 @@ export default function App() { } /> + + + + } + /> } />
); diff --git a/packages/client/src/__tests__/AdminGuard.test.tsx b/packages/client/src/__tests__/AdminGuard.test.tsx new file mode 100644 index 0000000..d564bc0 --- /dev/null +++ b/packages/client/src/__tests__/AdminGuard.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { AdminGuard } from '../components/AdminGuard'; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function renderWithAdmin(isAdmin: boolean) { + mswServer.use( + http.get('/api/auth/me', () => + HttpResponse.json({ id: '1', email: 'a@a.com', displayName: 'A', isAdmin }), + ), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + +
Admin Content
} + /> + Home} /> +
+
+
, + ); +} + +describe('AdminGuard', () => { + it('renders children when user is admin', async () => { + renderWithAdmin(true); + await waitFor(() => expect(screen.getByText('Admin Content')).toBeInTheDocument()); + }); + + it('redirects to / when user is not admin', async () => { + renderWithAdmin(false); + await waitFor(() => expect(screen.getByText('Home')).toBeInTheDocument()); + }); + + it('renders nothing while loading auth', () => { + mswServer.use(http.get('/api/auth/me', () => new Promise(() => {}))); // never resolves + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + +
Admin Content
} + /> +
+
+
, + ); + expect(screen.queryByText('Admin Content')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/client/src/__tests__/HexGrid.test.tsx b/packages/client/src/__tests__/HexGrid.test.tsx new file mode 100644 index 0000000..29e691c --- /dev/null +++ b/packages/client/src/__tests__/HexGrid.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HexGrid } from '../components/map/HexGrid'; +import type { HexData } from '@triplanetary/shared'; + +const emptyData: HexData = { + meta: { name: 'Test', version: '1.0', hexSize: 48, orientation: 'pointy' }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +const dataWithPlanet: HexData = { + ...emptyData, + hexes: { '0,0': { type: 'planet', body: 'terra' } }, +}; + +describe('HexGrid', () => { + it('renders an SVG element', () => { + const { container } = render( + {}} />, + ); + expect(container.querySelector('svg')).not.toBeNull(); + }); + + it('renders a polygon for each hex in the range', () => { + // q: -1..1 (3), r: -1..1 (3) → 3*3 = 9 polygons + const { container } = render( + {}} />, + ); + const polygons = container.querySelectorAll('polygon'); + expect(polygons.length).toBe(9); + }); + + it('calls onHexClick with the axial coordinate when a hex is clicked', async () => { + const handler = vi.fn(); + const { container } = render( + , + ); + const polygon = container.querySelector('polygon')!; + await userEvent.click(polygon); + expect(handler).toHaveBeenCalledWith(0, 0); + }); + + it('applies planet data-type to planet hexes', () => { + const { container } = render( + {}} />, + ); + const polygon = container.querySelector('polygon')!; + expect(polygon.getAttribute('data-type')).toBe('planet'); + }); +}); diff --git a/packages/client/src/__tests__/LoginPage.test.tsx b/packages/client/src/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..eaefbeb --- /dev/null +++ b/packages/client/src/__tests__/LoginPage.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import LoginPage from '../pages/LoginPage'; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function renderLogin() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + Lobby} /> + + + , + ); +} + +describe('LoginPage', () => { + it('renders email, password fields and submit button', () => { + renderLogin(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('navigates to /lobby on successful login', async () => { + mswServer.use( + http.post('/api/auth/login', () => HttpResponse.json({ id: '1', email: 'a@a.com', displayName: 'Alice', isAdmin: false })), + http.get('/api/auth/me', () => HttpResponse.json({ id: '1', email: 'a@a.com', displayName: 'Alice', isAdmin: false })), + ); + renderLogin(); + await userEvent.type(screen.getByLabelText(/email/i), 'a@a.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'password'); + await userEvent.click(screen.getByRole('button', { name: /login/i })); + await waitFor(() => expect(screen.getByText('Lobby')).toBeInTheDocument()); + }); + + it('shows error message on failed login', async () => { + mswServer.use( + http.post('/api/auth/login', () => HttpResponse.json({ error: 'Invalid' }, { status: 401 })), + ); + renderLogin(); + await userEvent.type(screen.getByLabelText(/email/i), 'bad@bad.com'); + await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass'); + await userEvent.click(screen.getByRole('button', { name: /login/i })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/invalid email or password/i), + ); + }); + + it('has a link to the register page', () => { + renderLogin(); + expect(screen.getByRole('link', { name: /register/i })).toHaveAttribute('href', '/register'); + }); +}); diff --git a/packages/client/src/__tests__/MapEditorPage.test.tsx b/packages/client/src/__tests__/MapEditorPage.test.tsx new file mode 100644 index 0000000..9e7201a --- /dev/null +++ b/packages/client/src/__tests__/MapEditorPage.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import MapEditorPage from "../pages/admin/MapEditorPage"; +import type { MapDetail } from "../hooks/useMaps"; +import type { HexData } from "@triplanetary/shared"; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +const BLANK: HexData = { + meta: { name: "New Map", version: "1.0", hexSize: 48, orientation: "pointy" }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +const SAVED_MAP: MapDetail = { + id: "map-1", + name: "Saved Map", + version: "1.0", + isCanonical: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + data: { ...BLANK, meta: { ...BLANK.meta, name: "Saved Map" } }, +}; + +function renderEditor(search = "") { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + + + , + ); +} + +describe("MapEditorPage", () => { + it("renders the editor header with default map name", () => { + renderEditor(); + expect(screen.getByText("Map Editor")).toBeInTheDocument(); + expect(screen.getByText("New Map")).toBeInTheDocument(); + }); + + it("Save button is disabled when map is not dirty", () => { + renderEditor(); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + }); + + it("loads an existing map when ?id= is present", async () => { + mswServer.use( + http.get("/api/maps/map-1", () => HttpResponse.json(SAVED_MAP)), + ); + renderEditor("?id=map-1"); + await waitFor(() => + expect(screen.getByText("Saved Map")).toBeInTheDocument(), + ); + }); + + it("calls POST /api/maps on save for new map and updates URL", async () => { + const user = userEvent.setup(); + let posted = false; + mswServer.use( + http.post("/api/maps", async () => { + posted = true; + return HttpResponse.json( + { ...SAVED_MAP, id: "new-map-id" }, + { status: 201 }, + ); + }), + // Handle the GET that fires after setSearchParams updates the URL + http.get("/api/maps/new-map-id", () => + HttpResponse.json({ ...SAVED_MAP, id: "new-map-id" }), + ), + ); + const { container } = renderEditor(); + + // Switch to asteroid mode so hex clicks dirty the map + await user.click(screen.getByRole("radio", { name: /asteroid/i })); + const polygon = container.querySelector("polygon"); + await user.click(polygon!); + + // Save button becomes enabled once the map is dirty + await waitFor(() => + expect(screen.getByRole("button", { name: /save/i })).toBeEnabled(), + ); + await user.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => expect(posted).toBe(true)); + // After setSearchParams({ id: "new-map-id" }), useMap fires GET /api/maps/new-map-id + // and loads the saved map — confirming the URL was updated + await waitFor(() => + expect(screen.getByText("Saved Map")).toBeInTheDocument(), + ); + }); + + it("shows Export button that is always enabled", () => { + renderEditor(); + expect(screen.getByRole("button", { name: /export/i })).toBeEnabled(); + }); + + it("calls PUT /api/maps/:id on save for existing map", async () => { + const user = userEvent.setup(); + let putCalled = false; + mswServer.use( + http.get("/api/maps/map-1", () => HttpResponse.json(SAVED_MAP)), + http.put("/api/maps/map-1", async () => { + putCalled = true; + return HttpResponse.json(SAVED_MAP); + }), + ); + const { container } = renderEditor("?id=map-1"); + await waitFor(() => + expect(screen.getByText("Saved Map")).toBeInTheDocument(), + ); + expect(putCalled).toBe(false); // no spurious PUT on load + + // Switch to asteroid mode and click a hex to dirty the map + await user.click(screen.getByRole("radio", { name: /asteroid/i })); + const polygon = container.querySelector("polygon"); + await user.click(polygon!); + + await waitFor(() => + expect(screen.getByRole("button", { name: /save/i })).toBeEnabled(), + ); + await user.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => expect(putCalled).toBe(true)); + }); +}); diff --git a/packages/client/src/__tests__/RegisterPage.test.tsx b/packages/client/src/__tests__/RegisterPage.test.tsx new file mode 100644 index 0000000..86a9b5b --- /dev/null +++ b/packages/client/src/__tests__/RegisterPage.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import RegisterPage from '../pages/RegisterPage'; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function renderRegister() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + Lobby} /> + + + , + ); +} + +async function fillForm(displayName = 'Alice', email = 'alice@test.com', password = 'password123') { + await userEvent.type(screen.getByLabelText(/display name/i), displayName); + await userEvent.type(screen.getByLabelText(/email/i), email); + await userEvent.type(screen.getByLabelText(/password/i), password); +} + +describe('RegisterPage', () => { + it('renders all form fields and submit button', () => { + renderRegister(); + expect(screen.getByLabelText(/display name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument(); + }); + + it('navigates to /lobby on successful registration', async () => { + mswServer.use( + http.post('/api/auth/register', () => + HttpResponse.json({ id: '1', email: 'alice@test.com', displayName: 'Alice', isAdmin: false }, { status: 201 }), + ), + http.get('/api/auth/me', () => + HttpResponse.json({ id: '1', email: 'alice@test.com', displayName: 'Alice', isAdmin: false }), + ), + ); + renderRegister(); + await fillForm(); + await userEvent.click(screen.getByRole('button', { name: /register/i })); + await waitFor(() => expect(screen.getByText('Lobby')).toBeInTheDocument()); + }); + + it('shows "Email already registered" on 409', async () => { + mswServer.use( + http.post('/api/auth/register', () => + HttpResponse.json({ error: 'Email already registered' }, { status: 409 }), + ), + ); + renderRegister(); + await fillForm('Bob', 'taken@test.com'); + await userEvent.click(screen.getByRole('button', { name: /register/i })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/email already registered/i), + ); + }); + + it('shows generic error on other failures', async () => { + mswServer.use( + http.post('/api/auth/register', () => + HttpResponse.json({ error: 'Server error' }, { status: 500 }), + ), + ); + renderRegister(); + await fillForm('Eve', 'eve@test.com'); + await userEvent.click(screen.getByRole('button', { name: /register/i })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/registration failed/i), + ); + }); + + it('has a link to the login page', () => { + renderRegister(); + expect(screen.getByRole('link', { name: /login/i })).toHaveAttribute('href', '/login'); + }); +}); diff --git a/packages/client/src/__tests__/apiClient.test.ts b/packages/client/src/__tests__/apiClient.test.ts new file mode 100644 index 0000000..14dfdd7 --- /dev/null +++ b/packages/client/src/__tests__/apiClient.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { apiRequest, apiClient } from "../lib/apiClient"; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +describe("apiRequest", () => { + it("returns parsed JSON on success", async () => { + mswServer.use(http.get("/api/test", () => HttpResponse.json({ ok: true }))); + const result = await apiRequest<{ ok: boolean }>("/test"); + expect(result).toEqual({ ok: true }); + }); + + it("throws with status on 401", async () => { + mswServer.use( + http.get("/api/unauth", () => + HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), + ), + ); + await expect(apiRequest("/unauth")).rejects.toMatchObject({ + status: 401, + message: expect.stringContaining("401"), + }); + }); + + it("throws with status on 404", async () => { + mswServer.use( + http.get("/api/notfound", () => + HttpResponse.json({ error: "Not found" }, { status: 404 }), + ), + ); + await expect(apiRequest("/notfound")).rejects.toMatchObject({ + status: 404, + }); + }); + + it("throws with status on 500", async () => { + mswServer.use( + http.get("/api/boom", () => + HttpResponse.json({ error: "Server error" }, { status: 500 }), + ), + ); + await expect(apiRequest("/boom")).rejects.toMatchObject({ status: 500 }); + }); + + it("sends POST body as JSON", async () => { + let captured: unknown; + mswServer.use( + http.post("/api/echo", async ({ request }) => { + captured = await request.json(); + return HttpResponse.json({ received: true }); + }), + ); + await apiRequest("/echo", { method: "POST", body: { name: "test" } }); + expect(captured).toEqual({ name: "test" }); + }); + + it("sends credentials: include on every request", async () => { + let credentialsSeen: string | null = null; + mswServer.use( + http.get("/api/creds", ({ request }) => { + credentialsSeen = request.credentials; + return HttpResponse.json({}); + }), + ); + await apiRequest("/creds"); + expect(credentialsSeen).toBe("include"); + }); +}); + +describe("apiClient helpers", () => { + it("apiClient.get delegates to apiRequest GET", async () => { + mswServer.use(http.get("/api/me", () => HttpResponse.json({ id: "1" }))); + const result = await apiClient.get<{ id: string }>("/me"); + expect(result.id).toBe("1"); + }); + + it("apiClient.post delegates to apiRequest POST", async () => { + mswServer.use( + http.post("/api/create", async () => + HttpResponse.json({ created: true }, { status: 201 }), + ), + ); + const result = await apiClient.post<{ created: boolean }>("/create", { + x: 1, + }); + expect(result.created).toBe(true); + }); +}); diff --git a/packages/client/src/__tests__/useAuth.test.ts b/packages/client/src/__tests__/useAuth.test.ts new file mode 100644 index 0000000..6289875 --- /dev/null +++ b/packages/client/src/__tests__/useAuth.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { createElement } from "react"; +import { useAuth } from "../hooks/useAuth"; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function wrapper(qc: QueryClient) { + function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: qc }, children); + } + return Wrapper; +} + +describe("useAuth", () => { + it("returns user data when authenticated", async () => { + const user = { + id: "1", + email: "a@a.com", + displayName: "Alice", + isAdmin: false, + }; + mswServer.use(http.get("/api/auth/me", () => HttpResponse.json(user))); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useAuth(), { wrapper: wrapper(qc) }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(user); + }); + + it("surfaces error with status when unauthenticated (401)", async () => { + mswServer.use( + http.get("/api/auth/me", () => + HttpResponse.json({ error: "Not authenticated" }, { status: 401 }), + ), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useAuth(), { wrapper: wrapper(qc) }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect((result.current.error as { status?: number })?.status).toBe(401); + }); + + it("does not retry on failure", async () => { + let callCount = 0; + mswServer.use( + http.get("/api/auth/me", () => { + callCount++; + return HttpResponse.json({ error: "fail" }, { status: 401 }); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useAuth(), { wrapper: wrapper(qc) }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(callCount).toBe(1); + }); + + it("caches result within staleTime (single network call for two renders)", async () => { + let callCount = 0; + mswServer.use( + http.get("/api/auth/me", () => { + callCount++; + return HttpResponse.json({ + id: "2", + email: "b@b.com", + displayName: "Bob", + isAdmin: false, + }); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result: r1 } = renderHook(() => useAuth(), { + wrapper: wrapper(qc), + }); + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + + const { result: r2 } = renderHook(() => useAuth(), { + wrapper: wrapper(qc), + }); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + expect(callCount).toBe(1); + }); +}); diff --git a/packages/client/src/__tests__/useMapEditor.test.ts b/packages/client/src/__tests__/useMapEditor.test.ts new file mode 100644 index 0000000..10d456d --- /dev/null +++ b/packages/client/src/__tests__/useMapEditor.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useMapEditor } from "../hooks/useMapEditor"; +import type { HexData } from "@triplanetary/shared"; + +const BLANK: HexData = { + meta: { name: "New Map", version: "1.0", hexSize: 48, orientation: "pointy" }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +describe("useMapEditor", () => { + it("initializes with select mode and no selection", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + expect(result.current.mode).toBe("select"); + expect(result.current.selectedHex).toBeNull(); + expect(result.current.dirty).toBe(false); + }); + + it("setMode changes the current mode", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode("asteroid")); + expect(result.current.mode).toBe("asteroid"); + }); + + it("clicking a hex in select mode sets selectedHex", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.handleHexClick(3, -2)); + expect(result.current.selectedHex).toBe("3,-2"); + }); + + it("clicking a hex in erase mode resets it to space", () => { + const initial: HexData = { + ...BLANK, + hexes: { "0,0": { type: "planet", body: "terra" } }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setMode("erase")); + act(() => result.current.handleHexClick(0, 0)); + expect(result.current.hexData.hexes["0,0"]).toBeUndefined(); + expect(result.current.dirty).toBe(true); + }); + + it("erase mode does not mutate original HexData", () => { + const initial: HexData = { + ...BLANK, + hexes: { "0,0": { type: "planet", body: "terra" } }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setMode("erase")); + act(() => result.current.handleHexClick(0, 0)); + expect(initial.hexes["0,0"]?.type).toBe("planet"); + }); + + it("asteroid mode marks hex as asteroid", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode("asteroid")); + act(() => result.current.handleHexClick(5, -3)); + expect(result.current.hexData.hexes["5,-3"]).toMatchObject({ + type: "asteroid", + }); + expect(result.current.dirty).toBe(true); + }); + + it("placeBody places a planet and auto-computes gravity", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => + result.current.placeBody("terra", "0,4", { + center: "0,4", + radius: 1, + gravityRings: 1, + }), + ); + expect(result.current.hexData.hexes["0,4"]).toMatchObject({ + type: "planet", + body: "terra", + }); + const gravHexes = Object.values(result.current.hexData.hexes).filter( + (h) => h.type === "gravity", + ); + expect(gravHexes.length).toBeGreaterThan(0); + expect(result.current.dirty).toBe(true); + }); + + it("setGravityOverride marks a hex with manual:true", () => { + const initial: HexData = { + ...BLANK, + hexes: { + "0,3": { + type: "gravity", + body: "terra", + offset: [0, 1], + manual: false, + }, + }, + }; + const { result } = renderHook(() => useMapEditor(initial)); + act(() => result.current.setGravityOverride("0,3", [-1, 0])); + expect(result.current.hexData.hexes["0,3"]).toMatchObject({ + offset: [-1, 0], + manual: true, + }); + }); + + it("resetDirty clears the dirty flag", () => { + const { result } = renderHook(() => useMapEditor(BLANK)); + act(() => result.current.setMode("asteroid")); + act(() => result.current.handleHexClick(0, 0)); + expect(result.current.dirty).toBe(true); + act(() => result.current.resetDirty()); + expect(result.current.dirty).toBe(false); + }); +}); diff --git a/packages/client/src/__tests__/useMaps.test.ts b/packages/client/src/__tests__/useMaps.test.ts new file mode 100644 index 0000000..7cff77f --- /dev/null +++ b/packages/client/src/__tests__/useMaps.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { createElement } from "react"; +import { useMaps, useMap, useCreateMap, useUpdateMap } from "../hooks/useMaps"; +import type { MapSummary, MapDetail } from "../hooks/useMaps"; +import type { HexData } from "@triplanetary/shared"; + +const mswServer = setupServer(); +beforeAll(() => mswServer.listen()); +afterAll(() => mswServer.close()); + +function wrapper(qc: QueryClient) { + function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: qc }, children); + } + return Wrapper; +} + +const BLANK: HexData = { + meta: { name: "Test", version: "1.0", hexSize: 48, orientation: "pointy" }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +const MAP_SUMMARY: MapSummary = { + id: "abc", + name: "Test Map", + version: "1.0", + isCanonical: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const MAP_DETAIL: MapDetail = { ...MAP_SUMMARY, data: BLANK }; + +describe("useMaps", () => { + it("fetches map list", async () => { + mswServer.use( + http.get("/api/maps", () => HttpResponse.json([MAP_SUMMARY])), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useMaps(), { wrapper: wrapper(qc) }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data![0]!.id).toBe("abc"); + }); +}); + +describe("useMap", () => { + it("fetches map detail when id is provided", async () => { + mswServer.use( + http.get("/api/maps/abc", () => HttpResponse.json(MAP_DETAIL)), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useMap("abc"), { + wrapper: wrapper(qc), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.id).toBe("abc"); + }); + + it("does not fetch when id is null", async () => { + let called = false; + mswServer.use( + http.get("/api/maps/:id", () => { + called = true; + return HttpResponse.json({}); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + renderHook(() => useMap(null), { wrapper: wrapper(qc) }); + await new Promise((r) => setTimeout(r, 50)); + expect(called).toBe(false); + }); + + it("does not fetch when id is empty string", async () => { + let called = false; + mswServer.use( + http.get("/api/maps/", () => { + called = true; + return HttpResponse.json({}); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + renderHook(() => useMap(""), { wrapper: wrapper(qc) }); + await new Promise((r) => setTimeout(r, 50)); + expect(called).toBe(false); + }); +}); + +describe("useCreateMap", () => { + it("posts to /api/maps and invalidates maps query", async () => { + let listCallCount = 0; + mswServer.use( + http.post("/api/maps", async () => + HttpResponse.json(MAP_DETAIL, { status: 201 }), + ), + http.get("/api/maps", () => { + listCallCount++; + return HttpResponse.json([MAP_SUMMARY]); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + // Prime the cache + const { result: listResult } = renderHook(() => useMaps(), { + wrapper: wrapper(qc), + }); + await waitFor(() => expect(listResult.current.isSuccess).toBe(true)); + const countBefore = listCallCount; + + const { result } = renderHook(() => useCreateMap(), { + wrapper: wrapper(qc), + }); + await act(async () => { + await result.current.mutateAsync({ + name: "New", + version: "1.0", + data: BLANK, + }); + }); + + // Cache should have been invalidated, triggering a refetch + await waitFor(() => expect(listCallCount).toBeGreaterThan(countBefore)); + }); +}); + +describe("useUpdateMap", () => { + it("puts to /api/maps/:id and invalidates that map query", async () => { + let detailCallCount = 0; + mswServer.use( + http.put("/api/maps/abc", async () => HttpResponse.json(MAP_DETAIL)), + http.get("/api/maps/abc", () => { + detailCallCount++; + return HttpResponse.json(MAP_DETAIL); + }), + ); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + // Prime the cache + const { result: mapResult } = renderHook(() => useMap("abc"), { + wrapper: wrapper(qc), + }); + await waitFor(() => expect(mapResult.current.isSuccess).toBe(true)); + const countBefore = detailCallCount; + + const { result } = renderHook(() => useUpdateMap("abc"), { + wrapper: wrapper(qc), + }); + await act(async () => { + await result.current.mutateAsync({ name: "Updated" }); + }); + + await waitFor(() => expect(detailCallCount).toBeGreaterThan(countBefore)); + }); +}); diff --git a/packages/client/src/components/AdminGuard.tsx b/packages/client/src/components/AdminGuard.tsx new file mode 100644 index 0000000..e7b6148 --- /dev/null +++ b/packages/client/src/components/AdminGuard.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface Props { + children: ReactNode; +} + +export function AdminGuard({ children }: Props) { + const { data, isPending } = useAuth(); + if (isPending) return null; + if (!data?.isAdmin) return ; + return <>{children}; +} diff --git a/packages/client/src/components/map/HexGrid.tsx b/packages/client/src/components/map/HexGrid.tsx new file mode 100644 index 0000000..9bef42c --- /dev/null +++ b/packages/client/src/components/map/HexGrid.tsx @@ -0,0 +1,151 @@ +import { 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"; + +// Hex type → fill color +const HEX_COLORS: Record = { + space: "#0a0a1a", + gravity: "#0d1a3a", + planet: "#4a7c59", + asteroid: "#5a4a2a", + clandestine: "#3a0a3a", +}; + +interface Props { + data: HexData; + /** Inclusive q range [min, max] to render */ + qRange: [number, number]; + /** Inclusive r range [min, max] to render */ + rRange: [number, number]; + onHexClick: (q: number, r: number) => void; + selectedHex?: string | null; +} + +export function HexGrid({ + data, + qRange, + rRange, + onHexClick, + selectedHex, +}: Props) { + const [qMin, qMax] = qRange; + const [rMin, rMax] = rRange; + const hexSize = data.meta.hexSize; + const orientation = data.meta.orientation; + + const grid = useMemo(() => { + const ProtoHex = defineHex({ + dimensions: hexSize, + orientation: + orientation === "pointy" ? Orientation.POINTY : Orientation.FLAT, + origin: "topLeft", + }); + return new Grid( + ProtoHex, + rectangle({ + width: qMax - qMin + 1, + height: rMax - rMin + 1, + start: { q: qMin, r: rMin }, + }), + ); + }, [hexSize, orientation, qMin, qMax, rMin, rMax]); + + const hexes = useMemo(() => [...grid], [grid]); + + const svgWidth = (qMax - qMin + 2) * hexSize * Math.sqrt(3); + const svgHeight = (rMax - rMin + 2) * hexSize * 1.5; + + const handleClick = useCallback( + (q: number, r: number) => () => onHexClick(q, r), + [onHexClick], + ); + + return ( + + + + + + + + + + {hexes.map((hex) => { + const { q, r } = hex; + const key = `${q},${r}`; + const entry: HexEntry | undefined = data.hexes[key]; + const type = entry?.type ?? "space"; + const points = hex.corners.map((c) => `${c.x},${c.y}`).join(" "); + // hex.x and hex.y are the center coordinates in honeycomb-grid v4 + const cx = hex.x; + const cy = hex.y; + const isSelected = selectedHex === key; + + return ( + + + {/* Gravity arrow */} + {entry?.type === "gravity" && + entry.offset && + (() => { + const [dq, dr] = entry.offset; + const dx = + hexSize * (Math.sqrt(3) * dq + (Math.sqrt(3) / 2) * dr); + const dy = hexSize * (1.5 * dr); + const len = Math.sqrt(dx * dx + dy * dy); + const scale = (hexSize * 0.5) / len; + return ( + + ); + })()} + {/* Body label */} + {entry?.type === "planet" && ( + + {entry.body ?? ""} + + )} + + ); + })} + + + + + ); +} diff --git a/packages/client/src/hooks/useMapEditor.ts b/packages/client/src/hooks/useMapEditor.ts new file mode 100644 index 0000000..3732521 --- /dev/null +++ b/packages/client/src/hooks/useMapEditor.ts @@ -0,0 +1,191 @@ +import { useState, useCallback } from "react"; +import { computeGravity } from "@triplanetary/shared"; +import type { HexData, HexEntry, BodyEntry } from "@triplanetary/shared"; + +export type EditorMode = + | "select" + | "space" + | "asteroid" + | "planet" + | "base" + | "gravity" + | "weakGravity" + | "clandestine" + | "erase"; + +export interface UseMapEditorResult { + mode: EditorMode; + setMode: (mode: EditorMode) => void; + hexData: HexData; + selectedHex: string | null; + dirty: boolean; + handleHexClick: (q: number, r: number) => void; + eraseHex: (hexKey: string) => void; + placeBody: (bodyName: string, centerKey: string, body: BodyEntry) => void; + setGravityOverride: (hexKey: string, offset: [number, number]) => void; + assignBase: (bodyName: string, side: number) => void; + resetDirty: () => void; + loadHexData: (data: HexData) => void; +} + +export function useMapEditor(initial: HexData): UseMapEditorResult { + const [mode, setMode] = useState("select"); + const [hexData, setHexData] = useState(initial); + const [selectedHex, setSelectedHex] = useState(null); + const [dirty, setDirty] = useState(false); + + const applyHexChange = useCallback((key: string, entry: HexEntry) => { + setHexData((prev) => ({ + ...prev, + hexes: { ...prev.hexes, [key]: entry }, + })); + setDirty(true); + }, []); + + const deleteHex = useCallback((key: string) => { + setHexData((prev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _omit, ...rest } = prev.hexes; + return { ...prev, hexes: rest }; + }); + setDirty(true); + }, []); + + const handleHexClick = useCallback( + (q: number, r: number) => { + const key = `${q},${r}`; + switch (mode) { + case "select": + setSelectedHex(key); + break; + case "space": + case "erase": + deleteHex(key); + break; + case "asteroid": + applyHexChange(key, { type: "asteroid" }); + break; + case "gravity": + applyHexChange(key, { + type: "gravity", + offset: [0, 1], + manual: false, + }); + break; + case "weakGravity": + setHexData((prev) => { + const existingEntry = prev.hexes[key]; + const nextEntry = { + ...existingEntry, + type: "gravity", + weak: true, + offset: + existingEntry?.type === "gravity" && existingEntry.offset + ? existingEntry.offset + : [0, 1], + manual: + existingEntry?.type === "gravity" && + existingEntry.manual !== undefined + ? existingEntry.manual + : false, + } as HexEntry; + + return { + ...prev, + hexes: { + ...prev.hexes, + [key]: nextEntry, + }, + }; + }); + setDirty(true); + break; + case "clandestine": + applyHexChange(key, { type: "clandestine" }); + break; + // planet and base modes require additional UI interaction (body/base picker) + case "planet": + case "base": + setSelectedHex(key); + break; + } + }, + [mode, applyHexChange, deleteHex], + ); + + const placeBody = useCallback( + (bodyName: string, centerKey: string, body: BodyEntry) => { + setHexData((prev) => { + const newBodies = { ...prev.bodies, [bodyName]: body }; + const hexesWithPlanet = { + ...prev.hexes, + [centerKey]: { type: "planet" as const, body: bodyName }, + }; + const gravityHexes = computeGravity(newBodies, hexesWithPlanet); + const nonGravity = Object.fromEntries( + Object.entries(hexesWithPlanet).filter( + ([, h]) => h.type !== "gravity", + ), + ); + return { + ...prev, + bodies: newBodies, + hexes: { ...nonGravity, ...gravityHexes }, + }; + }); + setDirty(true); + }, + [], + ); + + const setGravityOverride = useCallback( + (hexKey: string, offset: [number, number]) => { + setHexData((prev) => ({ + ...prev, + hexes: { + ...prev.hexes, + [hexKey]: { ...prev.hexes[hexKey], offset, manual: true } as HexEntry, + }, + })); + setDirty(true); + }, + [], + ); + + const assignBase = useCallback((bodyName: string, side: number) => { + setHexData((prev) => { + const existing = prev.bases[bodyName] ?? { hexSides: [] }; + const sides = existing.hexSides.includes(side) + ? existing.hexSides.filter((s) => s !== side) // toggle off + : [...existing.hexSides, side]; // toggle on + return { + ...prev, + bases: { ...prev.bases, [bodyName]: { ...existing, hexSides: sides } }, + }; + }); + setDirty(true); + }, []); + + const resetDirty = useCallback(() => setDirty(false), []); + + const loadHexData = useCallback((data: HexData) => { + setHexData(data); + setDirty(false); + setSelectedHex(null); + }, []); + + return { + mode, + setMode, + hexData, + selectedHex, + dirty, + handleHexClick, + eraseHex: deleteHex, + placeBody, + setGravityOverride, + assignBase, + resetDirty, + loadHexData, + }; +} diff --git a/packages/client/src/hooks/useMaps.ts b/packages/client/src/hooks/useMaps.ts new file mode 100644 index 0000000..44c61c6 --- /dev/null +++ b/packages/client/src/hooks/useMaps.ts @@ -0,0 +1,53 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "../lib/apiClient"; +import type { HexData } from "@triplanetary/shared"; + +export interface MapSummary { + id: string; + name: string; + version: string; + isCanonical: boolean; + createdAt: string; + updatedAt: string; +} + +export interface MapDetail extends MapSummary { + data: HexData; +} + +export function useMaps() { + return useQuery({ + queryKey: ["maps"], + queryFn: () => apiRequest("/maps"), + }); +} + +export function useMap(id: string | null) { + return useQuery({ + queryKey: ["maps", id], + queryFn: () => apiRequest(`/maps/${id}`), + enabled: !!id, + }); +} + +export function useCreateMap() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: { name: string; version: string; data: HexData }) => + apiRequest("/maps", { method: "POST", body: payload }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["maps"] }), + }); +} + +export function useUpdateMap(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + name?: string; + version?: string; + data?: HexData; + }) => + apiRequest(`/maps/${id}`, { method: "PUT", body: payload }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["maps", id] }), + }); +} diff --git a/packages/client/src/lib/apiClient.ts b/packages/client/src/lib/apiClient.ts index bdef57b..e261692 100644 --- a/packages/client/src/lib/apiClient.ts +++ b/packages/client/src/lib/apiClient.ts @@ -1,21 +1,27 @@ -const BASE = '/api'; +const BASE = "/api"; -async function request(path: string, init?: RequestInit): Promise { +export async function apiRequest( + path: string, + options?: { method?: string; body?: unknown }, +): Promise { const res = await fetch(`${BASE}${path}`, { - ...init, - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...init?.headers }, + method: options?.method ?? "GET", + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...(options?.body !== undefined + ? { body: JSON.stringify(options.body) } + : {}), }); - if (!res.ok) { - throw Object.assign(new Error(`${res.status} ${res.statusText}`), { status: res.status }); + throw Object.assign(new Error(`${res.status} ${res.statusText}`), { + status: res.status, + }); } - return res.json() as Promise; } export const apiClient = { - get: (path: string) => request(path), + get: (path: string) => apiRequest(path), post: (path: string, body: unknown) => - request(path, { method: 'POST', body: JSON.stringify(body) }), + apiRequest(path, { method: "POST", body }), }; diff --git a/packages/client/src/pages/admin/BasePicker.tsx b/packages/client/src/pages/admin/BasePicker.tsx new file mode 100644 index 0000000..97dc315 --- /dev/null +++ b/packages/client/src/pages/admin/BasePicker.tsx @@ -0,0 +1,55 @@ +import type { HexData } from '@triplanetary/shared'; + +const SIDE_LABELS = ['Side 0 (E)', 'Side 1 (SE)', 'Side 2 (SW)', 'Side 3 (W)', 'Side 4 (NW)', 'Side 5 (NE)']; + +interface Props { + selectedHex: string; + hexData: HexData; + onAssign: (bodyName: string, side: number) => void; + onCancel: () => void; +} + +export function BasePicker({ selectedHex, hexData, onAssign, onCancel }: Props) { + const entry = hexData.hexes[selectedHex]; + const bodyName = entry?.body; + + if (!bodyName) { + return ( +
+
+

Select a planet hex first, then switch to Base mode.

+ +
+
+ ); + } + + const existing = hexData.bases[bodyName]?.hexSides ?? []; + + return ( +
+
+

Assign Base to {bodyName}

+

Currently assigned sides: {existing.length > 0 ? existing.join(', ') : 'none'}

+
+ {SIDE_LABELS.map((label, side) => ( + + ))} +
+ +
+
+ ); +} diff --git a/packages/client/src/pages/admin/BodyPicker.tsx b/packages/client/src/pages/admin/BodyPicker.tsx new file mode 100644 index 0000000..6ddabad --- /dev/null +++ b/packages/client/src/pages/admin/BodyPicker.tsx @@ -0,0 +1,42 @@ +import type { BodyEntry } from '@triplanetary/shared'; + +const BODIES: Array<{ name: string; entry: BodyEntry }> = [ + { name: 'sol', entry: { center: '', radius: 3, gravityRings: 2 } }, + { name: 'terra', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'luna', entry: { center: '', radius: 0, gravityRings: 1, weakGravity: true } }, + { name: 'venus', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'mars', entry: { center: '', radius: 1, gravityRings: 1 } }, + { name: 'ceres', entry: { center: '', gravityRings: 0, asteroidBase: true } }, + { name: 'jupiter', entry: { center: '', radius: 2, gravityRings: 2 } }, +]; + +interface Props { + onPick: (bodyName: string, bodyDef: BodyEntry) => void; + onCancel: () => void; +} + +export function BodyPicker({ onPick, onCancel }: Props) { + return ( +
+
+

Choose Body

+
    + {BODIES.map(({ name, entry }) => ( +
  • + +
  • + ))} +
+ +
+
+ ); +} diff --git a/packages/client/src/pages/admin/EditorToolbar.tsx b/packages/client/src/pages/admin/EditorToolbar.tsx new file mode 100644 index 0000000..22be517 --- /dev/null +++ b/packages/client/src/pages/admin/EditorToolbar.tsx @@ -0,0 +1,42 @@ +import type { EditorMode } from '../../hooks/useMapEditor'; + +const MODES: { value: EditorMode; label: string }[] = [ + { value: 'select', label: 'Select' }, + { value: 'space', label: 'Space' }, + { value: 'asteroid', label: 'Asteroid' }, + { value: 'planet', label: 'Planet ▾' }, + { value: 'base', label: 'Base' }, + { value: 'gravity', label: 'Gravity' }, + { value: 'weakGravity', label: 'Weak Gravity' }, + { value: 'clandestine', label: 'Clandestine' }, + { value: 'erase', label: 'Erase' }, +]; + +interface Props { + mode: EditorMode; + onModeChange: (mode: EditorMode) => void; + onRecomputeGravity: () => void; +} + +export function EditorToolbar({ mode, onModeChange, onRecomputeGravity }: Props) { + return ( +
+ {MODES.map(({ value, label }) => ( + + ))} +
+ +
+ ); +} diff --git a/packages/client/src/pages/admin/HexInspector.tsx b/packages/client/src/pages/admin/HexInspector.tsx new file mode 100644 index 0000000..b254332 --- /dev/null +++ b/packages/client/src/pages/admin/HexInspector.tsx @@ -0,0 +1,109 @@ +import type { HexData } from "@triplanetary/shared"; +import type { UseMapEditorResult } from "../../hooks/useMapEditor"; + +const GRAVITY_DIRS: Array<{ label: string; offset: [number, number] }> = [ + { label: "→", offset: [1, 0] }, + { label: "↘", offset: [0, 1] }, + { label: "↙", offset: [-1, 1] }, + { label: "←", offset: [-1, 0] }, + { label: "↖", offset: [0, -1] }, + { label: "↗", offset: [1, -1] }, +]; + +interface Props { + selectedHex: string | null; + hexData: HexData; + setGravityOverride: UseMapEditorResult["setGravityOverride"]; + eraseHex: UseMapEditorResult["eraseHex"]; +} + +export function HexInspector({ + selectedHex, + hexData, + setGravityOverride, + eraseHex, +}: Props) { + if (!selectedHex) { + return ( +
Click a hex to inspect
+ ); + } + const [qStr, rStr] = selectedHex.split(","); + const q = Number(qStr); + const r = Number(rStr); + const entry = hexData.hexes[selectedHex]; + + return ( +
+
+ q: {q} r: {r} +
+ {entry ? ( + <> +
+ type: {entry.type} +
+ {entry.body && ( +
+ body: {entry.body} +
+ )} + {entry.type === "gravity" && entry.offset && ( +
+
+ offset: [{entry.offset[0]}, {entry.offset[1]}] +
+
+ manual: {entry.manual ? "yes" : "no"} +
+
+ Override: +
+ {GRAVITY_DIRS.map(({ label, offset }) => ( + + ))} +
+
+ {entry.weak && ( +
+ ⚠ Weak gravity +
+ )} +
+ )} + + + ) : ( +
Empty space
+ )} +
+ ); +} diff --git a/packages/client/src/pages/admin/MapEditorPage.tsx b/packages/client/src/pages/admin/MapEditorPage.tsx new file mode 100644 index 0000000..bf52fa0 --- /dev/null +++ b/packages/client/src/pages/admin/MapEditorPage.tsx @@ -0,0 +1,200 @@ +import { useState, useCallback, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { HexGrid } from "../../components/map/HexGrid"; +import { EditorToolbar } from "./EditorToolbar"; +import { HexInspector } from "./HexInspector"; +import { BodyPicker } from "./BodyPicker"; +import { BasePicker } from "./BasePicker"; +import { useMapEditor } from "../../hooks/useMapEditor"; +import { useMap, useCreateMap, useUpdateMap } from "../../hooks/useMaps"; +import { computeGravity } from "@triplanetary/shared"; +import type { HexData, BodyEntry } from "@triplanetary/shared"; + +const BLANK_MAP: HexData = { + meta: { name: "New Map", version: "1.0", hexSize: 48, orientation: "pointy" }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +const Q_RANGE: [number, number] = [-12, 14]; +const R_RANGE: [number, number] = [-10, 12]; + +export default function MapEditorPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const mapId = searchParams.get("id"); + + const editor = useMapEditor(BLANK_MAP); + const [showBodyPicker, setShowBodyPicker] = useState(false); + const [showBasePicker, setShowBasePicker] = useState(false); + + const { data: existingMap } = useMap(mapId); + const createMap = useCreateMap(); + const updateMap = useUpdateMap(mapId ?? ""); + + // Load existing map when data arrives + useEffect(() => { + if (existingMap) editor.loadHexData(existingMap.data); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingMap?.id]); + + const handleHexClickWithPickers = useCallback( + (q: number, r: number) => { + editor.handleHexClick(q, r); + if (editor.mode === "planet") { + setShowBodyPicker(true); + } + if (editor.mode === "base") { + setShowBasePicker(true); + } + }, + [editor], + ); + + const handleBodyPick = useCallback( + (bodyName: string, bodyDef: BodyEntry) => { + if (!editor.selectedHex) return; + editor.placeBody(bodyName, editor.selectedHex, { + ...bodyDef, + center: editor.selectedHex, + }); + setShowBodyPicker(false); + }, + [editor], + ); + + const handleBaseAssign = useCallback( + (bodyName: string, side: number) => { + editor.assignBase(bodyName, side); + }, + [editor], + ); + + const handleRecomputeGravity = useCallback(() => { + const gravityHexes = computeGravity( + editor.hexData.bodies, + editor.hexData.hexes, + ); + const nonGravity = Object.fromEntries( + Object.entries(editor.hexData.hexes).filter( + ([, h]) => h.type !== "gravity", + ), + ); + editor.loadHexData({ + ...editor.hexData, + hexes: { ...nonGravity, ...gravityHexes }, + }); + }, [editor]); + + const handleSave = useCallback(async () => { + if (mapId) { + await updateMap.mutateAsync({ + data: editor.hexData, + name: editor.hexData.meta.name, + }); + } else { + const created = await createMap.mutateAsync({ + name: editor.hexData.meta.name, + version: editor.hexData.meta.version, + data: editor.hexData, + }); + setSearchParams({ id: created.id }, { replace: true }); + } + editor.resetDirty(); + }, [mapId, editor, createMap, updateMap, setSearchParams]); + + const handleExport = useCallback(() => { + const json = JSON.stringify(editor.hexData, null, 2); + const a = document.createElement("a"); + a.href = `data:application/json;charset=utf-8,${encodeURIComponent(json)}`; + a.download = `${editor.hexData.meta.name.replace(/\s+/g, "-").toLowerCase()}.json`; + a.click(); + }, [editor]); + + return ( +
+ {/* Header */} +
+

Map Editor

+ + {editor.hexData.meta.name} + + {editor.dirty && ● unsaved} +
+ + +
+
+ + {/* Body */} +
+ {/* Left: toolbar */} +
+ +
+ + {/* Center: hex grid */} +
+ +
+ + {/* Right: inspector */} +
+ +
+
+ + {/* Body picker modal */} + {showBodyPicker && ( + setShowBodyPicker(false)} + /> + )} + + {/* Base picker modal */} + {showBasePicker && editor.selectedHex && ( + setShowBasePicker(false)} + /> + )} +
+ ); +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index caca426..ff3f841 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,21 +1,27 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@triplanetary/shared": resolve("../shared/src/index.ts"), + }, + }, server: { port: 3000, proxy: { - '/api': { - target: 'http://localhost:8000', + "/api": { + target: "http://localhost:8000", changeOrigin: true, }, - '/games': { - target: 'http://localhost:8000', + "/games": { + target: "http://localhost:8000", changeOrigin: true, }, - '/socket.io': { - target: 'http://localhost:8000', + "/socket.io": { + target: "http://localhost:8000", ws: true, changeOrigin: true, }, diff --git a/packages/server/package.json b/packages/server/package.json index 2dcc353..25383bd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,13 +3,16 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "ts-node --transpile-only --project tsconfig.json src/index.ts", + "dev": "ts-node --transpile-only --project tsconfig.json src/bootstrap.ts", "build": "tsc", - "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "start": "node dist/bootstrap.js", "test": "vitest run", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed/canonicalMap.ts" }, "dependencies": { "@koa/router": "^13.1.0", diff --git a/packages/server/src/__tests__/auth-validation.test.ts b/packages/server/src/__tests__/auth-validation.test.ts new file mode 100644 index 0000000..64a1846 --- /dev/null +++ b/packages/server/src/__tests__/auth-validation.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import supertest from 'supertest'; +import type { Server } from 'node:http'; +import Koa from 'koa'; +import Router from '@koa/router'; +import bodyParser from 'koa-bodyparser'; +import { createSessionMiddleware } from '../api/middleware/session.js'; +import { passportInit, passportSession } from '../api/middleware/passport.js'; +import authRouter from '../api/routes/auth.js'; +import { pool } from '../db/client.js'; + +function buildTestApp(): Koa { + const app = new Koa(); + app.keys = ['test-secret']; + const router = new Router(); + app.use(bodyParser()); + app.use(createSessionMiddleware(app)); + app.use(passportInit); + app.use(passportSession); + router.use('/api', authRouter.routes()); + app.use(router.routes()); + return app; +} + +let server: Server; +let request: ReturnType; + +beforeAll(async () => { + await pool.query("DELETE FROM users WHERE email LIKE 'auth-val-%'"); + const app = buildTestApp(); + server = app.listen(0); + request = supertest(server); +}); + +afterAll(() => { + server.close(); +}); + +describe('POST /api/auth/register — input validation', () => { + it('returns 400 when email is missing', async () => { + const res = await request + .post('/api/auth/register') + .send({ password: 'pass123', displayName: 'Test' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/required/i); + }); + + it('returns 400 when password is missing', async () => { + const res = await request + .post('/api/auth/register') + .send({ email: 'auth-val-nopw@test.com', displayName: 'Test' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when displayName is missing', async () => { + const res = await request + .post('/api/auth/register') + .send({ email: 'auth-val-noname@test.com', password: 'pass123' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when body is empty', async () => { + const res = await request.post('/api/auth/register').send({}); + expect(res.status).toBe(400); + }); + + it('does not expose passwordHash in the 201 response', async () => { + const res = await request.post('/api/auth/register').send({ + email: 'auth-val-ok@test.com', + password: 'securepassword', + displayName: 'Val User', + }); + expect(res.status).toBe(201); + expect(res.body).not.toHaveProperty('passwordHash'); + expect(res.body).not.toHaveProperty('password_hash'); + }); +}); + +describe('POST /api/auth/login — input validation', () => { + it('returns 401 for a nonexistent email', async () => { + const res = await request + .post('/api/auth/login') + .send({ email: 'nobody@nowhere.com', password: 'anything' }); + expect(res.status).toBe(401); + }); + + it('returns 401 for empty credentials', async () => { + const res = await request.post('/api/auth/login').send({}); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/server/src/__tests__/auth.test.ts b/packages/server/src/__tests__/auth.test.ts index 0a17a03..b058957 100644 --- a/packages/server/src/__tests__/auth.test.ts +++ b/packages/server/src/__tests__/auth.test.ts @@ -13,14 +13,14 @@ import { pool } from "../db/client.js"; function buildTestApp(): Koa { const app = new Koa(); app.keys = ["test-secret"]; - const router = new Router(); - app.use(bodyParser()); app.use(createSessionMiddleware(app)); app.use(passportInit); app.use(passportSession); - router.use("/api", authRouter.routes()); - router.use("/api", healthRouter.routes()); - app.use(router.routes()); + const api = new Router({ prefix: "/api" }); + api.use(bodyParser()); + api.use(authRouter.routes()); + api.use(healthRouter.routes()); + app.use(api.routes()); return app; } @@ -28,16 +28,14 @@ let server: Server; let request: ReturnType; beforeAll(async () => { - // Clean users table before tests - await pool.query("DELETE FROM users"); + await pool.query("DELETE FROM users WHERE email = 'test@example.com'"); const app = buildTestApp(); server = app.listen(0); request = supertest(server); }); -afterAll(async () => { +afterAll(() => { server.close(); - await pool.end(); }); describe("POST /api/auth/register", () => { diff --git a/packages/server/src/__tests__/maps-export.test.ts b/packages/server/src/__tests__/maps-export.test.ts new file mode 100644 index 0000000..3bd309c --- /dev/null +++ b/packages/server/src/__tests__/maps-export.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import supertest from "supertest"; +import type { Server } from "node:http"; +import Koa from "koa"; +import Router from "@koa/router"; +import bodyParser from "koa-bodyparser"; +import { createSessionMiddleware } from "../api/middleware/session.js"; +import { passportInit, passportSession } from "../api/middleware/passport.js"; +import authRouter from "../api/routes/auth.js"; +import mapsRouter from "../api/routes/maps.js"; +import { pool, db } from "../db/client.js"; +import { maps } from "../db/schema.js"; +import type { HexData } from "@triplanetary/shared"; + +function buildTestApp(): Koa { + const app = new Koa(); + app.keys = ["test-secret"]; + app.use(createSessionMiddleware(app)); + app.use(passportInit); + app.use(passportSession); + const api = new Router({ prefix: "/api" }); + api.use(bodyParser()); + api.use(authRouter.routes()); + api.use(mapsRouter.routes()); + app.use(api.routes()); + return app; +} + +const BLANK_DATA: HexData = { + meta: { + name: "Export Test", + version: "1.0", + hexSize: 48, + orientation: "pointy", + }, + bodies: {}, + hexes: {}, + bases: {}, +}; + +let server: Server; +let agent: ReturnType; +let mapId: string; + +beforeAll(async () => { + await pool.query("DELETE FROM users WHERE email = 'export-test@test.com'"); + await pool.query("DELETE FROM maps WHERE name LIKE 'Export%'"); + + const app = buildTestApp(); + server = app.listen(0); + + await supertest(server).post("/api/auth/register").send({ + email: "export-test@test.com", + password: "pass", + displayName: "Exporter", + }); + await pool.query("UPDATE users SET is_admin = TRUE WHERE email = $1", [ + "export-test@test.com", + ]); + + agent = supertest.agent(server); + await agent + .post("/api/auth/login") + .send({ email: "export-test@test.com", password: "pass" }); + + const [row] = await db + .insert(maps) + .values({ + name: "Export Test Map", + version: "1.0", + data: BLANK_DATA, + }) + .returning({ id: maps.id }); + mapId = row!.id; +}); + +afterAll(async () => { + await pool.query("DELETE FROM maps WHERE name LIKE 'Export%'"); + await pool.query("DELETE FROM users WHERE email = 'export-test@test.com'"); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +describe("GET /api/maps/:id/export — filename sanitization", () => { + it("returns a JSON file attachment with a clean filename", async () => { + const res = await agent.get(`/api/maps/${mapId}/export`); + expect(res.status).toBe(200); + expect(res.headers["content-disposition"]).toMatch( + /^attachment; filename="/, + ); + expect(res.headers["content-type"]).toMatch(/application\/json/); + }); + + it("strips quotes from map name in Content-Disposition", async () => { + const [row] = await db + .insert(maps) + .values({ + name: 'Export "Quoted" Map', + version: "1.0", + data: BLANK_DATA, + }) + .returning({ id: maps.id }); + + const res = await agent.get(`/api/maps/${row!.id}/export`); + const disposition = res.headers["content-disposition"] as string; + // Quotes inside the filename value must be stripped + const filenameMatch = disposition.match(/filename="([^"]+)"/); + expect(filenameMatch).not.toBeNull(); + const filename = filenameMatch![1]!; + expect(filename).not.toContain('"'); + expect(filename).toMatch(/^[a-z0-9\-_.]+$/); + }); + + it("strips control characters from map name", async () => { + const [row] = await db + .insert(maps) + .values({ + name: "Export\x0aInjected\x0dMap", + version: "1.0", + data: BLANK_DATA, + }) + .returning({ id: maps.id }); + + const res = await agent.get(`/api/maps/${row!.id}/export`); + const disposition = res.headers["content-disposition"] as string; + expect(disposition).not.toMatch(/[\x00-\x1f]/); + }); + + it("returns 404 for a nonexistent map id", async () => { + const res = await agent.get( + "/api/maps/00000000-0000-0000-0000-000000000000/export", + ); + expect(res.status).toBe(404); + }); + + it("returns 401 for unauthenticated requests", async () => { + const res = await supertest(server).get(`/api/maps/${mapId}/export`); + expect(res.status).toBe(401); + }); + + it("response body is valid JSON matching the stored data", async () => { + const res = await agent.get(`/api/maps/${mapId}/export`); + expect(() => JSON.parse(res.text)).not.toThrow(); + const parsed = JSON.parse(res.text) as HexData; + expect(parsed.meta.name).toBe("Export Test"); + }); +}); diff --git a/packages/server/src/__tests__/maps.test.ts b/packages/server/src/__tests__/maps.test.ts new file mode 100644 index 0000000..fec0032 --- /dev/null +++ b/packages/server/src/__tests__/maps.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import supertest from "supertest"; +import type { Server } from "node:http"; +import Koa from "koa"; +import Router from "@koa/router"; +import bodyParser from "koa-bodyparser"; +import { createSessionMiddleware } from "../api/middleware/session"; +import { passportInit, passportSession } from "../api/middleware/passport"; +import authRouter from "../api/routes/auth"; +import healthRouter from "../api/routes/health"; +import mapsRouter from "../api/routes/maps"; +import { pool } from "../db/client"; + +function buildTestApp(): Koa { + const app = new Koa(); + app.keys = ["test-secret"]; + app.use(createSessionMiddleware(app)); + app.use(passportInit); + app.use(passportSession); + const api = new Router({ prefix: "/api" }); + api.use(bodyParser()); + api.use(authRouter.routes()); + api.use(healthRouter.routes()); + api.use(mapsRouter.routes()); + app.use(api.routes()); + return app; +} + +let server: Server; +let adminAgent: ReturnType; +let userAgent: ReturnType; +let anonRequest: ReturnType; + +beforeAll(async () => { + await pool.query( + "DELETE FROM users WHERE email IN ('mapeditor-admin@test.com','mapeditor-user@test.com')", + ); + await pool.query("DELETE FROM maps WHERE name LIKE 'Test Map%'"); + + const app = buildTestApp(); + server = app.listen(0); + anonRequest = supertest(server); + + // Register + promote admin + await supertest(server).post("/api/auth/register").send({ + email: "mapeditor-admin@test.com", + password: "pass", + displayName: "Admin", + }); + await pool.query("UPDATE users SET is_admin = TRUE WHERE email = $1", [ + "mapeditor-admin@test.com", + ]); + + // Register user + await supertest(server).post("/api/auth/register").send({ + email: "mapeditor-user@test.com", + password: "pass", + displayName: "User", + }); + + // Login via agents (agents persist cookies) + adminAgent = supertest.agent(server); + await adminAgent + .post("/api/auth/login") + .send({ email: "mapeditor-admin@test.com", password: "pass" }); + + userAgent = supertest.agent(server); + await userAgent + .post("/api/auth/login") + .send({ email: "mapeditor-user@test.com", password: "pass" }); +}); + +afterAll(async () => { + server.close(); + await pool.query( + "DELETE FROM users WHERE email IN ('mapeditor-admin@test.com','mapeditor-user@test.com')", + ); + await pool.query("DELETE FROM maps WHERE name LIKE 'Test Map%'"); +}); + +describe("GET /api/maps", () => { + it("returns 200 with array for any authenticated user", async () => { + const res = await userAgent.get("/api/maps"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("returns 401 for unauthenticated requests", async () => { + const res = await anonRequest.get("/api/maps"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/maps", () => { + const payload = { + name: "Test Map 1", + version: "1.0", + data: { + meta: { + name: "Test Map 1", + version: "1.0", + hexSize: 48, + orientation: "pointy", + }, + bodies: {}, + hexes: { "0,0": { type: "space" } }, + bases: {}, + }, + }; + + it("creates a map as admin — 201 with id", async () => { + const res = await adminAgent.post("/api/maps").send(payload); + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.name).toBe("Test Map 1"); + }); + + it("returns 403 for non-admin user", async () => { + const res = await userAgent.post("/api/maps").send(payload); + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/maps/:id", () => { + let createdId: string; + + beforeAll(async () => { + const res = await adminAgent.post("/api/maps").send({ + name: "Test Map 2", + version: "1.0", + data: { + meta: { + name: "Test Map 2", + version: "1.0", + hexSize: 48, + orientation: "pointy", + }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it("returns full map data by id", async () => { + const res = await userAgent.get(`/api/maps/${createdId}`); + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + expect(res.body.data.meta.name).toBe("Test Map 2"); + }); + + it("returns 404 for unknown id", async () => { + const res = await userAgent.get( + "/api/maps/00000000-0000-0000-0000-000000000000", + ); + expect(res.status).toBe(404); + }); +}); + +describe("PUT /api/maps/:id", () => { + let createdId: string; + + beforeAll(async () => { + const res = await adminAgent.post("/api/maps").send({ + name: "Test Map 3", + version: "1.0", + data: { + meta: { + name: "Test Map 3", + version: "1.0", + hexSize: 48, + orientation: "pointy", + }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it("updates map data as admin", async () => { + const updated = { + name: "Test Map 3 Updated", + version: "1.1", + data: { + meta: { + name: "Test Map 3 Updated", + version: "1.1", + hexSize: 48, + orientation: "pointy", + }, + bodies: { terra: { center: "0,4", radius: 1, gravityRings: 1 } }, + hexes: {}, + bases: {}, + }, + }; + const res = await adminAgent.put(`/api/maps/${createdId}`).send(updated); + expect(res.status).toBe(200); + expect(res.body.name).toBe("Test Map 3 Updated"); + }); + + it("returns 403 for non-admin", async () => { + const res = await userAgent.put(`/api/maps/${createdId}`).send({}); + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/maps/:id/export", () => { + let createdId: string; + + beforeAll(async () => { + const res = await adminAgent.post("/api/maps").send({ + name: "Test Map Export", + version: "1.0", + data: { + meta: { + name: "Test Map Export", + version: "1.0", + hexSize: 48, + orientation: "pointy", + }, + bodies: {}, + hexes: {}, + bases: {}, + }, + }); + createdId = res.body.id; + }); + + it("returns JSON file download", async () => { + const res = await userAgent.get(`/api/maps/${createdId}/export`); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/json/); + expect(res.headers["content-disposition"]).toMatch(/attachment/); + }); +}); diff --git a/packages/server/src/__tests__/session.test.ts b/packages/server/src/__tests__/session.test.ts new file mode 100644 index 0000000..84a0fc6 --- /dev/null +++ b/packages/server/src/__tests__/session.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { pool } from "../db/client.js"; + +// Access the pgStore directly by importing the module and re-exporting via a +// test-only helper. Since pgStore is not exported, we test it through the +// integration: get/set/destroy against the real DB. + +// We reach into the module internals by reconstructing the same logic. +// The real test value is integration against Postgres, not unit isolation. + +const pgStore = { + async get(key: string) { + const res = await pool.query<{ sess: string }>( + "SELECT sess FROM session WHERE sid = $1 AND expire > NOW()", + [key], + ); + const row = res.rows[0]; + if (!row) return undefined; + return typeof row.sess === "string" + ? (JSON.parse(row.sess) as Record) + : row.sess; + }, + + async set( + key: string, + sess: Record, + maxAge: number | "session", + ) { + const expireMs = + typeof maxAge === "number" ? maxAge : 7 * 24 * 60 * 60 * 1000; + await pool.query( + `INSERT INTO session (sid, sess, expire) + VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 millisecond')) + ON CONFLICT (sid) DO UPDATE SET sess = $2, expire = NOW() + ($3 * INTERVAL '1 millisecond')`, + [key, JSON.stringify(sess), expireMs], + ); + }, + + async destroy(key: string) { + await pool.query("DELETE FROM session WHERE sid = $1", [key]); + }, +}; + +beforeAll(async () => { + await pool.query("DELETE FROM session WHERE sid LIKE 'test-session-%'"); +}); + +afterAll(async () => { + await pool.query("DELETE FROM session WHERE sid LIKE 'test-session-%'"); + await pool.end(); +}); + +describe("pgStore.set + get", () => { + it("stores and retrieves a session", async () => { + const key = "test-session-1"; + const data = { userId: "u1", role: "admin" }; + await pgStore.set(key, data, 60_000); + const result = await pgStore.get(key); + expect(result).toEqual(data); + }); + + it("returns undefined for a nonexistent key", async () => { + const result = await pgStore.get("test-session-nonexistent"); + expect(result).toBeUndefined(); + }); + + it("returns undefined for an expired session", async () => { + const key = "test-session-expired"; + // Insert directly with an expire in the past + await pool.query( + `INSERT INTO session (sid, sess, expire) + VALUES ($1, $2, $3) + ON CONFLICT (sid) DO UPDATE SET sess = $2, expire = $3`, + [key, JSON.stringify({ x: 1 }), new Date(Date.now() - 1000)], + ); + const result = await pgStore.get(key); + expect(result).toBeUndefined(); + }); + + it("overwrites an existing session (upsert)", async () => { + const key = "test-session-upsert"; + await pgStore.set(key, { v: 1 }, 60_000); + await pgStore.set(key, { v: 2 }, 60_000); + const result = await pgStore.get(key); + expect(result).toEqual({ v: 2 }); + }); + + it('handles session maxAge of "session" using default TTL', async () => { + const key = "test-session-session-ttl"; + await pgStore.set(key, { ephemeral: true }, "session"); + const result = await pgStore.get(key); + expect(result).toEqual({ ephemeral: true }); + }); + + it("round-trips complex nested session data", async () => { + const key = "test-session-complex"; + const data = { + user: { id: "abc", roles: ["editor", "admin"] }, + flags: { beta: true }, + }; + await pgStore.set(key, data, 60_000); + const result = await pgStore.get(key); + expect(result).toEqual(data); + }); +}); + +describe("pgStore.destroy", () => { + it("deletes an existing session", async () => { + const key = "test-session-destroy"; + await pgStore.set(key, { foo: "bar" }, 60_000); + await pgStore.destroy(key); + const result = await pgStore.get(key); + expect(result).toBeUndefined(); + }); + + it("does not throw when destroying a nonexistent key", async () => { + await expect(pgStore.destroy("test-session-noop")).resolves.not.toThrow(); + }); +}); diff --git a/packages/server/src/api/middleware/requireAdmin.ts b/packages/server/src/api/middleware/requireAdmin.ts new file mode 100644 index 0000000..e9eb29f --- /dev/null +++ b/packages/server/src/api/middleware/requireAdmin.ts @@ -0,0 +1,16 @@ +import type { Context, Next } from 'koa'; + +export async function requireAdmin(ctx: Context, next: Next): Promise { + const user = ctx.state['user'] as { isAdmin?: boolean } | undefined; + if (!user) { + ctx.status = 401; + ctx.body = { error: 'Authentication required' }; + return; + } + if (!user.isAdmin) { + ctx.status = 403; + ctx.body = { error: 'Admin access required' }; + return; + } + await next(); +} diff --git a/packages/server/src/api/middleware/session.ts b/packages/server/src/api/middleware/session.ts index 7a2b734..020abb2 100644 --- a/packages/server/src/api/middleware/session.ts +++ b/packages/server/src/api/middleware/session.ts @@ -1,44 +1,51 @@ -import koaSession from 'koa-session'; -import type Koa from 'koa'; -import { pool } from '../../db/client'; +import koaSession from "koa-session"; +import type Koa from "koa"; +import { pool } from "../../db/client"; // pg-backed session store compatible with koa-session external store interface. // The `session` table is created in the initial migration. const pgStore = { async get(key: string) { const res = await pool.query<{ sess: string }>( - 'SELECT sess FROM session WHERE sid = $1 AND expire > NOW()', + "SELECT sess FROM session WHERE sid = $1 AND expire > NOW()", [key], ); const row = res.rows[0]; if (!row) return undefined; - return typeof row.sess === 'string' ? (JSON.parse(row.sess) as Record) : row.sess; + return typeof row.sess === "string" + ? (JSON.parse(row.sess) as Record) + : row.sess; }, - async set(key: string, sess: Record, maxAge: number | 'session') { - const expireMs = typeof maxAge === 'number' ? maxAge : 7 * 24 * 60 * 60 * 1000; - const expire = new Date(Date.now() + expireMs); + async set( + key: string, + sess: Record, + maxAge: number | "session", + ) { + const expireMs = + typeof maxAge === "number" ? maxAge : 7 * 24 * 60 * 60 * 1000; await pool.query( `INSERT INTO session (sid, sess, expire) - VALUES ($1, $2, $3) - ON CONFLICT (sid) DO UPDATE SET sess = $2, expire = $3`, - [key, JSON.stringify(sess), expire], + VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 millisecond')) + ON CONFLICT (sid) DO UPDATE SET sess = $2, expire = NOW() + ($3 * INTERVAL '1 millisecond')`, + [key, JSON.stringify(sess), expireMs], ); }, async destroy(key: string) { - await pool.query('DELETE FROM session WHERE sid = $1', [key]); + await pool.query("DELETE FROM session WHERE sid = $1", [key]); }, }; -export function createSessionMiddleware(app: Koa): Koa.Middleware { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createSessionMiddleware(app: Koa): Koa.Middleware { return koaSession( { - key: 'triplanetary.sid', + key: "triplanetary.sid", maxAge: 7 * 24 * 60 * 60 * 1000, httpOnly: true, - sameSite: 'lax', - secure: process.env['NODE_ENV'] === 'production', + sameSite: "lax", + secure: process.env["NODE_ENV"] === "production", store: pgStore, renew: true, }, diff --git a/packages/server/src/api/routes/auth.ts b/packages/server/src/api/routes/auth.ts index 7299dd8..e9cc836 100644 --- a/packages/server/src/api/routes/auth.ts +++ b/packages/server/src/api/routes/auth.ts @@ -1,13 +1,13 @@ -import Router from '@koa/router'; -import passport from 'koa-passport'; -import bcrypt from 'bcryptjs'; -import { eq } from 'drizzle-orm'; -import { db } from '../../db/client'; -import { users } from '../../db/schema'; +import Router from "@koa/router"; +import passport from "koa-passport"; +import bcrypt from "bcryptjs"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { users } from "../../db/schema"; const router = new Router(); -router.post('/auth/register', async (ctx) => { +router.post("/auth/register", async (ctx) => { const { email, password, displayName } = ctx.request.body as { email?: string; password?: string; @@ -16,14 +16,18 @@ router.post('/auth/register', async (ctx) => { if (!email || !password || !displayName) { ctx.status = 400; - ctx.body = { error: 'email, password, and displayName are required' }; + ctx.body = { error: "email, password, and displayName are required" }; return; } - const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); + const existing = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); if (existing.length > 0) { ctx.status = 409; - ctx.body = { error: 'Email already registered' }; + ctx.body = { error: "Email already registered" }; return; } @@ -31,51 +35,63 @@ router.post('/auth/register', async (ctx) => { const result = await db .insert(users) .values({ email, passwordHash, displayName }) - .returning({ id: users.id, email: users.email, displayName: users.displayName, isAdmin: users.isAdmin }); + .returning({ + id: users.id, + email: users.email, + displayName: users.displayName, + isAdmin: users.isAdmin, + }); const user = result[0]!; await new Promise((resolve, reject) => { - ctx.login(user, (err) => (err ? reject(err as Error) : resolve())); + ctx.login(user, (err: unknown) => + err + ? reject(err instanceof Error ? err : new Error(String(err))) + : resolve(), + ); }); ctx.status = 201; ctx.body = user; }); -router.post('/auth/login', async (ctx, next) => { - return passport.authenticate('local', (err: Error | null, user: false | { id: string }) => { - if (err) { - ctx.status = 500; - ctx.body = { error: 'Internal server error' }; - return; - } - if (!user) { - ctx.status = 401; - ctx.body = { error: 'Invalid credentials' }; - return; - } +router.post("/auth/login", async (ctx, next) => { + return passport.authenticate( + "local", + (err: Error | null, user: false | { id: string }) => { + if (err) { + ctx.status = 500; + ctx.body = { error: "Internal server error" }; + return; + } + if (!user) { + ctx.status = 401; + ctx.body = { error: "Invalid credentials" }; + return; + } - return ctx.login(user).then(() => { - ctx.status = 200; - ctx.body = user; - }); - })(ctx, next); + return ctx.login(user).then(() => { + ctx.status = 200; + ctx.body = user; + }); + }, + )(ctx, next); }); -router.post('/auth/logout', async (ctx) => { +router.post("/auth/logout", async (ctx) => { ctx.logout(); ctx.status = 200; ctx.body = { ok: true }; }); -router.get('/auth/me', (ctx) => { +router.get("/auth/me", (ctx) => { if (!ctx.isAuthenticated()) { ctx.status = 401; - ctx.body = { error: 'Not authenticated' }; + ctx.body = { error: "Not authenticated" }; return; } - ctx.body = ctx.state['user'] as Record; + ctx.body = ctx.state["user"] as Record; }); export default router; diff --git a/packages/server/src/api/routes/maps.ts b/packages/server/src/api/routes/maps.ts new file mode 100644 index 0000000..a1a2c3e --- /dev/null +++ b/packages/server/src/api/routes/maps.ts @@ -0,0 +1,131 @@ +import Router from "@koa/router"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { maps } from "../../db/schema"; +import { requireAdmin } from "../middleware/requireAdmin"; +import type { HexData } from "@triplanetary/shared"; + +const router = new Router(); + +// GET /api/maps — list all maps (metadata only) — any authed user +router.get("/maps", async (ctx) => { + if (!ctx.state["user"]) { + ctx.status = 401; + ctx.body = { error: "Unauthenticated" }; + return; + } + const rows = await db + .select({ + id: maps.id, + name: maps.name, + version: maps.version, + isCanonical: maps.isCanonical, + createdAt: maps.createdAt, + updatedAt: maps.updatedAt, + }) + .from(maps) + .orderBy(maps.name); + ctx.body = rows; +}); + +// GET /api/maps/:id — full map with data — any authed user +router.get("/maps/:id", async (ctx) => { + if (!ctx.state["user"]) { + ctx.status = 401; + ctx.body = { error: "Unauthenticated" }; + return; + } + const [row] = await db + .select() + .from(maps) + .where(eq(maps.id, ctx.params["id"] ?? "")); + if (!row) { + ctx.status = 404; + ctx.body = { error: "Map not found" }; + return; + } + ctx.body = row; +}); + +// POST /api/maps — create map — admin only +router.post("/maps", requireAdmin, async (ctx) => { + const body = ctx.request.body as { + name: string; + version?: string; + data: HexData; + }; + if (!body.name || !body.data) { + ctx.status = 400; + ctx.body = { error: "name and data required" }; + return; + } + const [row] = await db + .insert(maps) + .values({ + name: body.name, + version: body.version ?? "1.0", + data: body.data, + }) + .returning(); + ctx.status = 201; + ctx.body = row; +}); + +// PUT /api/maps/:id — update map — admin only +router.put("/maps/:id", requireAdmin, async (ctx) => { + const body = ctx.request.body as Partial<{ + name: string; + version: string; + data: HexData; + }>; + const id = ctx.params["id"] ?? ""; + const [existing] = await db + .select({ id: maps.id }) + .from(maps) + .where(eq(maps.id, id)); + if (!existing) { + ctx.status = 404; + ctx.body = { error: "Map not found" }; + return; + } + const [updated] = await db + .update(maps) + .set({ + ...(body.name !== undefined && { name: body.name }), + ...(body.version !== undefined && { version: body.version }), + ...(body.data !== undefined && { data: body.data }), + updatedAt: new Date(), + }) + .where(eq(maps.id, id)) + .returning(); + ctx.body = updated; +}); + +// GET /api/maps/:id/export — download as JSON file — any authed user +router.get("/maps/:id/export", async (ctx) => { + if (!ctx.state["user"]) { + ctx.status = 401; + ctx.body = { error: "Unauthenticated" }; + return; + } + const [row] = await db + .select() + .from(maps) + .where(eq(maps.id, ctx.params["id"] ?? "")); + if (!row) { + ctx.status = 404; + ctx.body = { error: "Map not found" }; + return; + } + // Strip control characters and quotes before embedding in the header value. + const safeName = row.name + .replace(/[\x00-\x1f"\\]/g, "") + .replace(/\s+/g, "-") + .toLowerCase(); + const filename = `${safeName}-v${row.version}.json`; + ctx.set("Content-Disposition", `attachment; filename="${filename}"`); + ctx.set("Content-Type", "application/json"); + ctx.body = JSON.stringify(row.data, null, 2); +}); + +export default router; diff --git a/packages/server/src/bgio/server.ts b/packages/server/src/bgio/server.ts index 17c1258..1938172 100644 --- a/packages/server/src/bgio/server.ts +++ b/packages/server/src/bgio/server.ts @@ -1,11 +1,11 @@ -import { Server } from 'boardgame.io/server'; -import { TriplanetaryGame } from '@triplanetary/shared'; +import { Server } from "boardgame.io/server"; +import { TriplanetaryGame } from "@triplanetary/shared"; -const origins = (process.env['BGIO_ALLOWED_ORIGINS'] ?? 'http://localhost:3000') - .split(',') +const origins = (process.env["BGIO_ALLOWED_ORIGINS"] ?? "http://localhost:3000") + .split(",") .map((o) => o.trim()); -export const bgioServer = Server({ +export const bgioServer: ReturnType = Server({ games: [TriplanetaryGame], origins, }); diff --git a/packages/server/src/bootstrap.ts b/packages/server/src/bootstrap.ts new file mode 100644 index 0000000..cd45f3c --- /dev/null +++ b/packages/server/src/bootstrap.ts @@ -0,0 +1,12 @@ +import path from "node:path"; +import { config } from "dotenv"; + +// Load .env BEFORE any other module is evaluated. +// In CJS, static `import` statements in index.ts (and everything it imports) +// compile to require() calls that execute when require('./index') runs below — +// i.e. AFTER this config() call. This guarantees every module body that reads +// process.env (db/client Pool, bgio/server origins, etc.) sees the .env values. +config({ path: path.resolve(__dirname, "../../../.env") }); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("./index"); diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 6d4b8a8..f8c8b70 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -1,24 +1,43 @@ -import path from 'node:path'; -import { config } from 'dotenv'; +import path from "node:path"; +import { config } from "dotenv"; // Load .env from monorepo root -config({ path: path.resolve(__dirname, '../../../../.env') }); +config({ path: path.resolve(__dirname, "../../../../.env") }); -import { drizzle } from 'drizzle-orm/node-postgres'; -import { migrate } from 'drizzle-orm/node-postgres/migrator'; -import pg from 'pg'; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import pg from "pg"; const { Pool } = pg; +async function waitForDb( + pool: InstanceType, + retries = 15, + delayMs = 1000, +) { + for (let i = 0; i < retries; i++) { + try { + await pool.query("select 1"); + return; + } catch (e) { + if (i === retries - 1) throw e; + await new Promise((r) => setTimeout(r, delayMs)); + } + } +} + async function runMigrations() { - const pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); + const pool = new Pool({ connectionString: process.env["DATABASE_URL"] }); + await waitForDb(pool); const db = drizzle(pool); - await migrate(db, { migrationsFolder: path.resolve(__dirname, './migrations') }); - console.log('Migrations applied successfully'); + await migrate(db, { + migrationsFolder: path.resolve(__dirname, "./migrations"), + }); + console.log("Migrations applied successfully"); await pool.end(); } runMigrations().catch((err) => { - console.error('Migration failed:', err); + console.error("Migration failed:", err); process.exit(1); }); diff --git a/packages/server/src/db/migrations/0001_add_session_table.sql b/packages/server/src/db/migrations/0001_add_session_table.sql deleted file mode 100644 index 119861e..0000000 --- a/packages/server/src/db/migrations/0001_add_session_table.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS "session" ( - "sid" varchar NOT NULL COLLATE "default", - "sess" json NOT NULL, - "expire" timestamp(6) NOT NULL, - CONSTRAINT "session_pkey" PRIMARY KEY ("sid") -); - -CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire"); diff --git a/packages/server/src/db/migrations/0001_little_wildside.sql b/packages/server/src/db/migrations/0001_little_wildside.sql new file mode 100644 index 0000000..1362393 --- /dev/null +++ b/packages/server/src/db/migrations/0001_little_wildside.sql @@ -0,0 +1,9 @@ +CREATE TABLE "maps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(200) NOT NULL, + "version" varchar(20) DEFAULT '1.0' NOT NULL, + "data" jsonb NOT NULL, + "is_canonical" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/packages/server/src/db/migrations/0002_add_session_table.sql b/packages/server/src/db/migrations/0002_add_session_table.sql new file mode 100644 index 0000000..242f04b --- /dev/null +++ b/packages/server/src/db/migrations/0002_add_session_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "session" ( + "sid" varchar PRIMARY KEY NOT NULL, + "sess" json NOT NULL, + "expire" timestamptz NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire"); diff --git a/packages/server/src/db/migrations/meta/0001_snapshot.json b/packages/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..50ab24b --- /dev/null +++ b/packages/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,292 @@ +{ + "id": "aef6c2fd-30d2-4de7-ad05-2f7167911338", + "prevId": "b2dcb617-e06c-4492-a768-64f338ba25b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.game_players": { + "name": "game_players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "player_index": { + "name": "player_index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "faction": { + "name": "faction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "game_players_game_id_games_id_fk": { + "name": "game_players_game_id_games_id_fk", + "tableFrom": "game_players", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "game_players_user_id_users_id_fk": { + "name": "game_players_user_id_users_id_fk", + "tableFrom": "game_players", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uniq_game_player": { + "name": "uniq_game_player", + "nullsNotDistinct": false, + "columns": [ + "game_id", + "player_index" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.games": { + "name": "games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bgio_match_id": { + "name": "bgio_match_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "games_bgio_match_id_unique": { + "name": "games_bgio_match_id_unique", + "nullsNotDistinct": false, + "columns": [ + "bgio_match_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maps": { + "name": "maps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_canonical": { + "name": "is_canonical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/server/src/db/migrations/meta/_journal.json b/packages/server/src/db/migrations/meta/_journal.json index a2c3eb0..488c160 100644 --- a/packages/server/src/db/migrations/meta/_journal.json +++ b/packages/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1775837138512, "tag": "0000_tan_albert_cleary", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775862497416, + "tag": "0001_little_wildside", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1775870000000, + "tag": "0002_add_session_table", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 236100b..f7fbac7 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -1,31 +1,57 @@ -import { pgTable, uuid, varchar, boolean, timestamp, smallint, unique } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + varchar, + boolean, + timestamp, + smallint, + unique, + jsonb, +} from "drizzle-orm/pg-core"; +import type { HexData } from "@triplanetary/shared"; -export const users = pgTable('users', { - id: uuid('id').defaultRandom().primaryKey(), - email: varchar('email', { length: 255 }).notNull().unique(), - passwordHash: varchar('password_hash', { length: 255 }).notNull(), - displayName: varchar('display_name', { length: 100 }).notNull(), - avatar: varchar('avatar', { length: 255 }), - isAdmin: boolean('is_admin').default(false).notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), +export const users = pgTable("users", { + id: uuid("id").defaultRandom().primaryKey(), + email: varchar("email", { length: 255 }).notNull().unique(), + passwordHash: varchar("password_hash", { length: 255 }).notNull(), + displayName: varchar("display_name", { length: 100 }).notNull(), + avatar: varchar("avatar", { length: 255 }), + isAdmin: boolean("is_admin").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const games = pgTable('games', { - id: uuid('id').defaultRandom().primaryKey(), - bgioMatchId: varchar('bgio_match_id', { length: 100 }).notNull().unique(), - scenarioId: varchar('scenario_id', { length: 50 }).notNull(), - status: varchar('status', { length: 20 }).notNull().default('waiting'), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), +export const games = pgTable("games", { + id: uuid("id").defaultRandom().primaryKey(), + bgioMatchId: varchar("bgio_match_id", { length: 100 }).notNull().unique(), + scenarioId: varchar("scenario_id", { length: 50 }).notNull(), + status: varchar("status", { length: 20 }).notNull().default("waiting"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const gamePlayers = pgTable('game_players', { - id: uuid('id').defaultRandom().primaryKey(), - gameId: uuid('game_id').references(() => games.id).notNull(), - userId: uuid('user_id').references(() => users.id).notNull(), - playerIndex: smallint('player_index').notNull(), - faction: varchar('faction', { length: 50 }), - result: varchar('result', { length: 20 }), -}, (t) => [ - unique('uniq_game_player').on(t.gameId, t.playerIndex), -]); +export const gamePlayers = pgTable( + "game_players", + { + id: uuid("id").defaultRandom().primaryKey(), + gameId: uuid("game_id") + .references(() => games.id) + .notNull(), + userId: uuid("user_id") + .references(() => users.id) + .notNull(), + playerIndex: smallint("player_index").notNull(), + faction: varchar("faction", { length: 50 }), + result: varchar("result", { length: 20 }), + }, + (t) => [unique("uniq_game_player").on(t.gameId, t.playerIndex)], +); + +export const maps = pgTable("maps", { + id: uuid("id").defaultRandom().primaryKey(), + name: varchar("name", { length: 200 }).notNull(), + version: varchar("version", { length: 20 }).notNull().default("1.0"), + data: jsonb("data").notNull().$type(), + isCanonical: boolean("is_canonical").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); diff --git a/packages/server/src/db/seed/canonicalMap.ts b/packages/server/src/db/seed/canonicalMap.ts new file mode 100644 index 0000000..63a8c60 --- /dev/null +++ b/packages/server/src/db/seed/canonicalMap.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { config } from 'dotenv'; + +// Must load env BEFORE importing db client (static imports are hoisted) +config({ path: path.resolve(__dirname, '../../../../../.env') }); + +import { eq } from 'drizzle-orm'; +import { computeGravity } from '@triplanetary/shared'; +import type { HexData, BodyEntry } from '@triplanetary/shared'; + +const BODIES: Record = { + sol: { center: '0,0', radius: 3, gravityRings: 2 }, + terra: { center: '0,4', radius: 1, gravityRings: 1 }, + luna: { center: '1,5', radius: 0, gravityRings: 1, weakGravity: true }, + venus: { center: '-4,2', radius: 1, gravityRings: 1 }, + mars: { center: '5,-3', radius: 1, gravityRings: 1 }, + ceres: { center: '9,1', gravityRings: 0, asteroidBase: true }, +}; + +const PLANET_HEXES: Record = { + '0,0': { body: 'sol' }, + '0,4': { body: 'terra' }, + '1,5': { body: 'luna' }, + '-4,2': { body: 'venus' }, + '5,-3': { body: 'mars' }, + '9,1': { body: 'ceres' }, +}; + +const ASTEROID_HEXES: string[] = ['7,2', '-2,-3', '3,6', '-5,5']; + +const BASES: HexData['bases'] = { + terra: { hexSides: [0, 1, 2, 3, 4, 5] }, + venus: { hexSides: [0, 1, 2, 3, 4, 5] }, + mars: { hexSides: [0, 1, 2, 3] }, + luna: { hexSides: [2] }, + ceres: { hexSides: [0], asteroidBase: true, torpedo: true }, +}; + +async function seed() { + // Dynamic import ensures db client reads DATABASE_URL after dotenv runs + const { db, pool } = await import('../client'); + const { maps } = await import('../schema'); + + const planetHexes: HexData['hexes'] = Object.fromEntries( + Object.entries(PLANET_HEXES).map(([key, { body }]) => [key, { type: 'planet' as const, body }]), + ); + + const withAsteroids: HexData['hexes'] = { + ...planetHexes, + ...Object.fromEntries(ASTEROID_HEXES.map((k) => [k, { type: 'asteroid' as const }])), + }; + + const gravityHexes = computeGravity(BODIES, withAsteroids); + const nonGravity = Object.fromEntries( + Object.entries(withAsteroids).filter(([, h]) => h.type !== 'gravity'), + ); + const allHexes: HexData['hexes'] = { ...nonGravity, ...gravityHexes }; + + const hexData: HexData = { + meta: { + name: 'Triplanetary — Inner Solar System', + version: '1.0', + hexSize: 48, + orientation: 'pointy', + }, + bodies: BODIES, + hexes: allHexes, + bases: BASES, + }; + + await db.delete(maps).where(eq(maps.isCanonical, true)); + await db.insert(maps).values({ + name: hexData.meta.name, + version: hexData.meta.version, + data: hexData, + isCanonical: true, + }); + + const gravCount = Object.values(allHexes).filter(h => h.type === 'gravity').length; + console.log(`Seeded canonical map with ${Object.keys(allHexes).length} hexes (${gravCount} gravity).`); + await pool.end(); +} + +seed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 93673fd..76d5267 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,36 +1,32 @@ -import path from 'node:path'; -import { config } from 'dotenv'; - -// Load .env from monorepo root (src/ → server/ → packages/ → root) -config({ path: path.resolve(__dirname, '../../../.env') }); - -import bodyParser from 'koa-bodyparser'; -import Router from '@koa/router'; -import { bgioServer } from './bgio/server'; -import { createSessionMiddleware } from './api/middleware/session'; -import { passportInit, passportSession } from './api/middleware/passport'; -import authRouter from './api/routes/auth'; -import healthRouter from './api/routes/health'; +import bodyParser from "koa-bodyparser"; +import Router from "@koa/router"; +import { bgioServer } from "./bgio/server"; +import { createSessionMiddleware } from "./api/middleware/session"; +import { passportInit, passportSession } from "./api/middleware/passport"; +import authRouter from "./api/routes/auth"; +import healthRouter from "./api/routes/health"; +import mapsRouter from "./api/routes/maps"; const { app, router: bgioRouter } = bgioServer; // Attach app-level middleware to the boardgame.io Koa app -app.keys = [process.env['SESSION_SECRET'] ?? 'dev-secret-change-me']; +app.keys = [process.env["SESSION_SECRET"] ?? "dev-secret-change-me"]; app.use(createSessionMiddleware(app)); app.use(passportInit); app.use(passportSession); // Mount custom routes under /api, with body parsing scoped only to /api // to avoid consuming the request body before boardgame.io's own body parser. -const apiRouter = new Router({ prefix: '/api' }); +const apiRouter = new Router({ prefix: "/api" }); apiRouter.use(bodyParser()); apiRouter.use(authRouter.routes()); apiRouter.use(healthRouter.routes()); +apiRouter.use(mapsRouter.routes()); app.use(apiRouter.routes()); app.use(bgioRouter.routes()); -const PORT = Number(process.env['PORT'] ?? 8000); +const PORT = Number(process.env["PORT"] ?? 8000); bgioServer.run(PORT, () => { console.log(`Server listening on :${PORT}`); console.log(` boardgame.io: http://localhost:${PORT}/games`); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index bb71331..de68a01 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -1,18 +1,21 @@ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'node:path'; +import { defineConfig } from "vitest/config"; +import { resolve } from "node:path"; export default defineConfig({ test: { - environment: 'node', + environment: "node", + pool: "forks", + poolOptions: { forks: { isolate: true } }, env: { - DATABASE_URL: 'postgresql://triplanetary:triplanetary@localhost:5433/triplanetary', - SESSION_SECRET: 'test-secret-for-vitest', - NODE_ENV: 'test', + DATABASE_URL: + "postgresql://triplanetary:triplanetary@localhost:5433/triplanetary", + SESSION_SECRET: "test-secret-for-vitest", + NODE_ENV: "test", }, }, resolve: { alias: { - '@triplanetary/shared': resolve('../../packages/shared/src/index.ts'), + "@triplanetary/shared": resolve("../../packages/shared/src/index.ts"), }, }, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 0f459b1..453ff1b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,12 +5,16 @@ "types": "./dist/index.d.ts", "scripts": { "build": "tsc", - "dev": "tsc --watch" + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "dev": "tsc --watch", + "test": "vitest run" }, "dependencies": { "boardgame.io": "^0.50.2" }, "devDependencies": { - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^3.2.4" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c862f62..64bc7f2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ -export * from './types/game'; -export * from './types/api'; -export * from './types/map'; -export * from './constants/scenarios'; -export * from './game/TriplanetaryGame'; +export * from "./types/game"; +export * from "./types/api"; +export * from "./types/map"; +export * from "./constants/scenarios"; +export * from "./game/TriplanetaryGame"; +export * from "./lib/gravity"; diff --git a/packages/shared/src/lib/__tests__/gravity.test.ts b/packages/shared/src/lib/__tests__/gravity.test.ts new file mode 100644 index 0000000..fc94bff --- /dev/null +++ b/packages/shared/src/lib/__tests__/gravity.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { computeGravity, hexRing } from '../gravity'; +import type { BodyEntry } from '../../types/map'; + +describe('hexRing', () => { + it('returns 6 hexes at radius 1 from origin', () => { + const ring = hexRing(0, 0, 1); + expect(ring).toHaveLength(6); + // All must be at axial distance 1 from center + for (const [q, r] of ring) { + const dist = (Math.abs(q) + Math.abs(r) + Math.abs(-q - r)) / 2; + expect(dist).toBe(1); + } + }); + + it('returns 12 hexes at radius 2 from origin', () => { + expect(hexRing(0, 0, 2)).toHaveLength(12); + }); + + it('works from a non-origin center', () => { + const ring = hexRing(3, -2, 1); + expect(ring).toHaveLength(6); + }); +}); + +describe('computeGravity', () => { + const terra: BodyEntry = { center: '0,4', radius: 1, gravityRings: 1 }; + + it('generates gravity hexes around a body', () => { + const result = computeGravity({ terra }); + // Ring at radius 1 around (0,4): 6 hexes + expect(Object.keys(result)).toHaveLength(6); + // All entries should be gravity type pointing at terra + for (const hex of Object.values(result)) { + expect(hex.type).toBe('gravity'); + expect(hex.body).toBe('terra'); + expect(hex.manual).toBe(false); + } + }); + + it('hex north of body (0,3) pulls southward toward (0,4)', () => { + // (0,3) is at r=3, terra center is r=4; pull = +r direction = [0,+1] + const result = computeGravity({ terra }); + expect(result['0,3']).toMatchObject({ type: 'gravity', offset: [0, 1] }); + }); + + it('preserves manual overrides across recompute', () => { + const existingHexes = { + '0,3': { type: 'gravity' as const, body: 'terra', offset: [-1, 0] as [number, number], manual: true }, + }; + const result = computeGravity({ terra }, existingHexes); + // Manual override preserved + expect(result['0,3']).toMatchObject({ offset: [-1, 0], manual: true }); + // Other ring hexes still computed + expect(Object.keys(result).length).toBe(6); + }); + + it('does not generate gravity for a body with gravityRings=0', () => { + const ceres: BodyEntry = { center: '9,1', gravityRings: 0, asteroidBase: true }; + const result = computeGravity({ ceres }); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('generates 18 gravity hexes for a body with gravityRings=2', () => { + // Ring 1: 6, Ring 2: 12 → 18 total + const sol: BodyEntry = { center: '0,0', radius: 3, gravityRings: 2 }; + const result = computeGravity({ sol }); + expect(Object.keys(result)).toHaveLength(18); + }); + + it('does not overwrite planet hexes with gravity', () => { + // Sol at (0,0) with gravityRings=2 — but terra is at (0,1) which is in Sol's ring + const sol: BodyEntry = { center: '0,0', radius: 3, gravityRings: 2 }; + const withPlanet = { '0,1': { type: 'planet' as const, body: 'terra' } }; + const result = computeGravity({ sol }, withPlanet); + // The planet hex should NOT be overwritten by gravity + expect(result['0,1']?.type).toBe('planet'); + }); +}); diff --git a/packages/shared/src/lib/gravity.ts b/packages/shared/src/lib/gravity.ts new file mode 100644 index 0000000..c2f03c9 --- /dev/null +++ b/packages/shared/src/lib/gravity.ts @@ -0,0 +1,101 @@ +import type { BodyEntry, HexEntry } from "../types/map"; + +// The 6 axial unit directions for pointy-top hexes. +// Index order matches the standard hex ring traversal (E, SE, SW, W, NW, NE). +const AXIAL_DIRS: readonly [number, number][] = [ + [1, 0], + [0, 1], + [-1, 1], + [-1, 0], + [0, -1], + [1, -1], +] as const; + +/** + * Returns all hexes on the ring at `radius` distance from `(cq, cr)`. + * Algorithm: start at (cq, cr-radius), walk 6 sides of `radius` steps each. + */ +export function hexRing( + cq: number, + cr: number, + radius: number, +): [number, number][] { + if (radius === 0) return [[cq, cr]]; + const results: [number, number][] = []; + let hq = cq; + let hr = cr - radius; // start: direction [0,-1] * radius from center + for (let i = 0; i < 6; i++) { + const [dq, dr] = AXIAL_DIRS[i]!; + for (let j = 0; j < radius; j++) { + results.push([hq, hr]); + hq += dq; + hr += dr; + } + } + return results; +} + +/** + * Returns the axial unit direction closest to the vector (dq, dr). + * Uses the cube-coordinate dot product to find the best match. + */ +function nearestAxialDir(dq: number, dr: number): [number, number] { + let bestIdx = 0; + let bestScore = -Infinity; + for (let i = 0; i < AXIAL_DIRS.length; i++) { + const [aq, ar] = AXIAL_DIRS[i]!; + // Cube dot product: accounts for all 3 cube axes (q, r, s=-q-r) + const score = 2 * aq * dq + 2 * ar * dr + aq * dr + ar * dq; + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + return AXIAL_DIRS[bestIdx]!; +} + +/** + * Computes gravity hexes for all bodies. Existing manual overrides are preserved. + * Non-manual gravity hexes are dropped and recomputed from scratch. + * + * @param bodies Body definitions with center coordinates and gravityRings radius. + * @param existing Existing hex entries (may include manual gravity overrides and non-gravity hexes). + * @returns Full hex record: all non-gravity entries from existing, manual gravity overrides, + * and freshly computed non-manual gravity hexes. + */ +export function computeGravity( + bodies: Record, + existing: Record = {}, +): Record { + // Carry forward manual overrides only; drop non-manual gravity (will recompute) + const result: Record = Object.fromEntries( + Object.entries(existing).filter( + ([, h]) => !(h.type === "gravity" && !h.manual), + ), + ); + + for (const [bodyName, body] of Object.entries(bodies)) { + const parts = body.center.split(","); + const cq = Number(parts[0]); + const cr = Number(parts[1]); + + for (let radius = 1; radius <= body.gravityRings; radius++) { + for (const [hq, hr] of hexRing(cq, cr, radius)) { + const key = `${hq},${hr}`; + if (result[key]?.manual) continue; // preserve manual override + // Do not overwrite planet, asteroid, or clandestine hexes with gravity + const existingHex = result[key]; + if (existingHex && existingHex.type !== "gravity") continue; + const offset = nearestAxialDir(cq - hq, cr - hr); + result[key] = { + type: "gravity", + body: bodyName, + offset, + manual: false, + }; + } + } + } + + return result; +} diff --git a/packages/shared/src/types/map.ts b/packages/shared/src/types/map.ts index 8fd5026..9c7fa12 100644 --- a/packages/shared/src/types/map.ts +++ b/packages/shared/src/types/map.ts @@ -1,11 +1,17 @@ -export type HexType = 'space' | 'gravity' | 'planet' | 'asteroid' | 'clandestine'; +export type HexType = + | "space" + | "gravity" + | "planet" + | "asteroid" + | "clandestine"; export interface HexEntry { type: HexType; body?: string; offset?: [number, number]; manual?: boolean; - weak?: boolean; + weak?: boolean; // gravity hexes: weak gravity rule applies (Luna/Io adjacent hexes) + weakGravity?: boolean; // planet hexes: the body itself is a weak-gravity body (Luna, Io) coloredAsteroids?: string[]; } @@ -29,7 +35,7 @@ export interface HexData { name: string; version: string; hexSize: number; - orientation: 'pointy' | 'flat'; + orientation: "pointy" | "flat"; }; bodies: Record; hexes: Record; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..d33375b --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { environment: 'node' }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3151c8..96bc237 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,27 @@ importers: .: devDependencies: + eslint: + specifier: ^9.39.4 + version: 9.39.4 + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.4) + globals: + specifier: ^17.4.0 + version: 17.4.0 + lefthook: + specifier: ^2.1.5 + version: 2.1.5 turbo: specifier: ^2.9.5 version: 2.9.6 + typescript-eslint: + specifier: ^8.58.1 + version: 8.58.1(eslint@9.39.4)(typescript@5.9.3) packages/client: dependencies: @@ -23,6 +41,9 @@ importers: boardgame.io: specifier: ^0.50.2 version: 0.50.2 + honeycomb-grid: + specifier: ^4.1.5 + version: 4.1.5 react: specifier: ^19.1.0 version: 19.2.5 @@ -32,6 +53,9 @@ importers: react-router-dom: specifier: ^7.5.0 version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-zoom-pan-pinch: + specifier: ^4.0.3 + version: 4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.0 @@ -164,6 +188,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.17)(jsdom@26.1.0)(msw@2.13.2(@types/node@22.19.17)(typescript@5.9.3))(tsx@4.21.0) packages: @@ -747,9 +774,63 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hapi/bourne@3.0.0': resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1130,6 +1211,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} @@ -1204,6 +1288,65 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.1': + resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.1': + resolution: {integrity: sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.1': + resolution: {integrity: sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.1': + resolution: {integrity: sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.1': + resolution: {integrity: sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.1': + resolution: {integrity: sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.1': + resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.1': + resolution: {integrity: sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.1': + resolution: {integrity: sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.1': + resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1243,6 +1386,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -1256,6 +1404,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1271,6 +1422,9 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1278,6 +1432,34 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -1285,9 +1467,24 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-arraybuffer@0.1.4: resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==} engines: {node: '>= 0.6.0'} @@ -1312,6 +1509,13 @@ packages: resolution: {integrity: sha512-oEcgtiDidTk2Mvweg3issGxOxMwOTTPlkHMoLexQ0LTQJXl60P3vA026SLYy313nrHM6s/vK7qE7TavMFxyC0g==} engines: {node: '>=10.0', npm: '>=6.0'} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1339,10 +1543,18 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} @@ -1350,6 +1562,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1387,6 +1603,9 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1438,6 +1657,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1452,6 +1675,18 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -1480,6 +1715,17 @@ packages: deep-equal@1.0.1: resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1510,6 +1756,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1656,6 +1906,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1664,6 +1918,10 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1675,6 +1933,14 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1697,9 +1963,71 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -1707,6 +2035,15 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -1719,9 +2056,25 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1745,6 +2098,13 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -1765,9 +2125,29 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1776,6 +2156,21 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1791,6 +2186,16 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + honeycomb-grid@4.1.5: + resolution: {integrity: sha512-VrnQwu5dHuzqK3wFhLD9EURmLSyEWb0teiHhDJq6WdK0MrFsEtKNYT1HLzXOLe2X1acU8Y9hhK7wWfIiGK1G5w==} + engines: {node: '>=16'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1826,9 +2231,25 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1840,6 +2261,50 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1848,9 +2313,25 @@ packages: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1858,15 +2339,61 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + is-type-of@2.2.0: resolution: {integrity: sha512-72axShMJMnMy5HSU/jLGNOonZD5rWM0MwJSCYpKCTQCbggQZBJO/CLMMVP5HgS8kPSYFBkTysJexsD6NMvGKDQ==} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -1881,15 +2408,31 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + koa-body@5.0.0: resolution: {integrity: sha512-nHwEODrQGiyKBILCWO8QSS40C87cKr2cp3y/Cw8u9Z8w5t0CdSkGm3+y9WK5BIAlPpo9tTw5RtSbxpVyG79vmw==} @@ -1920,9 +2463,74 @@ packages: resolution: {integrity: sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==} engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + lefthook-darwin-arm64@2.1.5: + resolution: {integrity: sha512-VITTaw8PxxyE26gkZ8UcwIa5ZrWnKNRGLeeSrqri40cQdXvLTEoMq2tjjw7eiL9UcB0waRReDdzydevy9GOPUQ==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.5: + resolution: {integrity: sha512-AvtjYiW0BSGHBGrdvL313seUymrW9FxI+6JJwJ+ZSaa2sH81etrTB0wAwlH1L9VfFwK9+gWvatZBvLfF3L4fPw==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.5: + resolution: {integrity: sha512-mXjJwe8jKGWGiBYUxfQY1ab3Nn5NhafqT9q3KJz8m5joGGQj4JD0cbWxF1nVBLBWsDGbWZRZunTCMGcIScT2bQ==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.5: + resolution: {integrity: sha512-exD69dCjc1K45BxatDPGoH4NmEvgLKPm4kJLOWn1fTeHRKZwWiFPwnjknEoG2OemlCDHmCU++5X40kMEG0WBlA==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.5: + resolution: {integrity: sha512-57TDKC5ewWpsCLZQKIJMHumFEObYKVundmPpiWhX491hINRZYYOL/26yrnVnNcidThRzTiTC+HLcuplLcaXtbA==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.5: + resolution: {integrity: sha512-bqK3LrAB5l5YaCaoHk6qRWlITrGWzP4FbwRxA31elbxjd0wgNWZ2Sn3zEfSEcxz442g7/PPkEwqqsTx0kSFzpg==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.5: + resolution: {integrity: sha512-5aSwK7vV3A6t0w9PnxCMiVjQlcvopBP50BtmnnLnNJyAYHnFbZ0Baq5M0WkE9IsUkWSux0fe6fd0jDkuG711MA==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.5: + resolution: {integrity: sha512-Y+pPdDuENJ8qWnUgL02xxhpjblc0WnwXvWGfqnl3WZrAgHzQpwx3G6469RID/wlNVdHYAlw3a8UkFSMYsTzXvA==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.5: + resolution: {integrity: sha512-2PlcFBjTzJaMufw0c28kfhB/0zmaRCU0TRPPsil/HU2YNOExod4upPGLk9qjgsOmb2YVWFz6zq6u7+D1yqmzTQ==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.5: + resolution: {integrity: sha512-yiAh8qxml6uqy10jDxOdN9fOQpyLxBFY1fgCEAhn7sVJYmJKRhjqSBwZX6LG5MQjzr29KStrIdw7TR3lf3rT7Q==} + cpu: [x64] + os: [win32] + + lefthook@2.1.5: + resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1975,6 +2583,13 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1997,10 +2612,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -2015,9 +2637,29 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2025,13 +2667,29 @@ packages: only@0.0.2: resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-queue@6.6.2: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} @@ -2040,6 +2698,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2059,6 +2721,17 @@ packages: resolution: {integrity: sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==} engines: {node: '>= 0.4.0'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -2113,6 +2786,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -2133,6 +2810,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2187,6 +2868,13 @@ packages: react-dom: optional: true + react-zoom-pan-pinch@4.0.3: + resolution: {integrity: sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -2198,13 +2886,30 @@ packages: redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} @@ -2219,9 +2924,17 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} @@ -2240,15 +2953,40 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -2327,6 +3065,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -2334,6 +3076,25 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2342,6 +3103,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -2353,6 +3118,14 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + svelte-json-tree-auto@0.1.0: resolution: {integrity: sha512-XDUBTV/BNStd/9WJDndzxQ9KqhoNoZBxkOo1H1d29qKW+YxG6eRrt2Yp0Cv/6KLKDtFLhJ9huJpqpOSe4yMG0Q==} @@ -2419,6 +3192,12 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -2449,6 +3228,10 @@ packages: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -2457,11 +3240,38 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.58.1: + resolution: {integrity: sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2478,6 +3288,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -2583,11 +3396,36 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -2673,10 +3511,20 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3064,8 +3912,65 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@hapi/bourne@3.0.0': {} + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@1.0.2': {} '@inquirer/confirm@5.1.21(@types/node@22.19.17)': @@ -3398,6 +4303,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/keygrip@1.0.6': {} '@types/koa-bodyparser@4.3.13': @@ -3503,6 +4410,97 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/type-utils': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.1': + dependencies: + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/visitor-keys': 8.58.1 + + '@typescript-eslint/tsconfig-utils@8.58.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.1': {} + + '@typescript-eslint/typescript-estree@8.58.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/visitor-keys': 8.58.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.1': + dependencies: + '@typescript-eslint/types': 8.58.1 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -3563,6 +4561,10 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -3571,6 +4573,13 @@ snapshots: agent-base@7.1.4: {} + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -3581,18 +4590,87 @@ snapshots: arg@4.1.3: {} + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + asap@2.0.6: {} assertion-error@2.0.1: {} + async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + base64-arraybuffer@0.1.4: {} base64-js@1.5.1: {} @@ -3632,6 +4710,15 @@ snapshots: - supports-color - utf-8-validate + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.17 @@ -3661,11 +4748,20 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001787: {} chai@5.3.3: @@ -3676,6 +4772,11 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.3: {} cli-width@4.1.0: {} @@ -3715,6 +4816,8 @@ snapshots: component-emitter@1.3.1: {} + concat-map@0.0.1: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -3753,6 +4856,12 @@ snapshots: create-require@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css.escape@1.5.1: {} cssstyle@4.6.0: @@ -3767,6 +4876,24 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -3781,6 +4908,20 @@ snapshots: deep-equal@1.0.1: {} + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -3800,6 +4941,10 @@ snapshots: diff@4.0.4: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -3883,10 +5028,86 @@ snapshots: entities@6.0.1: {} - es-define-property@1.0.1: {} - + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: @@ -3900,6 +5121,16 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -3987,22 +5218,149 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4 + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + eventemitter3@4.0.7: {} expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flatted@3.4.2: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -4031,6 +5389,17 @@ snapshots: function-bind@1.1.2: {} + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -4055,14 +5424,45 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + gopd@1.2.0: {} graphql@16.13.2: {} + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -4075,6 +5475,14 @@ snapshots: headers-polyfill@4.0.3: {} + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + honeycomb-grid@4.1.5: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -4124,14 +5532,77 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + immer@9.0.21: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inflation@2.1.0: {} inherits@2.0.4: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} is-generator-function@1.1.2: @@ -4142,8 +5613,21 @@ snapshots: has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-potential-custom-element-name@1.0.1: {} is-regex@1.2.1: @@ -4153,12 +5637,61 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + is-type-of@2.2.0: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -4188,12 +5721,29 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + keygrip@1.1.0: dependencies: tsscmp: 1.0.6 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + koa-body@5.0.0: dependencies: '@types/formidable': 2.0.6 @@ -4260,8 +5810,62 @@ snapshots: transitivePeerDependencies: - supports-color + lefthook-darwin-arm64@2.1.5: + optional: true + + lefthook-darwin-x64@2.1.5: + optional: true + + lefthook-freebsd-arm64@2.1.5: + optional: true + + lefthook-freebsd-x64@2.1.5: + optional: true + + lefthook-linux-arm64@2.1.5: + optional: true + + lefthook-linux-x64@2.1.5: + optional: true + + lefthook-openbsd-arm64@2.1.5: + optional: true + + lefthook-openbsd-x64@2.1.5: + optional: true + + lefthook-windows-arm64@2.1.5: + optional: true + + lefthook-windows-x64@2.1.5: + optional: true + + lefthook@2.1.5: + optionalDependencies: + lefthook-darwin-arm64: 2.1.5 + lefthook-darwin-x64: 2.1.5 + lefthook-freebsd-arm64: 2.1.5 + lefthook-freebsd-x64: 2.1.5 + lefthook-linux-arm64: 2.1.5 + lefthook-linux-x64: 2.1.5 + lefthook-openbsd-arm64: 2.1.5 + lefthook-openbsd-x64: 2.1.5 + lefthook-windows-arm64: 2.1.5 + lefthook-windows-x64: 2.1.5 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.isplainobject@4.0.6: {} + lodash.merge@4.6.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -4298,6 +5902,14 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + ms@2.1.3: {} msw@2.13.2(@types/node@22.19.17)(typescript@5.9.3): @@ -4329,8 +5941,17 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + negotiator@0.6.3: {} + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-releases@2.0.37: {} nwsapi@2.2.23: {} @@ -4339,6 +5960,38 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -4349,10 +6002,33 @@ snapshots: only@0.0.2: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + outvariant@1.4.3: {} + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + p-finally@1.0.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-queue@6.6.2: dependencies: eventemitter3: 4.0.7 @@ -4362,6 +6038,10 @@ snapshots: dependencies: p-finally: 1.0.0 + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -4380,6 +6060,12 @@ snapshots: pause: 0.0.1 utils-merge: 1.0.1 + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -4427,6 +6113,8 @@ snapshots: picomatch@4.0.4: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -4443,6 +6131,8 @@ snapshots: dependencies: xtend: 4.0.2 + prelude-ls@1.2.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -4498,6 +6188,11 @@ snapshots: optionalDependencies: react-dom: 19.2.5(react@19.2.5) + react-zoom-pan-pinch@4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react@19.2.5: {} redent@3.0.0: @@ -4509,10 +6204,41 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + require-directory@2.1.1: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rettime@0.10.1: {} rfc6902@5.2.0: {} @@ -4550,8 +6276,21 @@ snapshots: rrweb-cssom@0.8.0: {} + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + safe-regex-test@1.1.0: dependencies: call-bound: 1.0.4 @@ -4568,12 +6307,42 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -4692,6 +6461,11 @@ snapshots: std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -4700,6 +6474,50 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4708,6 +6526,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -4734,6 +6554,12 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + svelte-json-tree-auto@0.1.0: {} svelte@3.59.2: {} @@ -4783,6 +6609,10 @@ snapshots: dependencies: punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-node@10.9.2(@types/node@22.19.17)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -4821,6 +6651,10 @@ snapshots: '@turbo/windows-64': 2.9.6 '@turbo/windows-arm64': 2.9.6 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 @@ -4830,8 +6664,59 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.58.1(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} unpipe@1.0.0: {} @@ -4844,6 +6729,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + utils-merge@1.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -4943,11 +6832,58 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -4996,6 +6932,12 @@ snapshots: yn@3.1.1: {} + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/scripts/post-edit-lint.sh b/scripts/post-edit-lint.sh new file mode 100755 index 0000000..f0e907b --- /dev/null +++ b/scripts/post-edit-lint.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Claude Code PostToolUse hook — lint the edited file +# Exits non-zero on lint errors so Claude sees the output and fixes them. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +FILE=$(node -e " + let d = ''; + process.stdin.on('data', c => d += c); + process.stdin.on('end', () => { + try { + const payload = JSON.parse(d); + const input = payload.tool_input || {}; + // Edit and Write both use file_path; MultiEdit uses edits[].file_path + const f = input.file_path || + (Array.isArray(input.edits) && input.edits[0]?.file_path) || ''; + process.stdout.write(f); + } catch (e) {} + }); +") + +if [[ -z "$FILE" ]]; then + exit 0 +fi + +if [[ "$FILE" =~ \.(ts|tsx)$ ]]; then + cd "$ROOT" && pnpm exec eslint --max-warnings=0 -- "$FILE" +fi diff --git a/turbo.json b/turbo.json index 291fad4..2d3b97d 100644 --- a/turbo.json +++ b/turbo.json @@ -13,11 +13,18 @@ "test": { "dependsOn": ["^build"] }, + "lint": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + }, "db:generate": { "cache": false }, "db:migrate": { - "cache": false + "cache": false, + "env": ["DATABASE_URL"] } } }