diff --git a/client/app/cards/PowerGrid/Battery.tsx b/client/app/cards/PowerGrid/Battery.tsx deleted file mode 100644 index 58dc5fc8..00000000 --- a/client/app/cards/PowerGrid/Battery.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { type ReactNode, useRef } from "react"; -import useAnimationFrame from "@client/hooks/useAnimationFrame"; -import { useLiveQuery } from "@thorium/live-query/client"; -import type { AppRouter } from "@server/init/router"; -import type { inferTransformedProcedureOutput } from "@thorium/live-query/server/types"; -import { Icon } from "@thorium/ui/Icon"; - -type BatteryItem = inferTransformedProcedureOutput< - AppRouter["powerGrid"]["batteries"]["get"] ->[0]; - -export function Battery({ - capacity, - id, - storage, - chargeAmount, - dischargeAmount, - chargeRate, - dischargeRate, - children, -}: BatteryItem & { - children: ReactNode; -}) { - const percentage = storage / capacity; - - const chargeRef = useRef(null); - const chargeMeter = useRef(null); - const dischargeRef = useRef(null); - const dischargeMeter = useRef(null); - const batteryRef100 = useRef(null); - const batteryRef75 = useRef(null); - const batteryRef50 = useRef(null); - const batteryRef25 = useRef(null); - const batteryRef0 = useRef(null); - const percentageRef = useRef(null); - - const { interpolate } = useLiveQuery(); - useAnimationFrame(() => { - const batteryValues = interpolate(id); - if (!batteryValues) return; - const { x: storage, y: chargeAmount, z: dischargeAmount } = batteryValues; - - if (chargeRef.current) { - chargeRef.current.title = `Charge Rate: ${( - (chargeAmount / chargeRate) * - 100 - ).toFixed(0)}%`; - } - if (chargeMeter.current) { - chargeMeter.current.value = chargeAmount / chargeRate; - } - if (dischargeRef.current) { - dischargeRef.current.title = `Discharge Rate: ${( - (dischargeAmount / dischargeRate) * - 100 - ).toFixed(0)}%`; - } - if (dischargeMeter.current) { - dischargeMeter.current.value = dischargeAmount / dischargeRate; - } - - const percentage = storage / capacity; - batteryRef100.current?.classList.add("hidden"); - batteryRef75.current?.classList.add("hidden"); - batteryRef50.current?.classList.add("hidden"); - batteryRef25.current?.classList.add("hidden"); - batteryRef0.current?.classList.add("hidden"); - switch (true) { - case percentage > 0.95: - batteryRef100.current?.classList.remove("hidden"); - break; - case percentage > 0.6: - batteryRef75.current?.classList.remove("hidden"); - break; - case percentage > 0.4: - batteryRef50.current?.classList.remove("hidden"); - break; - case percentage > 0.1: - batteryRef25.current?.classList.remove("hidden"); - break; - default: - batteryRef0.current?.classList.remove("hidden"); - } - - if (percentageRef.current) { - percentageRef.current.innerText = `${Math.round(percentage * 100)}%`; - } - }); - return ( -
-
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
- - {Math.round(percentage * 100)}% - -
-
-
- -
-
- - {children} -
- ); -} diff --git a/client/app/cards/PowerGrid/Connector.tsx b/client/app/cards/PowerGrid/Connector.tsx deleted file mode 100644 index f1218909..00000000 --- a/client/app/cards/PowerGrid/Connector.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - forwardRef, - memo, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; - -export interface ConnectorHandle { - update: (points: { - from: { x: number; y: number }; - to: { x: number; y: number }; - visible: boolean; - }) => void; - hide: () => void; -} -export const Connector = memo( - forwardRef< - ConnectorHandle, - { from?: { x: number; y: number }; to?: { x: number; y: number } } - >(({ from, to }, ref) => { - const innerRef = useRef(null); - function calcD( - from: { x: number; y: number }, - to: { x: number; y: number }, - ) { - const { x, y } = - innerRef.current?.parentElement?.getBoundingClientRect() || { - x: 0, - y: 0, - }; - - return ( - `M ${from.x - x} ${from.y - y} ` + - `C ${from.x - x + 50} ${from.y - y}, ` + - `${to.x - x - 50} ${to.y - y}, ` + - `${to.x - x} ${to.y - y}` - ); - } - - useImperativeHandle(ref, () => { - return { - update({ from, to }) { - if (from.x === 0 && from.y === 0 && to.x === 0 && to.y === 0) { - innerRef.current?.classList.add("opacity-0"); - return; - } - innerRef.current?.setAttribute("d", calcD(from, to)); - innerRef.current?.classList.remove("opacity-0"); - }, - hide() { - innerRef.current?.classList.add("opacity-0"); - }, - }; - }); - - useEffect(() => { - if (from && to) { - innerRef.current?.classList.remove("opacity-0"); - } - }, [from, to]); - - return ( - - ); - }), -); -Connector.displayName = "Connector"; diff --git a/client/app/cards/PowerGrid/PowerNode.tsx b/client/app/cards/PowerGrid/PowerNode.tsx deleted file mode 100644 index d9264b89..00000000 --- a/client/app/cards/PowerGrid/PowerNode.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import type { AppRouter } from "@server/init/router"; -import type { inferTransformedProcedureOutput } from "@thorium/live-query/server/types"; -import { capitalCase } from "change-case"; -import { type ReactNode, Suspense, useRef, useState } from "react"; -import { - useFloating, - useInteractions, - useClick, - useDismiss, -} from "@floating-ui/react"; -import Dropdown, { DropdownItem } from "@thorium/ui/Dropdown"; -import { Menu, Portal } from "@headlessui/react"; -import { q } from "@client/context/AppContext"; -import { SystemSlider } from "./SystemSlider"; -import Button from "@thorium/ui/Button"; -import { Tooltip } from "@thorium/ui/Tooltip"; -import { usePrompt } from "@thorium/ui/AlertDialog"; -import useAnimationFrame from "@client/hooks/useAnimationFrame"; -import { useLiveQuery } from "@thorium/live-query/client"; -import { Icon } from "@thorium/ui/Icon"; - -type PowerNodeItem = inferTransformedProcedureOutput< - AppRouter["powerGrid"]["powerNodes"]["get"] ->[0]; - -export function PowerNode({ - id, - name, - systemCount, - distributionMode, - children, - setDragging, -}: PowerNodeItem & { - children: ReactNode; - setDragging: (system: { id: number; name?: string }, rect: DOMRect) => void; -}) { - const inputRef = useRef(null); - const inputMeter = useRef(null); - - const { interpolate } = useLiveQuery(); - useAnimationFrame(() => { - const entityValues = interpolate(id); - if (!entityValues) return; - const { x: powerInput, y: powerRequirement } = entityValues; - const percent = - powerRequirement === 0 - ? 1 - : Math.max(0, Math.min(1, powerInput / powerRequirement)); - - if (inputRef.current) { - inputRef.current.title = `Power Input: ${Math.round( - percent * 100, - ).toFixed(0)}%`; - } - if (inputMeter.current) { - inputMeter.current.value = percent; - } - }); - - const [isOpen, setIsOpen] = useState(false); - - const { x, y, strategy, refs, context } = useFloating({ - open: isOpen, - onOpenChange: setIsOpen, - placement: "left", - }); - - const dismiss = useDismiss(context); - - const click = useClick(context); - - const { getReferenceProps, getFloatingProps } = useInteractions([ - click, - dismiss, - ]); - - return ( - <> -
- {children} -
-
- -
-
- -
- {capitalCase(name)} - {systemCount} Systems -
-
- {isOpen && ( - - -
- { - setIsOpen(false); - setDragging(system, rect); - }} - /> -
-
-
- )} - - ); -} - -function NodeDetails({ - id, - name, - distributionMode, - setDragging, -}: { - id: number; - name: string; - distributionMode: string; - setDragging: (system: { id: number; name?: string }, rect: DOMRect) => void; -}) { - const [systems] = q.powerGrid.powerNodes.systems.useNetRequest({ - nodeId: id, - }); - const prompt = usePrompt(); - if (!systems) return null; - return ( - <> -
- - {capitalCase(name)} Power Node - -
- -
-
-
    - {systems.map((system) => ( -
  • -
    - - setDragging( - system, - event.currentTarget.getBoundingClientRect(), - ) - } - > - {system.name} - - - - -
    -
    - {system.requestedPower <= system.maxSafePower * 1.25 ? ( - { - if (typeof value === "number") { - q.powerGrid.powerNodes.setRequestedPower.netSend({ - systemId: system.id, - nodeId: id, - requestedPower: value, - }); - } - }} - /> - ) : ( -
    -
    - Power Override Engaged: {system.requestedPower}MW -
    - Current Power Draw: {system.powerDraw}MW -
    - )} -
    -
  • - ))} -
