Skip to content

Commit b8f77a8

Browse files
committed
feat(adapter): normalize Foundry Sets & pseudo-doc collections to JSON
The generic adapter read live Foundry values straight into fields_data, but several Draw Steel fields are structures JSON.stringify renders as {}: - Sets (ability keywords, skills, power-roll characteristics, actor.statuses) - a CollectionField of pseudo-documents (an ability's system.power.effects tier ladder) Add normalizeFoundryValue(): Set→array, Collection/Map→array of member toObject(), recursive (catches nested Sets, e.g. a damage tier's types), and depth-guarded/defensive. Wire it into every scalar read and projected sub-field; a Set/array/object scalar on a string/json field is JSON-stringified so it arrives as parseable JSON (mirrors collection fields). +9 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf
1 parent 86d79d2 commit b8f77a8

2 files changed

Lines changed: 161 additions & 4 deletions

File tree

scripts/adapters/generic-adapter.mjs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
* - type: "json"/"string" → serialized JSON string; else a raw array
2626
* Collection fields are READ-ONLY today (pull only); write-back is a future tier,
2727
* so they are never included in the Foundry update path.
28+
*
29+
* Every extracted value (scalar or projected) is run through
30+
* `normalizeFoundryValue` so live Foundry structures that `JSON.stringify`
31+
* cannot serialize — Sets (keywords, skills, characteristics, actor.statuses)
32+
* and Collections of pseudo-documents (an ability's `system.power.effects` tier
33+
* ladder) — become plain arrays/objects instead of `{}`.
2834
*/
2935

3036
/**
@@ -145,6 +151,68 @@ export function getNestedValue(obj, path) {
145151
return current;
146152
}
147153

154+
/**
155+
* Normalize a value read off a live Foundry document into a JSON-safe plain
156+
* value. Foundry models many fields as data structures that `JSON.stringify`
157+
* cannot serialize meaningfully:
158+
* - a **Set** (keywords, skills, power-roll characteristics, actor.statuses)
159+
* serializes to `{}` — must become an array;
160+
* - a **Collection / ModelCollection** (a Map subclass — e.g. a CollectionField
161+
* of pseudo-documents like an ability's `system.power.effects` tier ladder)
162+
* also serializes to `{}` — must become an array of its members;
163+
* - a **DataModel / pseudo-document** member exposes its data via `toObject()`,
164+
* not own-enumerable properties (the schema fields are getters).
165+
*
166+
* This walks such structures recursively and returns arrays / plain objects /
167+
* primitives only. It is defensive (never throws) and depth-guarded against
168+
* cycles. Primitives and already-plain values pass straight through, so it is
169+
* safe to apply to every extracted field.
170+
*
171+
* @param {*} value
172+
* @param {number} [depth]
173+
* @returns {*} a JSON-safe value (primitive | array | plain object | null)
174+
*/
175+
export function normalizeFoundryValue(value, depth) {
176+
depth = depth || 0;
177+
if (value == null) return value;
178+
if (typeof value !== 'object') return value; // primitives pass through
179+
if (depth > 8) return null; // cycle / runaway guard
180+
181+
// Native Set OR Foundry Set → array of normalized members.
182+
if (value instanceof Set) {
183+
return Array.from(value, (v) => normalizeFoundryValue(v, depth + 1));
184+
}
185+
// Native array → map members.
186+
if (Array.isArray(value)) {
187+
return value.map((v) => normalizeFoundryValue(v, depth + 1));
188+
}
189+
// Foundry Collection (a Map subclass) exposes its members as `.contents`.
190+
// This covers CollectionField values (pseudo-document tier ladders) and the
191+
// actor's items/effects collections when a path points straight at one.
192+
if (Array.isArray(value.contents)) {
193+
return value.contents.map((v) => normalizeFoundryValue(v, depth + 1));
194+
}
195+
// Plain Map (no `.contents`) → array of its values.
196+
if (value instanceof Map) {
197+
return Array.from(value.values(), (v) => normalizeFoundryValue(v, depth + 1));
198+
}
199+
// DataModel / pseudo-document → its source object (schema fields are getters,
200+
// not own-enumerable props, so a key-copy would miss them). `toObject(false)`
201+
// yields the stored source (Sets already serialized to arrays); re-normalize
202+
// it to catch any nested live structures.
203+
if (typeof value.toObject === 'function') {
204+
try {
205+
return normalizeFoundryValue(value.toObject(false), depth + 1);
206+
} catch (e) { /* fall through to a shallow copy */ }
207+
}
208+
// Plain-ish object → normalize own enumerable props (catches nested Sets).
209+
const out = {};
210+
for (const k of Object.keys(value)) {
211+
out[k] = normalizeFoundryValue(value[k], depth + 1);
212+
}
213+
return out;
214+
}
215+
148216
/**
149217
* Extract a collection-mapped field (e.g. abilities/inventory from actor.items[]).
150218
* Reads field.foundry_collection off the actor, optionally filters by
@@ -187,7 +255,7 @@ export function extractCollectionField(actor, field) {
187255
if (!first) return '';
188256
if (proj) {
189257
const firstPath = Object.values(proj)[0];
190-
return getNestedValue(first, firstPath) ?? '';
258+
return normalizeFoundryValue(getNestedValue(first, firstPath)) ?? '';
191259
}
192260
return first.name ?? '';
193261
}
@@ -196,7 +264,7 @@ export function extractCollectionField(actor, field) {
196264
if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null };
197265
const out = {};
198266
for (const [outKey, path] of Object.entries(proj)) {
199-
out[outKey] = getNestedValue(it, path) ?? null;
267+
out[outKey] = normalizeFoundryValue(getNestedValue(it, path)) ?? null;
200268
}
201269
return out;
202270
});
@@ -223,7 +291,14 @@ export function buildChronicleFields(actor, mappedFields) {
223291
if (field.foundry_collection) {
224292
result[field.key] = extractCollectionField(actor, field);
225293
} else {
226-
const value = getNestedValue(actor, field.foundry_path);
294+
let value = normalizeFoundryValue(getNestedValue(actor, field.foundry_path));
295+
// A Set/array/object scalar (e.g. system.skills.value, actor.statuses)
296+
// declared on a string/json field is serialized to a JSON string so it
297+
// arrives as parseable JSON, mirroring how collection fields are stored.
298+
if (value != null && typeof value === 'object'
299+
&& (field.type === 'string' || field.type === 'json')) {
300+
value = JSON.stringify(value);
301+
}
227302
result[field.key] = value ?? null;
228303
}
229304
}

tools/test-generic-adapter.mjs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,25 @@
1212
import test from 'node:test';
1313
import assert from 'node:assert/strict';
1414

15-
const { getNestedValue, extractCollectionField, buildChronicleFields } =
15+
const { getNestedValue, extractCollectionField, buildChronicleFields, normalizeFoundryValue } =
1616
await import('../scripts/adapters/generic-adapter.mjs');
1717

18+
// ── Foundry-ish data structures for the normalizer tests ──────────────────
19+
// A Foundry Collection is a Map subclass exposing members via `.contents`.
20+
class FakeCollection extends Map {
21+
constructor(members) {
22+
super(members.map((m, i) => [m._id ?? String(i), m]));
23+
}
24+
get contents() { return Array.from(this.values()); }
25+
}
26+
// A pseudo-document / DataModel exposes data via toObject(), not own props —
27+
// the schema fields here are GETTERS so a naive key-copy would miss them.
28+
class FakeModel {
29+
constructor(source) { this._source = source; }
30+
get tier1() { return this._source.tier1; }
31+
toObject() { return JSON.parse(JSON.stringify(this._source)); }
32+
}
33+
1834
// A Foundry-ish actor with system data + an items collection (Map-like w/ .contents).
1935
function makeActor() {
2036
return {
@@ -134,3 +150,69 @@ test('buildChronicleFields sets null for a missing scalar path', () => {
134150
const out = buildChronicleFields(makeActor(), [{ key: 'speed', foundry_path: 'system.movement.speed', type: 'number' }]);
135151
assert.equal(out.speed, null);
136152
});
153+
154+
// ── normalizeFoundryValue ─────────────────────────────────────────────────
155+
156+
test('normalizeFoundryValue passes primitives and plain values through', () => {
157+
assert.equal(normalizeFoundryValue(7), 7);
158+
assert.equal(normalizeFoundryValue('hi'), 'hi');
159+
assert.equal(normalizeFoundryValue(null), null);
160+
assert.equal(normalizeFoundryValue(undefined), undefined);
161+
assert.deepEqual(normalizeFoundryValue([1, 2, 3]), [1, 2, 3]);
162+
assert.deepEqual(normalizeFoundryValue({ a: 1, b: 'x' }), { a: 1, b: 'x' });
163+
});
164+
165+
test('normalizeFoundryValue turns a Set into an array (keywords/skills/statuses)', () => {
166+
assert.deepEqual(normalizeFoundryValue(new Set(['magic', 'ranged'])), ['magic', 'ranged']);
167+
assert.deepEqual(normalizeFoundryValue(new Set()), []);
168+
});
169+
170+
test('normalizeFoundryValue normalizes nested Sets inside a plain object', () => {
171+
const v = normalizeFoundryValue({ type: 'damage', types: new Set(['fire']), n: 2 });
172+
assert.deepEqual(v, { type: 'damage', types: ['fire'], n: 2 });
173+
});
174+
175+
test('normalizeFoundryValue unrolls a Foundry Collection via .contents + toObject', () => {
176+
const effects = new FakeCollection([
177+
new FakeModel({ _id: 'a', type: 'damage', tier1: { value: '2 + @chr', types: ['fire'] } }),
178+
new FakeModel({ _id: 'b', type: 'damage', tier1: { value: '3', types: [] } }),
179+
]);
180+
const out = normalizeFoundryValue(effects);
181+
assert.ok(Array.isArray(out));
182+
assert.equal(out.length, 2);
183+
assert.deepEqual(out[0], { _id: 'a', type: 'damage', tier1: { value: '2 + @chr', types: ['fire'] } });
184+
assert.equal(out[1].tier1.value, '3');
185+
});
186+
187+
test('extractCollectionField projects a Set sub-field to a JSON array (ability keywords)', () => {
188+
const actor = {
189+
items: { contents: [
190+
{ id: 'i1', name: 'Ray of Wrath', type: 'ability',
191+
system: { type: 'main', category: 'signature', keywords: new Set(['magic', 'ranged']) } },
192+
] },
193+
};
194+
const json = extractCollectionField(actor, {
195+
key: 'abilities_json', type: 'string',
196+
foundry_collection: 'items', foundry_item_type: 'ability',
197+
foundry_item_fields: { name: 'name', type: 'system.type', keywords: 'system.keywords' },
198+
});
199+
const abilities = JSON.parse(json);
200+
assert.deepEqual(abilities[0], { name: 'Ray of Wrath', type: 'main', keywords: ['magic', 'ranged'] });
201+
});
202+
203+
test('buildChronicleFields serializes a Set scalar on a string field to JSON (skills)', () => {
204+
const actor = { system: { skills: { value: new Set(['alchemy', 'timescape']) } } };
205+
const out = buildChronicleFields(actor, [
206+
{ key: 'skills_json', foundry_path: 'system.skills.value', type: 'string' },
207+
]);
208+
assert.equal(typeof out.skills_json, 'string');
209+
assert.deepEqual(JSON.parse(out.skills_json), ['alchemy', 'timescape']);
210+
});
211+
212+
test('buildChronicleFields serializes actor.statuses (Set) for conditions', () => {
213+
const actor = { system: {}, statuses: new Set(['slowed', 'weakened']) };
214+
const out = buildChronicleFields(actor, [
215+
{ key: 'conditions_json', foundry_path: 'statuses', type: 'string' },
216+
]);
217+
assert.deepEqual(JSON.parse(out.conditions_json), ['slowed', 'weakened']);
218+
});

0 commit comments

Comments
 (0)