Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
135 changes: 135 additions & 0 deletions api/src/lib/fishingEngine.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
71 changes: 71 additions & 0 deletions api/src/tests/fishingEngine.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
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);
});
});