import { content, upgradeMaxLevel } from "@deadhead/content"; import { CURRENT_SAVE_VERSION, type Company, type SaveDocument } from "@deadhead/schema"; import type { RealTime } from "@deadhead/sim"; import type { RandomSource } from "../core"; import { fail } from "./errors"; import { companyName, shipName } from "./names"; import { companyView, deliveredTank, isDealAvailable } from "./state"; export const AVATAR_NAME = "Captain"; /** * A new company's save (CR-001 tutorial state, decision 0.5.74): starting * credits, the starting locations discovered, the avatar at Earth beside the * loaner, and a fresh world seed. Used for "Start your company" and * "Start a new company". */ export function startingSave( random: RandomSource, now: RealTime, options: { saveId?: string; avoidSeed?: number } = {}, ): SaveDocument { // A 32-bit seed (decision 0.5.10); a new company never reuses the old one. let worldSeed = random.seed(); while (worldSeed === options.avoidSeed) worldSeed = random.seed(); return { saveVersion: CURRENT_SAVE_VERSION, saveId: options.saveId ?? random.id(), lastTimeSeen: now, player: { worldSeed, tutorialActive: true, loanerLocationId: "earth", tutorialRunsCompleted: 0, dealAccepted: false, createdAt: now, }, company: { id: random.id(), name: companyName(worldSeed), credits: content.config.startingCredits, loanBalance: 0, }, ships: [], pilots: [ { id: random.id(), name: AVATAR_NAME, isAvatar: true, isVeteran: false, xp: 0, skills: {}, skillPointsSpent: 0, assignedShipId: null, locationId: "earth", status: "idle", dutyHours: 0, restEndsAt: null, createdAt: now, }, ], bays: [], discoveredLocations: content.locations .filter((l) => l.startsDiscovered) .map((l) => ({ locationId: l.id, discoveredAt: now })), contractBoards: [], dispatches: [], acceptedContracts: [], claimedGoals: [], stats: { contractDeliveries: 0, priorityDeliveries: 0 }, }; } /** Renames the company (decision 0.5.75); the name arrives validated and trimmed. */ export function renameCompany(save: SaveDocument, name: string): Company { save.company.name = name; return companyView(save); } /** * Pays the loan down by up to `amount` (decision 0.5.32): only the balance is * taken, and paying more than the player has fails with no change. */ export function repayLoan(save: SaveDocument, amount: number): Company { const payment = Math.min(amount, save.company.loanBalance); if (payment > save.company.credits) { throw fail("insufficient-funds", `You only have ${save.company.credits} credits.`); } save.company.credits -= payment; save.company.loanBalance -= payment; return companyView(save); } /** * Accepts the deal (Steps 10A.8, 10M.3): the loaner goes back to the company; * the player gets the Courier crewed by the avatar, the Clipper crewed by the * veteran, the Earth bay and the loan; the tutorial ends. */ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTime): void { if (!isDealAvailable(save)) { throw fail("deal-not-available", "There is no deal on the table right now."); } const { avatarShipModelId, veteranShipModelId, bayLocationId } = content.config.deal; const owned = save.ships.length; const [courierId, clipperId] = [random.id(), random.id()]; for (const [i, [id, modelId]] of ( [ [courierId, avatarShipModelId], [clipperId, veteranShipModelId], ] as const ).entries()) { save.ships.push({ id, modelId, name: shipName(save.player.worldSeed, owned + i), status: "idle", locationId: bayLocationId, upgrades: {}, inShopUntil: null, shopUpgradeId: null, shopLevel: null, fromDeal: true, createdAt: now, lastArrivalAt: now, // Halvorsen's ships come with what's left in their tanks (CR-015, user decision). ...deliveredTank(modelId, bayLocationId, content.config.deal.tankShare), }); } save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now }); const avatar = save.pilots.find((p) => p.isAvatar)!; Object.assign(avatar, { assignedShipId: courierId, locationId: null, status: "idle" }); // Halvorsen's veteran (decisions 0.5.70, 0.5.82), crewing his Clipper, at // their level with their skills bought and the rest unspent. const veteran = content.veteranPilot; save.pilots.push({ id: random.id(), name: veteran.name, isAvatar: false, isVeteran: true, xp: veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!, skills: { ...veteran.skills }, skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0), assignedShipId: clipperId, locationId: null, status: "idle", dutyHours: 0, restEndsAt: null, createdAt: now, }); save.company.loanBalance = content.dealLoanAmount; Object.assign(save.player, { tutorialActive: false, loanerLocationId: null, dealAccepted: true }); } /** Claims a completed goal's reward, once. */ export function claimGoal( save: SaveDocument, goalId: string, status: string | undefined, now: RealTime, ): Company { const goal = content.onboardingGoal(goalId); if (!goal) throw fail("unknown-goal", `No goal "${goalId}".`); if (status === "claimed") throw fail("goal-already-claimed", "You already claimed that reward."); if (status !== "ready") throw fail("goal-not-ready", `Not done yet: ${goal.title}.`); save.claimedGoals.push({ goalId, claimedAt: now }); save.company.credits += goal.reward; return companyView(save); } /** Every upgrade at its cap for a model (developer tool). */ export function maxedUpgrades(modelId: string): Record { return Object.fromEntries( content.upgrades .map((u) => [u.id, upgradeMaxLevel(u, modelId)] as const) .filter(([, level]) => level > 0), ); }