Compare commits

..
Author SHA1 Message Date
kamilkrzywda 939c9695da Merge pull request 'fix: NEXT TURN button into the right pane — full width, no longer overlapped (#52, v0.14.0)' (#54) from feat/next-turn-button into master
CI / build + unit + e2e (push) Successful in 3m5s
2026-10-06 15:09:56 +02:00
kamilkrzywda 34e8fd8514 chore(ui): address review nits — stale strip comment, test assertion (#52)
CI / build + unit + e2e (pull_request) Successful in 3m40s
2026-10-06 13:03:21 +00:00
kamilkrzywda 3ea2d1c38b chore(release): v0.14.0 — NEXT TURN button in the right pane (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda ca590c7b53 test(e2e): pin NEXT TURN full-width and no-overlap with the active-tasks strip (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda bb44530649 fix(ui): move NEXT TURN into the right pane — full width, no longer overlapped (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda 311eb5c713 Merge pull request 'feat: map spread limit — random 1-4 legs + loop-closing routes (#51, v0.13.0)' (#53) from feat/map-spread-limit into master
CI / build + unit + e2e (push) Successful in 3m15s
2026-10-06 14:40:01 +02:00
kamilkrzywda d20037762d chore: bump to v0.13.0 — #51 map spread limit + loop-closing routes
CI / build + unit + e2e (pull_request) Successful in 3m15s
2026-10-06 12:18:38 +00:00
kamilkrzywda ab22a7e68b 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.
2026-10-06 12:18:33 +00:00
kamilkrzywda 90de8818f8 world: #51 — ringEdges small-n fix + generateRing seeded 1–4 legs with attach
- 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).
2026-10-06 12:18:27 +00:00
kamilkrzywda a593cd04cd Merge pull request 'feat: trait outcome effects — FINDS HIDDEN THINGS + TALKS TO ANYONE (#43, v0.12.0)' (#50) from feat/trait-outcome-effects into master
CI / build + unit + e2e (push) Successful in 3m11s
2026-10-04 23:24:30 +02:00
11 changed files with 317 additions and 51 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v0.14.0 — 2026-10-06 12:50
- The NEXT TURN button now lives inside the right pane (full width of the pane, larger) instead of floating as a fixed bottom-right overlay — it can no longer be covered by the active-tasks strip (#52).
- The button grew from the SM to the MD size and spans the whole pane content width; it pins to the pane bottom when the content is short and scrolls with it when the cargo list is long. The `TURN <n>` counter, the EXEC styling and the in-flight behaviour are unchanged.
- E2E: new spec pins the button full-width in the right pane (≥ 280px, within ±8px of the CARGO HOLD panel) and asserts its bounding box does not intersect the active-tasks strip's while a task is queued.
## 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 ## 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. - 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. - TALKS TO ANYONE now raises the crew-recruit chance from 18% to 30% when a member with the trait is on the exploring team.
+11 -7
View File
@@ -529,6 +529,14 @@ export function MissionControl() {
)} )}
</PanelContent> </PanelContent>
</Panel> </Panel>
{/* #52 — the manual tick control (5b), moved in from the fixed bottom-right overlay:
last child of the pane's flex column — full width, size MD, pinned to the pane
bottom when the content is short (the wrapper's marginTop:auto) and scrolling
with the pane when the cargo list is long. In normal flow, the active-tasks strip
can no longer paint over it; the pane (unlike the tab content) is outside Radix's
unmount, so it stays visible on every tab. */}
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
</div> </div>
</div> </div>
</div> </div>
@@ -567,13 +575,9 @@ export function MissionControl() {
<Badge variant="ACTIVE">SYS ONLINE</Badge> <Badge variant="ACTIVE">SYS ONLINE</Badge>
</footer> </footer>
{/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it {/* #44 — the active-tasks strip: fixed bottom-left, outside the tab grid so it stays
stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes visible on every tab. (#52 moved the NEXT TURN button out of this fixed-overlay area
the snapshots this shell owns) */} and into the right pane, so the two can no longer overlap.) Data: the shell's system
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
{/* #44 — the active-tasks strip: fixed bottom-left (same mount convention as the button —
outside the tab grid so it stays visible on every tab). Data: the shell's system
snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on
every shell refresh with no fetch of its own. */} every shell refresh with no fetch of its own. */}
<TaskStrip tasks={system?.tasks ?? []} /> <TaskStrip tasks={system?.tasks ?? []} />
+14 -13
View File
@@ -3,12 +3,16 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from // NEXT TURN — the manual tick control (5b): a normal-flow block in the right pane (#52) carrying
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell // the current turn from the ship snapshot the shell holds. One click = POST /api/turn (no body);
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an // on success the shell re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an // game state — an empty queue is a FREE SKIP (the route always returns 200) — and the only
// in-flight request, which the handler ignores. `disabled` is never set, so the cva // transient is an in-flight request, which the handler ignores. `disabled` is never set, so the
// disabled:opacity-40 styling never applies either. // cva disabled:opacity-40 styling never applies either.
// #52 — the fixed bottom-right overlay became the right pane's last flex child (mission-control):
// full width, size MD, pinned to the pane bottom when the content is short (marginTop:auto) and
// scrolling with the pane when the cargo list is long. No position/zIndex any more — so the
// active-tasks strip can no longer paint over it.
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
const [inFlight, setInFlight] = useState(false) const [inFlight, setInFlight] = useState(false)
@@ -30,14 +34,11 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
return ( return (
<div <div
style={{ style={{
position: 'fixed',
right: '1rem',
bottom: '1rem',
zIndex: 60, // above the header/footer's 50
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-end', gap: '0.5rem',
gap: '0.375rem', width: '100%',
marginTop: 'auto',
}} }}
> >
{error && ( {error && (
@@ -46,7 +47,7 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}> <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
TURN {turn ?? '—'} TURN {turn ?? '—'}
</span> </span>
<Button variant="EXEC" size="SM" onClick={tick}> <Button variant="EXEC" size="MD" className="w-full" onClick={tick}>
NEXT TURN NEXT TURN
</Button> </Button>
</div> </div>
+2 -1
View File
@@ -2,7 +2,8 @@
import type { QueueDto } from '@/lib/game/save' import type { QueueDto } from '@/lib/game/save'
// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44). // TASK STRIP — the save's task queue, fixed bottom-left (#44); the NEXT TURN button moved into
// the right pane in #52, so the two can no longer overlap.
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING // The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows, // block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on // and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
+7 -6
View File
@@ -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;
+84
View File
@@ -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;
@@ -1006,3 +1046,47 @@ test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays
expect(after.turn).toBe(T + 1); expect(after.turn).toBe(T + 1);
expect(after.tasks.length).toBe(0); expect(after.tasks.length).toBe(0);
}); });
test('NEXT TURN button is full-width in the right pane and not covered by the task strip (#52)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(1);
// Queue a one-person JUMP and deliberately do not tick it (the #44 jump scenarios' pattern,
// the in-transit 409 one leaves it that way on purpose): the strip renders from the shell's
// system snapshot — the same queue — so a non-empty queue keeps ACTIVE TASKS visible all test.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
await page.goto('/');
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toBeVisible(); // the strip is up
// Two rects intersect when their x AND y ranges overlap (touching edges do not count).
type Box = { x: number; y: number; width: number; height: number };
const intersects = (a: Box, b: Box) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
const buttonBox = await page.getByRole('button', { name: 'NEXT TURN' }).boundingBox();
expect(buttonBox).not.toBeNull();
// The strip's root div is the parent of its 'ACTIVE TASKS' label (components/task-strip.tsx).
const stripBox = await page.getByText('ACTIVE TASKS', { exact: true }).locator('xpath=..').boundingBox();
expect(stripBox).not.toBeNull();
// The CARGO HOLD panel: the Panel root is the h3 heading's grandparent div (components/ui/panel.tsx).
const panelBox = await page.getByRole('heading', { name: 'CARGO HOLD // INVENTORY' }).locator('xpath=ancestor::div[2]').boundingBox();
expect(panelBox).not.toBeNull();
// (a) The button spans the right pane's content width — >= 280 on its own, and within ±8px of
// the CARGO HOLD panel, a sibling full-width element in the same pane column.
expect(buttonBox!.width).toBeGreaterThanOrEqual(280);
expect(Math.abs(buttonBox!.width - panelBox!.width)).toBeLessThanOrEqual(8);
// (b) The button's box does not intersect the active-tasks strip's box.
expect(intersects(buttonBox!, stripBox!)).toBe(false);
await drainQueue(page); // leave the queue clean for the next scenario
});
+3 -2
View File
@@ -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)
+21 -3
View File
@@ -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() })
+110 -13
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.12.0", "version": "0.14.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",