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