Compare commits

..
14 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
kamilkrzywda 352ef31625 test(game): pin RECRUIT_CHANCE_BASE === CREW_CHANCE so the no-trait recruit path can't silently diverge (#43)
CI / build + unit + e2e (pull_request) Successful in 3m3s
2026-10-04 21:20:27 +00:00
kamilkrzywda 9af7d21d93 chore(release): v0.12.0 — trait outcome effects (#43) 2026-10-04 21:04:46 +00:00
kamilkrzywda 7d5a561882 docs: trait outcome effects implementation plan (#43) 2026-10-04 21:04:28 +00:00
kamilkrzywda 39920b3e05 feat(game): trait outcome effects — extra haul item + recruit chance (#43) 2026-10-04 21:04:24 +00:00
14 changed files with 594 additions and 66 deletions
+15
View File
@@ -1,5 +1,20 @@
# 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
- 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.
+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
@@ -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
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
});
+8 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
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.
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.11.0", "version": "0.14.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",