type MapLocation = { x: number; y: number; z: number; clusterId?: number; }; type NPCLocation = { icon: string | null; description: string | null; label: string; name: string; location: MapLocation; }; type ProfessionNodeLocation = & { sourceMaterial: { name: string; level: number; }; label: string; name: string; location: MapLocation; } & ({ materialType: "ore"; professionType: "mining"; } | { materialType: "crop"; professionType: "farming"; } | { materialType: "log"; professionType: "woodcutting"; } | { materialType: "fish"; professionType: "fishing"; }); type ProfessionCraftingStationLocation = { name: string; label: string; professionType: | "alchemism" | "armouring" | "cooking" | "jeweling" | "scribing" | "tailoring" | "weaponsmithing" | "woodworking"; location: MapLocation; }; function getDistance(locationA: MapLocation, locationB: MapLocation): number { return Math.sqrt( (locationB.x - locationA.x) ** 2 + (locationB.y - locationA.y) ** 2 + (locationB.z - locationA.z) ** 2, ); } function middle(...locations: MapLocation[]): MapLocation { const sum = locations.reduce((prev, cur) => ({ x: prev.x + cur.x, y: prev.y + cur.y, z: prev.z + cur.z, })); return { x: Math.round(sum.x / locations.length), y: Math.round(sum.y / locations.length), z: Math.round(sum.z / locations.length), }; } function groupLocationItems( items: T[], itemsAreSimilar: (valA: T, valB: T) => boolean, getClusterName: (cluster: T[]) => string, getClusterIcon: (cluster: T[]) => string, ) { let clusterId = 0; for (let rootIdx = 0; rootIdx < items.length; rootIdx++) { const clusterRoot = items[rootIdx]; if (clusterRoot.location.clusterId === undefined) { clusterRoot.location.clusterId = clusterId++; } for (let idx = rootIdx + 1; idx < items.length; idx++) { if ( itemsAreSimilar(clusterRoot, items[idx]) ) { let originalClusterId = items[idx].location.clusterId; if ( originalClusterId !== undefined && originalClusterId !== clusterRoot.location.clusterId ) { for ( const itemWithOriginalClusterId of items.filter((item) => item.location.clusterId === originalClusterId ) ) { itemWithOriginalClusterId.location.clusterId = clusterRoot.location.clusterId; } } else { items[idx].location.clusterId = clusterRoot.location.clusterId; } } } } const clusteredItems: Record = {}; for (const item of items) { const groupByClusterid = item.location.clusterId; if (groupByClusterid === undefined) { throw new Error("clusterId should not be undefined here"); } if (clusteredItems[groupByClusterid] === undefined) { clusteredItems[groupByClusterid] = []; } clusteredItems[groupByClusterid].push(item); } const clusterPoints: Record< number, { name: string; icon: string; color: "#ffffffff"; visibility: "default"; location: MapLocation; } > = {}; for (const [clusterId, cluster] of Object.entries(clusteredItems)) { clusterPoints[clusterId] = { name: getClusterName(cluster), color: "#ffffffff", icon: getClusterIcon(cluster), visibility: "default", location: middle( ...cluster.map((item) => item.location), ), }; } return clusterPoints; } function professionNodeLocationsAreSimilar( nodeA: ProfessionNodeLocation, nodeB: ProfessionNodeLocation, ): boolean { if ( nodeA.materialType !== nodeB.materialType || nodeA.sourceMaterial.name !== nodeB.sourceMaterial.name ) return false; const MAX_DISTANCE = nodeA.professionType === "woodcutting" ? 64 : 16; return getDistance(nodeA.location, nodeB.location) <= MAX_DISTANCE; } function professionNodeLocationsClusterName( cluster: ProfessionNodeLocation[], ): string { if ( cluster.reduce( (prev, cur) => prev === cur.name ? prev : undefined, cluster[0].name as string | undefined, ) === undefined ) { console.error( "Can't find label", cluster, ); } return cluster[0].name.replace("Node", "") + "x" + cluster.length; } function professionNodeLocationsClusterIcon( cluster: ProfessionNodeLocation[], ): string { if ( cluster.reduce( (prev, cur) => prev === cur.professionType ? prev : undefined, cluster[0].professionType as string | undefined, ) === undefined ) { console.error( "Can't find icon", cluster, ); } return cluster[0].professionType; } async function main() { const dataPath = Bun.argv[2]; if (dataPath === undefined) { console.error("Data file path not specified."); console.log( "Usage: " + Bun.file(Bun.argv[1]).name + " /.../.minecraft/wynntils/storage/{uuid}.data.json", ); return; } console.debug(Bun.argv[2]); const data = JSON.parse(await (Bun.file(dataPath)).text()); const { professionNodeLocations, }: { professionNodeLocations: ProfessionNodeLocation[]; } = data["manager.crowdSourcedData.collectedData"].data.version210Beta2; const clusteredProfessionNodeLocations = groupLocationItems( professionNodeLocations, professionNodeLocationsAreSimilar, professionNodeLocationsClusterName, professionNodeLocationsClusterIcon, ); Bun.write( "out.json", JSON.stringify(Object.values(clusteredProfessionNodeLocations), null, 2), ); } main();