From 27eaff2f929b076a688bc5cd20785901a036102f Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:01:25 +0200 Subject: [PATCH 1/2] feat(naval): add oceanic fleet blockade, harpoon cannons, and cargo interception engine --- api/src/lib/navalFleetBlockade.ts | 170 +++++++++++++++++++++++ api/src/tests/navalFleetBlockade.test.ts | 75 ++++++++++ 2 files changed, 245 insertions(+) create mode 100644 api/src/lib/navalFleetBlockade.ts create mode 100644 api/src/tests/navalFleetBlockade.test.ts diff --git a/api/src/lib/navalFleetBlockade.ts b/api/src/lib/navalFleetBlockade.ts new file mode 100644 index 00000000..106d76bc --- /dev/null +++ b/api/src/lib/navalFleetBlockade.ts @@ -0,0 +1,170 @@ +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. + */ + 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 inRange = this.isVesselInBlockadeZone(blockade, vesselX, vesselY); + if (!inRange) { + 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..11efa968 --- /dev/null +++ b/api/src/tests/navalFleetBlockade.test.ts @@ -0,0 +1,75 @@ +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); + + // Vessel at (60, 50) -> 10 tiles away -> Inside + expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 60, 50)).toBe(true); + + // Vessel at (150, 50) -> 100 tiles away -> Outside + expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 150, 50)).toBe(false); + }); + + it("fires harpoon grappling hook to tether vessel inside zone and rejects vessels outside", () => { + const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "DREADNOUGHT_IRONCLAD", "KRAKEN_ABYSS", 0, 0, 40); + const merchantVessel: InterceptedVessel = { + vesselId: "vessel_01", + captainPlayerId: "merchant_tom", + currentHullHp: 1000, + maxHullHp: 1000, + cargoGoldValue: 400, + isGrappled: false, + isSunken: false, + }; + + // Grapple within 40 tiles + const grappleRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(blockade, merchantVessel, 20, 20); + expect(grappleRes.success).toBe(true); + expect(merchantVessel.isGrappled).toBe(true); + + // Grapple outside range + const outRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(blockade, merchantVessel, 200, 200); + expect(outRes.success).toBe(false); + expect(outRes.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, + }; + + // Dreadnought base 550 cannon power vs 0 armor -> 550 damage -> Sinks sloop + 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); // 75% of 600 + }); + + 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 From 3e1a48ea84bc4ab2be2613a5ab91eec8ec3763d4 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:00:31 +0200 Subject: [PATCH 2/2] fix(naval): gate harpoon grappling on specific warship harpoonRangeTiles --- api/src/lib/navalFleetBlockade.ts | 12 +++++++++--- api/src/tests/navalFleetBlockade.test.ts | 24 +++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/api/src/lib/navalFleetBlockade.ts b/api/src/lib/navalFleetBlockade.ts index 106d76bc..05a6328a 100644 --- a/api/src/lib/navalFleetBlockade.ts +++ b/api/src/lib/navalFleetBlockade.ts @@ -105,7 +105,7 @@ export class NavalFleetBlockadeEngine { } /** - * Fires harpoon grappling hook to tether and intercept an intruder vessel. + * Fires harpoon grappling hook to tether and intercept an intruder vessel, validated against warship harpoon range. */ public static fireHarpoonGrapple( blockade: ActiveNavalBlockade, @@ -121,8 +121,14 @@ export class NavalFleetBlockadeEngine { return { success: false, isGrappled: false, reason: "Target vessel is already sunken or invalid." }; } - const inRange = this.isVesselInBlockadeZone(blockade, vesselX, vesselY); - if (!inRange) { + 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." }; } diff --git a/api/src/tests/navalFleetBlockade.test.ts b/api/src/tests/navalFleetBlockade.test.ts index 11efa968..014cffee 100644 --- a/api/src/tests/navalFleetBlockade.test.ts +++ b/api/src/tests/navalFleetBlockade.test.ts @@ -17,15 +17,13 @@ describe("NavalFleetBlockadeEngine Fleet Blockades, Harpoons & Broadsides", () = it("verifies blockade zone territory coordinates", () => { const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "WAR_GALLEON", "SKULL_REEF", 50, 50, 30); - // Vessel at (60, 50) -> 10 tiles away -> Inside expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 60, 50)).toBe(true); - - // Vessel at (150, 50) -> 100 tiles away -> Outside expect(NavalFleetBlockadeEngine.isVesselInBlockadeZone(blockade, 150, 50)).toBe(false); }); - it("fires harpoon grappling hook to tether vessel inside zone and rejects vessels outside", () => { - const blockade = NavalFleetBlockadeEngine.establishBlockade("admiral", "DREADNOUGHT_IRONCLAD", "KRAKEN_ABYSS", 0, 0, 40); + 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", @@ -36,15 +34,16 @@ describe("NavalFleetBlockadeEngine Fleet Blockades, Harpoons & Broadsides", () = isSunken: false, }; - // Grapple within 40 tiles - const grappleRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(blockade, merchantVessel, 20, 20); + // Within 25 tiles (20, 0) -> Succeeds + const grappleRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(galleonBlockade, merchantVessel, 20, 0); expect(grappleRes.success).toBe(true); expect(merchantVessel.isGrappled).toBe(true); - // Grapple outside range - const outRes = NavalFleetBlockadeEngine.fireHarpoonGrapple(blockade, merchantVessel, 200, 200); - expect(outRes.success).toBe(false); - expect(outRes.reason).toContain("beyond blockade harpoon perimeter"); + 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", () => { @@ -59,12 +58,11 @@ describe("NavalFleetBlockadeEngine Fleet Blockades, Harpoons & Broadsides", () = isSunken: false, }; - // Dreadnought base 550 cannon power vs 0 armor -> 550 damage -> Sinks sloop 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); // 75% of 600 + expect(salvoRes.plunderGoldAwarded).toBe(450); }); it("guards against unsupported warship classes", () => {