Skip to content
Open
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
147 changes: 147 additions & 0 deletions api/src/lib/siegeWarMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Guild Siege War Machine & Fortification Assault Engine for OpenAO MMORPG.
* Simulates deployment of Catapults, Ballistas, and Battering Rams, ammunition damage modifiers,
* fortification gate armor penetration, reload timers enforcement, and operator crew attachment mechanics.
*/

export type SiegeMachineType = "CATAPULT" | "BALLISTA" | "BATTERING_RAM";
export type SiegeAmmoType = "HEAVY_BOULDER" | "FIRE_POT" | "IRON_BOLT";
export type StructureMaterial = "STONE_WALL" | "WOODEN_GATE" | "REINFORCED_IRON_GATE";

export interface SiegeMachineDefinition {
machineType: SiegeMachineType;
baseDamage: number;
reloadTimeSeconds: number;
maxHp: number;
supportedAmmo: SiegeAmmoType[];
}

export interface ActiveSiegeMachine {
instanceId: string;
machineType: SiegeMachineType;
guildId: string;
operatorPlayerId?: string;
currentHp: number;
maxHp: number;
loadedAmmo?: SiegeAmmoType;
lastFiredEpochMs: number;
}

export interface SiegeAttackResult {
damageDealt: number;
isTargetDestroyed: boolean;
remainingStructureHp: number;
wasEffectiveMaterialBonus: boolean;
isOnCooldown?: boolean;
reason?: string;
}

export const SIEGE_MACHINE_SPECS: Record<SiegeMachineType, SiegeMachineDefinition> = {
CATAPULT: {
machineType: "CATAPULT",
baseDamage: 800,
reloadTimeSeconds: 6,
maxHp: 2000,
supportedAmmo: ["HEAVY_BOULDER", "FIRE_POT"],
},
BALLISTA: {
machineType: "BALLISTA",
baseDamage: 450,
reloadTimeSeconds: 3,
maxHp: 1200,
supportedAmmo: ["IRON_BOLT", "FIRE_POT"],
},
BATTERING_RAM: {
machineType: "BATTERING_RAM",
baseDamage: 1200,
reloadTimeSeconds: 8,
maxHp: 3000,
supportedAmmo: ["HEAVY_BOULDER"],
},
};

export class SiegeWarMachineEngine {
/**
* Deploys a new siege engine for a guild.
*/
public static deployMachine(
instanceId: string,
machineType: SiegeMachineType,
guildId: string,
operatorPlayerId?: string
): ActiveSiegeMachine {
const spec = SIEGE_MACHINE_SPECS[machineType];
return {
instanceId,
machineType,
guildId,
operatorPlayerId,
currentHp: spec.maxHp,
maxHp: spec.maxHp,
loadedAmmo: spec.supportedAmmo[0],
lastFiredEpochMs: 0,
};
}

/**
* Calculates the damage multiplier based on ammunition and target structure material.
*/
public static getMaterialDamageMultiplier(ammo: SiegeAmmoType, material: StructureMaterial): { multiplier: number; isBonus: boolean } {
if (ammo === "HEAVY_BOULDER" && material === "STONE_WALL") {
return { multiplier: 1.5, isBonus: true };
}
if (ammo === "FIRE_POT" && material === "WOODEN_GATE") {
return { multiplier: 2.0, isBonus: true };
}
if (ammo === "IRON_BOLT" && material === "REINFORCED_IRON_GATE") {
return { multiplier: 0.5, isBonus: false }; // Ineffective
}
return { multiplier: 1.0, isBonus: false };
}

/**
* Executes a siege engine strike against a fortification structure, enforcing reload cooldowns.
*/
public static fireAtStructure(
machine: ActiveSiegeMachine,
targetStructureHp: number,
targetStructureArmor: number,
targetMaterial: StructureMaterial,
currentEpochMs: number
): SiegeAttackResult {
const spec = SIEGE_MACHINE_SPECS[machine.machineType];

// Enforce reload cooldown
const reloadMs = spec.reloadTimeSeconds * 1000;
if (machine.lastFiredEpochMs > 0 && currentEpochMs - machine.lastFiredEpochMs < reloadMs) {
const remainingCooldown = reloadMs - (currentEpochMs - machine.lastFiredEpochMs);
return {
damageDealt: 0,
isTargetDestroyed: false,
remainingStructureHp: targetStructureHp,
wasEffectiveMaterialBonus: false,
isOnCooldown: true,
reason: `Machine is currently reloading. Cooldown remaining: ${Math.ceil(remainingCooldown / 1000)}s.`,
};
}

const ammo = machine.loadedAmmo ?? spec.supportedAmmo[0];
const { multiplier, isBonus } = this.getMaterialDamageMultiplier(ammo, targetMaterial);

// Armor mitigation formula: RawDamage * (100 / (100 + armor))
const armorFactor = 100 / (100 + Math.max(0, targetStructureArmor));
const rawDamage = spec.baseDamage * multiplier;
Comment thread
gitar-bot[bot] marked this conversation as resolved.
const finalDamage = Math.max(50, Math.floor(rawDamage * armorFactor));

const remainingHp = Math.max(0, targetStructureHp - finalDamage);
machine.lastFiredEpochMs = currentEpochMs;

return {
damageDealt: finalDamage,
isTargetDestroyed: remainingHp === 0,
remainingStructureHp: remainingHp,
wasEffectiveMaterialBonus: isBonus,
isOnCooldown: false,
};
}
}
34 changes: 34 additions & 0 deletions api/src/tests/siegeWarMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { SiegeWarMachineEngine } from "../lib/siegeWarMachine.js";

describe("SiegeWarMachineEngine Fortification Assault & Reload Cooldowns", () => {
it("deploys siege machine and fires with effective material multiplier", () => {
const machine = SiegeWarMachineEngine.deployMachine("catapult_01", "CATAPULT", "guild_order");
machine.loadedAmmo = "FIRE_POT";

// Wooden gate with 0 armor and 2000 HP: base 800 * 2.0 = 1600 damage
const result = SiegeWarMachineEngine.fireAtStructure(machine, 2000, 0, "WOODEN_GATE", 100000);
expect(result.damageDealt).toBe(1600);
expect(result.wasEffectiveMaterialBonus).toBe(true);
expect(result.remainingStructureHp).toBe(400);
expect(machine.lastFiredEpochMs).toBe(100000);
});

it("blocks firing while reload cooldown is active", () => {
const machine = SiegeWarMachineEngine.deployMachine("catapult_01", "CATAPULT", "guild_order");

// Fire 1 at 100000 (Catapult reload is 6 seconds = 6000ms)
SiegeWarMachineEngine.fireAtStructure(machine, 2000, 0, "WOODEN_GATE", 100000);

// Attempt fire at 103000 (only 3 seconds elapsed)
const earlyResult = SiegeWarMachineEngine.fireAtStructure(machine, 2000, 0, "WOODEN_GATE", 103000);
expect(earlyResult.isOnCooldown).toBe(true);
expect(earlyResult.damageDealt).toBe(0);
expect(earlyResult.reason).toContain("Machine is currently reloading");

// Attempt fire after 6000ms at 106001 -> Success
const validResult = SiegeWarMachineEngine.fireAtStructure(machine, 400, 0, "WOODEN_GATE", 106001);
expect(validResult.isOnCooldown).toBe(false);
expect(validResult.damageDealt).toBeGreaterThan(0);
});
});