diff --git a/apps/backend/Contracts/WorldContracts.Infrastructure.cs b/apps/backend/Contracts/WorldContracts.Infrastructure.cs index 0caf12d..9fc1e8a 100644 --- a/apps/backend/Contracts/WorldContracts.Infrastructure.cs +++ b/apps/backend/Contracts/WorldContracts.Infrastructure.cs @@ -55,7 +55,13 @@ public sealed record StationDelta( public sealed record StationActionProgressSnapshot( string Lane, string Label, - float Progress); + float Progress, + float TimeRemainingSeconds, + float CycleSeconds, + IReadOnlyList Inputs, + IReadOnlyList Outputs); + +public sealed record RecipeEntrySnapshot(string ItemId, float Amount); public sealed record StationStorageUsageSnapshot( string StorageClass, diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 97a9452..68bf7a5 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -216,6 +216,7 @@ public sealed class ModuleDefinition [JsonPropertyName("product")] public List ProductIds { + get => Products; set => Products = value ?? []; } } diff --git a/apps/backend/Simulation/SimulationEngine.Replication.cs b/apps/backend/Simulation/SimulationEngine.Replication.cs index a266e13..0ed9649 100644 --- a/apps/backend/Simulation/SimulationEngine.Replication.cs +++ b/apps/backend/Simulation/SimulationEngine.Replication.cs @@ -542,12 +542,18 @@ public sealed partial class SimulationEngine { var recipe = SelectProductionRecipe(world, station, laneKey); var timer = GetStationProductionTimer(station, laneKey); + var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f); + var progress = Math.Clamp(timer / duration, 0f, 1f); return recipe is null || timer <= 0.01f ? null : new StationActionProgressSnapshot( laneKey, recipe.Label, - Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f)); + progress, + duration * (1f - progress), + duration, + recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(), + recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList()); }) .Where(snapshot => snapshot is not null) .Cast() diff --git a/apps/backend/Simulation/SimulationEngine.StationController.cs b/apps/backend/Simulation/SimulationEngine.StationController.cs index 453ab72..fa40607 100644 --- a/apps/backend/Simulation/SimulationEngine.StationController.cs +++ b/apps/backend/Simulation/SimulationEngine.StationController.cs @@ -131,14 +131,41 @@ public sealed partial class SimulationEngine var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f; - priority += recipe.Id switch + priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure); + + return priority; + } + + private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure) + { + var outputItemIds = recipe.Outputs + .Select(output => output.ItemId) + .ToHashSet(StringComparer.Ordinal); + + if (outputItemIds.Contains("hullparts")) { - "ship-parts-integration" => HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") + return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") ? -140f * MathF.Max(expansionPressure, fleetPressure) - : 280f * MathF.Max(expansionPressure, fleetPressure), - "hull-fabrication" => 180f * expansionPressure, - "equipment-assembly" => 170f * expansionPressure, - "gun-assembly" => 160f * expansionPressure, + : 280f * MathF.Max(expansionPressure, fleetPressure); + } + + if (outputItemIds.Contains("refinedmetals")) + { + return 180f * expansionPressure; + } + + if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"])) + { + return 170f * expansionPressure; + } + + if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"])) + { + return 160f * expansionPressure; + } + + return recipe.Id switch + { "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" => 220f * MathF.Max(expansionPressure, fleetPressure), "frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure), @@ -149,8 +176,6 @@ public sealed partial class SimulationEngine => -120f * expansionPressure, _ => 0f, }; - - return priority; } private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) diff --git a/apps/viewer/src/contractsInfrastructure.ts b/apps/viewer/src/contractsInfrastructure.ts index 4e6a581..003db65 100644 --- a/apps/viewer/src/contractsInfrastructure.ts +++ b/apps/viewer/src/contractsInfrastructure.ts @@ -1,9 +1,18 @@ import type { InventoryEntry, Vector3Dto } from "./contractsCommon"; +export interface RecipeEntrySnapshot { + itemId: string; + amount: number; +} + export interface StationActionProgressSnapshot { lane: string; label: string; progress: number; + timeRemainingSeconds: number; + cycleSeconds: number; + inputs: RecipeEntrySnapshot[]; + outputs: RecipeEntrySnapshot[]; } export interface StationStorageUsageSnapshot { diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index 494f5b3..9294150 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -284,7 +284,7 @@ canvas { .detail-progress, .ship-action-progress { - margin: 0 0 12px; + margin: 0 0 3px; } .detail-progress-label, @@ -300,6 +300,15 @@ canvas { line-height: 1; } +.module-item { + display: block; + padding-left: 1em; + color: var(--muted); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.72rem; + line-height: 1.5; +} + .detail-progress-track, .ship-action-progress-track { height: 6px; diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 55078a9..cbc59e9 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -5,6 +5,15 @@ import { formatSystemDistance, inventoryAmount, } from "./viewerMath"; +import modulesData from "../../../shared/data/modules.json"; +import itemsData from "../../../shared/data/items.json"; + +const moduleNameById = new Map( + (modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]), +); +const itemTransportById = new Map( + (itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]), +); import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import type { CameraMode, @@ -37,72 +46,74 @@ interface SystemPanelParams { cameraTargetShipId?: string; } -function laneModuleId(lane: string): string | undefined { - switch (lane) { - case "refinery": - return "refinery-stack"; - case "fabrication": - return "fabricator-array"; - case "components": - return "component-factory"; - case "shipyard": - return "ship-factory"; - default: - return undefined; - } +function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.ceil(seconds)}s`; + const m = Math.floor(seconds / 60); + const s = Math.ceil(seconds % 60); + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + + +function escapeAttr(str: string): string { + return str + .replaceAll("&", "&") + .replaceAll("\"", """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function renderProgressBar(progress: number): string { + return `
`; } function formatModuleListWithConstruction( world: WorldState, stationId: string, installedModules: string[], - currentProcesses: { lane: string; label: string; progress: number }[], + currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[], ): string { - const processByModule = new Map(); + const processByModule = new Map(); for (const process of currentProcesses) { - const moduleId = laneModuleId(process.lane); - if (!moduleId) { - continue; - } - - const existing = processByModule.get(moduleId) ?? []; - existing.push({ label: process.label, progress: process.progress }); - processByModule.set(moduleId, existing); + const existing = processByModule.get(process.lane) ?? []; + existing.push({ label: process.label, progress: process.progress, timeRemainingSeconds: process.timeRemainingSeconds, cycleSeconds: process.cycleSeconds, inputs: process.inputs, outputs: process.outputs }); + processByModule.set(process.lane, existing); } const renderedProcessCount = new Map(); - const moduleLines = installedModules.map((moduleId) => { + const moduleHtmlParts = installedModules.map((moduleId) => { const processIndex = renderedProcessCount.get(moduleId) ?? 0; const processes = processByModule.get(moduleId) ?? []; const process = processes[processIndex]; renderedProcessCount.set(moduleId, processIndex + 1); + const moduleName = moduleNameById.get(moduleId) ?? moduleId; if (!process) { - return moduleId; + return moduleName; } - return `${moduleId} -> ${process.label} (${Math.round(process.progress * 100)}%)`; + const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n"); + const outputLines = process.outputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n"); + const tooltip = `Cycle: ${formatDuration(process.cycleSeconds)}\nInputs:\n${inputLines || " none"}\nOutputs:\n${outputLines || " none"}`; + const rightLabel = `${Math.round(process.progress * 100)}% · ${formatDuration(process.timeRemainingSeconds)}`; + return `
${moduleName}${rightLabel}
${renderProgressBar(process.progress)}`; }); + const activeSites = [...world.constructionSites.values()] .filter((site) => site.stationId === stationId && site.state !== "completed") .sort((left, right) => left.targetDefinitionId.localeCompare(right.targetDefinitionId)); for (const site of activeSites) { const moduleId = site.blueprintId ?? site.targetDefinitionId; - const progress = Math.round(site.progress * 100); - const tooltip = site.requiredItems.length > 0 + const moduleName = moduleNameById.get(moduleId) ?? moduleId; + const progress = site.progress; + const constructionTooltip = site.requiredItems.length > 0 ? site.requiredItems .map((entry) => `${entry.itemId}: ${entry.amount.toFixed(0)} required / ${inventoryAmount(site.stationId ? (world.stations.get(site.stationId)?.inventory ?? []) : site.deliveredItems, entry.itemId).toFixed(0)} available`) .join("\n") : "No material requirements"; - const escapedTooltip = tooltip - .replaceAll("&", "&") - .replaceAll("\"", """) - .replaceAll("<", "<") - .replaceAll(">", ">"); - moduleLines.push(`${moduleId} (${progress}% constructing)`); + moduleHtmlParts.push(`
${moduleName}${Math.round(progress * 100)}% constructing
${renderProgressBar(progress)}`); } - return moduleLines.length > 0 ? moduleLines.join("
") : "none"; + return moduleHtmlParts.length > 0 ? moduleHtmlParts.join("
") : "none"; } function formatStorageClassLabel(storageClass: string): string { @@ -112,15 +123,31 @@ function formatStorageClassLabel(storageClass: string): string { .join(" "); } -function formatStorageUsage(storageUsage: { storageClass: string; used: number; capacity: number }[]): string { +function formatStorageWithInventory( + storageUsage: { storageClass: string; used: number; capacity: number }[], + inventory: { itemId: string; amount: number }[], +): string { if (storageUsage.length === 0) { - return "none"; + return inventory.length === 0 ? "none" : inventory.map((e) => `${e.itemId} ${e.amount.toFixed(0)}`).join("
"); + } + + const itemsByClass = new Map(); + for (const entry of inventory) { + const cls = itemTransportById.get(entry.itemId) ?? "unknown"; + const list = itemsByClass.get(cls) ?? []; + list.push(entry); + itemsByClass.set(cls, list); } return storageUsage .map((entry) => { const percentUsed = entry.capacity > 0 ? Math.round((entry.used / entry.capacity) * 100) : 0; - return `${formatStorageClassLabel(entry.storageClass)} ${percentUsed}% used (${entry.used.toFixed(0)} / ${entry.capacity.toFixed(0)})`; + const header = `${formatStorageClassLabel(entry.storageClass)} ${percentUsed}% (${entry.used.toFixed(0)} / ${entry.capacity.toFixed(0)})`; + const items = itemsByClass.get(entry.storageClass) ?? []; + if (items.length === 0) { + return `${header}
empty`; + } + return `${header}
${items.map((e) => `${e.itemId} ${e.amount.toFixed(0)}`).join("
")}`; }) .join("
"); } @@ -236,33 +263,16 @@ export function updateDetailPanel( const dockedShipLabels = station.dockedShipIds.length > 0 ? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("
") : "none"; - const stationInventory = station.inventory; - const stationStorageUsage = formatStorageUsage(station.storageUsage); - const stationProcesses = station.currentProcesses; - const stationProcessingHtml = stationProcesses.length > 0 - ? stationProcesses.map((process) => ` -
-
- ${process.label} - ${Math.round(process.progress * 100)}% -
-
-
-
-
- `).join("") - : ""; + const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory); detailTitleEl.textContent = station.label; detailBodyEl.innerHTML = `

${station.category} · ${station.systemId}

Parent ${parent}

- ${stationProcessingHtml}

Docked ${station.dockedShips} / ${station.dockingPads}
${dockedShipLabels}

Modules ${moduleList}

-

Storage ${stationStorageUsage}

-

Inventory ${formatInventory(stationInventory)}

+

Storage ${stationStorage}

`; return; } diff --git a/shared/data/scenario.json b/shared/data/scenario.json index d15d432..bb974b4 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -6,7 +6,9 @@ "module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", - "module_arg_stor_liquid_m_01" + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_refinedmetals_01" ], "systemId": "helios", "planetIndex": 2,