Skip to content

Commit 77dcff3

Browse files
committed
feat(tile-data): add tile lookup caching and implement findTile tests
Introduce caching for tile lookups in `findTile` using a `WeakMap`. Add helper functions to build and retrieve tile indices. Include unit tests to validate lookup behavior, chunk preferences, and seasonal variant handling.
1 parent d11864e commit 77dcff3

File tree

2 files changed

+179
-47
lines changed

2 files changed

+179
-47
lines changed

src/tile-data.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
TILESETS,
55
collectActiveModTilesets,
66
collectExternalTilesets,
7+
findTile,
78
getTilesetCompatibilityIdentities,
89
isContributionCompatible,
910
loadMergedTileset,
1011
resolveExternalChunkUrl,
1112
resolveModChunkUrl,
13+
type TilesetData,
1214
} from "./tile-data";
1315

1416
vi.mock("./utils/retry", async (importOriginal) => {
@@ -29,6 +31,15 @@ function fakeData(overrides: any): CBNData {
2931
} as unknown as CBNData;
3032
}
3133

34+
function createTestTileset(
35+
chunks: NonNullable<TilesetData>["tiles-new"],
36+
): NonNullable<TilesetData> {
37+
return {
38+
tile_info: [{ width: 32, height: 32, pixelscale: 1 }],
39+
"tiles-new": chunks,
40+
};
41+
}
42+
3243
describe("tile-data mod_tileset support", () => {
3344
const originalFetch = globalThis.fetch;
3445
const OriginalImage = globalThis.Image;
@@ -458,3 +469,80 @@ describe("tile-data mod_tileset support", () => {
458469
).toBe(true);
459470
});
460471
});
472+
473+
describe("findTile", () => {
474+
test("prefers later chunks over earlier ones", () => {
475+
const tile = findTile(
476+
createTestTileset([
477+
{
478+
file: "base.webp",
479+
nx: 2,
480+
ny: 1,
481+
tiles: [{ id: "foo", fg: 0 }],
482+
},
483+
{
484+
file: "override.webp",
485+
nx: 2,
486+
ny: 1,
487+
tiles: [{ id: "foo", fg: 2 }],
488+
},
489+
]),
490+
"foo",
491+
);
492+
493+
expect(tile?.fg).toMatchObject({
494+
file: "override.webp",
495+
tx: 0,
496+
ty: 0,
497+
});
498+
});
499+
500+
test("keeps the first matching entry within a chunk", () => {
501+
const tile = findTile(
502+
createTestTileset([
503+
{
504+
file: "base.webp",
505+
nx: 2,
506+
ny: 1,
507+
tiles: [
508+
{ id: "foo", fg: 0 },
509+
{ id: "foo", fg: 1 },
510+
],
511+
},
512+
]),
513+
"foo",
514+
);
515+
516+
expect(tile?.fg).toMatchObject({
517+
file: "base.webp",
518+
tx: 0,
519+
ty: 0,
520+
});
521+
});
522+
523+
test("prefers an exact tile over a later seasonal variant", () => {
524+
const tile = findTile(
525+
createTestTileset([
526+
{
527+
file: "base.webp",
528+
nx: 2,
529+
ny: 1,
530+
tiles: [{ id: "tree", fg: 0 }],
531+
},
532+
{
533+
file: "seasonal.webp",
534+
nx: 2,
535+
ny: 1,
536+
tiles: [{ id: "tree_season_summer", fg: 2 }],
537+
},
538+
]),
539+
"tree",
540+
);
541+
542+
expect(tile?.fg).toMatchObject({
543+
file: "base.webp",
544+
tx: 0,
545+
ty: 0,
546+
});
547+
});
548+
});

src/tile-data.ts

Lines changed: 91 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ function getDataBaseUrl(version: string): string {
163163
return `${CBN_DATA_BASE_URL}/data/${version}`;
164164
}
165165

166+
/**
167+
* @internal
168+
*/
166169
export function resolveModChunkUrl(
167170
version: string,
168171
modId: string,
@@ -174,6 +177,9 @@ export function resolveModChunkUrl(
174177
);
175178
}
176179

