From d8a698986a12224ea73c0c7c9584b13ec016e8a3 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 12:19:39 +0800 Subject: [PATCH] feat(extract): build containment tree and traverse all containers Add buildTree to mirror how TTS nests objects, with a display label (Nickname or class Name). Generalize traverseMod to descend into any object with ContainedObjects, not just bags, and add Nickname to TTSObject. --- packages/extract/src/objects.test.ts | 36 ++++++++++++++++++++++++--- packages/extract/src/objects.ts | 27 ++++++++++++++++++++ packages/extract/src/traverse.test.ts | 12 ++++++--- packages/extract/src/traverse.ts | 9 +++---- packages/shared/src/types.ts | 3 +++ 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/extract/src/objects.test.ts b/packages/extract/src/objects.test.ts index 137d419..8bba944 100644 --- a/packages/extract/src/objects.test.ts +++ b/packages/extract/src/objects.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { TTSMod, TTSObject } from '@tts/shared'; -import { filterObjects, findObject, flattenObjects } from './objects.js'; +import { + buildTree, + filterObjects, + findObject, + flattenObjects, +} from './objects.js'; function card(guid: string, name = 'Card'): TTSObject { return { Name: name, GUID: guid, Description: '' }; @@ -21,14 +26,15 @@ const mod: TTSMod = { }; describe('flattenObjects', () => { - it('returns every object including those inside bags', () => { + it('returns every object including containers and those inside them', () => { const guids = flattenObjects(mod).map((o) => o.GUID); - expect(guids).toEqual(['a', 'c', 'd', 'e']); + expect(guids).toEqual(['a', 'b', 'c', 'd', 'e']); }); it('sets Parent links before returning', () => { - const [a, c] = flattenObjects(mod); + const [a, b, c] = flattenObjects(mod); expect(a!.Parent).toBeUndefined(); + expect(b!.Parent).toBeUndefined(); expect(c!.Parent!.GUID).toBe('b'); }); }); @@ -48,4 +54,26 @@ describe('findObject', () => { it('returns undefined for a missing GUID', () => { expect(findObject(mod, 'zzz')).toBeUndefined(); }); +}); + +describe('buildTree', () => { + it('mirrors the containment hierarchy', () => { + const tree = buildTree(mod); + expect(tree.map((n) => n.object.GUID)).toEqual(['a', 'b', 'e']); + expect(tree[1]!.children.map((n) => n.object.GUID)).toEqual(['c', 'd']); + }); + + it('uses Nickname as the label when present', () => { + const named = { + ...card('n', 'Card'), + Nickname: 'Ace of Spades', + }; + const tree = buildTree({ ...mod, ObjectStates: [named] }); + expect(tree[0]!.label).toBe('Ace of Spades'); + }); + + it('falls back to the class Name as the label', () => { + const tree = buildTree(mod); + expect(tree[0]!.label).toBe('Deck'); + }); }); \ No newline at end of file diff --git a/packages/extract/src/objects.ts b/packages/extract/src/objects.ts index b8a52cb..0ea4809 100644 --- a/packages/extract/src/objects.ts +++ b/packages/extract/src/objects.ts @@ -1,6 +1,33 @@ import type { TTSMod, TTSObject } from '@tts/shared'; import { markParent, traverseMod } from './traverse.js'; +/** A node in the containment tree of a save. */ +export interface ObjectTreeNode { + object: TTSObject; + /** Display label: `Nickname` when present, else the class `Name`. */ + label: string; + children: ObjectTreeNode[]; +} + +/** + * Build the containment tree of a save, mirroring how TTS nests objects. + * Each node carries its object plus a display label and its children. + */ +export function buildTree(mod: TTSMod): ObjectTreeNode[] { + for (const state of mod.ObjectStates) { + markParent(state); + } + return mod.ObjectStates.map(nodeFrom); +} + +function nodeFrom(o: TTSObject): ObjectTreeNode { + return { + object: o, + label: o.Nickname || o.Name, + children: (o.ContainedObjects ?? []).map(nodeFrom), + }; +} + /** * Flatten all objects in a save into an array. * Ensures parent links are set before returning. diff --git a/packages/extract/src/traverse.test.ts b/packages/extract/src/traverse.test.ts index 2c0f778..5f89293 100644 --- a/packages/extract/src/traverse.test.ts +++ b/packages/extract/src/traverse.test.ts @@ -43,9 +43,9 @@ describe('markParent', () => { }); describe('traverseMod', () => { - it('yields every non-bag object in order', () => { + it('yields every object including containers, in order', () => { const guids = [...traverseMod(mod)].map((o) => o.GUID); - expect(guids).toEqual(['a', 'c', 'e', 'f']); + expect(guids).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); }); it('descends into nested bags', () => { @@ -53,8 +53,14 @@ describe('traverseMod', () => { expect(guids).toContain('e'); }); + it('descends into any object with ContainedObjects', () => { + const custom = { Name: 'Custom_Model', GUID: 'x', Description: '', ContainedObjects: [card('y')] }; + const guids = [...traverseMod(custom)].map((o) => o.GUID); + expect(guids).toEqual(['x', 'y']); + }); + it('accepts a single object', () => { const guids = [...traverseMod(bag('b', [card('c')]))].map((o) => o.GUID); - expect(guids).toEqual(['c']); + expect(guids).toEqual(['b', 'c']); }); }); \ No newline at end of file diff --git a/packages/extract/src/traverse.ts b/packages/extract/src/traverse.ts index 9db14cc..a6f17d5 100644 --- a/packages/extract/src/traverse.ts +++ b/packages/extract/src/traverse.ts @@ -14,8 +14,8 @@ export function markParent(o: TTSObject): void { } /** - * Yield every object in a save, descending into Bags and Custom_Model_Bags. - * Accepts either a full `TTSMod` or a single `TTSObject`. + * Yield every object in a save, descending into any object with + * `ContainedObjects`. Accepts either a full `TTSMod` or a single `TTSObject`. */ export function* traverseMod( mod: TTSMod | TTSObject, @@ -24,11 +24,10 @@ export function* traverseMod( for (const one of mod.ObjectStates) { yield* traverseMod(one); } - } else if (mod.Name === 'Bag' || mod.Name === 'Custom_Model_Bag') { + } else { + yield mod; for (const one of mod.ContainedObjects ?? []) { yield* traverseMod(one); } - } else { - yield mod; } } \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4b23f16..b856076 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -3,7 +3,10 @@ * Mirrors the structure produced by BSON-deserializing a TTS save. */ export interface TTSObject { + /** The object's class/type, e.g. `Card`, `Bag`, `Custom_Model`. */ Name: string; + /** The display name; often empty in saves, so fall back to `Name`. */ + Nickname?: string; GUID: string; Description: string; /** Set by `markParent` during traversal; not present in the raw save. */