diff --git a/app/.server/classes/FlightDataModel.ts b/app/.server/classes/FlightDataModel.ts index 483f5659..bfe50fad 100644 --- a/app/.server/classes/FlightDataModel.ts +++ b/app/.server/classes/FlightDataModel.ts @@ -124,7 +124,6 @@ export class FlightDataModel extends DataStore { } } - console.trace(); await Promise.all( ships.map(async (ship) => { if (!ship.assets.model) return; diff --git a/app/.server/systems/CoolantLoopSystem.ts b/app/.server/systems/CoolantLoopSystem.ts new file mode 100644 index 00000000..770fd2dc --- /dev/null +++ b/app/.server/systems/CoolantLoopSystem.ts @@ -0,0 +1,118 @@ +import { getShipSystem } from "@thorium/utils/.server/ship/getShipSystem"; +import { Entity, System } from "@thorium/utils/ecs"; +import { StephanBoltzmannConstant } from "@thorium/utils/unitTypes"; + +export class CoolantLoopSystem extends System { + static flightMode = ["nova"]; + test(entity: Entity) { + return !!entity.components.isShip; + } + update(entity: Entity, elapsed: number) { + const elapsedSeconds = elapsed / 1000; + // If any of these entities aren't present, we'll just use the default values + const coolantPump = getShipSystem( + this.ecs, + { systemType: "coolantPump", shipId: entity.id }, + true, + ); + const coolantRadiator = getShipSystem( + this.ecs, + { + systemType: "coolantRadiator", + shipId: entity.id, + }, + true, + ); + const coolantReservoir = getShipSystem( + this.ecs, + { + systemType: "coolantReservoir", + shipId: entity.id, + }, + true, + ); + + const systems: Entity[] = []; + for (const id of entity.components.shipSystems?.shipSystems.keys() || []) { + const sys = this.ecs.getEntityById(id); + if ( + sys && + sys.components.heat && + !sys.components.isCoolantRadiator && + !sys.components.isCoolantReservoir && + !sys.components.isCoolantPump + ) + systems.push(sys); + } + + // Collect all of the necessary constants + const baseFlowRate = (coolantPump?.components.isCoolantPump?.baseFlowRate || 40000) / 60; // Convert to seconds + // Actual flow rate is based on the power supplied to the pump, which physically is based on a cube root + const pumpMinPower = coolantPump?.components.power?.powerLevels[0] ?? 1; + const pumpCurrentPower = coolantPump?.components.power?.currentPower ?? 1; + const flowRate = baseFlowRate * (pumpCurrentPower / pumpMinPower) ** (1 / 3); + + const coolantDensity = + coolantReservoir?.components.isCoolantReservoir?.coolantDensity || 1113.2; + const coolantSpecificHeat = + coolantReservoir?.components.isCoolantReservoir?.coolantSpecificHeat || 2.42; + const reservoirVolume = coolantReservoir?.components.heat?.coolantVolume || 1000; + const radiatorVolume = coolantRadiator?.components.heat?.coolantVolume || 100; + const reservoirTemperature = coolantReservoir?.components.heat?.heat || 200; + const radiatorTemperature = coolantRadiator?.components.heat?.heat || 200; + const radiatorArea = coolantRadiator?.components.isCoolantRadiator!.area || 1; + const radiatorInCoolantLoop = coolantRadiator?.components.heat?.inCoolantLoop ?? true; + + // Derived Values + const radiatorCoolantMass = radiatorVolume * coolantDensity; + const heatTransferReservoir = flowRate ? (reservoirVolume * 1000) / flowRate : 0; + const heatTransferRadiator = flowRate ? (radiatorVolume * 1000) / flowRate : 0; + + // Coolant flows from the reservoir to the radiator, then through systems, then back to the reservoir + const heatToRadiator = radiatorInCoolantLoop + ? (reservoirTemperature - radiatorTemperature) / heatTransferRadiator + : 0; + + // Radiate out the heat + const wattsRadiated = radiatorArea * StephanBoltzmannConstant * radiatorTemperature ** 4; + const radiatorLoad = wattsRadiated / (radiatorCoolantMass * coolantSpecificHeat); + const radiatorInstTempChange = heatToRadiator - radiatorLoad; + + coolantRadiator?.updateComponent("heat", { + heat: radiatorTemperature + radiatorInstTempChange * elapsedSeconds, + }); + + let previousSystemTemperature = radiatorInCoolantLoop + ? radiatorTemperature + : reservoirTemperature; + + // Now process the heat change for each system + for (const system of systems) { + if (!system.components.heat) continue; + const { heat, coolantVolume, heatLoad, inCoolantLoop } = system.components.heat; + const heatTransferSystem = flowRate ? (coolantVolume * 1000) / flowRate : 0; + const systemCoolantMass = coolantVolume * coolantDensity; + const heatToSystem = inCoolantLoop + ? (previousSystemTemperature - heat) / heatTransferSystem + : 0; + // Convert to watt + const loadHeatChange = (heatLoad * 1e3) / (systemCoolantMass * coolantSpecificHeat); + + const heatInstTempChange = heatToSystem + loadHeatChange; + + if (inCoolantLoop) { + previousSystemTemperature = heat; + } + // Update the system's heat + system.updateComponent("heat", { + heat: heat + heatInstTempChange, + }); + } + + const heatToReservoir = + (previousSystemTemperature - reservoirTemperature) / heatTransferReservoir; + coolantReservoir?.updateComponent("heat", { + heat: reservoirTemperature + heatToReservoir, + }); + } +} diff --git a/app/.server/systems/HeatDispersionSystem.ts b/app/.server/systems/HeatDispersionSystem.ts deleted file mode 100644 index d3482c9a..00000000 --- a/app/.server/systems/HeatDispersionSystem.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getReactorInventory } from "@thorium/utils/.server/ship/getSystemInventory"; -import { type Entity, System } from "@thorium/utils/ecs"; -import { type MeterSquared, StephanBoltzmannConstant as _ } from "@thorium/utils/unitTypes"; - -// W = A * a * T^5 -// W = Watts -// A = area of radiator -// σ = Stefan-Boltzmann constant -// T^4 = radiator temperature -// Increasing it by another power as -// space magic. - -// For transferring the heat of the coolant -// into watts -// 𝚫T = (W * 𝚫t) / (c * m) -// 𝚫t = change in time -// c = specific heat -// m = mass -// 𝚫T = change in temperature - -// Specific heat is in J/gK, 1J = 1 wattsecond - -const _RADIATOR_AREA: MeterSquared = 1; - -export class HeatDispersionSystem extends System { - static flightMode = ["nova"]; - test(entity: Entity) { - return !!entity.components.heat; - } - update(entity: Entity, elapsed: number) { - const _elapsedInSeconds = elapsed / 1000; - if (!entity.components.heat) return; - const inventory = getReactorInventory(entity) || []; - // Radiate the heat of the coolant into space - for (const item of inventory) { - if (!item.flags?.coolant) continue; - // const wattsDispersed = RADIATOR_AREA * StephanBoltzmannConstant * temp ** 5; - - // const tempDrop = - // (wattsDispersed * elapsedInSeconds) / - // (item.flags.coolant.heatCapacity * item.flags.coolant.massPerUnit * 1000 * item.count); - // if (item.room) { - // item.room.contents[item.name].temperature = Math.max(2.7, temp - tempDrop); - // } - } - } -} diff --git a/app/.server/systems/HeatToCoolantSystem.ts b/app/.server/systems/HeatToCoolantSystem.ts deleted file mode 100644 index 3a130a51..00000000 --- a/app/.server/systems/HeatToCoolantSystem.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getReactorInventory } from "@thorium/utils/.server/ship/getSystemInventory"; -import { type Entity, System } from "@thorium/utils/ecs"; -import type { HeatCapacity, Kilograms, MeterSquared } from "@thorium/utils/unitTypes"; - -// For transferring the heat of the coolant -// into watts -// 𝚫T = (W * 𝚫t) / (c * m) -// W = (c * m * 𝚫T) / 𝚫t -// 𝚫t = change in time -// c = specific heat -// m = mass -// 𝚫T = change in temperature - -// Specific heat is in J/gK, 1J = 1 wattsecond - -// Assumed values for the systems -const HEAT_CAPACITY: HeatCapacity = 0.475; -const MASS: Kilograms = 10000; -const _COOLANT_AREA: MeterSquared = 1; -const _THERMAL_CONDUCTIVITY = 46.6; // W/(mK) -const _THERMAL_DISTANCE = 1e-3; -export class HeatToCoolantSystem extends System { - static flightMode = ["nova"]; - test(entity: Entity) { - return !!entity.components.heat; - } - update(entity: Entity, elapsed: number) { - const elapsedInSeconds = elapsed / 1000; - if (!entity.components.heat) return; - const inventory = getReactorInventory(entity) || []; - - // Transfer heat between the system and the coolant - // We do this by converting the heat of each thing - // into watts, averaging them, then converting them - // back into temperature and assigning them to their - // respective thing. - let systemWatts = - (HEAT_CAPACITY * MASS * 1000 * entity.components.heat.heat) / elapsedInSeconds; - - for (const item of inventory) { - if (!item.flags?.coolant) continue; - // let itemWatts = - // (item.flags.coolant.heatCapacity * - // item.flags.coolant.massPerUnit * - // 1000 * - // item.count * - // item.temperature) / - // elapsedInSeconds; - // const tempDifference = entity.components.heat.heat - item.temperature; - // const heatTransferRate = - // -1 * THERMAL_CONDUCTIVITY * COOLANT_AREA * (tempDifference / THERMAL_DISTANCE); - // systemWatts += heatTransferRate; - // itemWatts -= heatTransferRate; - - // const itemTemp = - // (itemWatts * elapsedInSeconds) / - // (item.flags.coolant.heatCapacity * item.flags.coolant.massPerUnit * 1000 * item.count); - // if (item.room) { - // item.room.contents[item.name].temperature = itemTemp; - // } - } - - const systemTemp = (systemWatts * elapsedInSeconds) / (HEAT_CAPACITY * MASS * 1000); - entity.updateComponent("heat", { heat: systemTemp }); - } -} diff --git a/app/.server/systems/PowerDrawSystem.ts b/app/.server/systems/PowerDrawSystem.ts index eb959c4e..d04f088c 100644 --- a/app/.server/systems/PowerDrawSystem.ts +++ b/app/.server/systems/PowerDrawSystem.ts @@ -94,7 +94,7 @@ export class PowerDrawSystem extends System { case "sensors": { // If there is an active scan, just draw the full amount of power for (const scan of this.ecs.componentCache.get("scan") || []) { - if (scan.components.scan?.parentId === ship.id) { + if (scan.components.scan?.parentId === ship.id && scan.components.scan.progress < 1) { powerDraw = maxSafePower; break; } diff --git a/app/.server/systems/ReactorHeatSystem.ts b/app/.server/systems/ReactorHeatSystem.ts index a0f677d7..b70d7e77 100644 --- a/app/.server/systems/ReactorHeatSystem.ts +++ b/app/.server/systems/ReactorHeatSystem.ts @@ -1,22 +1,5 @@ import { type Entity, System } from "@thorium/utils/ecs"; -import type { HeatCapacity, Kelvin, Kilograms, MegaWatt } from "@thorium/utils/unitTypes"; - -// W = Q / 𝚫t = (c * m * 𝚫T)/𝚫t -// W = watts -// Q = Energy Added (watthour) -// 𝚫t = change in time -// c = specific heat -// m = mass -// 𝚫T = change in temperature -// So, based on this -// 𝚫T = (W * 𝚫t) / (c * m) -// For now, assume c = 0.52, the specific heat of titanium -// And the mass of the reactor is 10000kg - -// Specific heat is in J/gK, 1 J = 1 wattsecond - -const HEAT_CAPACITY: HeatCapacity = 0.475; -const MASS: Kilograms = 10000; +import type { MegaWatt } from "@thorium/utils/unitTypes"; // TODO May 28, 2026 — If the net reactor output is greater than 0, pump all // of that extra energy into heat. @@ -25,19 +8,15 @@ export class ReactorHeatSystem extends System { test(entity: Entity) { return !!entity.components.isReactor && !!entity.components.heat; } - update(entity: Entity, elapsed: number) { - const elapsedInSeconds = elapsed / 1000; + update(entity: Entity) { if (!entity.components.isReactor || !entity.components.heat) return; const { currentOutput, balanced, balancedBonusMultiplier } = entity.components.isReactor; const { powerToHeat } = entity.components.heat; + const { heatMultiplier } = entity.components.damage || { heatMultiplier: 1 }; const heatGenerated: MegaWatt = - currentOutput * powerToHeat * (balanced ? balancedBonusMultiplier : 1); - - const heatInWatts = heatGenerated * 1e6; - - const changeInHeat: Kelvin = (heatInWatts * elapsedInSeconds) / (HEAT_CAPACITY * MASS * 1000); + currentOutput * powerToHeat * (balanced ? balancedBonusMultiplier : 1) * heatMultiplier; - entity.components.heat.heat += changeInHeat; + entity.updateComponent("heat", { heatLoad: heatGenerated }); } } diff --git a/app/.server/systems/ShipSystemHeatSystem.ts b/app/.server/systems/ShipSystemHeatSystem.ts new file mode 100644 index 00000000..965916c8 --- /dev/null +++ b/app/.server/systems/ShipSystemHeatSystem.ts @@ -0,0 +1,24 @@ +import { type Entity, System } from "@thorium/utils/ecs"; +import type { MegaWatt } from "@thorium/utils/unitTypes"; + +export class ShipSystemHeatSystem extends System { + static flightMode = ["nova"]; + test(entity: Entity) { + return ( + !!entity.components.isShipSystem && !!entity.components.heat && !!entity.components.power + ); + } + update(entity: Entity) { + if (!entity.components.isShipSystem || !entity.components.heat || !entity.components.power) + return; + // Reactors are handled on their own ECS system. + if (entity.components.isReactor) return; + const { powerToHeat } = entity.components.heat; + const { currentPower } = entity.components.power; + const { heatMultiplier } = entity.components.damage || { heatMultiplier: 1 }; + + const heatGenerated: MegaWatt = currentPower * powerToHeat * heatMultiplier; + + entity.updateComponent("heat", { heatLoad: heatGenerated }); + } +} diff --git a/app/.server/systems/__test__/CoolantLoopSystem.test.ts b/app/.server/systems/__test__/CoolantLoopSystem.test.ts new file mode 100644 index 00000000..682d0735 --- /dev/null +++ b/app/.server/systems/__test__/CoolantLoopSystem.test.ts @@ -0,0 +1,124 @@ +import { CoolantLoopSystem } from "@thorium/.server/systems/CoolantLoopSystem"; +import { thoriumContext } from "@thorium/utils/.server/context"; +import { createMockDataContext } from "@thorium/utils/.server/createMockDataContext"; +import { testDataStoreProps } from "@thorium/utils/.server/db-fs/testDataStoreProps"; +import { executeBlocks } from "@thorium/utils/.server/executeBlocks"; +import { processTriggers } from "@thorium/utils/.server/processTriggers"; +import { ECS } from "@thorium/utils/ecs"; +import Entity from "@thorium/utils/ecs/entity"; +import { aroundEach, beforeEach, describe, expect, it } from "vitest"; + +aroundEach(async (runTest) => { + await thoriumContext.run(testDataStoreProps, async () => { + await runTest(); + }); +}); + +describe("CoolantLoopSystem", () => { + let ecs: ECS; + let ship: Entity; + let coolantReservoir: Entity; + let coolantPump: Entity; + let coolantRadiator: Entity; + let system1: Entity; + let system2: Entity; + + beforeEach(() => { + const mockDataContext = createMockDataContext(); + ecs = new ECS(mockDataContext.server); + ecs.executeBlocks = (blocks, blockMetadata) => executeBlocks(ecs, blocks, blockMetadata); + ecs.processTriggers = (events) => processTriggers(ecs, events); + ecs.addSystem(new CoolantLoopSystem()); + + ship = new Entity(); + ship.addComponent("shipSystems"); + ship.addComponent("isShip"); + ecs.addEntity(ship); + const shipSystemsMap = ship.components.shipSystems!.shipSystems; + + coolantReservoir = new Entity(); + coolantReservoir.addComponent("heat", { coolantVolume: 1000 }); + coolantReservoir.addComponent("isCoolantReservoir"); + coolantReservoir.addComponent("isShipSystem", { shipId: ship.id, type: "coolantReservoir" }); + shipSystemsMap.set(coolantReservoir.id, {}); + ecs.addEntity(coolantReservoir); + + coolantRadiator = new Entity(); + coolantRadiator.addComponent("heat", { inCoolantLoop: true }); + coolantRadiator.addComponent("isCoolantRadiator"); + coolantRadiator.addComponent("isShipSystem", { shipId: ship.id, type: "coolantRadiator" }); + shipSystemsMap.set(coolantRadiator.id, {}); + ecs.addEntity(coolantRadiator); + + coolantPump = new Entity(); + coolantPump.addComponent("power", { currentPower: 1, powerLevels: [1, 8] }); + coolantPump.addComponent("isCoolantPump"); + coolantPump.addComponent("isShipSystem", { shipId: ship.id, type: "coolantPump" }); + shipSystemsMap.set(coolantPump.id, {}); + ecs.addEntity(coolantPump); + + system1 = new Entity(); + system1.addComponent("heat"); + system1.addComponent("isShipSystem", { shipId: ship.id, type: "generic" }); + shipSystemsMap.set(system1.id, {}); + ecs.addEntity(system1); + + system2 = new Entity(); + system2.addComponent("heat"); + system2.addComponent("isShipSystem", { shipId: ship.id, type: "generic" }); + shipSystemsMap.set(system2.id, {}); + ecs.addEntity(system2); + }); + it("should cool the reservoir when no systems are attached", () => { + const reservoirTemp = coolantReservoir.components.heat!.heat; + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(coolantReservoir.components.heat!.heat).toBeLessThan(reservoirTemp); + }); + it("should heat the reservoir when systems are attached and the radiator is not attached", () => { + const reservoirTemp = coolantReservoir.components.heat!.heat; + system1.updateComponent("heat", { inCoolantLoop: true, heatLoad: 1 }); + coolantRadiator.updateComponent("heat", { inCoolantLoop: false }); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(coolantReservoir.components.heat!.heat).toBeGreaterThan(reservoirTemp); + }); + it("should cool systems when the reservoir temperature is lower", () => { + coolantReservoir.updateComponent("heat", { heat: 100 }); + const beforeTemp = 1000; + system1.updateComponent("heat", { inCoolantLoop: true, heatLoad: 0.1, heat: beforeTemp }); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(system1.components.heat!.heat).toBeLessThan(beforeTemp); + }); + it("should heat systems when not connected to the coolant loop", () => { + const beforeTemp = 200; + system1.updateComponent("heat", { inCoolantLoop: false, heatLoad: 0.1, heat: beforeTemp }); + for (let i = 0; i < 60 * 60; i++) { + ecs.update(16); + } + expect(system1.components.heat!.heat).toBeGreaterThan(beforeTemp); + }); + it("should cool better if the pump is running faster", () => { + coolantReservoir.updateComponent("heat", { heat: 100 }); + system1.updateComponent("heat", { inCoolantLoop: true, heatLoad: 1, heat: 1000 }); + coolantReservoir.updateComponent("heat", { inCoolantLoop: true, heatLoad: 0.1, heat: 200 }); + coolantRadiator.updateComponent("heat", { inCoolantLoop: true, heat: 100 }); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + const slowPumpHeat = system1.components.heat!.heat; + + system1.updateComponent("heat", { inCoolantLoop: true, heatLoad: 1, heat: 1000 }); + coolantReservoir.updateComponent("heat", { inCoolantLoop: true, heatLoad: 0.1, heat: 200 }); + coolantRadiator.updateComponent("heat", { inCoolantLoop: true, heat: 100 }); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + // Verify that we're resetting the simulation correctly + expect(system1.components.heat!.heat).toEqual(slowPumpHeat); + }); +}); diff --git a/app/.server/systems/__test__/HeatDispersionSystem.test.ts b/app/.server/systems/__test__/HeatDispersionSystem.test.ts deleted file mode 100644 index 59aee71f..00000000 --- a/app/.server/systems/__test__/HeatDispersionSystem.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { DeckNode } from "@thorium/.server/classes/Plugins/Ship/Deck"; -import { thoriumContext } from "@thorium/utils/.server/context"; -import { createMockDataContext } from "@thorium/utils/.server/createMockDataContext"; -import { testDataStoreProps } from "@thorium/utils/.server/db-fs/testDataStoreProps"; -import { ECS, Entity } from "@thorium/utils/ecs"; -import { describe, it, beforeEach, aroundEach } from "vitest"; - -import { FilterInventorySystem } from "../FilterInventorySystem"; -import { FilterShipsWithReactors } from "../FilterShipsWithReactors"; -import { HeatDispersionSystem } from "../HeatDispersionSystem"; - -aroundEach(async (runTest) => { - await thoriumContext.run(testDataStoreProps, async () => { - await runTest(); - }); -}); - -describe("HeatDispersionSystem", () => { - let ecs: ECS; - let heatDispersionSystem: HeatDispersionSystem; - let filterShipsWithReactorSystem: FilterShipsWithReactors; - let filterInventorySystem: FilterInventorySystem; - let coolant: Entity; - let reactor: Entity; - let ship: Entity; - beforeEach(() => { - const mockDataContext = createMockDataContext(); - ecs = new ECS(mockDataContext.server); - heatDispersionSystem = new HeatDispersionSystem(); - filterShipsWithReactorSystem = new FilterShipsWithReactors(); - filterInventorySystem = new FilterInventorySystem(); - - // Set up the initial state for each of the tests - coolant = new Entity(); - coolant.addComponent("isInventory", { - volume: 0.001, - abundance: 1, - continuous: true, - flags: { coolant: { massPerUnit: 1, heatCapacity: 4.17 } }, - }); - coolant.addComponent("identity", { - name: `Water`, - }); - - ecs.addEntity(coolant); - - reactor = new Entity(); - reactor.addComponent("isShipSystem", { - type: "reactor", - }); - reactor.addComponent("isReactor", { - currentOutput: 6, - maxOutput: 180, - optimalOutputPercent: 0.7, - }); - reactor.addComponent("heat", { - heat: 300, - powerToHeat: 0.01, - nominalHeat: 300, - maxSafeHeat: 400, - maxHeat: 500, - }); - ship = new Entity(); - ship.addComponent("isShip", {}); - ship.addComponent("shipMap", { - decks: [ - { - name: "Deck 1", - backgroundUrl: "", - }, - ], - deckNodes: [ - new DeckNode({ - id: 1, - deckIndex: 0, - x: 0, - y: 0, - systems: ["reactor"], - isRoom: true, - contents: { - Water: { - count: 100, - }, - }, - }), - ], - }); - ship.addComponent("shipSystems"); - ship.components.shipSystems?.shipSystems.set(reactor.id, { roomId: 1 }); - - ecs.addEntity(reactor); - ecs.addEntity(ship); - ecs.addSystem(filterShipsWithReactorSystem); - ecs.addSystem(filterInventorySystem); - ecs.addSystem(heatDispersionSystem); - }); - it.todo("should disperse heat out into space", () => { - if (!reactor.components.isReactor) throw new Error("Not reactor"); - }); -}); diff --git a/app/.server/systems/__test__/HeatToCoolantSystem.test.ts b/app/.server/systems/__test__/HeatToCoolantSystem.test.ts deleted file mode 100644 index b48e2cfd..00000000 --- a/app/.server/systems/__test__/HeatToCoolantSystem.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { DeckNode } from "@thorium/.server/classes/Plugins/Ship/Deck"; -import { thoriumContext } from "@thorium/utils/.server/context"; -import { createMockDataContext } from "@thorium/utils/.server/createMockDataContext"; -import { testDataStoreProps } from "@thorium/utils/.server/db-fs/testDataStoreProps"; -import { ECS, Entity } from "@thorium/utils/ecs"; -import { describe, expect, it, beforeEach, aroundEach } from "vitest"; - -import { FilterInventorySystem } from "../FilterInventorySystem"; -import { FilterShipsWithReactors } from "../FilterShipsWithReactors"; -import { HeatToCoolantSystem } from "../HeatToCoolantSystem"; - -aroundEach(async (runTest) => { - await thoriumContext.run(testDataStoreProps, async () => { - await runTest(); - }); -}); - -describe("HeatToCoolantSystem", () => { - let ecs: ECS; - let heatToCoolantSystem: HeatToCoolantSystem; - let filterShipsWithReactorSystem: FilterShipsWithReactors; - let filterInventorySystem: FilterInventorySystem; - let coolant: Entity; - let reactor: Entity; - let ship: Entity; - beforeEach(() => { - const mockDataContext = createMockDataContext(); - ecs = new ECS(mockDataContext.server); - heatToCoolantSystem = new HeatToCoolantSystem(); - filterShipsWithReactorSystem = new FilterShipsWithReactors(); - filterInventorySystem = new FilterInventorySystem(); - - // Set up the initial state for each of the tests - coolant = new Entity(); - coolant.addComponent("isInventory", { - volume: 0.001, - abundance: 1, - continuous: true, - flags: { coolant: { massPerUnit: 1, heatCapacity: 4.17 } }, - }); - coolant.addComponent("identity", { - name: `Water`, - }); - - ecs.addEntity(coolant); - - reactor = new Entity(); - reactor.addComponent("isShipSystem", { - type: "reactor", - }); - reactor.addComponent("isReactor", { - currentOutput: 6, - maxOutput: 8, - optimalOutputPercent: 0.7, - }); - reactor.addComponent("heat", { - heat: 1200, - powerToHeat: 0.01, - nominalHeat: 300, - maxSafeHeat: 400, - maxHeat: 500, - }); - ship = new Entity(); - ship.addComponent("isShip", {}); - ship.addComponent("shipMap", { - decks: [ - { - name: "Deck 1", - backgroundUrl: "", - }, - ], - deckNodes: [ - new DeckNode({ - id: 1, - deckIndex: 0, - x: 0, - y: 0, - systems: ["reactor"], - isRoom: true, - contents: { - Water: { - count: 100, - }, - }, - }), - ], - }); - ship.addComponent("shipSystems"); - ship.components.shipSystems?.shipSystems.set(reactor.id, { roomId: 1 }); - - ecs.addEntity(reactor); - ecs.addEntity(ship); - ecs.addSystem(filterShipsWithReactorSystem); - ecs.addSystem(filterInventorySystem); - ecs.addSystem(heatToCoolantSystem); - }); - it("should bring the water temperature up to the reactor's temperature", () => { - if (!reactor.components.isReactor) throw new Error("Not reactor"); - - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`1200`); - - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`1191.9922564707272`); - - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`1127.424395170057`); - }); - it("should bring the water temperature down to the reactor's temperature", () => { - if (!reactor.components.isReactor) throw new Error("Not reactor"); - - if (reactor.components.heat) { - reactor.components.heat.heat = 300; - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`300`); - - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`308.00774352927226`); - - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`372.5756048299453`); - }); - it("more water should lower the reactor's heat faster", () => { - if (!reactor.components.isReactor) throw new Error("Not reactor"); - const water = ship.components.shipMap?.deckNodes[0].contents.Water; - if (water) { - water.count = 1; - } - if (reactor.components.heat) { - reactor.components.heat.heat = 1200; - } - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`1199.0351698356333`); - - if (water) { - water.count = 1000; - } - if (reactor.components.heat) { - reactor.components.heat.heat = 1200; - } - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(reactor.components.heat?.heat).toMatchInlineSnapshot(`1189.7420382472358`); - }); -}); diff --git a/app/.server/systems/__test__/ReactorHeatSystem.test.ts b/app/.server/systems/__test__/ReactorHeatSystem.test.ts index 00cac4bd..3fc05390 100644 --- a/app/.server/systems/__test__/ReactorHeatSystem.test.ts +++ b/app/.server/systems/__test__/ReactorHeatSystem.test.ts @@ -1,18 +1,13 @@ -import { DeckNode } from "@thorium/.server/classes/Plugins/Ship/Deck"; +import { ReactorHeatSystem } from "@thorium/.server/systems/ReactorHeatSystem"; import { thoriumContext } from "@thorium/utils/.server/context"; import { createMockDataContext } from "@thorium/utils/.server/createMockDataContext"; import { testDataStoreProps } from "@thorium/utils/.server/db-fs/testDataStoreProps"; import { executeBlocks } from "@thorium/utils/.server/executeBlocks"; import { processTriggers } from "@thorium/utils/.server/processTriggers"; -import { ECS, Entity } from "@thorium/utils/ecs"; +import { ECS } from "@thorium/utils/ecs"; +import Entity from "@thorium/utils/ecs/entity"; import { aroundEach, beforeEach, describe, expect, it } from "vitest"; -import { FilterInventorySystem } from "../FilterInventorySystem"; -import { FilterShipsWithReactors } from "../FilterShipsWithReactors"; -import { HeatDispersionSystem } from "../HeatDispersionSystem"; -import { HeatToCoolantSystem } from "../HeatToCoolantSystem"; -import { ReactorHeatSystem } from "../ReactorHeatSystem"; - aroundEach(async (runTest) => { await thoriumContext.run(testDataStoreProps, async () => { await runTest(); @@ -21,159 +16,43 @@ aroundEach(async (runTest) => { describe("ReactorHeatSystem", () => { let ecs: ECS; - let reactorHeatSystem: ReactorHeatSystem; - let filterShipsWithReactorSystem: FilterShipsWithReactors; - let filterInventorySystem: FilterInventorySystem; - let coolant: Entity; - let reactor: Entity; let ship: Entity; + let reactor: Entity; + beforeEach(() => { const mockDataContext = createMockDataContext(); ecs = new ECS(mockDataContext.server); ecs.executeBlocks = (blocks, blockMetadata) => executeBlocks(ecs, blocks, blockMetadata); ecs.processTriggers = (events) => processTriggers(ecs, events); - reactorHeatSystem = new ReactorHeatSystem(); - filterShipsWithReactorSystem = new FilterShipsWithReactors(); - filterInventorySystem = new FilterInventorySystem(); - - // Set up the initial state for each of the tests - coolant = new Entity(); - coolant.addComponent("isInventory", { - volume: 0.001, - abundance: 1, - continuous: true, - flags: { coolant: { massPerUnit: 1, heatCapacity: 4.17 } }, - }); - coolant.addComponent("identity", { - name: `Water`, - }); + ecs.addSystem(new ReactorHeatSystem()); - ecs.addEntity(coolant); - - reactor = new Entity(); - reactor.addComponent("isShipSystem", { - type: "reactor", - }); - reactor.addComponent("isReactor", { - currentOutput: 6, - maxOutput: 8, - optimalOutputPercent: 0.7, - }); - reactor.addComponent("heat", { - heat: 300, - powerToHeat: 0.01, - nominalHeat: 300, - maxSafeHeat: 400, - maxHeat: 500, - }); ship = new Entity(); - ship.addComponent("isShip", {}); - ship.addComponent("shipMap", { - decks: [ - { - name: "Deck 1", - backgroundUrl: "", - }, - ], - deckNodes: [ - new DeckNode({ - id: 1, - deckIndex: 0, - x: 0, - y: 0, - systems: ["reactor"], - isRoom: true, - contents: { - Deuterium: { - count: 100, - }, - }, - }), - ], - }); ship.addComponent("shipSystems"); - ship.components.shipSystems?.shipSystems.set(reactor.id, { roomId: 1 }); - - ecs.addEntity(reactor); + ship.addComponent("isShip"); ecs.addEntity(ship); - ecs.addSystem(filterShipsWithReactorSystem); - ecs.addSystem(filterInventorySystem); - ecs.addSystem(reactorHeatSystem); - }); - it("should heat up the reactor in the absence of coolant", () => { - if (!reactor.components.isReactor) throw new Error("Not reactor"); - const heatComponent = reactor.components.heat; - // One second - expect(heatComponent?.heat).toEqual(300); - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.01091368421044`); + const shipSystemsMap = ship.components.shipSystems!.shipSystems; - // One minute - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.6657347368366`); - }); - it("should transfer some of the heat into the coolant", () => { - const heatToCoolantSystem = new HeatToCoolantSystem(); - ecs.addSystem(heatToCoolantSystem); - if (ship.components.shipMap) { - ship.components.shipMap.deckNodes[0].contents.Water = { - count: 1000, - }; - } - - const heatComponent = reactor.components.heat; - - expect(heatComponent?.heat).toMatchInlineSnapshot(`300`); - // One second - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.0108617779006`); - - // One minute - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.5336132852704`); + reactor = new Entity(); + reactor.addComponent("heat"); + reactor.addComponent("isReactor"); + reactor.addComponent("isShipSystem", { shipId: ship.id, type: "reactor" }); + shipSystemsMap.set(reactor.id, {}); + ecs.addEntity(reactor); }); - it("should disperse some of the coolant's heat into space", () => { - const heatToCoolantSystem = new HeatToCoolantSystem(); - ecs.addSystem(heatToCoolantSystem); - const heatDispersionSystem = new HeatDispersionSystem(); - ecs.addSystem(heatDispersionSystem); - - if (ship.components.shipMap) { - ship.components.shipMap.deckNodes[0].contents.Water = { - count: 1000, - }; - } - - const heatComponent = reactor.components.heat; - - expect(heatComponent?.heat).toMatchInlineSnapshot(`300`); - - // One second - for (let i = 0; i < 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.0107158633319`); - // One minute - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.1534015618128`); - // Test turning off the reactor - if (reactor.components.isReactor) { - reactor.components.isReactor.currentOutput = 0; - } - for (let i = 0; i < 60 * 60; i++) { - ecs.update(16); - } - expect(heatComponent?.heat).toMatchInlineSnapshot(`299.30451177402915`); + it("should generate heat load based on the output", () => { + reactor.updateComponent("isReactor", { currentOutput: 10, balanced: false }); + ecs.update(16); + const baseHeatLoad = reactor.components.heat!.heatLoad; + expect(reactor.components.heat?.heatLoad).toMatchInlineSnapshot(`0.1`); + reactor.updateComponent("isReactor", { currentOutput: 12, balanced: false }); + ecs.update(16); + expect(reactor.components.heat?.heatLoad).toBeGreaterThan(baseHeatLoad); + reactor.updateComponent("isReactor", { currentOutput: 10, balanced: true }); + ecs.update(16); + expect(reactor.components.heat?.heatLoad).toBeLessThan(baseHeatLoad); + reactor.updateComponent("isReactor", { currentOutput: 6, balanced: false }); + ecs.update(16); + expect(reactor.components.heat?.heatLoad).toBeLessThan(baseHeatLoad); }); }); diff --git a/app/.server/systems/index.ts b/app/.server/systems/index.ts index ebb95fa7..f80cef67 100644 --- a/app/.server/systems/index.ts +++ b/app/.server/systems/index.ts @@ -1,4 +1,5 @@ import { CommSatelliteSystem } from "@thorium/.server/systems/CommSatelliteSystem"; +import { CoolantLoopSystem } from "@thorium/.server/systems/CoolantLoopSystem"; /** * Since the order of system execution matters, we need to import all * of the systems and re-export them in an array @@ -15,6 +16,7 @@ import { NPCKnowledgeSystem } from "@thorium/.server/systems/NPCKnowledgeSystem" import { PowerEfficiencyOverloadSystem } from "@thorium/.server/systems/PowerEfficiencyOverloadSystem"; import { ReactorPowerUpSystem } from "@thorium/.server/systems/ReactorPowerUpSystem"; import { SensorScanSystem } from "@thorium/.server/systems/SensorScanSystem"; +import { ShipSystemHeatSystem } from "@thorium/.server/systems/ShipSystemHeatSystem"; import { ShortRangeCommPowerSystem } from "@thorium/.server/systems/ShortRangeCommPowerSystem"; import { SpontaneousFailureSystem } from "@thorium/.server/systems/SpontaneousFailureSystem"; @@ -25,8 +27,6 @@ import { DataStreamSystem } from "./DataStreamSystem"; import { FacingWaypointSystem } from "./FacingWaypointSystem"; import { FilterInventorySystem } from "./FilterInventorySystem"; import { FilterShipsWithReactors } from "./FilterShipsWithReactors"; -import { HeatDispersionSystem } from "./HeatDispersionSystem"; -import { HeatToCoolantSystem } from "./HeatToCoolantSystem"; import { ImpulseSystem } from "./ImpulseSystem"; import { InterstellarTransitionSystem } from "./InterstellarTransitionSystem"; import { IsDestroyedSystem } from "./IsDestroyedSystem"; @@ -63,6 +63,7 @@ const systems = [ PowerDrawSystem, PowerDistributionSystem, PowerEfficiencyOverloadSystem, + ShipSystemHeatSystem, NPCPhaserChargeSystem, TorpedoLoadingSystem, NearbyObjectsSystem, @@ -87,8 +88,7 @@ const systems = [ WaypointRemoveSystem, FacingWaypointSystem, CollisionWarningSystem, - HeatToCoolantSystem, - HeatDispersionSystem, + CoolantLoopSystem, EngineeringPanelAssignmentSystem, DamageCheckSystem, SpontaneousFailureSystem, diff --git a/app/ecs-components/legacyCoolant.ts b/app/ecs-components/coolant.ts similarity index 68% rename from app/ecs-components/legacyCoolant.ts rename to app/ecs-components/coolant.ts index 5b08c962..190a8563 100644 --- a/app/ecs-components/legacyCoolant.ts +++ b/app/ecs-components/coolant.ts @@ -1,13 +1,13 @@ import z from "zod"; -export const legacyCoolant = z +export const coolant = z .object({ /** Percent for how full the system's coolant tank is */ - coolant: z.number().default(1), + legacyCoolant: z.number().default(1), /** Multiplier for how fast the coolant is transferred out of this system */ - coolantTransferRate: z.number().default(1), + legacyCoolantTransferRate: z.number().default(1), /** Multiplier for how fast the coolant is used to cool the system */ - coolantConsumptionRate: z.number().default(1), + legacyCoolantConsumptionRate: z.number().default(1), /** Whether the coolant is actively being applied to the heat */ cooling: z.boolean().default(false), }) diff --git a/app/ecs-components/heat.ts b/app/ecs-components/heat.ts index e969cb60..ecee8dfa 100644 --- a/app/ecs-components/heat.ts +++ b/app/ecs-components/heat.ts @@ -4,6 +4,21 @@ export const heat = z .object({ /** The current heat value in Kelvin. Defaults to room temperature. */ heat: z.number().min(0).default(295.37), + + /** + * How much volume the coolant takes up in this system in m^3. + * This affects both how fast systems heat up and how quickly + * they cool down when attached to the coolant loop. + * Default is a pipe 10 meters long and 0.3 meter radius + **/ + coolantVolume: z.number().default(100), + + /** Whether the coolant is flowing through this system */ + inCoolantLoop: z.boolean().default(false), + + /** How much heat energy in MW is applied to the system, calculated by ECS */ + heatLoad: z.number().default(0), + /** * The percentage of power that passes through the system which is turned * into heat. diff --git a/app/ecs-components/list.ts b/app/ecs-components/list.ts index c6a682a1..b5e95274 100644 --- a/app/ecs-components/list.ts +++ b/app/ecs-components/list.ts @@ -56,7 +56,7 @@ export * from "./variables"; export * from "./tweaks"; export * from "./damageReport"; export * from "./shipSystems/diagnostic"; -export * from "./legacyCoolant"; +export * from "./coolant"; export * from "./legacySensorContact"; export * from "./isInternalMessage"; export * from "./isDocument"; diff --git a/app/ecs-components/shipSystems/index.ts b/app/ecs-components/shipSystems/index.ts index bc24bb64..71a66b99 100644 --- a/app/ecs-components/shipSystems/index.ts +++ b/app/ecs-components/shipSystems/index.ts @@ -20,3 +20,4 @@ export * from "./isLongRangeComm"; export * from "./isShortRangeComm"; export * from "./isConversationTemplate"; export * from "./isExocomp"; +export * from "./isCoolantLoop"; diff --git a/app/ecs-components/shipSystems/isCoolantLoop.ts b/app/ecs-components/shipSystems/isCoolantLoop.ts new file mode 100644 index 00000000..ec89718a --- /dev/null +++ b/app/ecs-components/shipSystems/isCoolantLoop.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +// Three separate entities, so each can have its own +// heat component + +export const isCoolantReservoir = z.object({ + /** The actual volume is handled by the heat component */ + /** How much coolant can fit in the reservoir in liters */ + capacity: z.number().default(1000), + // Default coolant properties are ethylene glycol + /** The density of the coolant in kg/m^3. Used to determine flow mass and coolant mass. */ + coolantDensity: z.number().default(1113.2), + /** Specific heat in J/gK. Used to determine how quickly heat flows in and out of coolant. */ + coolantSpecificHeat: z.number().default(2.42), +}); + +export const isCoolantPump = z.object({ + /** How fast the pump pumps at required power in liters/minute */ + baseFlowRate: z.number().default(40000), +}); + +export const isCoolantRadiator = z.object({ + /** How big the radiator is in square meters */ + area: z.number().default(1), + /** How much energy is radiated, calculated every frame */ + radiationWatts: z.number().default(0), +}); diff --git a/app/ecs-components/shipSystems/isShipSystem.ts b/app/ecs-components/shipSystems/isShipSystem.ts index bd99e081..e2cb98ba 100644 --- a/app/ecs-components/shipSystems/isShipSystem.ts +++ b/app/ecs-components/shipSystems/isShipSystem.ts @@ -19,6 +19,9 @@ const shipSystemTypes = z.enum([ "longRangeComm", "shortRangeComm", "exocomps", + "coolantReservoir", + "coolantPump", + "coolantRadiator", ]); export type ShipSystemTypes = z.infer; diff --git a/app/routes/config/systems/heat.tsx b/app/routes/config/systems/heat.tsx index e5987e59..b6bc76b8 100644 --- a/app/routes/config/systems/heat.tsx +++ b/app/routes/config/systems/heat.tsx @@ -35,7 +35,8 @@ export default function Heat() { ( ecs: ECS, param: { systemType: ShipSystemTypes; shipId: number } | { systemId: number }, -) { + safe?: T, +): T extends true ? Entity | undefined : Entity { let system: Entity | undefined | null; if ("systemId" in param && param.systemId) { system = ecs.getEntityById(param.systemId); } else if ("systemType" in param) { - const cacheKey = `${param.shipId}-${param.systemType}`; - if (ecs.shipSystemCache.has(cacheKey)) { - const cacheEntry = ecs.shipSystemCache.get(cacheKey)!; - if (Array.isArray(cacheEntry)) return cacheEntry[0]; - return cacheEntry; + if (ecs.shipSystemCache.has(param.shipId)) { + const shipEntry = ecs.shipSystemCache.get(param.shipId)!; + if (shipEntry.has(param.systemType)) { + const cacheEntry = shipEntry.get(param.systemType); + if (Array.isArray(cacheEntry)) return cacheEntry[0]; + return cacheEntry!; + } + } else { + ecs.shipSystemCache.set(param.shipId, new Map()); } for (const [id] of ecs.getEntityById(param.shipId)?.components.shipSystems?.shipSystems || []) { const entity = ecs.getEntityById(id); @@ -24,19 +29,25 @@ export function getShipSystem( } } if (system) { - ecs.shipSystemCache.set(cacheKey, system); + ecs.shipSystemCache.get(param.shipId)!.set(param.systemType, system); } } - if (!system) throw new Error(`System ${JSON.stringify(param)} not found.`); - return system; + if (!safe) { + if (!system) throw new Error(`System ${JSON.stringify(param)} not found.`); + } + return system!; } export function getShipSystems(ecs: ECS, param: { systemType: string; shipId: number }) { - const cacheKey = `${param.shipId}-${param.systemType}`; - if (ecs.shipSystemCache.has(cacheKey)) { - const cacheEntry = ecs.shipSystemCache.get(cacheKey)!; - if (Array.isArray(cacheEntry)) return cacheEntry; - return [cacheEntry]; + if (ecs.shipSystemCache.has(param.shipId)) { + const shipEntry = ecs.shipSystemCache.get(param.shipId)!; + if (shipEntry.has(param.systemType)) { + const cacheEntry = shipEntry.get(param.systemType); + if (Array.isArray(cacheEntry)) return cacheEntry; + return [cacheEntry]; + } + } else { + ecs.shipSystemCache.set(param.shipId, new Map()); } const systems: Entity[] = []; const ship = ecs.getEntityById(param.shipId); @@ -46,7 +57,7 @@ export function getShipSystems(ecs: ECS, param: { systemType: string; shipId: nu systems.push(entity); } } - ecs.shipSystemCache.set(cacheKey, systems); + ecs.shipSystemCache.get(param.shipId)!.set(param.systemType, systems); return systems; } diff --git a/app/utils/ecs/ecs.ts b/app/utils/ecs/ecs.ts index 94d389c5..daf65d4f 100644 --- a/app/utils/ecs/ecs.ts +++ b/app/utils/ecs/ecs.ts @@ -38,7 +38,7 @@ class ECS { maxEntityId = 1; componentCache = new Map>(); colliderCache = new Map(); - shipSystemCache = new Map(); + shipSystemCache = new Map>(); changeBatch = new Set<`${number}-${ComponentIds}`>(); // The key is the sector number based on the location of this physics world worlds = new Map(); diff --git a/app/utils/live-query/.server/adapters/hono-adapter/index.ts b/app/utils/live-query/.server/adapters/hono-adapter/index.ts index ede09357..f5bc9fb3 100644 --- a/app/utils/live-query/.server/adapters/hono-adapter/index.ts +++ b/app/utils/live-query/.server/adapters/hono-adapter/index.ts @@ -144,27 +144,33 @@ export async function liveQueryPlugin({ }, async onOpen(_, ws) { socketEmitter.emit("open"); - - const result = await Promise.race([ - new Promise((res) => { - const handleConnection = (data: any) => { - const { type, ...message } = JSON.parse(data.toString()); - if (type === "clientConnect") { - const id = message.id; - socketEmitter.off("message", handleConnection); - res(id); - } - }; - socketEmitter.on("message", handleConnection); - }), - new Promise((res, rej) => setTimeout(() => rej(`Client Connect Timeout`), 60 * 1000)), - ]); - initWebsocket({ - clientId: result, - send: (data) => ws.send(data), - socketEmitter, - extraContext, - }); + try { + const result = await Promise.race([ + new Promise((res) => { + const handleConnection = (data: any) => { + const { type, ...message } = JSON.parse(data.toString()); + if (type === "clientConnect") { + const id = message.id; + socketEmitter.off("message", handleConnection); + res(id); + } + }; + socketEmitter.on("message", handleConnection); + }), + new Promise((res, rej) => + setTimeout(() => rej(`Client Connect Timeout`), 60 * 1000), + ), + ]); + initWebsocket({ + clientId: result, + send: (data) => ws.send(data), + socketEmitter, + extraContext, + }); + } catch (error) { + console.error("Failed to connect client", error); + ws.close(3008); + } }, }; })(c, next);