diff --git a/api/src/lib/navalFleetBlockade.ts b/api/src/lib/navalFleetBlockade.ts new file mode 100644 index 00000000..05a6328a --- /dev/null +++ b/api/src/lib/navalFleetBlockade.ts @@ -0,0 +1,176 @@ +import crypto from "node:crypto"; + +/** + * Oceanic Fleet Blockade, Harpoon Cannons & Cargo Interception Engine for OpenAO MMORPG. + * Simulates establishing naval blockade chokepoints, harpoon grappling hooks, + * broadside cannon salvos, and toll tax enforcement on passing vessels. + */ + +export type SeaChokepoint = "STORM_STRAIT" | "SIREN_ARCHIPELAGO" | "SKULL_REEF" | "KRAKEN_ABYSS"; +export type WarshipClass = "WAR_GALLEON" | "ARMORED_FRIGATE" | "DREADNOUGHT_IRONCLAD"; + +export interface WarshipData { + className: WarshipClass; + baseHullHp: number; + cannonPower: number; + harpoonRangeTiles: number; +} + +export interface ActiveNavalBlockade { + blockadeId: string; + admiralPlayerId: string; + warshipClass: WarshipClass; + chokepoint: SeaChokepoint; + anchorLocation: { x: number; y: number }; + controlRadiusTiles: number; + currentHullHp: number; + maxHullHp: number; + tollTaxGoldAmount: number; + isDestroyed: boolean; +} + +export interface InterceptedVessel { + vesselId: string; + captainPlayerId: string; + currentHullHp: number; + maxHullHp: number; + cargoGoldValue: number; + isGrappled: boolean; + isSunken: boolean; +} + +export const WARSHIP_CATALOG: Record = { + WAR_GALLEON: { className: "WAR_GALLEON", baseHullHp: 3000, cannonPower: 250, harpoonRangeTiles: 25 }, + ARMORED_FRIGATE: { className: "ARMORED_FRIGATE", baseHullHp: 4500, cannonPower: 380, harpoonRangeTiles: 35 }, + DREADNOUGHT_IRONCLAD: { className: "DREADNOUGHT_IRONCLAD", baseHullHp: 7000, cannonPower: 550, harpoonRangeTiles: 45 }, +}; + +export class NavalFleetBlockadeEngine { + /** + * Establishes a new oceanic naval blockade at a sea chokepoint. + */ + public static establishBlockade( + admiralPlayerId: string, + warshipClass: WarshipClass, + chokepoint: SeaChokepoint, + anchorX = 100, + anchorY = 100, + controlRadiusTiles = 30, + tollTaxGoldAmount = 50 + ): ActiveNavalBlockade { + const data = WARSHIP_CATALOG[warshipClass]; + if (!data) { + throw new Error(`Unsupported warship class: ${String(warshipClass)}`); + } + + const uuid = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random()}`; + const radius = Number.isFinite(controlRadiusTiles) ? Math.max(10, Math.min(100, controlRadiusTiles)) : 30; + const toll = Number.isFinite(tollTaxGoldAmount) ? Math.max(0, Math.floor(tollTaxGoldAmount)) : 50; + + return { + blockadeId: `blockade_${warshipClass.toLowerCase()}_${uuid}`, + admiralPlayerId, + warshipClass, + chokepoint, + anchorLocation: { + x: Number.isFinite(anchorX) ? anchorX : 0, + y: Number.isFinite(anchorY) ? anchorY : 0, + }, + controlRadiusTiles: radius, + currentHullHp: data.baseHullHp, + maxHullHp: data.baseHullHp, + tollTaxGoldAmount: toll, + isDestroyed: false, + }; + } + + /** + * Checks if a target vessel is within the blockade's territorial waters. + */ + public static isVesselInBlockadeZone( + blockade: ActiveNavalBlockade, + vesselX: number, + vesselY: number + ): boolean { + if (!blockade || blockade.isDestroyed) return false; + + const vx = Number.isFinite(vesselX) ? vesselX : 0; + const vy = Number.isFinite(vesselY) ? vesselY : 0; + + const dx = blockade.anchorLocation.x - vx; + const dy = blockade.anchorLocation.y - vy; + const dist = Math.hypot(dx, dy); + + return dist <= blockade.controlRadiusTiles; + } + + /** + * Fires harpoon grappling hook to tether and intercept an intruder vessel, validated against warship harpoon range. + */ + public static fireHarpoonGrapple( + blockade: ActiveNavalBlockade, + vessel: InterceptedVessel, + vesselX: number, + vesselY: number + ): { success: boolean; isGrappled: boolean; reason?: string } { + if (!blockade || blockade.isDestroyed) { + return { success: false, isGrappled: false, reason: "Blockade warship is destroyed or invalid." }; + } + + if (!vessel || vessel.isSunken) { + return { success: false, isGrappled: false, reason: "Target vessel is already sunken or invalid." }; + } + + const vx = Number.isFinite(vesselX) ? vesselX : 0; + const vy = Number.isFinite(vesselY) ? vesselY : 0; + const dist = Math.hypot(blockade.anchorLocation.x - vx, blockade.anchorLocation.y - vy); + + const data = WARSHIP_CATALOG[blockade.warshipClass]; + const harpoonRange = data.harpoonRangeTiles; + + if (dist > harpoonRange || !this.isVesselInBlockadeZone(blockade, vesselX, vesselY)) { + return { success: false, isGrappled: false, reason: "Target vessel is beyond blockade harpoon perimeter." }; + } + + vessel.isGrappled = true; + return { success: true, isGrappled: true }; + } + + /** + * Fires broadside cannon salvo at an intercepted vessel. + */ + public static fireBroadsideSalvo( + blockade: ActiveNavalBlockade, + vessel: InterceptedVessel, + targetArmorRating = 0 + ): { success: boolean; damageDealt: number; remainingVesselHp: number; isSunken: boolean; plunderGoldAwarded: number } { + if (!blockade || blockade.isDestroyed || !vessel || vessel.isSunken) { + return { success: false, damageDealt: 0, remainingVesselHp: vessel?.currentHullHp ?? 0, isSunken: vessel?.isSunken ?? true, plunderGoldAwarded: 0 }; + } + + const data = WARSHIP_CATALOG[blockade.warshipClass]; + const armor = Number.isFinite(targetArmorRating) ? Math.max(0, targetArmorRating) : 0; + const armorMitigation = 100 / (100 + armor); + + const damageDealt = Math.max(20, Math.floor(data.cannonPower * armorMitigation)); + vessel.currentHullHp = Math.max(0, vessel.currentHullHp - damageDealt); + + let isSunken = false; + let plunderGold = 0; + + if (vessel.currentHullHp === 0) { + vessel.isSunken = true; + vessel.isGrappled = false; + isSunken = true; + plunderGold = Math.floor(vessel.cargoGoldValue * 0.75); // 75% salvaged from wreckage + } + + return { + success: true, + damageDealt, + remainingVesselHp: vessel.currentHullHp, + isSunken, + plunderGoldAwarded: plunderGold, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/navalFleetBlockade.test.ts b/api/src/tests/navalFleetBlockade.test.ts new file mode 100644 index 00000000..014cffee --- /dev/null +++ b/api/src/tests/navalFleetBlockade.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { + NavalFleetBlockadeEngine, + ActiveNavalBlockade, + InterceptedVessel, +} from "../lib/navalFleetBlockade.js"; + +describe("NavalFleetBlockadeEngine Fleet Blockades, Harpoons & Broadsides", () => { + it("establishes an Armored Frigate blockade at Storm Strait", () => { + const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral_nelson", "ARMORED_FRIGATE", "STORM_STRAIT", 100, 100, 35, 80); + expect(blockade.warshipClass).toBe("ARMORED_FRIGATE"); + expect(blockade.currentHullHp).toBe(4500); + expect(blockade.controlRadiusTiles).toBe(35); + expect(blockade.tollTaxGoldAmount).toBe(80); + }); + + it("verifies blockade zone territory coordinates", () => { + const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "WAR_GALLEON", "SKULL_REEF", 50, 50, 30); + + expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 60, 50)).toBe(true); + expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 150, 50)).toBe(false); + }); + + it("gates harpoon grappling specifically by warship harpoonRangeTiles", () => { + // War Galleon has 25 tiles harpoon range and 50 control radius + const galleonBlockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "WAR_GALLEON", "KRAKEN_ABYSS", 0, 0, 50); + const merchantVessel: InterceptedVessel = { + vesselId: "vessel_01", + captainPlayerId: "merchant_tom", + currentHullHp: 1000, + maxHullHp: 1000, + cargoGoldValue: 400, + isGrappled: false, + isSunken: false, + }; + + // Within 25 tiles (20, 0) -> Succeeds + const grappleRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(galleonBlockade, merchantVessel, 20, 0); + expect(grappleRes.success).toBe(true); + expect(merchantVessel.isGrappled).toBe(true); + + merchantVessel.isGrappled = false; + // At 30 tiles (30, 0) inside control zone (50) but beyond Galleon harpoon range (25) -> Rejects + const outRangeGrapple = NavalFleetBlockadeEngine.fireHarpoonGrapple(galleonBlockade, merchantVessel, 30, 0); + expect(outRangeGrapple.success).toBe(false); + expect(outRangeGrapple.reason).toContain("beyond blockade harpoon perimeter"); + }); + + it("fires broadside cannon salvo and salvages 75% cargo when sunken", () => { + const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "DREADNOUGHT_IRONCLAD", "STORM_STRAIT", 0, 0, 50); + const weakSloop: InterceptedVessel = { + vesselId: "sloop_01", + captainPlayerId: "smuggler_1", + currentHullHp: 400, + maxHullHp: 400, + cargoGoldValue: 600, + isGrappled: true, + isSunken: false, + }; + + const salvoRes = NavalFleetBlockadeEngine.fireBroadsideSalvo(blockade, weakSloop, 0); + expect(salvoRes.success).toBe(true); + expect(salvoRes.isSunken).toBe(true); + expect(weakSloop.isSunken).toBe(true); + expect(salvoRes.plunderGoldAwarded).toBe(450); + }); + + it("guards against unsupported warship classes", () => { + expect(() => NavalFleetBlockadeEngine.establishBlockade("a", "SPACE_CARRIER" as any, "STORM_STRAIT")).toThrow( + "Unsupported warship class" + ); + }); +}); \ No newline at end of file