Exact source used by this build. SHA-256: 183d8368f32a22f35c4c68ed864db08ac07c0893fc3fd612ab1d4abb8267cd45.
1 import json from "../../../data/festivals/festivals.json";
2 import { FISHING } from "../fishing";
3 import { ITEMS } from "../items";
4 import { AREA_MAPS, TILED_MAPS, isMapBlocked } from "../maps";
5 import type { ContentDomain } from "../types";
6 import { FestivalsContentSchema } from "./schema";
7 export * from "./schema";
8 export const FESTIVALS_CONTENT = FestivalsContentSchema.parse(json);
9 export const FESTIVALS = FESTIVALS_CONTENT.festivals;
10 /** Every overlay and closure resolves to an actual blockout POI, never an invented location. */
11 export function validateFestivalReferences(value: unknown, itemIds: readonly string[]): void {
12 const data = FestivalsContentSchema.parse(value), ids = new Set(itemIds);
13 if (new Set(data.festivals.map((f) => f.id)).size !== data.festivals.length) throw new Error("Duplicate festival id");
14 if (new Set(data.festivals.map((f) => f.season)).size !== 4) throw new Error("One festival is required in each season");
15 const pois = new Map(TILED_MAPS.flatMap((map) => {
16 const areaId = map.properties.find((p) => p.name === "areaId")?.value;
17 return map.layers.flatMap((layer) => "objects" in layer ? layer.objects!.map((object) => [`${areaId}:${object.name}`, object] as const) : []);
18 }));
19 const checkPoi = (areaId: string, point: { poiId: string; x: number; y: number }) => {
20 const object = pois.get(`${areaId}:${point.poiId}`);
21 if (!object) throw new Error(`Missing festival POI ${point.poiId}`);
22 if (point.x < object.x / 16 || point.y < object.y / 16 || point.x >= (object.x + object.width) / 16 || point.y >= (object.y + object.height) / 16) throw new Error(`Festival location lies outside ${point.poiId}`);
23 };
24 for (const festival of data.festivals) {
25 const area = AREA_MAPS[festival.areaId];
26 if (!area) throw new Error(`Missing festival area ${festival.areaId}`);
27 for (const point of [festival.entrance, festival.shop, ...festival.layout]) checkPoi(festival.areaId, point);
28 for (const point of [festival.entrance, festival.shop, ...festival.layout.filter((p) => p.kind === "game"), ...festival.game.collectibles]) if (isMapBlocked(area, Math.floor(point.x), Math.floor(point.y))) throw new Error(`Festival ${festival.id} has inaccessible interaction`);
29 if (festival.game.kind === "fishing") {
30 const bank = festival.layout.find((point) => point.kind === "game")!;
31 const reachable = area.groundTiles.some((gid, index) => {
32 const tag = area.waterTags[gid];
33 return tag && Math.hypot(index % area.width - bank.x, Math.floor(index / area.width) - bank.y) <= FISHING.mechanics.castBaseDistance + FISHING.mechanics.castHorizontalBonus && FISHING.tables.some((table) => (table.areaId === area.id || table.areaId === "*") && table.waterTag === tag && table.fishIds.some((id) => FISHING.fish.some((fish) => fish.id === id && fish.seasons.includes(festival.season) && fish.timeWindows.some((window) => window.startMinute < festival.endMinute && window.endMinute > festival.startMinute))));
34 });
35 if (!reachable) throw new Error("Festival fishing needs reachable authored water and seasonal fish");
36 }
37 for (const stock of festival.shop.stock) if (!ids.has(stock.itemId)) throw new Error(`Missing festival stock item ${stock.itemId}`);
38 }
39 for (const building of data.closedBuildings) checkPoi(building.areaId, building);
40 }
41 export const festivalsDomain: ContentDomain = { id: "festivals", data: FESTIVALS_CONTENT, validate() { validateFestivalReferences(json, ITEMS.map((item) => item.id)); } };
42 festivalsDomain.validate();
43