Skip to content

Commit ee959d4

Browse files
committed
feat(adapter,inspector): single-item collections + accurate capability report (WS-3)
Adapter: - extractCollectionField honors foundry_item_single → returns the first matching item's name (or first projected value) as a plain string, for "exactly one X item" fields like a hero's class/ancestry/kit/subclass. Capability Inspector: - Stop mislabeling collection-mapped fields (abilities_json, class, …) as "declared-unmapped" — they're mapped via a collection, so classify them collection-mapped and count them separately (Status panel + Copy Report). - Surface one sample item schema PER item type in the collections section (was: only the first item overall). The Copy Report now lists the projectable fields for ability/feature/class/ancestry/kit/… — exactly what's needed to finish the remaining item-field mappings (feature split, immunities/weaknesses). Tests: 568 node tests green (+3). New coverage for single-item extraction (name, projection, empty), collection-mapped classification, and per-type sample schemas in the report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf
1 parent cd8c823 commit ee959d4

5 files changed

Lines changed: 130 additions & 12 deletions

File tree

scripts/adapters/generic-adapter.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ export function getNestedValue(obj, path) {
150150
* Reads field.foundry_collection off the actor, optionally filters by
151151
* foundry_item_type, projects each entry per foundry_item_fields (or a default
152152
* {id,name,type}), and returns a JSON string (type json/string) or a raw array.
153+
* When field.foundry_item_single is set, collapses to the FIRST matching item's
154+
* name (or first projected value) as a plain string — for "exactly one X item"
155+
* fields like a hero's class/ancestry/kit.
153156
* Defensive — a malformed actor/collection yields an empty result, never throws.
154157
*
155158
* @param {object} actor
@@ -176,6 +179,19 @@ export function extractCollectionField(actor, field) {
176179
? field.foundry_item_fields
177180
: null;
178181

182+
// Single-item collapse: "exactly one X item" fields (class/ancestry/kit)
183+
// want the item's NAME as a plain string, not a one-element JSON array.
184+
// Uses the first projected path when a projection is given, else item.name.
185+
if (field.foundry_item_single) {
186+
const first = contents[0];
187+
if (!first) return '';
188+
if (proj) {
189+
const firstPath = Object.values(proj)[0];
190+
return getNestedValue(first, firstPath) ?? '';
191+
}
192+
return first.name ?? '';
193+
}
194+
179195
const items = contents.map((it) => {
180196
if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null };
181197
const out = {};

scripts/capability-inspector.mjs

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,27 @@ function _summarizeCollection(coll, withSampleSchema) {
132132
}
133133
result.types = [...typeSet];
134134
if (withSampleSchema && contents.length > 0) {
135+
const readSys = (it) => {
136+
try {
137+
const raw = it.system;
138+
return raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {});
139+
} catch (err) {
140+
return {};
141+
}
142+
};
135143
const first = contents[0];
136-
let sys = {};
137-
try {
138-
const raw = first.system;
139-
sys = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {});
140-
} catch (err) {
141-
sys = {};
142-
}
143144
result.sample = {
144145
name: first.name ?? '(unnamed)',
145146
type: first.type ?? null,
146-
schema: walkSchema(sys, 'system', 3),
147+
schema: walkSchema(readSys(first), 'system', 3),
147148
};
149+
// One representative item PER distinct type, so the report exposes the
150+
// projectable fields for each (ability vs class vs feature vs kit…) — what
151+
// a future collection mapping needs to know to project the right paths.
152+
result.samples = result.types.map((t) => {
153+
const it = contents.find((c) => c && c.type === t) || {};
154+
return { type: t, name: it.name ?? '(unnamed)', schema: walkSchema(readSys(it), 'system', 2) };
155+
});
148156
}
149157
return result;
150158
}
@@ -161,6 +169,7 @@ export const CAP_STATUS = Object.freeze({
161169
SYNCED: 'synced',
162170
MAPPED_MISSING: 'mapped-missing',
163171
DECLARED_UNMAPPED: 'declared-unmapped',
172+
COLLECTION_MAPPED: 'collection-mapped',
164173
AVAILABLE_UNMAPPED: 'available-unmapped',
165174
COLLECTION: 'collection',
166175
});
@@ -190,6 +199,22 @@ export function buildCapabilityReport(snapshot, fieldDefs) {
190199
// 1. Walk the manifest: classify each declared field.
191200
for (const f of defs) {
192201
if (!f.foundry_path) {
202+
// A field with no foundry_path may still be MAPPED via a collection
203+
// (e.g. abilities_json → actor.items[type=ability], or class → the one
204+
// class item). Only a field with neither is genuinely unmapped.
205+
if (f.foundry_collection) {
206+
const types = Array.isArray(f.foundry_item_type) ? f.foundry_item_type.join(',') : (f.foundry_item_type || '');
207+
const desc = `${f.foundry_collection}[${types}]${f.foundry_item_single ? ' (single)' : ''}`;
208+
rows.push({
209+
status: CAP_STATUS.COLLECTION_MAPPED,
210+
key: f.key,
211+
foundry_path: desc,
212+
type: f.type || null,
213+
writable: f.foundry_writable !== false,
214+
sample: undefined,
215+
});
216+
continue;
217+
}
193218
rows.push({
194219
status: CAP_STATUS.DECLARED_UNMAPPED,
195220
key: f.key,
@@ -233,8 +258,9 @@ export function buildCapabilityReport(snapshot, fieldDefs) {
233258
source: 'actor.items',
234259
count: snapshot.items.count,
235260
types: snapshot.items.types,
236-
note: 'Embedded items (abilities/inventory) — not pullable by the dot-path adapter yet (WS-3).',
261+
note: 'Embedded items. Pullable via a collection mapping (foundry_collection + foundry_item_type[, foundry_item_single]); per-type schemas below show the projectable fields.',
237262
sample: snapshot.items.sample,
263+
samples: snapshot.items.samples || [],
238264
});
239265
}
240266
if (snapshot.effects && snapshot.effects.available) {
@@ -267,6 +293,7 @@ function _summarize(rows, collections) {
267293
synced: 0,
268294
mappedMissing: 0,
269295
declaredUnmapped: 0,
296+
collectionMapped: 0,
270297
availableUnmapped: 0,
271298
collectionsAvailable: collections.length,
272299
totalDeclared: 0,
@@ -275,9 +302,10 @@ function _summarize(rows, collections) {
275302
if (r.status === CAP_STATUS.SYNCED) c.synced++;
276303
else if (r.status === CAP_STATUS.MAPPED_MISSING) c.mappedMissing++;
277304
else if (r.status === CAP_STATUS.DECLARED_UNMAPPED) c.declaredUnmapped++;
305+
else if (r.status === CAP_STATUS.COLLECTION_MAPPED) c.collectionMapped++;
278306
else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) c.availableUnmapped++;
279307
}
280-
c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped;
308+
c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped + c.collectionMapped;
281309
return c;
282310
}
283311

@@ -296,7 +324,8 @@ export function renderCapabilityMarkdown(report) {
296324
lines.push(`Sampled actor: **${report.source.actorName}** (type \`${report.source.actorType}\`)`);
297325
lines.push('');
298326
lines.push(`- Synced: **${s.synced}** / ${s.totalDeclared} declared`);
299-
lines.push(`- Declared but unmapped (no foundry_path): **${s.declaredUnmapped}**`);
327+
lines.push(`- Collection-mapped (items, e.g. abilities/class): **${s.collectionMapped}**`);
328+
lines.push(`- Declared but unmapped (no foundry_path/collection): **${s.declaredUnmapped}**`);
300329
lines.push(`- Mapped but missing on this actor: **${s.mappedMissing}**`);
301330
lines.push(`- Available but not mapped (candidates): **${s.availableUnmapped}**`);
302331
lines.push(`- Collections available (items/effects): **${s.collectionsAvailable}**`);
@@ -308,9 +337,15 @@ export function renderCapabilityMarkdown(report) {
308337
}
309338
if (report.collections.length) {
310339
lines.push('');
311-
lines.push('## Collections (not yet pullable)');
340+
lines.push('## Collections');
312341
for (const c of report.collections) {
313342
lines.push(`- \`${c.source}\` — ${c.count} item(s), types: ${c.types.join(', ') || '—'}${c.note}`);
343+
// Per-type item schemas — the projectable fields for each item type, so a
344+
// collection mapping can target the right foundry_item_fields paths.
345+
for (const sample of (c.samples || [])) {
346+
const paths = (sample.schema || []).map((r) => `\`${r.path}\` (${r.type})`).join(', ');
347+
lines.push(` - **${sample.type}** — e.g. "${sample.name}": ${paths || '(no system fields)'}`);
348+
}
314349
}
315350
}
316351
return lines.join('\n') + '\n';

templates/sync-dashboard.hbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,7 @@
10971097
</div>
10981098
<div class="diagnostics-detail">
10991099
<div class="system-info-row"><span class="system-label">Synced:</span><span class="system-value">{{capability.summary.synced}} / {{capability.summary.totalDeclared}} declared</span></div>
1100+
<div class="system-info-row"><span class="system-label">Collection-mapped (items):</span><span class="system-value">{{capability.summary.collectionMapped}}</span></div>
11001101
<div class="system-info-row"><span class="system-label">Declared, unmapped:</span><span class="system-value">{{capability.summary.declaredUnmapped}}</span></div>
11011102
<div class="system-info-row"><span class="system-label">Mapped, missing on actor:</span><span class="system-value">{{capability.summary.mappedMissing}}</span></div>
11021103
<div class="system-info-row"><span class="system-label">Available, not mapped:</span><span class="system-value">{{capability.summary.availableUnmapped}}</span></div>

tools/test-capability-inspector.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,39 @@ test('buildCapabilityReport classifies synced / mapped-missing / declared-unmapp
116116
assert.ok(report.summary.availableUnmapped >= 2);
117117
});
118118

119+
test('buildCapabilityReport: a foundry_collection field is collection-mapped, not declared-unmapped', () => {
120+
const defs = {
121+
preset_slug: 'drawsteel-character',
122+
foundry_actor_type: 'hero',
123+
fields: [
124+
{ key: 'abilities_json', type: 'string', foundry_collection: 'items', foundry_item_type: ['ability'] },
125+
{ key: 'class', type: 'string', foundry_collection: 'items', foundry_item_type: ['class'], foundry_item_single: true },
126+
{ key: 'immunities', type: 'string' }, // genuinely unmapped (no path, no collection)
127+
],
128+
};
129+
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), defs);
130+
const byKey = Object.fromEntries(report.fields.filter((r) => r.key).map((r) => [r.key, r]));
131+
assert.equal(byKey.abilities_json.status, CAP_STATUS.COLLECTION_MAPPED);
132+
assert.equal(byKey.class.status, CAP_STATUS.COLLECTION_MAPPED);
133+
assert.match(byKey.class.foundry_path, /items\[class\] \(single\)/); // descriptor surfaced
134+
assert.equal(byKey.immunities.status, CAP_STATUS.DECLARED_UNMAPPED);
135+
assert.equal(report.summary.collectionMapped, 2);
136+
assert.equal(report.summary.declaredUnmapped, 1);
137+
});
138+
139+
test('collections expose a sample item schema per type (enables item-field mapping)', () => {
140+
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS);
141+
const items = report.collections.find((c) => c.source === 'actor.items');
142+
assert.ok(items, 'expected actor.items collection');
143+
const byType = Object.fromEntries((items.samples || []).map((s) => [s.type, s]));
144+
assert.ok(byType.ability && byType.kit, 'expected one sample per distinct type');
145+
// the ability sample exposes its system fields (e.g. keywords) for projection
146+
assert.ok(byType.ability.schema.some((r) => r.path === 'system.keywords'));
147+
// and the markdown renders the per-type schema lines
148+
const md = renderCapabilityMarkdown(report);
149+
assert.match(md, /\*\*ability\*\* e\.g\. "Ashfall Strike"/);
150+
});
151+
119152
test('renderers produce a titled markdown report and valid JSON; bad input tolerated', () => {
120153
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS);
121154
const md = renderCapabilityMarkdown(report);

tools/test-generic-adapter.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ function makeActor() {
3030
{ id: 'i1', name: 'Ashfall Strike', type: 'ability', system: { keywords: ['melee'], heroic: 3 } },
3131
{ id: 'i2', name: 'Mountain Stance', type: 'ability', system: { keywords: ['stance'] } },
3232
{ id: 'i3', name: 'Ashbrand', type: 'equipment', system: { equipped: true } },
33+
{ id: 'i4', name: 'Conduit', type: 'class', system: { level: 4 } },
34+
{ id: 'i5', name: 'Hakaan', type: 'ancestry', system: {} },
3335
],
3436
},
3537
};
@@ -71,6 +73,37 @@ test('extractCollectionField supports multiple types + default projection + raw
7173
assert.deepEqual(arr[0], { id: 'i3', name: 'Ashbrand', type: 'equipment' });
7274
});
7375

