Skip to content

Commit d62ac15

Browse files
authored
Merge pull request #71 from keyxmakerx/claude/chronicle-sheet-sync-j2m9s4
Sync Capability Inspector, generic collection pulls, Diagnostic Bundle, central logger
2 parents 6b77be0 + 6c9a491 commit d62ac15

10 files changed

Lines changed: 1297 additions & 13 deletions

scripts/adapters/generic-adapter.mjs

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,24 @@
77
* without a hand-written adapter, as long as the system manifest includes
88
* foundry_path annotations on its character fields.
99
*
10-
* Field definitions specify:
10+
* Field definitions specify either a SCALAR mapping or a COLLECTION mapping:
11+
*
12+
* Scalar (a single value on actor.system):
1113
* - key: Chronicle field key (e.g. "hp_current")
1214
* - foundry_path: dot-notation path on actor.system (e.g. "system.attributes.hp.value")
1315
* - foundry_writable: whether Chronicle may write back to this Foundry path (default true)
1416
* - type: field type ("number", "string", etc.) for casting
17+
*
18+
* Collection (embedded documents — abilities, inventory, features — that live in
19+
* actor.items[] etc., which a dot-path cannot reach):
20+
* - key: Chronicle field key (e.g. "abilities_json")
21+
* - foundry_collection: the actor collection to read ("items", "effects")
22+
* - foundry_item_type: optional Foundry item type(s) to keep (string or string[])
23+
* - foundry_item_fields: projection { outKey: "dot.path.on.item" }; omit for a
24+
* default {id,name,type} projection
25+
* - type: "json"/"string" → serialized JSON string; else a raw array
26+
* Collection fields are READ-ONLY today (pull only); write-back is a future tier,
27+
* so they are never included in the Foundry update path.
1528
*/
1629

1730
/**
@@ -35,14 +48,17 @@ export async function createGenericAdapter(api, chronicleSystemId) {
3548
return null;
3649
}
3750

38-
// Only include fields that have a foundry_path annotation.
39-
const mappedFields = fieldDefs.fields.filter((f) => f.foundry_path);
51+
// A field is pullable if it maps a scalar (foundry_path) OR a collection
52+
// (foundry_collection). Both are read on toChronicleFields.
53+
const mappedFields = fieldDefs.fields.filter((f) => f.foundry_path || f.foundry_collection);
4054
if (mappedFields.length === 0) {
41-
console.warn(`Chronicle: Generic adapter — no fields with foundry_path for "${chronicleSystemId}"`);
55+
console.warn(`Chronicle: Generic adapter — no fields with foundry_path/foundry_collection for "${chronicleSystemId}"`);
4256
return null;
4357
}
4458

45-
const writableFields = mappedFields.filter((f) => f.foundry_writable !== false);
59+
// Only scalar (foundry_path) fields are writable back to Foundry — collection
60+
// write-back is a future tier, so it is excluded from the update path here.
61+
const writableFields = mappedFields.filter((f) => f.foundry_path && f.foundry_writable !== false);
4662

4763
console.debug(
4864
`Chronicle: Generic adapter loaded for "${chronicleSystemId}" — ` +
@@ -72,12 +88,7 @@ export async function createGenericAdapter(api, chronicleSystemId) {
7288
* @returns {object} Chronicle fields_data object.
7389
*/
7490
toChronicleFields(actor) {
75-
const result = {};
76-
for (const field of mappedFields) {
77-
const value = _getNestedValue(actor, field.foundry_path);
78-
result[field.key] = value ?? null;
79-
}
80-
return result;
91+
return buildChronicleFields(actor, mappedFields);
8192
},
8293

8394
/**
@@ -117,13 +128,14 @@ export async function createGenericAdapter(api, chronicleSystemId) {
117128
/**
118129
* Read a nested value from an object using dot-notation path.
119130
* Supports both nested objects and Foundry's system data.
120-
* e.g., _getNestedValue(actor, "system.abilities.str.value")
131+
* e.g., getNestedValue(actor, "system.abilities.str.value")
121132
*
122133
* @param {object} obj
123134
* @param {string} path
124135
* @returns {*}
125136
*/
126-
function _getNestedValue(obj, path) {
137+
export function getNestedValue(obj, path) {
138+
if (!path) return undefined;
127139
const keys = path.split('.');
128140
let current = obj;
129141
for (const key of keys) {
@@ -132,3 +144,72 @@ function _getNestedValue(obj, path) {
132144
}
133145
return current;
134146
}
147+
148+
/**
149+
* Extract a collection-mapped field (e.g. abilities/inventory from actor.items[]).
150+
* Reads field.foundry_collection off the actor, optionally filters by
151+
* foundry_item_type, projects each entry per foundry_item_fields (or a default
152+
* {id,name,type}), and returns a JSON string (type json/string) or a raw array.
153+
* Defensive — a malformed actor/collection yields an empty result, never throws.
154+
*
155+
* @param {object} actor
156+
* @param {object} field - field def with foundry_collection
157+
* @returns {string|Array}
158+
*/
159+
export function extractCollectionField(actor, field) {
160+
const wantJson = field.type === 'json' || field.type === 'string';
161+
const empty = wantJson ? '[]' : [];
162+
try {
163+
const coll = actor?.[field.foundry_collection];
164+
if (!coll) return empty;
165+
let contents = coll.contents
166+
|| (typeof coll[Symbol.iterator] === 'function' ? Array.from(coll) : []);
167+
168+
if (field.foundry_item_type) {
169+
const types = Array.isArray(field.foundry_item_type)
170+
? field.foundry_item_type
171+
: [field.foundry_item_type];
172+
contents = contents.filter((it) => it && types.includes(it.type));
173+
}
174+
175+
const proj = field.foundry_item_fields && typeof field.foundry_item_fields === 'object'
176+
? field.foundry_item_fields
177+
: null;
178+
179+
const items = contents.map((it) => {
180+
if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null };
181+
const out = {};
182+
for (const [outKey, path] of Object.entries(proj)) {
183+
out[outKey] = getNestedValue(it, path) ?? null;
184+
}
185+
return out;
186+
});
187+
188+
return wantJson ? JSON.stringify(items) : items;
189+
} catch (err) {
190+
console.warn(`Chronicle: generic adapter — collection extract failed for "${field.key}"`, err);
191+
return empty;
192+
}
193+
}
194+
195+
/**
196+
* Build a Chronicle fields_data object from a Foundry actor and the mapped field
197+
* defs. Scalar fields read their foundry_path; collection fields extract from the
198+
* named actor collection. PURE (no Foundry globals) → unit-testable.
199+
*
200+
* @param {object} actor
201+
* @param {Array<object>} mappedFields
202+
* @returns {object}
203+
*/
204+
export function buildChronicleFields(actor, mappedFields) {
205+
const result = {};
206+
for (const field of mappedFields) {
207+
if (field.foundry_collection) {
208+
result[field.key] = extractCollectionField(actor, field);
209+
} else {
210+
const value = getNestedValue(actor, field.foundry_path);
211+
result[field.key] = value ?? null;
212+
}
213+
}
214+
return result;
215+
}

0 commit comments

Comments
 (0)