feat(game): trait outcome effects — extra haul item + recruit chance (#43)

This commit is contained in:
2026-10-04 21:04:24 +00:00
parent 92c2c26047
commit 39920b3e05
4 changed files with 190 additions and 14 deletions
+8 -6
View File
@@ -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<string>): 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<string>, 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) }
}
+159 -2
View File
@@ -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<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) -----------------
+21 -4
View File
@@ -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) {
+2 -2
View File
@@ -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' }],
},
},