Step 10I.3: upgrade validation, cost and the ship-in-shop check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
92520dbc4f
commit
0338d5fdbe
|
|
@ -942,3 +942,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits.
|
- **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits.
|
||||||
- **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps.
|
- **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps.
|
||||||
- **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player.
|
- **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player.
|
||||||
|
|
||||||
|
## Step 10I.3
|
||||||
|
|
||||||
|
- **Check order:** tutorial, loaner, in flight, in the shop, at an HQ, maxed, affordable. Situation checks come before price, so the reason shown is the one the player must fix first.
|
||||||
|
- **"At an HQ" is an input (`atHq`)**, not computed from bays in the sim, so the server decides it from the player's bays and the client from state, both with the same rule.
|
||||||
|
- **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time).
|
||||||
|
- **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators.
|
||||||
|
- **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
|
||||||
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
|
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
|
||||||
"ship-not-idle": "That ship is already flying.",
|
"ship-not-idle": "That ship is already flying.",
|
||||||
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
|
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
|
||||||
|
"ship-in-shop": "That ship is in the upgrade shop until its work is done.",
|
||||||
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
|
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
|
||||||
"pilot-not-idle": "That pilot is already flying.",
|
"pilot-not-idle": "That pilot is already flying.",
|
||||||
"pilot-not-assigned": "That pilot is not assigned to that ship.",
|
"pilot-not-assigned": "That pilot is not assigned to that ship.",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { formatWindow, goodName, PriorityChip } from "../components/Goods";
|
||||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
"ship-not-idle": "Ship is flying",
|
"ship-not-idle": "Ship is flying",
|
||||||
"ship-not-at-origin": "Not at pickup",
|
"ship-not-at-origin": "Not at pickup",
|
||||||
|
"ship-in-shop": "Ship is in the shop",
|
||||||
"pilot-resting": "Pilot is resting",
|
"pilot-resting": "Pilot is resting",
|
||||||
"pilot-not-idle": "Pilot is flying",
|
"pilot-not-idle": "Pilot is flying",
|
||||||
"pilot-not-assigned": "No pilot assigned",
|
"pilot-not-assigned": "No pilot assigned",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
||||||
"cannot-land": "Can't land there",
|
"cannot-land": "Can't land there",
|
||||||
"out-of-range": "Out of range",
|
"out-of-range": "Out of range",
|
||||||
"insufficient-credits": "Not enough credits for fuel",
|
"insufficient-credits": "Not enough credits for fuel",
|
||||||
|
"ship-in-shop": "Ship is in the shop",
|
||||||
"pilot-resting": "Pilot is resting",
|
"pilot-resting": "Pilot is resting",
|
||||||
"pilot-not-idle": "Pilot is flying",
|
"pilot-not-idle": "Pilot is flying",
|
||||||
"pilot-not-assigned": "No pilot assigned",
|
"pilot-not-assigned": "No pilot assigned",
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
|
||||||
|
|
||||||
- [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years).
|
- [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years).
|
||||||
- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged.
|
- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged.
|
||||||
- [ ] Step 10I.3 — Sim: upgrade validation and cost
|
- [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`.
|
||||||
- [ ] Step 10I.4 — Schema and database
|
- [ ] Step 10I.4 — Schema and database
|
||||||
- [ ] Step 10I.5 — Server: start upgrade
|
- [ ] Step 10I.5 — Server: start upgrade
|
||||||
- [ ] Step 10I.6 — Client: upgrade shop
|
- [ ] Step 10I.6 — Client: upgrade shop
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { fuelCost } from "./fuel";
|
||||||
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
import { deriveSeed, type Seed } from "./rng";
|
import { deriveSeed, type Seed } from "./rng";
|
||||||
import { travelTimeGameHours } from "./travel";
|
import { travelTimeGameHours } from "./travel";
|
||||||
import { effectiveStats, type EffectiveStats, type UpgradeLevels } from "./upgrades";
|
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
|
||||||
|
|
||||||
export interface ShipState {
|
export interface ShipState {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,6 +18,8 @@ export interface ShipState {
|
||||||
inFlight: boolean;
|
inFlight: boolean;
|
||||||
/** Installed upgrades (CR-009); absent means none. */
|
/** Installed upgrades (CR-009); absent means none. */
|
||||||
upgrades?: UpgradeLevels;
|
upgrades?: UpgradeLevels;
|
||||||
|
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
|
||||||
|
inShopUntil?: RealTime | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
||||||
|
|
@ -50,6 +52,7 @@ export interface DispatchProposal {
|
||||||
/** Every reason `validateDispatch` can fail, in check order. */
|
/** Every reason `validateDispatch` can fail, in check order. */
|
||||||
export const DISPATCH_FAILURES = [
|
export const DISPATCH_FAILURES = [
|
||||||
"ship-not-idle",
|
"ship-not-idle",
|
||||||
|
"ship-in-shop",
|
||||||
"ship-not-at-origin",
|
"ship-not-at-origin",
|
||||||
"pilot-resting",
|
"pilot-resting",
|
||||||
"pilot-not-idle",
|
"pilot-not-idle",
|
||||||
|
|
@ -113,6 +116,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
|
||||||
const departure = realToGame(now, content.config);
|
const departure = realToGame(now, content.config);
|
||||||
|
|
||||||
if (ship.inFlight) return fail("ship-not-idle");
|
if (ship.inFlight) return fail("ship-not-idle");
|
||||||
|
if (isInShop(ship, now)) return fail("ship-in-shop");
|
||||||
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
|
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
|
||||||
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
|
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
|
||||||
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
|
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
|
||||||
|
|
@ -240,6 +244,7 @@ export interface RepositionProposal {
|
||||||
/** Every reason `validateReposition` can fail, in check order. */
|
/** Every reason `validateReposition` can fail, in check order. */
|
||||||
export const REPOSITION_FAILURES = [
|
export const REPOSITION_FAILURES = [
|
||||||
"ship-not-idle",
|
"ship-not-idle",
|
||||||
|
"ship-in-shop",
|
||||||
"pilot-resting",
|
"pilot-resting",
|
||||||
"pilot-not-idle",
|
"pilot-not-idle",
|
||||||
"pilot-not-assigned",
|
"pilot-not-assigned",
|
||||||
|
|
@ -266,6 +271,7 @@ export function validateReposition(
|
||||||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||||
|
|
||||||
if (ship.inFlight) return failWith("ship-not-idle");
|
if (ship.inFlight) return failWith("ship-not-idle");
|
||||||
|
if (isInShop(ship, now)) return failWith("ship-in-shop");
|
||||||
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
|
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
|
||||||
if (pilot.inFlight) return failWith("pilot-not-idle");
|
if (pilot.inFlight) return failWith("pilot-not-idle");
|
||||||
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
||||||
|
|
|
||||||
|
|
@ -75,5 +75,19 @@ export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
|
||||||
|
|
||||||
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
|
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||||
|
|
||||||
export { effectiveStats } from "./upgrades";
|
export {
|
||||||
export type { EffectiveStats, UpgradeLevels } from "./upgrades";
|
UPGRADE_FAILURES,
|
||||||
|
effectiveStats,
|
||||||
|
isInShop,
|
||||||
|
nextUpgradeLevel,
|
||||||
|
upgradeCost,
|
||||||
|
upgradeShopMs,
|
||||||
|
validateUpgrade,
|
||||||
|
} from "./upgrades";
|
||||||
|
export type {
|
||||||
|
EffectiveStats,
|
||||||
|
UpgradeCheck,
|
||||||
|
UpgradeFailure,
|
||||||
|
UpgradeLevels,
|
||||||
|
UpgradeProposal,
|
||||||
|
} from "./upgrades";
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import {
|
||||||
type ShipModel,
|
type ShipModel,
|
||||||
type Upgrade,
|
type Upgrade,
|
||||||
} from "@deadhead/content";
|
} from "@deadhead/content";
|
||||||
|
import { MS_PER_MINUTE, type RealTime } from "./clock";
|
||||||
|
import type { ShipState } from "./dispatch";
|
||||||
|
|
||||||
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */
|
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */
|
||||||
export type UpgradeLevels = Readonly<Record<string, number>>;
|
export type UpgradeLevels = Readonly<Record<string, number>>;
|
||||||
|
|
@ -39,3 +41,85 @@ export function effectiveStats(
|
||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a ship is in the upgrade shop at real time `now` (until, not at, in-shop-until). */
|
||||||
|
export function isInShop(ship: Pick<ShipState, "inShopUntil">, now: RealTime): boolean {
|
||||||
|
return ship.inShopUntil != null && now < ship.inShopUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next level an upgrade would reach on a ship, or null when it is maxed. */
|
||||||
|
export function nextUpgradeLevel(
|
||||||
|
upgrade: Upgrade,
|
||||||
|
modelId: string,
|
||||||
|
levels: UpgradeLevels,
|
||||||
|
): number | null {
|
||||||
|
const next = (levels[upgrade.id] ?? 0) + 1;
|
||||||
|
return next > upgradeMaxLevel(upgrade, modelId) ? null : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cost of an upgrade level: a percent of the ship model's price, in whole credits. */
|
||||||
|
export function upgradeCost(model: ShipModel, level: number, config: GameConfig): number {
|
||||||
|
const entry = config.upgradeLevels[level - 1];
|
||||||
|
if (!entry) throw new Error(`No upgrade level ${level}`);
|
||||||
|
return Math.round((model.price * entry.costPercentOfPrice) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real milliseconds a ship spends in the shop for an upgrade level. */
|
||||||
|
export function upgradeShopMs(level: number, config: GameConfig): number {
|
||||||
|
const entry = config.upgradeLevels[level - 1];
|
||||||
|
if (!entry) throw new Error(`No upgrade level ${level}`);
|
||||||
|
return entry.shopRealMinutes * MS_PER_MINUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every reason `validateUpgrade` can fail, in check order (decisions 0.5.64, 0.5.65). */
|
||||||
|
export const UPGRADE_FAILURES = [
|
||||||
|
"tutorial-active",
|
||||||
|
"not-upgradable",
|
||||||
|
"ship-not-idle",
|
||||||
|
"ship-in-shop",
|
||||||
|
"not-at-hq",
|
||||||
|
"upgrade-maxed",
|
||||||
|
"insufficient-funds",
|
||||||
|
] as const;
|
||||||
|
export type UpgradeFailure = (typeof UPGRADE_FAILURES)[number];
|
||||||
|
|
||||||
|
export interface UpgradeProposal {
|
||||||
|
ship: ShipState;
|
||||||
|
upgrade: Upgrade;
|
||||||
|
/** Whether the player has a bay where the ship is. */
|
||||||
|
atHq: boolean;
|
||||||
|
tutorialActive: boolean;
|
||||||
|
credits: number;
|
||||||
|
now: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpgradeCheck =
|
||||||
|
| { ok: true; level: number; cost: number; shopUntil: RealTime }
|
||||||
|
| { ok: false; reason: UpgradeFailure };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates starting one level of an upgrade (decisions 0.5.64–0.5.66): tutorial
|
||||||
|
* over, a purchasable model (not the loaner), idle and out of the shop, at an HQ,
|
||||||
|
* not maxed, affordable. On success, gives the new level, its cost and when the
|
||||||
|
* ship leaves the shop.
|
||||||
|
*/
|
||||||
|
export function validateUpgrade(
|
||||||
|
proposal: UpgradeProposal,
|
||||||
|
content: { shipModels: readonly ShipModel[]; config: GameConfig },
|
||||||
|
): UpgradeCheck {
|
||||||
|
const { ship, upgrade, atHq, tutorialActive, credits, now } = proposal;
|
||||||
|
const fail = (reason: UpgradeFailure): UpgradeCheck => ({ ok: false, reason });
|
||||||
|
const model = content.shipModels.find((m) => m.id === ship.modelId);
|
||||||
|
if (!model) throw new Error(`Unknown ship model ${ship.modelId}`);
|
||||||
|
|
||||||
|
if (tutorialActive) return fail("tutorial-active");
|
||||||
|
if (!model.purchasable) return fail("not-upgradable");
|
||||||
|
if (ship.inFlight) return fail("ship-not-idle");
|
||||||
|
if (isInShop(ship, now)) return fail("ship-in-shop");
|
||||||
|
if (!atHq) return fail("not-at-hq");
|
||||||
|
const level = nextUpgradeLevel(upgrade, model.id, ship.upgrades ?? {});
|
||||||
|
if (level === null) return fail("upgrade-maxed");
|
||||||
|
const cost = upgradeCost(model, level, content.config);
|
||||||
|
if (credits < cost) return fail("insufficient-funds");
|
||||||
|
return { ok: true, level, cost, shopUntil: now + upgradeShopMs(level, content.config) };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
upgradeCost,
|
||||||
|
upgradeShopMs,
|
||||||
|
validateDispatch,
|
||||||
|
validateUpgrade,
|
||||||
|
type Contract,
|
||||||
|
type PilotState,
|
||||||
|
type ShipState,
|
||||||
|
type UpgradeProposal,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
|
const freighter = content.shipModel("freighter")!;
|
||||||
|
const engines = content.upgrade("engines")!;
|
||||||
|
const ship: ShipState = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false };
|
||||||
|
const valid: UpgradeProposal = {
|
||||||
|
ship,
|
||||||
|
upgrade: engines,
|
||||||
|
atHq: true,
|
||||||
|
tutorialActive: false,
|
||||||
|
credits: 1_000_000,
|
||||||
|
now,
|
||||||
|
};
|
||||||
|
const reason = (proposal: UpgradeProposal) => {
|
||||||
|
const result = validateUpgrade(proposal, content);
|
||||||
|
return result.ok ? "valid" : result.reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("upgrade cost and shop time (Step 10I.3)", () => {
|
||||||
|
it("costs 10 % of a Freighter's price at level 1 and 35 % at level 3", () => {
|
||||||
|
expect(upgradeCost(freighter, 1, content.config)).toBe(freighter.price * 0.1);
|
||||||
|
expect(upgradeCost(freighter, 3, content.config)).toBe(freighter.price * 0.35);
|
||||||
|
expect(upgradeShopMs(2, content.config)).toBe(20 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the next level, with its cost and shop time", () => {
|
||||||
|
expect(
|
||||||
|
validateUpgrade({ ...valid, ship: { ...ship, upgrades: { engines: 1 } } }, content),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
level: 2,
|
||||||
|
cost: upgradeCost(freighter, 2, content.config),
|
||||||
|
shopUntil: now + upgradeShopMs(2, content.config),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upgrade validation (Step 10I.3)", () => {
|
||||||
|
it("accepts a valid upgrade", () => expect(reason(valid)).toBe("valid"));
|
||||||
|
it("refuses during the tutorial", () =>
|
||||||
|
expect(reason({ ...valid, tutorialActive: true })).toBe("tutorial-active"));
|
||||||
|
it("refuses the company loaner", () =>
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, modelId: "company-courier" } })).toBe(
|
||||||
|
"not-upgradable",
|
||||||
|
));
|
||||||
|
it("refuses a ship in flight", () =>
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle"));
|
||||||
|
it("refuses a ship already in the shop", () =>
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, inShopUntil: now + 1000 } })).toBe("ship-in-shop"));
|
||||||
|
it("refuses away from an HQ", () => expect(reason({ ...valid, atHq: false })).toBe("not-at-hq"));
|
||||||
|
it("refuses a maxed upgrade", () =>
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, upgrades: { engines: 3 } } })).toBe(
|
||||||
|
"upgrade-maxed",
|
||||||
|
));
|
||||||
|
it("refuses without the credits", () =>
|
||||||
|
expect(reason({ ...valid, credits: upgradeCost(freighter, 1, content.config) - 1 })).toBe(
|
||||||
|
"insufficient-funds",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ships in the shop can't fly (Step 10I.3)", () => {
|
||||||
|
const pilot: PilotState = {
|
||||||
|
id: "p1",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
restEndsAt: null,
|
||||||
|
};
|
||||||
|
const contract: Contract = {
|
||||||
|
id: "c1",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 10,
|
||||||
|
payout: 2000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: now,
|
||||||
|
expiresAt: now + 3_600_000,
|
||||||
|
firstContact: false,
|
||||||
|
};
|
||||||
|
const at = (time: number, shopUntil: number) =>
|
||||||
|
validateDispatch(
|
||||||
|
{
|
||||||
|
ship: { ...ship, inShopUntil: shopUntil },
|
||||||
|
pilot,
|
||||||
|
contract,
|
||||||
|
tutorial: false,
|
||||||
|
credits: 1e6,
|
||||||
|
now: time,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("fails ship-in-shop one second before in-shop-until and passes at it", () => {
|
||||||
|
const until = now + 10 * 60_000;
|
||||||
|
expect(at(until - 1000, until)).toEqual({ ok: false, reason: "ship-in-shop" });
|
||||||
|
expect(at(until, until)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue