@terrafirmagreg-team/game 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +58 -3
  2. package/src/components/entry/atlas-sprite.tsx +57 -0
  3. package/src/components/entry/copy-id-menu.tsx +55 -0
  4. package/src/components/entry/entry.tsx +229 -0
  5. package/src/components/entry/frame.tsx +32 -0
  6. package/src/components/entry/group.tsx +180 -0
  7. package/src/components/entry/icon/tabs.tsx +118 -0
  8. package/src/components/entry/icon.tsx +187 -0
  9. package/src/components/entry/list.tsx +70 -0
  10. package/src/components/entry/mc-text.tsx +92 -0
  11. package/src/components/entry/stack-count.tsx +14 -0
  12. package/src/components/entry/tooltip.tsx +129 -0
  13. package/src/components/mdx/item-icon.tsx +69 -0
  14. package/src/components/preview-icon.tsx +25 -0
  15. package/src/hooks/use-dump-location.ts +18 -0
  16. package/src/hooks/use-entry-context.ts +35 -0
  17. package/src/hooks/use-icon-source.ts +297 -0
  18. package/src/hooks/use-lang-map.ts +18 -0
  19. package/src/lib/dump/assets/atlas/dto.ts +49 -0
  20. package/src/lib/dump/assets/atlas/lookup.ts +206 -0
  21. package/src/lib/dump/assets/atlas/sprites.ts +130 -0
  22. package/src/lib/dump/assets/lang/load.ts +17 -0
  23. package/src/lib/dump/assets/lang/names.ts +244 -0
  24. package/src/lib/dump/assets/urls.ts +143 -0
  25. package/src/lib/dump/client/atlases.ts +83 -0
  26. package/src/lib/dump/client/dump.ts +188 -0
  27. package/src/lib/dump/client/groups.ts +31 -0
  28. package/src/lib/dump/client/keybinds.ts +11 -0
  29. package/src/lib/dump/client/mods.ts +16 -0
  30. package/src/lib/dump/client/registry.ts +125 -0
  31. package/src/lib/dump/client/tabs.ts +27 -0
  32. package/src/lib/dump/client/tags.ts +64 -0
  33. package/src/lib/dump/contract/kind.ts +61 -0
  34. package/src/lib/dump/contract/read.ts +77 -0
  35. package/src/lib/dump/contract/ref.ts +262 -0
  36. package/src/lib/dump/contract/text.ts +86 -0
  37. package/src/lib/dump/data/registry/dto.ts +56 -0
  38. package/src/lib/dump/data/registry/parse.ts +291 -0
  39. package/src/lib/dump/data/tab/dto.ts +13 -0
  40. package/src/lib/dump/data/tab/parse.ts +26 -0
  41. package/src/lib/dump/data/tag/dto.ts +7 -0
  42. package/src/lib/dump/data/tag/parse.ts +57 -0
  43. package/src/lib/dump/gen/data/guides/book.ts +56 -0
  44. package/src/lib/dump/gen/data/guides/category.ts +30 -0
  45. package/src/lib/dump/gen/data/guides/entry.ts +48 -0
  46. package/src/lib/dump/gen/data/models/model.ts +3 -0
  47. package/src/lib/dump/gen/data/quests/chapter.ts +126 -0
  48. package/src/lib/dump/gen/data/quests/chapter_groups.ts +11 -0
  49. package/src/lib/dump/gen/data/quests/data.ts +40 -0
  50. package/src/lib/dump/gen/data/recipes/recipe.ts +78 -0
  51. package/src/lib/dump/gen/index.ts +20 -0
  52. package/src/lib/dump/gen/keybinds.ts +3 -0
  53. package/src/lib/dump/gen/lang.ts +3 -0
  54. package/src/lib/dump/gen/manifest.ts +59 -0
  55. package/src/lib/dump/gen/registry/blocks.ts +60 -0
  56. package/src/lib/dump/gen/registry/entities.ts +49 -0
  57. package/src/lib/dump/gen/registry/fluids.ts +48 -0
  58. package/src/lib/dump/gen/registry/groups.ts +14 -0
  59. package/src/lib/dump/gen/registry/items.ts +51 -0
  60. package/src/lib/dump/gen/registry/recipe_categories.ts +64 -0
  61. package/src/lib/dump/gen/registry/recipes.ts +3 -0
  62. package/src/lib/dump/gen/registry/tabs.ts +46 -0
  63. package/src/lib/dump/gen/registry/tags.ts +11 -0
  64. package/src/lib/dump/json-reader.ts +231 -0
  65. package/src/lib/dump/manifest/dto.ts +11 -0
  66. package/src/lib/dump/manifest/parse.ts +167 -0
  67. package/src/lib/dump/mods/dto.ts +40 -0
  68. package/src/lib/dump/mods/index.ts +81 -0
  69. package/src/lib/dump/mods/load.ts +61 -0
  70. package/src/lib/dump/paths.ts +171 -0
  71. package/src/lib/dump/reader.ts +15 -0
  72. package/src/lib/entry/display.ts +4 -0
  73. package/src/lib/entry/entry.ts +57 -0
  74. package/src/lib/entry/formula.ts +226 -0
  75. package/src/lib/entry/href.ts +148 -0
  76. package/src/lib/entry/icon-load.ts +195 -0
  77. package/src/lib/entry/icon.ts +10 -0
  78. package/src/lib/entry/tooltip.ts +60 -0
  79. package/src/lib/preview.ts +18 -0
  80. package/src/lib/search.ts +31 -0
  81. package/src/lib/snbt.ts +246 -0
  82. package/src/locales/en_us.json +9 -0
  83. package/src/locales/ru_ru.json +9 -0
  84. package/src/locales.ts +5 -0
  85. package/src/types/entry.ts +52 -0
  86. package/styles/fonts.css +15 -0
  87. package/styles/tokens.css +15 -0
  88. package/styles.css +2 -0
  89. package/README.md +0 -3
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
4
+ import { EntryIcon } from '@terrafirmagreg-team/game/components/entry/icon';
5
+ import type { ReferenceKind } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
6
+ import type { PreviewIcon } from '@terrafirmagreg-team/ui/types/preview';
7
+
8
+ export function PagePreviewIcon({ icon }: { icon: PreviewIcon }) {
9
+ if (icon.type === 'entry') {
10
+ return (
11
+ <EntryIcon
12
+ reference={{ kind: icon.kind as ReferenceKind, id: icon.id }}
13
+ dataset={icon.dataset}
14
+ size={16}
15
+ missing="placeholder"
16
+ />
17
+ );
18
+ }
19
+ return (
20
+ <DynamicIcon
21
+ name={icon.name as IconName}
22
+ className="size-4 shrink-0"
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import {
5
+ dumpLocation,
6
+ subscribeDumpLocations,
7
+ type DumpLocation,
8
+ } from '@terrafirmagreg-team/game/lib/dump/paths';
9
+
10
+ export function useDumpLocation(
11
+ dataset: string | undefined,
12
+ ): DumpLocation | undefined {
13
+ return useSyncExternalStore(
14
+ subscribeDumpLocations,
15
+ () => (dataset ? dumpLocation(dataset) : undefined),
16
+ () => undefined,
17
+ );
18
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ createElement,
6
+ useContext,
7
+ useMemo,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
11
+
12
+ type EntryContextValue = {
13
+ dataset: string;
14
+ lang?: Lang;
15
+ langMap?: Record<string, string>;
16
+ };
17
+
18
+ const EntryContext = createContext<EntryContextValue | null>(null);
19
+
20
+ export function EntryProvider({
21
+ dataset,
22
+ lang,
23
+ langMap,
24
+ children,
25
+ }: EntryContextValue & { children: ReactNode }) {
26
+ const value = useMemo(
27
+ () => ({ dataset, lang, langMap }),
28
+ [dataset, lang, langMap],
29
+ );
30
+ return createElement(EntryContext.Provider, { value }, children);
31
+ }
32
+
33
+ export function useEntryContext(): EntryContextValue | null {
34
+ return useContext(EntryContext);
35
+ }
@@ -0,0 +1,297 @@
1
+ 'use client';
2
+
3
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
4
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
5
+ import {
6
+ classifyRefId,
7
+ isTagKind,
8
+ parseStackId,
9
+ tagKindOf,
10
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
11
+ import { texturePublicUrl } from '@terrafirmagreg-team/game/lib/dump/assets/urls';
12
+ import { useDumpLocation } from '@terrafirmagreg-team/game/hooks/use-dump-location';
13
+ import { lookupSpriteInBundle } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/lookup';
14
+ import type { AtlasLookup } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/dto';
15
+ import {
16
+ blockItemId,
17
+ fetchAtlases,
18
+ fetchEntityTexture,
19
+ fetchTagMembers,
20
+ peekBlockItem,
21
+ peekEntityTexture,
22
+ peekTagMembers,
23
+ } from '@terrafirmagreg-team/game/lib/entry/icon-load';
24
+ import { loadClientModAtlases } from '@terrafirmagreg-team/game/lib/dump/client/atlases';
25
+ import { stackKindOf } from '@terrafirmagreg-team/game/lib/entry/icon';
26
+ import type {
27
+ DumpNbt,
28
+ ReferenceKind,
29
+ TaggedReference,
30
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
31
+
32
+ export const ENTRY_CYCLE_MS = 1500;
33
+
34
+ export type IconSource = {
35
+
36
+ rawId: string;
37
+
38
+ cycleRef: TaggedReference | null;
39
+
40
+ lookup: AtlasLookup | null;
41
+
42
+ missingLookup: AtlasLookup | null;
43
+ missingPending: boolean;
44
+
45
+ imgSrc: string | undefined;
46
+
47
+ sourceKey: string;
48
+
49
+ pending: boolean;
50
+ awaitingAtlas: boolean;
51
+ awaitingTexture: boolean;
52
+ usesAtlas: boolean;
53
+ isEntity: boolean;
54
+ };
55
+
56
+ export function useIconSource({
57
+ dataset,
58
+ reference,
59
+ id,
60
+ kind,
61
+ tag,
62
+ missing,
63
+ preferVariant,
64
+ }: {
65
+ dataset?: string;
66
+ reference?: TaggedReference;
67
+ id?: string;
68
+ kind?: ReferenceKind;
69
+ tag?: DumpNbt;
70
+ missing: 'placeholder' | 'item' | 'hide';
71
+ preferVariant?: boolean;
72
+ }): IconSource {
73
+ const ref = reference ?? (id ? classifyRefId(id, kind) : undefined);
74
+ const refKind = ref?.kind;
75
+ const rawId = ref?.id ?? id ?? '';
76
+ const nbt = tag ?? ref?.tag;
77
+ const tagMemberKind = tagKindOf(refKind);
78
+ const tagKey =
79
+ dataset && rawId && isTagKind(refKind)
80
+ ? `${dataset}/${tagMemberKind ?? 'item'}/${rawId}`
81
+ : null;
82
+ const tagMembersPeek = tagKey
83
+ ? peekTagMembers(dataset!, rawId, tagMemberKind ?? 'item')
84
+ : undefined;
85
+ const tagHit = useAsyncResult(
86
+ tagKey && tagMembersPeek === undefined
87
+ ? async () => ({
88
+ key: tagKey,
89
+ members: await fetchTagMembers(
90
+ dataset!,
91
+ rawId,
92
+ tagMemberKind ?? 'item',
93
+ ),
94
+ })
95
+ : false,
96
+ [dataset, rawId, tagMemberKind, tagKey, tagMembersPeek],
97
+ );
98
+ const tagMembers = !tagKey
99
+ ? undefined
100
+ : tagMembersPeek !== undefined
101
+ ? tagMembersPeek
102
+ : tagHit.pending
103
+ ? undefined
104
+ : tagHit.value?.key === tagKey
105
+ ? (tagHit.value.members ?? null)
106
+ : null;
107
+ const cycleRefs =
108
+ tagMembers && tagMembers.length > 1
109
+ ? tagMembers.map((member) => ({
110
+ kind: (tagMemberKind ?? 'item') as ReferenceKind,
111
+ id: member,
112
+ }))
113
+ : null;
114
+ const [cycleIndex] = useCycleIndex(cycleRefs?.length ?? 0, {
115
+ enabled: cycleRefs !== null,
116
+ intervalMs: ENTRY_CYCLE_MS,
117
+ });
118
+ const itemId = !rawId
119
+ ? ''
120
+ : tagKey
121
+ ? tagMembers === undefined
122
+ ? undefined
123
+ : tagMembers === null
124
+ ? null
125
+ : (tagMembers[0] ?? null)
126
+ : rawId;
127
+ const memberKind =
128
+ isTagKind(refKind) && itemId && !itemId.startsWith('#')
129
+ ? (tagMemberKind ?? 'item')
130
+ : refKind;
131
+ const blockKey =
132
+ dataset && memberKind === 'block' && itemId && !itemId.startsWith('#')
133
+ ? `${dataset}/${itemId}`
134
+ : null;
135
+ const blockPeek = blockKey ? peekBlockItem(dataset!, itemId!) : undefined;
136
+ const blockHit = useAsyncResult(
137
+ blockKey && blockPeek === undefined
138
+ ? async () => ({
139
+ key: blockKey,
140
+ id: await blockItemId(dataset!, itemId!),
141
+ })
142
+ : false,
143
+ [dataset, itemId, blockKey, blockPeek],
144
+ );
145
+ const blockResolved = !blockKey
146
+ ? undefined
147
+ : blockPeek !== undefined
148
+ ? blockPeek
149
+ : blockHit.pending
150
+ ? undefined
151
+ : blockHit.value?.key === blockKey
152
+ ? (blockHit.value.id ?? null)
153
+ : null;
154
+ const atlasId = blockKey ? (blockResolved ?? undefined) : itemId;
155
+ const displayKind = blockKey && blockResolved ? 'item' : memberKind;
156
+ const resolvedId = atlasId ?? rawId;
157
+ const isTexture = displayKind === 'texture';
158
+ const isEntity = displayKind === 'entity';
159
+ const parsed = parseStackId(resolvedId.replace(/^#/, ''));
160
+ const spriteId = parsed.id;
161
+ const eggId =
162
+ isEntity && spriteId && !spriteId.startsWith('#')
163
+ ? `${spriteId}_spawn_egg`
164
+ : undefined;
165
+ const usesAtlas =
166
+ Boolean(dataset && rawId) &&
167
+ (displayKind === 'item' ||
168
+ displayKind === 'block' ||
169
+ displayKind === 'fluid' ||
170
+ Boolean(eggId));
171
+ const modId = spriteId.includes(':')
172
+ ? spriteId.slice(0, spriteId.indexOf(':'))
173
+ : '';
174
+ const atlasNeedsLoad =
175
+ dataset && usesAtlas && modId && spriteId && !spriteId.startsWith('#');
176
+ const atlasPeek = atlasNeedsLoad
177
+ ? loadClientModAtlases.peek(dataset, modId)
178
+ : undefined;
179
+ const atlasBundle =
180
+ atlasPeek && (atlasPeek.items || atlasPeek.blocks) ? atlasPeek : null;
181
+ const atlases = useAsyncResult(
182
+ atlasNeedsLoad && atlasPeek === undefined
183
+ ? () => fetchAtlases(dataset, modId)
184
+ : false,
185
+ [atlasNeedsLoad, atlasPeek, dataset, modId],
186
+ );
187
+ const atlasValue = atlasPeek === undefined ? atlases.value : atlasBundle;
188
+ const sourceKey = `${dataset}:${displayKind}:${resolvedId}`;
189
+ const dumpLoc = useDumpLocation(dataset);
190
+ const texturePeek =
191
+ dataset && isTexture && rawId
192
+ ? texturePublicUrl(dataset, rawId)
193
+ : dataset && isEntity && resolvedId
194
+ ? peekEntityTexture(dataset, resolvedId, nbt)
195
+ : null;
196
+ const textureSrc = useAsyncResult(
197
+ dataset && isTexture && rawId
198
+ ? false
199
+ : dataset && isEntity && resolvedId && texturePeek === undefined
200
+ ? async () => ({
201
+ key: sourceKey,
202
+ url:
203
+ (await fetchEntityTexture(dataset, resolvedId, nbt)) ?? undefined,
204
+ })
205
+ : false,
206
+ [
207
+ isTexture,
208
+ isEntity,
209
+ dataset,
210
+ rawId,
211
+ resolvedId,
212
+ sourceKey,
213
+ nbt,
214
+ texturePeek,
215
+ ],
216
+ );
217
+ const resolvedTexture =
218
+ texturePeek !== undefined
219
+ ? (texturePeek ?? undefined)
220
+ : textureSrc.value?.key === sourceKey
221
+ ? textureSrc.value.url
222
+ : undefined;
223
+ const wantMissingItem =
224
+ missing === 'item' && dataset !== undefined && usesAtlas;
225
+ const missingPeek = wantMissingItem
226
+ ? loadClientModAtlases.peek(dataset, 'ftbquests')
227
+ : undefined;
228
+ const missingBundleValue =
229
+ missingPeek !== undefined
230
+ ? missingPeek.items || missingPeek.blocks
231
+ ? missingPeek
232
+ : null
233
+ : undefined;
234
+ const missingBundle = useAsyncResult(
235
+ wantMissingItem && missingPeek === undefined
236
+ ? () => fetchAtlases(dataset, 'ftbquests')
237
+ : false,
238
+ [wantMissingItem, missingPeek, dataset],
239
+ );
240
+ const missingLookup = wantMissingItem
241
+ ? lookupSpriteInBundle(
242
+ missingBundleValue === undefined
243
+ ? missingBundle.value
244
+ : missingBundleValue,
245
+ {
246
+ id: 'ftbquests:missing_item',
247
+ kind: 'item',
248
+ },
249
+ )
250
+ : null;
251
+
252
+ const lookupKind = stackKindOf(displayKind) ?? (eggId ? 'item' : undefined);
253
+ const lookup = usesAtlas
254
+ ? lookupSpriteInBundle(
255
+ atlasValue,
256
+ {
257
+ id: eggId ?? spriteId,
258
+ ...(lookupKind ? { kind: lookupKind } : {}),
259
+ ...(nbt !== undefined
260
+ ? { tag: nbt }
261
+ : parsed.nbt !== undefined
262
+ ? { tag: parsed.nbt }
263
+ : {}),
264
+ },
265
+ lookupKind,
266
+ preferVariant,
267
+ )
268
+ : null;
269
+ const pending =
270
+ (isTagKind(refKind) && itemId === undefined) ||
271
+ (blockKey !== null && blockResolved === undefined);
272
+ const awaitingAtlas =
273
+ usesAtlas &&
274
+ atlasId != null &&
275
+ !atlasId.startsWith('#') &&
276
+ atlases.pending;
277
+ const awaitingTexture =
278
+ (isTexture || isEntity) &&
279
+ texturePeek === undefined &&
280
+ (textureSrc.pending || (isTexture && !dumpLoc));
281
+
282
+ return {
283
+ rawId,
284
+ cycleRef:
285
+ cycleRefs && !pending ? (cycleRefs[cycleIndex] ?? cycleRefs[0]!) : null,
286
+ lookup: lookup ?? null,
287
+ missingLookup: missingLookup ?? null,
288
+ missingPending: missingBundle.pending,
289
+ imgSrc: isTexture || isEntity ? resolvedTexture : undefined,
290
+ sourceKey,
291
+ pending,
292
+ awaitingAtlas,
293
+ awaitingTexture,
294
+ usesAtlas,
295
+ isEntity,
296
+ };
297
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
4
+ import { dumpReader } from '@terrafirmagreg-team/game/lib/dump/reader';
5
+
6
+ export function useLangMap(
7
+ dataset: string | undefined,
8
+ lang: string | undefined,
9
+ enabled = true,
10
+ ): Record<string, string> | undefined {
11
+ const { value, pending } = useAsyncResult(
12
+ enabled && dataset && lang
13
+ ? () => dumpReader(dataset).then((reader) => reader.getLang(lang))
14
+ : false,
15
+ [dataset, lang, enabled],
16
+ );
17
+ return pending ? undefined : value;
18
+ }
@@ -0,0 +1,49 @@
1
+ import type { DumpNbt, TaggedReference } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
2
+
3
+ export type AtlasPage = {
4
+ image: string;
5
+ w: number;
6
+ h: number;
7
+ };
8
+
9
+ export type AtlasTint = 'none' | 'grass' | 'foliage' | 'water' | 'custom';
10
+
11
+ export type AtlasSprite = {
12
+ ref: TaggedReference;
13
+ page: number;
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ tint?: AtlasTint;
19
+ tintColor?: string;
20
+ langKey?: string;
21
+ };
22
+
23
+ export type AtlasKind = 'item' | 'block';
24
+
25
+ export type IconAtlas = {
26
+ sprites: AtlasSprite[];
27
+ pages: AtlasPage[];
28
+ modId: string;
29
+ dataset: string;
30
+ kind: AtlasKind;
31
+ };
32
+
33
+ export type AtlasBundle = {
34
+ items: IconAtlas | null;
35
+ blocks: IconAtlas | null;
36
+ };
37
+
38
+ export type SpriteQuery = {
39
+ kind?: 'item' | 'block' | 'fluid';
40
+ id: string;
41
+ tag?: DumpNbt;
42
+ };
43
+
44
+ export type AtlasLookup = {
45
+ atlas: IconAtlas;
46
+ sprite: AtlasSprite;
47
+ page: AtlasPage;
48
+ spriteId: string;
49
+ };
@@ -0,0 +1,206 @@
1
+ import type {
2
+ AtlasLookup,
3
+ AtlasSprite,
4
+ IconAtlas,
5
+ SpriteQuery,
6
+ } from '@terrafirmagreg-team/game/lib/dump/assets/atlas/dto';
7
+ import {
8
+ asTaggedReference,
9
+ nbtSpecificity,
10
+ parseStackId,
11
+ } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
12
+ import type { DumpNbt, TaggedReference } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
13
+ import { formatNbt } from '@terrafirmagreg-team/game/lib/snbt';
14
+
15
+ export { atlasPagePublicUrl } from '@terrafirmagreg-team/game/lib/dump/assets/urls';
16
+
17
+ function asStackKind(
18
+ kind: string | undefined,
19
+ ): SpriteQuery['kind'] | undefined {
20
+ return kind === 'item' || kind === 'block' || kind === 'fluid' ? kind : undefined;
21
+ }
22
+
23
+ function asSpriteQuery(
24
+ value: string | TaggedReference | SpriteQuery | null | undefined,
25
+ kind?: SpriteQuery['kind'],
26
+ ): SpriteQuery | null {
27
+ if (value == null) return null;
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ if (!trimmed) return null;
31
+ const parsed = parseStackId(trimmed);
32
+ if (!parsed.id) return null;
33
+ return {
34
+ id: parsed.id,
35
+ ...(kind ? { kind } : {}),
36
+ ...(parsed.nbt !== undefined ? { tag: parsed.nbt } : {}),
37
+ };
38
+ }
39
+ const tagged = asTaggedReference(value);
40
+ if (tagged) {
41
+ const stackKind = asStackKind(tagged.kind);
42
+ if (!stackKind) return null;
43
+ return {
44
+ kind: kind ?? stackKind,
45
+ id: tagged.id,
46
+ ...(tagged.tag !== undefined ? { tag: tagged.tag } : {}),
47
+ };
48
+ }
49
+ if (typeof value === 'object' && typeof value.id === 'string' && value.id) {
50
+ const resolvedKind = kind ?? asStackKind(value.kind);
51
+ return {
52
+ id: value.id,
53
+ ...(resolvedKind ? { kind: resolvedKind } : {}),
54
+ ...(value.tag !== undefined ? { tag: value.tag } : {}),
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function spriteScore(
61
+ ref: TaggedReference,
62
+ query: SpriteQuery,
63
+ preferVariant: boolean,
64
+ ): number {
65
+ if (ref.id !== query.id) return -1;
66
+ if (query.kind && ref.kind !== query.kind) return -1;
67
+ if (query.tag === undefined) {
68
+ if (ref.tag === undefined) return 500;
69
+ return preferVariant ? 600 : -1;
70
+ }
71
+ if (ref.tag === undefined) return 100;
72
+ return nbtSpecificity(ref.tag, query.tag);
73
+ }
74
+
75
+ type SpriteBucket = {
76
+ all: AtlasSprite[];
77
+ untagged: AtlasSprite[];
78
+ tagged: AtlasSprite[];
79
+ byTag: Map<string, AtlasSprite[]>;
80
+ maxTagKeys: number;
81
+ };
82
+
83
+ const atlasIndex = new WeakMap<IconAtlas, Map<string, SpriteBucket>>();
84
+
85
+ const EXACT_SAFE_MAX_KEYS = 99;
86
+
87
+ function nbtTopKeys(tag: DumpNbt | undefined): number {
88
+ return tag !== null && typeof tag === 'object' && !Array.isArray(tag)
89
+ ? Object.keys(tag ?? {}).length
90
+ : 0;
91
+ }
92
+
93
+ function spriteBuckets(atlas: IconAtlas): Map<string, SpriteBucket> {
94
+ let byId = atlasIndex.get(atlas);
95
+ if (byId) return byId;
96
+ byId = new Map();
97
+ for (const sprite of atlas.sprites) {
98
+ const bucket =
99
+ byId.get(sprite.ref.id) ??
100
+ (() => {
101
+ const created: SpriteBucket = {
102
+ all: [],
103
+ untagged: [],
104
+ tagged: [],
105
+ byTag: new Map(),
106
+ maxTagKeys: 0,
107
+ };
108
+ byId.set(sprite.ref.id, created);
109
+ return created;
110
+ })();
111
+ bucket.all.push(sprite);
112
+ if (sprite.ref.tag === undefined) {
113
+ bucket.untagged.push(sprite);
114
+ continue;
115
+ }
116
+ bucket.tagged.push(sprite);
117
+ const keyCount = nbtTopKeys(sprite.ref.tag);
118
+ if (keyCount > bucket.maxTagKeys) bucket.maxTagKeys = keyCount;
119
+ const key = formatNbt(sprite.ref.tag);
120
+ if (key !== undefined) {
121
+ const list = bucket.byTag.get(key);
122
+ if (list) list.push(sprite);
123
+ else bucket.byTag.set(key, [sprite]);
124
+ }
125
+ }
126
+ atlasIndex.set(atlas, byId);
127
+ return byId;
128
+ }
129
+
130
+ function spriteHit(
131
+ atlas: IconAtlas,
132
+ sprite: AtlasSprite,
133
+ ): AtlasLookup | null {
134
+ const page = atlas.pages[sprite.page];
135
+ return page ? { atlas, sprite, page, spriteId: sprite.ref.id } : null;
136
+ }
137
+
138
+ function lookupSprite(
139
+ atlas: IconAtlas | null | undefined,
140
+ query?: string | TaggedReference | SpriteQuery | null,
141
+ kind?: SpriteQuery['kind'],
142
+ preferVariant = false,
143
+ ): AtlasLookup | null {
144
+ if (!atlas || query == null) return null;
145
+ const resolved = asSpriteQuery(query, kind);
146
+ if (!resolved) return null;
147
+ const bucket = spriteBuckets(atlas).get(resolved.id);
148
+ if (!bucket) return null;
149
+ if (resolved.tag === undefined) {
150
+ const first = preferVariant ? bucket.tagged : bucket.untagged;
151
+ const second = preferVariant ? bucket.untagged : bucket.tagged;
152
+ for (const list of [first, second]) {
153
+ for (const sprite of list) {
154
+ if (resolved.kind && sprite.ref.kind !== resolved.kind) continue;
155
+ const hit = spriteHit(atlas, sprite);
156
+ if (hit) return hit;
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ if (bucket.maxTagKeys <= EXACT_SAFE_MAX_KEYS) {
162
+ const exacts = bucket.byTag.get(formatNbt(resolved.tag) ?? '');
163
+ if (exacts) {
164
+ for (const sprite of exacts) {
165
+ if (resolved.kind && sprite.ref.kind !== resolved.kind) continue;
166
+ const hit = spriteHit(atlas, sprite);
167
+ if (hit) return hit;
168
+ }
169
+ }
170
+ }
171
+ let best: AtlasLookup | null = null;
172
+ let bestScore = -1;
173
+ for (const sprite of bucket.all) {
174
+ const score = spriteScore(sprite.ref, resolved, preferVariant);
175
+ if (score <= bestScore) continue;
176
+ const hit = spriteHit(atlas, sprite);
177
+ if (!hit) continue;
178
+ best = hit;
179
+ bestScore = score;
180
+ }
181
+ return best;
182
+ }
183
+
184
+ export function lookupSpriteInBundle(
185
+ bundle: { items: IconAtlas | null; blocks: IconAtlas | null } | null | undefined,
186
+ query?: string | TaggedReference | SpriteQuery | null,
187
+ kind?: SpriteQuery['kind'],
188
+ preferVariant = false,
189
+ ): AtlasLookup | null {
190
+ if (!bundle) return null;
191
+ const resolved = asSpriteQuery(query, kind);
192
+ if (!resolved) return null;
193
+ if (resolved.kind === 'block') {
194
+ return (
195
+ lookupSprite(bundle.blocks, resolved, undefined, preferVariant) ??
196
+ lookupSprite(bundle.items, resolved, undefined, preferVariant)
197
+ );
198
+ }
199
+ if (resolved.kind === 'item' || resolved.kind === 'fluid') {
200
+ return lookupSprite(bundle.items, resolved, undefined, preferVariant);
201
+ }
202
+ return (
203
+ lookupSprite(bundle.items, resolved, undefined, preferVariant) ??
204
+ lookupSprite(bundle.blocks, resolved, undefined, preferVariant)
205
+ );
206
+ }