feat(game): trait outcome effects — extra haul item + recruit chance (#43)
This commit is contained in:
+8
-6
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user