180+
/**
181+
* @internal
182+
*/
177183
export function resolveExternalChunkUrl(version: string, file: string): string {
178184
return resolvePath(
179185
getDataBaseUrl(version),
@@ -345,6 +351,9 @@ function getCachedBaseTileset(url: string): Promise<NonNullable<TilesetData>> {
345351
return created;
346352
}
347353

354+
/**
355+
* @internal
356+
*/
348357
export function collectActiveModTilesets(
349358
data: CBNData,
350359
): ModTilesetContribution[] {
@@ -376,6 +385,9 @@ function isExternalTilesetChunk(file: string): boolean {
376385
return file.startsWith("external_tileset/");
377386
}
378387

388+
/**
389+
* @internal
390+
*/
379391
export function collectExternalTilesets(
380392
data: Pick<CBNData, "all">,
381393
): ExternalTilesetContribution[] {
@@ -402,6 +414,9 @@ export function collectExternalTilesets(
402414
return result;
403415
}
404416

417+
/**
418+
* @internal
419+
*/
405420
export function isContributionCompatible(
406421
contribution: Pick<TilesetContribution, "compatibility">,
407422
selectedAliases: Set<string>,
@@ -417,6 +432,9 @@ export function isContributionCompatible(
417432
return false;
418433
}
419434

435+
/**
436+
* @internal
437+
*/
420438
export function getTilesetCompatibilityIdentities(
421439
tilesetName: string,
422440
): Set<string> {
@@ -444,6 +462,9 @@ function getMergeCacheKey(
444462
return `${version}|${tileset.path ?? "ascii"}|${activeMods}|${aliasSignature}|${modsLoaded}`;
445463
}
446464

465+
/**
466+
* @internal
467+
*/
447468
export async function loadMergedTileset(
448469
data: CBNData,
449470
version: string,
@@ -593,6 +614,10 @@ export type TilesetData = {
593614
baseUrl?: string;
594615
} | null;
595616

617+
type IndexedTilesetData = NonNullable<TilesetData>;
618+
type TileLookupIndex = Map<string, TileInfo>;
619+
const tileLookupIndexCache = new WeakMap<IndexedTilesetData, TileLookupIndex>();
620+
596621
export function resolveTileLayerUrl(
597622
tileset: TilesetData,
598623
layer: TilePosition | undefined,
@@ -610,72 +635,91 @@ export function findTile(
610635
id: string,
611636
): TileInfo | undefined {
612637
if (!tileData || !id) return;
613-
//TODO: Cache tiles-new ranges and tile lookups per tileset to avoid per-cell scans.
638+
return getTileLookupIndex(tileData).get(id);
639+
}
640+
641+
function getTileLookupIndex(tileData: IndexedTilesetData): TileLookupIndex {
642+
const cached = tileLookupIndexCache.get(tileData);
643+
if (cached) return cached;
644+
645+
const indexed = buildTileLookupIndex(tileData);
646+
tileLookupIndexCache.set(tileData, indexed);
647+
return indexed;
648+
}
649+
650+
function buildTileLookupIndex(tileData: IndexedTilesetData): TileLookupIndex {
614651
let offset = 0;
615-
const ranges: { from: number; to: number; chunk: any }[] = [];
616-
for (const chunk of tileData["tiles-new"]) {
617-
ranges.push({
652+
const ranges = tileData["tiles-new"].map((chunk) => {
653+
const range = {
618654
from: offset,
619655
to: offset + chunk.nx * chunk.ny,
620656
chunk,
621-
});
622-
offset += chunk.nx * chunk.ny;
623-
}
624-
function findRange(id: number) {
625-
for (const range of ranges)
626-
if (id >= range.from && id < range.to) return range;
657+
};
658+
offset = range.to;
659+
return range;
660+
});
661+
662+
function findRange(spriteId: number) {
663+
for (const range of ranges) {
664+
if (spriteId >= range.from && spriteId < range.to) return range;
665+
}
627666
}
628-
function tileInfoForId(id: number | undefined): TilePosition | undefined {
629-
if (id == null) return;
630-
const range = findRange(id);
667+
668+
function tileInfoForSprite(
669+
spriteId: number | undefined,
670+
): TilePosition | undefined {
671+
if (spriteId == null) return;
672+
const range = findRange(spriteId);
631673
if (!range) return;
632-
const offsetInFile = id - range.from;
633-
const fgTx = offsetInFile % range.chunk.nx;
634-
const fgTy = (offsetInFile / range.chunk.nx) | 0;
674+
const offsetInFile = spriteId - range.from;
635675
return {
636676
file: range.chunk.file,
637677
file_url: range.chunk.file_url,
638678
source_base_url: range.chunk.source_base_url,
639-
// Safe to use ! because we check tileData at function entry
640-
width: range.chunk.sprite_width ?? tileData!.tile_info[0].width,
641-
height: range.chunk.sprite_height ?? tileData!.tile_info[0].height,
679+
width: range.chunk.sprite_width ?? tileData.tile_info[0].width,
680+
height: range.chunk.sprite_height ?? tileData.tile_info[0].height,
642681
offx: range.chunk.sprite_offset_x ?? 0,
643682
offy: range.chunk.sprite_offset_y ?? 0,
644-
tx: fgTx,
645-
ty: fgTy,
683+
tx: offsetInFile % range.chunk.nx,
684+
ty: (offsetInFile / range.chunk.nx) | 0,
685+
};
686+
}
687+
688+
function firstSpriteRef(value: unknown): number | undefined {
689+
const maybeArrayHead = Array.isArray(value) ? value[0] : value;
690+
if (typeof maybeArrayHead === "number") return maybeArrayHead;
691+
if (
692+
typeof maybeArrayHead === "object" &&
693+
maybeArrayHead !== null &&
694+
typeof maybeArrayHead.sprite === "number"
695+
) {
696+
return maybeArrayHead.sprite;
697+
}
698+
}
699+
700+
function tileInfoForEntry(entry: TileEntry): TileInfo {
701+
return {
702+
fg: tileInfoForSprite(firstSpriteRef(entry.fg)),
703+
bg: tileInfoForSprite(firstSpriteRef(entry.bg)),
646704
};
647705
}
648-
const idMatches = (testId: string) =>
649-
testId &&
650-
(testId === id ||
651-
(testId.startsWith(id) &&
652-
/^_season_(autumn|spring|summer|winter)$/.test(
653-
testId.substring(id.length),
654-
)));
655-
for (
656-
let chunkIdx = tileData["tiles-new"].length - 1;
657-
chunkIdx >= 0;
658-
chunkIdx--
659-
) {
660-
const chunk = tileData["tiles-new"][chunkIdx];
661-
for (const info of chunk.tiles) {
662-
if (
663-
Array.isArray(info.id) ? info.id.some(idMatches) : idMatches(info.id)
664-
) {
665-
let fg = Array.isArray(info.fg) ? info.fg[0] : info.fg;
666-
let bg = Array.isArray(info.bg) ? info.bg[0] : info.bg;
667-
if (fg && typeof fg === "object") fg = fg.sprite;
668-
if (bg && typeof bg === "object") bg = bg.sprite;
669-
return {
670-
fg: tileInfoForId(fg),
671-
bg: tileInfoForId(bg),
672-
};
706+
707+
const exact = new Map<string, TileInfo>();
708+
709+
for (const chunk of tileData["tiles-new"]) {
710+
for (let idx = chunk.tiles.length - 1; idx >= 0; idx--) {
711+
const entry = chunk.tiles[idx];
712+
const tileInfo = tileInfoForEntry(entry);
713+
for (const entryId of Array.isArray(entry.id) ? entry.id : [entry.id]) {
714+
exact.set(entryId, tileInfo);
673715
}
674716
}
675717
}
718+
719+
return exact;
676720
}
677721

678-
export const MAX_INHERITANCE_DEPTH = 10;
722+
const MAX_INHERITANCE_DEPTH = 10;
679723

680724
export function findTileOrLooksLike(
681725
data: CBNData,

0 commit comments

Comments
 (0)