Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/archetypes/facility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export function createFacility(sim: Sim, initial: InitialFacilityInput) {
.addComponent({ name: "journal", entries: [] })
.addComponent({
name: "hitpoints",
hp: { max: 100000, regen: 0, value: 100000 },
hp: { base: 100000, max: 100000, regen: 0, value: 100000, modifiers: {} },
hitBy: {},
})
.addComponent({
name: "name",
Expand Down
14 changes: 14 additions & 0 deletions core/archetypes/ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const shipComponents = [
"journal",
"model",
"subordinates",
"experience",
] as const;

export type ShipComponent = (typeof shipComponents)[number];
Expand Down Expand Up @@ -118,20 +119,28 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship {
.addComponent({
name: "hitpoints",
hp: {
base: initial.hitpoints.hp.value,
max: initial.hitpoints.hp.value,
regen: initial.hitpoints.hp.regen,
value: initial.hitpoints.hp.value,
modifiers: {},
},
shield: {
max: initial.hitpoints.shield.value,
regen: initial.hitpoints.shield.regen,
value: initial.hitpoints.shield.value,
},
hitBy: {},
})
.addComponent({
...initial.damage,
name: "damage",
targetId: null,
output: {
base: initial.damage.value,
current: initial.damage.value,
},
modifiers: {},
})
.addComponent({
name: "model",
Expand All @@ -143,6 +152,11 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship {
value: createShipName(entity.requireComponents(["model"])),
})
.addComponent({ name: "subordinates", ids: [] })
.addComponent({
name: "experience",
rank: 1,
amount: 0,
})
.addTag("selection")
.addTag("ship")
.addTag(`role:${initial.role}`);
Expand Down
2 changes: 2 additions & 0 deletions core/components/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type { CrewRequirement } from "./crewRequirement";
import type { Movable } from "./movable";
import type { StorageTransfer } from "./storageTransfer";
import type { Policies } from "./policies";
import type { Experience } from "./experience";

export interface CoreComponents {
ai: Ai;
Expand All @@ -67,6 +68,7 @@ export interface CoreComponents {
dockable: Dockable;
docks: Docks;
drive: Drive;
experience: Experience;
facilityModuleBonus: FacilityModuleBonus;
facilityModuleQueue: FacilityModuleQueue;
hecsPosition: HECSPosition;
Expand Down
15 changes: 14 additions & 1 deletion core/components/damage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ export interface Damage extends BaseComponent<"damage"> {
cooldown: number;
targetId: number | null;
range: number;
value: number;
angle: number;
modifiers: Record<string, number>;
output: {
base: number;
current: number;
};
}

export function recalculate(cp: Damage): void {
let multiplier = 1;
for (const mod of Object.values(cp.modifiers)) {
multiplier += mod;
}

cp.output.current = cp.output.base * multiplier;
}
32 changes: 32 additions & 0 deletions core/components/experience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { RequireComponent } from "@core/tsHelpers";
import type { BaseComponent } from "./component";

export interface Experience extends BaseComponent<"experience"> {
amount: number;
rank: number;
}

export const ranks = [200, 600, 1400, 3000, 6200, Infinity];

export function getRank(exp: number): number {
return ranks.findIndex((threshold) => exp < threshold);
}

export function addExperience(

Copilot AI Jul 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function updates modifiers and adds the recalculate tag even when rank doesn't change. This could cause unnecessary recalculations. Move the modifier updates and tag addition inside the if (newRank > entity.cp.experience.rank) block.

Copilot uses AI. Check for mistakes.
entity: RequireComponent<"experience">,
value: number
): void {
entity.cp.experience.amount += value;
const newRank = getRank(entity.cp.experience.amount);
if (newRank > entity.cp.experience.rank) {
entity.cp.experience.rank = newRank;

if (entity.hasComponents(["damage"])) {
entity.cp.damage.modifiers.rank = 0.1 * entity.cp.experience.rank;
}
if (entity.hasComponents(["hitpoints"])) {
entity.cp.hitpoints.hp.modifiers.rank = 0.1 * entity.cp.experience.rank;
}
entity.addTag("recalculate:modifiers");
}
}
31 changes: 27 additions & 4 deletions core/components/hitpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ import type { BaseComponent } from "./component";

export interface HitPoints extends BaseComponent<"hitpoints"> {
hp: {
base: number;
max: number;
value: number;
regen: number;
modifiers: Record<string, number>;
};
shield?: {
max: number;
value: number;
regen: number;
};
hit?: boolean;
hitBy: Record<number, number>; // entityId: timestamp
}

export function changeHp(
export function subtractHp(
entity: RequireComponent<"hitpoints">,
value: number
): void {
Expand All @@ -25,9 +27,30 @@ export function changeHp(
0,
entity.cp.hitpoints.shield.value - delta
);
delta -= Math.min(entity.cp.hitpoints.shield.value, value);
delta += Math.min(entity.cp.hitpoints.shield.value, value);

Copilot AI Jul 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line uses += but should use -= to correctly calculate remaining damage after shield absorption. The current code would add the absorbed shield damage back to delta instead of subtracting it.

Suggested change
delta += Math.min(entity.cp.hitpoints.shield.value, value);
delta -= Math.min(entity.cp.hitpoints.shield.value, value);

Copilot uses AI. Check for mistakes.
}

entity.cp.hitpoints.hp.value -= delta;
entity.cp.hitpoints.hit = true;
}

export function dealDamageToEntity(
entity: RequireComponent<"hitpoints">,
value: number,
attackerId: number
): void {
subtractHp(entity, value);
entity.cp.hitpoints.hitBy[attackerId] = entity.sim.getTime();
}

export function recalculate(cp: HitPoints): void {
let multiplier = 1;
for (const mod of Object.values(cp.hp.modifiers)) {
multiplier += mod;
}

const diff = cp.hp.base * multiplier - cp.hp.max;
cp.hp.max = cp.hp.base * multiplier;
if (multiplier > 1) {
cp.hp.value += diff;
}
}
1 change: 1 addition & 0 deletions core/components/masks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const componentList = [
"movable",
"storageTransfer",
"policies",
"experience",
];
export const componentMask: Record<keyof CoreComponents, bigint> =
componentList.reduce(
Expand Down
2 changes: 2 additions & 0 deletions core/sim/baseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { fogOfWarUpdatingSystem } from "@core/systems/fogOfWarUpdating";
import { storageTransferringSystem } from "@core/systems/storageTransferring";
import { sectorClaimingSystem } from "@core/systems/sectorClaiming";
import { NavigatingSystem } from "@core/systems/navigating";
import { modifierRecalculatingSystem } from "@core/systems/modifierRecalculating";
import type { SimConfig } from "./Sim";

export const bootstrapSystems = [
Expand All @@ -56,6 +57,7 @@ export const bootstrapSystems = [
disposableUnregisteringSystem,
crewGrowingSystem,
storageTransferringSystem,
modifierRecalculatingSystem,
];

export const createBaseConfig = async (): Promise<SimConfig> => {
Expand Down
4 changes: 2 additions & 2 deletions core/systems/attacking.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { changeHp } from "@core/components/hitpoints";
import { dealDamageToEntity } from "@core/components/hitpoints";
import settings from "@core/settings";
import type { Sim } from "@core/sim";
import type { RequireComponent } from "@core/tsHelpers";
Expand Down Expand Up @@ -142,7 +142,7 @@ export class AttackingSystem extends System {
transport3D.hooks.shoot.notify(
entity.requireComponents(["position", "damage"])
);
changeHp(target, entity.cp.damage.value);
dealDamageToEntity(target, entity.cp.damage.output.current, entity.id);
const parentEntity = entityOrParent;

if (target.hasComponents(["drive", "movable"])) {
Expand Down
30 changes: 30 additions & 0 deletions core/systems/deadUnregistering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import type { Sim } from "@core/sim";
import { dumpCargo } from "@core/components/storage";
import type { Faction } from "@core/archetypes/faction";
import { entityIndexer } from "@core/entityIndexer/entityIndexer";
import type { DockSize } from "@core/components/dockable";
import { addExperience } from "@core/components/experience";
import { System } from "./system";
import { transport3D } from "./transport3d";

const expValues: Record<DockSize, number> = {
large: 200,
medium: 50,
small: 20,
};
const timestampThreshold = 120; // 2 minutes

export class DeadUnregisteringSystem extends System {
apply = (sim: Sim) => {
super.apply(sim);
Expand All @@ -27,8 +36,29 @@ export class DeadUnregisteringSystem extends System {
time: this.sim.getTime(),
});
}

if (entity.hasComponents(["position"]))
transport3D.hooks.explode.notify(entity);

const attackers: number[] = [];

for (const [attackerId, timestamp] of Object.entries(
entity.cp.hitpoints.hitBy
)) {
if (timestamp + timestampThreshold > this.sim.getTime()) {
attackers.push(Number(attackerId));
}
}

const exp =
expValues[entity.cp.dockable?.size || "small"] / attackers.length;
for (const attackerId of attackers) {
const attacker = this.sim.get(attackerId);
if (attacker?.hasComponents(["experience"])) {
addExperience(attacker, exp);
Comment on lines +53 to +58

Copilot AI Jul 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Division by zero will occur if attackers.length is 0. Add a check to ensure attackers array is not empty before calculating experience distribution.

Suggested change
const exp =
expValues[entity.cp.dockable?.size || "small"] / attackers.length;
for (const attackerId of attackers) {
const attacker = this.sim.get(attackerId);
if (attacker?.hasComponents(["experience"])) {
addExperience(attacker, exp);
if (attackers.length > 0) {
const exp =
expValues[entity.cp.dockable?.size || "small"] / attackers.length;
for (const attackerId of attackers) {
const attacker = this.sim.get(attackerId);
if (attacker?.hasComponents(["experience"])) {
addExperience(attacker, exp);
}

Copilot uses AI. Check for mistakes.
}
}

entity.unregister("dead");
}
}
Expand Down
2 changes: 0 additions & 2 deletions core/systems/hitpointsRegenerating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ export class HitpointsRegeneratingSystem extends System<"exec"> {
entity.cp.hitpoints.shield.max
);
}

entity.cp.hitpoints.hit = true;
}
};
}
Expand Down
29 changes: 29 additions & 0 deletions core/systems/modifierRecalculating.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Sim } from "@core/sim";
import { entityIndexer } from "@core/entityIndexer/entityIndexer";
import { recalculate as recalculateDamage } from "@core/components/damage";
import { recalculate as recalculateHitpoints } from "@core/components/hitpoints";
import { System } from "./system";

export class ModifierRecalculatingSystem extends System {
apply(sim: Sim) {
sim.hooks.phase.start.subscribe(
this.constructor.name,
this.recalculateModifiers.bind(this)
);
}

// eslint-disable-next-line class-methods-use-this
recalculateModifiers(): void {
for (const entity of entityIndexer.search([], ["recalculate:modifiers"])) {
if (entity.hasComponents(["damage"])) {
recalculateDamage(entity.cp.damage);
}
if (entity.hasComponents(["hitpoints"])) {
recalculateHitpoints(entity.cp.hitpoints);
}
entity.removeTag("recalculate:modifiers");
}
}
}

export const modifierRecalculatingSystem = new ModifierRecalculatingSystem();
2 changes: 1 addition & 1 deletion core/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const tags = [
"ai:attack-force",
"ai:spare",
"ai:mission",
"ui:arrow",
"recalculate:modifiers",
...shipRoles.map<`role:${ShipRole}`>((role) => `role:${role}`),
...modules.map<`facilityModuleType:${FacilityModuleType}`>(
({ type: facilityModuleType }) =>
Expand Down
4 changes: 2 additions & 2 deletions core/world/data/base.json
Git LFS file not shown
4 changes: 2 additions & 2 deletions devtools/facilityModules/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ const FacilityModuleGeneralEditor: React.FC<{ index: number }> = ({
<TableCell>
{facilityModule.type === "military" && (
<input
{...register(`facilityModules.${index}.damage.value`, {
{...register(`facilityModules.${index}.damage.output.current`, {
valueAsNumber: true,
})}
defaultValue={facilityModule.damage?.value}
defaultValue={facilityModule.damage?.output?.current}
/>
)}
</TableCell>
Expand Down
26 changes: 26 additions & 0 deletions ui/components/ExperienceBar/ExperienceBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getRank, ranks } from "@core/components/experience";
import React from "react";
import styles from "./styles.scss";

export interface ExperienceBarProps {
amount: number;
}

export const ExperienceBar: React.FC<ExperienceBarProps> = ({ amount }) => {
const rank = getRank(amount);
const progress = rank === 5 ? 0 : amount / ranks[rank];
const expLabel = rank === 5 ? "Max Rank" : `${amount}/${ranks[rank]}`;
Comment on lines +10 to +12

Copilot AI Jul 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number 5 is used to check for max rank. Consider using ranks.length - 1 or defining a constant like MAX_RANK to make the code more maintainable.

Suggested change
const rank = getRank(amount);
const progress = rank === 5 ? 0 : amount / ranks[rank];
const expLabel = rank === 5 ? "Max Rank" : `${amount}/${ranks[rank]}`;
const MAX_RANK = ranks.length - 1;
const rank = getRank(amount);
const progress = rank === MAX_RANK ? 0 : amount / ranks[rank];
const expLabel = rank === MAX_RANK ? "Max Rank" : `${amount}/${ranks[rank]}`;

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +12

Copilot AI Jul 20, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another instance of magic number 5. This should use the same constant as suggested for line 11 to ensure consistency.

Suggested change
const progress = rank === 5 ? 0 : amount / ranks[rank];
const expLabel = rank === 5 ? "Max Rank" : `${amount}/${ranks[rank]}`;
const progress = rank === MAX_RANK ? 0 : amount / ranks[rank];
const expLabel = rank === MAX_RANK ? "Max Rank" : `${amount}/${ranks[rank]}`;

Copilot uses AI. Check for mistakes.
const rankLabel = rank ? `Rank ${rank}` : "No Rank";

return (
<>
<div>{rankLabel}</div>
<div
className={styles.root}
style={{ "--percent": `${progress * 100}%` } as React.CSSProperties}
>
<span className={styles.label}>{expLabel}</span>
</div>
</>
);
};
Loading
Loading