deadhead-space-freight-tycoon/packages/engine/test/ported/upgrades.test.ts

181 lines
6.5 KiB
TypeScript

// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
shipResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import {
effectiveStats,
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
upgradeCost,
} from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
useTestApp,
call,
getState,
setCredits,
testContract,
insertDispatch,
putBoard,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const freighterModel = content.shipModel("freighter")!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** An owner whose avatar now crews a newly bought Freighter at the Earth HQ. */
async function ownerWithFreighter() {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, freighterModel.price + 50_000);
const bought = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
const freighter = shipResponseSchema.parse(bought.json()).ship;
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/assign`, {
shipId: freighter.id,
});
return { owner, freighter };
}
const upgrade = (owner: Guest, shipId: string, upgradeId: string) =>
call(testApp().app, owner, "POST", `/api/ships/${shipId}/upgrades`, { upgradeId });
const shipIn = (state: PlayerState, id: string) => state.ships.find((s) => s.id === id)!;
describe("starting an upgrade (Step 10I.5)", () => {
it("charges, raises the level, keeps the ship in the shop, then flies it faster", async () => {
const { app, clock } = testApp();
const { owner, freighter } = await ownerWithFreighter();
const before = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, freighter.id, "engines");
expect(response.statusCode).toBe(200);
const until = clock.now + 10 * 60_000;
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
upgrades: { engines: 1 },
inShopUntil: until,
shop: { upgradeId: "engines", level: 1, until },
});
const state = await getState(app, owner);
expect(state.company.credits).toBe(before - upgradeCost(freighterModel, 1, content.config));
expect(shipIn(state, freighter.id).stats.cruiseSpeedKmPerGameHour).toBeCloseTo(
freighterModel.cruiseSpeedKmPerGameHour * 1.1,
);
const job = testContract({ massTonnes: 60, expiresAt: FAR_FUTURE });
await putBoard(testApp().app, [job], clock.now);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: freighter.id,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("ship-in-shop");
clock.now = until;
const flown = await dispatch();
expect(flown.statusCode).toBe(200);
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
const distance = routeDistance(content.location("earth")!, content.location("moon")!);
const hours = travelTimeGameHours(
effectiveStats(freighterModel, { engines: 1 }, content),
distance,
job.massTonnes,
content.config,
);
expect(trip.arrivalAt - trip.departureAt).toBeCloseTo(
(hours * MS_PER_HOUR) / content.config.timeCompression,
-1,
);
});
it("refuses away from an HQ with not-at-hq", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 50_000);
// Fly the Courier to the Moon, where the player has no bay.
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = record.arrivalAt;
const credits = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, owner.shipId, "cargo-bay");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("not-at-hq");
expect((await getState(app, owner)).company.credits).toBe(credits);
});
it("refuses an unknown upgrade with not-found", async () => {
const owner = await signUpOwner(testApp().app);
expect((await upgrade(owner, owner.shipId, "warp-drive")).statusCode).toBe(404);
});
it("makes exactly one of two simultaneous upgrades the player can afford only one of", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const courier = content.shipModel("courier")!;
await setCredits(testApp().app, courier.price);
const second = shipResponseSchema.parse(
(
await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
})
).json(),
).ship;
const cost = upgradeCost(courier, 1, content.config);
await setCredits(testApp().app, cost + cost / 2);
const responses = await Promise.all([
upgrade(owner, owner.shipId, "engines"),
upgrade(owner, second.id, "engines"),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, owner);
expect(state.company.credits).toBe(cost / 2);
expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1);
});
it("keeps the finished job until acknowledged, and only clears finished ones", async () => {
const { app, clock } = testApp();
const { owner, freighter } = await ownerWithFreighter();
await upgrade(owner, freighter.id, "cargo-bay");
const ack = () =>
call(app, owner, "POST", "/api/summaries/acknowledge", {
dispatchIds: [],
shipIds: [freighter.id],
});
// Still in the shop: acknowledging does nothing.
expect((await ack()).json()).toEqual({ acknowledged: 0 });
const until = shipIn(await getState(app, owner), freighter.id).shop!.until;
clock.now = until;
expect(shipIn(await getState(app, owner), freighter.id)).toMatchObject({
inShopUntil: null,
shop: { upgradeId: "cargo-bay", level: 1, until },
});
expect((await ack()).json()).toEqual({ acknowledged: 1 });
expect(shipIn(await getState(app, owner), freighter.id).shop).toBeNull();
});
});