Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
311eb5c713 | ||
|
|
d20037762d | ||
|
|
ab22a7e68b | ||
|
|
90de8818f8 | ||
|
|
a593cd04cd | ||
|
|
352ef31625 | ||
|
|
9af7d21d93 | ||
|
|
7d5a561882 | ||
|
|
39920b3e05 |
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.13.0 — 2026-10-06 12:17
|
||||||
|
- Jump spread limit (#51): jumping into an undiscovered system now mints a seeded-random 1–4 new systems (was a fixed 4). Each leg can instead attach an already-known system (closing a loop) with probability 0.35, so the chart grows slower and more circular. A frontier guarantee ensures at least one new system is always minted.
|
||||||
|
- `ringEdges` small-n fix: n=1 no longer emits a `[1,1]` self-loop, n=2 no longer emits a duplicate edge. n≥3 output is byte-identical to the legacy wheel.
|
||||||
|
- `generateRing` gains `attachCandidates`, `existingEdgeKeys`, and `anchorId` parameters; `RingRegion` gains an `attaches` field. The `EXPAND_RING` constant is removed in favour of `EXPAND_MIN`/`EXPAND_MAX`.
|
||||||
|
|
||||||
|
## v0.12.0 — 2026-10-04 21:04
|
||||||
|
- FINDS HIDDEN THINGS now grants +1 exploration haul item — one extra roll on the save's seeded per-planet stream, appended to the scan's haul.
|
||||||
|
- TALKS TO ANYONE now raises the crew-recruit chance from 18% to 30% when a member with the trait is on the exploring team.
|
||||||
|
- Determinism preserved: both effects draw from the existing seeded stream (same save+planet+team → identical haul), unit-tested with and without each trait; the no-trait path is byte-identical to v0.11.0.
|
||||||
|
|
||||||
## v0.11.0 — 2026-10-04 03:47
|
## v0.11.0 — 2026-10-04 03:47
|
||||||
- Skill progression: crew skills (NAVIGATION, SCANNING, ENGINEERING, RESEARCH) gain XP on every completed action, leveling through 5 bands; higher skill levels reduce task duration via power scaling.
|
- Skill progression: crew skills (NAVIGATION, SCANNING, ENGINEERING, RESEARCH) gain XP on every completed action, leveling through 5 bands; higher skill levels reduce task duration via power scaling.
|
||||||
- Role-based starting levels: PILOTs start NAVIGATION L2, SCIENTISTS start SCANNING+RESEARCH L2, etc. Recruits discovered during exploration get the same role-based starting profile.
|
- Role-based starting levels: PILOTs start NAVIGATION L2, SCIENTISTS start SCANNING+RESEARCH L2, etc. Recruits discovered during exploration get the same role-based starting profile.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Trait Outcome Effects Implementation Plan (PR C — Gitea #43, v0.12.0)
|
||||||
|
|
||||||
|
> **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:** The two remaining good traits (FINDS HIDDEN THINGS, TALKS TO ANYONE) get deterministic mechanical effects in the explore resolver.
|
||||||
|
|
||||||
|
**Architecture:** A small change to the explore resolver's seeded stream (PR A plan Task 4a) plus the recruit-chance constant; no new tables.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)
|
||||||
|
|
||||||
|
**Spec:** docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md (§5)
|
||||||
|
|
||||||
|
**Upstream:** Builds on the post-v0.11.0 state; the explore resolver it modifies is defined in the PR A plan (`resolveExploreTask`, Task 4a); task numbering continues from the PR B plan (12–16).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (`## v<version> — YYYY-MM-DD HH:MM`); local verify ONLY `npx tsc --noEmit`, `npx vitest run`, `npx playwright test --list` (e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v<version> on master → manual deploy `docker compose up -d --build` + verify /api/version at https://space-wanderer.dev.kkhost.pl.
|
||||||
|
- DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever.
|
||||||
|
- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it).
|
||||||
|
- Single-player by design: NO auth, accounts, or multiplayer, ever.
|
||||||
|
- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness.
|
||||||
|
- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts.
|
||||||
|
5. Seeded-stream draw order must be fixed regardless of traits: an extra haul roll for FINDS HIDDEN THINGS shifts later draws on that per-planet stream — pin the exploration outcome as a pure function of (seed, planetId, assigned crew) with determinism tests both with and without the trait.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 17: Trait Outcome Effects — extra haul item + recruit chance (PR C — Gitea issue #43)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `lib/game/save.ts` — modify `resolveExploreTask` (defined in PR A, Task 4a): insert the FINDS HIDDEN THINGS extra haul-item roll between the regular haul and the recruit roll on the per-planet seeded stream; switch the recruit chance from the hardcoded 0.18 to the `RECRUIT_CHANCE_TALKER` / `RECRUIT_CHANCE_BASE` constants from `crew-costs.ts`
|
||||||
|
- `lib/game/save.test.ts` — extend the existing `describe('resolveExploreTask (A/B against explorePlanet)')` block with three new `it` cases
|
||||||
|
- `lib/game/crew-costs.ts` (created in PR A, Task 1) — consume `RECRUIT_CHANCE_BASE` (0.18) and `RECRUIT_CHANCE_TALKER` (0.30); no new constants added
|
||||||
|
- `lib/game/crew.ts` — add an optional `chance?: number` parameter to `rollCrewMember` (JC-30); default `CREW_CHANCE` so the no-trait path is bit-identical
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- **Consumes:** `explorationRng(seed, planetId)`, `rollExploration(rng)`, `rollCrewMember(rng, takenNames)` — all already in `save.ts` from PR A; `RECRUIT_CHANCE_BASE` / `RECRUIT_CHANCE_TALKER` from `./crew-costs`; the `Task` row's `leadCrewId` and `helpers` (assigned crew); crew trait columns (`traitGood` / `traitBad`) from the `crew` table
|
||||||
|
- **Produces:** `resolveExploreTask(db, task) → Promise<{outcome: FoundItem[]; crewFound: CrewDto | null}>` — same signature and return shape; the trait-modified draw order is internal and invisible to callers (`explorePlanet`, the tick resolver)
|
||||||
|
|
||||||
|
1. - [ ] **Write tests** — extend the `resolveExploreTask` describe block in `lib/game/save.test.ts`; add `import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER } from './crew-costs'` to the import. Three new `it` cases:
|
||||||
|
- **Regression pin** — an explore with NO trait-bearing crew assigned produces a byte-identical haul + recruit outcome to the pre-change (PR A) behavior for the same seed and planet.
|
||||||
|
- **Review Focus 5 pin** — same seed + same planet; compare assignment [A] (no traits) vs [A, B] where B has FINDS HIDDEN THINGS: the first N haul items are identical between the two, the FHT run has exactly one extra item, and the whole outcome (haul + recruit) is reproducible across repeated runs of the same save.
|
||||||
|
- **TALKS TO ANYONE constant pin** — with a TALKS TO ANYONE member assigned the recruit roll uses 0.30: assert `RECRUIT_CHANCE_BASE === 0.18` and `RECRUIT_CHANCE_TALKER === 0.30`, and (via a seeded case or by asserting the exact constant is consumed by `rollCrewMember`) that the TALKS TO ANYONE path selects 0.30.
|
||||||
|
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the three new tests fail: the current code has no extra haul roll and uses the hardcoded 0.18 recruit chance.
|
||||||
|
3. - [ ] **Implement** — in `resolveExploreTask` (`lib/game/save.ts`): read assigned crew ids from `task.leadCrewId` + `task.helpers`; load their trait columns; set `hasFHT` / `hasTalker` booleans; if `hasFHT`, call `rollExploration(rng)` one additional time on the same seeded stream and append the result to `outcome`; pass `hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE` as the recruit chance to `rollCrewMember` (add an optional `chance` param defaulting to `RECRUIT_CHANCE_BASE` if not already present — see JC-30).
|
||||||
|
4. - [ ] **Run — expect green**: `npx vitest run lib/game/save.test.ts` → all tests pass (the three new ones plus all pre-existing PR A tests).
|
||||||
|
5. - [ ] **Commit**: `git commit -m "feat(game): trait outcome effects — extra haul item + recruit chance (#43)"`
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
|
||||||
|
- JC-29 — Trait-name matching uses the literal strings `'FINDS HIDDEN THINGS'` and `'TALKS TO ANYONE'` from the design spec §5, checking both `traitGood` and `traitBad` columns (defensive; in practice both are good traits). If PR A introduced trait-name constants in `crew-costs.ts` or elsewhere, use those instead — verify at implementation time.
|
||||||
|
- JC-30 — `rollCrewMember`'s current signature (PR A code is the source of truth) may or may not accept a chance parameter. If 0.18 is hardcoded inside the function, add an optional `chance?: number` param defaulting to `RECRUIT_CHANCE_BASE` so the no-trait path is bit-identical; if it already takes one, just pass the correct value.
|
||||||
|
|
||||||
|
### Task 18: Ship v0.12.0 (PR C — Gitea issue #43)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docs/superpowers/plans/2026-10-01-trait-outcome-effects.md` — this plan (commit on the PR C branch; the spec doc is unchanged — already committed in PR A)
|
||||||
|
- `package.json` (version bump 0.11.0 → 0.12.0)
|
||||||
|
- `CHANGELOG.md` (new top entry)
|
||||||
|
|
||||||
|
This is a process task that ships the PR C already built by Task 17. No failing-test step; ends in a verification gate.
|
||||||
|
|
||||||
|
1. - [ ] **Commit the PR C plan doc on the PR C branch** (`feat/trait-outcome-effects`, created in Task 17) — only this plan file needs committing (spec unchanged): `git add docs/superpowers/plans/2026-10-01-trait-outcome-effects.md && git commit -m "docs: trait outcome effects implementation plan (#43)"`.
|
||||||
|
2. - [ ] **Version bump** — `package.json` → `"version": "0.12.0"` (from 0.11.0). `CHANGELOG.md`: new top entry `## v0.12.0 — YYYY-MM-DD HH:MM` (fill date/time at execution) with bullets: FINDS HIDDEN THINGS grants +1 exploration haul item; TALKS TO ANYONE raises crew-recruit chance 18% → 30%.
|
||||||
|
3. - [ ] **Local verify — ONLY these three (e2e is CI-only, never run locally):** `npx tsc --noEmit` (clean), `npx vitest run` (all pass), `npx playwright test --list` (every spec compiles + lists). Do NOT run `npx playwright test`.
|
||||||
|
4. - [ ] **Push + open the PR to master** — `git push -u origin feat/trait-outcome-effects`, then open a PR to `master` on Gitea (repo `OpenCode/SpaceWanderer`). Credentials per JC-20 (gitea MCP preferred, else `GITEA_TOKEN` from repo-root `.env`).
|
||||||
|
5. - [ ] **Auto-merge on green** — `POST /repos/OpenCode/SpaceWanderer/pulls/<n>/merge` body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. Wait for CI (~7 min); poll until `merged_at` is set. If CI is red, **STOP and report the failure** — do not fix it in this step.
|
||||||
|
6. - [ ] **Tag on master** — `git checkout master && git pull && git tag v0.12.0 && git push origin v0.12.0`.
|
||||||
|
7. - [ ] **Manual deploy** — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` reports 0.12.0.
|
||||||
|
8. - [ ] **Close Gitea issue #43** — close #43 with a comment summarizing the shipped state (v0.12.0, tag v0.12.0, live deploy verified).
|
||||||
|
|
||||||
|
**Verification gate (whole task):** `/api/version` = 0.12.0 AND Gitea issue #43 is closed.
|
||||||
|
|
||||||
|
**Judgment calls** — none (process task mirroring the PR A plan's Task 11; no new judgment calls arise here).
|
||||||
+7
-6
@@ -49,12 +49,13 @@ export async function resolveAction(page: Page, path: string, data: Record<strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||||||
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
// undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
|
||||||
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
// lib/game/world.ts #51), so fresh territory is always available — picking the FIRST edge
|
||||||
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
// instead ping-pongs between exhausted systems, because edges come back in insertion order and
|
||||||
// led here. Falls back to any non-current adjacent system if none is undiscovered. The jump is
|
// the oldest route out of a system is usually the one that led here. Falls back to any
|
||||||
// queued with a default one-person crew and ticked to completion (resolveAction) before this
|
// non-current adjacent system if none is undiscovered. The jump is queued with a default
|
||||||
// returns, so the caller is already standing in the target system, not merely en route to it.
|
// one-person crew and ticked to completion (resolveAction) before this returns, so the caller
|
||||||
|
// is already standing in the target system, not merely en route to it.
|
||||||
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
const currentId = world.systems.find((s) => s.current)!.id;
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
|
|||||||
@@ -199,6 +199,46 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
|||||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#51: jumping into undiscovered territory mints 1–4 new systems, each routed to the target, and the graph stays connected', async ({ page }) => {
|
||||||
|
await drainQueue(page);
|
||||||
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
|
||||||
|
const after = await jumpToFreshSystem(page);
|
||||||
|
const target = after.systems.find((s) => s.current)!;
|
||||||
|
|
||||||
|
const beforeIds = new Set(before.systems.map((s) => s.id));
|
||||||
|
const fresh = after.systems.filter((s) => !beforeIds.has(s.id));
|
||||||
|
// #51: seeded-random 1–4 new systems (the frontier guarantee ensures at least one)
|
||||||
|
expect(fresh.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(fresh.length).toBeLessThanOrEqual(4);
|
||||||
|
// all new systems are undiscovered
|
||||||
|
expect(fresh.every((s) => !s.discovered)).toBe(true);
|
||||||
|
// each new system is directly routed to the jump target
|
||||||
|
for (const n of fresh) {
|
||||||
|
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true);
|
||||||
|
}
|
||||||
|
// the whole graph stays connected: BFS from the target reaches every system
|
||||||
|
const adj = new Map<number, number[]>();
|
||||||
|
for (const [a, b] of after.edges) {
|
||||||
|
if (!adj.has(a)) adj.set(a, []);
|
||||||
|
if (!adj.has(b)) adj.set(b, []);
|
||||||
|
adj.get(a)!.push(b);
|
||||||
|
adj.get(b)!.push(a);
|
||||||
|
}
|
||||||
|
const seen = new Set([target.id]);
|
||||||
|
const queue = [target.id];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
for (const nb of adj.get(cur) ?? []) {
|
||||||
|
if (!seen.has(nb)) {
|
||||||
|
seen.add(nb);
|
||||||
|
queue.push(nb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen.size).toBe(after.systems.length); // no orphaned systems
|
||||||
|
});
|
||||||
|
|
||||||
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
||||||
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
|
||||||
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
|||||||
+8
-6
@@ -90,12 +90,14 @@ export function generateStartingCrew(rng: () => number): CrewMember[] {
|
|||||||
return crew
|
return crew
|
||||||
}
|
}
|
||||||
|
|
||||||
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws
|
// The exploration crew roll: with the given chance (defaults to CREW_CHANCE, matching
|
||||||
// from the SAME per-planet rng stream as the resource/part haul (called after it in
|
// RECRUIT_CHANCE_BASE in crew-costs) a member is found and joins the ship. Draws from the
|
||||||
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current
|
// SAME per-planet rng stream as the resource/part haul (called after it in save.ts), so the
|
||||||
// roster's names, keeping every name on the ship unique.
|
// outcome stays deterministic per save+planet. `taken` is the current roster's names, keeping
|
||||||
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null {
|
// every name on the ship unique. The chance is raised to RECRUIT_CHANCE_TALKER when the
|
||||||
if (rng() >= CREW_CHANCE) return null
|
// assigned team includes TALKS TO ANYONE (wired in save.ts, #43).
|
||||||
|
export function rollCrewMember(rng: () => number, taken: Set<string>, chance: number = CREW_CHANCE): CrewMember | null {
|
||||||
|
if (rng() >= chance) return null
|
||||||
const role = pick(rng, ROLES)
|
const role = pick(rng, ROLES)
|
||||||
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
|
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-5
@@ -5,13 +5,13 @@ import { afterAll, describe, expect, it } from 'vitest'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
import { CREW_CHANCE, ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||||
import { STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||||
import { MIN_AVAILABLE } from './missions'
|
import { MIN_AVAILABLE } from './missions'
|
||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
import { TECHS } from './tech'
|
import { TECHS } from './tech'
|
||||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, makePowerOfFor, queueStateFromSave, resolveJumpTask, resolveExploreTask, runTurn, saveTaskRow, type WorldState } from './save'
|
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, makePowerOfFor, queueStateFromSave, resolveJumpTask, resolveExploreTask, runTurn, saveTaskRow, type WorldState } from './save'
|
||||||
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
|
import { addCrew, buildWorldFixture, insertTaskRow, T0 } from './test-fixtures'
|
||||||
import { queueTask, type Task } from './tasks'
|
import { queueTask, type Task } from './tasks'
|
||||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
@@ -114,9 +114,10 @@ describe('jumpToSystem success path', () => {
|
|||||||
expect(currentOf(after).id).toBe(target.id)
|
expect(currentOf(after).id).toBe(target.id)
|
||||||
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
|
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
|
||||||
|
|
||||||
// Expansion: 4 new undiscovered neighbours around the target, each routed to it.
|
// #51: 1–4 new undiscovered systems minted around the target, each routed to it.
|
||||||
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
|
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
|
||||||
expect(fresh).toHaveLength(4)
|
expect(fresh.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(fresh.length).toBeLessThanOrEqual(4)
|
||||||
expect(fresh.every((s) => !s.discovered)).toBe(true)
|
expect(fresh.every((s) => !s.discovered)).toBe(true)
|
||||||
for (const n of fresh) {
|
for (const n of fresh) {
|
||||||
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
|
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
|
||||||
@@ -466,6 +467,167 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
|
|||||||
await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
|
await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
|
||||||
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
|
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('no trait-bearing assigned crew ⇒ identical haul and recruit gate to the un-assigned path (regression pin)', async () => {
|
||||||
|
// World A: un-assigned (syntheticTask, leadCrewId: 0, no crew rows → taken = Set())
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const fa = await buildWorldFixture(a)
|
||||||
|
const resultA = await explorePlanet(a, fa.p0)
|
||||||
|
|
||||||
|
// World B: one assigned crew member with non-FHT/non-TALKER traits
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
const fb = await buildWorldFixture(b)
|
||||||
|
const [saveB] = await b.db.select().from(schema.saves).limit(1)
|
||||||
|
const [c1] = await b.db
|
||||||
|
.insert(schema.crew)
|
||||||
|
.values({ saveId: saveB.id, name: 'QUICK GUY', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
|
||||||
|
.returning({ id: schema.crew.id })
|
||||||
|
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, saveB.id))
|
||||||
|
|
||||||
|
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fb.p0 }), leadCrewId: c1.id, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultB = await resolveExploreTask(b, taskB)
|
||||||
|
|
||||||
|
// The haul is draw-order-stable (drawn before any crew interaction) — must be identical.
|
||||||
|
expect(resultB.outcome).toEqual(resultA.outcome)
|
||||||
|
|
||||||
|
// The recruit gate (first draw after the haul) uses the same rng value → same found/null result.
|
||||||
|
// (The recruit NAME can differ because the taken set differs: world A has no crew rows,
|
||||||
|
// world B has QUICK GUY. We assert gate equality, then recompute the recruit for
|
||||||
|
// world B's taken set to prove determinism.)
|
||||||
|
expect(resultB.crewFound === null).toBe(resultA.crewFound === null)
|
||||||
|
|
||||||
|
if (resultB.crewFound !== null) {
|
||||||
|
// Recompute: same rng stream, same taken set as world B.
|
||||||
|
const rng = explorationRng('42', fb.p0)
|
||||||
|
rollExploration(rng) // consume the haul draws first, exactly as save.ts does
|
||||||
|
const expected = rollCrewMember(rng, new Set(['QUICK GUY']))!
|
||||||
|
expect(resultB.crewFound!.name).toBe(expected.name)
|
||||||
|
expect(resultB.crewFound!.role).toBe(expected.role)
|
||||||
|
expect([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad]).toEqual([expected.traitGood, expected.traitBad])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FINDS HIDDEN THINGS: the FHT team gets exactly one extra haul item — the no-FHT haul is a prefix (Review Focus 5)', async () => {
|
||||||
|
// Find a planet id whose SECOND rollExploration draw yields exactly 1 item (so the
|
||||||
|
// FHT delta is concretely +1 item). Mirrors the explorePlanet crew-finds id-scanning pattern.
|
||||||
|
let fhtPlanetId = -1
|
||||||
|
for (let id = 1; id <= 50 && fhtPlanetId === -1; id++) {
|
||||||
|
const rng = explorationRng('42', id)
|
||||||
|
rollExploration(rng) // first haul (consumes its draws)
|
||||||
|
const second = rollExploration(rng) // second haul (the FHT extra)
|
||||||
|
if (second.length === 1) fhtPlanetId = id
|
||||||
|
}
|
||||||
|
expect(fhtPlanetId).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Hand-built world (same pattern as the crew-finds test): seed '42', one system,
|
||||||
|
// planets P1..P{fhtPlanetId} so the target planet has id = fhtPlanetId.
|
||||||
|
const setupWorld = async (db: GameDb, withFht: boolean) => {
|
||||||
|
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
||||||
|
const [sys] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
|
||||||
|
for (let i = 1; i <= fhtPlanetId; i++) {
|
||||||
|
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
|
||||||
|
}
|
||||||
|
// Always one non-FHT lead; add an FHT helper when withFht.
|
||||||
|
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
|
||||||
|
const helperIds: number[] = []
|
||||||
|
if (withFht) {
|
||||||
|
const [helper] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'FINDS', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
|
||||||
|
helperIds.push(helper.id)
|
||||||
|
}
|
||||||
|
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
|
||||||
|
return { leadId: lead.id, helperIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
// World A: no FHT in the team → one haul roll.
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const worldA = await setupWorld(a, false)
|
||||||
|
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultA = await resolveExploreTask(a, taskA)
|
||||||
|
|
||||||
|
// World B: FHT in the team (as helper) → two haul rolls.
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
const worldB = await setupWorld(b, true)
|
||||||
|
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldB.leadId, helpers: worldB.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultB = await resolveExploreTask(b, taskB)
|
||||||
|
|
||||||
|
// The no-FHT haul is a prefix of the FHT haul.
|
||||||
|
const noFhtHaul = resultA.outcome
|
||||||
|
const fhtHaul = resultB.outcome
|
||||||
|
expect(fhtHaul.slice(0, noFhtHaul.length)).toEqual(noFhtHaul)
|
||||||
|
// The FHT run has EXACTLY ONE more haul item (the second rollExploration yielded 1 item).
|
||||||
|
expect(fhtHaul.length).toBe(noFhtHaul.length + 1)
|
||||||
|
|
||||||
|
// Reproducibility: a second independent fresh file with the same inserts produces
|
||||||
|
// the identical outcome (haul + recruit).
|
||||||
|
const { db: c } = await freshFile()
|
||||||
|
const worldC = await setupWorld(c, true)
|
||||||
|
const taskC: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldC.leadId, helpers: worldC.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultC = await resolveExploreTask(c, taskC)
|
||||||
|
expect(resultC.outcome).toEqual(resultB.outcome)
|
||||||
|
if (resultB.crewFound !== null) {
|
||||||
|
expect(resultC.crewFound).not.toBeNull()
|
||||||
|
expect(resultC.crewFound!.name).toBe(resultB.crewFound!.name)
|
||||||
|
expect(resultC.crewFound!.role).toBe(resultB.crewFound!.role)
|
||||||
|
expect([resultC.crewFound!.traitGood, resultC.crewFound!.traitBad]).toEqual([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad])
|
||||||
|
} else {
|
||||||
|
expect(resultC.crewFound).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('TALKS TO ANYONE: recruit chance raised 18% → 30% — constant consumed by rollCrewMember, end-to-end on a discriminating planet', async () => {
|
||||||
|
// 1. Pin the constants.
|
||||||
|
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
|
||||||
|
expect(RECRUIT_CHANCE_TALKER).toBe(0.30)
|
||||||
|
// Guard the two separately-declared 0.18 constants: the no-trait recruit path passes
|
||||||
|
// RECRUIT_CHANCE_BASE explicitly while rollCrewMember's default uses CREW_CHANCE — if
|
||||||
|
// they ever diverge the no-trait outcome would silently drift from its bit-identity pin.
|
||||||
|
expect(RECRUIT_CHANCE_BASE).toBe(CREW_CHANCE)
|
||||||
|
|
||||||
|
// 2. Prove the constant is consumed by rollCrewMember: a fixed rng in [0.18, 0.30)
|
||||||
|
// (a member is found iff rng() < chance).
|
||||||
|
const rng024 = () => 0.24
|
||||||
|
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_TALKER)).not.toBeNull() // 0.24 < 0.30 → found
|
||||||
|
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_BASE)).toBeNull() // 0.24 >= 0.18 → null
|
||||||
|
expect(rollCrewMember(rng024, new Set<string>())).toBeNull() // default = CREW_CHANCE = 0.18 → null
|
||||||
|
|
||||||
|
// 3. End-to-end: find a planet whose recruit gate value (the rng draw after the haul)
|
||||||
|
// falls in [0.18, 0.30) — TALKER finds a member, non-talker does not.
|
||||||
|
let talkerPlanetId = -1
|
||||||
|
for (let id = 1; id <= 100 && talkerPlanetId === -1; id++) {
|
||||||
|
const rng = explorationRng('42', id)
|
||||||
|
rollExploration(rng) // consume the haul draws
|
||||||
|
const gate = rng() // the recruit gate draw
|
||||||
|
if (gate >= RECRUIT_CHANCE_BASE && gate < RECRUIT_CHANCE_TALKER) talkerPlanetId = id
|
||||||
|
}
|
||||||
|
expect(talkerPlanetId).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Hand-built world (same pattern as the FHT test above).
|
||||||
|
const setupWorld = async (db: GameDb, talker: boolean) => {
|
||||||
|
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
||||||
|
const [sys] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
|
||||||
|
for (let i = 1; i <= talkerPlanetId; i++) {
|
||||||
|
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
|
||||||
|
}
|
||||||
|
const traitGood = talker ? 'TALKS TO ANYONE' : 'QUICK HANDS'
|
||||||
|
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood, traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
|
||||||
|
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
|
||||||
|
return { leadId: lead.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// World A: non-talker crew → gate >= 0.18 → crewFound == null.
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const worldA = await setupWorld(a, false)
|
||||||
|
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultA = await resolveExploreTask(a, taskA)
|
||||||
|
expect(resultA.crewFound).toBeNull()
|
||||||
|
|
||||||
|
// World B: talker crew → gate < 0.30 → crewFound != null.
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
const worldB = await setupWorld(b, true)
|
||||||
|
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldB.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
const resultB = await resolveExploreTask(b, taskB)
|
||||||
|
expect(resultB.crewFound).not.toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
|
// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
|
||||||
|
|||||||
+42
-7
@@ -3,7 +3,7 @@ import type { GameDb } from '@/lib/db/client'
|
|||||||
import { persist } from '@/lib/db/client'
|
import { persist } from '@/lib/db/client'
|
||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||||
import { ACTION_SKILL, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
import { ACTION_SKILL, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||||
import { resolveResearchTask, ResearchError } from './research'
|
import { resolveResearchTask, ResearchError } from './research'
|
||||||
@@ -354,15 +354,33 @@ export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
|
|||||||
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
|
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
|
||||||
|
|
||||||
if (!target.discovered) {
|
if (!target.discovered) {
|
||||||
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
|
// #51 — growing the chart: a seeded-random 1–4 leg ring around the newly reached
|
||||||
// system, so there is always somewhere to jump next and a route back.
|
// system; each leg may attach an existing system (closing a loop) instead of minting.
|
||||||
const rng = expansionRng(save.seed, save.turn)
|
const rng = expansionRng(save.seed, save.turn)
|
||||||
const takenNames = new Set(systems.map((s) => s.name))
|
const takenNames = new Set(systems.map((s) => s.name))
|
||||||
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
|
|
||||||
|
// Edge keys already touching any pair of this save's systems — an attach must not
|
||||||
|
// duplicate one (the PK on (fromSystem, toSystem) would reject it).
|
||||||
|
const systemIds = new Set(systems.map((s) => s.id))
|
||||||
|
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||||
|
const existingEdgeKeys = new Set(
|
||||||
|
routes
|
||||||
|
.filter((r) => systemIds.has(r.fromSystem) && systemIds.has(r.toSystem))
|
||||||
|
.map((r) => `${Math.min(r.fromSystem, r.toSystem)}-${Math.max(r.fromSystem, r.toSystem)}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const candidates = systems.filter((s) => s.id !== target.id)
|
||||||
|
const ring = generateRing(rng, target.x, target.y, takenNames, systems, candidates, existingEdgeKeys, target.id)
|
||||||
const ringIds: number[] = []
|
const ringIds: number[] = []
|
||||||
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
|
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
|
||||||
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
|
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
|
||||||
await insertRoutes(db, ring.edges, resolve)
|
await insertRoutes(db, ring.edges, resolve)
|
||||||
|
// Loop-closing attaches: a two-way route from the anchor to each attached candidate.
|
||||||
|
for (const ci of ring.attaches) {
|
||||||
|
const cand = candidates[ci]!
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: target.id, toSystem: cand.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: cand.id, toSystem: target.id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||||
@@ -552,9 +570,26 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
|
|||||||
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
|
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
|
||||||
|
|
||||||
const rng = explorationRng(save.seed, target.id)
|
const rng = explorationRng(save.seed, target.id)
|
||||||
const outcome = rollExploration(rng) // resource/part haul first…
|
let outcome = rollExploration(rng) // resource/part haul first…
|
||||||
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
|
|
||||||
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
|
// Trait outcome effects (#43): the assigned team (lead + helpers) can change how this
|
||||||
|
// per-planet stream is drawn. Read their traits from the roster (a plain select — no rng draw).
|
||||||
|
const assigned = new Set([task.leadCrewId, ...task.helpers].filter((id) => id > 0))
|
||||||
|
const crewRows = await db.db.select().from(schema.crew)
|
||||||
|
let hasFHT = false
|
||||||
|
let hasTalker = false
|
||||||
|
for (const c of crewRows) {
|
||||||
|
if (!assigned.has(c.id)) continue
|
||||||
|
if (c.traitGood === 'FINDS HIDDEN THINGS' || c.traitBad === 'FINDS HIDDEN THINGS') hasFHT = true
|
||||||
|
if (c.traitGood === 'TALKS TO ANYONE' || c.traitBad === 'TALKS TO ANYONE') hasTalker = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FINDS HIDDEN THINGS: one extra haul roll on the same seeded stream, appended (spec §5).
|
||||||
|
if (hasFHT) outcome = [...outcome, ...rollExploration(rng)]
|
||||||
|
|
||||||
|
const takenNames = new Set(crewRows.map((c) => c.name))
|
||||||
|
// TALKS TO ANYONE: recruit chance 30% instead of the base 18% (base === crew.ts CREW_CHANCE).
|
||||||
|
const foundMember = rollCrewMember(rng, takenNames, hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE)
|
||||||
|
|
||||||
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
|
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
|
||||||
for (const item of outcome) {
|
for (const item of outcome) {
|
||||||
|
|||||||
+110
-13
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, rollExploration } from './world'
|
import { ATTACH_PROB, EXPAND_MAX, EXPAND_MIN, bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, ringEdges, rollExploration } from './world'
|
||||||
|
import type { WorldSystem } from './world'
|
||||||
|
|
||||||
describe('generateStartRegion', () => {
|
describe('generateStartRegion', () => {
|
||||||
const region = generateStartRegion(mulberry32(42))
|
const region = generateStartRegion(mulberry32(42))
|
||||||
@@ -47,37 +48,133 @@ describe('generateStartRegion', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ringEdges (#51 dedup)', () => {
|
||||||
|
it('n=0 is empty and n=1 is the single anchor↔member edge (the old wheel emitted a [1,1] self-loop here)', () => {
|
||||||
|
expect(ringEdges(0)).toEqual([])
|
||||||
|
expect(ringEdges(1)).toEqual([[0, 1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n=2 is the complete triangle (the old wheel emitted the [1,2] edge twice, as [1,2] and [2,1])', () => {
|
||||||
|
expect(ringEdges(2)).toEqual([[0, 1], [0, 2], [1, 2]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n>=3 stays the simple wheel, byte-identical to the legacy output', () => {
|
||||||
|
expect(ringEdges(3)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 1]])
|
||||||
|
expect(ringEdges(4)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 1]])
|
||||||
|
expect(ringEdges(6)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 5], [0, 5], [5, 6], [0, 6], [6, 1]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('generateRing (expansion)', () => {
|
describe('generateRing (expansion)', () => {
|
||||||
it('adds four systems spaced away from the anchor and existing positions', () => {
|
// A scripted rng: returns the given values in order, then a constant fallback for the rest
|
||||||
|
// (placeRing's position jitter/radius draws) so a test can pin the leg-drawing phase exactly.
|
||||||
|
function scriptedRng(script: number[], fallback = 0.5): () => number {
|
||||||
|
let i = 0
|
||||||
|
return () => (i < script.length ? script[i++] : fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('#51: mints a seeded-random 1–4 new systems, and all four widths occur across seeds', () => {
|
||||||
|
const counts = new Set<number>()
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN)
|
||||||
|
expect(ring.systems.length).toBeLessThanOrEqual(EXPAND_MAX)
|
||||||
|
counts.add(ring.systems.length)
|
||||||
|
}
|
||||||
|
expect(counts).toEqual(new Set([EXPAND_MIN, EXPAND_MIN + 1, EXPAND_MIN + 2, EXPAND_MAX]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with no attach candidates every leg mints, so systems.length is the leg count and attaches is empty', () => {
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
expect(ring.attaches).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mints are spaced away from the anchor and existing positions and never reuse a taken name', () => {
|
||||||
const takenNames = new Set(['Kepler Drift'])
|
const takenNames = new Set(['Kepler Drift'])
|
||||||
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||||
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
|
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
|
||||||
|
|
||||||
expect(ring.systems).toHaveLength(4)
|
|
||||||
const ringNames = ring.systems.map((s) => s.name)
|
const ringNames = ring.systems.map((s) => s.name)
|
||||||
for (const s of ring.systems) {
|
for (const s of ring.systems) {
|
||||||
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
|
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
|
||||||
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
|
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
|
||||||
}
|
}
|
||||||
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set
|
expect(new Set(ringNames).size).toBe(ringNames.length) // and makeName claimed them into the set
|
||||||
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
|
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
|
||||||
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle
|
})
|
||||||
for (const [a, b] of ring.edges) {
|
|
||||||
expect(a).toBeGreaterThanOrEqual(0)
|
it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => {
|
||||||
expect(a).toBeLessThan(5)
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
expect(b).toBeGreaterThanOrEqual(0)
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
expect(b).toBeLessThan(5)
|
const n = ring.systems.length
|
||||||
|
expect(ring.edges).toEqual(ringEdges(n)) // anchor↔each member + the member cycle
|
||||||
|
for (const [a, b] of ring.edges) {
|
||||||
|
expect(a).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(a).toBeLessThan(n + 1)
|
||||||
|
expect(b).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(b).toBeLessThan(n + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never reuses a taken name', () => {
|
it('attaches existing systems to close loops, but never one already routed to the anchor', () => {
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 100, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 200, name: 'B', x: 20, y: 20 },
|
||||||
|
{ id: 300, name: 'C', x: 30, y: 30 },
|
||||||
|
]
|
||||||
|
// The anchor (id 7) is already routed to candidate 200 — that one can never be attached.
|
||||||
|
const existing = new Set(['7-200'])
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [], candidates, existing, 7)
|
||||||
|
for (const ci of ring.attaches) {
|
||||||
|
expect(ci).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(ci).toBeLessThan(candidates.length)
|
||||||
|
expect(candidates[ci].id).not.toBe(200) // the already-routed candidate is never re-routed
|
||||||
|
}
|
||||||
|
expect(new Set(ring.attaches).size).toBe(ring.attaches.length) // attaches are unique
|
||||||
|
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN) // the frontier still grows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('frontier guarantee: a scripted all-attach run still mints exactly one system', () => {
|
||||||
|
// Force legCount=4 (first draw 0.8) and all four legs to attach to four DISTINCT candidates
|
||||||
|
// (attach-check 0.1 < ATTACH_PROB, ci draws 0.1/0.3/0.6/0.9 → candidates 0/1/2/3). Without the
|
||||||
|
// guarantee this would mint zero systems and strand the chart; with it, one attach becomes a mint.
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 1, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 2, name: 'B', x: 20, y: 20 },
|
||||||
|
{ id: 3, name: 'C', x: 30, y: 30 },
|
||||||
|
{ id: 4, name: 'D', x: 40, y: 40 },
|
||||||
|
]
|
||||||
|
const ring = generateRing(scriptedRng([0.8, 0.1, 0.1, 0.1, 0.3, 0.1, 0.6, 0.1, 0.9]), 50, 50, new Set(), [], candidates, new Set(), 99)
|
||||||
|
expect(ring.systems.length).toBe(1) // one attach converted into a mint
|
||||||
|
expect(ring.attaches).toEqual([0, 1, 2]) // the last-drawn attach (index 3) became the mint
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic per seed and differs across seeds (candidates + existing edges included)', () => {
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 100, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 200, name: 'B', x: 20, y: 20 },
|
||||||
|
]
|
||||||
|
const existing = new Set(['7-100'])
|
||||||
|
const positions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||||
|
const a = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
const b = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
expect(b).toEqual(a) // same seed + inputs → identical region
|
||||||
|
const c = generateRing(mulberry32(12), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
expect(JSON.stringify(c)).not.toBe(JSON.stringify(a))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makeName never reuses a taken name', () => {
|
||||||
const rng = mulberry32(1)
|
const rng = mulberry32(1)
|
||||||
const taken = new Set<string>()
|
const taken = new Set<string>()
|
||||||
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||||
expect(taken.size).toBe(40)
|
expect(taken.size).toBe(40)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('terminates with numbered variants once all 128 base names are taken', () => {
|
it('makeName terminates with numbered variants once all 128 base names are taken', () => {
|
||||||
const rng = mulberry32(9)
|
const rng = mulberry32(9)
|
||||||
const taken = new Set<string>()
|
const taken = new Set<string>()
|
||||||
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
|
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
|
||||||
|
|||||||
+54
-5
@@ -11,7 +11,13 @@ export const START_X = 50
|
|||||||
export const START_Y = 50
|
export const START_Y = 50
|
||||||
|
|
||||||
const START_RING = 6 // neighbours around the starting system on a new game
|
const START_RING = 6 // neighbours around the starting system on a new game
|
||||||
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
|
// #51 — the spread limit: jumping into an undiscovered system mints a seeded-random number of
|
||||||
|
// new systems in [EXPAND_MIN, EXPAND_MAX]. Each "leg" can instead attach an already-known
|
||||||
|
// system (closing a loop) with probability ATTACH_PROB, so the chart grows slower and more
|
||||||
|
// circular. EXPAND_RING (the old fixed count) is replaced by this range.
|
||||||
|
export const EXPAND_MIN = 1
|
||||||
|
export const EXPAND_MAX = 4
|
||||||
|
export const ATTACH_PROB = 0.35
|
||||||
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
|
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
|
||||||
|
|
||||||
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
|
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
|
||||||
@@ -95,7 +101,13 @@ function placeRing(
|
|||||||
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
|
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
|
||||||
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
|
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
|
||||||
// get back). Pairs are indices into [anchor, ...members].
|
// get back). Pairs are indices into [anchor, ...members].
|
||||||
function ringEdges(n: number): [number, number][] {
|
// #51 — small-n special cases: n=0 is empty, n=1 is the single spoke, n=2 is the complete
|
||||||
|
// triangle (the old wheel formula produced a self-loop at n=1 and a duplicate edge at n=2).
|
||||||
|
// n≥3 is the unchanged wheel.
|
||||||
|
export function ringEdges(n: number): [number, number][] {
|
||||||
|
if (n === 0) return []
|
||||||
|
if (n === 1) return [[0, 1]]
|
||||||
|
if (n === 2) return [[0, 1], [0, 2], [1, 2]]
|
||||||
const edges: [number, number][] = []
|
const edges: [number, number][] = []
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
edges.push([0, i + 1])
|
edges.push([0, i + 1])
|
||||||
@@ -121,19 +133,56 @@ export function generateStartRegion(rng: () => number): StartRegion {
|
|||||||
export interface RingRegion {
|
export interface RingRegion {
|
||||||
systems: WorldSystem[]
|
systems: WorldSystem[]
|
||||||
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
||||||
|
attaches: number[] // indices into the attachCandidates array that were attached (loop-closed) rather than minted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
||||||
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
|
// neighbours around it. #51 — the ring width is a seeded-random 1–4 legs; each leg may
|
||||||
|
// instead attach an already-known system (closing a loop) with probability ATTACH_PROB.
|
||||||
|
// `takenNames`/`takenPositions` are every system already in the save. `attachCandidates`
|
||||||
|
// are the systems eligible for loop-closing (all save systems except the anchor).
|
||||||
|
// `existingEdgeKeys` is the set of `${min(a,b)}-${max(a,b)}` keys for routes already
|
||||||
|
// touching the anchor, so an attach never duplicates one. `anchorId` is the DB id of the
|
||||||
|
// anchor system (used to build edge keys).
|
||||||
export function generateRing(
|
export function generateRing(
|
||||||
rng: () => number,
|
rng: () => number,
|
||||||
anchorX: number,
|
anchorX: number,
|
||||||
anchorY: number,
|
anchorY: number,
|
||||||
takenNames: Set<string>,
|
takenNames: Set<string>,
|
||||||
takenPositions: WorldSystem[],
|
takenPositions: WorldSystem[],
|
||||||
|
attachCandidates: (WorldSystem & { id: number })[] = [],
|
||||||
|
existingEdgeKeys: Set<string> = new Set(),
|
||||||
|
anchorId: number = 0,
|
||||||
): RingRegion {
|
): RingRegion {
|
||||||
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1))
|
||||||
return { systems, edges: ringEdges(systems.length) }
|
|
||||||
|
let mints = 0
|
||||||
|
const attachSet = new Set<number>()
|
||||||
|
let lastAttachCi = -1
|
||||||
|
|
||||||
|
for (let leg = 0; leg < legCount; leg++) {
|
||||||
|
let isAttach = false
|
||||||
|
if (attachCandidates.length > 0 && rng() < ATTACH_PROB) {
|
||||||
|
const ci = Math.floor(rng() * attachCandidates.length)
|
||||||
|
const a = Math.min(anchorId, attachCandidates[ci]!.id)
|
||||||
|
const b = Math.max(anchorId, attachCandidates[ci]!.id)
|
||||||
|
if (!existingEdgeKeys.has(`${a}-${b}`)) {
|
||||||
|
attachSet.add(ci)
|
||||||
|
lastAttachCi = ci
|
||||||
|
isAttach = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAttach) mints++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontier guarantee: at least one new system must be minted so the chart always grows.
|
||||||
|
if (mints === 0 && lastAttachCi >= 0) {
|
||||||
|
attachSet.delete(lastAttachCi)
|
||||||
|
mints = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const systems = placeRing(rng, anchorX, anchorY, mints, takenNames, takenPositions)
|
||||||
|
return { systems, edges: ringEdges(mints), attaches: [...attachSet] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
|
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
|
||||||
|
|||||||
+2
-2
@@ -149,7 +149,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Queue a scan of a planet or moon',
|
summary: 'Queue a scan of a planet or moon',
|
||||||
description:
|
description:
|
||||||
'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.',
|
'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the crew roll (~18% base, raised to 30% when the assigned team includes TALKS TO ANYONE) and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.',
|
||||||
operationId: 'explorePlanet',
|
operationId: 'explorePlanet',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -1183,7 +1183,7 @@ export const openApiSpec = {
|
|||||||
items: { $ref: '#/components/schemas/FoundItem' },
|
items: { $ref: '#/components/schemas/FoundItem' },
|
||||||
},
|
},
|
||||||
crewFound: {
|
crewFound: {
|
||||||
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response',
|
description: 'A crew member who joined the ship on this scan (~18% of scans, raised to 30% when the assigned team includes TALKS TO ANYONE), null when none — always null in the queueing response',
|
||||||
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.11.0",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user