177 lines
6.0 KiB
TypeScript
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),
|
|
);
|
|
}
|