From 90de8818f8af44ecf9c2fc0f98cdfdbf2a71470e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 6 Oct 2026 12:18:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?world:=20#51=20=E2=80=94=20ringEdges=20smal?= =?UTF-8?q?l-n=20fix=20+=20generateRing=20seeded=201=E2=80=934=20legs=20wi?= =?UTF-8?q?th=20attach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ringEdges: n=0 → [], n=1 → [[0,1]], n=2 → [[0,1],[0,2],[1,2]]; n≥3 wheel is byte-identical to the legacy output. - generateRing: new params attachCandidates, existingEdgeKeys, anchorId; legCount is seeded-random in [EXPAND_MIN, EXPAND_MAX] (1–4); each leg may attach a known system (closing a loop) with prob ATTACH_PROB instead of minting; frontier guarantee converts the last-drawn attach to a mint when all legs attached. - RingRegion gains attaches: number[] (candidate indices attached). - Remove the EXPAND_RING constant (replaced by EXPAND_MIN/EXPAND_MAX). --- lib/game/world.test.ts | 123 ++++++++++++++++++++++++++++++++++++----- lib/game/world.ts | 59 ++++++++++++++++++-- 2 files changed, 164 insertions(+), 18 deletions(-) diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts index bd8c7b6..66bd304 100644 --- a/lib/game/world.test.ts +++ b/lib/game/world.test.ts @@ -1,5 +1,6 @@ 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', () => { 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)', () => { - 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() + 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 takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }] const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions) - - expect(ring.systems).toHaveLength(4) const ringNames = ring.systems.map((s) => s.name) for (const s of ring.systems) { 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(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) - expect(ring.edges).toHaveLength(8) // anchor↔each + cycle - for (const [a, b] of ring.edges) { - expect(a).toBeGreaterThanOrEqual(0) - expect(a).toBeLessThan(5) - expect(b).toBeGreaterThanOrEqual(0) - expect(b).toBeLessThan(5) + }) + + it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => { + for (let seed = 1; seed <= 50; seed++) { + const ring = generateRing(mulberry32(seed), 50, 50, new Set(), []) + 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 taken = new Set() for (let i = 0; i < 40; i++) makeName(rng, taken) 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 taken = new Set() for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool diff --git a/lib/game/world.ts b/lib/game/world.ts index 4cf3c22..84730c7 100644 --- a/lib/game/world.ts +++ b/lib/game/world.ts @@ -11,7 +11,13 @@ export const START_X = 50 export const START_Y = 50 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 // 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 // cycle connecting consecutive members (so every system has ≥2 routes and you can always // 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][] = [] for (let i = 0; i < n; i++) { edges.push([0, i + 1]) @@ -121,19 +133,56 @@ export function generateStartRegion(rng: () => number): StartRegion { export interface RingRegion { systems: WorldSystem[] 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) -// 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( rng: () => number, anchorX: number, anchorY: number, takenNames: Set, takenPositions: WorldSystem[], + attachCandidates: (WorldSystem & { id: number })[] = [], + existingEdgeKeys: Set = new Set(), + anchorId: number = 0, ): RingRegion { - const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) - return { systems, edges: ringEdges(systems.length) } + const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1)) + + let mints = 0 + const attachSet = new Set() + 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. From ab22a7e68b3cbc835d0ec05676abca809914e8e0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 6 Oct 2026 12:18:33 +0000 Subject: [PATCH 2/3] =?UTF-8?q?save/e2e:=20#51=20=E2=80=94=20wire=20genera?= =?UTF-8?q?teRing=20attach=20into=20resolveJumpTask=20+=20spread-limit=20e?= =?UTF-8?q?2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveJumpTask: build existingEdgeKeys from all jump routes (both endpoints in the save's system set), pass all systems except the target as attachCandidates, insert closing routes for each attach. - save.test.ts: jump test now expects 1–4 fresh systems (was pinned to exactly 4); A/B snapshot test stays green (both paths share the same resolveJumpTask code). - e2e/helpers.ts: update stale comment ('4 fresh neighbours' → '1–4 new systems'). - e2e/smoke.spec.ts: new test verifying 1–4 mints, all undiscovered, each routed to the target, and BFS connectivity of the full graph. --- e2e/helpers.ts | 13 +++++++------ e2e/smoke.spec.ts | 40 ++++++++++++++++++++++++++++++++++++++++ lib/game/save.test.ts | 5 +++-- lib/game/save.ts | 24 +++++++++++++++++++++--- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 331f3db..26c940d 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -49,12 +49,13 @@ export async function resolveAction(page: Page, path: string, data: Record { const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5d7c1c2..e2a14d4 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -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); }); +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(); + 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 }) => { 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; diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index b75ed42..6934f01 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -114,9 +114,10 @@ describe('jumpToSystem success path', () => { expect(currentOf(after).id).toBe(target.id) 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)) - expect(fresh).toHaveLength(4) + expect(fresh.length).toBeGreaterThanOrEqual(1) + expect(fresh.length).toBeLessThanOrEqual(4) expect(fresh.every((s) => !s.discovered)).toBe(true) 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) diff --git a/lib/game/save.ts b/lib/game/save.ts index a162988..1885922 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -354,15 +354,33 @@ export async function resolveJumpTask(db: GameDb, task: Task): Promise { await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) if (!target.discovered) { - // Growing the chart: a ring of new (undiscovered) neighbours around the newly reached - // system, so there is always somewhere to jump next and a route back. + // #51 — growing the chart: a seeded-random 1–4 leg ring around the newly reached + // system; each leg may attach an existing system (closing a loop) instead of minting. const rng = expansionRng(save.seed, save.turn) 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[] = [] 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]) 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() }) From d20037762d46aa4bcd78d40c8c8a07863266bbb2 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 6 Oct 2026 12:18:38 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20bump=20to=20v0.13.0=20=E2=80=94=20?= =?UTF-8?q?#51=20map=20spread=20limit=20+=20loop-closing=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1965a2d..1a28b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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. diff --git a/package.json b/package.json index 9b603be..5b1761e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.12.0", + "version": "0.13.0", "private": true, "scripts": { "dev": "next dev -p 3000",