feat: trait outcome effects — FINDS HIDDEN THINGS + TALKS TO ANYONE (#43, v0.12.0) #50

Merged
kamilkrzywda merged 4 commits from feat/trait-outcome-effects into master 2026-10-04 23:24:31 +02:00
7 changed files with 278 additions and 16 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 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.
@@ -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).
+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) }
} }
+164 -3
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'
@@ -466,6 +466,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) -----------------
+21 -4
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'
@@ -552,9 +552,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) {
+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.12.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",