Exact source used by this build. SHA-256: 6d91dfccce3931a142b1c829ab1a0fbd52c303db68710484f8a3bf584c91de1f.

1 import { z } from 'zod';
2 import type { IntentResult, Player, SimRules, TilePosition, WorldState } from '../../types';
3 import { createRng, type SeededRng } from '../../rng';
4 import { inToolRange } from '../../state';
5 import { defineIntent, defineSystem, type IntentContext } from '../registry';
6 import { TilePositionSchema } from '../schema';
7 import { addItem, countItem, removeItem, type ItemStack } from '../inventory';
8 import { grantXp, hasProfessionHook, skillLevel } from '../skills';
9 import { shopAccess } from '../shops';
10 import { debitMoney } from '../shipping/money';
11 import { recordCollection } from '../community';
12 import { recordFestivalCatch } from '../festivals';
13 import { recordVillagerQuestProgress } from '../villagers';
14 import type { FishingCatch, FishingContent, FishingPlayer, FishingSession, FishingWorld } from './model';
15 import { advanceFishingMinigame, clamp, fishingBiteWait, fishingCastDistance, fishingQuality, fishingSizeFactor, fishingTable, fishingXp, waterAt, waterDepth } from './mechanics';
16 import { crabPotIngredients, fishingBaits, fishingItemIds } from './integration';
17 export * from './model';
18 export * from './mechanics';
19 export * from './integration';
20 const playerDefaults = (): FishingPlayer => ({ rodId: null, baitId: null, tackleId: null, session: null, result: null });
21 const worldDefaults = (): FishingWorld => ({ timelineMs: 0, collection: {}, legendaryCaught: [], pots: [], nextPotId: 1 });
22 export function getFishing(player: Player): FishingPlayer { return { ...playerDefaults(), ...player.systems.fishing as FishingPlayer | undefined }; }
23 export function getFishingWorld(state: WorldState): FishingWorld { return { ...worldDefaults(), ...state.systems.fishing as FishingWorld | undefined }; }
24 export function fishingContent(rules: SimRules): FishingContent | undefined { return rules.content?.fishing as FishingContent | undefined; }
25 function putPlayer(player: Player, fishing: FishingPlayer): Player { return { ...player, systems: { ...player.systems, fishing } }; }
26 function success(state: WorldState, player: Player, type = 'fishingChanged', data?: Record<string, unknown>): IntentResult { return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [player.id]: player } }, events: [{ type, playerId: player.id, areaId: player.areaId, ...(['fishingChanged', 'fishingHooked'].includes(type) ? { transient: true } : {}), ...(data ? { data } : {}) }] }; }
27 function fail(state: WorldState, error: string): IntentResult { return { state, events: [], error }; }
28 function putWorld(state: WorldState, fishing: FishingWorld): WorldState { return { ...state, systems: { ...state.systems, fishing } }; }
29 function time(state: WorldState): number {
30   const world = getFishingWorld(state);
31   // Integrate-1 stored whole-tick time and added the calendar remainder when read.
32   // Preserve an in-progress saved cast while migrating once to real elapsed time.
33   return world.timelineMs + (world.elapsedModelVersion === 1 ? 0 : state.clock.realMsRemainder);
34 }
35 function weighted<T extends { weight: number }>(entries: readonly T[], rng: SeededRng): T | undefined { let value = rng.next() * entries.reduce((sum, entry) => sum + entry.weight, 0); return entries.find((entry) => (value -= entry.weight) < 0) ?? entries.at(-1); }
36 function busy(player: Player): boolean { const value = getFishing(player); return value.session !== null || !!value.result?.pending.length; }
37 function validOrigin(player: Player, session: FishingSession): boolean { return player.areaId === session.areaId && Math.hypot(player.x - session.origin.x, player.y - session.origin.y) <= .5; }
38 function recordCatch(world: FishingWorld, itemId: string, size: number): FishingWorld { const before = world.collection[itemId] ?? { count: 0, maxSize: 0 }; return { ...world, collection: { ...world.collection, [itemId]: { count: before.count + 1, maxSize: Math.max(before.maxSize, size) } } }; }
39 function insertAll(player: Player, stacks: readonly ItemStack[], rules: SimRules): Player | null { let next = player; for (const stack of stacks) { const added = addItem(next, stack, rules); if (added.remaining) return null; next = added.player; } return next; }
40 /** Both crafting placement and fishing placement create one fishing-owned pot. */
41 export function placeFishingPot(context: IntentContext, tile: TilePosition, itemId?: string): IntentResult {
42   const { state, player, rules } = context, data = fishingContent(rules), area = state.areas[player.areaId], world = getFishingWorld(state), tag = data && area && waterAt(area, tile, data);
43   if (!data || !tag || !inToolRange(player, tile) || world.pots.some((pot) => pot.areaId === player.areaId && pot.x === tile.x && pot.y === tile.y) || area?.tiles[tile.y * area.width + tile.x]?.objectId) return fail(state, 'Crab pot needs reachable empty water');
44   const candidates = fishingItemIds(rules, 'crabPot', data.crabPot.itemId);
45   const source = itemId ? candidates.find((id) => id === itemId) : candidates.find((id) => countItem(player, id, rules));
46   const supplied = source && removeItem(player, source, 1, rules); if (!supplied) return fail(state, 'Crab pot is not in inventory');
47   const pot = { id: `fishing_pot_${world.nextPotId}`, itemId: source!, ownerId: player.id, areaId: player.areaId, ...tile, waterTag: tag, baitId: null, catch: null, size: 0 };
48   return success(putWorld(state, { ...world, nextPotId: world.nextPotId + 1, pots: [...world.pots, pot] }), supplied, 'fishingPotPlaced');
49 }
50 function finishCatch(state: WorldState, playerId: string, rules: SimRules): IntentResult {
51   const player = state.players[playerId]!, value = getFishing(player), session = value.session!, data = fishingContent(rules)!;
52   const fish = data.fish.find((entry) => entry.id === session.fishId), game = session.game;
53   let world = getFishingWorld(state);
54   if (fish?.legendary && world.legendaryCaught.includes(fish.id)) return success(state, putPlayer(player, { ...value, session: null }), 'fishingEscaped');
55   const rod = data.rods.find((entry) => entry.id === value.rodId), tackle = data.tackles.find((entry) => entry.id === session.tackleId);
56   const perfect = !!game?.perfect, treasure = !!game?.treasure?.caught;
57   const baseFactor = session.baseQuality === 'regular' ? 0 : session.baseQuality === 'silver' ? .5 : 1;
58   const quality = fish ? fishingQuality(baseFactor, perfect, rod?.training, tackle?.qualityBonus) : 'regular';
59   const xp = fish ? fishingXp(fish.difficulty, session.baseQuality, perfect, treasure, fish.legendary, data.mechanics) : data.mechanics.trashXp;
60   let size = fish ? Math.max(fish.minSize, session.size - Math.floor((game?.outsideMs ?? 0) / 800)) : 0;
61   if (fish && !perfect && size === fish.maxSize) size--;
62   if (fish && rod?.training) size = fish.minSize;
63   const itemId = fish?.itemId ?? session.trashItemId!;
64   const stacks: ItemStack[] = [{ id: itemId, quantity: 1, quality }];
65   if (treasure) { const rng = createRng(game!.rngState), reward = weighted(data.treasure, rng); if (reward) stacks.push({ id: reward.itemId, quality: 'regular', quantity: reward.minQuantity + rng.nextInt(reward.maxQuantity - reward.minQuantity + 1) }); }
66   const received = insertAll(player, stacks, rules);
67   const result: FishingCatch = { itemId, quality, size, perfect, treasure, legendary: fish?.legendary ?? false, xp, pending: received ? [] : stacks };
68   if (fish) world = recordCatch(world, itemId, size);
69   if (fish?.legendary) world = { ...world, legendaryCaught: [...world.legendaryCaught, fish.id] };
70   const caught = success(putWorld(state, world), putPlayer(grantXp(received ?? player, 'fishing', xp, rules), { ...value, session: null, result }), 'fishingCaught', { ...result });
71   if (fish) {
72     caught.state = recordCollection(caught.state, 'fish', itemId, 1, rules);
73     caught.state = recordVillagerQuestProgress(caught.state, playerId, { kind: 'fishing', itemId, quantity: 1 }, rules);
74     caught.state = recordFestivalCatch(caught.state, playerId, rules);
75   }
76   return caught;
77 }
78 /** Both periodic input and clock hooks advance to one server-authored timeline, never client durations. */
79 export function advanceFishing(state: WorldState, playerId: string, targetMs: number, rules: SimRules): IntentResult {
80   const player = state.players[playerId]; if (!player) return { state, events: [] };
81   const value = getFishing(player), source = value.session, data = fishingContent(rules);
82   if (!source || !data || state.paused || !player.connected) return { state, events: [] };
83   const rod = data.rods.find((entry) => entry.id === value.rodId);
84   if (player.sleeping || !validOrigin(player, source) || !rod || !countItem(player, rod.itemId, rules)) return success(state, putPlayer(player, { ...value, session: null }), 'fishingCancelled');
85   let remaining = Math.max(0, targetMs - source.updatedAtMs);
86   if (!remaining) return { state, events: [] };
87   const session = { ...source, updatedAtMs: targetMs };
88   const events: IntentResult['events'] = [];
89   if (session.phase === 'charging') session.chargeMs = Math.min(data.mechanics.chargeMaxMs, session.chargeMs + remaining);
90   else {
91     if (session.phase === 'waiting') {
92       const consumed = Math.min(remaining, session.waitMs); session.waitMs -= consumed; remaining -= consumed;
93       if (session.waitMs <= 0) { session.phase = 'bite'; session.biteMs = data.mechanics.biteWindowMs; events.push({ type: 'fishingBite', playerId, areaId: player.areaId, transient: true }); }
94     }
95     if (session.phase === 'bite' && remaining > 0) {
96       session.biteMs -= remaining;
97       if (session.biteMs <= 0) return success(state, putPlayer(player, { ...value, session: null }), 'fishingEscaped');
98     } else if (session.phase === 'minigame' && session.game) {
99       const fish = data.fish.find((entry) => entry.id === session.fishId);
100       if (!fish) return success(state, putPlayer(player, { ...value, session: null }), 'fishingCancelled');
101       session.game = advanceFishingMinigame(session.game, fish, remaining, data.mechanics, data.tackles.find((entry) => entry.id === session.tackleId));
102       const changed = success(state, putPlayer(player, { ...value, session }));
103       if (session.game.meter >= 1) return finishCatch(changed.state, playerId, rules);
104       if (session.game.meter <= 0) return success(state, putPlayer(player, { ...value, session: null }), 'fishingEscaped');
105     }
106   }
107   const changed = success(state, putPlayer(player, { ...value, session }));
108   return { ...changed, events: [...events, ...changed.events] };
109 }
110 export function startFishingCast({ state, player, rules }: IntentContext): IntentResult {
111       const data = fishingContent(rules), value = getFishing(player), rod = data?.rods.find((entry) => entry.id === value.rodId);
112       if (!data || !rod || !countItem(player, rod.itemId, rules) || skillLevel(player, 'fishing') < rod.requiredLevel) return fail(state, 'Equip an available fishing rod');
113       if (busy(player)) return fail(state, 'Finish fishing or claim your catch first');
114       const session: FishingSession = { phase: 'charging', areaId: player.areaId, origin: { x: player.x, y: player.y }, tile: null, waterTag: null, chargeMs: 0, waitMs: 0, biteMs: 0, updatedAtMs: time(state), depth: 0, castDistance: 0, fishId: null, trashItemId: null, size: 0, baseQuality: 'regular', baitId: null, tackleId: null, game: null, rngState: 0 };
115       return success(state, putPlayer(player, { ...value, session, result: null }));
116 
117 }
118 export function hookFishing({ state, playerId, rules }: IntentContext): IntentResult {
119       const result = advanceFishing(state, playerId, time(state), rules), player = result.state.players[playerId]!, value = getFishing(player), session = value.session, data = fishingContent(rules);
120       if (!session || session.phase !== 'bite' || !data) return fail(state, 'No fish is biting');
121       if (session.trashItemId) return finishCatch(result.state, playerId, rules);
122       const m = data.mechanics, rng = createRng(session.rngState), rod = data.rods.find((entry) => entry.id === value.rodId), bait = fishingBaits(data, rules).find((entry) => entry.id === session.baitId), tackle = data.tackles.find((entry) => entry.id === session.tackleId);
123       const barHeight = Math.min(m.barTrackHeight, m.barBaseHeight + m.barPerLevel * Math.max(skillLevel(player, 'fishing'), rod?.training ? m.trainingBarLevel : 0) + (bait?.barSizeBonus ?? 0) + (tackle?.barSizeBonus ?? 0));
124       const treasure = rng.next() < (m.treasureChance + (tackle?.treasureChanceBonus ?? 0)) * (hasProfessionHook(player, 'fishing_04', rules) ? 2 : 1);
125       const game = { fishY: barHeight / 2, fishVelocity: 0, targetY: barHeight / 2, targetFrames: 60, barY: 0, barHeight, barVelocity: 0, meter: m.catchInitial, perfect: true, holding: false, elapsedMs: 0, outsideMs: 0, remainderMs: 0, treasure: treasure ? { y: m.barTrackHeight * (.15 + .7 * rng.next()), progress: 0, appearsAtMs: m.treasureDelayMs, caught: false } : null, rngState: rng.state };
126       return success(result.state, putPlayer(player, { ...value, session: { ...session, phase: 'minigame', game } }), 'fishingHooked');
127 
128 }
129 const rodId = z.string().min(1), optionalId = z.string().min(1).nullable();
130 export const fishingSystem = defineSystem({
131   id: 'fishing', worldDefaults: () => ({ ...worldDefaults() }), playerDefaults: () => ({ ...playerDefaults() }),
132   events: ['fishingChanged', 'fishingBought', 'fishingCast', 'fishingBite', 'fishingHooked', 'fishingCaught', 'fishingEscaped', 'fishingCancelled', 'fishingClaimed', 'fishingPotPlaced', 'fishingPotHarvested'],
133   updateIntervalMs: (state: WorldState) => !state.paused && Object.values(state.players).some((player) => player.connected && !!getFishing(player).session) ? 250 : undefined,
134   intents: [
135     defineIntent('fishingBuy', z.object({ type: z.literal('fishingBuy'), itemId: rodId, quantity: z.number().int().min(1).max(999).optional() }), ({ state, player, rules }, intent) => {
136       const data = fishingContent(rules); if (!data) return fail(state, 'Fishing content is unavailable');
137       if (!data.shopIds.some((shopId) => !('error' in shopAccess(state, player, rules, shopId)))) return fail(state, 'Fishing shop is closed or out of reach');
138       const definition = [...data.rods, ...data.baits, data.crabPot].find((entry) => entry.itemId === intent.itemId);
139       if (!definition || skillLevel(player, 'fishing') < definition.requiredLevel) return fail(state, 'Fishing skill is too low or item is unavailable');
140       const quantity = intent.quantity ?? 1;
141       if (data.rods.some((entry) => entry.itemId === intent.itemId) && quantity !== 1) return fail(state, 'Buy one rod at a time');
142       const paid = debitMoney(state, player.id, definition.price * quantity); if (!paid) return fail(state, 'Insufficient funds');
143       const received = addItem(player, { id: intent.itemId, quantity, quality: 'regular' }, rules); if (received.remaining) return fail(state, 'Inventory is full');
144       return success(paid, received.player, 'fishingBought');
145     }),
146     defineIntent('fishingEquip', z.object({ type: z.literal('fishingEquip'), rodId }), ({ state, player, rules }, intent) => {
147       const data = fishingContent(rules), rod = data?.rods.find((entry) => entry.id === intent.rodId);
148       if (busy(player)) return fail(state, 'Finish fishing first');
149       if (!rod || skillLevel(player, 'fishing') < rod.requiredLevel || !countItem(player, rod.itemId, rules)) return fail(state, 'Rod is unavailable');
150       const value = getFishing(player);
151       return success(state, putPlayer(player, { ...value, rodId: rod.id, baitId: rod.baitSlots ? value.baitId : null, tackleId: rod.tackleSlots ? value.tackleId : null }));
152     }),
153     defineIntent('fishingBait', z.object({ type: z.literal('fishingBait'), baitId: optionalId }), ({ state, player, rules }, intent) => {
154       const data = fishingContent(rules), value = getFishing(player), rod = data?.rods.find((entry) => entry.id === value.rodId), bait = data && fishingBaits(data, rules).find((entry) => entry.id === intent.baitId);
155       if (busy(player)) return fail(state, 'Finish fishing first');
156       if (intent.baitId && (!rod?.baitSlots || !bait || !countItem(player, bait.itemId, rules))) return fail(state, 'Bait cannot be attached');
157       return success(state, putPlayer(player, { ...value, baitId: intent.baitId }));
158     }),
159     defineIntent('fishingTackle', z.object({ type: z.literal('fishingTackle'), tackleId: optionalId }), ({ state, player, rules }, intent) => {
160       const data = fishingContent(rules), value = getFishing(player), rod = data?.rods.find((entry) => entry.id === value.rodId), tackle = data?.tackles.find((entry) => entry.id === intent.tackleId);
161       if (busy(player)) return fail(state, 'Finish fishing first');
162       if (intent.tackleId && (!rod?.tackleSlots || !tackle || !countItem(player, tackle.itemId, rules))) return fail(state, 'Tackle cannot be attached');
163       return success(state, putPlayer(player, { ...value, tackleId: intent.tackleId }));
164     }),
165     defineIntent('fishingStartCast', z.object({ type: z.literal('fishingStartCast') }), startFishingCast, { timing: 'fishing', persistence: 'batched' }),
166     defineIntent('fishingCast', z.object({ type: z.literal('fishingCast'), tile: TilePositionSchema }), ({ state, playerId, rules }, intent) => {
167       const advanced = advanceFishing(state, playerId, time(state), rules), player = advanced.state.players[playerId]!, value = getFishing(player), session = value.session, data = fishingContent(rules), rod = data?.rods.find((entry) => entry.id === value.rodId), area = advanced.state.areas[player.areaId];
168       if (!session || session.phase !== 'charging' || !data || !rod || !area) return fail(state, 'Start charging a cast first');
169       const tag = waterAt(area, intent.tile, data); if (!tag) return fail(state, 'Bobber must land on water');
170       const level = skillLevel(player, 'fishing'), m = data.mechanics;
171       const facing = Math.abs(intent.tile.x - player.x) > Math.abs(intent.tile.y - player.y) ? 'right' : 'down';
172       const maximum = fishingCastDistance(level, session.chargeMs, facing, m);
173       const distance = Math.hypot(player.x - intent.tile.x, player.y - intent.tile.y);
174       if (distance > maximum + .5 || distance < .5) return fail(state, 'Water is beyond the charged cast distance');
175       const cost = Math.max(0, m.energyBase - m.energyPerLevel * level); if (player.energy < cost) return fail(state, 'Not enough energy');
176       const table = fishingTable(data, player.areaId, tag); if (!table) return fail(state, 'No fishing table for this water');
177       const rng = createRng(advanced.state.rngState), world = getFishingWorld(advanced.state), depth = waterDepth(area, intent.tile, data);
178       const reserved = new Set(Object.values(advanced.state.players).flatMap((entry) => getFishing(entry).session?.fishId ?? []));
179       const eligible = data.fish.filter((fish) => table.fishIds.includes(fish.id) && level >= fish.requiredLevel && depth >= (fish.requiredDepth ?? 0) && fish.seasons.includes(state.clock.season) && fish.weather.includes(state.weather) && fish.timeWindows.some((window) => state.clock.minuteOfDay >= window.startMinute && state.clock.minuteOfDay < window.endMinute) && (!rod.training || fish.difficulty <= m.trainingMaxDifficulty && !fish.legendary) && (!fish.legendary || !world.legendaryCaught.includes(fish.id) && !reserved.has(fish.id)));
180       const bait = rod.baitSlots ? fishingBaits(data, rules).find((entry) => entry.id === value.baitId && countItem(player, entry.itemId, rules)) : undefined;
181       const tackle = rod.tackleSlots ? data.tackles.find((entry) => entry.id === value.tackleId && countItem(player, entry.itemId, rules)) : undefined;
182       const trashChance = clamp(table.trashChance * (1 + (m.depthMax - depth) * .1) * (bait ? .75 : 1), 0, 1);
183       const fish = eligible.length && rng.next() >= trashChance ? eligible[rng.nextInt(eligible.length)] : undefined;
184       const trash = fish ? undefined : weighted(data.trash, rng); if (!fish && !trash) return fail(state, 'No catch is available');
185       const factor = fishingSizeFactor(level, depth, rng.next(), rng.next());
186       const next: FishingSession = { ...session, phase: 'waiting', tile: intent.tile, waterTag: tag, castDistance: distance, depth, waitMs: fishingBiteWait(level, bait?.biteTimeMultiplier ?? 1, rng.next(), m, distance, depth, tackle?.biteTimeMultiplier), baitId: bait?.id ?? null, tackleId: tackle?.id ?? null, fishId: fish?.id ?? null, trashItemId: trash?.itemId ?? null, size: fish ? Math.floor(fish.minSize + factor * (fish.maxSize - fish.minSize) + 1) : 0, baseQuality: fishingQuality(factor, false, rod.training), rngState: rng.nextInt(0x100000000) };
187       const supplied = bait ? removeItem(player, bait.itemId, 1, rules)! : player;
188       return success({ ...advanced.state, rngState: rng.state }, putPlayer({ ...supplied, energy: player.energy - cost }, { ...value, session: next }), 'fishingCast');
189     }, { timing: 'fishing' }),
190     defineIntent('fishingInput', z.object({ type: z.literal('fishingInput'), holding: z.boolean() }), ({ state, playerId, rules }, intent) => {
191       const result = advanceFishing(state, playerId, time(state), rules), player = result.state.players[playerId]!, value = getFishing(player);
192       if (!value.session?.game || value.session.game.holding === intent.holding) return result;
193       const changed = success(result.state, putPlayer(player, { ...value, session: { ...value.session, game: { ...value.session.game, holding: intent.holding } } }));
194       return { ...changed, events: result.events };
195     }, { timing: 'fishing', persistence: 'batched' }),
196     defineIntent('fishingHook', z.object({ type: z.literal('fishingHook') }), hookFishing, { timing: 'fishing', persistence: 'batched' }),
197     defineIntent('fishingCancel', z.object({ type: z.literal('fishingCancel') }), ({ state, player }) => success(state, putPlayer(player, { ...getFishing(player), session: null }), 'fishingCancelled'), { allowPaused: true, allowSleeping: true }),
198     defineIntent('fishingClaim', z.object({ type: z.literal('fishingClaim') }), ({ state, player, rules }) => {
199       const value = getFishing(player); if (!value.result?.pending.length) return fail(state, 'No catch is waiting');
200       const received = insertAll(player, value.result.pending, rules); if (!received) return fail(state, 'Inventory is full');
201       return success(state, putPlayer(received, { ...value, result: { ...value.result, pending: [] } }), 'fishingClaimed');
202     }),
203     defineIntent('fishingDismiss', z.object({ type: z.literal('fishingDismiss') }), ({ state, player }) => {
204       const value = getFishing(player); if (value.result?.pending.length) return fail(state, 'Claim your catch first');
205       return success(state, putPlayer(player, { ...value, result: null }));
206     }, { persistence: 'batched' }),
207     defineIntent('fishingCraftPot', z.object({ type: z.literal('fishingCraftPot') }), ({ state, player, rules }) => {
208       const data = fishingContent(rules); if (!data || skillLevel(player, 'fishing') < data.crabPot.requiredLevel) return fail(state, 'Crab pot recipe is not unlocked');
209       const ingredients = crabPotIngredients(data, player, rules);
210       let supplied = player; for (const ingredient of ingredients) { const removed = removeItem(supplied, ingredient.itemId, ingredient.quantity, rules); if (!removed) return fail(state, 'Not enough crafting materials'); supplied = removed; }
211       const received = addItem(supplied, { id: data.crabPot.itemId, quality: 'regular', quantity: 1 }, rules); if (received.remaining) return fail(state, 'Inventory is full');
212       return success(state, received.player);
213     }),
214     defineIntent('fishingPlacePot', z.object({ type: z.literal('fishingPlacePot'), tile: TilePositionSchema }), (context, intent) => placeFishingPot(context, intent.tile)),
215     defineIntent('fishingBaitPot', z.object({ type: z.literal('fishingBaitPot'), potId: rodId, baitId: rodId }), ({ state, player, rules }, intent) => {
216       const data = fishingContent(rules), world = getFishingWorld(state), pot = world.pots.find((entry) => entry.id === intent.potId), bait = data && fishingBaits(data, rules).find((entry) => entry.id === intent.baitId);
217       if (!pot || pot.areaId !== player.areaId || !inToolRange(player, pot) || pot.baitId || pot.catch || !bait) return fail(state, 'Pot cannot be baited');
218       const supplied = removeItem(player, bait.itemId, 1, rules); if (!supplied) return fail(state, 'Bait is not in inventory');
219       return success(putWorld(state, { ...world, pots: world.pots.map((entry) => entry.id === pot.id ? { ...pot, baitId: bait.id } : entry) }), supplied);
220     }),
221     defineIntent('fishingHarvestPot', z.object({ type: z.literal('fishingHarvestPot'), potId: rodId }), ({ state, player, rules }, intent) => {
222       const data = fishingContent(rules), world = getFishingWorld(state), pot = world.pots.find((entry) => entry.id === intent.potId);
223       if (!data || !pot?.catch || pot.areaId !== player.areaId || !inToolRange(player, pot)) return fail(state, 'Pot has no reachable catch');
224       const received = addItem(player, pot.catch, rules); if (received.remaining) return fail(state, 'Inventory is full');
225       const caught = data.crabCreatures.some((entry) => entry.itemId === pot.catch!.id) ? recordCatch(world, pot.catch.id, pot.size) : world;
226       const harvested = success(putWorld(state, { ...caught, pots: caught.pots.map((entry) => entry.id === pot.id ? { ...pot, catch: null, size: 0 } : entry) }), grantXp(received.player, 'fishing', data.crabPot.xp, rules), 'fishingPotHarvested');
227       if (caught !== world) {
228         harvested.state = recordCollection(harvested.state, 'fish', pot.catch.id, 1, rules);
229         harvested.state = recordVillagerQuestProgress(harvested.state, player.id, { kind: 'fishing', itemId: pot.catch.id, quantity: 1 }, rules);
230       }
231       return harvested;
232     }),
233     defineIntent('fishingRemovePot', z.object({ type: z.literal('fishingRemovePot'), potId: rodId }), ({ state, player, rules }, intent) => {
234       const data = fishingContent(rules), world = getFishingWorld(state), pot = world.pots.find((entry) => entry.id === intent.potId);
235       if (!data || !pot || pot.areaId !== player.areaId || !inToolRange(player, pot) || pot.catch) return fail(state, 'Empty a reachable pot first');
236       const bait = fishingBaits(data, rules).find((entry) => entry.id === pot.baitId);
237       const stacks: ItemStack[] = [{ id: pot.itemId ?? data.crabPot.itemId, quantity: 1, quality: 'regular' }, ...(bait ? [{ id: bait.itemId, quantity: 1, quality: 'regular' as const }] : [])];
238       const received = insertAll(player, stacks, rules); if (!received) return fail(state, 'Inventory is full');
239       return success(putWorld(state, { ...world, pots: world.pots.filter((entry) => entry.id !== pot.id) }), received);
240     }),
241   ],
242   onElapsedTime: ({ state, rules, elapsedMs }) => {
243     if (state.paused || !Object.values(state.players).some((player) => player.connected)) return { state, events: [] };
244     const world = getFishingWorld(state), timelineMs = time(state) + Math.max(0, elapsedMs ?? 0);
245     let next = putWorld(state, { ...world, timelineMs, elapsedModelVersion: 1 }); const events: IntentResult['events'] = [];
246     for (const playerId of Object.keys(next.players).sort()) { const result = advanceFishing(next, playerId, timelineMs, rules); next = result.state; events.push(...result.events); }
247     return { state: { ...next, revision: Math.max(state.revision + 1, next.revision) }, events };
248   },
249   onPresenceChange: ({ state, playerId, connected }) => {
250     const player = playerId && state.players[playerId];
251     return player && !connected && getFishing(player).session ? success(state, putPlayer(player, { ...getFishing(player), session: null }), 'fishingCancelled') : { state, events: [] };
252   },
253   onOvernight: ({ state, rules }) => {
254     const data = fishingContent(rules), world = getFishingWorld(state), rng = createRng(state.rngState);
255     const pots = world.pots.map((pot) => {
256       const owner = state.players[pot.ownerId];
257       if (!data || !owner || pot.catch || !pot.baitId && !hasProfessionHook(owner, 'fishing_06', rules)) return pot;
258       const table = fishingTable(data, pot.areaId, pot.waterTag), entries = data.crabCreatures.filter((entry) => table?.crabCreatureIds.includes(entry.id));
259       if (!entries.length) return pot;
260       const isTrash = !hasProfessionHook(owner, 'fishing_05', rules) && rng.next() < data.crabPot.trashChance;
261       const creature = isTrash ? undefined : weighted(entries, rng), trash = isTrash ? weighted(data.trash, rng) : undefined;
262       if (!creature && !trash) return pot;
263       const bait = fishingBaits(data, rules).find((entry) => entry.id === pot.baitId);
264       return { ...pot, baitId: null, catch: { id: creature?.itemId ?? trash!.itemId, quantity: 1, quality: creature ? bait?.crabQuality ?? 'regular' : 'regular' } as ItemStack, size: creature ? creature.minSize + rng.nextInt(creature.maxSize - creature.minSize + 1) : 0 };
265     });
266     const players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => [id, putPlayer(player, { ...getFishing(player), session: null })]));
267     return { state: { ...putWorld(state, { ...world, pots }), players, rngState: rng.state, revision: state.revision + 1 }, events: [] };
268   },
269 });
270