76+
test('extractCollectionField single → first matching item name (class/ancestry/kit)', () => {
77+
const cls = extractCollectionField(makeActor(), {
78+
key: 'class', type: 'string',
79+
foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true,
80+
});
81+
assert.equal(cls, 'Conduit'); // plain string, not a JSON array
82+
83+
const anc = extractCollectionField(makeActor(), {
84+
key: 'ancestry', type: 'string',
85+
foundry_collection: 'items', foundry_item_type: ['ancestry'], foundry_item_single: true,
86+
});
87+
assert.equal(anc, 'Hakaan');
88+
});
89+
90+
test('extractCollectionField single honors a projection path and is empty when none match', () => {
91+
// projection → use the first projected path instead of name
92+
const lvl = extractCollectionField(makeActor(), {
93+
key: 'class_level', type: 'string',
94+
foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true,
95+
foundry_item_fields: { level: 'system.level' },
96+
});
97+
assert.equal(lvl, 4);
98+
99+
// no matching item → empty string (a string field, not '[]')
100+
const none = extractCollectionField(makeActor(), {
101+
key: 'kit', type: 'string',
102+
foundry_collection: 'items', foundry_item_type: 'kit', foundry_item_single: true,
103+
});
104+
assert.equal(none, '');
105+
});
106+
74107
test('extractCollectionField is defensive (missing collection / bad actor → empty)', () => {
75108
assert.equal(extractCollectionField({}, { key: 'x', type: 'string', foundry_collection: 'items' }), '[]');
76109
assert.deepEqual(extractCollectionField({}, { key: 'x', type: 'array', foundry_collection: 'items' }), []);

0 commit comments

Comments
 (0)