- - ); -} diff --git a/client/app/cards/PowerGrid/Reactor.tsx b/client/app/cards/PowerGrid/Reactor.tsx deleted file mode 100644 index a3b739e8..00000000 --- a/client/app/cards/PowerGrid/Reactor.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { q } from "@client/context/AppContext"; -import type { AppRouter } from "@server/init/router"; -import type { inferTransformedProcedureOutput } from "@thorium/live-query/server/types"; -import type { ReactNode } from "react"; -import { ReactorSlider } from "./ReactorSlider"; -import { Icon } from "@thorium/ui/Icon"; - -type ReactorItem = inferTransformedProcedureOutput< - AppRouter["powerGrid"]["reactors"]["get"] ->[0]; - -export function Reactor({ - id, - name, - desiredOutput, - maxOutput, - optimalOutputPercent, - nominalHeat, - maxSafeHeat, - maxHeat, - reserve, - fuel, - children, -}: ReactorItem & { children: ReactNode }) { - const currentHeat = maxSafeHeat; - - return ( -
- {children} - -
-
- - - { - if (typeof value === "number") - q.powerGrid.reactors.setDesired.netSend({ - reactorId: id, - desiredOutput: value, - }); - }} - /> - {maxOutput} MW -
-
-
-
- -
- -
- -
-
- -
- -
-
-
- -
- -
-
- ); -} diff --git a/client/app/cards/PowerGrid/ReactorSlider.tsx b/client/app/cards/PowerGrid/ReactorSlider.tsx deleted file mode 100644 index 505e6475..00000000 --- a/client/app/cards/PowerGrid/ReactorSlider.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from "react"; - -import { type SliderState, useSliderState } from "react-stately"; - -import { - mergeProps, - useFocusRing, - useNumberFormatter, - useSlider, - useSliderThumb, - VisuallyHidden, - type AriaSliderProps, - type AriaSliderThumbOptions, -} from "react-aria"; - -import type { NumberFormatOptions } from "@internationalized/number"; -import { useLiveQuery } from "@thorium/live-query/client"; -import useAnimationFrame from "@client/hooks/useAnimationFrame"; - -export function ReactorSlider( - props: AriaSliderProps & { - formatOptions?: NumberFormatOptions; - className?: string; - reactorId: number; - maxOutput: number; - }, -) { - const trackRef = React.useRef(null); - const powerBarRef = React.useRef(null); - const numberFormatter = useNumberFormatter(props.formatOptions); - const state = useSliderState({ ...props, numberFormatter }); - const { groupProps, trackProps, labelProps, outputProps } = useSlider( - props, - state, - trackRef, - ); - - const { interpolate } = useLiveQuery(); - useAnimationFrame(() => { - const power = interpolate(props.reactorId)?.x; - if (powerBarRef.current && typeof power === "number") { - let percent = (power / props.maxOutput) * 100; - if (percent < 1) percent = 0; - powerBarRef.current.style.width = `${percent}%`; - } - }); - return ( -
- {/* Create a container for the label and output element. */} - {props.label && ( -
- - {state.getThumbValueLabel(0)} -
- )} - {/* The track element holds the visible track line and the thumb. */} -
-
-
-
- -
-
- ); -} - -function Thumb( - props: { state: SliderState } & Omit, -) { - const { state, trackRef, index } = props; - const inputRef = React.useRef(null); - const { thumbProps, inputProps, isDragging } = useSliderThumb( - { - index, - trackRef, - inputRef, - }, - state, - ); - - const { focusProps, isFocusVisible } = useFocusRing(); - return ( -
- {state.isThumbDragging(0) && ( - - {state.values[0].toFixed(1)} - - )} - - - -
- ); -} diff --git a/client/app/cards/PowerGrid/SketchyConnector.tsx b/client/app/cards/PowerGrid/SketchyConnector.tsx deleted file mode 100644 index 070bb7f5..00000000 --- a/client/app/cards/PowerGrid/SketchyConnector.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { Connector, type ConnectorHandle } from "./Connector"; - -export function SketchyConnector({ - out, - in: inId, - cardLoaded, -}: { - out: number; - in: number; - cardLoaded: boolean; - revalidate: any; -}) { - const connectorRef = useRef(null); - - const handleAdjust = useCallback(() => { - const outDims = document - .querySelector(`[data-id="${out}"]`) - ?.getBoundingClientRect(); - const inDims = document - .querySelector(`[data-id="${inId}"][data-outid="${out}"]`) - ?.getBoundingClientRect(); - - if (outDims && inDims) { - connectorRef.current?.update({ - from: { - x: outDims.x + outDims.width / 2, - y: outDims.y + outDims.height / 2, - }, - to: { x: inDims.x + inDims.width / 2, y: inDims.y + inDims.height / 2 }, - visible: cardLoaded, - }); - } - }, [cardLoaded, inId, out]); - - useEffect(() => { - window.addEventListener("resize", handleAdjust); - return () => window.removeEventListener("resize", handleAdjust); - }, [handleAdjust]); - - useEffect(() => { - handleAdjust(); - }); - - return ; -} diff --git a/client/app/cards/PowerGrid/SystemSlider.tsx b/client/app/cards/PowerGrid/SystemSlider.tsx deleted file mode 100644 index f619c9de..00000000 --- a/client/app/cards/PowerGrid/SystemSlider.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React from "react"; - -import { type SliderState, useSliderState } from "react-stately"; - -import { - mergeProps, - useFocusRing, - useNumberFormatter, - useSlider, - useSliderThumb, - VisuallyHidden, - type AriaSliderProps, - type AriaSliderThumbOptions, -} from "react-aria"; - -import type { NumberFormatOptions } from "@internationalized/number"; -import { Tooltip } from "@thorium/ui/Tooltip"; - -export function SystemSlider( - props: AriaSliderProps & { - formatOptions?: NumberFormatOptions; - className?: string; - powerDraw: number; - maxOutput: number; - requiredPower: number; - defaultPower: number; - maxSafePower: number; - }, -) { - const trackRef = React.useRef(null); - const numberFormatter = useNumberFormatter(props.formatOptions); - const state = useSliderState({ ...props, numberFormatter }); - const { groupProps, trackProps, labelProps, outputProps } = useSlider( - props, - state, - trackRef, - ); - let percent = (props.powerDraw / props.maxOutput) * 100; - if (percent < 1) percent = 0; - - return ( -
- {/* Create a container for the label and output element. */} - {props.label && ( -
- -
- )} - {/* The track element holds the visible track line and the thumb. */} -
-
-
90 ? (percent - 90) * 999 : 0 - }px`, - borderBottomRightRadius: `${ - percent > 90 ? (percent - 90) * 999 : 0 - }px`, - }} - /> -
- -
- - -
- - -
-
- ); -} - -function Thumb( - props: { state: SliderState } & Omit, -) { - const { state, trackRef, index } = props; - const inputRef = React.useRef(null); - const { thumbProps, inputProps, isDragging } = useSliderThumb( - { - index, - trackRef, - inputRef, - }, - state, - ); - - const { focusProps, isFocusVisible } = useFocusRing(); - return ( -
- {state.isThumbDragging(0) && ( - - {state.values[0].toFixed(1)} - - )} - - - -
- ); -} diff --git a/client/app/cards/PowerGrid/data.ts b/client/app/cards/PowerGrid/data.ts deleted file mode 100644 index 9d580901..00000000 --- a/client/app/cards/PowerGrid/data.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { pubsub } from "@server/init/pubsub"; -import { t } from "@server/init/t"; -import type { Entity } from "@server/utils/ecs"; -import { getShipSystems } from "@server/utils/getShipSystem"; -import { getReactorInventory } from "@server/utils/getSystemInventory"; -import type { MegaWattHour } from "@server/utils/unitTypes"; -import { z } from "zod"; - -export const powerGrid = t.router({ - reactors: t.router({ - get: t.procedure - .filter((publish: { shipId: number; systemId: number }, { ctx }) => { - if (publish && publish.shipId !== ctx.ship?.id) return false; - return true; - }) - .request(({ ctx }) => { - const reactors = getShipSystems(ctx, { systemType: "reactor" }); - return reactors.map((r) => { - const inventory = getReactorInventory(r); - const fuelPower: MegaWattHour = - inventory?.reduce((prev, next) => { - return prev + (next.flags.fuel?.fuelDensity || 0) * next.count; - }, 0) || 0; - const output = r.components.isReactor!.currentOutput; - // The reserve is considered full if we can maintain the current output - // for one hour - const reserve = Math.min( - 1, - Math.max(0, fuelPower / (output || Number.EPSILON)), - ); - - return { - id: r.id, - name: r.components.identity!.name, - desiredOutput: r.components.isReactor!.desiredOutput, - maxOutput: r.components.isReactor!.maxOutput, - optimalOutputPercent: r.components.isReactor!.optimalOutputPercent, - nominalHeat: r.components.heat!.nominalHeat, - maxSafeHeat: r.components.heat!.maxSafeHeat, - maxHeat: r.components.heat!.maxHeat, - connectedTo: r.components.isReactor!.connectedEntities, - reserve, - fuel: r.components.isReactor!.unusedFuel.amount || 0, - }; - }); - }), - setDesired: t.procedure - .input(z.object({ reactorId: z.number(), desiredOutput: z.number() })) - .send(({ ctx, input }) => { - const reactor = ctx.flight?.ecs.getEntityById(input.reactorId); - if (!reactor?.components.isReactor) return 0; - reactor.updateComponent("isReactor", { - desiredOutput: Math.max( - 0, - Math.min( - input.desiredOutput, - reactor.components.isReactor.maxOutput, - ), - ), - }); - const reactorShip = ctx.flight?.ships.find((s) => - s.components.shipSystems?.shipSystems.has(input.reactorId), - ); - if (reactorShip) - pubsub.publish.powerGrid.reactors.get({ - shipId: reactorShip.id, - systemId: input.reactorId, - }); - }), - }), - batteries: t.router({ - get: t.procedure - .filter((publish: { shipId: number; systemId: number }, { ctx }) => { - if (publish && publish.shipId !== ctx.ship?.id) return false; - return true; - }) - .request(({ ctx }) => { - const batteries = getShipSystems(ctx, { systemType: "battery" }); - return batteries.map((b) => ({ - id: b.id, - capacity: b.components.isBattery!.capacity, - storage: b.components.isBattery!.storage, - connectedTo: b.components.isBattery!.connectedNodes, - chargeAmount: b.components.isBattery!.chargeAmount, - chargeRate: b.components.isBattery!.chargeRate, - dischargeAmount: b.components.isBattery!.dischargeAmount, - dischargeRate: b.components.isBattery!.dischargeRate, - })); - }), - }), - powerNodes: t.router({ - get: t.procedure - .filter((publish: { shipId: number; systemId: number }, { ctx }) => { - if (publish && publish.shipId !== ctx.ship?.id) return false; - return true; - }) - .request(({ ctx }) => { - const powerNode = getShipSystems(ctx, { systemType: "powerNode" }); - return powerNode.map((p) => ({ - id: p.id, - name: p.components.identity!.name, - distributionMode: - p.components.isPowerNode?.distributionMode || "evenly", - systemCount: p.components.isPowerNode?.connectedSystems.length, - })); - }), - systems: t.procedure - .input(z.object({ nodeId: z.number() })) - .filter((publish: { nodeId: number }, { ctx, input }) => { - if (publish && publish.nodeId !== input.nodeId) return false; - return true; - }) - .request(({ ctx, input }) => { - const powerNode = ctx.flight?.ecs.getEntityById(input.nodeId); - if (!powerNode) return []; - - return powerNode.components.isPowerNode?.connectedSystems - .map((id) => { - const system = ctx.flight?.ecs.getEntityById(id); - if (!system) return null; - return { - id, - name: system.components.identity?.name, - requiredPower: system.components.power?.requiredPower || 0, - defaultPower: system.components.power?.defaultPower || 0, - maxSafePower: system.components.power?.maxSafePower || 0, - currentPower: system.components.power?.currentPower || 0, - powerDraw: system.components.power?.powerDraw || 0, - requestedPower: system.components.power?.requestedPower || 0, - }; - }) - .filter(filterBoolean); - }), - setDistributionMode: t.procedure - .input( - z.object({ - nodeId: z.number(), - distributionMode: z.union([ - z.literal("evenly"), - z.literal("mostFirst"), - z.literal("leastFirst"), - ]), - }), - ) - .send(({ input, ctx }) => { - const powerNode = ctx.flight?.ecs.getEntityById(input.nodeId); - if (!powerNode?.components.isPowerNode) return; - powerNode.updateComponent("isPowerNode", { - distributionMode: input.distributionMode, - }); - - const powerNodeShip = ctx.flight?.ships.find((s) => - s.components.shipSystems?.shipSystems.has(input.nodeId), - ); - if (powerNodeShip) { - pubsub.publish.powerGrid.powerNodes.get({ - systemId: powerNode.id, - shipId: powerNodeShip.id, - }); - } - }), - setRequestedPower: t.procedure - .input( - z.object({ - systemId: z.number(), - nodeId: z.number(), - requestedPower: z.number(), - }), - ) - .send(({ input, ctx }) => { - const system = ctx.flight?.ecs.getEntityById(input.systemId); - if (!system?.components.power) return; - system.updateComponent("power", { - requestedPower: Math.max(0, input.requestedPower), - }); - pubsub.publish.powerGrid.powerNodes.systems({ nodeId: input.nodeId }); - }), - transferSystem: t.procedure - .input( - z.object({ - systemId: z.number(), - nodeId: z.number(), - }), - ) - .send(({ input, ctx }) => { - const powerNodeShip = ctx.flight?.ships.find((s) => - s.components.shipSystems?.shipSystems.has(input.nodeId), - ); - - if (powerNodeShip) { - const originalPowerNode = Array.from( - powerNodeShip.components.shipSystems?.shipSystems.keys() || [], - ).reduce((prev: null | Entity, id) => { - if (prev) return prev; - const system = ctx.flight?.ecs.getEntityById(id); - if ( - system?.components.isPowerNode?.connectedSystems.includes( - input.systemId, - ) - ) - return system; - return null; - }, null); - - originalPowerNode?.updateComponent("isPowerNode", { - connectedSystems: - originalPowerNode.components.isPowerNode?.connectedSystems.filter( - (sys) => sys !== input.systemId, - ) || [], - }); - - const newPowerNode = ctx.flight?.ecs.getEntityById(input.nodeId); - newPowerNode?.updateComponent("isPowerNode", { - connectedSystems: [ - ...(newPowerNode.components.isPowerNode?.connectedSystems || []), - input.systemId, - ], - }); - - pubsub.publish.powerGrid.powerNodes.get({ - systemId: input.nodeId, - shipId: powerNodeShip.id, - }); - } - pubsub.publish.powerGrid.powerNodes.systems({ nodeId: input.nodeId }); - }), - }), - stream: t.procedure.dataStream(({ ctx, entity }) => { - if (!entity) return false; - return Boolean( - (entity.components.isReactor || - entity.components.isBattery || - entity.components.isPowerNode) && - ctx.ship?.components.shipSystems?.shipSystems.has(entity.id), - ); - }), - connectNodes: t.procedure - .input(z.object({ out: z.number(), in: z.number() })) - .send(({ ctx, input }) => { - const entity = ctx.flight?.ecs.getEntityById(input.out); - const entityShip = ctx.flight?.ships.find((s) => - s.components.shipSystems?.shipSystems.has(entity?.id || -1), - ); - if (entity?.components.isReactor) { - if (entity.components.isReactor.connectedEntities.includes(input.in)) - return; - entity.components.isReactor.connectedEntities.push(input.in); - - if (entityShip) - pubsub.publish.powerGrid.reactors.get({ - shipId: entityShip.id, - systemId: entity.id, - }); - } - if (entity?.components.isBattery) { - if (entity.components.isBattery.connectedNodes.includes(input.in)) - return; - entity.components.isBattery.connectedNodes.push(input.in); - - if (entityShip) - pubsub.publish.powerGrid.batteries.get({ - shipId: entityShip.id, - systemId: entity.id, - }); - } - }), - disconnectNodes: t.procedure - .input(z.object({ out: z.number(), in: z.number() })) - .send(({ ctx, input }) => { - const entity = ctx.flight?.ecs.getEntityById(input.out); - const entityShip = ctx.flight?.ships.find((s) => - s.components.shipSystems?.shipSystems.has(entity?.id || -1), - ); - if (entity?.components.isReactor) { - entity.components.isReactor.connectedEntities = - entity.components.isReactor.connectedEntities.filter( - (id) => id !== input.in, - ); - if (entityShip) - pubsub.publish.powerGrid.reactors.get({ - shipId: entityShip.id, - systemId: entity.id, - }); - } - - if (entity?.components.isBattery) { - entity.components.isBattery.connectedNodes = - entity.components.isBattery.connectedNodes.filter( - (id) => id !== input.in, - ); - - if (entityShip) - pubsub.publish.powerGrid.batteries.get({ - shipId: entityShip.id, - systemId: entity.id, - }); - } - }), -}); - -function filterBoolean(val: T | null | undefined): val is T { - return Boolean(val); -} diff --git a/client/app/cards/PowerGrid/index.tsx b/client/app/cards/PowerGrid/index.tsx deleted file mode 100644 index 3712855f..00000000 --- a/client/app/cards/PowerGrid/index.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import type { CardProps } from "@client/routes/flight.station/CardProps"; -import { q } from "@client/context/AppContext"; -import { type PointerEventHandler, useMemo, useRef, useState } from "react"; -import { Connector, type ConnectorHandle } from "./Connector"; -import { PowerNode } from "./PowerNode"; -import { Reactor } from "./Reactor"; -import { Battery } from "./Battery"; -import { SketchyConnector } from "./SketchyConnector"; -import clsx from "clsx"; -import { Portal } from "@headlessui/react"; - -function ConnectionPoint({ - handleDrag, - side, - id, - outId, -}: { - side: "out" | "in"; - id: number; - outId?: number; - handleDrag: PointerEventHandler; -}) { - return ( -
-
-
- ); -} - -export function PowerGrid({ cardLoaded }: CardProps) { - // Refetch every second, that's all that we really need. - const [reactors] = q.powerGrid.reactors.get.useNetRequest(undefined, { - // refetchInterval: 1000, - }); - const [batteries] = q.powerGrid.batteries.get.useNetRequest(); - const [powerNodes] = q.powerGrid.powerNodes.get.useNetRequest(); - - q.powerGrid.stream.useDataStream(); - - const draggingRef = useRef(null); - - const { powerToBatteries, powerToNodes } = useMemo(() => { - const powerToBatteries: Record = {}; - const powerToNodes: Record = {}; - - reactors.forEach((reactor) => { - reactor.connectedTo.forEach((id) => { - if (batteries.some((b) => b.id === id)) { - if (!powerToBatteries[id]) powerToBatteries[id] = []; - powerToBatteries[id].push(reactor.id); - } - if (powerNodes.some((b) => b.id === id)) { - if (!powerToNodes[id]) powerToNodes[id] = []; - powerToNodes[id].push(reactor.id); - } - }); - }); - batteries.forEach((battery) => { - battery.connectedTo.forEach((id) => { - if (powerNodes.some((b) => b.id === id)) { - if (!powerToNodes[id]) powerToNodes[id] = []; - powerToNodes[id].push(battery.id); - } - }); - }); - return { powerToBatteries, powerToNodes }; - }, [batteries, powerNodes, reactors]); - - const handleDrag: PointerEventHandler = (event) => { - document.body.classList.add("drag-active"); - document.addEventListener("pointerup", handleDragStop); - document.addEventListener("pointermove", handleDragging); - - const rect = event.currentTarget.getBoundingClientRect(); - let side = event.currentTarget.dataset.side; - let id = Number(event.currentTarget.dataset.id); - const outId = Number(event.currentTarget.dataset.outid); - const existingConnection = - side === "in" && - (powerToBatteries[id]?.includes(outId) || - powerToNodes[id]?.includes(outId)); - - let point1 = { - x: rect.x + rect.width / 2, - y: rect.y + rect.height / 2, - }; - - const point2 = { - x: event.clientX, - y: event.clientY, - }; - - if (existingConnection) { - const outDims = document - .querySelector(`[data-id="${outId}"]`) - ?.getBoundingClientRect(); - if (outDims) { - point1 = { - x: outDims.x + outDims.width / 2, - y: outDims.y + outDims.height / 2, - }; - } - side = "out"; - q.powerGrid.disconnectNodes.netSend({ in: id, out: outId }); - } - - draggingRef.current?.update({ - from: side === "out" ? point1 : point2, - to: side === "out" ? point2 : point1, - visible: true, - }); - - function handleDragging(event: PointerEvent) { - const point2 = { - x: event.clientX, - y: event.clientY, - }; - draggingRef.current?.update({ - from: side === "out" ? point1 : point2, - to: side === "out" ? point2 : point1, - visible: true, - }); - } - - function handleDragStop(stopEvent: PointerEvent) { - document.removeEventListener("pointerup", handleDragStop); - document.removeEventListener("pointermove", handleDragging); - draggingRef.current?.hide(); - document.body.classList.remove("drag-active"); - - if ( - stopEvent.target instanceof HTMLDivElement && - stopEvent.target.dataset.id && - event.target instanceof HTMLDivElement - ) { - const connectSide = stopEvent.target.dataset.side; - const connectId = Number(stopEvent.target.dataset.id); - if (existingConnection) { - id = outId; - } - if (!connectSide || !side || Number.isNaN(connectId + id)) return; - const connection = { - [connectSide]: connectId, - [side]: id, - } as { in: number; out: number }; - q.powerGrid.connectNodes.netSend(connection); - } - } - }; - - const [draggingSystem, setDraggingSystem] = useState<{ - id: number; - name?: string; - } | null>(null); - const draggingSystemRef = useRef(null); - - const cardArea = document - .querySelector(".card-area") - ?.getBoundingClientRect(); - - function setDragging(system: { id: number; name?: string }, rect: DOMRect) { - setDraggingSystem(system); - let x = rect.left - 9; - let y = rect.top - 9; - if (draggingSystemRef.current) { - draggingSystemRef.current.style.left = `${x}px`; - draggingSystemRef.current.style.top = `${y}px`; - } - const powerNodes = Array.from(document.querySelectorAll("[data-nodeid]")); - - function handleDrag(event: globalThis.PointerEvent) { - document.body.classList.add("drag-active"); - - x += event.movementX; - y += event.movementY; - powerNodes.forEach((node) => node.classList.remove("!brightness-150")); - const containingNode = powerNodes.find( - (el) => - event.target && - (el === event.target || el.contains(event.target as any)), - ); - if (containingNode) { - containingNode.classList.add("!brightness-150"); - } - if (draggingSystemRef.current) { - draggingSystemRef.current.style.left = `${x}px`; - draggingSystemRef.current.style.top = `${y}px`; - } - } - - document.addEventListener("pointermove", handleDrag); - - document.addEventListener( - "pointerup", - (event) => { - const containingNode = powerNodes.find( - (el) => - event.target && - (el === event.target || el.contains(event.target as any)), - ); - if ( - containingNode && - containingNode instanceof HTMLDivElement && - !Number.isNaN(Number(containingNode.dataset.nodeid)) - ) { - q.powerGrid.powerNodes.transferSystem.netSend({ - nodeId: Number(containingNode.dataset.nodeid), - systemId: system.id, - }); - } - document.body.classList.remove("drag-active"); - powerNodes.forEach((node) => node.classList.remove("!brightness-150")); - - document.removeEventListener("pointermove", handleDrag); - setDraggingSystem(null); - }, - { once: true }, - ); - } - - return ( - <> -
- -
- {draggingSystem?.name} -
-
- - Connections - - {Object.entries(powerToBatteries).map(([inId, outIds]) => - outIds.map((outId) => ( - - )), - )} - {Object.entries(powerToNodes).map(([inId, outIds]) => - outIds.map((outId) => ( - - )), - )} - -
- {reactors.map((reactor) => ( - -
- -
-
- ))} -
-
- {batteries.map((battery) => ( - -
- -
-
- - {powerToBatteries[battery.id]?.map((id, i) => ( - - ))} -
-
- ))} -
-
- {powerNodes.map((powerNode) => ( - -
- - {powerToNodes[powerNode.id]?.map((id, i) => ( - - ))} -
-
- ))} -
-
- - ); -} diff --git a/client/app/cards/SystemsMonitor/data.ts b/client/app/cards/SystemsMonitor/data.ts new file mode 100644 index 00000000..66c22ad8 --- /dev/null +++ b/client/app/cards/SystemsMonitor/data.ts @@ -0,0 +1,103 @@ +import { pubsub } from "@server/init/pubsub"; +import { t } from "@server/init/t"; +import type { Entity } from "@server/utils/ecs"; +import { getShipSystems } from "@server/utils/getShipSystem"; +import { getReactorInventory } from "@server/utils/getSystemInventory"; +import type { MegaWattHour } from "@server/utils/unitTypes"; +import { z } from "zod"; + +export const systemsMonitor = t.router({ + reactors: t.router({ + get: t.procedure + .filter((publish: { shipId: number; systemId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + const reactors = getShipSystems(ctx, { systemType: "reactor" }); + return reactors.map((r) => { + const inventory = getReactorInventory(r); + const fuelPower: MegaWattHour = + inventory?.reduce((prev, next) => { + return prev + (next.flags.fuel?.fuelDensity || 0) * next.count; + }, 0) || 0; + const output = r.components.isReactor!.currentOutput; + // The reserve is considered full if we can maintain the current output + // for one hour + const reserve = Math.min( + 1, + Math.max(0, fuelPower / (output || Number.EPSILON)), + ); + + return { + id: r.id, + name: r.components.identity!.name, + desiredOutput: r.components.isReactor!.outputAssignment.length, + maxOutput: r.components.isReactor!.maxOutput, + optimalOutputPercent: r.components.isReactor!.optimalOutputPercent, + nominalHeat: r.components.heat!.nominalHeat, + maxSafeHeat: r.components.heat!.maxSafeHeat, + maxHeat: r.components.heat!.maxHeat, + reserve, + fuel: r.components.isReactor!.unusedFuel.amount || 0, + }; + }); + }), + }), + batteries: t.router({ + get: t.procedure + .filter((publish: { shipId: number; systemId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + const batteries = getShipSystems(ctx, { systemType: "battery" }); + return batteries.map((b) => ({ + id: b.id, + name: b.components.identity!.name, + capacity: b.components.isBattery!.capacity, + storage: b.components.isBattery!.storage, + chargeAmount: b.components.isBattery!.chargeAmount, + chargeRate: b.components.isBattery!.chargeRate, + outputAmount: b.components.isBattery!.outputAmount, + outputRate: b.components.isBattery!.outputRate, + })); + }), + }), + + systems: t.router({ + get: t.procedure + .filter((publish: { shipId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + const systems = []; + for (const systemId of ctx.ship?.components.shipSystems?.shipSystems.keys() || + []) { + const system = ctx.flight?.ecs.getEntityById(systemId); + if (!system) continue; + systems.push({ + id: systemId, + name: system.components.identity!.name, + requestedPower: system.components.power?.requestedPower || 0, + maxSafePower: system.components.power?.maxSafePower || 0, + requiredPower: system.components.power?.requiredPower || 0, + efficiency: system.components.efficiency!.efficiency || 1, + heat: system.components.heat?.heat || 0, + maxHeat: system.components.heat?.maxHeat || 0, + maxSafeHeat: system.components.heat?.maxSafeHeat || 0, + nominalHeat: system.components.heat?.nominalHeat || 0, + }); + } + + return systems; + }), + }), + stream: t.procedure.dataStream(({ ctx, entity }) => { + if (!entity) return false; + return Boolean( + ctx.ship?.components.shipSystems?.shipSystems.has(entity.id), + ); + }), +}); diff --git a/client/app/cards/SystemsMonitor/index.tsx b/client/app/cards/SystemsMonitor/index.tsx new file mode 100644 index 00000000..013498ee --- /dev/null +++ b/client/app/cards/SystemsMonitor/index.tsx @@ -0,0 +1,604 @@ +import { q } from "@client/context/AppContext"; +import useAnimationFrame from "@client/hooks/useAnimationFrame"; +import type { CardProps } from "@client/routes/flight.station/CardProps"; +import { cn } from "@client/utils/cn"; +import { useLiveQuery } from "@thorium/live-query/client"; +import Button from "@thorium/ui/Button"; +import { Icon } from "@thorium/ui/Icon"; +import RadialDial from "@thorium/ui/RadialDial"; +import { Tooltip } from "@thorium/ui/Tooltip"; +import { + type Dispatch, + forwardRef, + type SetStateAction, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { Fragment } from "react/jsx-runtime"; +import { createRNG } from "@thorium/rng"; + +/** + * TODO: + * - Reactor boxes should turn yellow based on the amount of power being used by batteries or systems + * - Which means there needs to be more data indicating where power is allocated + * + */ +export function SystemsMonitor({ cardLoaded }: CardProps) { + const [reactors] = q.systemsMonitor.reactors.get.useNetRequest(); + const [batteries] = q.systemsMonitor.batteries.get.useNetRequest(); + const [systems] = q.systemsMonitor.systems.get.useNetRequest(); + + q.systemsMonitor.stream.useDataStream(); + + const [selectedPowerSupplier, setSelectedPowerSupplier] = useState< + number | null + >(null); + const [selectedSystem, setSelectedSystem] = useState(null); + return ( +
+
+ {reactors.map((reactor, i) => ( + + ))} +
+
+ {batteries.map((battery, i) => ( + + ))} +
+
+ {systems.map((system) => ( + + ))} +
+
+ ); +} + +function Reactor({ + name, + id, + index, + selectedPowerSupplier, + setSelectedPowerSupplier, + nominalHeat, + maxSafeHeat, + maxHeat, + fuel, + reserve, + desiredOutput, + maxOutput, + optimalOutputPercent, + cardLoaded, +}: { + name: string; + id: number; + index: number; + selectedPowerSupplier: number | null; + setSelectedPowerSupplier: Dispatch>; + nominalHeat: number; + maxSafeHeat: number; + maxHeat: number; + fuel: number; + reserve: number; + desiredOutput: number; + maxOutput: number; + optimalOutputPercent: number; + cardLoaded: boolean; +}) { + const heatRef = useRef(null); + const heatProgressRef = useRef<{ setValue: (value: number) => void }>(null); + + const elementRefs = useRef>(new Map()); + + const { interpolate } = useLiveQuery(); + useAnimationFrame(() => { + const reactor = interpolate(id); + if (!reactor) return; + const currentOutput = reactor.x; + + for (const [i, el] of elementRefs.current) { + if (i + 1 <= Math.ceil(currentOutput)) { + el.classList.add("border-yellow-400"); + el.classList.remove("border-gray-400"); + } else { + el.classList.add("border-gray-400"); + el.classList.remove("border-yellow-400"); + } + } + + const heat = reactor.z; + const heatValue = (heat - nominalHeat) / (maxHeat - nominalHeat); + if (heatRef.current) { + heatRef.current.innerText = `Heat: ${Math.round(heat)}K`; + } + if (heatProgressRef.current) { + heatProgressRef.current.setValue(heatValue); + } + }, cardLoaded); + + return ( +
setSelectedPowerSupplier(id)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setSelectedPowerSupplier(id); + } + }} + key={id} + aria-expanded={selectedPowerSupplier === id} + className={cn( + "cursor-pointer text-left relative w-full grid grid-cols-[auto_1fr] items-center gap-x-2 p-2 panel panel-primary overflow-hidden group", + { + "brightness-150": selectedPowerSupplier === id, + }, + )} + > +
+ + {name} {index + 1} + +
+ + + + + + + + + + + + + + + +
+ +
+
+ {Array.from({ + length: desiredOutput, + }).map((_, i) => ( + +
el && elementRefs.current.set(i, el)} + className={cn( + "w-3 h-3 mr-1 last-of-type:mr-0 border-2 bg-gray-500", + { + "mr-0": i + 1 === maxOutput * optimalOutputPercent, + }, + )} + /> + {i + 1 === maxOutput * optimalOutputPercent && ( + +
+ + )} + + ))} +
+
+
+ ); +} + +function Battery({ + id, + name, + index, + setSelectedPowerSupplier, + selectedPowerSupplier, + chargeRate, + outputRate, + capacity, + cardLoaded, +}: { + id: number; + setSelectedPowerSupplier: Dispatch>; + selectedPowerSupplier: number | null; + name: string; + index: number; + chargeRate: number; + outputRate: number; + capacity: number; + cardLoaded: boolean; +}) { + const chargeElementRefs = useRef>(new Map()); + const storageRef = useRef(null); + const storageProgressRef = useRef<{ setValue: (value: number) => void }>( + null, + ); + const outputRef = useRef(null); + const outputProgressRef = useRef<{ setValue: (value: number) => void }>(null); + const batteryIconRef = useRef<{ setPercentage: (value: number) => void }>( + null, + ); + const { interpolate } = useLiveQuery(); + useAnimationFrame(() => { + const system = interpolate(id); + if (!system) return; + const storage = system.x; + const chargeAmount = system.y; + const dischargeAmount = system.z; + for (const [i, el] of chargeElementRefs.current) { + if (i + 1 <= Math.ceil(chargeAmount)) { + el.classList.add("bg-yellow-400", "border-yellow-400"); + el.classList.remove("border-gray-500", "bg-gray-500"); + } else { + el.classList.add("border-gray-500", "bg-gray-500"); + el.classList.remove("bg-yellow-400", "border-yellow-400"); + } + } + if (storageRef.current) { + storageRef.current.innerText = `Storage: ${( + (storage / capacity) * + 100 + ).toFixed(0)}% (${storage.toFixed(2)}MWh)`; + } + if (storageProgressRef.current) { + storageProgressRef.current.setValue(storage / capacity); + } + if (batteryIconRef.current) { + batteryIconRef.current.setPercentage(storage / capacity); + } + if (outputRef.current) { + outputRef.current.innerText = `Output: ${dischargeAmount.toFixed(2)}MW`; + } + if (outputProgressRef.current) { + outputProgressRef.current.setValue(dischargeAmount / outputRate); + } + }, cardLoaded); + + return ( +
setSelectedPowerSupplier(id)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setSelectedPowerSupplier(id); + } + }} + className={cn( + "relative w-full flex flex-col items-start justify-start py-2 px-4 panel panel-warning col-start-2", + { + "brightness-150": selectedPowerSupplier === id, + }, + )} + > +
+ + {name} {index + 1} + +
+ + + + + + + + + + + +
+
+

Power Input

+
+ + + +
+ {Array.from({ + length: Math.floor(chargeRate), + }).map((_, i) => ( +
el && chargeElementRefs.current.set(i, el)} + className="w-3 h-3 mr-1 last-of-type:mr-0 border-2" + /> + ))} +
+ + + +
+
+
+ ); +} + +function System({ + id, + name, + requestedPower, + maxSafePower, + requiredPower, + efficiency, + heat, + maxSafeHeat, + maxHeat, + nominalHeat, + cardLoaded, +}: { + id: number; + name: string; + requestedPower: number; + maxSafePower: number; + requiredPower: number; + efficiency?: number; + heat?: number; + maxSafeHeat?: number; + maxHeat?: number; + nominalHeat?: number; + selectedSystem: number | null; + setSelectedSystem: Dispatch>; + cardLoaded: boolean; +}) { + const elementRefs = useRef>(new Map()); + const heatRef = useRef(null); + const heatProgressRef = useRef<{ setValue: (value: number) => void }>(null); + + const { interpolate } = useLiveQuery(); + useAnimationFrame(() => { + const system = interpolate(id); + if (!system) return; + const currentPower = system.y; + const heat = system.z; + for (const [i, el] of elementRefs.current) { + if (i + 1 <= Math.ceil(currentPower)) { + el.classList.add("bg-yellow-400", "border-yellow-400"); + el.classList.remove("border-gray-500", "bg-gray-500"); + } else { + el.classList.add("border-gray-500", "bg-gray-500"); + el.classList.remove("bg-yellow-400", "border-yellow-400"); + } + } + if (heatRef.current) { + heatRef.current.innerText = `Heat: ${Math.round(heat)}K`; + } + if (heatProgressRef.current && nominalHeat && maxHeat) { + heatProgressRef.current.setValue( + (heat - nominalHeat) / (maxHeat - nominalHeat), + ); + } + }, cardLoaded); + + return ( +
+
+ {name} +
+ {typeof heat === "number" && + typeof nominalHeat === "number" && + maxHeat ? ( + + + + + + ) : null} + {typeof efficiency === "number" ? ( + + + + + + ) : null} +
+ +
+
+ + + +
+ {Array.from({ + length: Math.max(requestedPower, requiredPower), + }).map((_, i) => ( + + {/* Display a warning indicator if we're past the max safe power */} + {i + 1 === maxSafePower + 1 && ( + +
+ + )} +
el && elementRefs.current.set(i, el)} + className={cn("w-3 h-3 mr-1 last-of-type:mr-0 border-2", { + "mr-0": i + 1 === requiredPower || i + 1 === maxSafePower, + })} + /> + + {i + 1 === requiredPower && ( + +
+ + )} + + ))} +
+ + + +
+
+
+ ); +} + +const BatteryIcon = forwardRef< + { setPercentage: (value: number) => void }, + { percentage: number } +>(({ percentage }, ref) => { + const batteryRef100 = useRef(null); + const batteryRef75 = useRef(null); + const batteryRef50 = useRef(null); + const batteryRef25 = useRef(null); + const batteryRef0 = useRef(null); + + useImperativeHandle( + ref, + () => ({ + setPercentage: (percentage: number) => { + batteryRef100.current?.classList.add("hidden"); + batteryRef75.current?.classList.add("hidden"); + batteryRef50.current?.classList.add("hidden"); + batteryRef25.current?.classList.add("hidden"); + batteryRef0.current?.classList.add("hidden"); + switch (true) { + case percentage > 0.95: + batteryRef100.current?.classList.remove("hidden"); + break; + case percentage > 0.6: + batteryRef75.current?.classList.remove("hidden"); + break; + case percentage > 0.4: + batteryRef50.current?.classList.remove("hidden"); + break; + case percentage > 0.1: + batteryRef25.current?.classList.remove("hidden"); + break; + default: + batteryRef0.current?.classList.remove("hidden"); + } + }, + }), + [], + ); + + return ( + <> +
+ +
+
= 0.95 || percentage < 0.6 ? "hidden" : ""} + > + +
+
= 0.6 || percentage < 0.4 ? "hidden" : ""} + > + +
+
= 0.4 || percentage < 0.1 ? "hidden" : ""} + > + +
+
= 0.1 ? "hidden" : ""}> + +
+ + ); +}); + +BatteryIcon.displayName = "BatteryIcon"; diff --git a/client/app/cards/data.ts b/client/app/cards/data.ts index e0220bfe..f564695d 100644 --- a/client/app/cards/data.ts +++ b/client/app/cards/data.ts @@ -4,7 +4,7 @@ export { navigation, waypoints } from "./Navigation/data"; export { cargoControl } from "./CargoControl/data"; export { viewscreen } from "./Viewscreen/data"; export { alertLevel } from "./AlertLevel/data"; -export { powerGrid } from "./PowerGrid/data"; export { remoteAccess } from "./RemoteAccess/data"; export { targeting } from "./Targeting/data"; export { objectives } from "./Objectives/data"; +export { systemsMonitor } from "./SystemsMonitor/data"; diff --git a/client/app/cards/index.ts b/client/app/cards/index.ts index 6488dc0b..9e11924d 100644 --- a/client/app/cards/index.ts +++ b/client/app/cards/index.ts @@ -6,7 +6,7 @@ export * from "./Navigation"; export * from "./CargoControl"; export * from "./Viewscreen"; export * from "./AlertLevel"; -export * from "./PowerGrid"; export * from "./RemoteAccess"; export * from "./Targeting"; export * from "./Objectives"; +export * from "./SystemsMonitor"; diff --git a/client/app/components/ui/RadialDial.tsx b/client/app/components/ui/RadialDial.tsx index bcabf835..a667d0fb 100644 --- a/client/app/components/ui/RadialDial.tsx +++ b/client/app/components/ui/RadialDial.tsx @@ -1,22 +1,100 @@ -const RadialDial: React.FC<{ - label: string; - count: number; - max?: number; - color?: string; -}> = ({ label, count, max = 100, color = "#fff000" }) => { - return ( -
-
-
{label}
-
- ); -}; +import { forwardRef, useImperativeHandle, useRef } from "react"; + +const RadialDial = forwardRef< + { setValue: (value: number) => void }, + { + label: string; + count: number; + max?: number; + marker?: number; + color?: string; + backgroundColor?: string; + children?: React.ReactNode; + } +>( + ( + { + label, + count, + max = 100, + marker, + color = "#fff000", + backgroundColor, + children, + }, + ref, + ) => { + const divRef = useRef(null); + useImperativeHandle( + ref, + () => { + return { + setValue(value: number) { + if (!divRef.current) return; + const endAngle = value / max; + divRef.current.style.setProperty( + "--end-angle", + `${endAngle * 100}%`, + ); + + divRef.current.style.setProperty( + "background", + `conic-gradient( + var(--radial-color, #fff000) 0%, + ${ + marker && marker < endAngle + ? `var(--radial-color, #fff000) ${(marker / max) * 100 - 1}%, yellow ${ + (marker / max) * 100 + }%, yellow ${ + (marker / max) * 100 + 1 + }%, var(--radial-color, #fff000) ${(marker / max) * 100 + 2}%,` + : "" + } + var(--radial-color, #fff000) var(--end-angle, 0%), + var(--radial-background) var(--end-angle, 0%), + ${ + marker && marker > endAngle + ? `var(--radial-background) ${(marker / max) * 100 - 1}%, yellow ${ + (marker / max) * 100 + }%, yellow ${(marker / max) * 100 + 1}%,var(--radial-background) ${ + (marker / max) * 100 + 2 + }%,` + : "" + } + var(--radial-background) 100% + )`, + ); + }, + }; + }, + [max, marker], + ); + + return ( +
+
+
{children || Math.round(count)}
+
+
{label}
+
+ ); + }, +); + +RadialDial.displayName = "RadialDial"; export default RadialDial; diff --git a/client/app/components/ui/Tooltip.tsx b/client/app/components/ui/Tooltip.tsx index 02f87a60..02c89044 100644 --- a/client/app/components/ui/Tooltip.tsx +++ b/client/app/components/ui/Tooltip.tsx @@ -1,3 +1,4 @@ +import { cn } from "@client/utils/cn"; import { flip, offset, @@ -9,53 +10,71 @@ import { type Placement, } from "@floating-ui/react"; import { Portal } from "@headlessui/react"; -import { type ReactNode, useState } from "react"; +import { forwardRef, type ReactNode, useState } from "react"; -export function Tooltip({ - content, - children, - placement = "top", - ...props -}: { - content: ReactNode; - children: ReactNode; - placement?: Placement; - className?: string; -}) { - const [open, setOpen] = useState(false); +export const Tooltip = forwardRef< + HTMLDivElement, + { + content: ReactNode; + children: ReactNode; + placement?: Placement; + className?: string; + tooltipClassName?: string; + } +>( + ( + { content, children, placement = "top", tooltipClassName, ...props }, + ref, + ) => { + const [open, setOpen] = useState(false); - const { x, y, refs, strategy, context } = useFloating({ - placement, - middleware: [offset(), flip(), shift()], - open, - onOpenChange: setOpen, - }); + const { x, y, refs, strategy, context } = useFloating({ + placement, + middleware: [offset(), flip(), shift()], + open, + onOpenChange: setOpen, + }); - const { getReferenceProps, getFloatingProps } = useInteractions([ - useHover(context), - useRole(context, { role: "tooltip" }), - ]); - return ( - <> -
- {children} -
- {open && ( - -
- {content} -
-
- )} - - ); -} + const { getReferenceProps, getFloatingProps } = useInteractions([ + useHover(context), + useRole(context, { role: "tooltip" }), + ]); + return ( + <> +
+ {children} +
+ {open && ( + +
{ + refs.setFloating(el); + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + ref.current = el; + } + } + }} + style={{ + position: strategy, + top: y ?? 0, + left: x ?? 0, + }} + className={cn( + "text-white border-white/50 border bg-black/90 py-1 px-2 rounded drop-shadow-xl z-50", + tooltipClassName, + )} + {...getFloatingProps()} + > + {content} +
+
+ )} + + ); + }, +); + +Tooltip.displayName = "Tooltip"; diff --git a/client/app/components/ui/icons/name.d.ts b/client/app/components/ui/icons/name.d.ts index 7f7bfc1c..b951fbb8 100644 --- a/client/app/components/ui/icons/name.d.ts +++ b/client/app/components/ui/icons/name.d.ts @@ -46,6 +46,8 @@ | "package-open" | "pencil" | "picture-in-picture" + | "plug-zap" + | "plug" | "plus" | "power-node" | "reactor" diff --git a/client/app/components/ui/icons/sprite.svg b/client/app/components/ui/icons/sprite.svg index 624d6b60..0bc75f69 100644 --- a/client/app/components/ui/icons/sprite.svg +++ b/client/app/components/ui/icons/sprite.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/client/app/icons/plug-zap.svg b/client/app/icons/plug-zap.svg new file mode 100644 index 00000000..d0692eb2 --- /dev/null +++ b/client/app/icons/plug-zap.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/client/app/icons/plug.svg b/client/app/icons/plug.svg new file mode 100644 index 00000000..3a097868 --- /dev/null +++ b/client/app/icons/plug.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/client/app/styles/theme.css b/client/app/styles/theme.css index eee50596..006e28af 100644 --- a/client/app/styles/theme.css +++ b/client/app/styles/theme.css @@ -657,31 +657,33 @@ kbd { } .radial-dial { @apply flex flex-col items-center; + --radial-radius: 25px; + --radial-border-width: 4px; + --radial-background: transparent; } .radial-indicator { position: relative; background: conic-gradient( var(--radial-color, #fff000) 0%, var(--radial-color, #fff000) var(--end-angle, 0%), - transparent var(--end-angle, 0%), - transparent 100% + var(--radial-background) var(--end-angle, 0%), + var(--radial-background) 100% ); - width: 50px; - height: 50px; + width: var(--radial-radius); + height: var(--radial-radius); border-radius: 50%; display: flex; justify-content: center; align-items: center; } -.radial-indicator:before { - content: attr(data-value) " "; +.radial-indicator .radial-inner { display: flex; justify-content: center; align-items: center; - width: 30px; - height: 30px; - left: 20%; - top: 20%; + width: calc(var(--radial-radius) - var(--radial-border-width)); + height: calc(var(--radial-radius) - var(--radial-border-width)); + left: calc(var(--radial-border-width) / 2); + top: calc(var(--radial-border-width) / 2); position: absolute; background-color: #333; border-radius: 50%; diff --git a/server/src/components/shipSystems/powerGrid/index.ts b/server/src/components/shipSystems/powerGrid/index.ts index 9051a8b4..835f6fa7 100644 --- a/server/src/components/shipSystems/powerGrid/index.ts +++ b/server/src/components/shipSystems/powerGrid/index.ts @@ -1,3 +1,2 @@ export * from "./isBattery"; -export * from "./isPowerNode"; export * from "./isReactor"; diff --git a/server/src/components/shipSystems/powerGrid/isBattery.ts b/server/src/components/shipSystems/powerGrid/isBattery.ts index 4b669751..afac0d0e 100644 --- a/server/src/components/shipSystems/powerGrid/isBattery.ts +++ b/server/src/components/shipSystems/powerGrid/isBattery.ts @@ -3,42 +3,45 @@ import z from "zod"; export const isBattery = z .object({ /** - * The power nodes that are associated with this battery - */ - connectedNodes: z.array(z.number()).default([]), - /** - * The amount of power this battery can hold. This provides + * The amount of power this battery can hold in megawatthours. Based + * on the other defaults, this value provides * 23 minutes of sustained power. */ - capacity: z.number().default(46), + capacity: z.number().default(2), /** * How much power the battery is currently storing */ - storage: z.number().default(46), + storage: z.number().default(2), /** - * How much energy the battery can use to charge. Typically - * batteries charge faster than they discharge, while capacitors - * discharge much faster than they charge. + * How much energy the battery can use to charge. Measured in Megawatts. Typically + * batteries charge faster, while capacitors discharge much faster. + * Both should discharge slower than they charge. */ - chargeRate: z.number().default(180), + chargeRate: z.number().default(4), /** * How much energy is being added to the battery, calculated every frame. * Used for displaying on the power grid card. */ chargeAmount: z.number().default(0), /** - * How much energy the battery provides to connected systems. + * How much energy the battery can provide to connected systems. */ - dischargeRate: z.number().default(120), + outputRate: z.number().default(6), /** * How much energy is being drained from the battery, calculated every frame. - * Used for displaying on the power grid card. + * Used for displaying on the power grid card. This will always be less than + * or equal to the length of outputAssignment. */ - dischargeAmount: z.number().default(0), + outputAmount: z.number().default(0), /** * Capacitors only discharge when toggled on. This is where that * toggling happens. Normal batteries won't ever adjust this. */ discharging: z.boolean().default(true), + /** + * Which system the units of power are allocated. + * Each item represents 1 MW of power. + */ + outputAssignment: z.array(z.number()).default([]), }) .default({}); diff --git a/server/src/components/shipSystems/powerGrid/isPowerNode.ts b/server/src/components/shipSystems/powerGrid/isPowerNode.ts deleted file mode 100644 index 5dfa2450..00000000 --- a/server/src/components/shipSystems/powerGrid/isPowerNode.ts +++ /dev/null @@ -1,31 +0,0 @@ -import z from "zod"; - -export const isPowerNode = z - .object({ - /** - * The systems that are associated with this power node - */ - connectedSystems: z.array(z.number()).default([]), - /** - * The number of incoming connections which this power node supports - */ - maxConnections: z.number().default(3), - /** - * How the power is distributed through the connected systems: - * - Evenly (fill up systems evenly until the system is full) - * - 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: z - .enum(["evenly", "leastFirst", "mostFirst"]) - .default("evenly"), - /** - * How much power is being put into the power node, updated every frame - */ - powerInput: z.number().default(0), - /** - * How much power the power node needs, updated every frame - */ - powerRequirement: z.number().default(0), - }) - .default({}); diff --git a/server/src/components/shipSystems/powerGrid/isReactor.ts b/server/src/components/shipSystems/powerGrid/isReactor.ts index 3cf2e5c3..65438434 100644 --- a/server/src/components/shipSystems/powerGrid/isReactor.ts +++ b/server/src/components/shipSystems/powerGrid/isReactor.ts @@ -2,32 +2,29 @@ import z from "zod"; export const isReactor = z .object({ - /** - * The power nodes and batteries that are associated with this reactor - */ - connectedEntities: z.array(z.number()).default([]), /** * This will be set when the ship is spawned * based on the total power required * to run all systems divided by the number of * reactors in the ship */ - maxOutput: z.number().default(120), + maxOutput: z.number().default(12), /** * What percent of the max output provides a 100% fuel-to-energy conversion. * Any higher output than this decreases overall efficiency, * any lower increases overall efficiency, making fuel last longer. */ optimalOutputPercent: z.number().default(0.7), - /** - * The desired output specified by the crew member; - */ - desiredOutput: z.number().default(84), /** * What the reactor is currently outputting, updated by the power ECS System. * It will always be less than or equal to the desired output, never more. */ - currentOutput: z.number().default(84), + currentOutput: z.number().default(8), + /** + * Which system the units of power are allocated. + * Each item represents 1 MW of power. The length of this is the desired output. + */ + outputAssignment: z.array(z.number()).default([]), /** * How much fuel is left to burn after the previous tick. Fuel is only removed * from inventory in whole units. Any fuel not turned into power remains in the diff --git a/server/src/init/dataStreamEntity.ts b/server/src/init/dataStreamEntity.ts index 4fc87307..9b36a8b3 100644 --- a/server/src/init/dataStreamEntity.ts +++ b/server/src/init/dataStreamEntity.ts @@ -15,7 +15,7 @@ export function dataStreamEntity(e: Entity) { return { id: e.id.toString(), x: e.components.isReactor.currentOutput, - y: e.components.heat?.heat || 0, + z: e.components.heat?.heat || 0, }; } if (e.components.isBattery) { @@ -23,14 +23,7 @@ export function dataStreamEntity(e: Entity) { id: e.id.toString(), x: e.components.isBattery.storage, y: e.components.isBattery.chargeAmount, - z: e.components.isBattery.dischargeAmount, - }; - } - if (e.components.isPowerNode) { - return { - id: e.id.toString(), - x: e.components.isPowerNode.powerInput, - y: e.components.isPowerNode.powerRequirement, + z: e.components.isBattery.outputAmount, }; } if (e.components.isImpulseEngines) { @@ -48,6 +41,8 @@ export function dataStreamEntity(e: Entity) { return { id: e.id.toString(), x: targetSpeed, + y: e.components.power?.currentPower, + z: e.components.heat?.heat || 0, }; } if (e.components.isWarpEngines) { @@ -55,6 +50,15 @@ export function dataStreamEntity(e: Entity) { return { id: e.id.toString(), x: maxVelocity, + y: e.components.power?.currentPower, + z: e.components.heat?.heat || 0, + }; + } + if (e.components.power) { + return { + id: e.id.toString(), + y: e.components.power.currentPower, + z: e.components.heat?.heat || 0, }; } if (e.components.isTorpedo) { diff --git a/server/src/inputs/__test__/flight.test.ts b/server/src/inputs/__test__/flight.test.ts index 7ccf2ee4..93d08b14 100644 --- a/server/src/inputs/__test__/flight.test.ts +++ b/server/src/inputs/__test__/flight.test.ts @@ -149,13 +149,13 @@ describe("flight input", () => { if (!mockDataContext.flight) throw new Error("No flight created"); expect(flight.playerShips[0].components.position).toMatchInlineSnapshot(` - { - "parentId": 19, - "type": "solar", - "x": -228630890, - "y": 0, - "z": 12500.000000028002, - } - `); + { + "parentId": 9, + "type": "solar", + "x": -228630890, + "y": 0, + "z": 12500.000000028002, + } + `); }); }); diff --git a/server/src/spawners/ship.test.ts b/server/src/spawners/ship.test.ts index 7f52f93e..de5b47d1 100644 --- a/server/src/spawners/ship.test.ts +++ b/server/src/spawners/ship.test.ts @@ -60,12 +60,12 @@ describe("Ship Spawner", () => { expect(ship.components.position?.x).toEqual(10); expect(ship.components.position?.y).toEqual(20); expect(ship.components.position?.z).toEqual(30); - expect(extraEntities.length).toEqual(10); - expect(extraEntities[5].components.identity?.name).toEqual( + expect(extraEntities.length).toEqual(5); + expect(extraEntities[0].components.identity?.name).toEqual( "Generic System", ); - expect(extraEntities[5].components.isShipSystem?.type).toEqual("generic"); - expect(extraEntities[6].components.cargoContainer?.volume).toEqual(4000); - expect(extraEntities[7].components.cargoContainer?.volume).toEqual(4000); + expect(extraEntities[0].components.isShipSystem?.type).toEqual("generic"); + expect(extraEntities[1].components.cargoContainer?.volume).toEqual(4000); + expect(extraEntities[2].components.cargoContainer?.volume).toEqual(4000); }); }); diff --git a/server/src/spawners/ship.ts b/server/src/spawners/ship.ts index a118b3eb..ea5d6da8 100644 --- a/server/src/spawners/ship.ts +++ b/server/src/spawners/ship.ts @@ -81,22 +81,6 @@ export function spawnShip( const systemEntities: Entity[] = []; - // First we'll create some power nodes - const powerNodes: Record = {}; - if (params.playerShip) { - template.powerNodes?.forEach((name) => { - const node = new Entity(); - node.addComponent("identity", { name }); - node.addComponent("isPowerNode", { - maxConnections: 3, - connectedSystems: [], - distributionMode: "evenly", - }); - powerNodes[name] = { entity: node, count: 0 }; - systemEntities.push(node); - }); - } - template.shipSystems?.forEach((system) => { const systemPlugin = getSystem( dataContext, @@ -126,26 +110,9 @@ export function spawnShip( break; } default: { + // TODO: Set up power from reactors and batteries const entity = spawnShipSystem(shipId, systemPlugin, system.overrides); systemEntities.push(entity); - if (params.playerShip && entity.components.power) { - // Hook up the power node - const leastPowerNode = Object.entries(powerNodes).reduce( - (prev, next) => { - if (next[1].count < prev.count) return next[1]; - return prev; - }, - powerNodes[Object.keys(powerNodes)[0]], - ); - const powerNode = - powerNodes[systemPlugin.powerNode || ""] || leastPowerNode; - powerNode.count += 1; - powerNode.entity.components.isPowerNode?.connectedSystems.push( - entity.id, - ); - } else { - entity.removeComponent("power"); - } break; } } @@ -187,54 +154,12 @@ export function spawnShip( sys.updateComponent("isReactor", { maxOutput, currentOutput: maxOutput * systemPlugin.optimalOutputPercent, - desiredOutput: maxOutput * systemPlugin.optimalOutputPercent, optimalOutputPercent: systemPlugin.optimalOutputPercent, }); systemEntities.push(sys); }); } }); - - // And connect up the power nodes for good measure - // Every battery gets one Reactor - const batteries = systemEntities.filter((e) => e.components.isBattery); - const reactors = systemEntities.filter((e) => e.components.isReactor); - let reactorIndex = 0; - // Connect batteries to power nodes in this order - const powerNodeOrder = [ - "internal", - "intel", - "defense", - "navigation", - "offense", - ]; - batteries.forEach((battery, i) => { - reactorIndex = i % reactors.length; - const reactor = reactors[reactorIndex]; - reactor.updateComponent("isReactor", { - connectedEntities: [ - ...(reactor.components.isReactor?.connectedEntities || []), - battery.id, - ], - }); - const powerNode = powerNodes[powerNodeOrder[i % powerNodeOrder.length]]; - battery.updateComponent("isBattery", { - connectedNodes: [ - ...(battery.components.isBattery?.connectedNodes || []), - powerNode.entity.id, - ], - }); - }); - // Make sure every power node is connected to at least one reactor - Object.values(powerNodes).forEach((node, i) => { - const reactor = reactors[(reactorIndex + i) % reactors.length]; - reactor?.updateComponent("isReactor", { - connectedEntities: [ - ...(reactor.components.isReactor?.connectedEntities || []), - node.entity.id, - ], - }); - }); } systemEntities.forEach((e) => { diff --git a/server/src/systems/PowerDistributionSystem.ts b/server/src/systems/PowerDistributionSystem.ts new file mode 100644 index 00000000..9a6a5a6c --- /dev/null +++ b/server/src/systems/PowerDistributionSystem.ts @@ -0,0 +1,146 @@ +import { reactor } from "@client/data/plugins/systems/reactor"; +import { type Entity, System } from "@server/utils/ecs"; + +export class PowerDistributionSystem extends System { + test(entity: Entity) { + return !!entity.components.isShip; + } + update(entity: Entity, elapsed: number) { + const elapsedTimeHours = elapsed / 1000 / 60 / 60; + + const poweredSystems: Entity[] = []; + const reactors: Entity[] = []; + const batteries: Entity[] = []; + + const systemIds = entity.components.shipSystems?.shipSystems.keys() || []; + + for (const 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.isShipSystem && sys.components.power) + poweredSystems.push(sys); + } + + // Reset all of the battery metrics + batteries.forEach((battery) => { + battery.updateComponent("isBattery", { + chargeAmount: 0, + outputAmount: 0, + }); + }); + + // Key is systemId, value is array of reactor/battery IDs + const reactorPowerAssignment = new Map(); + const batteryPowerAssignment = new Map(); + + // Pass power from reactors to batteries and systems + for (const reactor of reactors) { + // Each assignment is one unit of power applied to that system or battery + for (const systemId of reactor.components.isReactor?.outputAssignment || + []) { + reactorPowerAssignment.set(systemId, [ + ...(reactorPowerAssignment.get(systemId) || []), + reactor.id, + ]); + } + } + + // Key is reactor/battery ID, value is power supplied + const reactorPowerSupplied = new Map(); + const batteryPowerSupplied = new Map(); + + // Apply power from reactors, then do the same thing with batteries + for (const battery of batteries) { + // Charge the battery from the Reactors + const storage = battery.components.isBattery?.storage || 0; + const capacity = battery.components.isBattery?.capacity || 0; + const chargeRate = + battery.components.isBattery?.chargeRate || Number.POSITIVE_INFINITY; + const batteryPowerSupply = reactorPowerAssignment.get(battery.id) || []; + + let suppliedPower = 0; + for (let i = 0; i < chargeRate; i++) { + if (batteryPowerSupply.length === 0) break; + const reactorId = batteryPowerSupply.pop(); + if (!reactorId) break; + reactorPowerSupplied.set( + reactorId, + (reactorPowerSupplied.get(reactorId) || 0) + 1, + ); + suppliedPower += 1; + } + + const chargeAmount = storage === capacity ? 0 : suppliedPower; + battery.updateComponent("isBattery", { + chargeAmount, + storage: Math.min(storage + chargeAmount * elapsedTimeHours, capacity), + }); + + // Output battery power to systems + if (battery.components.isBattery?.discharging) { + // How many points of power + let maxOutput = battery.components.isBattery.storage / elapsedTimeHours; + // Each assignment is one unit of power applied to that system or battery + for (const systemId of battery.components.isBattery?.outputAssignment || + []) { + if (maxOutput <= 0) { + break; + } + batteryPowerAssignment.set(systemId, [ + ...(batteryPowerAssignment.get(systemId) || []), + battery.id, + ]); + maxOutput -= 1; + } + } + } + + // Apply power to systems, first from reactors, then from batteries + for (const system of poweredSystems) { + const powerDraw = system.components.power?.powerDraw || 0; + let suppliedPower = 0; + for (let i = 0; i < powerDraw; i++) { + const reactorId = reactorPowerAssignment.get(system.id)?.pop(); + if (reactorId) { + reactorPowerSupplied.set( + reactorId, + (reactorPowerSupplied.get(reactorId) || 0) + 1, + ); + suppliedPower += 1; + continue; + } + const batteryId = batteryPowerAssignment.get(system.id)?.pop(); + if (batteryId) { + const battery = this.ecs.getEntityById(batteryId); + batteryPowerSupplied.set( + batteryId, + (batteryPowerSupplied.get(batteryId) || 0) + 1, + ); + suppliedPower += 1; + continue; + } + break; + } + system.updateComponent("power", { currentPower: suppliedPower }); + } + + // Update the reactor and battery components with the power supplied + for (const reactor of reactors) { + reactor.updateComponent("isReactor", { + currentOutput: reactorPowerSupplied.get(reactor.id) || 0, + }); + } + for (const battery of batteries) { + const storage = battery.components.isBattery?.storage || 0; + battery.updateComponent("isBattery", { + outputAmount: batteryPowerSupplied.get(battery.id) || 0, + storage: Math.max( + 0, + storage - + (batteryPowerSupplied.get(battery.id) || 0) * elapsedTimeHours, + ), + }); + } + } +} diff --git a/server/src/systems/PowerGridSystem.ts b/server/src/systems/PowerGridSystem.ts deleted file mode 100644 index 72c1f497..00000000 --- a/server/src/systems/PowerGridSystem.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { type 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; - const poweredSystems: Entity[] = []; - const reactors: Entity[] = []; - const batteries: Entity[] = []; - const powerNodes: Entity[] = []; - - const systemIds = entity.components.shipSystems?.shipSystems.keys() || []; - for (const 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); - } - - // Reset all of the battery and power node metrics - batteries.forEach((battery) => { - battery.updateComponent("isBattery", { - chargeAmount: 0, - dischargeAmount: 0, - }); - }); - powerNodes.forEach((node) => { - node.updateComponent("isPowerNode", { - powerRequirement: 0, - powerInput: 0, - }); - }); - - // First, figure out how much power each power node is requesting - for (const node of powerNodes) { - let nodePower = 0; - for (const systemId of node.components.isPowerNode?.connectedSystems || - []) { - const system = poweredSystems.find((s) => s.id === systemId); - nodePower += system?.components.power?.powerDraw || 0; - } - node.updateComponent("isPowerNode", { powerRequirement: 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( - powerNodes.map((e) => [e.id, 0]), - ); - const batterySuppliedPower = new Map( - batteries.map((e) => [e.id, 0]), - ); - for (const 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, - (powerNode.components.isPowerNode?.powerRequirement || 0) - - (nodeSuppliedPower.get(id) || 0), - ), - }; - }) - .filter(Boolean) as { id: number; requestedPower: number }[]; - - reactorNodes.sort((a, b) => { - return a.requestedPower - b.requestedPower; - }); - while (totalPower > 0) { - const 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) { - const 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 || Number.POSITIVE_INFINITY; - const chargeAmount = storage === capacity ? 0 : Math.min(value, limit); - battery?.updateComponent("isBattery", { - storage: Math.min(capacity, storage + chargeAmount * elapsedTimeHours), - chargeAmount, - }); - }); - // 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 < (node?.components.isPowerNode?.powerRequirement || 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 = - (node?.components.isPowerNode?.powerRequirement || 0) - value; - - connectedBatteries.forEach((battery) => { - const limit = - battery.components.isBattery?.dischargeRate || - Number.POSITIVE_INFINITY; - const storage = battery.components.isBattery?.storage || 0; - const dischargeAmount = Math.min(limit, excessDemand); - if (storage > dischargeAmount * elapsedTimeHours) { - battery.updateComponent("isBattery", { - storage: Math.max( - 0, - storage - dischargeAmount * elapsedTimeHours, - ), - dischargeAmount, - }); - excessDemand = 0; - value = node?.components.isPowerNode?.powerRequirement || 0; - } else { - excessDemand -= storage / elapsedTimeHours; - value += storage / elapsedTimeHours; - battery.updateComponent("isBattery", { - storage: 0, - dischargeAmount: 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) - ); - } - return ( - (a.components.power?.powerDraw || 0) - - (b.components.power?.powerDraw || 0) - ); - }); - - node?.updateComponent("isPowerNode", { powerInput: value }); - if (distributionMode === "evenly") { - connectedSystems.forEach((entity) => { - entity.updateComponent("power", { currentPower: 0 }); - }); - - while (value > 0) { - const 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/ReactorFuelSystem.ts b/server/src/systems/ReactorFuelSystem.ts index 0b25b85d..a71054aa 100644 --- a/server/src/systems/ReactorFuelSystem.ts +++ b/server/src/systems/ReactorFuelSystem.ts @@ -20,11 +20,10 @@ export class ReactorFuelSystem extends System { return; } - const { - desiredOutput: powerNeeded, - optimalOutputPercent, - maxOutput, - } = entity.components.isReactor; + const { outputAssignment, optimalOutputPercent, maxOutput } = + entity.components.isReactor; + + const powerNeeded = outputAssignment.length; const optimalOutput = maxOutput * optimalOutputPercent; const outputBonus = powerNeeded / optimalOutput; @@ -43,7 +42,7 @@ export class ReactorFuelSystem extends System { Math.abs(energyNeeded - energyProvided) / entity.components.isReactor.unusedFuel.density; entity.components.isReactor.currentOutput = - entity.components.isReactor.desiredOutput; + entity.components.isReactor.outputAssignment.length; return; } entity.components.isReactor.unusedFuel.amount = 0; @@ -86,13 +85,14 @@ export class ReactorFuelSystem extends System { Math.abs(energyNeeded - energyProvided) / entity.components.isReactor.unusedFuel.density; entity.components.isReactor.currentOutput = - entity.components.isReactor.desiredOutput; + entity.components.isReactor.outputAssignment.length; return; } } // Figure out the current power output based on how much power has been provided const powerProvided: MegaWatt = energyProvided / elapsedTimeHours / outputBonus; + entity.components.isReactor.currentOutput = powerProvided; } } diff --git a/server/src/systems/__test__/HeatDispersionSystem.test.ts b/server/src/systems/__test__/HeatDispersionSystem.test.ts index b0b3f5c2..bff097fc 100644 --- a/server/src/systems/__test__/HeatDispersionSystem.test.ts +++ b/server/src/systems/__test__/HeatDispersionSystem.test.ts @@ -39,8 +39,8 @@ describe("HeatDispersionSystem", () => { type: "reactor", }); reactor.addComponent("isReactor", { - currentOutput: 120, - desiredOutput: 120, + currentOutput: 6, + outputAssignment: [1, 2, 3, 4, 5, 6], maxOutput: 180, optimalOutputPercent: 0.7, }); diff --git a/server/src/systems/__test__/HeatToCoolantSystem.test.ts b/server/src/systems/__test__/HeatToCoolantSystem.test.ts index d27f5738..928a25d5 100644 --- a/server/src/systems/__test__/HeatToCoolantSystem.test.ts +++ b/server/src/systems/__test__/HeatToCoolantSystem.test.ts @@ -39,9 +39,9 @@ describe("HeatToCoolantSystem", () => { type: "reactor", }); reactor.addComponent("isReactor", { - currentOutput: 120, - desiredOutput: 120, - maxOutput: 180, + currentOutput: 6, + outputAssignment: [1, 1, 1, 1, 1, 1], + maxOutput: 8, optimalOutputPercent: 0.7, }); reactor.addComponent("heat", { diff --git a/server/src/systems/__test__/PowerDistributionSystem.test.ts b/server/src/systems/__test__/PowerDistributionSystem.test.ts new file mode 100644 index 00000000..5304781d --- /dev/null +++ b/server/src/systems/__test__/PowerDistributionSystem.test.ts @@ -0,0 +1,322 @@ +import { PowerDistributionSystem } from "@server/systems/PowerDistributionSystem"; +import { createMockDataContext } from "@server/utils/createMockDataContext"; +import { ECS, Entity } from "@server/utils/ecs"; +import { randomFromList } from "@server/utils/randomFromList"; + +describe("PowerDistributionSystem", () => { + let ecs: ECS; + let ship: Entity; + beforeEach(() => { + const mockDataContext = createMockDataContext(); + + ecs = new ECS(mockDataContext.server); + ecs.addSystem(new PowerDistributionSystem()); + 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: 4, currentPower: 0 }); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + const sysId = system.id; + ecs.addEntity(system); + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", { type: "reactor" }); + reactor.addComponent("isReactor", { + currentOutput: 6, + outputAssignment: [sysId, sysId, sysId, sysId], + }); + 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(4); + + reactor.updateComponent("isReactor", { + outputAssignment: [sysId, sysId, sysId], + }); + ecs.update(16); + expect(system.components.power?.currentPower).toEqual(3); + expect(reactor.components.isReactor?.currentOutput).toEqual(3); + + system.updateComponent("power", { powerDraw: 2 }); + ecs.update(16); + expect(system.components.power?.currentPower).toEqual(2); + expect(reactor.components.isReactor?.currentOutput).toEqual(2); + }); + + 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: 3, 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: 3, currentPower: 0 }); + ship.components.shipSystems?.shipSystems.set(system2.id, {}); + ecs.addEntity(system2); + + const sys1 = system1.id; + const sys2 = system2.id; + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", { type: "reactor" }); + reactor.addComponent("isReactor", { + currentOutput: 6, + outputAssignment: [sys1, sys1, sys1, sys2, sys2, sys2], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(3); + expect(system2.components.power?.currentPower).toEqual(3); + + system1.updateComponent("power", { powerDraw: 1 }); + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(1); + expect(system2.components.power?.currentPower).toEqual(3); + }); + it("should work with multiple reactors connected to multiple systems", () => { + const system1 = new Entity(); + system1.addComponent("isShipSystem", { type: "generic" }); + system1.addComponent("power", { powerDraw: 4, 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: 4, 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: 4, 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: 4, currentPower: 0 }); + ship.components.shipSystems?.shipSystems.set(system4.id, {}); + ecs.addEntity(system4); + const sys1 = system1.id; + const sys2 = system2.id; + const sys3 = system3.id; + const sys4 = system4.id; + + const reactor = new Entity(); + reactor.addComponent("isShipSystem", { type: "reactor" }); + reactor.addComponent("isReactor", { + currentOutput: 12, + outputAssignment: [sys1, sys1, sys2, sys2, sys3, sys3, sys4, sys4], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(2); + expect(system2.components.power?.currentPower).toEqual(2); + expect(system3.components.power?.currentPower).toEqual(2); + expect(system4.components.power?.currentPower).toEqual(2); + + const reactor2 = new Entity(); + reactor2.addComponent("isShipSystem", { type: "reactor" }); + reactor2.addComponent("isReactor", { + currentOutput: 2, + outputAssignment: [sys3, sys4], + }); + ship.components.shipSystems?.shipSystems.set(reactor2.id, {}); + ecs.addEntity(reactor2); + + ecs.update(16); + expect(system1.components.power?.currentPower).toEqual(2); + expect(system2.components.power?.currentPower).toEqual(2); + expect(system3.components.power?.currentPower).toEqual(3); + expect(system4.components.power?.currentPower).toEqual(3); + }); + it("should properly charge and discharge batteries", () => { + const system = new Entity(); + system.addComponent("isShipSystem", { type: "generic" }); + system.addComponent("power", { powerDraw: 20, currentPower: 0 }); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + ecs.addEntity(system); + + const battery = new Entity(); + battery.addComponent("isShipSystem", { type: "battery" }); + battery.addComponent("isBattery", { + outputAssignment: [], + 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: 3, + outputAssignment: [battery.id], + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + + expect(battery.components.isBattery?.storage).toEqual(0); + for (let i = 0; i < 60; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toMatchInlineSnapshot( + "0.0002666666666666669", + ); + battery.updateComponent("isBattery", { + outputAssignment: [system.id, system.id], + }); + for (let i = 0; i < 30; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toMatchInlineSnapshot( + "0.0001333333333333334", + ); + + reactor.updateComponent("isReactor", { + currentOutput: 12, + outputAssignment: Array.from({ + length: battery.components.isBattery!.chargeRate, + }).map(() => battery.id), + }); + battery.updateComponent("isBattery", { storage: 0 }); + ecs.update(16); + const storage = battery.components.isBattery?.storage; + expect(storage).toBeGreaterThan(0); + reactor.updateComponent("isReactor", { currentOutput: 20 }); + battery.updateComponent("isBattery", { storage: 0 }); + ecs.update(16); + expect(storage).toEqual(battery.components.isBattery?.storage); + + battery.updateComponent("isBattery", { storage: 0, outputAssignment: [] }); + reactor.updateComponent("isReactor", { + outputAssignment: Array.from({ + length: battery.components.isBattery!.chargeRate, + }).map(() => battery.id), + }); + ecs.update(16); + expect(battery.components.isBattery?.chargeAmount).toEqual(4); + + // It should take about 40 minutes to fully charge a battery at this rate. + for (let i = 0; i < 60 * 60 * 40; i++) { + ecs.update(16); + } + expect(battery.components.isBattery?.storage).toEqual( + battery.components.isBattery?.capacity, + ); + + // It should take about 21 minutes to fully discharge a battery at this rate. + reactor.updateComponent("isReactor", { outputAssignment: [] }); + expect(battery.components.isBattery?.storage).toEqual(2); + battery.updateComponent("isBattery", { + outputAssignment: Array.from({ + length: battery.components.isBattery!.outputRate, + }).map(() => system.id), + }); + ecs.update(16); + expect(battery.components.isBattery?.storage).toBeLessThan(2); + expect(battery.components.isBattery?.outputAmount).toEqual(6); + expect(system.components.power?.currentPower).toEqual(6); + for (let i = 0; i < 60 * 60 * 21; i++) { + ecs.update(16); + } + // expect(battery.components.isBattery?.outputAmount).toEqual(0); + // expect(system.components.power?.currentPower).toEqual(0); + expect(battery.components.isBattery?.storage).toEqual(0); + }); + + 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: 10, + }); + ship.components.shipSystems?.shipSystems.set(reactor.id, {}); + ecs.addEntity(reactor); + return reactor; + }); + + const batteries = Array.from({ length: 4 }).map(() => { + const battery = new Entity(); + battery.addComponent("isShipSystem", { type: "battery" }); + battery.addComponent("isBattery", { + storage: 0, + }); + Array.from({ + length: battery.components.isBattery?.outputRate || 0, + }).forEach(() => { + const reactor = randomFromList(reactors); + if ( + reactor.components.isReactor && + reactor.components.isReactor?.outputAssignment.length < + reactor.components.isReactor?.maxOutput + ) { + reactor.updateComponent("isReactor", { + outputAssignment: [ + ...reactor.components.isReactor!.outputAssignment, + battery.id, + ], + }); + } + }); + ship.components.shipSystems?.shipSystems.set(battery.id, {}); + ecs.addEntity(battery); + return battery; + }); + + const reactorsAndBatteries = [...reactors, ...batteries]; + Array.from({ length: 50 }).map(() => { + const system = new Entity(); + system.addComponent("isShipSystem", { type: "generic" }); + system.addComponent("power", { + powerDraw: Math.ceil(Math.random() * 6), + currentPower: 0, + }); + ship.components.shipSystems?.shipSystems.set(system.id, {}); + ecs.addEntity(system); + for (let i = 0; i < (system.components.power?.powerDraw || 0); i++) { + const powerSource = randomFromList(reactorsAndBatteries); + if ( + powerSource.components.isReactor && + powerSource.components.isReactor?.outputAssignment.length < + powerSource.components.isReactor?.maxOutput + ) { + powerSource.updateComponent("isReactor", { + outputAssignment: [ + ...powerSource.components.isReactor!.outputAssignment, + system.id, + ], + }); + } + if ( + powerSource.components.isBattery && + powerSource.components.isBattery?.outputAssignment.length < + powerSource.components.isBattery?.outputRate + ) { + powerSource.updateComponent("isBattery", { + outputAssignment: [ + ...powerSource.components.isBattery!.outputAssignment, + system.id, + ], + }); + } + } + + return system; + }); + + const time = performance.now(); + ecs.update(16); + expect(performance.now() - time).toBeLessThan(1); + }); +}); diff --git a/server/src/systems/__test__/PowerGridSystem.test.ts b/server/src/systems/__test__/PowerGridSystem.test.ts deleted file mode 100644 index 82bd1e0c..00000000 --- a/server/src/systems/__test__/PowerGridSystem.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -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: [], - 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`, - ); - battery.updateComponent("isBattery", { connectedNodes: [powerNode.id] }); - ecs.update(16); - expect(battery.components.isBattery?.storage).toMatchInlineSnapshot( - `0.00004444444444444442`, - ); - - 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.0005777777777777779`); - expect(storage).toEqual(battery.components.isBattery?.storage); - - // It should take about 23 minutes to fully charge a battery at this rate. - for (let i = 0; i < 60 * 60 * 23; i++) { - ecs.update(16); - } - expect(battery.components.isBattery?.storage).toMatchInlineSnapshot( - `45.99977777777778`, - ); - - 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", - "leastFirst", - "mostFirst", - ]) 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/__test__/ReactorFuelSystem.test.ts b/server/src/systems/__test__/ReactorFuelSystem.test.ts index 6ac91656..5ac9d180 100644 --- a/server/src/systems/__test__/ReactorFuelSystem.test.ts +++ b/server/src/systems/__test__/ReactorFuelSystem.test.ts @@ -40,9 +40,9 @@ describe("ReactorFuelSystem", () => { type: "reactor", }); reactor.addComponent("isReactor", { - currentOutput: 120, - desiredOutput: 120, - maxOutput: 180, + currentOutput: 6, + outputAssignment: [1, 1, 1, 1, 1, 1], + maxOutput: 8, optimalOutputPercent: 0.7, }); ship = new Entity(); @@ -102,26 +102,26 @@ describe("ReactorFuelSystem", () => { ship.components.shipMap.deckNodes[0].contents.Deuterium.count = 0; } const reactorComponent = reactor.components.isReactor; - expect(reactorComponent.currentOutput).toMatchInlineSnapshot(`120`); + expect(reactorComponent.currentOutput).toMatchInlineSnapshot('6'); expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot(`0.33`); for (let i = 0; i < 60; i++) { ecs.update(16); } - expect(reactorComponent.currentOutput).toMatchInlineSnapshot(`120`); + expect(reactorComponent.currentOutput).toMatchInlineSnapshot('6'); expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot( - `0.29952380952380925`, + '0.328285714285714', ); for (let i = 0; i < 60 * 9 + 50; i++) { ecs.update(16); } expect(reactorComponent.currentOutput).toMatchInlineSnapshot( - `82.4999999994225`, + '6', ); - expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot(`0`); + expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot('0.31142857142856833'); ecs.update(16); - expect(reactorComponent.currentOutput).toMatchInlineSnapshot(`0`); - expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot(`0`); + expect(reactorComponent.currentOutput).toMatchInlineSnapshot('6'); + expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot('0.3113999999999969'); }); it("should consume extra fuel when the desired power is above the optimal level", () => { if (!reactor.components.isReactor) throw new Error("not reactor"); @@ -131,8 +131,11 @@ describe("ReactorFuelSystem", () => { density: 1, }; const reactorComponent = reactor.components.isReactor; - reactorComponent.desiredOutput = - reactorComponent.maxOutput * reactorComponent.optimalOutputPercent; + reactorComponent.outputAssignment = Array.from({ + length: Math.ceil( + reactorComponent.maxOutput * reactorComponent.optimalOutputPercent, + ), + }).map(() => 1); for (let i = 0; i < 60; i++) { ecs.update(16); @@ -140,7 +143,9 @@ describe("ReactorFuelSystem", () => { const fuel1 = reactorComponent.unusedFuel.amount; const fuelDiff1 = startingFuel - fuel1; - reactorComponent.desiredOutput = reactorComponent.maxOutput; + reactorComponent.outputAssignment = Array.from({ + length: Math.ceil(reactorComponent.maxOutput), + }); for (let i = 0; i < 60; i++) { ecs.update(16); @@ -150,8 +155,13 @@ describe("ReactorFuelSystem", () => { expect(fuelDiff1).toBeLessThan(fuelDiff2); - reactorComponent.desiredOutput = - reactorComponent.maxOutput * reactorComponent.optimalOutputPercent * 0.5; + reactorComponent.outputAssignment = Array.from({ + length: Math.ceil( + reactorComponent.maxOutput * + reactorComponent.optimalOutputPercent * + 0.5, + ), + }); for (let i = 0; i < 60; i++) { ecs.update(16); @@ -168,7 +178,7 @@ describe("ReactorFuelSystem", () => { }; const reactorComponent = reactor.components.isReactor; - expect(reactorComponent.currentOutput).toMatchInlineSnapshot(`120`); + expect(reactorComponent.currentOutput).toMatchInlineSnapshot('6'); expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot(`0.01`); expect( ship.components.shipMap?.deckNodes[0].contents.Deuterium.count, @@ -177,12 +187,12 @@ describe("ReactorFuelSystem", () => { for (let i = 0; i < 60; i++) { ecs.update(16); } - expect(reactorComponent.currentOutput).toMatchInlineSnapshot(`120`); + expect(reactorComponent.currentOutput).toMatchInlineSnapshot('6'); expect(reactorComponent.unusedFuel.amount).toMatchInlineSnapshot( - `0.9985436785436778`, + '0.008285714285714311', ); expect( ship.components.shipMap?.deckNodes[0].contents.Deuterium.count, - ).toMatchInlineSnapshot(`99`); + ).toMatchInlineSnapshot('100'); }); }); diff --git a/server/src/systems/__test__/ReactorHeatSystem.test.ts b/server/src/systems/__test__/ReactorHeatSystem.test.ts index 51b559e8..fdf8e6f6 100644 --- a/server/src/systems/__test__/ReactorHeatSystem.test.ts +++ b/server/src/systems/__test__/ReactorHeatSystem.test.ts @@ -42,9 +42,9 @@ describe("ReactorHeatSystem", () => { type: "reactor", }); reactor.addComponent("isReactor", { - currentOutput: 120, - desiredOutput: 120, - maxOutput: 180, + currentOutput: 6, + outputAssignment: [1, 1, 1, 1, 1, 1], + maxOutput: 8, optimalOutputPercent: 0.7, }); reactor.addComponent("heat", { @@ -98,13 +98,13 @@ describe("ReactorHeatSystem", () => { for (let i = 0; i < 60; i++) { ecs.update(16); } - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.2425263157909`); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.0121263157894'); // One minute for (let i = 0; i < 60 * 60; i++) { ecs.update(16); } - expect(heatComponent?.heat).toMatchInlineSnapshot(`314.79410526324386`); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.7397052631518'); }); it("should transfer some of the heat into the coolant", () => { const heatToCoolantSystem = new HeatToCoolantSystem(); @@ -125,15 +125,15 @@ describe("ReactorHeatSystem", () => { for (let i = 0; i < 60; i++) { ecs.update(16); } - expect(water?.temperature).toMatchInlineSnapshot(`300.0013139087228`); - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.24137284223843`); + expect(water?.temperature).toMatchInlineSnapshot('300.00006569543626'); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.0120686421116'); // One minute for (let i = 0; i < 60 * 60; i++) { ecs.update(16); } - expect(water?.temperature).toMatchInlineSnapshot(`303.3444012519671`); - expect(heatComponent?.heat).toMatchInlineSnapshot(`311.858073006254`); + expect(water?.temperature).toMatchInlineSnapshot('300.16722006259494'); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.5929036503006'); }); it("should disperse some of the coolant's heat into space", () => { const heatToCoolantSystem = new HeatToCoolantSystem(); @@ -158,15 +158,15 @@ describe("ReactorHeatSystem", () => { for (let i = 0; i < 60; i++) { ecs.update(16); } - expect(water?.temperature).toMatchInlineSnapshot(`299.96976663913847`); - expect(heatComponent?.heat).toMatchInlineSnapshot(`300.2412269271415`); + expect(water?.temperature).toMatchInlineSnapshot('299.96851865283526'); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.0119227275404'); // One minute for (let i = 0; i < 60 * 60; i++) { ecs.update(16); } - expect(water?.temperature).toMatchInlineSnapshot(`301.8342184058807`); - expect(heatComponent?.heat).toMatchInlineSnapshot(`311.4734007014108`); + expect(water?.temperature).toMatchInlineSnapshot('298.689037122951'); + expect(heatComponent?.heat).toMatchInlineSnapshot('300.21266879320314'); // Test turning off the reactor if (reactor.components.isReactor) { reactor.components.isReactor.currentOutput = 0; @@ -174,7 +174,7 @@ describe("ReactorHeatSystem", () => { for (let i = 0; i < 60 * 60; i++) { ecs.update(16); } - expect(water?.temperature).toMatchInlineSnapshot(`303.8757180028086`); - expect(heatComponent?.heat).toMatchInlineSnapshot(`307.92052500643865`); + expect(water?.temperature).toMatchInlineSnapshot('297.823551606293'); + expect(heatComponent?.heat).toMatchInlineSnapshot('299.34962722360433'); }); }); diff --git a/server/src/systems/index.ts b/server/src/systems/index.ts index f30a6dc2..e2cce033 100644 --- a/server/src/systems/index.ts +++ b/server/src/systems/index.ts @@ -16,7 +16,7 @@ import { ReactorHeatSystem } from "./ReactorHeatSystem"; import { HeatToCoolantSystem } from "./HeatToCoolantSystem"; import { HeatDispersionSystem } from "./HeatDispersionSystem"; import { PowerDrawSystem } from "./PowerDrawSystem"; -import { PowerGridSystem } from "./PowerGridSystem"; +// import { PowerGridSystem } from "./PowerGridSystem"; import { WaypointRemoveSystem } from "./WaypointRemoveSystem"; import { DebugSphereSystem } from "./DebugSphereSystem"; import { ProcessTriggersSystem } from "./ProcessTriggersSystem"; @@ -41,7 +41,6 @@ const systems = [ ReactorFuelSystem, ReactorHeatSystem, PowerDrawSystem, - PowerGridSystem, TorpedoLoadingSystem, NearbyObjectsSystem, ShipBehaviorSystem,