feat:移除了弹窗,服务器添加sls
This commit is contained in:
255
apps/app-frontend/src/lab/seed-map/backend.ts
Normal file
255
apps/app-frontend/src/lab/seed-map/backend.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import type { SeedMapDimension } from './biomes.ts'
|
||||
import type { SeedMapFeature, SeedMapFeatureKind } from './features.ts'
|
||||
import type { SeedMapOreHit, SeedMapOreKind } from './ores.ts'
|
||||
|
||||
export type SeedMapEdition = 'java' | 'java-large-biomes'
|
||||
|
||||
export type SeedMapVersionProfile = {
|
||||
edition: SeedMapEdition
|
||||
version: string
|
||||
available: boolean
|
||||
dimensions: SeedMapDimension[]
|
||||
ores: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type SeedMapTileRequest = {
|
||||
epoch: number
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
version: string
|
||||
dimension: SeedMapDimension
|
||||
x: number
|
||||
z: number
|
||||
scale: number
|
||||
width: number
|
||||
height: number
|
||||
elevation?: number
|
||||
terrain: boolean
|
||||
contours: boolean
|
||||
highlightBiomes?: number[]
|
||||
}
|
||||
|
||||
export type SeedMapTile = {
|
||||
epoch: number
|
||||
width: number
|
||||
height: number
|
||||
bitmap: ImageBitmap
|
||||
approximate: boolean
|
||||
}
|
||||
|
||||
export type SeedMapFeatureQuery = {
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
version: string
|
||||
dimension: SeedMapDimension
|
||||
minX: number
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxZ: number
|
||||
featureMask?: number
|
||||
}
|
||||
|
||||
export type SeedMapSpawnPoint = {
|
||||
x: number
|
||||
z: number
|
||||
approximate: boolean
|
||||
}
|
||||
|
||||
export type SeedMapLevelDat = {
|
||||
seed: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export type SeedMapOreScanChunk = {
|
||||
cx: number
|
||||
cz: number
|
||||
hits: SeedMapOreHit[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Versions the bundled cubiomes engine understands, newest first. Releases
|
||||
* since the 1.21 winter drop (26.x and the late 1.21.x patches) share the
|
||||
* same world generation and all run on the newest bundled engine.
|
||||
*/
|
||||
export const SEED_MAP_FALLBACK_VERSIONS: readonly string[] = [
|
||||
'26.2',
|
||||
'26.1.2',
|
||||
'26.1',
|
||||
'1.21.9',
|
||||
'1.21.6',
|
||||
'1.21.5',
|
||||
'1.21.4',
|
||||
'1.21.3',
|
||||
'1.21.1',
|
||||
'1.20',
|
||||
'1.19.4',
|
||||
'1.19.2',
|
||||
'1.18',
|
||||
'1.17',
|
||||
'1.16',
|
||||
'1.15',
|
||||
'1.14',
|
||||
'1.13',
|
||||
'1.12',
|
||||
'1.11',
|
||||
'1.10',
|
||||
'1.9',
|
||||
'1.8',
|
||||
'1.7',
|
||||
'1.6',
|
||||
'1.5',
|
||||
'1.4',
|
||||
'1.3',
|
||||
'1.2',
|
||||
'1.1',
|
||||
'1.0',
|
||||
]
|
||||
|
||||
export const SEED_MAP_DEFAULT_VERSION = SEED_MAP_FALLBACK_VERSIONS[0]
|
||||
|
||||
export function fallbackSeedMapProfiles(): SeedMapVersionProfile[] {
|
||||
const profiles: SeedMapVersionProfile[] = []
|
||||
for (const edition of ['java', 'java-large-biomes'] as const) {
|
||||
for (const version of SEED_MAP_FALLBACK_VERSIONS) {
|
||||
if (edition === 'java-large-biomes' && compareToMinor(version, 1, 3) < 0) continue
|
||||
const dimensions: SeedMapDimension[] = ['overworld']
|
||||
if (compareToMinor(version, 1, 16) >= 0) dimensions.push('nether')
|
||||
if (compareToMinor(version, 1, 9) >= 0) dimensions.push('end')
|
||||
profiles.push({
|
||||
edition,
|
||||
version,
|
||||
available: true,
|
||||
dimensions,
|
||||
ores: compareToMinor(version, 1, 18) >= 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
function compareToMinor(version: string, major: number, minor: number): number {
|
||||
const [versionMajor = 0, versionMinor = 0] = version.split('.').map(Number)
|
||||
if (versionMajor !== major) return versionMajor - major
|
||||
return versionMinor - minor
|
||||
}
|
||||
|
||||
export async function getSeedMapProfiles(): Promise<SeedMapVersionProfile[]> {
|
||||
return await invoke<SeedMapVersionProfile[]>('plugin:seed-map|seed_map_profiles')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles render natively; this cap only bounds how many blocking renders are
|
||||
* in flight at once so pans stay responsive while tiles stream in.
|
||||
*/
|
||||
export function seedMapTileConcurrency(
|
||||
scale: number,
|
||||
hardwareConcurrency = typeof navigator === 'undefined' ? 4 : navigator.hardwareConcurrency || 4,
|
||||
): number {
|
||||
const cores = Math.max(1, Math.floor(hardwareConcurrency))
|
||||
const limit = Math.max(2, Math.min(8, cores - 2))
|
||||
return scale <= 1 ? Math.min(4, limit) : limit
|
||||
}
|
||||
|
||||
export async function renderSeedMapTile(request: SeedMapTileRequest): Promise<SeedMapTile> {
|
||||
const pixels = await invoke<ArrayBuffer>('plugin:seed-map|seed_map_render_tile', {
|
||||
request: {
|
||||
seed: request.seed,
|
||||
edition: request.edition,
|
||||
version: request.version,
|
||||
dimension: request.dimension,
|
||||
x: request.x,
|
||||
z: request.z,
|
||||
scale: request.scale,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
elevation: request.elevation ?? 128,
|
||||
terrain: request.terrain,
|
||||
contours: request.contours,
|
||||
highlightBiomes: request.highlightBiomes ?? null,
|
||||
},
|
||||
})
|
||||
const expectedLength = request.width * request.height * 4
|
||||
if (pixels.byteLength !== expectedLength) {
|
||||
throw new Error('The seed map backend returned an unexpected tile size.')
|
||||
}
|
||||
const image = new ImageData(new Uint8ClampedArray(pixels), request.width, request.height)
|
||||
const bitmap = await createImageBitmap(image, {
|
||||
colorSpaceConversion: 'none',
|
||||
premultiplyAlpha: 'none',
|
||||
})
|
||||
return {
|
||||
epoch: request.epoch,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
bitmap,
|
||||
approximate: request.terrain || request.contours,
|
||||
}
|
||||
}
|
||||
|
||||
export async function findSeedMapFeatures(query: SeedMapFeatureQuery): Promise<SeedMapFeature[]> {
|
||||
const features = await invoke<
|
||||
{ kind: string; x: number; z: number; approximate: boolean; endShip?: boolean }[]
|
||||
>('plugin:seed-map|seed_map_find_features', {
|
||||
query: {
|
||||
seed: query.seed,
|
||||
edition: query.edition,
|
||||
version: query.version,
|
||||
dimension: query.dimension,
|
||||
minX: query.minX,
|
||||
minZ: query.minZ,
|
||||
maxX: query.maxX,
|
||||
maxZ: query.maxZ,
|
||||
featureMask: query.featureMask ?? null,
|
||||
},
|
||||
})
|
||||
return features.map((feature) => ({
|
||||
kind: feature.kind as SeedMapFeatureKind,
|
||||
x: feature.x,
|
||||
z: feature.z,
|
||||
approximate: feature.approximate,
|
||||
endShip: feature.endShip,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getSeedMapSpawn(
|
||||
seed: string,
|
||||
edition: SeedMapEdition,
|
||||
version: string,
|
||||
): Promise<SeedMapSpawnPoint> {
|
||||
return await invoke<SeedMapSpawnPoint>('plugin:seed-map|seed_map_spawn', {
|
||||
seed,
|
||||
edition,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSeedMapBiomeAt(query: {
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
version: string
|
||||
dimension: SeedMapDimension
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}): Promise<number> {
|
||||
return await invoke<number>('plugin:seed-map|seed_map_biome_at', query)
|
||||
}
|
||||
|
||||
export async function scanSeedMapOres(request: {
|
||||
seed: string
|
||||
version: string
|
||||
dimension: SeedMapDimension
|
||||
ores: SeedMapOreKind[]
|
||||
chunks: number[]
|
||||
}): Promise<SeedMapOreScanChunk[]> {
|
||||
return await invoke<SeedMapOreScanChunk[]>('plugin:seed-map|seed_map_scan_ores', {
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readSeedMapLevelDat(path: string): Promise<SeedMapLevelDat> {
|
||||
return await invoke<SeedMapLevelDat>('plugin:seed-map|seed_map_read_level_dat', { path })
|
||||
}
|
||||
197
apps/app-frontend/src/lab/seed-map/biomes.ts
Normal file
197
apps/app-frontend/src/lab/seed-map/biomes.ts
Normal file
@ -0,0 +1,197 @@
|
||||
export type SeedMapDimension = 'overworld' | 'nether' | 'end'
|
||||
|
||||
export type SeedMapBiomeCategory =
|
||||
| 'beach'
|
||||
| 'cave'
|
||||
| 'desert'
|
||||
| 'forest'
|
||||
| 'ice'
|
||||
| 'jungle'
|
||||
| 'mesa'
|
||||
| 'mountains'
|
||||
| 'mushroom'
|
||||
| 'ocean'
|
||||
| 'plains'
|
||||
| 'river'
|
||||
| 'savanna'
|
||||
| 'swamp'
|
||||
| 'taiga'
|
||||
| 'nether'
|
||||
| 'end'
|
||||
|
||||
export type SeedMapBiome = {
|
||||
id: number
|
||||
dimensions: readonly SeedMapDimension[]
|
||||
category: SeedMapBiomeCategory
|
||||
color: string
|
||||
}
|
||||
|
||||
export type SeedMapBiomeGroup = {
|
||||
category: SeedMapBiomeCategory
|
||||
dimension: SeedMapDimension
|
||||
biomes: SeedMapBiome[]
|
||||
}
|
||||
|
||||
const OVERWORLD: readonly SeedMapDimension[] = ['overworld']
|
||||
const NETHER: readonly SeedMapDimension[] = ['nether']
|
||||
const END: readonly SeedMapDimension[] = ['end']
|
||||
|
||||
/**
|
||||
* Java biome ids as used by cubiomes, grouped for the picker. Colors mirror
|
||||
* the native renderer's cubiomes palette so the picker and map stay aligned.
|
||||
*/
|
||||
export const SEED_MAP_BIOMES: readonly SeedMapBiome[] = [
|
||||
{ id: 16, dimensions: OVERWORLD, category: 'beach', color: '#FADE55' },
|
||||
{ id: 26, dimensions: OVERWORLD, category: 'beach', color: '#FAF0C0' },
|
||||
{ id: 25, dimensions: OVERWORLD, category: 'beach', color: '#A2A284' },
|
||||
{ id: 183, dimensions: OVERWORLD, category: 'cave', color: '#031F29' },
|
||||
{ id: 174, dimensions: OVERWORLD, category: 'cave', color: '#4E3012' },
|
||||
{ id: 175, dimensions: OVERWORLD, category: 'cave', color: '#283C00' },
|
||||
{ id: 187, dimensions: OVERWORLD, category: 'cave', color: '#C8C828' },
|
||||
{ id: 2, dimensions: OVERWORLD, category: 'desert', color: '#FA9418' },
|
||||
{ id: 27, dimensions: OVERWORLD, category: 'forest', color: '#307444' },
|
||||
{ id: 185, dimensions: OVERWORLD, category: 'forest', color: '#FF91C8' },
|
||||
{ id: 29, dimensions: OVERWORLD, category: 'forest', color: '#40511A' },
|
||||
{ id: 132, dimensions: OVERWORLD, category: 'forest', color: '#2D8E49' },
|
||||
{ id: 4, dimensions: OVERWORLD, category: 'forest', color: '#056621' },
|
||||
{ id: 178, dimensions: OVERWORLD, category: 'forest', color: '#47726C' },
|
||||
{ id: 155, dimensions: OVERWORLD, category: 'forest', color: '#589C6C' },
|
||||
{ id: 186, dimensions: OVERWORLD, category: 'forest', color: '#696D95' },
|
||||
{ id: 34, dimensions: OVERWORLD, category: 'forest', color: '#5B7352' },
|
||||
{ id: 181, dimensions: OVERWORLD, category: 'ice', color: '#B0B3CE' },
|
||||
{ id: 11, dimensions: OVERWORLD, category: 'ice', color: '#A0A0FF' },
|
||||
{ id: 140, dimensions: OVERWORLD, category: 'ice', color: '#B4DCDC' },
|
||||
{ id: 168, dimensions: OVERWORLD, category: 'jungle', color: '#849500' },
|
||||
{ id: 21, dimensions: OVERWORLD, category: 'jungle', color: '#507B0A' },
|
||||
{ id: 23, dimensions: OVERWORLD, category: 'jungle', color: '#60930F' },
|
||||
{ id: 37, dimensions: OVERWORLD, category: 'mesa', color: '#D94515' },
|
||||
{ id: 165, dimensions: OVERWORLD, category: 'mesa', color: '#FF6D3D' },
|
||||
{ id: 38, dimensions: OVERWORLD, category: 'mesa', color: '#B09765' },
|
||||
{ id: 180, dimensions: OVERWORLD, category: 'mountains', color: '#DCDCC8' },
|
||||
{ id: 177, dimensions: OVERWORLD, category: 'mountains', color: '#60A445' },
|
||||
{ id: 179, dimensions: OVERWORLD, category: 'mountains', color: '#C4C4C4' },
|
||||
{ id: 182, dimensions: OVERWORLD, category: 'mountains', color: '#7B8F74' },
|
||||
{ id: 131, dimensions: OVERWORLD, category: 'mountains', color: '#888888' },
|
||||
{ id: 3, dimensions: OVERWORLD, category: 'mountains', color: '#606060' },
|
||||
{ id: 14, dimensions: OVERWORLD, category: 'mushroom', color: '#FF00FF' },
|
||||
{ id: 46, dimensions: OVERWORLD, category: 'ocean', color: '#202070' },
|
||||
{ id: 49, dimensions: OVERWORLD, category: 'ocean', color: '#202038' },
|
||||
{ id: 50, dimensions: OVERWORLD, category: 'ocean', color: '#404090' },
|
||||
{ id: 48, dimensions: OVERWORLD, category: 'ocean', color: '#000040' },
|
||||
{ id: 24, dimensions: OVERWORLD, category: 'ocean', color: '#000030' },
|
||||
{ id: 10, dimensions: OVERWORLD, category: 'ocean', color: '#7070D6' },
|
||||
{ id: 45, dimensions: OVERWORLD, category: 'ocean', color: '#000090' },
|
||||
{ id: 0, dimensions: OVERWORLD, category: 'ocean', color: '#000070' },
|
||||
{ id: 44, dimensions: OVERWORLD, category: 'ocean', color: '#0000AC' },
|
||||
{ id: 1, dimensions: OVERWORLD, category: 'plains', color: '#8DB360' },
|
||||
{ id: 12, dimensions: OVERWORLD, category: 'plains', color: '#FFFFFF' },
|
||||
{ id: 129, dimensions: OVERWORLD, category: 'plains', color: '#B5DB88' },
|
||||
{ id: 7, dimensions: OVERWORLD, category: 'river', color: '#0000FF' },
|
||||
{ id: 35, dimensions: OVERWORLD, category: 'savanna', color: '#BDB25F' },
|
||||
{ id: 36, dimensions: OVERWORLD, category: 'savanna', color: '#A79D64' },
|
||||
{ id: 163, dimensions: OVERWORLD, category: 'savanna', color: '#E5DA87' },
|
||||
{ id: 184, dimensions: OVERWORLD, category: 'swamp', color: '#2CCC8E' },
|
||||
{ id: 6, dimensions: OVERWORLD, category: 'swamp', color: '#07F9B2' },
|
||||
{ id: 32, dimensions: OVERWORLD, category: 'taiga', color: '#596651' },
|
||||
{ id: 160, dimensions: OVERWORLD, category: 'taiga', color: '#818E79' },
|
||||
{ id: 30, dimensions: OVERWORLD, category: 'taiga', color: '#31554A' },
|
||||
{ id: 5, dimensions: OVERWORLD, category: 'taiga', color: '#0B6A5F' },
|
||||
{ id: 8, dimensions: NETHER, category: 'nether', color: '#572526' },
|
||||
{ id: 170, dimensions: NETHER, category: 'nether', color: '#4D3A2E' },
|
||||
{ id: 171, dimensions: NETHER, category: 'nether', color: '#981A11' },
|
||||
{ id: 172, dimensions: NETHER, category: 'nether', color: '#49907B' },
|
||||
{ id: 173, dimensions: NETHER, category: 'nether', color: '#645F63' },
|
||||
{ id: 9, dimensions: END, category: 'end', color: '#8080FF' },
|
||||
{ id: 40, dimensions: END, category: 'end', color: '#4B4BAB' },
|
||||
{ id: 41, dimensions: END, category: 'end', color: '#C9C959' },
|
||||
{ id: 42, dimensions: END, category: 'end', color: '#B5B536' },
|
||||
{ id: 43, dimensions: END, category: 'end', color: '#7070CC' },
|
||||
]
|
||||
|
||||
/** Display names for the Java biome ids shown in the picker. */
|
||||
export const SEED_MAP_BIOME_NAMES: Readonly<Record<number, string>> = {
|
||||
0: 'Ocean',
|
||||
1: 'Plains',
|
||||
2: 'Desert',
|
||||
3: 'Windswept Hills',
|
||||
4: 'Forest',
|
||||
5: 'Taiga',
|
||||
6: 'Swamp',
|
||||
7: 'River',
|
||||
8: 'Nether Wastes',
|
||||
9: 'The End',
|
||||
10: 'Frozen Ocean',
|
||||
11: 'Frozen River',
|
||||
12: 'Snowy Plains',
|
||||
14: 'Mushroom Fields',
|
||||
16: 'Beach',
|
||||
21: 'Jungle',
|
||||
23: 'Sparse Jungle',
|
||||
24: 'Deep Ocean',
|
||||
25: 'Stony Shore',
|
||||
26: 'Snowy Beach',
|
||||
27: 'Birch Forest',
|
||||
29: 'Dark Forest',
|
||||
30: 'Snowy Taiga',
|
||||
32: 'Old Growth Pine Taiga',
|
||||
34: 'Windswept Forest',
|
||||
35: 'Savanna',
|
||||
36: 'Savanna Plateau',
|
||||
37: 'Badlands',
|
||||
38: 'Wooded Badlands',
|
||||
40: 'Small End Islands',
|
||||
41: 'End Midlands',
|
||||
42: 'End Highlands',
|
||||
43: 'End Barrens',
|
||||
44: 'Warm Ocean',
|
||||
45: 'Lukewarm Ocean',
|
||||
46: 'Cold Ocean',
|
||||
48: 'Deep Lukewarm Ocean',
|
||||
49: 'Deep Cold Ocean',
|
||||
50: 'Deep Frozen Ocean',
|
||||
129: 'Sunflower Plains',
|
||||
131: 'Windswept Gravelly Hills',
|
||||
132: 'Flower Forest',
|
||||
140: 'Ice Spikes',
|
||||
155: 'Old Growth Birch Forest',
|
||||
160: 'Old Growth Spruce Taiga',
|
||||
163: 'Windswept Savanna',
|
||||
165: 'Eroded Badlands',
|
||||
168: 'Bamboo Jungle',
|
||||
170: 'Soul Sand Valley',
|
||||
171: 'Crimson Forest',
|
||||
172: 'Warped Forest',
|
||||
173: 'Basalt Deltas',
|
||||
174: 'Dripstone Caves',
|
||||
175: 'Lush Caves',
|
||||
177: 'Meadow',
|
||||
178: 'Grove',
|
||||
179: 'Snowy Slopes',
|
||||
180: 'Jagged Peaks',
|
||||
181: 'Frozen Peaks',
|
||||
182: 'Stony Peaks',
|
||||
183: 'Deep Dark',
|
||||
184: 'Mangrove Swamp',
|
||||
185: 'Cherry Grove',
|
||||
186: 'Pale Garden',
|
||||
187: 'Sulfur Caves',
|
||||
}
|
||||
|
||||
export function seedMapBiomeGroups(): SeedMapBiomeGroup[] {
|
||||
const groups = new Map<SeedMapBiomeCategory, SeedMapBiomeGroup>()
|
||||
for (const biome of SEED_MAP_BIOMES) {
|
||||
const dimension = biome.dimensions[0] ?? 'overworld'
|
||||
const group = groups.get(biome.category) ?? {
|
||||
category: biome.category,
|
||||
dimension,
|
||||
biomes: [],
|
||||
}
|
||||
group.biomes.push(biome)
|
||||
groups.set(biome.category, group)
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
export function seedMapBiomeSlug(name: string): string {
|
||||
return name.toLocaleLowerCase().replaceAll(' ', '-')
|
||||
}
|
||||
103
apps/app-frontend/src/lab/seed-map/features.ts
Normal file
103
apps/app-frontend/src/lab/seed-map/features.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { SeedMapDimension } from './biomes.ts'
|
||||
|
||||
export type SeedMapFeatureKind =
|
||||
| 'village'
|
||||
| 'outpost'
|
||||
| 'shipwreck'
|
||||
| 'monument'
|
||||
| 'mansion'
|
||||
| 'ancient-city'
|
||||
| 'trail-ruins'
|
||||
| 'trial-chambers'
|
||||
| 'ruined-portal'
|
||||
| 'stronghold'
|
||||
| 'slime-chunk'
|
||||
| 'desert-pyramid'
|
||||
| 'jungle-temple'
|
||||
| 'swamp-hut'
|
||||
| 'igloo'
|
||||
| 'ocean-ruin'
|
||||
| 'buried-treasure'
|
||||
| 'mineshaft'
|
||||
| 'desert-well'
|
||||
| 'geode'
|
||||
| 'fortress'
|
||||
| 'bastion'
|
||||
| 'end-city'
|
||||
| 'end-gateway'
|
||||
|
||||
export type SeedMapFeature = {
|
||||
kind: SeedMapFeatureKind
|
||||
x: number
|
||||
z: number
|
||||
approximate: boolean
|
||||
endShip?: boolean
|
||||
}
|
||||
|
||||
export type SeedMapFeatureDefinition = {
|
||||
kind: SeedMapFeatureKind
|
||||
mask: number
|
||||
dimensions: readonly SeedMapDimension[]
|
||||
maxScale: number
|
||||
}
|
||||
|
||||
const OVERWORLD: readonly SeedMapDimension[] = ['overworld']
|
||||
const NETHER: readonly SeedMapDimension[] = ['nether']
|
||||
const END: readonly SeedMapDimension[] = ['end']
|
||||
const ALL_DIMENSIONS: readonly SeedMapDimension[] = ['overworld', 'nether', 'end']
|
||||
const OVERWORLD_AND_NETHER: readonly SeedMapDimension[] = ['overworld', 'nether']
|
||||
|
||||
/**
|
||||
* Map layers in display order. `maxScale` bounds how far the map can zoom out
|
||||
* before a dense layer stops being queried and drawn.
|
||||
*/
|
||||
export const SEED_MAP_FEATURES: readonly SeedMapFeatureDefinition[] = [
|
||||
{ kind: 'slime-chunk', mask: 1 << 10, dimensions: ALL_DIMENSIONS, maxScale: 4 },
|
||||
{ kind: 'village', mask: 1 << 0, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'outpost', mask: 1 << 1, dimensions: OVERWORLD, maxScale: 64 },
|
||||
{ kind: 'shipwreck', mask: 1 << 2, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'monument', mask: 1 << 3, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'mansion', mask: 1 << 4, dimensions: OVERWORLD, maxScale: 64 },
|
||||
{ kind: 'ancient-city', mask: 1 << 5, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'trail-ruins', mask: 1 << 6, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'trial-chambers', mask: 1 << 7, dimensions: OVERWORLD, maxScale: 4 },
|
||||
{ kind: 'ruined-portal', mask: 1 << 8, dimensions: OVERWORLD_AND_NETHER, maxScale: 4 },
|
||||
{ kind: 'stronghold', mask: 1 << 9, dimensions: OVERWORLD, maxScale: 64 },
|
||||
{ kind: 'desert-pyramid', mask: 1 << 11, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'jungle-temple', mask: 1 << 12, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'swamp-hut', mask: 1 << 13, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'igloo', mask: 1 << 14, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'ocean-ruin', mask: 1 << 15, dimensions: OVERWORLD, maxScale: 4 },
|
||||
{ kind: 'buried-treasure', mask: 1 << 16, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'mineshaft', mask: 1 << 17, dimensions: OVERWORLD, maxScale: 4 },
|
||||
{ kind: 'desert-well', mask: 1 << 18, dimensions: OVERWORLD, maxScale: 16 },
|
||||
{ kind: 'geode', mask: 1 << 19, dimensions: OVERWORLD, maxScale: 4 },
|
||||
{ kind: 'fortress', mask: 1 << 20, dimensions: NETHER, maxScale: 64 },
|
||||
{ kind: 'bastion', mask: 1 << 21, dimensions: NETHER, maxScale: 16 },
|
||||
{ kind: 'end-city', mask: 1 << 22, dimensions: END, maxScale: 16 },
|
||||
{ kind: 'end-gateway', mask: 1 << 23, dimensions: END, maxScale: 4 },
|
||||
]
|
||||
|
||||
export function visibleFeatureDefinitions(
|
||||
kinds: SeedMapFeatureKind[],
|
||||
dimension: SeedMapDimension,
|
||||
scale: number,
|
||||
): SeedMapFeatureDefinition[] {
|
||||
return SEED_MAP_FEATURES.filter(
|
||||
(feature) =>
|
||||
kinds.includes(feature.kind) &&
|
||||
feature.dimensions.includes(dimension) &&
|
||||
scale <= feature.maxScale,
|
||||
)
|
||||
}
|
||||
|
||||
export function featureMask(kinds: SeedMapFeatureKind[]): number {
|
||||
return SEED_MAP_FEATURES.reduce(
|
||||
(mask, feature) => (kinds.includes(feature.kind) ? mask | feature.mask : mask),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function featureKey(feature: Pick<SeedMapFeature, 'kind' | 'x' | 'z'>): string {
|
||||
return `${feature.kind}:${feature.x}:${feature.z}`
|
||||
}
|
||||
188
apps/app-frontend/src/lab/seed-map/history.test.ts
Normal file
188
apps/app-frontend/src/lab/seed-map/history.test.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
clearSeedMapHistory,
|
||||
loadSeedMapHistory,
|
||||
recordSeedMapHistory,
|
||||
removeSeedMapHistoryEntry,
|
||||
sanitizeSeedMapHistory,
|
||||
SEED_MAP_HISTORY_LIMIT,
|
||||
seedMapHistoryId,
|
||||
updateSeedMapHistoryProgress,
|
||||
} from './history.ts'
|
||||
|
||||
function memoryStorage(): Pick<Storage, 'getItem' | 'setItem'> {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => void values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
test('recording keeps newest entries first and dedupes revisits', () => {
|
||||
const storage = memoryStorage()
|
||||
recordSeedMapHistory(
|
||||
{ seed: '111', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
1_000,
|
||||
storage,
|
||||
)
|
||||
recordSeedMapHistory(
|
||||
{ seed: '222', edition: 'java', gameVersion: '26.2', source: 'random' },
|
||||
2_000,
|
||||
storage,
|
||||
)
|
||||
const entries = recordSeedMapHistory(
|
||||
{ seed: '111', edition: 'java', gameVersion: '1.21.3', source: 'manual' },
|
||||
3_000,
|
||||
storage,
|
||||
)
|
||||
assert.equal(entries.length, 2)
|
||||
assert.equal(entries[0].seed, '111')
|
||||
assert.equal(entries[0].gameVersion, '1.21.3')
|
||||
assert.equal(entries[0].firstViewedAt, 1_000)
|
||||
assert.equal(entries[0].lastViewedAt, 3_000)
|
||||
assert.equal(entries[1].seed, '222')
|
||||
})
|
||||
|
||||
test('instance attribution survives a later manual revisit', () => {
|
||||
const storage = memoryStorage()
|
||||
recordSeedMapHistory(
|
||||
{
|
||||
seed: '10292992',
|
||||
edition: 'java',
|
||||
gameVersion: '26.2',
|
||||
source: 'instance',
|
||||
instanceName: 'Fabric 26.2',
|
||||
worldName: '生存世界',
|
||||
},
|
||||
1_000,
|
||||
storage,
|
||||
)
|
||||
const entries = recordSeedMapHistory(
|
||||
{ seed: '10292992', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
2_000,
|
||||
storage,
|
||||
)
|
||||
assert.equal(entries[0].source, 'instance')
|
||||
assert.equal(entries[0].instanceName, 'Fabric 26.2')
|
||||
assert.equal(entries[0].worldName, '生存世界')
|
||||
assert.equal(entries[0].lastViewedAt, 2_000)
|
||||
})
|
||||
|
||||
test('the same seed in different editions stays as separate entries', () => {
|
||||
const storage = memoryStorage()
|
||||
recordSeedMapHistory(
|
||||
{ seed: '7', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
1_000,
|
||||
storage,
|
||||
)
|
||||
const entries = recordSeedMapHistory(
|
||||
{ seed: '7', edition: 'java-large-biomes', gameVersion: '26.2', source: 'manual' },
|
||||
2_000,
|
||||
storage,
|
||||
)
|
||||
assert.equal(entries.length, 2)
|
||||
assert.notEqual(entries[0].id, entries[1].id)
|
||||
assert.equal(seedMapHistoryId('7', 'java'), 'java:7')
|
||||
})
|
||||
|
||||
test('history is capped and blank seeds are ignored', () => {
|
||||
const storage = memoryStorage()
|
||||
for (let index = 0; index < SEED_MAP_HISTORY_LIMIT + 10; index++) {
|
||||
recordSeedMapHistory(
|
||||
{ seed: `seed-${index}`, edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
index,
|
||||
storage,
|
||||
)
|
||||
}
|
||||
recordSeedMapHistory(
|
||||
{ seed: ' ', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
99_999,
|
||||
storage,
|
||||
)
|
||||
const entries = loadSeedMapHistory(storage)
|
||||
assert.equal(entries.length, SEED_MAP_HISTORY_LIMIT)
|
||||
assert.equal(entries[0].seed, `seed-${SEED_MAP_HISTORY_LIMIT + 9}`)
|
||||
})
|
||||
|
||||
test('remove and clear update the persisted list', () => {
|
||||
const storage = memoryStorage()
|
||||
recordSeedMapHistory(
|
||||
{ seed: 'a', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
1_000,
|
||||
storage,
|
||||
)
|
||||
recordSeedMapHistory(
|
||||
{ seed: 'b', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
2_000,
|
||||
storage,
|
||||
)
|
||||
const afterRemove = removeSeedMapHistoryEntry(seedMapHistoryId('a', 'java'), storage)
|
||||
assert.deepEqual(
|
||||
afterRemove.map((entry) => entry.seed),
|
||||
['b'],
|
||||
)
|
||||
assert.deepEqual(clearSeedMapHistory(storage), [])
|
||||
assert.deepEqual(loadSeedMapHistory(storage), [])
|
||||
})
|
||||
|
||||
test('exploration progress is stored per seed and survives revisits', () => {
|
||||
const storage = memoryStorage()
|
||||
recordSeedMapHistory(
|
||||
{ seed: 'base', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
1_000,
|
||||
storage,
|
||||
)
|
||||
const id = seedMapHistoryId('base', 'java')
|
||||
updateSeedMapHistoryProgress(id, ['village:0:0'], ['diamond:1:2:3'], storage)
|
||||
let entries = loadSeedMapHistory(storage)
|
||||
assert.deepEqual(entries[0].completedFeatures, ['village:0:0'])
|
||||
assert.deepEqual(entries[0].completedOres, ['diamond:1:2:3'])
|
||||
|
||||
entries = recordSeedMapHistory(
|
||||
{ seed: 'base', edition: 'java', gameVersion: '26.2', source: 'manual' },
|
||||
2_000,
|
||||
storage,
|
||||
)
|
||||
assert.deepEqual(entries[0].completedFeatures, ['village:0:0'])
|
||||
|
||||
entries = recordSeedMapHistory(
|
||||
{
|
||||
seed: 'base',
|
||||
edition: 'java',
|
||||
gameVersion: '26.2',
|
||||
source: 'manual',
|
||||
completedFeatures: ['village:0:0', 'monument:5:5'],
|
||||
completedOres: [],
|
||||
},
|
||||
3_000,
|
||||
storage,
|
||||
)
|
||||
assert.deepEqual(entries[0].completedFeatures, ['village:0:0', 'monument:5:5'])
|
||||
assert.deepEqual(entries[0].completedOres, [])
|
||||
assert.deepEqual(
|
||||
updateSeedMapHistoryProgress(seedMapHistoryId('missing', 'java'), ['x'], [], storage).map(
|
||||
(entry) => entry.seed,
|
||||
),
|
||||
['base'],
|
||||
)
|
||||
})
|
||||
|
||||
test('sanitizing drops malformed entries and duplicate ids', () => {
|
||||
const entries = sanitizeSeedMapHistory([
|
||||
{ seed: 'ok', edition: 'java', gameVersion: '26.2', source: 'share', lastViewedAt: 10 },
|
||||
{ seed: 'ok', edition: 'java', gameVersion: '26.2', source: 'manual', lastViewedAt: 20 },
|
||||
{ seed: '', edition: 'java' },
|
||||
{ seed: 42 },
|
||||
null,
|
||||
'nonsense',
|
||||
{ seed: 'weird-source', edition: 'java', gameVersion: '26.2', source: 'nope' },
|
||||
])
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.seed),
|
||||
['ok', 'weird-source'],
|
||||
)
|
||||
assert.equal(entries[0].source, 'share')
|
||||
assert.equal(entries[1].source, 'manual')
|
||||
})
|
||||
181
apps/app-frontend/src/lab/seed-map/history.ts
Normal file
181
apps/app-frontend/src/lab/seed-map/history.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import type { SeedMapEdition } from './backend.ts'
|
||||
|
||||
export type SeedMapHistorySource = 'manual' | 'random' | 'instance' | 'share'
|
||||
|
||||
export type SeedMapHistoryEntry = {
|
||||
id: string
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
gameVersion: string
|
||||
source: SeedMapHistorySource
|
||||
instanceName?: string
|
||||
worldName?: string
|
||||
firstViewedAt: number
|
||||
lastViewedAt: number
|
||||
completedFeatures: string[]
|
||||
completedOres: string[]
|
||||
}
|
||||
|
||||
export type SeedMapHistoryDraft = {
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
gameVersion: string
|
||||
source: SeedMapHistorySource
|
||||
instanceName?: string
|
||||
worldName?: string
|
||||
completedFeatures?: string[]
|
||||
completedOres?: string[]
|
||||
}
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
export const SEED_MAP_HISTORY_KEY = 'axolotl.lab.seed-map.history.v1'
|
||||
export const SEED_MAP_HISTORY_LIMIT = 60
|
||||
|
||||
const SOURCES: readonly SeedMapHistorySource[] = ['manual', 'random', 'instance', 'share']
|
||||
|
||||
export function seedMapHistoryId(seed: string, edition: SeedMapEdition): string {
|
||||
return `${edition}:${seed}`
|
||||
}
|
||||
|
||||
export function loadSeedMapHistory(
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapHistoryEntry[] {
|
||||
if (!storage) return []
|
||||
try {
|
||||
const raw = storage.getItem(SEED_MAP_HISTORY_KEY)
|
||||
if (!raw) return []
|
||||
return sanitizeSeedMapHistory(JSON.parse(raw))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a viewed seed. Revisiting a known seed refreshes its timestamp and
|
||||
* game version in place; richer attribution (an instance/world label) always
|
||||
* wins over a plain manual revisit so the list keeps its most useful context.
|
||||
*/
|
||||
export function recordSeedMapHistory(
|
||||
draft: SeedMapHistoryDraft,
|
||||
now: number = Date.now(),
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapHistoryEntry[] {
|
||||
const seed = draft.seed.trim().slice(0, 256)
|
||||
if (!seed) return loadSeedMapHistory(storage)
|
||||
const entries = loadSeedMapHistory(storage)
|
||||
const id = seedMapHistoryId(seed, draft.edition)
|
||||
const existing = entries.find((entry) => entry.id === id)
|
||||
const keepExistingAttribution =
|
||||
existing !== undefined && existing.instanceName !== undefined && draft.source !== 'instance'
|
||||
const next: SeedMapHistoryEntry = {
|
||||
id,
|
||||
seed,
|
||||
edition: draft.edition,
|
||||
gameVersion: draft.gameVersion,
|
||||
source: keepExistingAttribution ? existing.source : draft.source,
|
||||
instanceName: keepExistingAttribution ? existing.instanceName : draft.instanceName,
|
||||
worldName: keepExistingAttribution ? existing.worldName : draft.worldName,
|
||||
firstViewedAt: existing?.firstViewedAt ?? now,
|
||||
lastViewedAt: now,
|
||||
completedFeatures: draft.completedFeatures ?? existing?.completedFeatures ?? [],
|
||||
completedOres: draft.completedOres ?? existing?.completedOres ?? [],
|
||||
}
|
||||
const merged = [next, ...entries.filter((entry) => entry.id !== id)].slice(
|
||||
0,
|
||||
SEED_MAP_HISTORY_LIMIT,
|
||||
)
|
||||
persist(merged, storage)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists exploration progress for a known seed without touching its
|
||||
* position in the list or its timestamps. Unknown ids are ignored.
|
||||
*/
|
||||
export function updateSeedMapHistoryProgress(
|
||||
id: string,
|
||||
completedFeatures: string[],
|
||||
completedOres: string[],
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapHistoryEntry[] {
|
||||
const entries = loadSeedMapHistory(storage)
|
||||
const entry = entries.find((candidate) => candidate.id === id)
|
||||
if (!entry) return entries
|
||||
entry.completedFeatures = completedFeatures.slice(0, 2_000)
|
||||
entry.completedOres = completedOres.slice(0, 10_000)
|
||||
persist(entries, storage)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function removeSeedMapHistoryEntry(
|
||||
id: string,
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapHistoryEntry[] {
|
||||
const entries = loadSeedMapHistory(storage).filter((entry) => entry.id !== id)
|
||||
persist(entries, storage)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function clearSeedMapHistory(
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapHistoryEntry[] {
|
||||
persist([], storage)
|
||||
return []
|
||||
}
|
||||
|
||||
export function sanitizeSeedMapHistory(value: unknown): SeedMapHistoryEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<string>()
|
||||
const entries: SeedMapHistoryEntry[] = []
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const source = item as Partial<SeedMapHistoryEntry>
|
||||
if (typeof source.seed !== 'string' || !source.seed.trim()) continue
|
||||
const edition: SeedMapEdition =
|
||||
source.edition === 'java-large-biomes' ? 'java-large-biomes' : 'java'
|
||||
const seed = source.seed.trim().slice(0, 256)
|
||||
const id = seedMapHistoryId(seed, edition)
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const lastViewedAt = finiteTime(source.lastViewedAt)
|
||||
entries.push({
|
||||
id,
|
||||
seed,
|
||||
edition,
|
||||
gameVersion: typeof source.gameVersion === 'string' ? source.gameVersion.slice(0, 32) : '',
|
||||
source: SOURCES.includes(source.source as SeedMapHistorySource)
|
||||
? (source.source as SeedMapHistorySource)
|
||||
: 'manual',
|
||||
instanceName:
|
||||
typeof source.instanceName === 'string' ? source.instanceName.slice(0, 128) : undefined,
|
||||
worldName: typeof source.worldName === 'string' ? source.worldName.slice(0, 128) : undefined,
|
||||
firstViewedAt: finiteTime(source.firstViewedAt) || lastViewedAt,
|
||||
lastViewedAt,
|
||||
completedFeatures: sanitizeKeyList(source.completedFeatures, 2_000),
|
||||
completedOres: sanitizeKeyList(source.completedOres, 10_000),
|
||||
})
|
||||
}
|
||||
return entries.sort((a, b) => b.lastViewedAt - a.lastViewedAt).slice(0, SEED_MAP_HISTORY_LIMIT)
|
||||
}
|
||||
|
||||
function persist(entries: SeedMapHistoryEntry[], storage: StorageLike | null): void {
|
||||
try {
|
||||
storage?.setItem(SEED_MAP_HISTORY_KEY, JSON.stringify(entries))
|
||||
} catch {
|
||||
// Ignore quota errors: history is a convenience and must never break the map.
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeKeyList(value: unknown, limit: number): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((key): key is string => typeof key === 'string').slice(0, limit)
|
||||
}
|
||||
|
||||
function finiteTime(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
}
|
||||
8
apps/app-frontend/src/lab/seed-map/index.ts
Normal file
8
apps/app-frontend/src/lab/seed-map/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from './backend.ts'
|
||||
export * from './biomes.ts'
|
||||
export * from './features.ts'
|
||||
export * from './history.ts'
|
||||
export * from './ore-layer.ts'
|
||||
export * from './ores.ts'
|
||||
export * from './visuals.ts'
|
||||
export * from './workspace.ts'
|
||||
286
apps/app-frontend/src/lab/seed-map/ore-layer.ts
Normal file
286
apps/app-frontend/src/lab/seed-map/ore-layer.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { scanSeedMapOres } from './backend.ts'
|
||||
import type { SeedMapDimension } from './biomes.ts'
|
||||
import {
|
||||
preferSeedMapOreHit,
|
||||
SEED_MAP_ORE_CACHE_LIMIT,
|
||||
SEED_MAP_ORE_CACHE_TARGET,
|
||||
SEED_MAP_ORE_MAX_SCALE,
|
||||
type SeedMapOreChunk,
|
||||
seedMapOreColumnKey,
|
||||
type SeedMapOreHit,
|
||||
type SeedMapOreKind,
|
||||
seedMapOreScanBudget,
|
||||
} from './ores.ts'
|
||||
|
||||
export type SeedMapOreViewport = {
|
||||
minX: number
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
export type SeedMapOreLayerContext = {
|
||||
enabled: boolean
|
||||
seed: string
|
||||
version: string
|
||||
dimension: SeedMapDimension
|
||||
scale: number
|
||||
selectedOres: SeedMapOreKind[]
|
||||
yMin: number | null
|
||||
yMax: number | null
|
||||
bounds: SeedMapOreViewport
|
||||
center: { x: number; z: number }
|
||||
}
|
||||
|
||||
type OreChunkCache = Map<SeedMapOreKind, SeedMapOreHit[]>
|
||||
|
||||
const SCAN_BATCH_CHUNKS = 96
|
||||
|
||||
export function useSeedMapOreLayer(options: {
|
||||
onUpdate: () => void
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const hits = ref<SeedMapOreHit[]>([])
|
||||
const scanning = ref(false)
|
||||
const progress = ref(0)
|
||||
const scannedChunks = ref(0)
|
||||
const totalChunks = ref(0)
|
||||
const chunkCache = new Map<string, OreChunkCache>()
|
||||
let context: SeedMapOreLayerContext | null = null
|
||||
let contextKey = ''
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let refreshGeneration = 0
|
||||
let lastRebuild = 0
|
||||
|
||||
function refresh(nextContext: SeedMapOreLayerContext, delay = 180): void {
|
||||
context = cloneContext(nextContext)
|
||||
refreshGeneration++
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
const nextKey = `${nextContext.seed}|${nextContext.version}|${nextContext.dimension}`
|
||||
if (nextKey !== contextKey) {
|
||||
contextKey = nextKey
|
||||
chunkCache.clear()
|
||||
}
|
||||
rebuildVisibleHits()
|
||||
if (!canScan(nextContext)) {
|
||||
scanning.value = false
|
||||
progress.value = 0
|
||||
scannedChunks.value = 0
|
||||
totalChunks.value = 0
|
||||
return
|
||||
}
|
||||
const generation = refreshGeneration
|
||||
refreshTimer = setTimeout(() => void scanVisibleChunks(generation), delay)
|
||||
}
|
||||
|
||||
function refreshFilter(yMin: number | null, yMax: number | null): void {
|
||||
if (!context) return
|
||||
context.yMin = yMin
|
||||
context.yMax = yMax
|
||||
rebuildVisibleHits()
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
refreshGeneration++
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
if (rebuildTimer) clearTimeout(rebuildTimer)
|
||||
chunkCache.clear()
|
||||
hits.value = []
|
||||
scanning.value = false
|
||||
progress.value = 0
|
||||
scannedChunks.value = 0
|
||||
totalChunks.value = 0
|
||||
options.onUpdate()
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
clear()
|
||||
}
|
||||
|
||||
async function scanVisibleChunks(generation: number): Promise<void> {
|
||||
refreshTimer = undefined
|
||||
if (generation !== refreshGeneration || !context || !canScan(context)) return
|
||||
scanning.value = true
|
||||
progress.value = 0
|
||||
try {
|
||||
const missing = missingChunks(context)
|
||||
totalChunks.value = missing.length
|
||||
scannedChunks.value = 0
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < missing.length && generation === refreshGeneration;
|
||||
offset += SCAN_BATCH_CHUNKS
|
||||
) {
|
||||
const batch = missing.slice(offset, offset + SCAN_BATCH_CHUNKS)
|
||||
const results = await scanSeedMapOres({
|
||||
seed: context.seed,
|
||||
version: context.version,
|
||||
dimension: context.dimension,
|
||||
ores: [...context.selectedOres],
|
||||
chunks: batch.flatMap(({ cx, cz }) => [cx, cz]),
|
||||
})
|
||||
if (generation !== refreshGeneration) return
|
||||
for (const cell of results) {
|
||||
const cache = chunkCache.get(chunkKey(cell.cx, cell.cz)) ?? new Map()
|
||||
const grouped = new Map<SeedMapOreKind, SeedMapOreHit[]>()
|
||||
for (const hit of cell.hits) {
|
||||
const list = grouped.get(hit.ore) ?? []
|
||||
list.push(hit)
|
||||
grouped.set(hit.ore, list)
|
||||
}
|
||||
for (const ore of context.selectedOres) {
|
||||
cache.set(ore, grouped.get(ore) ?? [])
|
||||
}
|
||||
chunkCache.set(chunkKey(cell.cx, cell.cz), cache)
|
||||
}
|
||||
scannedChunks.value = Math.min(missing.length, offset + batch.length)
|
||||
progress.value = Math.min(1, scannedChunks.value / Math.max(1, missing.length))
|
||||
pruneCache(context)
|
||||
scheduleRebuild()
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === refreshGeneration) {
|
||||
options.onError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
} finally {
|
||||
if (generation === refreshGeneration) {
|
||||
scanning.value = false
|
||||
progress.value = 1
|
||||
rebuildVisibleHits()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingChunks(activeContext: SeedMapOreLayerContext): SeedMapOreChunk[] {
|
||||
const chunks = chunksInViewport(activeContext.bounds, activeContext.center)
|
||||
const budget = seedMapOreScanBudget(activeContext.selectedOres)
|
||||
const missing: SeedMapOreChunk[] = []
|
||||
for (const chunk of chunks) {
|
||||
const cache = chunkCache.get(chunkKey(chunk.cx, chunk.cz))
|
||||
if (activeContext.selectedOres.every((ore) => cache?.has(ore))) continue
|
||||
missing.push(chunk)
|
||||
if (missing.length >= budget) break
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
function rebuildVisibleHits(): void {
|
||||
if (!context || !canDisplay(context)) {
|
||||
hits.value = []
|
||||
options.onUpdate()
|
||||
return
|
||||
}
|
||||
const columns = new Map<string, SeedMapOreHit>()
|
||||
const selected = new Set(context.selectedOres)
|
||||
for (const chunk of chunksInViewport(context.bounds, context.center, false)) {
|
||||
const cache = chunkCache.get(chunkKey(chunk.cx, chunk.cz))
|
||||
if (!cache) continue
|
||||
for (const [ore, oreHits] of cache) {
|
||||
if (!selected.has(ore)) continue
|
||||
for (const hit of oreHits) {
|
||||
if (context.yMin !== null && hit.y < context.yMin) continue
|
||||
if (context.yMax !== null && hit.y > context.yMax) continue
|
||||
const key = seedMapOreColumnKey(hit)
|
||||
columns.set(key, preferSeedMapOreHit(columns.get(key), hit))
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.value = [...columns.values()].sort(
|
||||
(a, b) =>
|
||||
(a.x - context!.center.x) ** 2 +
|
||||
(a.z - context!.center.z) ** 2 -
|
||||
((b.x - context!.center.x) ** 2 + (b.z - context!.center.z) ** 2),
|
||||
)
|
||||
lastRebuild = performance.now()
|
||||
options.onUpdate()
|
||||
}
|
||||
|
||||
function scheduleRebuild(): void {
|
||||
if (rebuildTimer) return
|
||||
const delay = Math.max(0, 140 - (performance.now() - lastRebuild))
|
||||
rebuildTimer = setTimeout(() => {
|
||||
rebuildTimer = undefined
|
||||
rebuildVisibleHits()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function pruneCache(activeContext: SeedMapOreLayerContext): void {
|
||||
if (chunkCache.size <= SEED_MAP_ORE_CACHE_LIMIT) return
|
||||
const centerX = Math.floor(activeContext.center.x / 16)
|
||||
const centerZ = Math.floor(activeContext.center.z / 16)
|
||||
const entries = [...chunkCache.keys()].map((key) => {
|
||||
const [cx, cz] = key.split(',').map(Number)
|
||||
return { key, distance: (cx - centerX) ** 2 + (cz - centerZ) ** 2 }
|
||||
})
|
||||
entries.sort((a, b) => b.distance - a.distance)
|
||||
const removeCount = chunkCache.size - SEED_MAP_ORE_CACHE_TARGET
|
||||
for (let index = 0; index < removeCount; index++) {
|
||||
chunkCache.delete(entries[index].key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hits,
|
||||
scanning,
|
||||
progress,
|
||||
scannedChunks,
|
||||
totalChunks,
|
||||
refresh,
|
||||
refreshFilter,
|
||||
clear,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function canDisplay(context: SeedMapOreLayerContext): boolean {
|
||||
return (
|
||||
context.enabled &&
|
||||
context.dimension !== 'end' &&
|
||||
context.scale <= SEED_MAP_ORE_MAX_SCALE &&
|
||||
context.selectedOres.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function canScan(context: SeedMapOreLayerContext): boolean {
|
||||
return canDisplay(context) && context.seed.trim().length > 0
|
||||
}
|
||||
|
||||
function chunksInViewport(
|
||||
bounds: SeedMapOreViewport,
|
||||
center: { x: number; z: number },
|
||||
sort = true,
|
||||
): SeedMapOreChunk[] {
|
||||
const chunks: SeedMapOreChunk[] = []
|
||||
for (let cx = Math.floor(bounds.minX / 16); cx <= Math.floor(bounds.maxX / 16); cx++) {
|
||||
for (let cz = Math.floor(bounds.minZ / 16); cz <= Math.floor(bounds.maxZ / 16); cz++) {
|
||||
chunks.push({ cx, cz })
|
||||
}
|
||||
}
|
||||
if (sort) {
|
||||
const centerX = center.x / 16
|
||||
const centerZ = center.z / 16
|
||||
chunks.sort(
|
||||
(a, b) =>
|
||||
(a.cx - centerX) ** 2 +
|
||||
(a.cz - centerZ) ** 2 -
|
||||
((b.cx - centerX) ** 2 + (b.cz - centerZ) ** 2),
|
||||
)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function chunkKey(cx: number, cz: number): string {
|
||||
return `${cx},${cz}`
|
||||
}
|
||||
|
||||
function cloneContext(context: SeedMapOreLayerContext): SeedMapOreLayerContext {
|
||||
return {
|
||||
...context,
|
||||
selectedOres: [...context.selectedOres],
|
||||
bounds: { ...context.bounds },
|
||||
center: { ...context.center },
|
||||
}
|
||||
}
|
||||
203
apps/app-frontend/src/lab/seed-map/ores.ts
Normal file
203
apps/app-frontend/src/lab/seed-map/ores.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import type { SeedMapDimension } from './biomes.ts'
|
||||
|
||||
export type SeedMapDisplayMode = 'structures' | 'ores'
|
||||
|
||||
export type SeedMapOreKind =
|
||||
| 'diamond'
|
||||
| 'iron'
|
||||
| 'iron_vein'
|
||||
| 'copper'
|
||||
| 'copper_vein'
|
||||
| 'gold'
|
||||
| 'redstone'
|
||||
| 'lapis'
|
||||
| 'coal'
|
||||
| 'netherite'
|
||||
|
||||
export type SeedMapOreDefinition = {
|
||||
kind: SeedMapOreKind
|
||||
dimension: Exclude<SeedMapDimension, 'end'>
|
||||
yMin: number
|
||||
yMax: number
|
||||
bandRows: number
|
||||
image: string
|
||||
texture: string
|
||||
deepslateTexture?: string
|
||||
}
|
||||
|
||||
export type SeedMapOreHit = {
|
||||
ore: SeedMapOreKind
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
verified: boolean
|
||||
yMin: number
|
||||
yMax: number
|
||||
precision: number
|
||||
}
|
||||
|
||||
export type SeedMapOreChunk = {
|
||||
cx: number
|
||||
cz: number
|
||||
}
|
||||
|
||||
const ORE_ASSET_ROOT = '/seed-map-assets/ores'
|
||||
|
||||
export const SEED_MAP_ORE_MAX_SCALE = 0.25
|
||||
export const SEED_MAP_ORE_CACHE_LIMIT = 6_000
|
||||
export const SEED_MAP_ORE_CACHE_TARGET = 5_400
|
||||
|
||||
/**
|
||||
* Ore prediction replays the vanilla 1.18+ population RNG natively, so the
|
||||
* offered kinds match the scattered-ore features of those versions. The
|
||||
* `bandRows` value reflects each distribution's Y span and scales the scan
|
||||
* budget so wide bands do not overwhelm a single pass.
|
||||
*/
|
||||
export const SEED_MAP_ORES: readonly SeedMapOreDefinition[] = [
|
||||
{
|
||||
kind: 'diamond',
|
||||
dimension: 'overworld',
|
||||
yMin: -144,
|
||||
yMax: 16,
|
||||
bandRows: 160,
|
||||
image: `${ORE_ASSET_ROOT}/diamond-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/diamond_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_diamond_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'iron',
|
||||
dimension: 'overworld',
|
||||
yMin: -64,
|
||||
yMax: 319,
|
||||
bandRows: 384,
|
||||
image: `${ORE_ASSET_ROOT}/iron-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/iron_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_iron_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'iron_vein',
|
||||
dimension: 'overworld',
|
||||
yMin: -60,
|
||||
yMax: -8,
|
||||
bandRows: 128,
|
||||
image: `${ORE_ASSET_ROOT}/block-of-raw-iron.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/iron_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_iron_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'copper',
|
||||
dimension: 'overworld',
|
||||
yMin: -16,
|
||||
yMax: 112,
|
||||
bandRows: 144,
|
||||
image: `${ORE_ASSET_ROOT}/copper-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/copper_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_copper_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'copper_vein',
|
||||
dimension: 'overworld',
|
||||
yMin: 0,
|
||||
yMax: 50,
|
||||
bandRows: 128,
|
||||
image: `${ORE_ASSET_ROOT}/block-of-raw-copper.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/copper_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_copper_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'gold',
|
||||
dimension: 'overworld',
|
||||
yMin: -64,
|
||||
yMax: 32,
|
||||
bandRows: 104,
|
||||
image: `${ORE_ASSET_ROOT}/gold-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/gold_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_gold_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'redstone',
|
||||
dimension: 'overworld',
|
||||
yMin: -96,
|
||||
yMax: 15,
|
||||
bandRows: 112,
|
||||
image: `${ORE_ASSET_ROOT}/redstone-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/redstone_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_redstone_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'lapis',
|
||||
dimension: 'overworld',
|
||||
yMin: -64,
|
||||
yMax: 64,
|
||||
bandRows: 136,
|
||||
image: `${ORE_ASSET_ROOT}/lapis-lazuli-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/lapis_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_lapis_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'coal',
|
||||
dimension: 'overworld',
|
||||
yMin: 0,
|
||||
yMax: 319,
|
||||
bandRows: 328,
|
||||
image: `${ORE_ASSET_ROOT}/coal-ore.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/coal_ore.png`,
|
||||
deepslateTexture: `${ORE_ASSET_ROOT}/ore-flat/deepslate_coal_ore.png`,
|
||||
},
|
||||
{
|
||||
kind: 'netherite',
|
||||
dimension: 'nether',
|
||||
yMin: 8,
|
||||
yMax: 119,
|
||||
bandRows: 128,
|
||||
image: `${ORE_ASSET_ROOT}/ancient-debris.png`,
|
||||
texture: `${ORE_ASSET_ROOT}/ore-flat/ancient_debris_side.png`,
|
||||
},
|
||||
]
|
||||
|
||||
const ORE_BY_KIND = new Map(SEED_MAP_ORES.map((ore) => [ore.kind, ore]))
|
||||
|
||||
export function seedMapOreDefinition(kind: SeedMapOreKind): SeedMapOreDefinition {
|
||||
const definition = ORE_BY_KIND.get(kind)
|
||||
if (!definition) throw new Error(`Unknown seed-map ore: ${kind}`)
|
||||
return definition
|
||||
}
|
||||
|
||||
export function seedMapOresForDimension(dimension: SeedMapDimension): SeedMapOreDefinition[] {
|
||||
if (dimension === 'end') return []
|
||||
return SEED_MAP_ORES.filter((ore) => ore.dimension === dimension)
|
||||
}
|
||||
|
||||
export function seedMapOreScanBudget(kinds: readonly SeedMapOreKind[]): number {
|
||||
const maxBandRows = Math.max(1, ...kinds.map((kind) => seedMapOreDefinition(kind).bandRows))
|
||||
return Math.max(60, Math.round(33_600 / maxBandRows))
|
||||
}
|
||||
|
||||
export function seedMapOreYRange(kinds: readonly SeedMapOreKind[]): [number, number] {
|
||||
if (kinds.length === 0) return [-64, 319]
|
||||
return [
|
||||
Math.min(...kinds.map((kind) => seedMapOreDefinition(kind).yMin)),
|
||||
Math.max(...kinds.map((kind) => seedMapOreDefinition(kind).yMax)),
|
||||
]
|
||||
}
|
||||
|
||||
export function seedMapOreKey(hit: SeedMapOreHit): string {
|
||||
return `${hit.ore}:${hit.x}:${hit.y}:${hit.z}`
|
||||
}
|
||||
|
||||
export function seedMapOreColumnKey(hit: Pick<SeedMapOreHit, 'ore' | 'x' | 'z'>): string {
|
||||
return `${hit.ore}:${hit.x}:${hit.z}`
|
||||
}
|
||||
|
||||
export function preferSeedMapOreHit(
|
||||
current: SeedMapOreHit | undefined,
|
||||
candidate: SeedMapOreHit,
|
||||
): SeedMapOreHit {
|
||||
if (!current) return candidate
|
||||
if (candidate.verified !== current.verified) return candidate.verified ? candidate : current
|
||||
if (candidate.precision !== current.precision)
|
||||
return candidate.precision > current.precision ? candidate : current
|
||||
const currentRange = current.yMax - current.yMin
|
||||
const candidateRange = candidate.yMax - candidate.yMin
|
||||
return candidateRange < currentRange ? candidate : current
|
||||
}
|
||||
103
apps/app-frontend/src/lab/seed-map/visuals.ts
Normal file
103
apps/app-frontend/src/lab/seed-map/visuals.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
CompassIcon,
|
||||
DatabaseIcon,
|
||||
GridIcon,
|
||||
HomeIcon,
|
||||
LandmarkIcon,
|
||||
LayersIcon,
|
||||
PickaxeIcon,
|
||||
PinIcon,
|
||||
TagCategoryFlagIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { SeedMapFeatureKind } from './features.ts'
|
||||
|
||||
export const SEED_MAP_STRUCTURE_ASSET_ROOT = '/seed-map-assets/structures'
|
||||
|
||||
export const SEED_MAP_END_CITY_IMAGE_SOURCES = {
|
||||
ship: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/end_city_ship.webp`,
|
||||
noShip: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/end_city_no_ship.webp`,
|
||||
} as const
|
||||
|
||||
export const SEED_MAP_FEATURE_ICONS: Record<SeedMapFeatureKind, Component> = {
|
||||
village: HomeIcon,
|
||||
outpost: TagCategoryFlagIcon,
|
||||
shipwreck: CompassIcon,
|
||||
monument: LandmarkIcon,
|
||||
mansion: HomeIcon,
|
||||
'ancient-city': DatabaseIcon,
|
||||
'trail-ruins': LayersIcon,
|
||||
'trial-chambers': LandmarkIcon,
|
||||
'ruined-portal': WorldIcon,
|
||||
stronghold: CompassIcon,
|
||||
'slime-chunk': GridIcon,
|
||||
'desert-pyramid': LandmarkIcon,
|
||||
'jungle-temple': LandmarkIcon,
|
||||
'swamp-hut': HomeIcon,
|
||||
igloo: HomeIcon,
|
||||
'ocean-ruin': LayersIcon,
|
||||
'buried-treasure': PinIcon,
|
||||
mineshaft: PickaxeIcon,
|
||||
'desert-well': CompassIcon,
|
||||
geode: DatabaseIcon,
|
||||
fortress: LandmarkIcon,
|
||||
bastion: HomeIcon,
|
||||
'end-city': HomeIcon,
|
||||
'end-gateway': WorldIcon,
|
||||
}
|
||||
|
||||
export const SEED_MAP_FEATURE_IMAGE_SOURCES: Partial<Record<SeedMapFeatureKind, string>> = {
|
||||
village: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/village.webp`,
|
||||
outpost: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/pillager_outpost.webp`,
|
||||
shipwreck: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/shipwreck.webp`,
|
||||
monument: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/ocean_monument.webp`,
|
||||
mansion: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/woodland_mansion.webp`,
|
||||
'ancient-city': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/ancient_city.webp`,
|
||||
'trail-ruins': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/trail_ruin.webp`,
|
||||
'trial-chambers': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/trial_chambers.webp`,
|
||||
'ruined-portal': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/ruined_portal.webp`,
|
||||
stronghold: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/stronghold.webp`,
|
||||
'slime-chunk': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/slime_chunks.webp`,
|
||||
'desert-pyramid': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/desert_pyramid.webp`,
|
||||
'jungle-temple': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/jungle_temple.webp`,
|
||||
'swamp-hut': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/swamp_hut.webp`,
|
||||
igloo: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/igloo.webp`,
|
||||
'ocean-ruin': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/ocean_ruin.webp`,
|
||||
'buried-treasure': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/buried_treasure.webp`,
|
||||
mineshaft: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/mineshaft.webp`,
|
||||
'desert-well': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/desert_well.webp`,
|
||||
geode: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/amethyst_geode.webp`,
|
||||
fortress: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/nether_fortress.webp`,
|
||||
bastion: `${SEED_MAP_STRUCTURE_ASSET_ROOT}/bastion_remnant.webp`,
|
||||
'end-city': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/end_city.webp`,
|
||||
'end-gateway': `${SEED_MAP_STRUCTURE_ASSET_ROOT}/end_gateway.webp`,
|
||||
}
|
||||
|
||||
export const SEED_MAP_FEATURE_COLORS: Record<SeedMapFeatureKind, string> = {
|
||||
village: '#D58B35',
|
||||
outpost: '#C94B52',
|
||||
shipwreck: '#3B9CC6',
|
||||
monument: '#34A99A',
|
||||
mansion: '#8C67C5',
|
||||
'ancient-city': '#6677D9',
|
||||
'trail-ruins': '#C96F3C',
|
||||
'trial-chambers': '#4DAE72',
|
||||
'ruined-portal': '#C95B92',
|
||||
stronghold: '#8793A4',
|
||||
'slime-chunk': '#83B735',
|
||||
'desert-pyramid': '#D0A23E',
|
||||
'jungle-temple': '#4B9A56',
|
||||
'swamp-hut': '#6A8D58',
|
||||
igloo: '#80B6CA',
|
||||
'ocean-ruin': '#4F9EAB',
|
||||
'buried-treasure': '#E0B84E',
|
||||
mineshaft: '#9B7657',
|
||||
'desert-well': '#D6AF62',
|
||||
geode: '#9969C7',
|
||||
fortress: '#A94A51',
|
||||
bastion: '#7E6658',
|
||||
'end-city': '#A57BC5',
|
||||
'end-gateway': '#70559F',
|
||||
}
|
||||
205
apps/app-frontend/src/lab/seed-map/workspace.test.ts
Normal file
205
apps/app-frontend/src/lab/seed-map/workspace.test.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
fallbackSeedMapProfiles,
|
||||
SEED_MAP_DEFAULT_VERSION,
|
||||
seedMapTileConcurrency,
|
||||
} from './backend.ts'
|
||||
import { SEED_MAP_BIOME_NAMES, SEED_MAP_BIOMES, seedMapBiomeGroups } from './biomes.ts'
|
||||
import { featureMask, SEED_MAP_FEATURES, visibleFeatureDefinitions } from './features.ts'
|
||||
import {
|
||||
applyShareQuery,
|
||||
createDefaultSeedMapWorkspace,
|
||||
isCurrentSeedMapEpoch,
|
||||
sanitizeSeedMapWorkspace,
|
||||
SEED_MAP_MIN_ZOOM,
|
||||
} from './workspace.ts'
|
||||
|
||||
test('seed map starts near spawn with a lightweight layer set', () => {
|
||||
const workspace = createDefaultSeedMapWorkspace()
|
||||
assert.equal(workspace.zoom, 1)
|
||||
assert.equal(workspace.terrainEstimation, true)
|
||||
assert.equal(workspace.gameVersion, SEED_MAP_DEFAULT_VERSION)
|
||||
assert.equal(workspace.elevation, 128)
|
||||
assert.deepEqual(workspace.visibleFeatures, [
|
||||
'village',
|
||||
'ruined-portal',
|
||||
'stronghold',
|
||||
'fortress',
|
||||
'bastion',
|
||||
'end-city',
|
||||
])
|
||||
assert.equal(workspace.displayMode, 'structures')
|
||||
assert.deepEqual(workspace.selectedOres, ['diamond'])
|
||||
})
|
||||
|
||||
test('seed map workspace recovers from invalid persisted values', () => {
|
||||
const state = sanitizeSeedMapWorkspace({
|
||||
seed: ' mushroom island ',
|
||||
edition: 'java',
|
||||
zoom: 99,
|
||||
center: { x: Number.NaN, z: 72 },
|
||||
visibleFeatures: ['village', 'invalid'],
|
||||
})
|
||||
assert.equal(state.seed, 'mushroom island')
|
||||
assert.equal(state.zoom, 4)
|
||||
assert.deepEqual(state.center, { x: 0, z: 72 })
|
||||
assert.deepEqual(state.visibleFeatures, ['village'])
|
||||
assert.equal(state.showSpawn, true)
|
||||
assert.deepEqual(state.highlightedBiomes, [])
|
||||
})
|
||||
|
||||
test('seed map uses fixed surface and underground elevation slices', () => {
|
||||
assert.equal(sanitizeSeedMapWorkspace({ elevation: 62 }).elevation, 128)
|
||||
assert.equal(sanitizeSeedMapWorkspace({ elevation: 128 }).elevation, 128)
|
||||
assert.equal(sanitizeSeedMapWorkspace({ elevation: 16 }).elevation, 16)
|
||||
assert.equal(sanitizeSeedMapWorkspace({ elevation: -32 }).elevation, -32)
|
||||
})
|
||||
|
||||
test('seed map keeps every offered ore kind and drops unknown ones', () => {
|
||||
const state = sanitizeSeedMapWorkspace({
|
||||
...createDefaultSeedMapWorkspace(),
|
||||
selectedOres: ['diamond', 'iron_vein', 'copper_vein', 'coal', 'bogus'],
|
||||
})
|
||||
assert.deepEqual(state.selectedOres, ['diamond', 'iron_vein', 'copper_vein', 'coal'])
|
||||
})
|
||||
|
||||
test('seed map supports a closer maximum zoom', () => {
|
||||
const close = sanitizeSeedMapWorkspace({ zoom: SEED_MAP_MIN_ZOOM })
|
||||
const tooClose = sanitizeSeedMapWorkspace({ zoom: SEED_MAP_MIN_ZOOM - 10 })
|
||||
assert.equal(close.zoom, SEED_MAP_MIN_ZOOM)
|
||||
assert.equal(tooClose.zoom, SEED_MAP_MIN_ZOOM)
|
||||
})
|
||||
|
||||
test('seed map exposes every categorized cubiomes biome exactly once', () => {
|
||||
const groups = seedMapBiomeGroups()
|
||||
assert.equal(SEED_MAP_BIOMES.length, 65)
|
||||
assert.equal(groups.length, 17)
|
||||
assert.equal(groups.flatMap((group) => group.biomes).length, 65)
|
||||
assert.equal(new Set(SEED_MAP_BIOMES.map((biome) => biome.id)).size, 65)
|
||||
assert.deepEqual(
|
||||
groups.filter((group) => group.dimension === 'nether').map((group) => group.category),
|
||||
['nether'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
groups.filter((group) => group.dimension === 'end').map((group) => group.category),
|
||||
['end'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
SEED_MAP_BIOMES.filter((biome) => !SEED_MAP_BIOME_NAMES[biome.id]),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('seed map migrates legacy biome highlighting and terrain defaults', () => {
|
||||
const state = sanitizeSeedMapWorkspace({
|
||||
version: 1,
|
||||
highlightBiomeEnabled: true,
|
||||
highlightedBiome: 21,
|
||||
terrainEstimation: false,
|
||||
})
|
||||
assert.deepEqual(state.highlightedBiomes, [21])
|
||||
assert.equal(state.highlightBiomeEnabled, true)
|
||||
assert.equal(state.terrainEstimation, true)
|
||||
assert.equal(state.version, 3)
|
||||
})
|
||||
|
||||
test('seed map share queries preserve useful map state', () => {
|
||||
const workspace = createDefaultSeedMapWorkspace()
|
||||
const shared = applyShareQuery(
|
||||
{
|
||||
seed: '1234',
|
||||
edition: 'java-large-biomes',
|
||||
version: '1.21.1',
|
||||
dimension: 'nether',
|
||||
mode: 'ores',
|
||||
ores: 'netherite',
|
||||
yMin: '12',
|
||||
yMax: '24',
|
||||
x: '-500',
|
||||
z: '312',
|
||||
zoom: '1.25',
|
||||
},
|
||||
workspace,
|
||||
)
|
||||
assert.equal(shared.seed, '1234')
|
||||
assert.equal(shared.edition, 'java-large-biomes')
|
||||
assert.equal(shared.gameVersion, '1.21.1')
|
||||
assert.equal(shared.dimension, 'nether')
|
||||
assert.equal(shared.displayMode, 'ores')
|
||||
assert.deepEqual(shared.selectedOres, ['netherite'])
|
||||
assert.equal(shared.oreYMin, 12)
|
||||
assert.equal(shared.oreYMax, 24)
|
||||
assert.equal(shared.zoom, 1.25)
|
||||
assert.deepEqual(shared.center, { x: -500, z: 312 })
|
||||
})
|
||||
|
||||
test('fallback profiles gate dimensions, ores, and large biomes by version', () => {
|
||||
const profiles = fallbackSeedMapProfiles()
|
||||
const modern = profiles.find(
|
||||
(profile) => profile.edition === 'java' && profile.version === '1.21.3',
|
||||
)
|
||||
assert.deepEqual(modern?.dimensions, ['overworld', 'nether', 'end'])
|
||||
assert.equal(modern?.ores, true)
|
||||
const legacy = profiles.find((profile) => profile.edition === 'java' && profile.version === '1.9')
|
||||
assert.deepEqual(legacy?.dimensions, ['overworld', 'end'])
|
||||
assert.equal(legacy?.ores, false)
|
||||
const oldest = profiles.find((profile) => profile.edition === 'java' && profile.version === '1.8')
|
||||
assert.deepEqual(oldest?.dimensions, ['overworld'])
|
||||
assert.ok(
|
||||
!profiles.some(
|
||||
(profile) => profile.edition === 'java-large-biomes' && profile.version === '1.0',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('feature masks match the selected map layers', () => {
|
||||
const mask = featureMask(['village', 'stronghold', 'slime-chunk'])
|
||||
const expected = SEED_MAP_FEATURES.filter((feature) =>
|
||||
['village', 'stronghold', 'slime-chunk'].includes(feature.kind),
|
||||
).reduce((value, feature) => value | feature.mask, 0)
|
||||
assert.equal(mask, expected)
|
||||
})
|
||||
|
||||
test('seed map ignores a response from an older tile request epoch', () => {
|
||||
assert.equal(isCurrentSeedMapEpoch(8, 8), true)
|
||||
assert.equal(isCurrentSeedMapEpoch(7, 8), false)
|
||||
})
|
||||
|
||||
test('seed map tile concurrency reserves capacity for the interface', () => {
|
||||
assert.equal(seedMapTileConcurrency(4, 4), 2)
|
||||
assert.equal(seedMapTileConcurrency(4, 8), 6)
|
||||
assert.equal(seedMapTileConcurrency(4, 16), 8)
|
||||
assert.equal(seedMapTileConcurrency(1, 16), 4)
|
||||
})
|
||||
|
||||
test('dense feature layers are omitted until the map is close enough', () => {
|
||||
const close = visibleFeatureDefinitions(['slime-chunk', 'village', 'stronghold'], 'overworld', 4)
|
||||
const medium = visibleFeatureDefinitions(
|
||||
['slime-chunk', 'village', 'stronghold'],
|
||||
'overworld',
|
||||
64,
|
||||
)
|
||||
const far = visibleFeatureDefinitions(['slime-chunk', 'village', 'stronghold'], 'overworld', 256)
|
||||
assert.deepEqual(
|
||||
close.map((feature) => feature.kind),
|
||||
['slime-chunk', 'village', 'stronghold'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
medium.map((feature) => feature.kind),
|
||||
['stronghold'],
|
||||
)
|
||||
assert.deepEqual(far, [])
|
||||
})
|
||||
|
||||
test('slime chunks are the first layer in every dimension', () => {
|
||||
for (const dimension of ['overworld', 'nether', 'end'] as const) {
|
||||
const visible = visibleFeatureDefinitions(
|
||||
['village', 'fortress', 'end-city', 'slime-chunk'],
|
||||
dimension,
|
||||
4,
|
||||
)
|
||||
assert.equal(visible[0]?.kind, 'slime-chunk')
|
||||
}
|
||||
})
|
||||
299
apps/app-frontend/src/lab/seed-map/workspace.ts
Normal file
299
apps/app-frontend/src/lab/seed-map/workspace.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import { SEED_MAP_DEFAULT_VERSION, type SeedMapEdition } from './backend.ts'
|
||||
import { SEED_MAP_BIOMES, type SeedMapDimension } from './biomes.ts'
|
||||
import { SEED_MAP_FEATURES, type SeedMapFeatureKind } from './features.ts'
|
||||
import { SEED_MAP_ORES, type SeedMapDisplayMode, type SeedMapOreKind } from './ores.ts'
|
||||
|
||||
export type SeedMapMarker = {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
z: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export type SeedMapWorkspace = {
|
||||
version: 3
|
||||
seed: string
|
||||
edition: SeedMapEdition
|
||||
gameVersion: string
|
||||
dimension: SeedMapDimension
|
||||
displayMode: SeedMapDisplayMode
|
||||
center: { x: number; z: number }
|
||||
zoom: number
|
||||
elevation: number
|
||||
showGrid: boolean
|
||||
showChunkCoordinates: boolean
|
||||
showSpawn: boolean
|
||||
terrainEstimation: boolean
|
||||
contourLines: boolean
|
||||
highlightBiomeEnabled: boolean
|
||||
highlightedBiomes: number[]
|
||||
visibleFeatures: SeedMapFeatureKind[]
|
||||
selectedOres: SeedMapOreKind[]
|
||||
oreYMin: number | null
|
||||
oreYMax: number | null
|
||||
markers: SeedMapMarker[]
|
||||
completedFeatures: string[]
|
||||
completedOres: string[]
|
||||
}
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
export const SEED_MAP_STORAGE_KEY = 'axolotl.lab.seed-map.v1'
|
||||
|
||||
export const SEED_MAP_SCALES = [1, 4, 16, 64, 256] as const
|
||||
export const SEED_MAP_MIN_ZOOM = -2
|
||||
/** Fixed vertical slices exposed by the seed-map elevation selector. */
|
||||
export const SEED_MAP_ELEVATIONS = [128, 16, -32] as const
|
||||
|
||||
export function createDefaultSeedMapWorkspace(): SeedMapWorkspace {
|
||||
return {
|
||||
version: 3,
|
||||
seed: '10292992',
|
||||
edition: 'java',
|
||||
gameVersion: SEED_MAP_DEFAULT_VERSION,
|
||||
dimension: 'overworld',
|
||||
displayMode: 'structures',
|
||||
center: { x: 0, z: 0 },
|
||||
zoom: 1,
|
||||
elevation: SEED_MAP_ELEVATIONS[0],
|
||||
showGrid: false,
|
||||
showChunkCoordinates: false,
|
||||
showSpawn: true,
|
||||
terrainEstimation: true,
|
||||
contourLines: false,
|
||||
highlightBiomeEnabled: false,
|
||||
highlightedBiomes: [],
|
||||
visibleFeatures: ['village', 'ruined-portal', 'stronghold', 'fortress', 'bastion', 'end-city'],
|
||||
selectedOres: ['diamond'],
|
||||
oreYMin: null,
|
||||
oreYMax: null,
|
||||
markers: [],
|
||||
completedFeatures: [],
|
||||
completedOres: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSeedMapWorkspace(
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): SeedMapWorkspace {
|
||||
const fallback = createDefaultSeedMapWorkspace()
|
||||
if (!storage) return fallback
|
||||
try {
|
||||
const raw = storage.getItem(SEED_MAP_STORAGE_KEY)
|
||||
return raw ? sanitizeSeedMapWorkspace(JSON.parse(raw), fallback) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSeedMapWorkspace(
|
||||
workspace: SeedMapWorkspace,
|
||||
storage: StorageLike | null = getBrowserStorage(),
|
||||
): void {
|
||||
if (storage)
|
||||
storage.setItem(SEED_MAP_STORAGE_KEY, JSON.stringify(sanitizeSeedMapWorkspace(workspace)))
|
||||
}
|
||||
|
||||
export function sanitizeSeedMapWorkspace(
|
||||
value: unknown,
|
||||
fallback = createDefaultSeedMapWorkspace(),
|
||||
): SeedMapWorkspace {
|
||||
if (!value || typeof value !== 'object') return fallback
|
||||
const source = value as Partial<Omit<SeedMapWorkspace, 'version'>> & {
|
||||
version?: unknown
|
||||
highlightedBiome?: unknown
|
||||
}
|
||||
const center =
|
||||
source.center && typeof source.center === 'object' ? source.center : fallback.center
|
||||
const zoom = finiteNumber(source.zoom, fallback.zoom)
|
||||
const availableBiomeIds = new Set(SEED_MAP_BIOMES.map((biome) => biome.id))
|
||||
const highlightedBiomes = (
|
||||
Array.isArray(source.highlightedBiomes)
|
||||
? source.highlightedBiomes
|
||||
: typeof source.highlightedBiome === 'number'
|
||||
? [source.highlightedBiome]
|
||||
: []
|
||||
)
|
||||
.filter(
|
||||
(biome, index, values): biome is number =>
|
||||
typeof biome === 'number' &&
|
||||
Number.isInteger(biome) &&
|
||||
availableBiomeIds.has(biome) &&
|
||||
values.indexOf(biome) === index,
|
||||
)
|
||||
.slice(0, SEED_MAP_BIOMES.length)
|
||||
const selectedOres = sanitizeOreKinds(source.selectedOres)
|
||||
const oreYMin = nullableInteger(source.oreYMin, -144, 319)
|
||||
const oreYMax = nullableInteger(source.oreYMax, -144, 319)
|
||||
return {
|
||||
version: 3,
|
||||
seed:
|
||||
typeof source.seed === 'string' && source.seed.trim()
|
||||
? source.seed.trim().slice(0, 256)
|
||||
: fallback.seed,
|
||||
edition: source.edition === 'java-large-biomes' ? source.edition : 'java',
|
||||
gameVersion: typeof source.gameVersion === 'string' ? source.gameVersion : fallback.gameVersion,
|
||||
dimension:
|
||||
source.dimension === 'nether' || source.dimension === 'end' ? source.dimension : 'overworld',
|
||||
displayMode: source.displayMode === 'ores' ? 'ores' : 'structures',
|
||||
center: {
|
||||
x: finiteNumber(center.x, fallback.center.x),
|
||||
z: finiteNumber(center.z, fallback.center.z),
|
||||
},
|
||||
zoom:
|
||||
Math.round(Math.min(Math.max(zoom, SEED_MAP_MIN_ZOOM), SEED_MAP_SCALES.length - 1) * 1_000) /
|
||||
1_000,
|
||||
elevation: sanitizeElevation(source.elevation, fallback.elevation),
|
||||
showGrid: source.showGrid === true,
|
||||
showChunkCoordinates: source.showChunkCoordinates === true,
|
||||
showSpawn: source.showSpawn !== false,
|
||||
terrainEstimation: source.version === 1 ? true : source.terrainEstimation !== false,
|
||||
contourLines: source.contourLines === true,
|
||||
highlightBiomeEnabled: source.highlightBiomeEnabled === true && highlightedBiomes.length > 0,
|
||||
highlightedBiomes,
|
||||
visibleFeatures: sanitizeFeatureKinds(source.visibleFeatures),
|
||||
selectedOres: selectedOres.length > 0 ? selectedOres : [...fallback.selectedOres],
|
||||
oreYMin: oreYMin !== null && oreYMax !== null && oreYMin > oreYMax ? oreYMax : oreYMin,
|
||||
oreYMax: oreYMin !== null && oreYMax !== null && oreYMax < oreYMin ? oreYMin : oreYMax,
|
||||
markers: sanitizeMarkers(source.markers),
|
||||
completedFeatures: Array.isArray(source.completedFeatures)
|
||||
? source.completedFeatures
|
||||
.filter((key): key is string => typeof key === 'string')
|
||||
.slice(0, 2_000)
|
||||
: [],
|
||||
completedOres: Array.isArray(source.completedOres)
|
||||
? source.completedOres
|
||||
.filter((key): key is string => typeof key === 'string')
|
||||
.slice(0, 10_000)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createShareQuery(workspace: SeedMapWorkspace): Record<string, string> {
|
||||
return {
|
||||
seed: workspace.seed,
|
||||
edition: workspace.edition,
|
||||
version: workspace.gameVersion,
|
||||
dimension: workspace.dimension,
|
||||
mode: workspace.displayMode,
|
||||
ores: workspace.selectedOres.join(','),
|
||||
yMin: workspace.oreYMin === null ? '' : String(workspace.oreYMin),
|
||||
yMax: workspace.oreYMax === null ? '' : String(workspace.oreYMax),
|
||||
x: String(Math.round(workspace.center.x)),
|
||||
z: String(Math.round(workspace.center.z)),
|
||||
zoom: String(workspace.zoom),
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentSeedMapEpoch(responseEpoch: number, currentEpoch: number): boolean {
|
||||
return responseEpoch === currentEpoch
|
||||
}
|
||||
|
||||
export function applyShareQuery(
|
||||
query: Record<string, unknown>,
|
||||
workspace = createDefaultSeedMapWorkspace(),
|
||||
): SeedMapWorkspace {
|
||||
return sanitizeSeedMapWorkspace({
|
||||
...workspace,
|
||||
seed: typeof query.seed === 'string' ? query.seed : workspace.seed,
|
||||
edition: query.edition,
|
||||
gameVersion: typeof query.version === 'string' ? query.version : workspace.gameVersion,
|
||||
dimension: query.dimension,
|
||||
displayMode: query.mode,
|
||||
selectedOres:
|
||||
typeof query.ores === 'string' && query.ores.length > 0
|
||||
? query.ores.split(',')
|
||||
: workspace.selectedOres,
|
||||
oreYMin: parseNullableQueryNumber(query.yMin, workspace.oreYMin),
|
||||
oreYMax: parseNullableQueryNumber(query.yMax, workspace.oreYMax),
|
||||
center: {
|
||||
x: parseQueryNumber(query.x, workspace.center.x),
|
||||
z: parseQueryNumber(query.z, workspace.center.z),
|
||||
},
|
||||
zoom: parseQueryNumber(query.zoom, workspace.zoom),
|
||||
})
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function sanitizeElevation(value: unknown, fallback: number): number {
|
||||
const parsed = Math.round(finiteNumber(value, Number.NaN))
|
||||
if (SEED_MAP_ELEVATIONS.some((elevation) => elevation === parsed)) return parsed
|
||||
const normalizedFallback = Math.round(finiteNumber(fallback, SEED_MAP_ELEVATIONS[0]))
|
||||
return SEED_MAP_ELEVATIONS.some((elevation) => elevation === normalizedFallback)
|
||||
? normalizedFallback
|
||||
: SEED_MAP_ELEVATIONS[0]
|
||||
}
|
||||
|
||||
function parseQueryNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'string') return fallback
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function parseNullableQueryNumber(value: unknown, fallback: number | null): number | null {
|
||||
if (value === '') return null
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return fallback
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown, min: number, max: number): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return Math.min(max, Math.max(min, Math.round(parsed)))
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
}
|
||||
|
||||
function sanitizeFeatureKinds(value: unknown): SeedMapFeatureKind[] {
|
||||
if (!Array.isArray(value)) return createDefaultSeedMapWorkspace().visibleFeatures
|
||||
const available = new Set(SEED_MAP_FEATURES.map((feature) => feature.kind))
|
||||
return value.filter(
|
||||
(kind): kind is SeedMapFeatureKind =>
|
||||
typeof kind === 'string' && available.has(kind as SeedMapFeatureKind),
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeOreKinds(value: unknown): SeedMapOreKind[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const available = new Set(SEED_MAP_ORES.map((ore) => ore.kind))
|
||||
return value
|
||||
.filter(
|
||||
(ore, index, values): ore is SeedMapOreKind =>
|
||||
typeof ore === 'string' &&
|
||||
available.has(ore as SeedMapOreKind) &&
|
||||
values.indexOf(ore) === index,
|
||||
)
|
||||
.slice(0, SEED_MAP_ORES.length)
|
||||
}
|
||||
|
||||
function sanitizeMarkers(value: unknown): SeedMapMarker[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.flatMap((marker) => {
|
||||
if (!marker || typeof marker !== 'object') return []
|
||||
const source = marker as Partial<SeedMapMarker>
|
||||
if (typeof source.id !== 'string' || typeof source.name !== 'string') return []
|
||||
if (!Number.isFinite(source.x) || !Number.isFinite(source.z)) return []
|
||||
return [
|
||||
{
|
||||
id: source.id.slice(0, 128),
|
||||
name: source.name.slice(0, 128),
|
||||
x: Math.round(source.x as number),
|
||||
z: Math.round(source.z as number),
|
||||
color:
|
||||
typeof source.color === 'string' && /^#[0-9A-Fa-f]{6}$/.test(source.color)
|
||||
? source.color
|
||||
: '#22C55E',
|
||||
},
|
||||
]
|
||||
})
|
||||
.slice(0, 1_000)
|
||||
}
|
||||
Reference in New Issue
Block a user