From 04e201f6588765668a556038ca49f0e718735105 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:44:29 +0200 Subject: [PATCH 1/2] feat(skills): water tile fishing difficulty and catch rate (#166) --- api/src/lib/fishingEngine.ts | 135 ++++++++++++++++++++++++++++ api/src/tests/fishingEngine.test.ts | 71 +++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 api/src/lib/fishingEngine.ts create mode 100644 api/src/tests/fishingEngine.test.ts diff --git a/api/src/lib/fishingEngine.ts b/api/src/lib/fishingEngine.ts new file mode 100644 index 00000000..d7a58d5c --- /dev/null +++ b/api/src/lib/fishingEngine.ts @@ -0,0 +1,135 @@ +/** + * Water Tile Fishing Simulation & Loot Catch Engine for OpenAO MMORPG. + * Simulates water salinity/depth classifications, bait modifiers, nocturnal species, + * and sunken treasure chest recovery. + */ + +export type WaterTileType = "FRESHWATER_RIVER" | "COASTAL_OCEAN" | "DEEP_SEA" | "LAVA_LAKE"; + +export interface FishingRod { + rodId: string; + name: string; + tier: number; // 1 to 4 + bonusCatchPercent: number; // e.g. 0.05 = +5% +} + +export interface FishingBait { + baitId: string; + name: string; + potency: number; // 1 to 3 + attractsRare: boolean; +} + +export interface FishSpecies { + speciesId: string; + name: string; + waterType: WaterTileType; + minSkill: number; + baseCatchWeight: number; + goldValue: number; + isNocturnalOnly?: boolean; + isTreasureChest?: boolean; +} + +export interface FishAttemptParams { + fishingSkill: number; // 1 to 100 + rod: FishingRod; + bait?: FishingBait; + waterType: WaterTileType; + isNightTime: boolean; + rng?: () => number; +} + +export interface FishCatchResult { + success: boolean; + caughtFish?: FishSpecies; + skillExpGained: number; + reason?: string; +} + +export const FISH_SPECIES_CATALOG: FishSpecies[] = [ + { speciesId: "carp_river", name: "River Carp", waterType: "FRESHWATER_RIVER", minSkill: 1, baseCatchWeight: 60, goldValue: 5 }, + { speciesId: "trout_river", name: "Rainbow Trout", waterType: "FRESHWATER_RIVER", minSkill: 20, baseCatchWeight: 30, goldValue: 18 }, + { speciesId: "salmon_ocean", name: "Coastal Salmon", waterType: "COASTAL_OCEAN", minSkill: 35, baseCatchWeight: 45, goldValue: 35 }, + { speciesId: "shadow_eel", name: "Shadow Eel", waterType: "COASTAL_OCEAN", minSkill: 50, baseCatchWeight: 25, goldValue: 75, isNocturnalOnly: true }, + { speciesId: "kraken_tentacle", name: "Deep Kraken Tentacle", waterType: "DEEP_SEA", minSkill: 75, baseCatchWeight: 15, goldValue: 250 }, + { speciesId: "magma_swordfish", name: "Magma Swordfish", waterType: "LAVA_LAKE", minSkill: 90, baseCatchWeight: 10, goldValue: 500 }, + { speciesId: "sunken_treasure", name: "Sunken Treasure Chest", waterType: "DEEP_SEA", minSkill: 60, baseCatchWeight: 5, goldValue: 1000, isTreasureChest: true }, +]; + +export class FishingEngine { + public static calculateCatchChance( + skill: number, + rodTier: number, + rodBonus: number, + baitPotency = 0 + ): number { + // Base formula: 30% + (skill / 100 * 45%) + (rodTier * 4%) + rodBonus + (bait * 5%) + const chance = 0.30 + (skill / 100) * 0.45 + rodTier * 0.04 + rodBonus + baitPotency * 0.05; + return Math.min(0.95, Math.max(0.10, chance)); + } + + public static attemptFishing(params: FishAttemptParams): FishCatchResult { + const rng = params.rng || Math.random; + const catchChance = this.calculateCatchChance( + params.fishingSkill, + params.rod.tier, + params.rod.bonusCatchPercent, + params.bait?.potency ?? 0 + ); + + if (rng() > catchChance) { + return { + success: false, + skillExpGained: 2, + reason: "The fish escaped the hook.", + }; + } + + // Filter eligible species for this water body and time of day + const eligible = FISH_SPECIES_CATALOG.filter((fish) => { + if (fish.waterType !== params.waterType) return false; + if (fish.minSkill > params.fishingSkill) return false; + if (fish.isNocturnalOnly && !params.isNightTime) return false; + return true; + }); + + if (eligible.length === 0) { + return { + success: false, + skillExpGained: 1, + reason: "No fish of suitable skill inhabit these waters.", + }; + } + + // Weighted selection + let totalWeight = 0; + const weightedPool = eligible.map((fish) => { + let weight = fish.baseCatchWeight; + if (fish.isTreasureChest && params.bait?.attractsRare) { + weight *= 3.0; // Bait boosts rare drops + } + totalWeight += weight; + return { fish, weight }; + }); + + let roll = rng() * totalWeight; + let selectedFish: FishSpecies = eligible[0]; + + for (const item of weightedPool) { + if (roll < item.weight) { + selectedFish = item.fish; + break; + } + roll -= item.weight; + } + + const exp = Math.max(5, Math.floor(selectedFish.minSkill * 1.5)); + + return { + success: true, + caughtFish: selectedFish, + skillExpGained: exp, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/fishingEngine.test.ts b/api/src/tests/fishingEngine.test.ts new file mode 100644 index 00000000..831b1c8f --- /dev/null +++ b/api/src/tests/fishingEngine.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { FishingEngine, FishingRod, FishingBait } from "../lib/fishingEngine.js"; + +describe("FishingEngine Water Habitats & Catch Rates", () => { + const basicRod: FishingRod = { + rodId: "rod_cane_01", + name: "Simple Wooden Rod", + tier: 1, + bonusCatchPercent: 0.0, + }; + + it("catches river fish with basic skill", () => { + const res = FishingEngine.attemptFishing({ + fishingSkill: 15, + rod: basicRod, + waterType: "FRESHWATER_RIVER", + isNightTime: false, + rng: () => 0.05, // High success roll + }); + + assert.equal(res.success, true); + assert.equal(res.caughtFish?.speciesId, "carp_river"); + assert.ok(res.skillExpGained > 0); + }); + + it("restricts nocturnal shadow eels to nighttime conditions", () => { + // Daytime attempt at Coastal Ocean with high skill + const dayRes = FishingEngine.attemptFishing({ + fishingSkill: 60, + rod: basicRod, + waterType: "COASTAL_OCEAN", + isNightTime: false, + rng: () => 0.05, + }); + assert.equal(dayRes.caughtFish?.speciesId, "salmon_ocean"); + + // Nighttime attempt allows Shadow Eel + let callCount = 0; + const rolls = [0.05, 0.90]; // First roll passes bite check, second roll picks nocturnal eel + const nightRes = FishingEngine.attemptFishing({ + fishingSkill: 60, + rod: basicRod, + waterType: "COASTAL_OCEAN", + isNightTime: true, + rng: () => rolls[callCount++ % rolls.length], + }); + assert.equal(nightRes.success, true); + assert.equal(nightRes.caughtFish?.speciesId, "shadow_eel"); + }); + + it("boosts sunken treasure chest chances with rare bait in deep sea", () => { + const rareBait: FishingBait = { + baitId: "bait_glow_shrimp", + name: "Luminescent Shrimp", + potency: 3, + attractsRare: true, + }; + + const res = FishingEngine.attemptFishing({ + fishingSkill: 80, + rod: { ...basicRod, tier: 4, bonusCatchPercent: 0.15 }, + bait: rareBait, + waterType: "DEEP_SEA", + isNightTime: true, + rng: () => 0.01, + }); + + assert.equal(res.success, true); + }); +}); \ No newline at end of file From 2e2bf4131b407fe48b5ff5c3775cfe04f83ea8d4 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:17:32 +0200 Subject: [PATCH 2/2] fix(fishing): vitest runner alignment and input-clamped catch rate calculation --- api/src/lib/fishingEngine.ts | 12 +++++++----- api/src/tests/fishingEngine.test.ts | 29 +++++++++++++---------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/api/src/lib/fishingEngine.ts b/api/src/lib/fishingEngine.ts index d7a58d5c..ef60c791 100644 --- a/api/src/lib/fishingEngine.ts +++ b/api/src/lib/fishingEngine.ts @@ -1,7 +1,7 @@ /** * Water Tile Fishing Simulation & Loot Catch Engine for OpenAO MMORPG. * Simulates water salinity/depth classifications, bait modifiers, nocturnal species, - * and sunken treasure chest recovery. + * and sunken treasure chest recovery with robust input validation. */ export type WaterTileType = "FRESHWATER_RIVER" | "COASTAL_OCEAN" | "DEEP_SEA" | "LAVA_LAKE"; @@ -64,8 +64,12 @@ export class FishingEngine { rodBonus: number, baitPotency = 0 ): number { + const clampedSkill = Math.min(100, Math.max(1, skill)); + const clampedRodTier = Math.min(4, Math.max(1, rodTier)); + const clampedBait = Math.min(3, Math.max(0, baitPotency)); + // Base formula: 30% + (skill / 100 * 45%) + (rodTier * 4%) + rodBonus + (bait * 5%) - const chance = 0.30 + (skill / 100) * 0.45 + rodTier * 0.04 + rodBonus + baitPotency * 0.05; + const chance = 0.30 + (clampedSkill / 100) * 0.45 + clampedRodTier * 0.04 + rodBonus + clampedBait * 0.05; return Math.min(0.95, Math.max(0.10, chance)); } @@ -86,7 +90,6 @@ export class FishingEngine { }; } - // Filter eligible species for this water body and time of day const eligible = FISH_SPECIES_CATALOG.filter((fish) => { if (fish.waterType !== params.waterType) return false; if (fish.minSkill > params.fishingSkill) return false; @@ -102,12 +105,11 @@ export class FishingEngine { }; } - // Weighted selection let totalWeight = 0; const weightedPool = eligible.map((fish) => { let weight = fish.baseCatchWeight; if (fish.isTreasureChest && params.bait?.attractsRare) { - weight *= 3.0; // Bait boosts rare drops + weight *= 3.0; } totalWeight += weight; return { fish, weight }; diff --git a/api/src/tests/fishingEngine.test.ts b/api/src/tests/fishingEngine.test.ts index 831b1c8f..5529b19a 100644 --- a/api/src/tests/fishingEngine.test.ts +++ b/api/src/tests/fishingEngine.test.ts @@ -1,8 +1,7 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; +import { describe, it, expect } from "vitest"; import { FishingEngine, FishingRod, FishingBait } from "../lib/fishingEngine.js"; -describe("FishingEngine Water Habitats & Catch Rates", () => { +describe("FishingEngine Habitat Classification and Catch Calculations", () => { const basicRod: FishingRod = { rodId: "rod_cane_01", name: "Simple Wooden Rod", @@ -10,22 +9,21 @@ describe("FishingEngine Water Habitats & Catch Rates", () => { bonusCatchPercent: 0.0, }; - it("catches river fish with basic skill", () => { + it("catches river fish with baseline skill and rod", () => { const res = FishingEngine.attemptFishing({ fishingSkill: 15, rod: basicRod, waterType: "FRESHWATER_RIVER", isNightTime: false, - rng: () => 0.05, // High success roll + rng: () => 0.05, }); - assert.equal(res.success, true); - assert.equal(res.caughtFish?.speciesId, "carp_river"); - assert.ok(res.skillExpGained > 0); + expect(res.success).toBe(true); + expect(res.caughtFish?.speciesId).toBe("carp_river"); + expect(res.skillExpGained).toBeGreaterThan(0); }); it("restricts nocturnal shadow eels to nighttime conditions", () => { - // Daytime attempt at Coastal Ocean with high skill const dayRes = FishingEngine.attemptFishing({ fishingSkill: 60, rod: basicRod, @@ -33,11 +31,10 @@ describe("FishingEngine Water Habitats & Catch Rates", () => { isNightTime: false, rng: () => 0.05, }); - assert.equal(dayRes.caughtFish?.speciesId, "salmon_ocean"); + expect(dayRes.caughtFish?.speciesId).toBe("salmon_ocean"); - // Nighttime attempt allows Shadow Eel let callCount = 0; - const rolls = [0.05, 0.90]; // First roll passes bite check, second roll picks nocturnal eel + const rolls = [0.05, 0.90]; const nightRes = FishingEngine.attemptFishing({ fishingSkill: 60, rod: basicRod, @@ -45,11 +42,11 @@ describe("FishingEngine Water Habitats & Catch Rates", () => { isNightTime: true, rng: () => rolls[callCount++ % rolls.length], }); - assert.equal(nightRes.success, true); - assert.equal(nightRes.caughtFish?.speciesId, "shadow_eel"); + expect(nightRes.success).toBe(true); + expect(nightRes.caughtFish?.speciesId).toBe("shadow_eel"); }); - it("boosts sunken treasure chest chances with rare bait in deep sea", () => { + it("boosts rare sunken treasure chest probability with glowing shrimp bait", () => { const rareBait: FishingBait = { baitId: "bait_glow_shrimp", name: "Luminescent Shrimp", @@ -66,6 +63,6 @@ describe("FishingEngine Water Habitats & Catch Rates", () => { rng: () => 0.01, }); - assert.equal(res.success, true); + expect(res.success).toBe(true); }); }); \ No newline at end of file