Step 26.7: client: hull and results
CI / check (push) Successful in 7m55s
Details
CI / check (push) Successful in 7m55s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db5bdf6f2d
commit
9b2fa19767
|
|
@ -1948,3 +1948,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The default is labelled "(if you don't answer)"**, and the countdown line says what the pilot will do when it runs out.
|
- **The default is labelled "(if you don't answer)"**, and the countdown line says what the pilot will do when it runs out.
|
||||||
- **Decide later** puts a call off for the session; the Bridge lists every waiting call with Answer, and its tab badge counts them alongside goals to claim.
|
- **Decide later** puts a call off for the session; the Bridge lists every waiting call with Answer, and its tab badge counts them alongside goals to claim.
|
||||||
- **`PlayerState.nextRadioCallAt`** tells the app when to refresh for the next call (a call appears just by time passing, which no save change marks). It only gives the time, not which event.
|
- **`PlayerState.nextRadioCallAt`** tells the app when to refresh for the next call (a call appears just by time passing, which no save change marks). It only gives the time, not which event.
|
||||||
|
|
||||||
|
## Step 26.7
|
||||||
|
|
||||||
|
- **"Ship detail" is the Upgrades sheet**: it's the one per-ship sheet, and repairs use the same shop, so the hull and its Repair button sit at its top. Its subtitle now says repairs are done there too.
|
||||||
|
- **Which events are highlights**: good events (whatever was chosen) and any that hurt a hull, ranked just below level-ups. A detour or a fee without damage is in the Logbook and the short list, not a highlight.
|
||||||
|
- **The short list shows a trip's event next to the trip**, without it counting as one of the 3 events: a delivery with a detour still reads as a quick check-in.
|
||||||
|
- **The hull bar colours** follow the rules: amber below 50% (no Dangerous cargo), red below 25% (Needs repair).
|
||||||
|
- **Developer tools: Damage hull** sets a ship to 20%, to try the warning and repairs (the CR's manual check).
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useClock } from "../clock/clock";
|
||||||
import { fill, formatCredits, locationName } from "../format";
|
import { fill, formatCredits, locationName } from "../format";
|
||||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Button, Icon, Sheet } from "../ui";
|
import { Button, Icon, Sheet } from "../ui";
|
||||||
|
import { eventResultText } from "./EventResults";
|
||||||
import { SleepBanner } from "./FleetSleep";
|
import { SleepBanner } from "./FleetSleep";
|
||||||
|
|
||||||
const words = content.awayWording;
|
const words = content.awayWording;
|
||||||
|
|
@ -106,6 +107,8 @@ export function highlightText(highlight: AwayHighlight, state: PlayerState): str
|
||||||
});
|
});
|
||||||
case "problem":
|
case "problem":
|
||||||
return problemText(highlight.problem, name);
|
return problemText(highlight.problem, name);
|
||||||
|
case "event":
|
||||||
|
return eventResultText(highlight.entry, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +146,8 @@ export function directText(entry: LogEntry, state: PlayerState): string {
|
||||||
level: entry.level,
|
level: entry.level,
|
||||||
points: plural(entry.skillPoints, "skill point"),
|
points: plural(entry.skillPoints, "skill point"),
|
||||||
});
|
});
|
||||||
|
case "event":
|
||||||
|
return eventResultText(entry, state);
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { LogEntry, PlayerState } from "@deadhead/schema";
|
||||||
|
import { MS_PER_HOUR } from "@deadhead/sim";
|
||||||
|
import { formatCredits } from "../format";
|
||||||
|
|
||||||
|
type EventEntry = Extract<LogEntry, { kind: "event" }>;
|
||||||
|
|
||||||
|
/** Game hours for a real-time delay, to one decimal ("3 h", "1.5 h"). */
|
||||||
|
function hours(delayMs: number): string {
|
||||||
|
const h = Math.abs(delayMs * content.config.timeCompression) / MS_PER_HOUR;
|
||||||
|
return `${Math.round(h * 10) / 10} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an event actually changed, in a few words: "+3 h", "hull −14%", "+1,200 cr". */
|
||||||
|
export function eventEffects(entry: EventEntry): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (entry.delayMs > 0) parts.push(`+${hours(entry.delayMs)}`);
|
||||||
|
if (entry.delayMs < 0) parts.push(`${hours(entry.delayMs)} sooner`);
|
||||||
|
if (entry.hullLoss > 0) parts.push(`hull −${entry.hullLoss}%`);
|
||||||
|
if (entry.payLoss > 0) parts.push(`−${formatCredits(entry.payLoss)} pay`);
|
||||||
|
if (entry.fee > 0) parts.push(`${formatCredits(entry.fee)} fee`);
|
||||||
|
if (entry.bonus > 0) parts.push(`+${formatCredits(entry.bonus)}`);
|
||||||
|
if (entry.priorityLost > 0) {
|
||||||
|
parts.push(`missed the Priority window (−${formatCredits(entry.priorityLost)})`);
|
||||||
|
}
|
||||||
|
if (entry.xpBonusPct > 0) parts.push(`+${entry.xpBonusPct}% XP`);
|
||||||
|
if (entry.extraFuelFu > 0) parts.push(`+${Math.round(entry.extraFuelFu)} fu fuel`);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event's result in a line (CR-026 §5): "Debris field ahead — Rosa: Go
|
||||||
|
* around (no answer) · +3 h". Automatic events read "Solar flare — Rosa: +3 h".
|
||||||
|
*/
|
||||||
|
export function eventResultText(entry: EventEntry, state: PlayerState): string {
|
||||||
|
const def = content.tripEvent(entry.eventId);
|
||||||
|
const pilot = state.pilots.find((p) => p.id === entry.pilotId)?.name ?? "A pilot";
|
||||||
|
const effects = eventEffects(entry);
|
||||||
|
const choice =
|
||||||
|
def?.kind === "radio"
|
||||||
|
? `${def.options.find((o) => o.id === entry.optionId)?.label ?? entry.optionId}${entry.answered ? "" : " (no answer)"}`
|
||||||
|
: null;
|
||||||
|
const what = [choice, ...effects].filter((p): p is string => p !== null);
|
||||||
|
return `${def?.title ?? entry.eventId} — ${pilot}: ${what.length > 0 ? what.join(" · ") : "no harm done"}`;
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { formatCredits, locationName } from "../format";
|
||||||
import { formatLocalTime } from "../screens/DispatchSheet";
|
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||||
import { Button, Icon } from "../ui";
|
import { Button, Icon } from "../ui";
|
||||||
import { pilotNow } from "./AwaySummary";
|
import { pilotNow } from "./AwaySummary";
|
||||||
|
import { eventResultText } from "./EventResults";
|
||||||
import { formatSleepTime } from "./FleetSleep";
|
import { formatSleepTime } from "./FleetSleep";
|
||||||
|
|
||||||
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
|
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
|
||||||
|
|
@ -157,6 +158,19 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
|
||||||
{entry.skillPoints === 1 ? "skill point" : "skill points"}
|
{entry.skillPoints === 1 ? "skill point" : "skill points"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
case "event":
|
||||||
|
return (
|
||||||
|
<span className={entry.good ? "text-emerald-200" : "text-amber-200"}>
|
||||||
|
{eventResultText(entry, state)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "repair":
|
||||||
|
return (
|
||||||
|
<span className="text-cyan-100">
|
||||||
|
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
|
||||||
|
{formatCredits(entry.cost)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
case "goal-ready":
|
case "goal-ready":
|
||||||
return (
|
return (
|
||||||
<span className="text-amber-200">
|
<span className="text-amber-200">
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export function simShip(ship: Ship): ShipState {
|
||||||
upgrades: ship.upgrades,
|
upgrades: ship.upgrades,
|
||||||
inShopUntil: ship.inShopUntil,
|
inShopUntil: ship.inShopUntil,
|
||||||
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
||||||
|
hullConditionPct: ship.hullConditionPct,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,14 @@ export function DevTools() {
|
||||||
>
|
>
|
||||||
Finish shop work
|
Finish shop work
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !ship}
|
||||||
|
onClick={() => shipTool.mutate({ shipId, action: "damage-hull" })}
|
||||||
|
>
|
||||||
|
Damage hull
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export function explainProblems(
|
||||||
fuel: { neededFu: number; onBoardFu: number },
|
fuel: { neededFu: number; onBoardFu: number },
|
||||||
gameNow: number,
|
gameNow: number,
|
||||||
now: number,
|
now: number,
|
||||||
|
/** The ship's hull, in percent (CR-026); absent: whole. */
|
||||||
|
hullConditionPct = 100,
|
||||||
): string[] {
|
): string[] {
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
// Resting blocks flying (CR-004): first, with the time left.
|
// Resting blocks flying (CR-004): first, with the time left.
|
||||||
|
|
@ -146,6 +148,13 @@ export function explainProblems(
|
||||||
if (longHaul < contract.requiredLongHaulLevel) {
|
if (longHaul < contract.requiredLongHaulLevel) {
|
||||||
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
|
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
|
||||||
}
|
}
|
||||||
|
// A damaged hull can't carry Dangerous cargo until repaired (decision 0.5.148).
|
||||||
|
if (
|
||||||
|
contract.cargoTypeId === "dangerous" &&
|
||||||
|
hullConditionPct < content.config.hull.noDangerousBelowPct
|
||||||
|
) {
|
||||||
|
problems.push(INELIGIBLE_REASONS["hull-too-damaged"]);
|
||||||
|
}
|
||||||
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
|
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
|
||||||
if (fuel.onBoardFu < fuel.neededFu) {
|
if (fuel.onBoardFu < fuel.neededFu) {
|
||||||
problems.push(
|
problems.push(
|
||||||
|
|
@ -256,6 +265,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
{ neededFu: fuelFu, onBoardFu: ship.fuelFu },
|
{ neededFu: fuelFu, onBoardFu: ship.fuelFu },
|
||||||
gameNow,
|
gameNow,
|
||||||
now,
|
now,
|
||||||
|
ship.hullConditionPct,
|
||||||
);
|
);
|
||||||
// In the shop (CR-009): first, with the time left.
|
// In the shop (CR-009): first, with the time left.
|
||||||
if (isInShop(ship, now))
|
if (isInShop(ship, now))
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ function ShipRow(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FuelGauge ship={ship} />
|
<FuelGauge ship={ship} />
|
||||||
|
<HullGauge ship={ship} />
|
||||||
{low && (
|
{low && (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -174,7 +175,8 @@ function ShipRow(props: {
|
||||||
)}
|
)}
|
||||||
{inShop && (
|
{inShop && (
|
||||||
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
|
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
|
||||||
In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
|
||||||
|
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{ship.status === "idle" && !inShop && (
|
{ship.status === "idle" && !inShop && (
|
||||||
|
|
@ -369,3 +371,32 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ship's hull condition (decision 0.5.148), with a red "Needs repair" below
|
||||||
|
* 25%: repairs are done at an HQ from the ship's Upgrades sheet.
|
||||||
|
*/
|
||||||
|
export function HullGauge({ ship }: { ship: Ship }) {
|
||||||
|
const pct = ship.hullConditionPct;
|
||||||
|
const { needsRepairBelowPct, noDangerousBelowPct } = content.config.hull;
|
||||||
|
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
<span>Hull</span>
|
||||||
|
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
|
||||||
|
</div>
|
||||||
|
<Meter label={`${ship.name} hull`} value={pct} tone={tone} size="sm" className="mt-1" />
|
||||||
|
{pct < needsRepairBelowPct && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-label={`${ship.name} needs repair`}
|
||||||
|
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
||||||
|
>
|
||||||
|
<span className="font-display font-semibold tracking-wide uppercase">Needs repair</span>
|
||||||
|
<span className="text-rose-200/80">Repair it at an HQ</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
validateUpgrade,
|
validateUpgrade,
|
||||||
type EffectiveStats,
|
type EffectiveStats,
|
||||||
type UpgradeFailure,
|
type UpgradeFailure,
|
||||||
|
repairCost,
|
||||||
|
repairShopMs,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
|
|
@ -66,6 +68,59 @@ export function upgradeEffect(ship: Ship, upgrade: Upgrade): string | null {
|
||||||
return `${label} ${format(before)} → ${format(after)}`;
|
return `${label} ${format(before)} → ${format(after)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The hull and its repair (decision 0.5.148): at an HQ, on the ground, below 100%. */
|
||||||
|
function HullRepair({ shipId, atHq }: { shipId: string; atHq: boolean }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const now = useClock((s) => s.now);
|
||||||
|
const repair = useGameAction("repairShip");
|
||||||
|
const ship = state.ships.find((s) => s.id === shipId)!;
|
||||||
|
const pct = ship.hullConditionPct;
|
||||||
|
const model = shipModel(ship.modelId);
|
||||||
|
const cost = repairCost(model, pct, content.config);
|
||||||
|
const inShop = ship.inShopUntil !== null && ship.inShopUntil > now;
|
||||||
|
const can = atHq && ship.status === "idle" && !inShop && pct < 100;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Hull"
|
||||||
|
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-display font-bold tracking-wide">Hull</span>
|
||||||
|
<span className="tabular-nums">{Math.round(pct)}%</span>
|
||||||
|
</div>
|
||||||
|
{pct >= 100 ? (
|
||||||
|
<p className="mt-1 text-ink-400">In perfect shape.</p>
|
||||||
|
) : can ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
disabled={repair.isPending || state.company.credits < cost}
|
||||||
|
onClick={() => repair.mutate({ shipId: ship.id })}
|
||||||
|
>
|
||||||
|
Repair to 100% · {formatCredits(cost)} ·{" "}
|
||||||
|
{formatDuration(repairShopMs(pct, content.config))}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-ink-400">
|
||||||
|
{ship.repairing
|
||||||
|
? "Under repair."
|
||||||
|
: !atHq
|
||||||
|
? "Repairs are done at one of your HQs."
|
||||||
|
: "The ship must be on the ground and out of the shop."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{can && state.company.credits < cost && (
|
||||||
|
<p className="mt-1 text-ink-400">Not enough credits for the repair yet.</p>
|
||||||
|
)}
|
||||||
|
{repair.isError && (
|
||||||
|
<p role="alert" className="mt-2 text-rose-300">
|
||||||
|
{errorMessage(repair.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The ship's detail view: its upgrades, the next level of each, and starting one (CR-009). */
|
/** The ship's detail view: its upgrades, the next level of each, and starting one (CR-009). */
|
||||||
export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () => void }) {
|
export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
|
@ -82,8 +137,9 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
titleId="upgrades-title"
|
titleId="upgrades-title"
|
||||||
eyebrow={model.name}
|
eyebrow={model.name}
|
||||||
title={ship.name}
|
title={ship.name}
|
||||||
subtitle="Upgrades are installed at an HQ. The ship stays in the shop until the work is done."
|
subtitle="Upgrades and hull repairs are done at an HQ. The ship stays in the shop until the work is done."
|
||||||
>
|
>
|
||||||
|
<HullRepair shipId={ship.id} atHq={atHq} />
|
||||||
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
||||||
Upgrades
|
Upgrades
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Contract, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||||
|
import { buildAwaySummary, MS_PER_HOUR, repairCost } from "@deadhead/sim";
|
||||||
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useClock } from "../src/clock/clock";
|
||||||
|
import { directText, highlightText } from "../src/components/AwaySummary";
|
||||||
|
import { pilotLogbook } from "../src/components/PilotLogbook";
|
||||||
|
import { formatCredits } from "../src/format";
|
||||||
|
import { pairOptions } from "../src/screens/DispatchSheet";
|
||||||
|
import { pilotExtras } from "./fixtures";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression;
|
||||||
|
|
||||||
|
const ship = (id: string, name: string, over: Partial<Ship> = {}): Ship => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
modelId: "courier",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
pilotId: "avatar",
|
||||||
|
lastArrivalAt: 0,
|
||||||
|
...shipExtras("courier"),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const rosa: Pilot = {
|
||||||
|
...samplePlayerState().pilots[0]!,
|
||||||
|
id: "p1",
|
||||||
|
name: "Rosa",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
...pilotExtras,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = (ships: Ship[], over: Partial<PlayerState> = {}) =>
|
||||||
|
samplePlayerState({
|
||||||
|
tutorial: null,
|
||||||
|
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
ships,
|
||||||
|
pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: ships[0]?.id ?? null }, rosa],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = (over: Partial<Extract<LogEntry, { kind: "event" }>>): LogEntry => ({
|
||||||
|
id: "e1",
|
||||||
|
at: NOW - 60_000,
|
||||||
|
pilotId: "p1",
|
||||||
|
shipId: "s1",
|
||||||
|
kind: "event",
|
||||||
|
dispatchId: "d1",
|
||||||
|
eventId: "debris-field",
|
||||||
|
optionId: "go-around",
|
||||||
|
good: false,
|
||||||
|
answered: false,
|
||||||
|
delayMs: 3 * GAME_HOUR,
|
||||||
|
hullLoss: 0,
|
||||||
|
payLoss: 0,
|
||||||
|
fee: 0,
|
||||||
|
bonus: 0,
|
||||||
|
priorityLost: 0,
|
||||||
|
xpBonusPct: 0,
|
||||||
|
extraFuelFu: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hull condition and event results on screen (Step 26.7, decisions 0.5.146, 0.5.148)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("shows a hull bar on Fleet cards, and Needs repair below 25%", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () =>
|
||||||
|
state([
|
||||||
|
ship("s1", "Dented Wren", { hullConditionPct: 20 }),
|
||||||
|
ship("s2", "Fine Kite", { hullConditionPct: 60, pilotId: null }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
"/fleet",
|
||||||
|
);
|
||||||
|
const dented = (await screen.findByText("Dented Wren")).closest("li")!;
|
||||||
|
expect(within(dented).getByRole("progressbar", { name: "Dented Wren hull" })).toHaveAttribute(
|
||||||
|
"aria-valuenow",
|
||||||
|
"20",
|
||||||
|
);
|
||||||
|
expect(within(dented).getByLabelText("Dented Wren needs repair")).toHaveTextContent(
|
||||||
|
"Needs repair",
|
||||||
|
);
|
||||||
|
const fine = screen.getByText("Fine Kite").closest("li")!;
|
||||||
|
expect(within(fine).queryByText("Needs repair")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Repair in the ship's sheet at an HQ, with its cost and time, and calls the engine", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: () => state([ship("s1", "Dented Wren", { hullConditionPct: 60 })]),
|
||||||
|
repairShip: () => ({
|
||||||
|
ship: ship("s1", "Dented Wren", { hullConditionPct: 60, repairing: true }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
renderApp(api, "/fleet");
|
||||||
|
const card = (await screen.findByText("Dented Wren")).closest("li")!;
|
||||||
|
await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" }));
|
||||||
|
const hull = within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Hull");
|
||||||
|
const cost = repairCost(content.shipModel("courier")!, 60, content.config);
|
||||||
|
const button = within(hull).getByRole("button", { name: /^Repair to 100%/ });
|
||||||
|
expect(button).toHaveTextContent(`Repair to 100% · ${formatCredits(cost)} · 20m 00s`);
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(api.call).toHaveBeenCalledWith("repairShip", { shipId: "s1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says repairs are done at an HQ away from one, and lists Hull Plating in the shop", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () =>
|
||||||
|
state([ship("s1", "Far Wren", { hullConditionPct: 60, locationId: "moon" })]),
|
||||||
|
}),
|
||||||
|
"/fleet",
|
||||||
|
);
|
||||||
|
const card = (await screen.findByText("Far Wren")).closest("li")!;
|
||||||
|
await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Far Wren" }));
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Far Wren" });
|
||||||
|
expect(within(sheet).getByLabelText("Hull")).toHaveTextContent(
|
||||||
|
"Repairs are done at one of your HQs.",
|
||||||
|
);
|
||||||
|
const plating = within(sheet).getByRole("listitem", { name: "Hull Plating" });
|
||||||
|
const rating = content.shipModel("courier")!.hullRating;
|
||||||
|
expect(plating).toHaveTextContent(`Hull rating ${rating} → ${rating + 1}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the dispatch reason for Dangerous cargo on a damaged hull", () => {
|
||||||
|
const hazmat: Contract = {
|
||||||
|
id: "c",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "dangerous",
|
||||||
|
goodId: "helium-3",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 5,
|
||||||
|
payout: 1_000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
};
|
||||||
|
const [pair] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 40 })]), hazmat, NOW);
|
||||||
|
expect(pair!.problems).toContain("Hull too damaged for hazardous cargo: repair at an HQ");
|
||||||
|
const [whole] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 50 })]), hazmat, NOW);
|
||||||
|
expect(whole!.problems).not.toContain("Hull too damaged for hazardous cargo: repair at an HQ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("words event results for the summary and the Logbook, and logs repairs", () => {
|
||||||
|
const s = state([ship("s1", "Wren")]);
|
||||||
|
expect(directText(event({}), s)).toBe(
|
||||||
|
"Debris field ahead — Rosa: Go around (no answer) · +3 h",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
directText(event({ optionId: "push-through", answered: true, delayMs: 0, hullLoss: 14 }), s),
|
||||||
|
).toBe("Debris field ahead — Rosa: Push through · hull −14%");
|
||||||
|
expect(
|
||||||
|
directText(
|
||||||
|
event({
|
||||||
|
eventId: "solar-tailwind",
|
||||||
|
optionId: "ride",
|
||||||
|
good: true,
|
||||||
|
delayMs: -1.5 * GAME_HOUR,
|
||||||
|
}),
|
||||||
|
s,
|
||||||
|
),
|
||||||
|
).toBe("Solar tailwind — Rosa: 1.5 h sooner");
|
||||||
|
const book = pilotLogbook(s, [
|
||||||
|
event({ eventId: "grateful-client", optionId: "tip", good: true, delayMs: 0, bonus: 800 }),
|
||||||
|
{ id: "r1", at: NOW, pilotId: "p1", shipId: "s1", kind: "repair", fromPct: 60, cost: 2_000 },
|
||||||
|
]);
|
||||||
|
const lines = book.pilots[0]!.entries;
|
||||||
|
expect(lines.map((e) => e.kind)).toEqual(["repair", "event"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks good outcomes and hull damage just below discoveries and level-ups", () => {
|
||||||
|
const s = state([ship("s1", "Wren")]);
|
||||||
|
const flights = Array.from({ length: 4 }, (_, i): LogEntry => ({
|
||||||
|
id: `f${i}`,
|
||||||
|
at: NOW - 30_000 + i,
|
||||||
|
pilotId: "p1",
|
||||||
|
shipId: "s1",
|
||||||
|
kind: "flight",
|
||||||
|
dispatchId: `d${i}`,
|
||||||
|
deadhead: false,
|
||||||
|
tutorial: false,
|
||||||
|
independent: true,
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 5,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
payout: 1_000,
|
||||||
|
pilotPay: 0,
|
||||||
|
fuelCost: 0,
|
||||||
|
loanRepayment: 0,
|
||||||
|
xp: 5,
|
||||||
|
scriptedId: null,
|
||||||
|
}));
|
||||||
|
const summary = buildAwaySummary({
|
||||||
|
from: NOW - MS_PER_HOUR,
|
||||||
|
to: NOW,
|
||||||
|
entries: [
|
||||||
|
...flights,
|
||||||
|
{
|
||||||
|
id: "g",
|
||||||
|
at: NOW - 50_000,
|
||||||
|
pilotId: null,
|
||||||
|
shipId: null,
|
||||||
|
kind: "goal-ready",
|
||||||
|
goalId: "first-own-delivery",
|
||||||
|
},
|
||||||
|
event({ id: "bad", hullLoss: 14, optionId: "push-through", answered: true, delayMs: 0 }),
|
||||||
|
event({ id: "quiet" }),
|
||||||
|
{
|
||||||
|
id: "l",
|
||||||
|
at: NOW - 40_000,
|
||||||
|
pilotId: "p1",
|
||||||
|
shipId: "s1",
|
||||||
|
kind: "level-up",
|
||||||
|
level: 2,
|
||||||
|
skillPoints: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pilots: [{ id: "p1", name: "Rosa", now: "At Moon" }],
|
||||||
|
problems: [],
|
||||||
|
});
|
||||||
|
// The detour without harm isn't a highlight; the hull hit is, after the level-up.
|
||||||
|
expect(summary.highlights.map((h) => h.kind)).toEqual(["level-up", "event", "goal-ready"]);
|
||||||
|
expect(highlightText(summary.highlights[1]!, s)).toBe(
|
||||||
|
"Debris field ahead — Rosa: Push through · hull −14%",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 699 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -553,7 +553,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
|
||||||
- [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time.
|
- [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time.
|
||||||
- [x] **Step 26.5 — Engine: events in play and catch-up.** Contract and deadhead dispatches (the player's and Independent pilots') roll their event with `rollForDispatch` and store it on the dispatch, counting the pacing. Catch-up settles events in time order before shop ends, arrivals and decisions at the same moment: automatic events at their fire time, radio calls with their default when the window closes unanswered. `applyTripEvent` sets the new arrival, the hull (never below 5%) and extra fuel, and logs an `event` entry; resolution pays the event's bonuses and losses. `answerRadioCall` (manual decision) applies the chosen option within the window; `repairShip` (manual decision) charges the repair at an HQ, puts the ship in the shop (counting as rest) and `finishRepair` makes it 100% at the shop end with a `repair` entry. API: `PlayerState.radioCalls`, ship `hullConditionPct` and `repairing`, summary `event`. Save version 11 (10 → 11 per CR-026 §6). Also fixed: goals already ready when a catch-up starts are logged at the save's last-seen time, so they're the same however the catch-up is split. Tests: `packages/engine/test/trip-events.test.ts` (answering in the window, refused before and after; the default and its 3-hour detour when unanswered; an automatic tip paid on delivery; an Independent pilot's call defaults and can be answered; one go equals 7-minute pieces with a defaulted call and an automatic event; the first event after the grace trips is the derelict pod, with the counters; grace trips are quiet; a repair at an HQ: cost, shop, 100% and the entry, refused when intact; refused away from an HQ); `packages/schema/test/save.test.ts` (10 → 11).
|
- [x] **Step 26.5 — Engine: events in play and catch-up.** Contract and deadhead dispatches (the player's and Independent pilots') roll their event with `rollForDispatch` and store it on the dispatch, counting the pacing. Catch-up settles events in time order before shop ends, arrivals and decisions at the same moment: automatic events at their fire time, radio calls with their default when the window closes unanswered. `applyTripEvent` sets the new arrival, the hull (never below 5%) and extra fuel, and logs an `event` entry; resolution pays the event's bonuses and losses. `answerRadioCall` (manual decision) applies the chosen option within the window; `repairShip` (manual decision) charges the repair at an HQ, puts the ship in the shop (counting as rest) and `finishRepair` makes it 100% at the shop end with a `repair` entry. API: `PlayerState.radioCalls`, ship `hullConditionPct` and `repairing`, summary `event`. Save version 11 (10 → 11 per CR-026 §6). Also fixed: goals already ready when a catch-up starts are logged at the save's last-seen time, so they're the same however the catch-up is split. Tests: `packages/engine/test/trip-events.test.ts` (answering in the window, refused before and after; the default and its 3-hour detour when unanswered; an automatic tip paid on delivery; an Independent pilot's call defaults and can be answered; one go equals 7-minute pieces with a defaulted call and an automatic event; the first event after the grace trips is the derelict pod, with the counters; grace trips are quiet; a repair at an HQ: cost, shop, 100% and the entry, refused when intact; refused away from an HQ); `packages/schema/test/save.test.ts` (10 → 11).
|
||||||
- [x] **Step 26.6 — Client: radio calls.** `apps/web/src/components/RadioCalls.tsx`: `RadioCallSheet` shows the first waiting call (after "While you were away" is closed): the event's title and text with the pilot, the ship, "Answer within mm:ss — then Rosa will …" (the default), and each option with what it might do from content ("+3 h", "35% chance: hull −10–20%"), the default marked, unavailable ones disabled ("Not possible on this trip"); "Radio calls (2)" when more wait; Decide later tucks it away. The Bridge lists the waiting calls (`RadioCallsPanel`), each reopening its card, and its tab badge counts them. `PlayerState.nextRadioCallAt` (new) and the waiting calls' window ends refetch the state on time (`useRefetchOnRadio`). Tests (`apps/web/test/radio-calls.test.tsx`): a call's text, options, effects and countdown; answering calls the engine and the call leaves; the window closing removes it; two calls read Radio calls (2); the Bridge badge, Decide later and Answer from the Bridge; an unavailable option is disabled. The result in the summary is Step 26.7's. Screenshots: `docs/screenshots/26-6-radio-call-{phone,desktop}.png` (a fresh company's first event, the derelict pod).
|
- [x] **Step 26.6 — Client: radio calls.** `apps/web/src/components/RadioCalls.tsx`: `RadioCallSheet` shows the first waiting call (after "While you were away" is closed): the event's title and text with the pilot, the ship, "Answer within mm:ss — then Rosa will …" (the default), and each option with what it might do from content ("+3 h", "35% chance: hull −10–20%"), the default marked, unavailable ones disabled ("Not possible on this trip"); "Radio calls (2)" when more wait; Decide later tucks it away. The Bridge lists the waiting calls (`RadioCallsPanel`), each reopening its card, and its tab badge counts them. `PlayerState.nextRadioCallAt` (new) and the waiting calls' window ends refetch the state on time (`useRefetchOnRadio`). Tests (`apps/web/test/radio-calls.test.tsx`): a call's text, options, effects and countdown; answering calls the engine and the call leaves; the window closing removes it; two calls read Radio calls (2); the Bridge badge, Decide later and Answer from the Bridge; an unavailable option is disabled. The result in the summary is Step 26.7's. Screenshots: `docs/screenshots/26-6-radio-call-{phone,desktop}.png` (a fresh company's first event, the derelict pod).
|
||||||
- [ ] Step 26.7 — Client: hull and results
|
- [x] **Step 26.7 — Client: hull and results.** Fleet cards have a hull bar (`HullGauge`: green, amber below 50%, red below 25% with "Needs repair") and say "Under repair" in the shop. The ship's Upgrades sheet has a Hull section: Repair to 100% with its cost and shop time at an HQ (with "Not enough credits" when short), "Repairs are done at one of your HQs" elsewhere; Hull Plating shows in the upgrade list ("Hull rating 1 → 2"). The client's ship state carries the hull, and the dispatch sheet lists "Hull too damaged for hazardous cargo: repair at an HQ". Event results (`apps/web/src/components/EventResults.tsx`): "Debris field ahead — Rosa: Go around (no answer) · +3 h" in the pop-up's short list, the Logbook (and repairs there), and as highlights: good events and hull hits rank just below level-ups (the builder's new `event` highlight; a short absence lists its trips' events, not counted towards the 3). Developer tools: Damage hull (to 20%). Tests: `apps/web/test/hull-events.test.tsx` (the bar and Needs repair; Repair's cost, time and call; away from an HQ; Hull Plating in the shop; the dispatch reason at 40% and not at 50%; event and repair wording; highlight order); `packages/engine/test/trip-events.test.ts` (Damage hull). Screenshots: `docs/screenshots/26-7-hull-card-{phone,desktop}.png`, `26-7-repair-{phone,desktop}.png`.
|
||||||
- [ ] Step 26.8 — Notifications
|
- [ ] Step 26.8 — Notifications
|
||||||
- [ ] Step 26.9 — Balance
|
- [ ] Step 26.9 — Balance
|
||||||
- [ ] Step 26.10 — Plan amendments and regression
|
- [ ] Step 26.10 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,10 @@ export function commandTable(): CommandTable {
|
||||||
// A smaller tank holds no more than it can (CR-015).
|
// A smaller tank holds no more than it can (CR-015).
|
||||||
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
|
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
|
||||||
break;
|
break;
|
||||||
|
case "damage-hull":
|
||||||
|
// To try repairs and the "Needs repair" warning (CR-026).
|
||||||
|
ship.hullConditionPct = 20;
|
||||||
|
break;
|
||||||
case "finish-shop":
|
case "finish-shop":
|
||||||
// Done now: the "out of the shop" note still shows, as for a finished job.
|
// Done now: the "out of the shop" note still shows, as for a finished job.
|
||||||
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;
|
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;
|
||||||
|
|
|
||||||
|
|
@ -292,3 +292,14 @@ describe("repairs (Step 26.5, decision 0.5.148)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export const devPilotRequestSchema = z.object({
|
||||||
export const devShipRequestSchema = z.discriminatedUnion("action", [
|
export const devShipRequestSchema = z.discriminatedUnion("action", [
|
||||||
z.object({
|
z.object({
|
||||||
shipId: idSchema,
|
shipId: idSchema,
|
||||||
action: z.enum(["max-upgrades", "reset-upgrades", "finish-shop"]),
|
action: z.enum(["max-upgrades", "reset-upgrades", "finish-shop", "damage-hull"]),
|
||||||
}),
|
}),
|
||||||
z.object({ shipId: idSchema, action: z.literal("move"), locationId: idSchema }),
|
z.object({ shipId: idSchema, action: z.literal("move"), locationId: idSchema }),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ export type AwayProblem =
|
||||||
export type AwayHighlight =
|
export type AwayHighlight =
|
||||||
| { kind: "discovery"; locationId: string; pilotId: string | null }
|
| { kind: "discovery"; locationId: string; pilotId: string | null }
|
||||||
| { kind: "level-up"; pilotId: string | null; level: number; skillPoints: number }
|
| { kind: "level-up"; pilotId: string | null; level: number; skillPoints: number }
|
||||||
|
/** A good event, or one that hurt a hull (CR-026 §5): just below level-ups. */
|
||||||
|
| { kind: "event"; entry: Extract<LogbookEntry, { kind: "event" }> }
|
||||||
| { kind: "goal-ready"; goalId: string }
|
| { kind: "goal-ready"; goalId: string }
|
||||||
| { kind: "scripted"; scriptedId: string }
|
| { kind: "scripted"; scriptedId: string }
|
||||||
| {
|
| {
|
||||||
|
|
@ -119,7 +121,8 @@ export interface AwaySummary {
|
||||||
idlePilots: number;
|
idlePilots: number;
|
||||||
/**
|
/**
|
||||||
* A short absence (3 events or fewer: flights, discoveries, level-ups,
|
* A short absence (3 events or fewer: flights, discoveries, level-ups,
|
||||||
* finished upgrades): those entries, oldest first, to list directly; else null.
|
* finished upgrades): those entries and the trips' events (CR-026), oldest
|
||||||
|
* first, to list directly; else null.
|
||||||
*/
|
*/
|
||||||
direct: LogbookEntry[] | null;
|
direct: LogbookEntry[] | null;
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +142,12 @@ export const MAX_PILOT_LINES = 5;
|
||||||
export const SHORT_ABSENCE_EVENTS = 3;
|
export const SHORT_ABSENCE_EVENTS = 3;
|
||||||
|
|
||||||
const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]);
|
const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]);
|
||||||
const LISTED_DIRECTLY = new Set<AwayHighlight["kind"]>(["discovery", "level-up", "best-job"]);
|
const LISTED_DIRECTLY = new Set<AwayHighlight["kind"]>([
|
||||||
|
"discovery",
|
||||||
|
"level-up",
|
||||||
|
"best-job",
|
||||||
|
"event",
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "While you were away" from the logbook (decision 0.5.140): totals, at most 3
|
* "While you were away" from the logbook (decision 0.5.140): totals, at most 3
|
||||||
|
|
@ -190,6 +198,10 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
|
||||||
level: l.level,
|
level: l.level,
|
||||||
skillPoints: l.skillPoints,
|
skillPoints: l.skillPoints,
|
||||||
})),
|
})),
|
||||||
|
// Good outcomes and hull damage rank just below level-ups (CR-026 §5).
|
||||||
|
...of("event")
|
||||||
|
.filter((e) => e.good || e.hullLoss > 0)
|
||||||
|
.map((entry) => ({ kind: "event" as const, entry })),
|
||||||
...of("goal-ready").map((g) => ({ kind: "goal-ready" as const, goalId: g.goalId })),
|
...of("goal-ready").map((g) => ({ kind: "goal-ready" as const, goalId: g.goalId })),
|
||||||
...flights.flatMap((f) =>
|
...flights.flatMap((f) =>
|
||||||
f.scriptedId ? [{ kind: "scripted" as const, scriptedId: f.scriptedId }] : [],
|
f.scriptedId ? [{ kind: "scripted" as const, scriptedId: f.scriptedId }] : [],
|
||||||
|
|
@ -227,7 +239,11 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
|
||||||
const rest = lines.slice(MAX_PILOT_LINES);
|
const rest = lines.slice(MAX_PILOT_LINES);
|
||||||
|
|
||||||
const events = entries.filter((e) => EVENT_KINDS.has(e.kind));
|
const events = entries.filter((e) => EVENT_KINDS.has(e.kind));
|
||||||
const direct = events.length <= SHORT_ABSENCE_EVENTS ? events : null;
|
// A short absence also lists its trips' events (CR-026), which don't count towards the 3.
|
||||||
|
const direct =
|
||||||
|
events.length <= SHORT_ABSENCE_EVENTS
|
||||||
|
? entries.filter((e) => EVENT_KINDS.has(e.kind) || e.kind === "event")
|
||||||
|
: null;
|
||||||
// A short absence lists discoveries, level-ups and jobs itself: not again as highlights.
|
// A short absence lists discoveries, level-ups and jobs itself: not again as highlights.
|
||||||
const shown = direct ? highlights.filter((h) => !LISTED_DIRECTLY.has(h.kind)) : highlights;
|
const shown = direct ? highlights.filter((h) => !LISTED_DIRECTLY.has(h.kind)) : highlights;
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue