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.
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TTSMod, TTSObject } from '@tts/shared';
|
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 {
|
function card(guid: string, name = 'Card'): TTSObject {
|
||||||
return { Name: name, GUID: guid, Description: '' };
|
return { Name: name, GUID: guid, Description: '' };
|
||||||
@@ -21,14 +26,15 @@ const mod: TTSMod = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('flattenObjects', () => {
|
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);
|
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', () => {
|
it('sets Parent links before returning', () => {
|
||||||
const [a, c] = flattenObjects(mod);
|
const [a, b, c] = flattenObjects(mod);
|
||||||
expect(a!.Parent).toBeUndefined();
|
expect(a!.Parent).toBeUndefined();
|
||||||
|
expect(b!.Parent).toBeUndefined();
|
||||||
expect(c!.Parent!.GUID).toBe('b');
|
expect(c!.Parent!.GUID).toBe('b');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -48,4 +54,26 @@ describe('findObject', () => {
|
|||||||
it('returns undefined for a missing GUID', () => {
|
it('returns undefined for a missing GUID', () => {
|
||||||
expect(findObject(mod, 'zzz')).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
import type { TTSMod, TTSObject } from '@tts/shared';
|
import type { TTSMod, TTSObject } from '@tts/shared';
|
||||||
import { markParent, traverseMod } from './traverse.js';
|
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.
|
* Flatten all objects in a save into an array.
|
||||||
* Ensures parent links are set before returning.
|
* Ensures parent links are set before returning.
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ describe('markParent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('traverseMod', () => {
|
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);
|
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', () => {
|
it('descends into nested bags', () => {
|
||||||
@@ -53,8 +53,14 @@ describe('traverseMod', () => {
|
|||||||
expect(guids).toContain('e');
|
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', () => {
|
it('accepts a single object', () => {
|
||||||
const guids = [...traverseMod(bag('b', [card('c')]))].map((o) => o.GUID);
|
const guids = [...traverseMod(bag('b', [card('c')]))].map((o) => o.GUID);
|
||||||
expect(guids).toEqual(['c']);
|
expect(guids).toEqual(['b', 'c']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14,8 +14,8 @@ export function markParent(o: TTSObject): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Yield every object in a save, descending into Bags and Custom_Model_Bags.
|
* Yield every object in a save, descending into any object with
|
||||||
* Accepts either a full `TTSMod` or a single `TTSObject`.
|
* `ContainedObjects`. Accepts either a full `TTSMod` or a single `TTSObject`.
|
||||||
*/
|
*/
|
||||||
export function* traverseMod(
|
export function* traverseMod(
|
||||||
mod: TTSMod | TTSObject,
|
mod: TTSMod | TTSObject,
|
||||||
@@ -24,11 +24,10 @@ export function* traverseMod(
|
|||||||
for (const one of mod.ObjectStates) {
|
for (const one of mod.ObjectStates) {
|
||||||
yield* traverseMod(one);
|
yield* traverseMod(one);
|
||||||
}
|
}
|
||||||
} else if (mod.Name === 'Bag' || mod.Name === 'Custom_Model_Bag') {
|
} else {
|
||||||
|
yield mod;
|
||||||
for (const one of mod.ContainedObjects ?? []) {
|
for (const one of mod.ContainedObjects ?? []) {
|
||||||
yield* traverseMod(one);
|
yield* traverseMod(one);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
yield mod;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
* Mirrors the structure produced by BSON-deserializing a TTS save.
|
* Mirrors the structure produced by BSON-deserializing a TTS save.
|
||||||
*/
|
*/
|
||||||
export interface TTSObject {
|
export interface TTSObject {
|
||||||
|
/** The object's class/type, e.g. `Card`, `Bag`, `Custom_Model`. */
|
||||||
Name: string;
|
Name: string;
|
||||||
|
/** The display name; often empty in saves, so fall back to `Name`. */
|
||||||
|
Nickname?: string;
|
||||||
GUID: string;
|
GUID: string;
|
||||||
Description: string;
|
Description: string;
|
||||||
/** Set by `markParent` during traversal; not present in the raw save. */
|
/** Set by `markParent` during traversal; not present in the raw save. */
|
||||||
|
|||||||
Reference in New Issue
Block a user