deadhead-space-freight-tycoon/packages/engine/src/game/company.ts

177 lines
6.0 KiB
TypeScript

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<string, number> {
return Object.fromEntries(
content.upgrades
.map((u) => [u.id, upgradeMaxLevel(u, modelId)] as const)
.filter(([, level]) => level > 0),
);
}