Exact source used by this build. SHA-256: 4e277cfb3d5a107bb7a48e017d4d2436aa355e1ccdfa9235a7b6d42174e02c8c.
1 import type { Area, Facing, TilePosition } from '../../types';
2 import { createRng } from '../../rng';
3 import type { Quality } from '../items';
4 import type { FishDefinition, FishingContent, FishingMechanics, FishingMinigame, FishingTable, TackleDefinition } from './model';
5 export const clamp = (n: number, min: number, max: number): number => Math.max(min, Math.min(max, n));
6 export function fishingEnergyCost(level: number): number { return Math.max(0, 8 - .1 * level); }
7 export function fishingBarHeight(level: number, training = false, bonus = 0): number { return 96 + 8 * Math.max(training ? 5 : 0, level) + bonus; }
8 export function fishingCastDistance(level: number, chargeMs: number, facing: Facing = 'down', mechanics?: FishingMechanics): number {
9 const levels = mechanics?.castLevelThresholds ?? [1, 4, 8, 15];
10 const maximum = (mechanics?.castBaseDistance ?? 3) + levels.filter((threshold) => level >= threshold).length * (mechanics?.castLevelBonus ?? 1) + (facing === 'left' || facing === 'right' ? mechanics?.castHorizontalBonus ?? 1 : 0);
11 return 1 + Math.floor(clamp(chargeMs / (mechanics?.chargeMaxMs ?? 1000), 0, 1) * (maximum - 1));
12 }
13 export function fishingXp(difficulty: number, quality: Quality, perfect = false, treasure = false, legendary = false, mechanics?: FishingMechanics): number {
14 let xp = Math.floor(({ regular: 0, silver: 1, gold: 2, iridium: 4 }[quality] + 1) * (mechanics?.xpQualityFactor ?? 3) + difficulty / (mechanics?.xpDifficultyDivisor ?? 3));
15 if (treasure) xp = Math.floor(xp * (mechanics?.xpTreasureMultiplier ?? 2.2));
16 if (perfect) xp = Math.floor(xp * (mechanics?.xpPerfectMultiplier ?? 2.4));
17 if (legendary) xp *= mechanics?.xpLegendaryMultiplier ?? 5;
18 return xp;
19 }
20 /** The source quality thresholds use the size factor; a perfect regular catch stays regular. */
21 export function fishingQuality(sizeFactor: number, perfect: boolean, training = false, bonus = 0): Quality {
22 if (training) return 'regular';
23 let quality = clamp((sizeFactor < .33 ? 0 : sizeFactor < .66 ? 1 : 2) + bonus, 0, 3);
24 if (perfect && quality > 0) quality++;
25 return (['regular', 'silver', 'gold', 'iridium'] as const)[clamp(quality, 0, 3)]!;
26 }
27 export function fishingSizeFactor(level: number, depth: number, roll: number, variation: number): number {
28 const minimumSkill = Math.floor(Math.max(0, level) / 2) * 2;
29 const possibleSkills = Math.max(1, 1 + (10 - minimumSkill) / 2);
30 const skill = minimumSkill + 2 * Math.floor(clamp(roll, 0, .999999) * possibleSkills);
31 const random = 90 + Math.floor(clamp(variation, 0, .999999) * 21);
32 return clamp(depth / 5 * (skill + 2) / 10 * random / 100, 0, 1);
33 }
34 export function fishingBiteWait(level: number, baitMultiplier: number, roll: number, mechanics: FishingMechanics, distance = 1, depth = 1, tackleMultiplier = 1): number {
35 const maximum = Math.max(mechanics.biteMinMs, mechanics.biteMaxMs - level * mechanics.biteLevelReductionMs);
36 // Shallow, short casts incur repeated unsuccessful nibbles; deep casts retain the source bite window.
37 const shallowPenalty = 1 + Math.max(0, mechanics.depthMax - depth) * mechanics.shallowBiteDepthPenalty + Math.max(0, mechanics.shallowBiteDistanceThreshold - distance) * mechanics.shallowBiteDistancePenalty;
38 return Math.max(mechanics.biteFloorMs, (mechanics.biteMinMs + roll * (maximum - mechanics.biteMinMs)) * mechanics.biteFirstMultiplier * baitMultiplier * tackleMultiplier * shallowPenalty);
39 }
40 export function waterAt(area: Area, tile: TilePosition, content: FishingContent): string | null {
41 if (!Number.isInteger(tile.x) || !Number.isInteger(tile.y) || tile.x < 0 || tile.y < 0 || tile.x >= area.width || tile.y >= area.height || !content.waterTileIds.includes(area.groundTiles[tile.y * area.width + tile.x] ?? -1)) return null;
42 return content.waters.find((water) => water.areaId === area.id && tile.x >= water.x && tile.y >= water.y && tile.x < water.x + water.width && tile.y < water.y + water.height && (water.waterTileIds ?? content.waterTileIds).includes(area.groundTiles[tile.y * area.width + tile.x]!))?.waterTag ?? (area.systems.fishing as { waterTag?: string } | undefined)?.waterTag ?? null;
43 }
44 export function waterDepth(area: Area, tile: TilePosition, content: FishingContent): number {
45 if (!waterAt(area, tile, content)) return 0;
46 for (let distance = 1; distance <= content.mechanics.depthMax; distance++) {
47 for (let offset = -distance; offset <= distance; offset++) for (const point of [{ x: tile.x + offset, y: tile.y - distance }, { x: tile.x + offset, y: tile.y + distance }, { x: tile.x - distance, y: tile.y + offset }, { x: tile.x + distance, y: tile.y + offset }]) if (!waterAt(area, point, content)) return distance;
48 }
49 return content.mechanics.depthMax;
50 }
51 export function fishingTable(content: FishingContent, areaId: string, waterTag: string): FishingTable | undefined { return content.tables.find((table) => table.areaId === areaId && table.waterTag === waterTag) ?? content.tables.find((table) => table.areaId === '*' && table.waterTag === waterTag); }
52 export function advanceFishingMinigame(source: FishingMinigame, fish: FishDefinition, elapsedMs: number, mechanics: FishingMechanics, tackle?: TackleDefinition): FishingMinigame {
53 const game = { ...source, treasure: source.treasure ? { ...source.treasure } : null }, rng = createRng(game.rngState);
54 game.remainderMs += Math.max(0, elapsedMs);
55 const step = mechanics.physicsStepMs, top = mechanics.barTrackHeight;
56 while (game.remainderMs + 1e-7 >= step && game.meter > 0 && game.meter < 1) {
57 game.remainderMs = Math.max(0, game.remainderMs - step); game.elapsedMs += step;
58 if (--game.targetFrames <= 0) {
59 const roll = rng.next();
60 game.targetY = top * (fish.behavior === 'sinker' ? roll * roll : fish.behavior === 'floater' ? 1 - roll * roll : roll);
61 game.targetFrames = Math.max(8, Math.floor(90 - fish.difficulty * .45 + rng.next() * 60));
62 if (fish.behavior === 'dart') game.fishVelocity += (rng.next() - .5) * 2 * fish.difficulty / 10;
63 }
64 const smooth = fish.behavior === 'smooth' ? .015 : fish.behavior === 'dart' ? .07 : .035;
65 game.fishVelocity += clamp((game.targetY - game.fishY) * smooth - game.fishVelocity * .15, -fish.difficulty / 100, fish.difficulty / 100);
66 game.fishVelocity = clamp(game.fishVelocity, -fish.difficulty / 12, fish.difficulty / 12);
67 game.fishY = clamp(game.fishY + game.fishVelocity, 0, top);
68 if (game.fishY === 0 || game.fishY === top) game.fishVelocity *= -.5;
69 game.barVelocity += game.holding ? mechanics.liftAcceleration : -mechanics.gravity;
70 game.barY += game.barVelocity;
71 if (game.barY <= 0) { game.barY = 0; game.barVelocity = Math.abs(game.barVelocity) * mechanics.bounce * (tackle?.bounceMultiplier ?? 1); }
72 if (game.barY >= top - game.barHeight) { game.barY = top - game.barHeight; game.barVelocity = -Math.abs(game.barVelocity) * mechanics.bounce; }
73 const overlap = game.fishY >= game.barY && game.fishY <= game.barY + game.barHeight;
74 if (!overlap) { game.perfect = false; game.outsideMs = (game.outsideMs ?? 0) + step; }
75 const chest = game.treasure;
76 if (chest && !chest.caught && game.elapsedMs >= chest.appearsAtMs) {
77 const onChest = chest.y >= game.barY && chest.y <= game.barY + game.barHeight;
78 chest.progress = clamp(chest.progress + (onChest ? mechanics.treasureFillPerFrame : -mechanics.treasureDrainPerFrame), 0, 1);
79 if (chest.progress >= 1) chest.caught = true;
80 }
81 game.meter = clamp(game.meter + (overlap ? mechanics.catchFillPerFrame : -mechanics.catchDrainPerFrame * (tackle?.catchDrainMultiplier ?? 1)), 0, 1);
82 }
83 game.rngState = rng.state;
84 return game;
85 }
86