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