save/e2e: #51 — wire generateRing attach into resolveJumpTask + spread-limit e2e
- 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.
This commit is contained in:
+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
|
||||
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
||||
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
||||
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
||||
// led here. Falls back to any non-current adjacent system if none is undiscovered. The jump is
|
||||
// queued with a default 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.
|
||||
// undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
|
||||
// lib/game/world.ts #51), so fresh territory is always available — picking the FIRST edge
|
||||
// instead ping-pongs between exhausted systems, because edges come back in insertion order and
|
||||
// the oldest route out of a system is usually the one that led here. Falls back to any
|
||||
// non-current adjacent system if none is undiscovered. The jump is queued with a default
|
||||
// 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> {
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
+21
-3
@@ -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))
|
||||
|
||||
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() })
|
||||
|
||||
Reference in New Issue
Block a user