deadhead-space-freight-tycoon/packages/engine/test/trip-events.test.ts

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { MS_PER_HOUR, repairCost, type EventOutcome, type TripEvent } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
addPilot,
addShip,
edit,
insertDispatch,
newTestApp,
putBoard,
readSave,
setCredits,
signUpOwner,
testContract,
type TestApp,
} from "./support";
const MINUTE = 60_000;
const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression;
const none: EventOutcome = {
delayMs: 0,
hullLoss: 0,
payLoss: 0,
fee: 0,
bonus: 0,
xpBonusPct: 0,
extraFuelFu: 0,
};
/** A debris field 40% into a trip: go around (+3 h, the default) or push through (−15% hull). */
function debris(departureAt: number, arrivalAt: number): TripEvent {
const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.4);
return {
eventId: "debris-field",
kind: "radio",
good: false,
fireAt,
windowEndsAt: Math.min(fireAt + 30 * MINUTE, arrivalAt),
options: [
{ id: "go-around", available: true, outcome: { ...none, delayMs: 3 * GAME_HOUR } },
{ id: "push-through", available: true, outcome: { ...none, hullLoss: 15 } },
],
defaultOptionId: "go-around",
};
}
/** A grateful client's tip, automatic, 30% into a trip. */
function tip(departureAt: number, arrivalAt: number, bonus: number): TripEvent {
const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.3);
return {
eventId: "grateful-client",
kind: "automatic",
good: true,
fireAt,
windowEndsAt: fireAt,
options: [{ id: "tip", available: true, outcome: { ...none, bonus } }],
defaultOptionId: "tip",
};
}
/** An owner whose Courier is on a long trip to Mars carrying `event`. */
async function flying(
test: TestApp,
make: (departureAt: number, arrivalAt: number) => TripEvent,
options: { independent?: boolean } = {},
) {
const owner = await signUpOwner(test.app);
const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id;
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: testContract({ destinationId: "mars", payout: 8_000 }),
});
const event = make(record.departureAt, record.arrivalAt);
await edit(test.app, (save) => {
const d = save.dispatches.find((x) => x.id === record.id)!;
d.event = event;
d.independent = options.independent ?? false;
});
return { owner, record, event, avatar };
}
const dispatchOf = (save: SaveDocument, id: string) => save.dispatches.find((d) => d.id === id)!;
describe("events in play and catch-up (Step 26.5, decisions 0.5.143, 0.5.144)", () => {
it("lets the player answer a radio call in its window, and not before or after", async () => {
const test = newTestApp();
const { owner, record, event } = await flying(test, debris);
const answer = (optionId: string) =>
test.app.call("answerRadioCall", { dispatchId: record.id, optionId });
await expect(answer("push-through")).rejects.toMatchObject({ code: "radio-call-not-found" });
test.clock.now = event.fireAt + MINUTE;
const state = await test.app.call("getPlayerState", {});
expect(state.radioCalls).toEqual([
expect.objectContaining({
dispatchId: record.id,
eventId: "debris-field",
windowEndsAt: event.windowEndsAt,
defaultOptionId: "go-around",
}),
]);
expect((await answer("push-through")).radioCalls).toEqual([]);
const save = await readSave(test.app);
const d = dispatchOf(save, record.id);
expect(d.eventApplied).toMatchObject({
optionId: "push-through",
answered: true,
hullLoss: 15,
});
expect(d.arrivalAt).toBe(record.arrivalAt);
expect(save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct).toBe(85);
await expect(answer("go-around")).rejects.toMatchObject({ code: "radio-call-closed" });
});
it("takes the default when the window closes unanswered: a 3-hour detour", async () => {
const test = newTestApp();
const { record, event } = await flying(test, debris);
test.clock.now = event.windowEndsAt + MINUTE;
const state = await test.app.call("getPlayerState", {});
expect(state.radioCalls).toEqual([]);
const save = await readSave(test.app);
const d = dispatchOf(save, record.id);
expect(d.eventApplied).toMatchObject({ optionId: "go-around", answered: false });
expect(d.arrivalAt).toBe(record.arrivalAt + 3 * GAME_HOUR);
expect(save.logbook.find((e) => e.kind === "event")).toMatchObject({
at: event.windowEndsAt,
eventId: "debris-field",
optionId: "go-around",
answered: false,
delayMs: 3 * GAME_HOUR,
});
await expect(
test.app.call("answerRadioCall", { dispatchId: record.id, optionId: "push-through" }),
).rejects.toMatchObject({ code: "radio-call-closed" });
});
it("applies an automatic event when it fires, and pays its tip on delivery", async () => {
const test = newTestApp();
const { record } = await flying(test, (from, to) => tip(from, to, 800));
test.clock.now = record.arrivalAt + MINUTE;
const state = await test.app.call("getPlayerState", {});
const summary = state.summaries.find((s) => s.dispatchId === record.id)!;
expect(summary.event).toMatchObject({ eventId: "grateful-client", bonus: 800 });
// 8,000 + 800 for the avatar, less the loan share if any.
expect(summary.credits + summary.loanRepayment).toBe(8_800);
});
it("gives an Independent pilot's unanswered call its default, and lets the player answer one", async () => {
const test = newTestApp();
const { record, event } = await flying(test, debris, { independent: true });
test.clock.now = event.fireAt + MINUTE;
expect((await test.app.call("getPlayerState", {})).radioCalls[0]).toMatchObject({
independent: true,
});
test.clock.now = event.windowEndsAt;
await test.app.call("getPlayerState", {});
expect(dispatchOf(await readSave(test.app), record.id).eventApplied).toMatchObject({
optionId: "go-around",
answered: false,
});
});
it("gives the same save caught up in one go or in pieces, defaulted calls included", async () => {
const once = newTestApp();
const { record, event } = await flying(once, debris);
await addShip(once.app, { id: "s2", modelId: "courier", name: "Wren" });
await addPilot(once.app, { id: "p2", name: "Ada", assignedShipId: "s2" });
const second = await insertDispatch(once.app, {
shipId: "s2",
pilotId: "p2",
departAt: once.clock.now,
contract: testContract({ destinationId: "mars", payout: 5_000 }),
});
await edit(once.app, (save) => {
dispatchOf(save, second.id).event = tip(second.departureAt, second.arrivalAt, 300);
});
const { code } = await once.app.call("exportSave", {});
const pieces = newTestApp();
pieces.clock.now = once.clock.now;
await pieces.app.call("importSave", { code });
const end = Math.max(record.arrivalAt, second.arrivalAt) + 8 * 60 * MINUTE;
once.clock.now = end;
await once.app.call("getPlayerState", {});
while (pieces.clock.now < end) {
pieces.clock.now = Math.min(end, pieces.clock.now + 7 * MINUTE);
await pieces.app.call("getPlayerState", {});
}
const a = await readSave(once.app);
const b = await readSave(pieces.app);
expect(dispatchOf(a, record.id).eventApplied?.optionId).toBe("go-around");
expect(dispatchOf(a, record.id).status).toBe("resolved");
expect(a.logbook.filter((e) => e.kind === "event")).toHaveLength(2);
expect(event.windowEndsAt).toBeLessThan(record.arrivalAt);
expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 });
});
});
describe("events rolled at dispatch (Step 26.5, decision 0.5.145)", () => {
it("makes the first event after the grace trips the derelict pod, and counts the pacing", async () => {
const test = newTestApp();
await signUpOwner(test.app);
await setCredits(test.app, 100_000);
await addShip(test.app, { id: "c1", modelId: "clipper", name: "Heron" });
await addPilot(test.app, { id: "p1", name: "Rosa", assignedShipId: "c1" });
await edit(test.app, (save) => {
save.player.dealAccepted = true;
save.company.tripsSinceDeal = content.config.events.graceTripsAfterDeal;
});
const job = testContract({
destinationId: "mars",
expiresAt: test.clock.now + MS_PER_HOUR,
});
await putBoard(test.app, [job], test.clock.now);
const { dispatch } = await test.app.call("createDispatch", {
kind: "contract",
contractId: job.id,
shipId: "c1",
pilotId: "p1",
});
const save = await readSave(test.app);
const d = dispatchOf(save, dispatch.id);
expect(d.event?.eventId).toBe("derelict-pod");
expect(d.event?.kind).toBe("radio");
expect(save.company).toMatchObject({ firstEventDone: true, tripsSinceDeal: 4 });
expect(save.ships.find((s) => s.id === "c1")!.quietTrips).toBe(0);
});
it("has no events in the grace trips, while counting them", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await edit(test.app, (save) => {
save.player.dealAccepted = true;
});
const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id;
const { dispatch } = await test.app.call("createDispatch", {
kind: "reposition",
shipId: owner.shipId,
pilotId: avatar,
destinationId: "moon",
});
const save = await readSave(test.app);
expect(dispatchOf(save, dispatch.id).event).toBeNull();
expect(save.company.tripsSinceDeal).toBe(1);
expect(save.ships.find((s) => s.id === owner.shipId)!.quietTrips).toBe(3);
});
});
describe("repairs (Step 26.5, decision 0.5.148)", () => {
it("repairs to 100% at an HQ: charged now, in the shop for the time, logged when done", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await setCredits(test.app, 50_000);
await edit(test.app, (save) => {
save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct = 60;
});
const cost = repairCost(content.shipModel("courier")!, 60, content.config);
const started = test.clock.now;
const { ship } = await test.app.call("repairShip", { shipId: owner.shipId });
expect(ship).toMatchObject({ inShopUntil: started + 20 * MINUTE, repairing: true });
expect((await readSave(test.app)).company.credits).toBe(50_000 - cost);
test.clock.now = started + 20 * MINUTE;
const state = await test.app.call("getPlayerState", {});
expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({
hullConditionPct: 100,
repairing: false,
});
expect((await readSave(test.app)).logbook.find((e) => e.kind === "repair")).toMatchObject({
shipId: owner.shipId,
fromPct: 60,
cost,
});
await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({
code: "hull-intact",
});
});
it("refuses a repair away from an HQ", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await edit(test.app, (save) => {
const ship = save.ships.find((s) => s.id === owner.shipId)!;
ship.hullConditionPct = 60;
ship.locationId = "moon";
});
await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({
code: "no-bay-at-location",
});
});
});
describe("developer tools: damage a hull (Step 26.7)", () => {
it("drops a ship's hull to 20% to try repairs", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
expect(
(await readSave(test.app)).ships.find((s) => s.id === owner.shipId)!.hullConditionPct,
).toBe(20);
});
});
describe("hull wear (Step 26.9, decision 0.5.152)", () => {
it("wears a hull by the game hours flown when the trip lands", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id;
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: testContract({ destinationId: "mars" }),
});
test.clock.now = record.arrivalAt + MINUTE;
const ship = (await test.app.call("getPlayerState", {})).ships.find(
(s) => s.id === owner.shipId,
)!;
const hours =
((record.arrivalAt - record.departureAt) * content.config.timeCompression) / MS_PER_HOUR;
expect(ship.hullConditionPct).toBeCloseTo(100 - hours * content.config.hull.wearPerGameHour, 0);
expect(ship.hullConditionPct).toBeLessThan(100);
});
});
describe("service settings on new ships (Step 27.1, decision 0.5.157)", () => {
it("gives a bought ship auto-repair on at 40% and no booking", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await setCredits(test.app, 100_000);
const { ship } = await test.app.call("buyShip", {
modelId: "courier",
bayId: owner.earthBayId,
});
const saved = (await readSave(test.app)).ships.find((s) => s.id === ship.id)!;
expect(saved.autoRepair).toEqual({ on: true, thresholdPct: 40 });
expect(saved.booking).toBeNull();
});
});