db+game: tech table + starting tech list seeded on new game and v5 backfill step
This commit is contained in:
@@ -81,6 +81,19 @@ CREATE TABLE IF NOT EXISTS ship_systems (
|
|||||||
UNIQUE (save_id, type)
|
UNIQUE (save_id, type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tech (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
slug TEXT NOT NULL, -- stable key, e.g. WEAPONS_SYSTEMS
|
||||||
|
name TEXT NOT NULL, -- display name, e.g. 'WEAPONS SYSTEMS'
|
||||||
|
cost_kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||||
|
cost_qty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
prerequisites TEXT NOT NULL DEFAULT '', -- comma-separated tech slugs, '' when none
|
||||||
|
effect TEXT NOT NULL, -- effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||||
|
researched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (save_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
|||||||
@@ -88,6 +88,26 @@ export const shipSystems = sqliteTable(
|
|||||||
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The technology tree, one row per known tech (see TECHS in lib/game/tech.ts). Definitions are
|
||||||
|
// seeded from the pure module on new game / backfill v5 — same pattern as ship_systems.
|
||||||
|
// `researched` is the per-save progress; prerequisites/effect are stored for completeness but
|
||||||
|
// the canonical semantics live in lib/game/tech.ts (EFFECTS).
|
||||||
|
export const tech = sqliteTable(
|
||||||
|
'tech',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
slug: text('slug').notNull(), // stable key, e.g. WEAPONS_SYSTEMS
|
||||||
|
name: text('name').notNull(), // display name, e.g. 'WEAPONS SYSTEMS'
|
||||||
|
costKind: text('cost_kind').notNull(), // 'resource' | 'part'
|
||||||
|
costQty: integer('cost_qty').notNull().default(1),
|
||||||
|
prerequisites: text('prerequisites').notNull().default(''), // comma-separated tech slugs, '' when none
|
||||||
|
effect: text('effect').notNull(), // effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||||
|
researched: integer('researched', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
|
||||||
|
)
|
||||||
|
|
||||||
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||||
// writers and a read endpoint.
|
// writers and a read endpoint.
|
||||||
export const events = sqliteTable('events', {
|
export const events = sqliteTable('events', {
|
||||||
|
|||||||
+26
-1
@@ -7,6 +7,7 @@ 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 { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||||
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 { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
@@ -329,11 +330,15 @@ describe('backfill (old saves)', () => {
|
|||||||
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
||||||
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
||||||
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
||||||
|
const techShape = (rows: { id: number; slug: string; name: string; costKind: string; costQty: number; prerequisites: string; effect: string; researched: boolean }[]) =>
|
||||||
|
rows.map((t) => ({ slug: t.slug, name: t.name, costKind: t.costKind, costQty: t.costQty, prerequisites: t.prerequisites, effect: t.effect, researched: t.researched })).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
const beforeTech = techShape(await db.db.select().from(schema.tech))
|
||||||
|
|
||||||
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL).
|
// Simulate a save created by pre-v0.5 code: no bodies, no crew, no ship, no tech, no version stamp (NULL).
|
||||||
await db.db.delete(schema.planets)
|
await db.db.delete(schema.planets)
|
||||||
await db.db.delete(schema.crew)
|
await db.db.delete(schema.crew)
|
||||||
await db.db.delete(schema.shipSystems)
|
await db.db.delete(schema.shipSystems)
|
||||||
|
await db.db.delete(schema.tech)
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: null })
|
await db.db.update(schema.saves).set({ schemaVersion: null })
|
||||||
|
|
||||||
const world = await getWorldState(db) // ensureSave → backfill runs
|
const world = await getWorldState(db) // ensureSave → backfill runs
|
||||||
@@ -345,6 +350,7 @@ describe('backfill (old saves)', () => {
|
|||||||
}
|
}
|
||||||
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
||||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||||
|
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||||
|
|
||||||
const [save] = await db.db.select().from(schema.saves)
|
const [save] = await db.db.select().from(schema.saves)
|
||||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
@@ -355,6 +361,7 @@ describe('backfill (old saves)', () => {
|
|||||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||||
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
||||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||||
|
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||||
for (const s of systems) {
|
for (const s of systems) {
|
||||||
const rows = await rowsFor(s.id)
|
const rows = await rowsFor(s.id)
|
||||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
||||||
@@ -387,4 +394,22 @@ describe('backfill (old saves)', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('seeds the tech tree on a new game: one row per known tech, none researched', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const rows = (await db.db.select().from(schema.tech)).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
expect(rows).toHaveLength(TECHS.length)
|
||||||
|
for (const def of TECHS) {
|
||||||
|
const row = rows.find((r) => r.slug === def.slug)!
|
||||||
|
expect({ name: row.name, costKind: row.costKind, costQty: row.costQty, prerequisites: row.prerequisites, effect: row.effect }).toEqual({
|
||||||
|
name: def.name,
|
||||||
|
costKind: def.costKind,
|
||||||
|
costQty: def.costQty,
|
||||||
|
prerequisites: def.prereqs.join(','),
|
||||||
|
effect: def.effect,
|
||||||
|
})
|
||||||
|
expect(row.researched).toBe(false) // nothing is researched at the start
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-2
@@ -4,6 +4,7 @@ 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 { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
|
import { TECHS } from './tech'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
||||||
|
|
||||||
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||||
@@ -94,9 +95,9 @@ 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). When #9/#10 add
|
// current — each step is idempotent (a no-op when its data already exists). When #10 adds
|
||||||
// seeding, bump SCHEMA_VERSION and append a step here.
|
// seeding, bump SCHEMA_VERSION and append a step here.
|
||||||
export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship
|
export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -106,6 +107,13 @@ async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seeds the tech tree: one row per known tech (definitions from lib/game/tech.ts), none researched.
|
||||||
|
async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
|
||||||
|
for (const def of TECHS) {
|
||||||
|
await db.db.insert(schema.tech).values({ saveId, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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> {
|
||||||
@@ -133,6 +141,7 @@ async function createNewGame(db: GameDb): Promise<void> {
|
|||||||
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 seedStartingShip(db, save.id)
|
await seedStartingShip(db, save.id)
|
||||||
|
await seedStartingTech(db, save.id)
|
||||||
|
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
await persist(db)
|
await persist(db)
|
||||||
@@ -155,6 +164,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
|||||||
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
||||||
version = 4
|
version = 4
|
||||||
}
|
}
|
||||||
|
if (version < 5) {
|
||||||
|
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
|
||||||
|
version = 5
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Pure technology model: the known techs, their costs/prerequisites and what each unlock does.
|
||||||
|
// No DB imports — both lib/game/research.ts + lib/game/ship.ts (server) and the frontend import
|
||||||
|
// from here so research/cap/gate logic can never drift from what the API enforces.
|
||||||
|
|
||||||
|
import { MAX_CAPACITY, MAX_LEVEL } from './ship-costs'
|
||||||
|
|
||||||
|
export interface TechDef {
|
||||||
|
slug: string // stable key used in DB + prerequisites
|
||||||
|
name: string // display name
|
||||||
|
costKind: 'resource' | 'part'
|
||||||
|
costQty: number
|
||||||
|
prereqs: string[] // slugs that must be researched first (short chains only)
|
||||||
|
effect: string // effect slug — see EFFECTS below
|
||||||
|
}
|
||||||
|
|
||||||
|
// The technology tree, in display order. Roots have no prerequisites; the two chains are
|
||||||
|
// WEAPONS SYSTEMS → PLASMA CANNON and REACTOR OVERCLOCK → FUSION CORE. Costs stay small so a
|
||||||
|
// handful of scans makes something affordable (the e2e scan loop relies on that).
|
||||||
|
export const TECHS: TechDef[] = [
|
||||||
|
{ slug: 'WEAPONS_SYSTEMS', name: 'WEAPONS SYSTEMS', costKind: 'part', costQty: 2, prereqs: [], effect: 'BUILD_WEAPONS' },
|
||||||
|
{ slug: 'REACTOR_OVERCLOCK', name: 'REACTOR OVERCLOCK', costKind: 'resource', costQty: 3, prereqs: [], effect: 'REACTOR_LEVEL_CAP' },
|
||||||
|
{ slug: 'HULL_PLATING', name: 'HULL PLATING', costKind: 'resource', costQty: 3, prereqs: [], effect: 'HULL_CAPACITY_CAP' },
|
||||||
|
{ slug: 'NAV_AI', name: 'NAVIGATION AI', costKind: 'resource', costQty: 2, prereqs: [], effect: 'NAV_LEVEL_CAP' },
|
||||||
|
{ slug: 'PLASMA_CANNON', name: 'PLASMA CANNON', costKind: 'part', costQty: 3, prereqs: ['WEAPONS_SYSTEMS'], effect: 'WEAPONS_LEVEL_CAP' },
|
||||||
|
{ slug: 'FUSION_CORE', name: 'FUSION CORE', costKind: 'resource', costQty: 4, prereqs: ['REACTOR_OVERCLOCK'], effect: 'REACTOR_CAPACITY_CAP' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// What each researched tech changes on the ship. `unlock_build` gates a BUILD action; the cap
|
||||||
|
// effects raise the UPGRADE/EXPAND ceiling for one system type (stacked over the base caps).
|
||||||
|
export interface TechEffect {
|
||||||
|
kind: 'unlock_build' | 'raise_level_cap' | 'raise_capacity_cap'
|
||||||
|
shipType: string
|
||||||
|
bonus?: number // cap effects only
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EFFECTS: Record<string, TechEffect & { description: string }> = {
|
||||||
|
BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' },
|
||||||
|
REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
|
||||||
|
HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||||
|
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
|
||||||
|
WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` },
|
||||||
|
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TechStateName = 'RESEARCHED' | 'AVAILABLE' | 'LOCKED'
|
||||||
|
|
||||||
|
// One tech as served to the UI (GET /api/tech) and consumed by both tabs.
|
||||||
|
export interface TechDto {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
costKind: 'resource' | 'part'
|
||||||
|
costQty: number
|
||||||
|
prereqs: string[] // prerequisite NAMES (for display), in TECHS order
|
||||||
|
effect: string // human-readable unlock description
|
||||||
|
state: TechStateName
|
||||||
|
missingPrereqs: string[] // unmet prerequisite names — non-empty only when LOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
export function techBySlug(slug: string): TechDef | undefined {
|
||||||
|
return TECHS.find((t) => t.slug === slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the
|
||||||
|
// missing prerequisite names so the UI can say which one is blocking it).
|
||||||
|
export function techStatus(def: TechDef, researchedSlugs: Set<string>): { state: TechStateName; missingPrereqs: string[] } {
|
||||||
|
if (researchedSlugs.has(def.slug)) return { state: 'RESEARCHED', missingPrereqs: [] }
|
||||||
|
const missing = def.prereqs.filter((p) => !researchedSlugs.has(p)).map((slug) => techBySlug(slug)?.name ?? slug)
|
||||||
|
return missing.length === 0 ? { state: 'AVAILABLE', missingPrereqs: [] } : { state: 'LOCKED', missingPrereqs: missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The UPGRADE ceiling for a system type given what is researched (base cap + bonuses).
|
||||||
|
export function effectiveLevelCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||||
|
let cap = MAX_LEVEL
|
||||||
|
for (const def of TECHS) {
|
||||||
|
if (!researchedSlugs.has(def.slug)) continue
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'raise_level_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||||
|
}
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// The EXPAND ceiling for a system type given what is researched.
|
||||||
|
export function effectiveCapacityCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||||
|
let cap = MAX_CAPACITY
|
||||||
|
for (const def of TECHS) {
|
||||||
|
if (!researchedSlugs.has(def.slug)) continue
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'raise_capacity_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||||
|
}
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name of the tech that must be researched before a system type can be BUILT, or null when
|
||||||
|
// building it is not gated (or the gate is already researched).
|
||||||
|
export function buildGate(shipType: string, researchedSlugs: Set<string>): string | null {
|
||||||
|
for (const def of TECHS) {
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'unlock_build' && effect.shipType === shipType && !researchedSlugs.has(def.slug)) return def.name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user