diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2f71d6..166bea09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [1.0.0-alpha.10](https://github.com/Thorium-Sim/thorium-nova/compare/1.0.0-alpha.9...1.0.0-alpha.10) (2023-02-23) + + +### Bug Fixes + +* Add back in the "Reconnect to Server" dialogs ([7f34e1c](https://github.com/Thorium-Sim/thorium-nova/commit/7f34e1cfce0619209b3496c29cdba2b59f6f7915)) +* Auto-claim host when first connecting. ([52b4d75](https://github.com/Thorium-Sim/thorium-nova/commit/52b4d757daac0ebd940df30d30e6c1635a019828)) +* Config UX improvements. ([e5bd455](https://github.com/Thorium-Sim/thorium-nova/commit/e5bd4554fe8134dcd2a49ee30aa32860c28ea870)) +* Darken the background when configuring plugins. ([d20f426](https://github.com/Thorium-Sim/thorium-nova/commit/d20f426cfb9085ac6fef40d08dbab90dc189d6b7)) +* Flight Director right-click to spawn and order ships. ([4cbcb0e](https://github.com/Thorium-Sim/thorium-nova/commit/4cbcb0e33d98ea8095a687e0f580d0125257da84)) +* If a ship doesn't have a theme assigned to it, only automatically assign themes marked as default. ([004c954](https://github.com/Thorium-Sim/thorium-nova/commit/004c954cd17724f212bdbf0b15f632ea713b04c6)) +* Interstellar viewscreen no longer shows the Flight Director view. ([e8c9840](https://github.com/Thorium-Sim/thorium-nova/commit/e8c98403190a7674b2d5e52767226af370353bb1)) +* Navigation no longer crashes when transitioning to interstellar space. ([acab5f5](https://github.com/Thorium-Sim/thorium-nova/commit/acab5f5dcf66e142037ab9afdb08c01ef4f7e248)) +* Properly save flights periodically while the app is open and when it closes. ([55ca019](https://github.com/Thorium-Sim/thorium-nova/commit/55ca0199ef2c666c6f5b687f0eb5c3036373f942)) +* Refactor inventory to use liters instead of cubic meters. ([6d0e95f](https://github.com/Thorium-Sim/thorium-nova/commit/6d0e95f31f14c37b0313ea674018d45a90107849)) +* Resolves backend errors when visiting plugin config page. ([be8fb36](https://github.com/Thorium-Sim/thorium-nova/commit/be8fb3696e50c58ff38c1f63b477b123c75ef3ef)) +* Send initial data stream when a client first requests it. ([9a94fc2](https://github.com/Thorium-Sim/thorium-nova/commit/9a94fc2c6aff13cabe34fd7e09ce92e1a208377d)) +* System specific config overrides ([1bb73da](https://github.com/Thorium-Sim/thorium-nova/commit/1bb73da04059652465d859d1f5a872bf86a0622b)) + + +### Features + +* Add a button to restore the default plugin, which will also update the default plugin contents with new Thorium Nova versions. ([82388a8](https://github.com/Thorium-Sim/thorium-nova/commit/82388a8e6ca5cd4d298d0a4e200748e1b9aa1c03)) +* Enable snapping network data instead of smoothly interpolating. ([2ab84d8](https://github.com/Thorium-Sim/thorium-nova/commit/2ab84d89a6c9ac6363743d9e11ec16b005e0dd55)) +* Improve the behavior of autopilot rotation. ([0ca5723](https://github.com/Thorium-Sim/thorium-nova/commit/0ca57238f4a3e61d824dec5d03a355e29d2520a1)) +* Randomly cycle through backgrounds. ([945dd22](https://github.com/Thorium-Sim/thorium-nova/commit/945dd2240ecefb69eed894ff95c4d13d2d615a7e)) + # [1.0.0-alpha.9](https://github.com/Thorium-Sim/thorium-nova/compare/1.0.0-alpha.8...1.0.0-alpha.9) (2023-02-08) diff --git a/package.json b/package.json index 4cbf7a8e..00041933 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thorium-nova", - "version": "1.0.0-alpha.9", + "version": "1.0.0-alpha.10", "description": "Spaceship Simulator Controls Platform", "keywords": [], "author": "Alex Anderson", diff --git a/server/src/components/power.ts b/server/src/components/power.ts index 26c6f9d9..493d892c 100644 --- a/server/src/components/power.ts +++ b/server/src/components/power.ts @@ -13,10 +13,14 @@ export class PowerComponent extends Component { /** The threshold of power usage for safely using this system */ maxSafePower: MegaWatt = 20; - /** The current power provided to this system, calculated every frame */ + /** The current power provided to this system, calculated every frame. */ currentPower: MegaWatt = 10; - /** How much power the system is currently drawing, calculated every frame */ + /** + * How much power the system is attempting to draw, calculated every frame. + * This will always be less than or equal to requested power. If the system + * isn't doing as much work, it won't draw as much power. + */ powerDraw: MegaWatt = 0; /** How much power is currently being requested. Could be more than the maxSafePower */ diff --git a/server/src/components/shipSystems/powerGrid/index.ts b/server/src/components/shipSystems/powerGrid/index.ts index 449fb13d..9051a8b4 100644 --- a/server/src/components/shipSystems/powerGrid/index.ts +++ b/server/src/components/shipSystems/powerGrid/index.ts @@ -1,4 +1,3 @@ export * from "./isBattery"; export * from "./isPowerNode"; export * from "./isReactor"; -export * from "./powerConnection"; diff --git a/server/src/components/shipSystems/powerGrid/isBattery.ts b/server/src/components/shipSystems/powerGrid/isBattery.ts index c8b34f2e..be962266 100644 --- a/server/src/components/shipSystems/powerGrid/isBattery.ts +++ b/server/src/components/shipSystems/powerGrid/isBattery.ts @@ -4,11 +4,21 @@ import {Component} from "../../utils"; export class IsBatteryComponent extends Component { static id = "isBattery" as const; + /** + * The power nodes that are associated with this battery + */ + connectedNodes: number[] = []; + /** * The amount of power this battery can hold. This provides * 23 minutes of sustained power. */ capacity: MegaWattHour = 46; + + /** + * How much power the battery is currently storing + */ + storage: MegaWattHour = 46; /** * How much energy the battery can use to charge. Typically * batteries charge faster than they discharge, while capacitors diff --git a/server/src/components/shipSystems/powerGrid/isPowerNode.ts b/server/src/components/shipSystems/powerGrid/isPowerNode.ts index bccb56fb..bdde571e 100644 --- a/server/src/components/shipSystems/powerGrid/isPowerNode.ts +++ b/server/src/components/shipSystems/powerGrid/isPowerNode.ts @@ -19,5 +19,5 @@ export class IsPowerNodeComponent extends Component { * - Least Need First (first fill up the systems with the smallest power requirement) * - Most Need First (first fill up the systems with the largest power requirement) */ - distributionMode: "evenly" | "leastFirst" | "mostFirst" = "leastFirst"; + distributionMode: "evenly" | "leastFirst" | "mostFirst" = "evenly"; } diff --git a/server/src/components/shipSystems/powerGrid/isReactor.ts b/server/src/components/shipSystems/powerGrid/isReactor.ts index 9ac72f05..82e8328c 100644 --- a/server/src/components/shipSystems/powerGrid/isReactor.ts +++ b/server/src/components/shipSystems/powerGrid/isReactor.ts @@ -4,6 +4,11 @@ import {Component} from "../../utils"; export class IsReactorComponent extends Component { static id = "isReactor" as const; + /** + * The power nodes and batteries that are associated with this reactor + */ + connectedEntities: number[] = []; + /** * This will be set when the ship is spawned * based on the total power required diff --git a/server/src/components/shipSystems/powerGrid/powerConnection.ts b/server/src/components/shipSystems/powerGrid/powerConnection.ts deleted file mode 100644 index 1bd6ff2e..00000000 --- a/server/src/components/shipSystems/powerGrid/powerConnection.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {Component} from "../../utils"; - -export class PowerConnectionComponent extends Component { - static id = "powerConnection" as const; - - /** - * Entities which provide power to this entity - */ - inputEntities: number[] = []; - /** - * Entities which this entity provides power to - */ - outputEntities: number[] = []; -} diff --git a/server/src/spawners/ship.ts b/server/src/spawners/ship.ts index 49fdc4fa..bdaa0825 100644 --- a/server/src/spawners/ship.ts +++ b/server/src/spawners/ship.ts @@ -10,6 +10,7 @@ import {spawnShipSystem} from "./shipSystem"; import ReactorPlugin from "@server/classes/Plugins/ShipSystems/Reactor"; import BaseShipSystemPlugin from "@server/classes/Plugins/ShipSystems/BaseSystem"; import {getInventoryTemplates} from "@server/utils/getInventoryTemplates"; +import {battery} from "@client/pages/Config/data/systems/battery"; const systemCache: Record = {}; function getSystem( @@ -84,7 +85,7 @@ export function spawnShip( node.addComponent("isPowerNode", { maxConnections: 3, connectedSystems: [], - distributionMode: "leastFirst", + distributionMode: "evenly", }); powerNodes[name] = {entity: node, count: 0}; systemEntities.push(node); @@ -102,6 +103,16 @@ export function spawnShip( // Reactors are special, so take care of them later. break; + case "battery": { + const entity = spawnShipSystem(systemPlugin, system.overrides); + if (entity.components.isBattery) { + entity.components.isBattery.storage = + entity.components.isBattery.capacity; + } + systemEntities.push(entity); + + break; + } default: { const entity = spawnShipSystem(systemPlugin, system.overrides); systemEntities.push(entity); diff --git a/server/src/systems/PowerGridSystem.ts b/server/src/systems/PowerGridSystem.ts new file mode 100644 index 00000000..de0d56ad --- /dev/null +++ b/server/src/systems/PowerGridSystem.ts @@ -0,0 +1,306 @@ +import {Entity, System} from "../utils/ecs"; + +/** + * There's a lot to weight with how power is distributed. + * First, there's the double-connection between reactors and power nodes. + * A power node might be connected to multiple reactors, and each reactor + * could be connected to multiple power nodes. + * + * Also, there's the issue of batteries, which shouldn't be charged until + * all of the power nodes connected to a reactor have been supplied all + * the power they need, but also reactors attached to a battery should be + * weighted less than other reactors to provide more of an opportunity for + * the batteries to be charged, at the expense of having a different reactor + * not connected to the battery generate more power to fulfill the power + * node requirements. + * + * This algorithm should be efficient, which means as little looping as possible. + */ + +export class PowerGridSystem extends System { + test(entity: Entity) { + return !!entity.components.isShip; + } + update(entity: Entity, elapsed: number) { + const elapsedTimeHours = elapsed / 1000 / 60 / 60; + let poweredSystems: Entity[] = []; + let reactors: Entity[] = []; + let batteries: Entity[] = []; + let powerNodes: Entity[] = []; + + const systemIds = entity.components.shipSystems?.shipSystems.keys() || []; + for (let sysId of systemIds) { + const sys = this.ecs.getEntityById(sysId); + if (sys?.components.isReactor) reactors.push(sys); + else if (sys?.components.isBattery) batteries.push(sys); + else if (sys?.components.isPowerNode) powerNodes.push(sys); + else if (sys?.components.isShipSystem && sys.components.power) + poweredSystems.push(sys); + } + + // First, figure out how much power each power node is requesting + const nodeRequestedPower = new Map(); + for (let node of powerNodes) { + let nodePower = 0; + for (let systemId of node.components.isPowerNode?.connectedSystems || + []) { + const system = poweredSystems.find(s => s.id === systemId); + nodePower += system?.components.power?.powerDraw || 0; + } + nodeRequestedPower.set(node.id, nodePower); + } + + // Sort reactors based on whether they are connected to batteries, + // and how many power nodes they are connected to. + reactors.sort((a, b) => { + if (!a.components.isReactor) return -1; + if (!b.components.isReactor) return 1; + const aBatteries = a.components.isReactor?.connectedEntities.filter(id => + batteries.find(b => b.id === id) + ).length; + const bBatteries = b.components.isReactor?.connectedEntities.filter(id => + batteries.find(b => b.id === id) + ).length; + if (aBatteries > bBatteries) return -1; + if (bBatteries > aBatteries) return 1; + + return ( + a.components.isReactor?.connectedEntities.length - + b.components.isReactor?.connectedEntities.length + ); + }); + // Supply reactor power to the power nodes, + // but only up to their requested power level + const nodeSuppliedPower = new Map(); + const batterySuppliedPower = new Map(); + for (let reactor of reactors) { + if (!reactor.components.isReactor) continue; + if (reactor.components.isReactor.connectedEntities.length === 0) continue; + + // Convert the total power output to the instantaneous output by dividing it by one hour + let totalPower = reactor.components.isReactor.currentOutput; + + // Distribute power to power nodes first + const reactorNodes = reactor.components.isReactor.connectedEntities + .map(id => { + const powerNode = powerNodes.find(node => node.id === id); + if (!powerNode) return null; + return { + id: powerNode.id, + requestedPower: Math.max( + 0, + (nodeRequestedPower.get(id) || 0) - + (nodeSuppliedPower.get(id) || 0) + ), + }; + }) + .filter(Boolean) as {id: number; requestedPower: number}[]; + + reactorNodes.sort((a, b) => { + return a.requestedPower - b.requestedPower; + }); + while (totalPower > 0) { + let powerSplit = totalPower / reactorNodes.length; + + const leastNode = reactorNodes[0]; + + if (!leastNode) break; + + // The least node doesn't need it's allotment of power, so let's + // give it all that it's asking for and split the rest among + // the other nodes + if (leastNode.requestedPower < powerSplit) { + reactorNodes.forEach(node => { + const currentPower = nodeSuppliedPower.get(node.id) || 0; + totalPower -= leastNode.requestedPower; + nodeSuppliedPower.set( + node.id, + leastNode.requestedPower + currentPower + ); + }); + reactorNodes.shift(); + continue; + } + + // There isn't enough power for all the nodes + // to get all that they want from this reactor + // so we'll give them all it can give. + reactorNodes.forEach(node => { + const currentPower = nodeSuppliedPower.get(node.id) || 0; + nodeSuppliedPower.set(node.id, currentPower + powerSplit); + totalPower -= powerSplit; + }); + break; + } + + // Is there power left over? Charge up the batteries + const reactorBatteries = reactor.components.isReactor.connectedEntities + .map(id => { + const battery = batteries.find(node => node.id === id); + if (!battery?.components.isBattery) return null; + const chargeCapacity = + (battery.components.isBattery?.chargeRate || 0) - + (batterySuppliedPower.get(id) || 0); + return { + id: battery.id, + requestedPower: Math.max(0, chargeCapacity), + }; + }) + .filter(Boolean) as {id: number; requestedPower: number}[]; + + reactorBatteries.sort((a, b) => { + return a.requestedPower - b.requestedPower; + }); + + while (totalPower > 0) { + let powerSplit = totalPower / reactorBatteries.length; + + const leastBattery = reactorBatteries[0]; + + if (!leastBattery) break; + + // The least node doesn't need it's allotment of power, so let's + // give it all that it's asking for and split the rest among + // the other nodes + if (leastBattery.requestedPower < powerSplit) { + reactorBatteries.forEach(battery => { + const currentPower = batterySuppliedPower.get(battery.id) || 0; + + totalPower -= leastBattery.requestedPower; + batterySuppliedPower.set( + battery.id, + leastBattery.requestedPower + currentPower + ); + }); + reactorBatteries.shift(); + continue; + } + + // There isn't enough power for all the batteries + // to get all that they want from this reactor + // so we'll give them all it can give. + reactorBatteries.forEach(node => { + const currentPower = batterySuppliedPower.get(node.id) || 0; + batterySuppliedPower.set(node.id, currentPower + powerSplit); + }); + break; + } + } + + // Now apply the battery power levels + batterySuppliedPower.forEach((value, key) => { + const battery = batteries.find(node => node.id === key); + const capacity = battery?.components.isBattery?.capacity || 0; + const storage = battery?.components.isBattery?.storage || 0; + const limit = battery?.components.isBattery?.chargeRate || Infinity; + battery?.updateComponent("isBattery", { + storage: Math.min( + capacity, + storage + Math.min(value, limit) * elapsedTimeHours + ), + }); + }); + + // Distribute the power node power to all of the connected systems + nodeSuppliedPower.forEach((value, key) => { + const node = powerNodes.find(node => node.id === key); + if (value < (nodeRequestedPower.get(key) || 0)) { + // If a power node doesn't have sufficient power, + // draw that power from batteries + const connectedBatteries = batteries.filter(b => + b.components.isBattery?.connectedNodes.includes(key) + ); + let excessDemand = (nodeRequestedPower.get(key) || 0) - value; + connectedBatteries.forEach(battery => { + const limit = battery.components.isBattery?.dischargeRate || Infinity; + const storage = battery.components.isBattery?.storage || 0; + const powerDraw = Math.min(limit, excessDemand) * elapsedTimeHours; + if (storage > powerDraw) { + battery.updateComponent("isBattery", { + storage: Math.max(0, storage - powerDraw), + }); + excessDemand = 0; + value = nodeRequestedPower.get(key) || 0; + } else { + excessDemand -= storage / elapsedTimeHours; + value += storage / elapsedTimeHours; + battery.updateComponent("isBattery", {storage: 0}); + } + }); + } + // Distribute all of the power to systems based on the power node's distribution scheme + const connectedSystems = poweredSystems.filter(sys => + node?.components.isPowerNode?.connectedSystems.includes(sys.id) + ); + const distributionMode = + node?.components.isPowerNode?.distributionMode || "evenly"; + + connectedSystems.sort((a, b) => { + if (distributionMode === "mostFirst") { + return ( + (b.components.power?.powerDraw || 0) - + (a.components.power?.powerDraw || 0) + ); + } else { + return ( + (a.components.power?.powerDraw || 0) - + (b.components.power?.powerDraw || 0) + ); + } + }); + if (distributionMode === "evenly") { + connectedSystems.forEach(entity => { + entity.updateComponent("power", {currentPower: 0}); + }); + + while (value > 0) { + let powerSplit = value / connectedSystems.length; + const leastPowerRequired = connectedSystems[0]; + if (!leastPowerRequired) break; + + // The system with the least power need doesn't need it's allotment of power, so let's + // give it all that it's trying to pull and split the rest among the other systems + const requestedPower = + leastPowerRequired.components.power?.powerDraw || 0; + + if (requestedPower < powerSplit) { + connectedSystems.forEach(entity => { + value -= requestedPower; + const currentPower = entity.components.power?.currentPower || 0; + const sysRequestedPower = entity.components.power?.powerDraw || 0; + entity.updateComponent("power", { + currentPower: Math.min( + sysRequestedPower, + requestedPower + currentPower + ), + }); + }); + connectedSystems.shift(); + continue; + } + // There isn't enough power for all the systems + // to get all that they want from this node + // so we'll give them all it can give. + connectedSystems.forEach(system => { + const currentPower = system.components.power?.currentPower || 0; + const requestedPower = system.components.power?.powerDraw || 0; + system.updateComponent("power", { + currentPower: Math.min(requestedPower, powerSplit + currentPower), + }); + }); + break; + } + } else { + connectedSystems.forEach(system => { + let powerDraw = Math.min( + system.components.power?.powerDraw || 0, + value + ); + if (powerDraw < 0) powerDraw = 0; + system.updateComponent("power", {currentPower: powerDraw}); + value -= powerDraw; + }); + } + }); + } +} diff --git a/server/src/systems/__test__/PowerGridSystem.test.ts b/server/src/systems/__test__/PowerGridSystem.test.ts new file mode 100644 index 00000000..f6a92b55 --- /dev/null +++ b/server/src/systems/__test__/PowerGridSystem.test.ts @@ -0,0 +1,357 @@ +import {createMockDataContext} from "@server/utils/createMockDataContext"; +import {ECS, Entity} from "@server/utils/ecs"; +import {randomFromList} from "@server/utils/randomFromList"; +import {PowerGridSystem} from "../PowerGridSystem"; + +describe("PowerGridSystem", () => { + let ecs: ECS; + let ship: Entity; + beforeEach(() => { + const mockDataContext = createMockDataContext(); + + ecs = new ECS(mockDataContext.server); + ecs.addSystem(new PowerGridSystem()); + ship = new Entity(); + ship.addComponent("isShip"); + ship.addComponent("shipSystems"); + ecs.addEntity(ship); + }); + it("should work with a simple setup", () => { + const system = new Entity(); + system.addComponent("isShipSystem", {type: "generic"}); + system.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + ecs.addEntity(system); + + const powerNode = new Entity(); + powerNode.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [system.id], + }); + ship.components.shipSystems?.shipSystems.set(powerNode.id, {}); + ecs.addEntity(powerNode); + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", {type: "reactor"}); + reactor.addComponent("isReactor", { + currentOutput: 60, + connectedEntities: [powerNode.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + expect(system.components.power?.currentPower).toEqual(0); + + ecs.update(16); + expect(system.components.power?.currentPower).toEqual(50); + + reactor.updateComponent("isReactor", {currentOutput: 10}); + ecs.update(16); + expect(system.components.power?.currentPower).toEqual(10); + + system.updateComponent("power", {powerDraw: 5}); + ecs.update(16); + expect(system.components.power?.currentPower).toEqual(5); + }); + + it("should properly distribute power from a single reactor to multiple systems", () => { + const system1 = new Entity(); + system1.addComponent("isShipSystem", {type: "generic"}); + system1.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system1.id, {}); + ecs.addEntity(system1); + const system2 = new Entity(); + system2.addComponent("isShipSystem", {type: "generic"}); + system2.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system2.id, {}); + ecs.addEntity(system2); + + const powerNode = new Entity(); + powerNode.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [system1.id, system2.id], + }); + ship.components.shipSystems?.shipSystems.set(powerNode.id, {}); + ecs.addEntity(powerNode); + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", {type: "reactor"}); + reactor.addComponent("isReactor", { + currentOutput: 60, + connectedEntities: [powerNode.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(30); + expect(system2.components.power?.currentPower).toEqual(30); + + system1.updateComponent("power", {powerDraw: 10}); + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(10); + expect(system2.components.power?.currentPower).toEqual(50); + + powerNode.updateComponent("isPowerNode", {distributionMode: "leastFirst"}); + reactor.updateComponent("isReactor", {currentOutput: 15}); + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(10); + expect(system2.components.power?.currentPower).toEqual(5); + + powerNode.updateComponent("isPowerNode", {distributionMode: "mostFirst"}); + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(0); + expect(system2.components.power?.currentPower).toEqual(15); + }); + it("should work with multiple reactors connected to multiple power nodes", () => { + const system1 = new Entity(); + system1.addComponent("isShipSystem", {type: "generic"}); + system1.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system1.id, {}); + ecs.addEntity(system1); + const system2 = new Entity(); + system2.addComponent("isShipSystem", {type: "generic"}); + system2.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system2.id, {}); + ecs.addEntity(system2); + const system3 = new Entity(); + system3.addComponent("isShipSystem", {type: "generic"}); + system3.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system3.id, {}); + ecs.addEntity(system3); + const system4 = new Entity(); + system4.addComponent("isShipSystem", {type: "generic"}); + system4.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system4.id, {}); + ecs.addEntity(system4); + + const powerNode1 = new Entity(); + powerNode1.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [system1.id, system2.id], + }); + ship.components.shipSystems?.shipSystems.set(powerNode1.id, {}); + ecs.addEntity(powerNode1); + const powerNode2 = new Entity(); + powerNode2.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [system3.id, system4.id], + }); + ship.components.shipSystems?.shipSystems.set(powerNode2.id, {}); + ecs.addEntity(powerNode2); + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", {type: "reactor"}); + reactor.addComponent("isReactor", { + currentOutput: 60, + connectedEntities: [powerNode1.id, powerNode2.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(15); + expect(system2.components.power?.currentPower).toEqual(15); + expect(system3.components.power?.currentPower).toEqual(15); + expect(system4.components.power?.currentPower).toEqual(15); + + const reactor2 = new Entity(); + reactor2.addComponent("isShipSystem", {type: "reactor"}); + reactor2.addComponent("isReactor", { + currentOutput: 60, + connectedEntities: [powerNode2.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor2.id, {}); + ecs.addEntity(reactor2); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(15); + expect(system2.components.power?.currentPower).toEqual(15); + expect(system3.components.power?.currentPower).toEqual(45); + expect(system4.components.power?.currentPower).toEqual(45); + + system1.updateComponent("power", {powerDraw: 10}); + system2.updateComponent("power", {powerDraw: 10}); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(10); + expect(system2.components.power?.currentPower).toEqual(10); + expect(system3.components.power?.currentPower).toEqual(50); + expect(system4.components.power?.currentPower).toEqual(50); + + system1.updateComponent("power", {powerDraw: 50}); + system2.updateComponent("power", {powerDraw: 50}); + system3.updateComponent("power", {powerDraw: 10}); + system4.updateComponent("power", {powerDraw: 10}); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(30); + expect(system2.components.power?.currentPower).toEqual(30); + expect(system3.components.power?.currentPower).toEqual(10); + expect(system4.components.power?.currentPower).toEqual(10); + }); + it("should properly charge and discharge batteries", () => { + const system = new Entity(); + system.addComponent("isShipSystem", {type: "generic"}); + system.addComponent("power", {powerDraw: 50, currentPower: 0}); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + ecs.addEntity(system); + + const powerNode = new Entity(); + powerNode.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [system.id], + }); + ship.components.shipSystems?.shipSystems.set(powerNode.id, {}); + ecs.addEntity(powerNode); + + const battery = new Entity(); + battery.addComponent("isShipSystem", {type: "battery"}); + battery.addComponent("isBattery", { + connectedNodes: [powerNode.id], + storage: 0, + }); + ship.components.shipSystems?.shipSystems.set(battery.id, {}); + ecs.addEntity(battery); + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", {type: "reactor"}); + reactor.addComponent("isReactor", { + currentOutput: 30, + connectedEntities: [battery.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + expect(battery.components.isBattery?.storage).toEqual(0); + ecs.update(16); + expect(battery.components.isBattery?.storage).toMatchInlineSnapshot( + `0.00013333333333333334` + ); + reactor.updateComponent("isReactor", {currentOutput: 180}); + battery.updateComponent("isBattery", {storage: 0}); + ecs.update(16); + const storage = battery.components.isBattery?.storage; + reactor.updateComponent("isReactor", {currentOutput: 500}); + battery.updateComponent("isBattery", {storage: 0}); + ecs.update(16); + expect(storage).toMatchInlineSnapshot(`0.0008000000000000001`); + expect(storage).toEqual(battery.components.isBattery?.storage); + + // It should take 16 minutes to fully charge a battery at this rate. + for (let i = 0; i < 60 * 60 * 16; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toMatchInlineSnapshot(`46`); + + reactor.updateComponent("isReactor", { + currentOutput: 50, + connectedEntities: [battery.id, powerNode.id], + }); + battery.updateComponent("isBattery", {storage: 10}); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toEqual(10); + reactor.updateComponent("isReactor", { + currentOutput: 30, + connectedEntities: [battery.id, powerNode.id], + }); + battery.updateComponent("isBattery", {storage: 10}); + ecs.update(16); + expect(battery.components.isBattery?.storage).toEqual(9.99991111111111); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toEqual(9.994577777777742); + }); + it("should perform decently well", () => { + const reactors = Array.from({length: 5}).map(() => { + const reactor = new Entity(); + reactor.addComponent("isShipSystem", {type: "reactor"}); + reactor.addComponent("isReactor", { + currentOutput: 60, + connectedEntities: [], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + return reactor; + }); + const powerNodes = Array.from({length: 5}).map(() => { + const powerNode = new Entity(); + powerNode.addComponent("isPowerNode", { + maxConnections: 3, + connectedSystems: [], + distributionMode: randomFromList([ + "evenly", + "leastNeed", + "mostNeed", + ]) as any, + }); + ship.components.shipSystems?.shipSystems.set(powerNode.id, {}); + ecs.addEntity(powerNode); + const nodeReactors = new Set(); + + nodeReactors.add(randomFromList(reactors)); + nodeReactors.add(randomFromList(reactors)); + nodeReactors.forEach(reactor => { + reactor.updateComponent("isReactor", { + connectedEntities: [ + ...(reactor.components.isReactor?.connectedEntities || []), + powerNode.id, + ], + }); + }); + return powerNode; + }); + + Array.from({length: 4}).map(() => { + const battery = new Entity(); + battery.addComponent("isShipSystem", {type: "battery"}); + const nodeSet = new Set(); + nodeSet.add(randomFromList(powerNodes)); + nodeSet.add(randomFromList(powerNodes)); + battery.addComponent("isBattery", { + connectedNodes: [...nodeSet.values()].map(n => n.id), + storage: 0, + }); + const reactorSet = new Set(); + reactorSet.add(randomFromList(reactors)); + reactorSet.add(randomFromList(reactors)); + reactorSet.forEach(reactor => { + reactor.updateComponent("isReactor", { + connectedEntities: [ + ...(reactor.components.isReactor?.connectedEntities || []), + battery.id, + ], + }); + }); + ship.components.shipSystems?.shipSystems.set(battery.id, {}); + ecs.addEntity(battery); + return battery; + }); + Array.from({length: 50}).map(() => { + const system = new Entity(); + system.addComponent("isShipSystem", {type: "generic"}); + system.addComponent("power", { + powerDraw: Math.random() * 100, + currentPower: 0, + }); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + ecs.addEntity(system); + + const node = randomFromList(powerNodes); + node.updateComponent("isPowerNode", { + connectedSystems: [ + ...(node.components.isPowerNode?.connectedSystems || []), + system.id, + ], + }); + return system; + }); + + const time = performance.now(); + ecs.update(16); + expect(performance.now() - time).toBeLessThan(3); + }); +}); diff --git a/server/src/systems/index.ts b/server/src/systems/index.ts index 91ad274d..4e94da49 100644 --- a/server/src/systems/index.ts +++ b/server/src/systems/index.ts @@ -22,6 +22,7 @@ import {FilterInventorySystem} from "./FilterInventorySystem"; import {ReactorHeatSystem} from "./ReactorHeatSystem"; import {HeatToCoolantSystem} from "./HeatToCoolantSystem"; import {HeatDispersionSystem} from "./HeatDispersionSystem"; +import {PowerGridSystem} from "./PowerGridSystem"; const systems = [ FilterInventorySystem, @@ -32,6 +33,7 @@ const systems = [ TimerSystem, ReactorFuelSystem, ReactorHeatSystem, + PowerGridSystem, AutoRotateSystem, AutoThrustSystem, ThrusterSystem,