diff --git a/lib/game/crew.ts b/lib/game/crew.ts index 0856a85..82ec699 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -90,12 +90,14 @@ export function generateStartingCrew(rng: () => number): CrewMember[] { return crew } -// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws -// from the SAME per-planet rng stream as the resource/part haul (called after it in -// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current -// roster's names, keeping every name on the ship unique. -export function rollCrewMember(rng: () => number, taken: Set): CrewMember | null { - if (rng() >= CREW_CHANCE) return null +// The exploration crew roll: with the given chance (defaults to CREW_CHANCE, matching +// RECRUIT_CHANCE_BASE in crew-costs) a member is found and joins the ship. Draws from the +// SAME per-planet rng stream as the resource/part haul (called after it in save.ts), so the +// outcome stays deterministic per save+planet. `taken` is the current roster's names, keeping +// every name on the ship unique. The chance is raised to RECRUIT_CHANCE_TALKER when the +// assigned team includes TALKS TO ANYONE (wired in save.ts, #43). +export function rollCrewMember(rng: () => number, taken: Set, chance: number = CREW_CHANCE): CrewMember | null { + if (rng() >= chance) return null const role = pick(rng, ROLES) return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) } } diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 8984373..8a6941d 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -6,12 +6,12 @@ import { and, eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { 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 { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { TECHS } from './tech' 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 { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -466,6 +466,163 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => { 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' }) }) + + 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) + + // 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(), RECRUIT_CHANCE_TALKER)).not.toBeNull() // 0.24 < 0.30 → found + expect(rollCrewMember(rng024, new Set(), RECRUIT_CHANCE_BASE)).toBeNull() // 0.24 >= 0.18 → null + expect(rollCrewMember(rng024, new Set())).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) ----------------- diff --git a/lib/game/save.ts b/lib/game/save.ts index 0e8bf81..a162988 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -3,7 +3,7 @@ import type { GameDb } from '@/lib/db/client' import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' 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 { checkMissionCompletion, topUpMissionBoard } from './missions' 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`) const rng = explorationRng(save.seed, target.id) - const 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 + let outcome = rollExploration(rng) // resource/part haul first… + + // 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)) for (const item of outcome) { diff --git a/lib/openapi.ts b/lib/openapi.ts index 30016b3..1a4c2a4 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -149,7 +149,7 @@ export const openApiSpec = { post: { summary: 'Queue a scan of a planet or moon', 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', requestBody: { required: true, @@ -1183,7 +1183,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/FoundItem' }, }, 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' }], }, },