343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
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();
|
||
});
|
||
});
|