deadhead-space-freight-tycoon/packages/content/test/schemas.test.ts

224 lines
6.6 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 { describe, expect, it } from "vitest";
import type { z } from "zod";
import {
cargoTypeSchema,
gameConfigSchema,
goodSchema,
locationSchema,
onboardingGoalSchema,
scriptedTutorialContractSchema,
shipModelSchema,
skillSchema,
} from "../src/index";
const validLocation = {
id: "earth",
name: "Earth",
type: "atmospheric-world",
landingRequirement: "atmospheric",
position: { radiusMillionKm: 150, angleDegrees: 210 },
fuelPrice: 1.6,
sellsFuel: true,
launchRangeFactor: 1,
startsDiscovered: true,
bayLeasePrice: 5000,
sprite: "body-mars",
produces: ["mail"],
demands: ["lunar-titanium"],
cargoClassMix: { produces: ["standard"], demands: ["heavy"] },
shippers: ["Pacific Orbital Logistics"],
};
/** Optional fields (tier, priorityName) are left out so every key here is required. */
const validGood = {
id: "mail",
name: "Mail & Parcels",
cargoClassId: "standard",
massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2,
priorityEligible: true,
producedAt: ["earth"],
demandedAt: ["moon"],
};
const validShipModel = {
id: "courier",
name: "Courier",
archetype: "courier",
price: 20000,
cruiseSpeedKmPerGameHour: 300000,
cargoCapacityTonnes: 10,
tankCapacityFu: 40,
burnFuPerMillionKm: 20,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 2,
purchasable: true,
role: "Fast and cheap.",
sprite: "ship-courier",
};
const validCargoType = {
id: "standard",
name: "Standard",
basePayPerTonnePerMillionKm: 10,
tierPayMultiplier: 1,
requiredSkillId: null,
};
const validSkill = {
id: "long-haul",
name: "Long Haul",
maxLevel: 5,
description: "Access to longer routes.",
};
const validGameConfig = {
timeCompression: 60,
epoch: { realStart: "2026-01-01T00:00:00Z", gameStart: "2100-01-01T00:00:00Z" },
startingCredits: 0,
tutorialPayoutShare: 0.2,
contractBoardSize: 8,
tutorialBoardSize: 50,
contractBoardPeriodRealMinutes: 60,
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
restGameHours: 60,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
contractPayVariance: 0.15,
priorityChance: 0.15,
priorityPayoutMultiplier: 1.5,
priorityReferenceShipModelId: "freighter",
priorityWindowFactor: 1,
contractMassBands: [
{ min: 1, max: 20, count: 4 },
{ min: 21, max: 100, count: 4 },
],
boardPreviewSlots: 1,
boardOpenSlotsPerBand: 1,
longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
pilotLevelXpThresholds: [100, 250, 500],
skillPointsPerLevel: 1,
pilotHireCost: 1000,
pilotProfitShare: 0.25,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 5,
tutorialXpMultiplier: 2,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
loanRepaymentShare: 0.3,
boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168,
baseFuelPricePerFu: 10,
emergencyFuelMultiplier: 2.5,
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },
deal: {
avatarShipModelId: "courier",
veteranShipModelId: "clipper",
bayLocationId: "earth",
tankShare: 0.25,
},
};
const validScripted = {
id: "unmarked-crates",
run: 3,
name: "Unmarked crates",
cargoTypeId: "standard",
payMultiplier: 2,
flavorMessage: "Nobody asked.",
};
const validGoal = {
id: "first-hire",
title: "Hire your first pilot",
condition: { kind: "pilots-hired", count: 1 },
reward: 1000,
};
const cases: [string, z.ZodType, Record<string, unknown>][] = [
["Location", locationSchema, validLocation],
["ShipModel", shipModelSchema, validShipModel],
["CargoType", cargoTypeSchema, validCargoType],
["Good", goodSchema, validGood],
["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig],
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
["OnboardingGoal", onboardingGoalSchema, validGoal],
];
describe.each(cases)("%s schema", (_name, schema, valid) => {
it("accepts a valid example", () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it.each(Object.keys(valid))("rejects an object missing %s", (field) => {
const missing = { ...valid };
delete missing[field];
expect(schema.safeParse(missing).success).toBe(false);
});
});
describe("Good refinements (CR-007)", () => {
it("rejects a mass range whose min exceeds its max", () => {
const good = { ...validGood, massTonnes: { min: 9, max: 8 } };
expect(goodSchema.safeParse(good).success).toBe(false);
});
it("rejects a tier outside 1–5 and a shipperless location", () => {
expect(goodSchema.safeParse({ ...validGood, tier: 6 }).success).toBe(false);
expect(goodSchema.safeParse({ ...validGood, tier: 0 }).success).toBe(false);
expect(locationSchema.safeParse({ ...validLocation, shippers: [] }).success).toBe(false);
});
});
describe("GameConfig refinements", () => {
it("rejects tutorial mass tiers that do not increase", () => {
const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects a goal condition of an unknown kind", () => {
const goal = { ...validGoal, condition: { kind: "win-the-game" } };
expect(onboardingGoalSchema.safeParse(goal).success).toBe(false);
});
it("rejects a mass band with min above max", () => {
const config = {
...validGameConfig,
contractMassBands: [
{ min: 50, max: 10, count: 4 },
{ min: 21, max: 100, count: 4 },
],
};
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects mass band counts that do not add up to the board size", () => {
const config = { ...validGameConfig, contractMassBands: [{ min: 1, max: 20, count: 3 }] };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects negative or fractional board slot counts (CR-006)", () => {
for (const bad of [-1, 0.5]) {
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardPreviewSlots: bad }).success,
).toBe(false);
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: bad }).success,
).toBe(false);
}
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: 0 }).success,
).toBe(true);
});
it("rejects Long Haul cut-offs that are not increasing", () => {
const config = { ...validGameConfig, longHaulCutoffsMillionKm: [100, 100, 300] };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
});