Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e8fd8514 | ||
|
|
3ea2d1c38b | ||
|
|
ca590c7b53 | ||
|
|
bb44530649 | ||
|
|
311eb5c713 | ||
|
|
d20037762d | ||
|
|
ab22a7e68b | ||
|
|
90de8818f8 | ||
|
|
a593cd04cd | ||
|
|
352ef31625 | ||
|
|
9af7d21d93 | ||
|
|
7d5a561882 | ||
|
|
39920b3e05 | ||
|
|
92c2c26047 | ||
|
|
b8a998dcbb | ||
|
|
1a36160392 | ||
|
|
9d1a8e9d89 | ||
|
|
f3751b4747 | ||
|
|
5bc411cb76 | ||
|
|
7f2121a717 | ||
|
|
449d4b9895 |
@@ -1,5 +1,29 @@
|
|||||||
# 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
|
||||||
|
- 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.
|
||||||
|
- Level-up events: when a member's skill steps into the SENIOR (L3) or MASTER (L5) band, a named event is emitted (e.g. "A. OKAFOR is now a SENIOR SCIENTIST — SCANNING L3").
|
||||||
|
- CREW tab: each member now shows their four skill levels and a title (SENIOR/MASTER + role) based on their highest skill.
|
||||||
|
- Team-builder: the duration preview now accounts for crew skill power (same computation as the queue routes).
|
||||||
|
- Combat traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) now display a "STANDBY — encounter-ready" label, as promised in v0.10.0.
|
||||||
|
- OpenAPI: `CrewMember` gains `skills` (array of {skill, level}) and `title` fields; fixes pre-existing snake_case `trait_good`/`trait_bad` to camelCase to match the actual response.
|
||||||
|
|
||||||
## v0.10.1 — 2026-10-03 18:36
|
## v0.10.1 — 2026-10-03 18:36
|
||||||
- Fix React 19 hydration mismatch on every production load — the banner's live clock was prerendered at build time in the static page and desynced at hydration (CI-verified: no hydration errors in the job logs). A real fix, but NOT the cause of the red e2e jump specs.
|
- Fix React 19 hydration mismatch on every production load — the banner's live clock was prerendered at build time in the static page and desynced at hydration (CI-verified: no hydration errors in the job logs). A real fix, but NOT the cause of the red e2e jump specs.
|
||||||
- Fix the world-map jump dispatch — the actual cause of the three deterministically red e2e jump specs: TeamBuilder's `blocked` term was `undefined`, not `null`, whenever no jump was queued (`Array.find` returns `undefined`), so the roster rendered (truthiness check) while the DISPATCH button's `blocked !== null` guard read it as blocked and stayed disabled — the JUMP panel's dispatch was permanently dead on an empty queue (the 300 s `waitForResponse` timeout; present since the builder shipped in v0.10.0, so the UI-jump specs were red on every run since, on a fresh DB; non-JUMP panels were unaffected, their branch yielding `null`).
|
- Fix the world-map jump dispatch — the actual cause of the three deterministically red e2e jump specs: TeamBuilder's `blocked` term was `undefined`, not `null`, whenever no jump was queued (`Array.find` returns `undefined`), so the roster rendered (truthiness check) while the DISPATCH button's `blocked !== null` guard read it as blocked and stayed disabled — the JUMP panel's dispatch was permanently dead on an empty queue (the 300 s `waitForResponse` timeout; present since the builder shipped in v0.10.0, so the UI-jump specs were red on every run since, on a fresh DB; non-JUMP panels were unaffected, their branch yielding `null`).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
import { checkExploreTarget, ExploreError, getSystemState, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the
|
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the
|
||||||
@@ -29,7 +30,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'EXPLORE'))
|
||||||
await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
|
await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
|
||||||
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
import { checkJumpTarget, getWorldState, JumpError, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP
|
// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP
|
||||||
@@ -28,7 +29,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'JUMP'))
|
||||||
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
||||||
return Response.json(await getWorldState(db))
|
return Response.json(await getWorldState(db))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research'
|
import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research'
|
||||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the
|
// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the
|
||||||
@@ -30,7 +31,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq
|
await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'RESEARCH'))
|
||||||
await saveTaskRow(db, task)
|
await saveTaskRow(db, task)
|
||||||
await saveInventory(db, inventory) // the cost was deducted at queue time
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
return Response.json(await getTechState(db))
|
return Response.json(await getTechState(db))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers)
|
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers)
|
||||||
@@ -30,7 +31,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate
|
await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_BUILD'))
|
||||||
await saveTaskRow(db, task)
|
await saveTaskRow(db, task)
|
||||||
await saveInventory(db, inventory) // the cost was deducted at queue time
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
return Response.json(await getShipState(db))
|
return Response.json(await getShipState(db))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team
|
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team
|
||||||
@@ -30,7 +31,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity
|
await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_EXPAND'))
|
||||||
await saveTaskRow(db, task)
|
await saveTaskRow(db, task)
|
||||||
await saveInventory(db, inventory) // the cost was deducted at queue time
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
return Response.json(await getShipState(db))
|
return Response.json(await getShipState(db))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Level up a built ship system by one (capped at max level): assign the crew team (lead +
|
// Level up a built ship system by one (capped at max level): assign the crew team (lead +
|
||||||
@@ -30,7 +31,8 @@ export async function POST(req: Request) {
|
|||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level
|
await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_UPGRADE'))
|
||||||
await saveTaskRow(db, task)
|
await saveTaskRow(db, task)
|
||||||
await saveInventory(db, inventory) // the cost was deducted at queue time
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
return Response.json(await getShipState(db))
|
return Response.json(await getShipState(db))
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { CrewDto, CrewState } from '@/lib/game/save'
|
|||||||
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
|
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
|
||||||
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
|
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
|
||||||
|
|
||||||
|
// Spec §8: the three combat traits that carry no duration effect yet — their effect lands
|
||||||
|
// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY).
|
||||||
|
// The label is additive — the original name always appears in the text unchanged.
|
||||||
|
const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT'])
|
||||||
|
const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait)
|
||||||
|
|
||||||
interface CrewPanelProps {
|
interface CrewPanelProps {
|
||||||
narrow?: boolean
|
narrow?: boolean
|
||||||
}
|
}
|
||||||
@@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) {
|
|||||||
>
|
>
|
||||||
{member.role}
|
{member.role}
|
||||||
</p>
|
</p>
|
||||||
|
{/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
|
||||||
|
<p style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||||
|
{member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
|
||||||
|
</p>
|
||||||
|
{/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
|
||||||
|
{member.title !== null && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>
|
||||||
|
{member.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitGood)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitBad)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
ORIGIN // {member.origin.toUpperCase()}
|
ORIGIN // {member.origin.toUpperCase()}
|
||||||
|
|||||||
@@ -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 ?? []} />
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { useState } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { CrewDto, QueueDto } from '@/lib/game/save'
|
import type { CrewDto, QueueDto } from '@/lib/game/save'
|
||||||
import { computeDuration, type TaskType } from '@/lib/game/tasks'
|
import { computeDuration, type TaskType } from '@/lib/game/tasks'
|
||||||
|
import { makePowerOf, type SkillRow } from '@/lib/game/skills'
|
||||||
|
|
||||||
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
|
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
|
||||||
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
|
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
|
||||||
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
|
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
|
||||||
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
|
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
|
||||||
// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
|
// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's
|
||||||
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
|
// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the
|
||||||
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
|
// queueing routes make, so the preview equals the stored duration); each member row shows
|
||||||
|
// that member's power contribution for the armed action's skill. The rows mirror Task 3's
|
||||||
|
// queueTask so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
|
||||||
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
||||||
type: TaskType
|
type: TaskType
|
||||||
hopCount?: number
|
hopCount?: number
|
||||||
@@ -31,9 +34,15 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
|||||||
const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null
|
const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null
|
||||||
const blocked = inTransit ?? queuedJump
|
const blocked = inTransit ?? queuedJump
|
||||||
|
|
||||||
|
// R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) —
|
||||||
|
// built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored
|
||||||
|
// duration instead of assuming every member is power 1.
|
||||||
|
const skillRows: SkillRow[] = roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
|
||||||
|
const powerOf = makePowerOf(roster, skillRows, type)
|
||||||
|
|
||||||
const lead = roster.find((c) => c.id === leadId) ?? null
|
const lead = roster.find((c) => c.id === leadId) ?? null
|
||||||
const helpers = roster.filter((c) => helperIds.includes(c.id))
|
const helpers = roster.filter((c) => helperIds.includes(c.id))
|
||||||
const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember
|
const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember
|
||||||
|
|
||||||
const leadReason = (c: CrewDto) => {
|
const leadReason = (c: CrewDto) => {
|
||||||
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
|
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
|
||||||
@@ -68,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
|||||||
/>
|
/>
|
||||||
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
|
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
|
||||||
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
{c.name}
|
{c.name} (power {powerOf(c)})
|
||||||
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
|
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Skill Progression Implementation Plan (PR B — Gitea #45, v0.11.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:** Crews gain skills by doing jobs; power bands make experienced teams faster; role names unchanged.
|
||||||
|
|
||||||
|
**Architecture:** A pure `lib/game/skills.ts` plus the `crew_skills` table (schema v8) feed power bands into the existing queue/tick engine defined by the PR A plan.
|
||||||
|
|
||||||
|
**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 (§6)
|
||||||
|
|
||||||
|
**Upstream:** PR A plan: docs/superpowers/plans/2026-10-01-crew-driven-actions.md — Tasks 3/4a/4b define `queueTask` / `runTurn` / `computeDuration` / `Task`; this plan assumes the post-v0.10.0 merge and starts a fresh feature branch.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
Task numbering continues from the PR A plan (1–11).
|
||||||
|
|
||||||
|
|
||||||
|
### Task 12: crew_skills table, schema v8 (PR B — Gitea issue #45)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/db/client.ts` (add `crew_skills` to the `DDL` template, after `tasks`), `lib/db/schema.ts` (drizzle table `crewSkills` + unique index), `lib/game/save.ts` (`SCHEMA_VERSION` 7→8; idempotent `seedCrewSkills` helper; the v8 step in `backfillSave`; a `createNewGame` hook — JC-21), `lib/db/admin.ts` (register `crew_skills` in `ADMIN_TABLES`)
|
||||||
|
- Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (Task 2's v7-fixture pattern: `freshFile()` + `getWorldState` + stub `schemaVersion` + reopen via `openGameDb(file)`); `lib/db/admin.test.ts`'s registry test picks up the table automatically (it derives its expected list from `schema.ts`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SkillName` + `STARTING_LEVELS` (`Record<string, Record<SkillName, number>>`) from Task 1's `lib/game/crew-costs.ts`; `schema.crew`/`schema.saves`; `backfillSave`/`ensureSave` in `lib/game/save.ts` (now `SCHEMA_VERSION = 7` after Task 2)
|
||||||
|
- Produces (for Task 13+): the `crew_skills` table — `saveId` (FK saves), `crewId` (FK crew), `skill` (`SkillName`), `level` (0–5), `xp` — seeded to **exactly 4 rows per member** (one per skill) with `level = STARTING_LEVELS[role][skill]`, `xp = 0`, for every save (fresh + upgraded). Task 13 (XP-on-completion, level-ups, power bands, UI) reads/writes this table; level→power stays in `crew-costs.ts` (`POWER_BANDS`)
|
||||||
|
|
||||||
|
- [ ] **Create the branch** (PR B, off `master` post-v0.10.0 merge): `git checkout -b feat/skill-progression master`. Every commit in this PR references `(#45)`.
|
||||||
|
|
||||||
|
- [ ] **Write the failing test** — extend the `backfill (old saves)` describe in `lib/game/save.test.ts`; add `import { STARTING_LEVELS } from './crew-costs'` at the top. Pins **Review Focus 1 (v8)**: v7→v8 seeds 4 rows per member without touching turn/crew/inventory, idempotent across restart:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
|
||||||
|
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
|
||||||
|
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||||
|
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
|
||||||
|
const skillMap = async (db: GameDb, crewId: number) => {
|
||||||
|
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
|
||||||
|
expect(rows).toHaveLength(4)
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }]))
|
||||||
|
}
|
||||||
|
it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp 0, state untouched; idempotent across restart', async () => {
|
||||||
|
const { db, file } = await freshFile()
|
||||||
|
await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21)
|
||||||
|
const save = (await db.db.select().from(schema.saves))[0]
|
||||||
|
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 7 })
|
||||||
|
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
|
||||||
|
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
|
||||||
|
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
|
||||||
|
const crewBefore = await db.db.select().from(schema.crew)
|
||||||
|
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||||
|
const turnBefore = (await db.db.select().from(schema.saves))[0].turn
|
||||||
|
await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
|
||||||
|
expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT
|
||||||
|
expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 0 } }) // SCIENTIST
|
||||||
|
expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0)
|
||||||
|
for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp 0
|
||||||
|
const expected = Object.fromEntries(Object.keys(STARTING_LEVELS[c.role]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: 0 }]))
|
||||||
|
expect(await skillMap(db, c.id)).toEqual(expected)
|
||||||
|
}
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory
|
||||||
|
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
|
||||||
|
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||||
|
await getWorldState(reopened)
|
||||||
|
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
|
||||||
|
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/save.test.ts` → red: `schema.crewSkills` is not exported (the test can't compile) and `SCHEMA_VERSION` is still 7. The other describes stay green (they don't reference `crewSkills`).
|
||||||
|
|
||||||
|
- [ ] **Implement**, each in the existing style of its file:
|
||||||
|
|
||||||
|
`lib/db/client.ts` — add to the `DDL` template after the `tasks` table (keep in sync with `schema.ts`):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_skills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||||||
|
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
|
||||||
|
level INTEGER NOT NULL DEFAULT 0, -- 0–5
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
|
||||||
|
UNIQUE (crew_id, skill)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
`lib/db/schema.ts` — append after `tasks` (mirrors the DDL; `uniqueIndex` is already imported):
|
||||||
|
```ts
|
||||||
|
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
|
||||||
|
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
|
||||||
|
export const crewSkills = sqliteTable('crew_skills', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
crewId: integer('crew_id').notNull().references(() => crew.id),
|
||||||
|
skill: text('skill').notNull(), // SkillName
|
||||||
|
level: integer('level').notNull().default(0), // 0–5
|
||||||
|
xp: integer('xp').notNull().default(0),
|
||||||
|
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
|
||||||
|
```
|
||||||
|
`lib/game/save.ts` — add `import { STARTING_LEVELS, type SkillName } from './crew-costs'`; bump the constant; add the idempotent seeder (called by `createNewGame` and the v8 backfill):
|
||||||
|
```ts
|
||||||
|
export const SCHEMA_VERSION = 8 // v1: save + start · v2: bodies · v3: crew · v4: ship · v5: tech · v6: missions · v7: tasks · v8: crew_skills (role-based starting levels)
|
||||||
|
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
|
||||||
|
// (level = STARTING_LEVELS[role][skill], xp = 0). Called by createNewGame and the v8 backfill.
|
||||||
|
async function seedCrewSkills(db: GameDb, saveId: number) {
|
||||||
|
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
|
||||||
|
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
|
||||||
|
for (const m of crew) {
|
||||||
|
if (have.has(m.id)) continue // already seeded — idempotent
|
||||||
|
for (const skill of Object.keys(STARTING_LEVELS[m.role]) as SkillName[]) {
|
||||||
|
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: STARTING_LEVELS[m.role][skill], xp: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `createNewGame`, add after `await seedStartingCrew(db, save.id, seed)`: `await seedCrewSkills(db, save.id)`. In `backfillSave`, add after the v7 step:
|
||||||
|
```ts
|
||||||
|
if (version < 8) {
|
||||||
|
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
|
||||||
|
version = 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`lib/db/admin.ts` — register in `ADMIN_TABLES` after `tasks`: `crew_skills: schema.crewSkills,`
|
||||||
|
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run lib/game/save.test.ts lib/db/admin.test.ts` → green (new v8 test passes; the admin registry test picks up `crew_skills` automatically); `npx tsc --noEmit` clean.
|
||||||
|
|
||||||
|
- [ ] **Commit** (PR B): `git add -A && git commit -m "feat(db): crew_skills table + role-based starting levels — schema v8 (#45)"`.
|
||||||
|
|
||||||
|
**Closeout (PR B):** version bump **v0.11.0** + `CHANGELOG.md` entry + open/auto-merge PR + tag **v0.11.0** + manual deploy + close Gitea issue #45 follow the standard per-PR loop (mirroring the v0.10.0 / #44 closeout at this plan's tail). Gitea numbering here is the plan's convention (spec §10's pre-fix numbering lists this work as #44; the actual Gitea issue is #45 (the table is corrected in PR A's Task 11 before the spec is committed)).
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-21 — `ensureSave` runs `backfillSave` only for pre-existing saves; a *fresh* save is built by `createNewGame`, which never backfills. So the v8 seeding is also called from `createNewGame` (the same idempotent `seedCrewSkills`) — otherwise fresh v8 saves would have no `crew_skills` rows at all. Mirrors how `seedStartingCrew`/`seedStartingShip`/`seedStartingTech` are invoked from both `createNewGame` and their backfill steps.
|
||||||
|
|
||||||
|
### Task 13: skills.ts — XP, levels, power bands, titles (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/game/skills.ts` (pure — no `lib/db` imports; operates on plain `SkillRow` data from Task 12's `crew_skills` table; the frontend imports it for the CREW-tab skill display + titles, same server+client precedent as `crew-costs.ts`) and test `lib/game/skills.test.ts` (colocated, pure — no DB)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: from `./crew-costs` — `SkillName`, `XP_THRESHOLDS` (level thresholds [5,10,15,20,25]), `POWER_BANDS` ([1,1,1,2,2,3]); the `crew_skills` rows (Task 12) as plain `{ saveId, crewId, skill, level, xp }`
|
||||||
|
- Produces (for Task 14+): `SkillRow` interface, `grantXp(skillRows, crewIds, skill, members) → { rows: SkillRow[]; events: string[] }`, `levelFromXp(xp) → number`, `powerForLevel(level) → number`, `titleFor(member, skillRows) → string | null`
|
||||||
|
|
||||||
|
- [ ] Steps: TDD — write failing tests FIRST in `lib/game/skills.test.ts` using the spec's EXACT values (run `npx vitest run lib/game/skills.test.ts` → red, the module does not exist yet):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { grantXp, levelFromXp, powerForLevel, titleFor, type SkillRow } from './skills'
|
||||||
|
const pilot = { name: 'BEN OKONKWO', role: 'PILOT' }
|
||||||
|
const row = (crewId: string, skill: SkillName, level: number, xp: number): SkillRow =>
|
||||||
|
({ saveId: 's1', crewId, skill, level, xp })
|
||||||
|
const XP: [number, number][] = [[4, 0], [5, 1], [10, 2], [15, 3], [25, 5], [100, 5]]
|
||||||
|
describe('levelFromXp (thresholds [5,10,15,20,25], cap 5)', () => {
|
||||||
|
it('maps cumulative xp → highest level reached', () => {
|
||||||
|
for (const [xp, lvl] of XP) expect(levelFromXp(xp), `xp ${xp}`).toBe(lvl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const POWER: [number, number][] = [[0, 1], [2, 1], [3, 2], [4, 2], [5, 3]]
|
||||||
|
describe('powerForLevel (bands [1,1,1,2,2,3])', () => {
|
||||||
|
it('L0–2 → 1 · L3–4 → 2 · L5 → 3', () => {
|
||||||
|
for (const [lvl, p] of POWER) expect(powerForLevel(lvl), `L${lvl}`).toBe(p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('grantXp — +1 XP to every listed member; event only on a titled-band crossing', () => {
|
||||||
|
const pilotRows = (): SkillRow[] => [
|
||||||
|
row('c1', 'NAVIGATION', 2, 14), row('c1', 'SCANNING', 0, 0),
|
||||||
|
row('c1', 'ENGINEERING', 0, 0), row('c1', 'RESEARCH', 0, 0),
|
||||||
|
]
|
||||||
|
it('L0 → L1: xp 4 +1 → 5 (L1), no event (no event for plain L1/L2)', () => {
|
||||||
|
const { rows, events } = grantXp([row('c1', 'NAVIGATION', 0, 4)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows[0]).toEqual({ saveId: 's1', crewId: 'c1', skill: 'NAVIGATION', level: 1, xp: 5 })
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
it('L2 → L3: xp 14 +1 → 15, SENIOR event in the exact spec §6 text', () => {
|
||||||
|
const { rows, events } = grantXp(pilotRows(), ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows.find((r) => r.skill === 'NAVIGATION')!.level).toBe(3)
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3'])
|
||||||
|
})
|
||||||
|
it('L4 → L5: xp 24 +1 → 25, MASTER event', () => {
|
||||||
|
const { events } = grantXp([row('c1', 'NAVIGATION', 4, 24), row('c1', 'SCANNING', 0, 0)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a MASTER PILOT — NAVIGATION L5'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('titleFor — from the member's HIGHEST skill level', () => {
|
||||||
|
it('SCIENTIST with SCANNING L3 + RESEARCH L5 → MASTER SCIENTIST (highest wins)', () => {
|
||||||
|
const scientist = { name: 'AVA VANCE', role: 'SCIENTIST' }
|
||||||
|
const rows = [row('c2', 'SCANNING', 3, 15), row('c2', 'RESEARCH', 5, 25), row('c2', 'NAVIGATION', 0, 0), row('c2', 'ENGINEERING', 0, 0)]
|
||||||
|
expect(titleFor(scientist, rows)).toBe('MASTER SCIENTIST')
|
||||||
|
})
|
||||||
|
it('GUNNER with all L0 → null', () => {
|
||||||
|
const gunner = { name: 'RIG HOLLOWAY', role: 'GUNNER' }
|
||||||
|
const rows = [row('c3', 'NAVIGATION', 0, 0), row('c3', 'SCANNING', 0, 0), row('c3', 'ENGINEERING', 0, 0), row('c3', 'RESEARCH', 0, 0)]
|
||||||
|
expect(titleFor(gunner, rows)).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Implement `lib/game/skills.ts` (pure — no `lib/db` imports; frontend imports it for the CREW-tab skill display + titles, same precedent as `crew-costs.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { POWER_BANDS, XP_THRESHOLDS } from './crew-costs'
|
||||||
|
export interface SkillRow {
|
||||||
|
saveId: string
|
||||||
|
crewId: string
|
||||||
|
skill: SkillName
|
||||||
|
level: number
|
||||||
|
xp: number
|
||||||
|
}
|
||||||
|
export type CrewIdentity = { name: string; role: string }
|
||||||
|
// Highest level 0–5 with xp ≥ XP_THRESHOLDS[level−1]; thresholds [5,10,15,20,25], cap 5.
|
||||||
|
export function levelFromXp(xp: number): number {
|
||||||
|
let level = 0
|
||||||
|
for (let l = 1; l <= XP_THRESHOLDS.length; l++) if (xp >= XP_THRESHOLDS[l - 1]) level = l
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
// L0–2 → 1 · L3–4 → 2 · L5 → 3 (direct lookup into crew-costs POWER_BANDS).
|
||||||
|
export function powerForLevel(level: number): number {
|
||||||
|
return POWER_BANDS[level]
|
||||||
|
}
|
||||||
|
// From the member's HIGHEST skill level: ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
|
||||||
|
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
|
||||||
|
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
|
||||||
|
if (highest >= 5) return `MASTER ${member.role}`
|
||||||
|
if (highest >= 3) return `SENIOR ${member.role}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
|
||||||
|
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
|
||||||
|
export function grantXp(
|
||||||
|
skillRows: SkillRow[],
|
||||||
|
crewIds: string[],
|
||||||
|
skill: SkillName,
|
||||||
|
members: Record<string, CrewIdentity>,
|
||||||
|
): { rows: SkillRow[]; events: string[] } {
|
||||||
|
const rows = skillRows.map((r) =>
|
||||||
|
r.skill === skill && crewIds.includes(r.crewId)
|
||||||
|
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
|
||||||
|
: r,
|
||||||
|
)
|
||||||
|
const events: string[] = []
|
||||||
|
for (const crewId of crewIds) {
|
||||||
|
const member = members[crewId]
|
||||||
|
if (!member) continue
|
||||||
|
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
if (newLevel === 3 || newLevel === 5) {
|
||||||
|
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
|
||||||
|
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, events }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run lib/game/skills.test.ts` → green (both levelFromXp/powerForLevel tables, the three grantXp crossings, the two titleFor cases); `npx tsc --noEmit` clean.
|
||||||
|
|
||||||
|
- [ ] **Commit** (PR B, same branch as Task 12): `git add -A && git commit -m "feat(game): skills.ts — XP, levels, power bands, titles (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-22 — spec §6 shows one level-up event (the SENIOR PILOT example) and none for plain L1/L2/L4; so `grantXp` emits an event only when the gained skill steps into a titled band (new level 3 → SENIOR, 5 → MASTER). The event's title is computed from the member's HIGHEST skill level (via `titleFor` over all their rows); if a member is already MASTER from one skill and a second then crosses L3, this would emit a MASTER-titled event naming the L3 skill — a rare edge left as-is (per-member dedup can be added in Task 14's orchestrator if wanted).
|
||||||
|
|
||||||
|
### Task 14: Wire skill XP into the tick + power bands into duration (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/game/skills.ts` (add `makePowerOf` — the power-band closure); `lib/game/save.ts` (`runTurn`: grant XP on each resolved task, fold the level-up events into the tick's event batch, persist `crew_skills` in the final write; new `makePowerOfFor` route helper; imports `grantXp`/`makePowerOf`/`type SkillRow`/`ACTION_SKILL`); `lib/game/tasks.ts` (`queueTask` gains an optional `powerOf` param, forwarded to `computeDuration`); and the five queue paths — `app/api/jump/route.ts`, `app/api/explore/route.ts`, `app/api/ship/{upgrade,expand,build}/route.ts`, `app/api/research/route.ts` (each passes `makePowerOfFor(db, save, type)` into `queueTask`)
|
||||||
|
- Test: extend `lib/game/save.test.ts` (the `runTurn (tick on the DB)` describe) and `lib/game/tasks.test.ts` (the `queueTask` block). No new files.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 4b's `runTurn(db)` orchestrator in `save.ts` — reads `saves`/`crew`/`tasks`/`inventory`/`shipSystems`/`tech` into a `QueueState`, runs `tickTurn`, writes `saves.turn` before resolving, dispatches `result.completed` through `TASK_RESOLVERS`, and ends in one whole-DB `persist(db)`; Task 13's `grantXp(skillRows, crewIds, skill, members) → { rows, events }`, `powerForLevel(level)`, `levelFromXp(xp)`, `titleFor(member, rows)`, `SkillRow`, `CrewIdentity`; Task 1's `ACTION_SKILL: Record<TaskType, SkillName>`, the unchanged `computeDuration(type, hopCount, lead, helpers, powerOf?)`, and `CrewMember`; Task 12's `schema.crewSkills` table; Task 3's `queueTask(state, req)` / `QueueState`.
|
||||||
|
- Produces: `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number` in `skills.ts` — the exact `powerOf` closure Task 15's live UI preview calls (Task 15's crew endpoint must expose each member's skill rows for the UI to build it); and `makePowerOfFor(db, save, type): Promise<(m: CrewMember) => number>` in `save.ts` — the route-side builder the five queue paths call.
|
||||||
|
|
||||||
|
- [ ] **Write the failing tests** — extend the two existing describes (no new files):
|
||||||
|
- `lib/game/tasks.test.ts` (`queueTask` block) — a power-band duration case: a solo member with a `powerOf` returning 2 → `queueTask(state, { type: 'EXPLORE', ... }, (m) => 2)` stores `remaining 2` (`ceil(4/2)`), while the no-`powerOf` call (default flat 1) still stores 4 — pins the new param AND its backward-compatible default.
|
||||||
|
- `lib/game/save.test.ts` (`runTurn` describe) — three cases over the 4a `buildWorldFixture`/`freshFile` pattern (patch a member's `crew_skills` row directly; a fresh save already has 4 rows per member from Task 12's seed):
|
||||||
|
1. **XP accrual** — a 3-person EXPLORE (lead + 2 helpers) completes in one tick → all three members' `SCANNING` rows each gain exactly +1 XP (lead and helpers equally).
|
||||||
|
2. **Level-up event** — a SCIENTIST whose `SCANNING` row is at xp 14 (L2) completes an EXPLORE → the tick writes the §6 text into `schema.events`: `AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3` (xp 15 → L3 → SENIOR band).
|
||||||
|
3. **§6 duration-shift** — a member at `SCANNING` L0/xp 0 queues an EXPLORE solo → stored `duration 4`; after 15 completed EXPLOREs (xp 15 → L3 → power 2) the next queued EXPLORE → stored `duration 2` (`ceil(4/2)`). The lead is a SCIENTIST (the preferred EXPLORE role) so the §6 "4 → 2" holds without a mismatch penalty — see JC-23.
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/tasks.test.ts lib/game/save.test.ts` → the new `it`s red (`queueTask` has no `powerOf` param; `runTurn` doesn't read `crew_skills`, so no XP accrual, no level-up event, and the queued duration stays 4 at L3); every pre-existing test (Task 3's `queueTask` block, 4b's `runTurn` block, the 4a A/B + guard blocks) stays green.
|
||||||
|
- [ ] **Implement** — one action per file:
|
||||||
|
1. `lib/game/skills.ts` — add `import { ACTION_SKILL } from './crew-costs'` and `import type { CrewDto } from './save'` (type-only, so no runtime cycle — the `tasks.ts` precedent), then `makePowerOf`: closes over `Map<name, crewId>` (names are unique per single-player save) + `Map<crewId, level-in-ACTION_SKILL[type]>`; returns `(m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)` — an unknown member / no row falls back to L0 → power 1 (Task 1's default).
|
||||||
|
2. `lib/game/tasks.ts` — `queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1)`; the one line `const duration = computeDuration(req.type, hops, lead, helpers)` becomes `computeDuration(req.type, hops, lead, helpers, powerOf)`. `computeDuration` itself is unchanged.
|
||||||
|
3. `lib/game/save.ts` — import `grantXp`, `makePowerOf`, `type SkillRow` from `./skills` and `ACTION_SKILL` from `./crew-costs`. In `runTurn`: add `crewSkills` to the top `Promise.all` read; map the rows to `SkillRow[]` (stringify `saveId`/`crewId`) and build `members: Record<string, CrewIdentity>` keyed by `String(crew.id)`; in the `result.completed` loop, after each `TASK_RESOLVERS[task.type](db, task)`, call `grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)` and reassign `skillRows` (so overlapping crew across several completions in one tick accumulate), pushing its `events` into a tick-level array; after the loop, insert the level-up events as one `schema.events` batch at `result.turn` and update every changed `crew_skills` row — both inside the existing final `persist(db)`. Add `makePowerOfFor(db, save, type)` (reads `crew` + `crew_skills`, returns `makePowerOf(roster, rows, type)`).
|
||||||
|
4. The five queue-path routes — pass `await makePowerOfFor(db, save, type)` as the third `queueTask` arg so the stored duration uses power bands.
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the three new `runTurn` cases, the new `queueTask` power-band case, and every pre-existing block); `npx tsc --noEmit` clean.
|
||||||
|
- [ ] **Commit** (PR B, same branch as Tasks 12–13): `git add -A && git commit -m "feat(game): skill XP on task completion + power bands in duration (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-23 — the locked design's "e.g. a COMMANDER" for the §6 duration-shift would in fact incur EXPLORE's mismatch penalty (COMMANDER ≠ the preferred SCIENTIST → +1 → 5, not 4), contradicting the cited "duration 4 → 2 (`ceil(4/2)`)". The test therefore leads the EXPLORE with a SCIENTIST (the preferred role, no penalty) seeded at `SCANNING` L0 so the exact §6 "rookie 4 → 2" numbers hold; the power-band effect (the point of the test) is identical either way.
|
||||||
|
- JC-24 — `SkillRow.crewId`/`saveId` are strings (Task 13) but `crew_skills.crew_id`/`save_id` and `tasks.leadCrewId`/`helpers` are numeric; `runTurn` stringifies `crew.id`/`save.id` when mapping rows to `SkillRow[]` and when building `members` and the `grantXp` `crewIds`, keeping one string-id domain end to end so `grantXp`'s `crewIds.includes(r.crewId)` matches.
|
||||||
|
- JC-25 — 4a's resolvers each insert their own resolution event and `persist`; to keep the level-up events "in the same event batch" and the `crew_skills` updates "in the same single write" as the resolution, `runTurn` writes the level-up events as one batch insert and the `crew_skills` updates inside its existing final `persist(db)` (same `result.turn`, one atomic commit) rather than refactoring every 4a resolver to return its event.
|
||||||
|
|
||||||
|
### Task 15: CREW tab skill levels + titles, team-builder contributions (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/game/save.ts` — the crew-state reader `getCrewState` (post-PR-A it returns the `CrewState { crew, tasks }` wrapper; this is the only lib change in the task) attaches per-member skill data to each `CrewDto`: `skills` (the member's four `crew_skills` rows as `{ skill, level }` in the fixed order NAVIGATION → SCANNING → ENGINEERING → RESEARCH) and `title` (Task 13's `titleFor({ name, role }, memberRows)` — `null` when the member's highest skill is below L3); the `CrewDto` interface gains those two fields; imports `titleFor`/`type SkillRow` from `./skills` and `type SkillName` from `./crew-costs` (type-only, no runtime cycle); the `crew_skills` select joins the existing crew read in the same `Promise.all`.
|
||||||
|
- Modify: `components/crew-panel.tsx` — per member, render the four skill levels on one `·`-separated line (e.g. `NAVIGATION L3 · SCANNING L0 · ENGINEERING L0 · RESEARCH L2`) from `member.skills`, and, only when `member.title` is non-null, a title line (e.g. `SENIOR PILOT`). Display-only — no new interaction. Also label the three STANDBY traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) as "STANDBY — encounter-ready" in each member's trait line (spec §8).
|
||||||
|
- Modify: `components/team-builder.tsx` (PR A's component) — extend each member row to show that member's power contribution for the armed action's skill (e.g. `BEN OKONKWO (power 2)`), computed with Task 14's `makePowerOf`; the existing live duration preview and the dispatch control stay unchanged.
|
||||||
|
- Test: extend `lib/game/save.test.ts` — one new `it` in the `getCrewState` snapshot block for per-member `skills` + `title`. No component tests (the repo has no jsdom/@testing-library — the unit suite is pure-TS only), so the rendered UI is covered by e2e (CI-only), per the standard skip note.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the post-PR-A crew-state shape — `getCrewState(db): Promise<CrewState>` with `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` (PR A Task 6 / JC-7); Task 13's `titleFor(member: CrewIdentity, rows: SkillRow[]) → string | null` and `SkillRow`; Task 14's `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number`; Task 12's `crew_skills` table; Task 1's `ACTION_SKILL` / `SkillName`.
|
||||||
|
- Produces: `CrewDto.skills: { skill: SkillName; level: number }[]` (the four rows, fixed order) and `CrewDto.title: string | null` on the `GET /api/crew` response — asserted by the new unit test and by e2e (the CREW-tab skill/title lines and the team-builder `(power N)` rows). No new endpoint, no schema/table change.
|
||||||
|
|
||||||
|
- [ ] **Write the failing test** — in `lib/game/save.test.ts`'s `getCrewState` snapshot block, over a fresh save (which Task 12 already seeds with four `crew_skills` rows per member): pin one member (e.g. a PILOT) to `NAVIGATION` L3 (xp 15) and its other three skills L0, and leave a second member all-L0. Assert the pinned member's `getCrewState(db).crew` entry carries `skills` = `[{ NAVIGATION, 3 }, { SCANNING, 0 }, { ENGINEERING, 0 }, { RESEARCH, 0 }]` (all four, fixed order) and `title` = `"SENIOR PILOT"`, and the all-L0 member's `title` = `null`.
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/save.test.ts` → the new `it` is red (`getCrewState` still returns `CrewDto` entries without `skills`/`title`); every pre-existing block (4b, Task 14's XP/event/duration, the PR-A snapshot-queue block) stays green.
|
||||||
|
- [ ] **Implement** — one action per file:
|
||||||
|
1. `lib/game/save.ts` — in `getCrewState`, add `crew_skills` to the read and group rows by `crewId`; for each `CrewDto` set `skills` to the four fixed-order `{ skill, level }` rows and `title` to `titleFor({ name, role }, memberRows)`; add the two fields to the `CrewDto` interface and the type-only imports.
|
||||||
|
2. `components/crew-panel.tsx` — under each member's role line, render the `·`-separated skill-level line from `member.skills` and, only when set, a `member.title` line; purely presentational.
|
||||||
|
3. `components/team-builder.tsx` — import `makePowerOf` (and `type SkillRow`) from `@/lib/game/skills`; build `skillRows: SkillRow[]` once from the roster (`roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))`); `const powerOf = makePowerOf(roster, skillRows, type)`; append `(power N)` to each member row's name for that member's contribution in the armed action's skill. Leave the live `duration` preview and dispatch as-is.
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the new `getCrewState` skills/title `it` plus every pre-existing block); `npx tsc --noEmit` clean. The rendered CREW-tab lines and team-builder `(power N)` rows are e2e-covered (CI-only, per AGENTS.md).
|
||||||
|
- [ ] **Commit** (PR B, same branch as Tasks 12–14): `git add -A && git commit -m "feat(ui): skill levels + titles on CREW tab and team-builder (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-26 — `getCrewState` returns the lightweight `{ skill, level }` per member (not the full `SkillRow` with `xp`/`saveId`) to keep the `GET /api/crew` payload display-oriented; the team builder reconstructs just enough of a `SkillRow[]` (`crewId` + `skill` + `level`) to feed `makePowerOf`, which reads only those fields, so the full rows are not shipped to the client twice.
|
||||||
|
- JC-27 — skill levels render in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (the spec §6 skill list) rather than DB row order, so the CREW-tab line and any e2e text assertion are stable; the same order is pinned in the new unit test.
|
||||||
|
- JC-28 — the `GET /api/crew` response object gains two additive display-only fields, but per the locked design this task keeps the OpenAPI `CrewMember` schema (and its `openapi.test.ts` key pin) at its six documented keys, leaving the spec slightly under-documented; a trivial follow-up may mirror `skills`/`title` into the schema + pin later. CI stays green as-is because the pin is unchanged.
|
||||||
|
|
||||||
|
### Task 16: Ship v0.11.0 (PR B — Gitea issue #45)
|
||||||
|
|
||||||
|
**Files touched (this ship step only — no app code, no failing-test step):**
|
||||||
|
- `docs/superpowers/plans/2026-10-01-skill-progression.md` — this plan (untracked, committed here; the spec doc was already committed + approved in PR A, so no spec edits in this PR).
|
||||||
|
- `package.json` (version bump) and `CHANGELOG.md` (new top entry).
|
||||||
|
|
||||||
|
This is a process task that ships the PR B already built by Tasks 12–15 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate.
|
||||||
|
|
||||||
|
1. - [ ] **Commit the PR B plan document on the PR B branch** (the branch created in Task 12, `feat/skill-progression`) — the plan doc is untracked: `git add docs/superpowers/plans/2026-10-01-skill-progression.md && git commit -m "docs: skill-progression implementation plan (#45)"`. The spec doc was already committed in PR A — no spec edits in this PR.
|
||||||
|
|
||||||
|
2. - [ ] **Version bump** — `package.json` → `"version": "0.11.0"` (from the current `0.10.0`). `CHANGELOG.md`: new entry at the very top in the repo's format `## v0.11.0 — YYYY-MM-DD HH:MM` (fill the date/time at execution) with a bullet summary of the PR: the `crew_skills` table (schema v8) with role-based starting specialisation; +1 XP per completed task for all assigned crew; power bands in task duration (L0–2→1, L3–4→2, L5→3); SENIOR/MASTER titles; the CREW tab shows skill levels + title and the team-builder shows per-member power contribution.
|
||||||
|
|
||||||
|
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). All three must pass. Do NOT run the e2e suite (`npx playwright test`) — CI-only per AGENTS.md.
|
||||||
|
|
||||||
|
4. - [ ] **Push + open the PR to master** — `git push -u origin feat/skill-progression`, then open a pull request to `master` on Gitea (repo `OpenCode/SpaceWanderer` at git.kkhost.pl). Gitea API access per Task 11's JC-20: prefer the pre-configured **gitea MCP tools** to create the PR; if you must use raw REST/curl, `source` the repo-root `.env` (`GITEA_TOKEN`, gitignored).
|
||||||
|
|
||||||
|
5. - [ ] **Auto-merge on green** — trigger Gitea's auto-merge: `POST /repos/OpenCode/SpaceWanderer/pulls/<n>/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. The PR merges automatically once the required status check `CI / build + unit + e2e (pull request)` passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR until `merged_at` is set (max ~15 min; poll the API every ~30s). If CI is red, **STOP and report the failure** — never force-merge.
|
||||||
|
|
||||||
|
6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.11.0 && git push origin v0.11.0`.
|
||||||
|
|
||||||
|
7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.11.0 payload.
|
||||||
|
|
||||||
|
8. - [ ] **Close the issue** — Gitea API: close issue #45 with a comment summarizing the shipped state (version 0.11.0, tag v0.11.0, live deploy verified).
|
||||||
|
|
||||||
|
**Verification gate (whole task):** `/api/version` on the live URL (`https://space-wanderer.dev.kkhost.pl`) reports 0.11.0 **AND** Gitea issue #45 is closed — only then is this PR done.
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
+87
-1
@@ -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;
|
||||||
@@ -334,7 +374,9 @@ test('CREW tab renders real roster cards, each showing both traits and an origin
|
|||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
||||||
const card = heading.locator('xpath=ancestor::div[3]');
|
const card = heading.locator('xpath=ancestor::div[3]');
|
||||||
await expect(card.getByText(member.role)).toBeVisible();
|
// exact: the new skill line renders 'ENGINEERING', which a loose 'ENGINEER' role search
|
||||||
|
// would also match (substring) — the role line is the only element with the role's exact text.
|
||||||
|
await expect(card.getByText(member.role, { exact: true })).toBeVisible();
|
||||||
await expect(card.getByText(member.traitGood)).toBeVisible();
|
await expect(card.getByText(member.traitGood)).toBeVisible();
|
||||||
await expect(card.getByText(member.traitBad)).toBeVisible();
|
await expect(card.getByText(member.traitBad)).toBeVisible();
|
||||||
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
|
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
|
||||||
@@ -1004,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
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const ADMIN_TABLES = {
|
|||||||
missions: schema.missions,
|
missions: schema.missions,
|
||||||
events: schema.events,
|
events: schema.events,
|
||||||
tasks: schema.tasks,
|
tasks: schema.tasks,
|
||||||
|
crew_skills: schema.crewSkills,
|
||||||
} as const satisfies Record<string, SQLiteTable>
|
} as const satisfies Record<string, SQLiteTable>
|
||||||
|
|
||||||
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
|
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
|
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
|
||||||
queued_at_turn INTEGER NOT NULL DEFAULT 0
|
queued_at_turn INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_skills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||||||
|
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
|
||||||
|
level INTEGER NOT NULL DEFAULT 0, -- 0–5
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
|
||||||
|
UNIQUE (crew_id, skill)
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
||||||
|
|||||||
@@ -153,3 +153,14 @@ export const tasks = sqliteTable('tasks', {
|
|||||||
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
|
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
|
||||||
queuedAtTurn: integer('queued_at_turn').notNull().default(0),
|
queuedAtTurn: integer('queued_at_turn').notNull().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
|
||||||
|
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
|
||||||
|
export const crewSkills = sqliteTable('crew_skills', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
crewId: integer('crew_id').notNull().references(() => crew.id),
|
||||||
|
skill: text('skill').notNull(), // SkillName
|
||||||
|
level: integer('level').notNull().default(0), // 0–5
|
||||||
|
xp: integer('xp').notNull().default(0),
|
||||||
|
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
|
||||||
|
|||||||
+11
-7
@@ -22,7 +22,9 @@ const LAST_NAMES = [
|
|||||||
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
|
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
|
// The role universe: the starting-crew draw and the recruit roll both pick from here.
|
||||||
|
// Exported so tests can pin that every role is covered by crew-costs's STARTING_LEVELS.
|
||||||
|
export const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
|
||||||
|
|
||||||
const GOOD_TRAITS = [
|
const GOOD_TRAITS = [
|
||||||
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
|
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
|
||||||
@@ -88,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) }
|
||||||
}
|
}
|
||||||
|
|||||||
+353
-10
@@ -2,16 +2,17 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { afterAll, describe, expect, it } from 'vitest'
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
import { 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 { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
import { CREW_CHANCE, ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||||
|
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, resolveJumpTask, resolveExploreTask, runTurn, 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 type { Task } from './tasks'
|
import { queueTask, type Task } from './tasks'
|
||||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
const dirs: string[] = []
|
const dirs: string[] = []
|
||||||
@@ -113,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)
|
||||||
@@ -258,6 +260,43 @@ describe('getCrewState', () => {
|
|||||||
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
|
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
|
||||||
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
|
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('attaches per-member skill levels + title: the four crew_skills rows in fixed order, SENIOR from L3, null below', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const { crew: seeded } = await getCrewState(db) // ensureSave seeds the starting crew + their crew_skills rows (v8)
|
||||||
|
expect(seeded.length).toBeGreaterThanOrEqual(2)
|
||||||
|
const [pinned, other] = [seeded[0], seeded[1]]
|
||||||
|
|
||||||
|
// Pin the first member to NAVIGATION L3 (xp 15 — the L3 threshold, so levelFromXp round-trips)
|
||||||
|
// with the other three skills L0, and reset the second member to all-L0 (a specialist role
|
||||||
|
// would otherwise seed L2).
|
||||||
|
for (const skill of ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] as const) {
|
||||||
|
const level = skill === 'NAVIGATION' ? 3 : 0
|
||||||
|
await db.db.update(schema.crewSkills).set({ level, xp: level > 0 ? XP_THRESHOLDS[level - 1] : 0 }).where(and(eq(schema.crewSkills.crewId, pinned.id), eq(schema.crewSkills.skill, skill)))
|
||||||
|
}
|
||||||
|
for (const r of (await db.db.select().from(schema.crewSkills)).filter((r) => r.crewId === other.id)) {
|
||||||
|
await db.db.update(schema.crewSkills).set({ level: 0, xp: 0 }).where(eq(schema.crewSkills.id, r.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { crew } = await getCrewState(db)
|
||||||
|
const p = crew.find((c) => c.id === pinned.id)!
|
||||||
|
expect(p.skills).toEqual([
|
||||||
|
{ skill: 'NAVIGATION', level: 3 },
|
||||||
|
{ skill: 'SCANNING', level: 0 },
|
||||||
|
{ skill: 'ENGINEERING', level: 0 },
|
||||||
|
{ skill: 'RESEARCH', level: 0 },
|
||||||
|
])
|
||||||
|
expect(p.title).toBe(`SENIOR ${p.role}`) // the pinned skill is the member's highest — L3 → SENIOR
|
||||||
|
|
||||||
|
const o = crew.find((c) => c.id === other.id)!
|
||||||
|
expect(o.skills).toEqual([
|
||||||
|
{ skill: 'NAVIGATION', level: 0 },
|
||||||
|
{ skill: 'SCANNING', level: 0 },
|
||||||
|
{ skill: 'ENGINEERING', level: 0 },
|
||||||
|
{ skill: 'RESEARCH', level: 0 },
|
||||||
|
])
|
||||||
|
expect(o.title).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('explorePlanet crew finds', () => {
|
describe('explorePlanet crew finds', () => {
|
||||||
@@ -428,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) -----------------
|
||||||
@@ -538,6 +738,103 @@ describe('runTurn (tick on the DB)', () => {
|
|||||||
expect(types.filter((t) => t === 'explore')).toHaveLength(1)
|
expect(types.filter((t) => t === 'explore')).toHaveLength(1)
|
||||||
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
|
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// PR B (#45): the fixture's crew rows never ran through createNewGame's seeder, so the
|
||||||
|
// tests here establish the crew_skills rows they need directly (insert / patch).
|
||||||
|
const seedRows = async (db: GameDb, saveId: number, crewId: number, rows: [skill: string, level: number, xp: number][]) => {
|
||||||
|
for (const [skill, level, xp] of rows) {
|
||||||
|
await db.db.insert(schema.crewSkills).values({ saveId, crewId, skill, level, xp })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// addCrew always mints PILOTs — insert a SCIENTIST directly (the pinCrew pattern from the
|
||||||
|
// v8 backfill test above) with duration-neutral traits.
|
||||||
|
const insertScientist = async (db: GameDb, saveId: number) =>
|
||||||
|
db.db.insert(schema.crew).values({ saveId, name: 'AVA VANCE', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||||
|
|
||||||
|
it('a resolved EXPLORE grants +1 SCANNING XP to every assigned member — lead and helpers alike — and no level-up event below L3', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2, c3] = await addCrew(db, 3)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (const id of [c1, c2, c3]) await seedRows(db, save.id, id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]])
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [c2, c3], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
|
||||||
|
const r = await runTurn(db)
|
||||||
|
expect(r.turn).toBe(1)
|
||||||
|
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||||
|
|
||||||
|
for (const id of [c1, c2, c3]) { // every assigned member's SCANNING row gained exactly +1 XP (xp 1 < the L1 threshold 5 — still L0); the other skills are untouched
|
||||||
|
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, id))
|
||||||
|
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 1 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } })
|
||||||
|
}
|
||||||
|
expect((await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')).toHaveLength(0) // L1 is a plain level — no event
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a SCIENTIST at SCANNING xp 14 (L2) completing an EXPLORE writes the §6 level-up event at the tick\'s new turn', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const sc = await insertScientist(db, save.id)
|
||||||
|
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 2, 14], ['ENGINEERING', 0, 0], ['RESEARCH', 2, 10]]) // one completion from L3
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
|
||||||
|
const r = await runTurn(db)
|
||||||
|
expect(r.turn).toBe(1)
|
||||||
|
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||||
|
|
||||||
|
const levelUps = (await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')
|
||||||
|
expect(levelUps.map((e) => [e.turn, e.message] as const)).toEqual([[1, 'AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3']]) // §6 text, at the tick's new turn
|
||||||
|
const scanning = (await db.db.select().from(schema.crewSkills).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING'))))[0]
|
||||||
|
expect({ level: scanning.level, xp: scanning.xp }).toEqual({ level: 3, xp: 15 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('§6 duration shift: a SCANNING L0 member queues a solo EXPLORE at stored duration 4; at L3 (xp 15) the next queued EXPLORE stores duration 2 (ceil(4/2))', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const sc = await insertScientist(db, save.id)
|
||||||
|
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // rookie — JC-23: SCIENTIST leads, no mismatch penalty
|
||||||
|
|
||||||
|
// Rookie (SCANNING L0 → power 1): the queued EXPLORE stores the full 4 turns.
|
||||||
|
const first = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||||||
|
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === first)!).toMatchObject({ status: 'ACTIVE', remaining: 4 })
|
||||||
|
|
||||||
|
// After 15 completed EXPLOREs the member is SCANNING L3 (power 2) — the next queued EXPLORE halves to 2.
|
||||||
|
await db.db.update(schema.crewSkills).set({ level: 3, xp: 15 }).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING')))
|
||||||
|
const second = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||||||
|
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === second)!).toMatchObject({ status: 'PENDING', remaining: 2 }) // PENDING — the lead chains behind its own ACTIVE
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a recruit found on a resolved EXPLORE is seeded with role-based starting skills — the 4-rows-per-member invariant holds (R-P6)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [c1] = await addCrew(db, 1) // a crew id (addCrew returns ids, not rows)
|
||||||
|
await seedRows(db, save.id, c1, [['NAVIGATION', 2, 10], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // PILOT profile
|
||||||
|
// Seed '3' + planet 1 (Alder) is the fixture's known recruit stream — the crew roll is
|
||||||
|
// recomputed here, mirroring save.ts: the item haul draws first, then the crew roll on the same stream.
|
||||||
|
await db.db.update(schema.saves).set({ seed: '3' })
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
|
||||||
|
const r = await runTurn(db)
|
||||||
|
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||||
|
|
||||||
|
const crew = await db.db.select().from(schema.crew)
|
||||||
|
expect(crew).toHaveLength(2) // exactly one join
|
||||||
|
const recruit = crew.find((c) => c.id !== c1)!
|
||||||
|
const expectedRng = explorationRng('3', f.p0)
|
||||||
|
rollExploration(expectedRng) // consume the haul draws first, exactly as save.ts does
|
||||||
|
const expectedMember = rollCrewMember(expectedRng, new Set(['CREW 1']))!
|
||||||
|
expect(recruit.name).toBe(expectedMember.name)
|
||||||
|
const levels = STARTING_LEVELS[expectedMember.role]
|
||||||
|
// The recruit gets the same profile as starting crew: role-based levels, xp = the level's threshold (levelFromXp round-trips).
|
||||||
|
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, recruit.id))
|
||||||
|
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual(
|
||||||
|
Object.fromEntries((Object.keys(levels) as SkillName[]).map((s) => [s, { level: levels[s], xp: levels[s] > 0 ? XP_THRESHOLDS[levels[s] - 1] : 0 }] as const)),
|
||||||
|
)
|
||||||
|
// The seeder is idempotent — the pre-seeded member is not re-seeded: still exactly 4 rows per member.
|
||||||
|
expect((await db.db.select().from(schema.crewSkills)).length).toBe(4 * crew.length)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- The GET snapshots' task queue (Task 6) ---------------------------------------------
|
// --- The GET snapshots' task queue (Task 6) ---------------------------------------------
|
||||||
@@ -718,10 +1015,10 @@ describe('backfill (old saves)', () => {
|
|||||||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||||
|
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
|
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
|
||||||
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7
|
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 → v8
|
||||||
|
|
||||||
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
|
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
|
||||||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
|
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
|
||||||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
|
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
|
||||||
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||||
@@ -730,10 +1027,56 @@ describe('backfill (old saves)', () => {
|
|||||||
|
|
||||||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||||
await getWorldState(reopened)
|
await getWorldState(reopened)
|
||||||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
|
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
|
||||||
|
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
|
||||||
|
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||||
|
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
|
||||||
|
const skillMap = async (db: GameDb, crewId: number) => {
|
||||||
|
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
|
||||||
|
expect(rows).toHaveLength(4)
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }]))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp consistent with level, state untouched; idempotent across restart', async () => {
|
||||||
|
const { db, file } = await freshFile()
|
||||||
|
await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21)
|
||||||
|
const save = (await db.db.select().from(schema.saves))[0]
|
||||||
|
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 7 })
|
||||||
|
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
|
||||||
|
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
|
||||||
|
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
|
||||||
|
const crewBefore = await db.db.select().from(schema.crew)
|
||||||
|
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||||
|
const turnBefore = (await db.db.select().from(schema.saves))[0].turn
|
||||||
|
await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 10 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT
|
||||||
|
expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 10 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 10 } }) // SCIENTIST
|
||||||
|
expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0)
|
||||||
|
for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp = the level's threshold (levelFromXp round-trips)
|
||||||
|
const expected = Object.fromEntries((Object.keys(STARTING_LEVELS[c.role]) as SkillName[]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: STARTING_LEVELS[c.role][s] > 0 ? XP_THRESHOLDS[STARTING_LEVELS[c.role][s] - 1] : 0 }]))
|
||||||
|
expect(await skillMap(db, c.id)).toEqual(expected)
|
||||||
|
}
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory
|
||||||
|
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
|
||||||
|
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||||
|
await getWorldState(reopened)
|
||||||
|
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pins the role universe: every role in crew.ts is a key of STARTING_LEVELS, so the v8 seeder never hits an unknown role', () => {
|
||||||
|
for (const role of ROLES) {
|
||||||
|
expect(STARTING_LEVELS[role], `STARTING_LEVELS[${role}]`).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
|
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
|
||||||
const { db } = await freshFile()
|
const { db } = await freshFile()
|
||||||
await getWorldState(db)
|
await getWorldState(db)
|
||||||
|
|||||||
+151
-13
@@ -1,12 +1,14 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import type { GameDb } from '@/lib/db/client'
|
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, 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'
|
||||||
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
|
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
|
||||||
|
import { grantXp, makePowerOf, titleFor, type CrewIdentity, type SkillRow } from './skills'
|
||||||
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
|
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
|
||||||
import { TECHS } from './tech'
|
import { TECHS } from './tech'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
|
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
|
||||||
@@ -119,7 +121,7 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
|
|||||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||||
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
||||||
// current — each step is idempotent (a no-op when its data already exists).
|
// current — each step is idempotent (a no-op when its data already exists).
|
||||||
export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table
|
export const SCHEMA_VERSION = 8 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table · v8: crew_skills (role-based starting levels)
|
||||||
|
|
||||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||||
// unbuilt (level/capacity 0) until a BUILD action.
|
// unbuilt (level/capacity 0) until a BUILD action.
|
||||||
@@ -136,6 +138,24 @@ async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
|
||||||
|
// (level = STARTING_LEVELS[role][skill], xp = the threshold that level implies — levelFromXp
|
||||||
|
// must round-trip: a seeded L2 starts at xp 10, not 0, or Task 14's grantXp (which recomputes
|
||||||
|
// level from cumulative xp) would regress the member L2 → L0). Called by createNewGame and the
|
||||||
|
// v8 backfill. A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing
|
||||||
|
// — the seeder must never brick a save.
|
||||||
|
async function seedCrewSkills(db: GameDb, saveId: number) {
|
||||||
|
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
|
||||||
|
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
|
||||||
|
for (const m of crew) {
|
||||||
|
if (have.has(m.id)) continue // already seeded — idempotent
|
||||||
|
const levels = STARTING_LEVELS[m.role] ?? { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }
|
||||||
|
for (const skill of Object.keys(levels) as SkillName[]) {
|
||||||
|
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: levels[skill] > 0 ? XP_THRESHOLDS[levels[skill] - 1] : 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
||||||
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
||||||
export async function ensureSave(db: GameDb): Promise<void> {
|
export async function ensureSave(db: GameDb): Promise<void> {
|
||||||
@@ -162,6 +182,7 @@ async function createNewGame(db: GameDb): Promise<void> {
|
|||||||
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
||||||
await insertRoutes(db, region.edges, resolve)
|
await insertRoutes(db, region.edges, resolve)
|
||||||
await seedStartingCrew(db, save.id, seed)
|
await seedStartingCrew(db, save.id, seed)
|
||||||
|
await seedCrewSkills(db, save.id)
|
||||||
await seedStartingShip(db, save.id)
|
await seedStartingShip(db, save.id)
|
||||||
await seedStartingTech(db, save.id)
|
await seedStartingTech(db, save.id)
|
||||||
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
|
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
|
||||||
@@ -200,6 +221,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
|||||||
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
|
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
|
||||||
version = 7
|
version = 7
|
||||||
}
|
}
|
||||||
|
if (version < 8) {
|
||||||
|
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
|
||||||
|
version = 8
|
||||||
|
}
|
||||||
|
|
||||||
if (from !== SCHEMA_VERSION) {
|
if (from !== SCHEMA_VERSION) {
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||||
@@ -329,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() })
|
||||||
@@ -460,6 +503,28 @@ export interface CrewDto {
|
|||||||
traitGood: string
|
traitGood: string
|
||||||
traitBad: string
|
traitBad: string
|
||||||
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||||
|
skills: { skill: SkillName; level: number }[] // the member's four crew_skills rows, fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (JC-27); display-oriented — no xp (JC-26)
|
||||||
|
title: string | null // 'SENIOR <ROLE>' from the member's highest level ≥ 3, 'MASTER <ROLE>' from ≥ 5, null below (titleFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixed display order of the four skills (JC-27, spec §6) — the CREW-tab line and the
|
||||||
|
// /api/crew payload never depend on DB row order.
|
||||||
|
const SKILL_ORDER: SkillName[] = ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH']
|
||||||
|
|
||||||
|
// One CrewDto from a crew row and that member's crew_skills rows: the four fixed-order
|
||||||
|
// { skill, level } entries (JC-26/27) plus the title from the member's highest level
|
||||||
|
// (titleFor — null below L3). A member with no rows (a hand-built save whose crew was never
|
||||||
|
// seeded) reads all-L0 with a null title — the same fallback as makePowerOf's missing-row →
|
||||||
|
// L0 → power 1, so a preview built from the DTO matches the queueing route's rows.
|
||||||
|
function buildCrewDto(
|
||||||
|
c: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string },
|
||||||
|
skillRows: { crewId: number; skill: string; level: number }[],
|
||||||
|
): CrewDto {
|
||||||
|
const levelBySkill = new Map<SkillName, number>()
|
||||||
|
for (const r of skillRows) levelBySkill.set(r.skill as SkillName, r.level)
|
||||||
|
const skills = SKILL_ORDER.map((skill) => ({ skill, level: levelBySkill.get(skill) ?? 0 }))
|
||||||
|
const title = titleFor({ name: c.name, role: c.role }, skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
|
||||||
|
return { id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin, skills, title }
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
|
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
|
||||||
@@ -473,8 +538,17 @@ export interface CrewState {
|
|||||||
// Auto-initializes like the other reads.
|
// Auto-initializes like the other reads.
|
||||||
export async function getCrewState(db: GameDb): Promise<CrewState> {
|
export async function getCrewState(db: GameDb): Promise<CrewState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const crew = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
const [crew, skillRows] = await Promise.all([
|
||||||
return { crew, tasks: await taskQueue(db) }
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.crewSkills),
|
||||||
|
])
|
||||||
|
const rowsByCrew = new Map<number, typeof skillRows>()
|
||||||
|
for (const r of skillRows) {
|
||||||
|
const list = rowsByCrew.get(r.crewId) ?? []
|
||||||
|
list.push(r)
|
||||||
|
rowsByCrew.set(r.crewId, list)
|
||||||
|
}
|
||||||
|
return { crew: crew.map((c) => buildCrewDto(c, rowsByCrew.get(c.id) ?? [])), tasks: await taskQueue(db) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreResult extends SystemState {
|
export interface ExploreResult extends SystemState {
|
||||||
@@ -496,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) {
|
||||||
@@ -516,7 +607,8 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
|
|||||||
const systems = await db.db.select().from(schema.solarSystems)
|
const systems = await db.db.select().from(schema.solarSystems)
|
||||||
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
|
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
|
||||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
|
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
|
||||||
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
|
await seedCrewSkills(db, save.id) // the recruit gets role-based starting rows like the starting crew — the seeder is idempotent, so only the new member is seeded (R-P6)
|
||||||
|
crewFound = buildCrewDto(row, await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, row.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
||||||
@@ -589,16 +681,17 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str
|
|||||||
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
|
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
|
const [crew, taskRows, inventory, shipSystems, tech, skillRows] = await Promise.all([
|
||||||
db.db.select().from(schema.crew),
|
db.db.select().from(schema.crew),
|
||||||
db.db.select().from(schema.tasks),
|
db.db.select().from(schema.tasks),
|
||||||
db.db.select().from(schema.inventoryItems),
|
db.db.select().from(schema.inventoryItems),
|
||||||
db.db.select().from(schema.shipSystems),
|
db.db.select().from(schema.shipSystems),
|
||||||
db.db.select().from(schema.tech),
|
db.db.select().from(schema.tech),
|
||||||
|
db.db.select().from(schema.crewSkills),
|
||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
turn: save.turn,
|
turn: save.turn,
|
||||||
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
|
crew: crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))),
|
||||||
tasks: taskRows.map(rowToTask),
|
tasks: taskRows.map(rowToTask),
|
||||||
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
|
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
|
||||||
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
||||||
@@ -640,6 +733,16 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
|||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const state = await queueStateFromSave(db)
|
const state = await queueStateFromSave(db)
|
||||||
|
|
||||||
|
// PR B (§6): the skill rows this tick's XP grant runs over — string ids end to end (JC-24):
|
||||||
|
// crew_skills' numeric id columns stringify into SkillRow's, and `members` keys off
|
||||||
|
// String(crew.id) so grantXp's crewIds.includes(r.crewId) matches. The snapshot is taken
|
||||||
|
// before any resolution: a recruit who joins mid-tick has no rows in it — and a recruit is
|
||||||
|
// never on a task that completes in the same tick.
|
||||||
|
const skillRows0: SkillRow[] = (await db.db.select().from(schema.crewSkills)).map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||||
|
let skillRows = skillRows0
|
||||||
|
const members: Record<string, CrewIdentity> = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }]))
|
||||||
|
const levelUpEvents: string[] = []
|
||||||
|
|
||||||
const result = tickTurn(state)
|
const result = tickTurn(state)
|
||||||
|
|
||||||
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
|
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
|
||||||
@@ -654,12 +757,33 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
|||||||
for (const task of result.completed) {
|
for (const task of result.completed) {
|
||||||
try {
|
try {
|
||||||
await TASK_RESOLVERS[task.type](db, task)
|
await TASK_RESOLVERS[task.type](db, task)
|
||||||
|
// §6: every assigned member — lead and helpers equally — gains +1 XP in the action's
|
||||||
|
// skill. The rows reassign, so a member on several completions this tick accumulates,
|
||||||
|
// and the level-up events collect for the batch write below. A task whose resolver
|
||||||
|
// failed its 4a guard grants nothing: its effect did not land.
|
||||||
|
const { rows, events } = grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)
|
||||||
|
skillRows = rows
|
||||||
|
levelUpEvents.push(...events)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tick's level-up events as one batch at the new turn, and the crew_skills rows that
|
||||||
|
// changed — both land in the final whole-DB write (JC-25): the resolution, the events and
|
||||||
|
// the skill progression commit together. Only rows whose level or xp moved are written.
|
||||||
|
for (const message of levelUpEvents) {
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: result.turn, type: 'level_up', message, createdAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
const beforeSkill = new Map(skillRows0.map((r): [string, SkillRow] => [`${r.crewId}:${r.skill}`, r]))
|
||||||
|
for (const row of skillRows) {
|
||||||
|
const old = beforeSkill.get(`${row.crewId}:${row.skill}`)!
|
||||||
|
if (old.level !== row.level || old.xp !== row.xp) {
|
||||||
|
await db.db.update(schema.crewSkills).set({ level: row.level, xp: row.xp }).where(and(eq(schema.crewSkills.crewId, Number(row.crewId)), eq(schema.crewSkills.skill, row.skill)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
|
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
|
||||||
// one final whole-DB write makes the turn atomic on disk.
|
// one final whole-DB write makes the turn atomic on disk.
|
||||||
for (const before of state.tasks) {
|
for (const before of state.tasks) {
|
||||||
@@ -672,3 +796,17 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queueing routes' power-band builder (PR B, §6): the save's roster and crew_skills rows
|
||||||
|
// folded into makePowerOf's closure for the action's skill — the same closure the live
|
||||||
|
// duration preview uses, so a queued task's stored duration matches what the crew picker
|
||||||
|
// shows. The single save row is passed in by the route rather than re-selected here.
|
||||||
|
export async function makePowerOfFor(db: GameDb, save: { id: number }, type: TaskType): Promise<(m: CrewMember) => number> {
|
||||||
|
const [crew, skillRows] = await Promise.all([
|
||||||
|
db.db.select().from(schema.crew).where(eq(schema.crew.saveId, save.id)),
|
||||||
|
db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.saveId, save.id)),
|
||||||
|
])
|
||||||
|
const roster: CrewDto[] = crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id)))
|
||||||
|
const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||||
|
return makePowerOf(roster, rows, type)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { grantXp, levelFromXp, powerForLevel, titleFor, type SkillRow } from './skills'
|
||||||
|
const pilot = { name: 'BEN OKONKWO', role: 'PILOT' }
|
||||||
|
const row = (crewId: string, skill: SkillName, level: number, xp: number): SkillRow =>
|
||||||
|
({ saveId: 's1', crewId, skill, level, xp })
|
||||||
|
const XP: [number, number][] = [[4, 0], [5, 1], [10, 2], [15, 3], [25, 5], [100, 5]]
|
||||||
|
describe('levelFromXp (thresholds [5,10,15,20,25], cap 5)', () => {
|
||||||
|
it('maps cumulative xp → highest level reached', () => {
|
||||||
|
for (const [xp, lvl] of XP) expect(levelFromXp(xp), `xp ${xp}`).toBe(lvl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const POWER: [number, number][] = [[0, 1], [2, 1], [3, 2], [4, 2], [5, 3]]
|
||||||
|
describe('powerForLevel (bands [1,1,1,2,2,3])', () => {
|
||||||
|
it('L0–2 → 1 · L3–4 → 2 · L5 → 3', () => {
|
||||||
|
for (const [lvl, p] of POWER) expect(powerForLevel(lvl), `L${lvl}`).toBe(p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('grantXp — +1 XP to every listed member; event only on a titled-band crossing', () => {
|
||||||
|
const pilotRows = (): SkillRow[] => [
|
||||||
|
row('c1', 'NAVIGATION', 2, 14), row('c1', 'SCANNING', 0, 0),
|
||||||
|
row('c1', 'ENGINEERING', 0, 0), row('c1', 'RESEARCH', 0, 0),
|
||||||
|
]
|
||||||
|
it('L0 → L1: xp 4 +1 → 5 (L1), no event (no event for plain L1/L2)', () => {
|
||||||
|
const { rows, events } = grantXp([row('c1', 'NAVIGATION', 0, 4)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows[0]).toEqual({ saveId: 's1', crewId: 'c1', skill: 'NAVIGATION', level: 1, xp: 5 })
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
it('L2 → L3: xp 14 +1 → 15, SENIOR event in the exact spec §6 text', () => {
|
||||||
|
const { rows, events } = grantXp(pilotRows(), ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows.find((r) => r.skill === 'NAVIGATION')!.level).toBe(3)
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3'])
|
||||||
|
})
|
||||||
|
it('L4 → L5: xp 24 +1 → 25, MASTER event', () => {
|
||||||
|
const { events } = grantXp([row('c1', 'NAVIGATION', 4, 24), row('c1', 'SCANNING', 0, 0)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a MASTER PILOT — NAVIGATION L5'])
|
||||||
|
})
|
||||||
|
it('no event when a member is already at the titled level (stays at L3 after gaining xp)', () => {
|
||||||
|
// Member at SCANNING L3 (xp 16) gains 1 → xp 17, still L3 → NO event
|
||||||
|
const rows = [row('c1', 'SCANNING', 3, 16), row('c1', 'NAVIGATION', 0, 0)]
|
||||||
|
const { events } = grantXp(rows, ['c1'], 'SCANNING', { c1: { name: 'X', role: 'SCIENTIST' } })
|
||||||
|
expect(events).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('titleFor — from the member\'s HIGHEST skill level', () => {
|
||||||
|
it('SCIENTIST with SCANNING L3 + RESEARCH L5 → MASTER SCIENTIST (highest wins)', () => {
|
||||||
|
const scientist = { name: 'AVA VANCE', role: 'SCIENTIST' }
|
||||||
|
const rows = [row('c2', 'SCANNING', 3, 15), row('c2', 'RESEARCH', 5, 25), row('c2', 'NAVIGATION', 0, 0), row('c2', 'ENGINEERING', 0, 0)]
|
||||||
|
expect(titleFor(scientist, rows)).toBe('MASTER SCIENTIST')
|
||||||
|
})
|
||||||
|
it('GUNNER with all L0 → null', () => {
|
||||||
|
const gunner = { name: 'RIG HOLLOWAY', role: 'GUNNER' }
|
||||||
|
const rows = [row('c3', 'NAVIGATION', 0, 0), row('c3', 'SCANNING', 0, 0), row('c3', 'ENGINEERING', 0, 0), row('c3', 'RESEARCH', 0, 0)]
|
||||||
|
expect(titleFor(gunner, rows)).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
|
||||||
|
import { ACTION_SKILL, POWER_BANDS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||||
|
import type { CrewMember } from './crew'
|
||||||
|
import type { CrewDto } from './save'
|
||||||
|
import type { TaskType } from './tasks'
|
||||||
|
export interface SkillRow {
|
||||||
|
saveId: string
|
||||||
|
crewId: string
|
||||||
|
skill: SkillName
|
||||||
|
level: number
|
||||||
|
xp: number
|
||||||
|
}
|
||||||
|
export type CrewIdentity = { name: string; role: string }
|
||||||
|
// Highest level 0–5 with xp ≥ XP_THRESHOLDS[level−1]; thresholds [5,10,15,20,25], cap 5.
|
||||||
|
export function levelFromXp(xp: number): number {
|
||||||
|
let level = 0
|
||||||
|
for (let l = 1; l <= XP_THRESHOLDS.length; l++) if (xp >= XP_THRESHOLDS[l - 1]) level = l
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
// L0–2 → 1 · L3–4 → 2 · L5 → 3 (direct lookup into crew-costs POWER_BANDS).
|
||||||
|
export function powerForLevel(level: number): number {
|
||||||
|
return POWER_BANDS[level]
|
||||||
|
}
|
||||||
|
// From the member's HIGHEST skill level: ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
|
||||||
|
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
|
||||||
|
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
|
||||||
|
if (highest >= 5) return `MASTER ${member.role}`
|
||||||
|
if (highest >= 3) return `SENIOR ${member.role}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
|
||||||
|
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
|
||||||
|
export function grantXp(
|
||||||
|
skillRows: SkillRow[],
|
||||||
|
crewIds: string[],
|
||||||
|
skill: SkillName,
|
||||||
|
members: Record<string, CrewIdentity>,
|
||||||
|
): { rows: SkillRow[]; events: string[] } {
|
||||||
|
const rows = skillRows.map((r) =>
|
||||||
|
r.skill === skill && crewIds.includes(r.crewId)
|
||||||
|
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
|
||||||
|
: r,
|
||||||
|
)
|
||||||
|
const events: string[] = []
|
||||||
|
for (const crewId of crewIds) {
|
||||||
|
const member = members[crewId]
|
||||||
|
if (!member) continue
|
||||||
|
const oldLevel = skillRows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
if (newLevel > oldLevel && (newLevel === 3 || newLevel === 5)) {
|
||||||
|
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
|
||||||
|
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The duration formula's powerOf (Task 1) driven by the skill band (PR B, §6): each member's
|
||||||
|
// power is the band of that member's level in the action's skill (ACTION_SKILL[type]). Names
|
||||||
|
// map to crew ids (unique per single-player save); an unknown member or a missing row falls
|
||||||
|
// back to L0 → power 1 (Task 1's default). Called by save.ts's makePowerOfFor (the queueing
|
||||||
|
// routes) and — once /api/crew carries each member's skill rows — by the team builder's live
|
||||||
|
// preview (Task 15).
|
||||||
|
export function makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType): (m: CrewMember) => number {
|
||||||
|
const idByName = new Map(roster.map((c): [string, string] => [c.name, String(c.id)]))
|
||||||
|
const levelByCrew = new Map(skillRows.filter((r) => r.skill === ACTION_SKILL[type]).map((r): [string, number] => [r.crewId, r.level]))
|
||||||
|
return (m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)
|
||||||
|
}
|
||||||
+21
-5
@@ -93,12 +93,19 @@ describe('crew-costs — the §11 tuning surface', () => {
|
|||||||
|
|
||||||
// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster,
|
// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster,
|
||||||
// a state factory (turn 10, empty queue) and a one-turn row factory.
|
// a state factory (turn 10, empty queue) and a one-turn row factory.
|
||||||
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
|
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and
|
||||||
// the CrewDto shape (unused by the duration model but part of the type).
|
// `skills` fill the CrewDto shape (unused by the duration model but part of the type):
|
||||||
|
// each role's STARTING_LEVELS (all below L3, so every fixture member's title is null).
|
||||||
|
const skillsOf = (n: number, s: number, e: number, r: number) => [
|
||||||
|
{ skill: 'NAVIGATION' as const, level: n },
|
||||||
|
{ skill: 'SCANNING' as const, level: s },
|
||||||
|
{ skill: 'ENGINEERING' as const, level: e },
|
||||||
|
{ skill: 'RESEARCH' as const, level: r },
|
||||||
|
]
|
||||||
const CREW: CrewDto[] = [
|
const CREW: CrewDto[] = [
|
||||||
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
|
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW', skills: skillsOf(2, 0, 0, 0), title: null },
|
||||||
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
|
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW', skills: skillsOf(0, 0, 2, 0), title: null },
|
||||||
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
|
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW', skills: skillsOf(0, 2, 0, 2), title: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
||||||
@@ -163,6 +170,15 @@ describe('queueTask', () => {
|
|||||||
expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
|
expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
|
||||||
expect(active.task.queuedAtTurn).toBe(10)
|
expect(active.task.queuedAtTurn).toBe(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('power-band param: a powerOf returning 2 halves a solo EXPLORE (ceil(4/2) = 2); the default flat 1 still stores 4 (backward-compatible)', () => {
|
||||||
|
// CASS (id 3) is a SCIENTIST — the preferred EXPLORE role, so no mismatch penalty; her
|
||||||
|
// trait pair (CALM IN A CRISIS −1 / FORGETS TO EAT +1) cancels on EXPLORE.
|
||||||
|
const withPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }, () => 2)
|
||||||
|
expect(withPower.task.remaining).toBe(2) // ceil(4/2)
|
||||||
|
const defaultPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] })
|
||||||
|
expect(defaultPower.task.remaining).toBe(4) // no powerOf → the default flat 1
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
||||||
|
|||||||
+9
-5
@@ -51,9 +51,10 @@ function traitModifier(trait: string, type: TaskType): number {
|
|||||||
|
|
||||||
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
|
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
|
||||||
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
|
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
|
||||||
// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers
|
// constant 1 — queueTask's optional powerOf param feeds the skill band instead (PR B). The
|
||||||
// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do
|
// mismatch penalty and the trait modifiers apply to the LEAD only — helpers contribute
|
||||||
// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types).
|
// power, are never penalised, and their traits do not apply. `hopCount` is only meaningful
|
||||||
|
// for JUMP (pass 1 for the other types).
|
||||||
export function computeDuration(
|
export function computeDuration(
|
||||||
type: TaskType,
|
type: TaskType,
|
||||||
hopCount: number,
|
hopCount: number,
|
||||||
@@ -119,7 +120,10 @@ export interface QueueResult {
|
|||||||
|
|
||||||
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
|
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
|
||||||
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
|
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
|
||||||
export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult {
|
// The optional powerOf (default: the constant 1) maps each team member to their power for
|
||||||
|
// the stored duration — the queueing routes pass makePowerOf over the roster's skill rows
|
||||||
|
// (PR B); callers without a skill context (tests, the immediate actions) use the default.
|
||||||
|
export function queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1): QueueResult {
|
||||||
const lead = state.crew.find((c) => c.id === req.leadCrewId)
|
const lead = state.crew.find((c) => c.id === req.leadCrewId)
|
||||||
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
|
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
|
||||||
const helpers: CrewDto[] = []
|
const helpers: CrewDto[] = []
|
||||||
@@ -168,7 +172,7 @@ export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult
|
|||||||
// lead chaining behind its own ACTIVE can be non-free.
|
// lead chaining behind its own ACTIVE can be non-free.
|
||||||
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
|
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
|
||||||
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
|
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
|
||||||
const duration = computeDuration(req.type, hops, lead, helpers)
|
const duration = computeDuration(req.type, hops, lead, helpers, powerOf)
|
||||||
const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
|
const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+107
-10
@@ -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
|
})
|
||||||
|
|
||||||
|
it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => {
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
const n = ring.systems.length
|
||||||
|
expect(ring.edges).toEqual(ringEdges(n)) // anchor↔each member + the member cycle
|
||||||
for (const [a, b] of ring.edges) {
|
for (const [a, b] of ring.edges) {
|
||||||
expect(a).toBeGreaterThanOrEqual(0)
|
expect(a).toBeGreaterThanOrEqual(0)
|
||||||
expect(a).toBeLessThan(5)
|
expect(a).toBeLessThan(n + 1)
|
||||||
expect(b).toBeGreaterThanOrEqual(0)
|
expect(b).toBeGreaterThanOrEqual(0)
|
||||||
expect(b).toBeLessThan(5)
|
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
@@ -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.
|
||||||
|
|||||||
+8
-3
@@ -111,7 +111,7 @@ describe('OpenAPI spec', () => {
|
|||||||
|
|
||||||
const props = openApiSpec.components.schemas.Event.properties;
|
const props = openApiSpec.components.schemas.Event.properties;
|
||||||
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
||||||
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
|
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up']);
|
||||||
|
|
||||||
const get = openApiSpec.paths['/api/events'].get;
|
const get = openApiSpec.paths['/api/events'].get;
|
||||||
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
||||||
@@ -137,8 +137,13 @@ describe('OpenAPI spec', () => {
|
|||||||
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
|
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
|
||||||
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
const member = openApiSpec.components.schemas.CrewMember.properties;
|
// R-P1: the response object is camelCase (traitGood/traitBad), and since the skill-
|
||||||
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
|
// progression work the member also carries its four fixed-order skill levels + title.
|
||||||
|
const member = openApiSpec.components.schemas.CrewMember as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(member.properties)).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
|
||||||
|
expect(member.required).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
|
||||||
|
expect((member.properties.skills as { items: { required: string[] } }).items.required).toEqual(['skill', 'level']);
|
||||||
|
expect((member.properties.title as { nullable: boolean }).nullable).toBe(true);
|
||||||
|
|
||||||
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
|
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
|
||||||
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
|
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
|
||||||
|
|||||||
+19
-6
@@ -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,
|
||||||
@@ -976,11 +976,24 @@ export const openApiSpec = {
|
|||||||
id: { type: 'integer' },
|
id: { type: 'integer' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
|
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
|
||||||
trait_good: { type: 'string', description: 'The member good trait' },
|
traitGood: { type: 'string', description: 'The member good trait' },
|
||||||
trait_bad: { type: 'string', description: 'The member bad trait' },
|
traitBad: { type: 'string', description: 'The member bad trait' },
|
||||||
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" },
|
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" },
|
||||||
|
skills: {
|
||||||
|
type: 'array',
|
||||||
|
description: "The member's four crew_skills levels in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (display-oriented — levels only, no xp)",
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
skill: { type: 'string', enum: ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] },
|
||||||
|
level: { type: 'integer' },
|
||||||
},
|
},
|
||||||
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
|
required: ['skill', 'level'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: { type: ['string', 'null'], nullable: true, description: "'SENIOR <ROLE>' from the member's highest skill level ≥ 3, 'MASTER <ROLE>' from ≥ 5 — null below" },
|
||||||
|
},
|
||||||
|
required: ['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title'],
|
||||||
},
|
},
|
||||||
CrewState: {
|
CrewState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1080,7 +1093,7 @@ export const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'integer' },
|
id: { type: 'integer' },
|
||||||
turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' },
|
turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' },
|
||||||
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up'] },
|
||||||
message: { type: 'string' },
|
message: { type: 'string' },
|
||||||
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
||||||
},
|
},
|
||||||
@@ -1170,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.10.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user