From ab22a7e68b3cbc835d0ec05676abca809914e8e0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 6 Oct 2026 12:18:33 +0000 Subject: [PATCH] =?UTF-8?q?save/e2e:=20#51=20=E2=80=94=20wire=20generateRi?= =?UTF-8?q?ng=20attach=20into=20resolveJumpTask=20+=20spread-limit=20e2e?= 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() })