diff --git a/api/src/lib/siegeWarMachine.ts b/api/src/lib/siegeWarMachine.ts new file mode 100644 index 00000000..d53f9094 --- /dev/null +++ b/api/src/lib/siegeWarMachine.ts @@ -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 = { + 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; + 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, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/siegeWarMachine.test.ts b/api/src/tests/siegeWarMachine.test.ts new file mode 100644 index 00000000..31896e7d --- /dev/null +++ b/api/src/tests/siegeWarMachine.test.ts @@ -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); + }); +}); \ No newline at end of file