Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scripts/adapters/generic-adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export function getNestedValue(obj, path) {
* Reads field.foundry_collection off the actor, optionally filters by
* foundry_item_type, projects each entry per foundry_item_fields (or a default
* {id,name,type}), and returns a JSON string (type json/string) or a raw array.
* When field.foundry_item_single is set, collapses to 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.
* Defensive — a malformed actor/collection yields an empty result, never throws.
*
* @param {object} actor
Expand All @@ -176,6 +179,19 @@ export function extractCollectionField(actor, field) {
? field.foundry_item_fields
: null;

// Single-item collapse: "exactly one X item" fields (class/ancestry/kit)
// want the item's NAME as a plain string, not a one-element JSON array.
// Uses the first projected path when a projection is given, else item.name.
if (field.foundry_item_single) {
const first = contents[0];
if (!first) return '';
if (proj) {
const firstPath = Object.values(proj)[0];
return getNestedValue(first, firstPath) ?? '';
}
return first.name ?? '';
}

const items = contents.map((it) => {
if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null };
const out = {};
Expand Down
59 changes: 47 additions & 12 deletions scripts/capability-inspector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,27 @@ function _summarizeCollection(coll, withSampleSchema) {
}
result.types = [...typeSet];
if (withSampleSchema && contents.length > 0) {
const readSys = (it) => {
try {
const raw = it.system;
return raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {});
} catch (err) {
return {};
}
};
const first = contents[0];
let sys = {};
try {
const raw = first.system;
sys = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {});
} catch (err) {
sys = {};
}
result.sample = {
name: first.name ?? '(unnamed)',
type: first.type ?? null,
schema: walkSchema(sys, 'system', 3),
schema: walkSchema(readSys(first), 'system', 3),
};
// One representative item PER distinct type, so the report exposes the
// projectable fields for each (ability vs class vs feature vs kit…) — what
// a future collection mapping needs to know to project the right paths.
result.samples = result.types.map((t) => {
const it = contents.find((c) => c && c.type === t) || {};
return { type: t, name: it.name ?? '(unnamed)', schema: walkSchema(readSys(it), 'system', 2) };
});
}
return result;
}
Expand All @@ -161,6 +169,7 @@ export const CAP_STATUS = Object.freeze({
SYNCED: 'synced',
MAPPED_MISSING: 'mapped-missing',
DECLARED_UNMAPPED: 'declared-unmapped',
COLLECTION_MAPPED: 'collection-mapped',
AVAILABLE_UNMAPPED: 'available-unmapped',
COLLECTION: 'collection',
});
Expand Down Expand Up @@ -190,6 +199,22 @@ export function buildCapabilityReport(snapshot, fieldDefs) {
// 1. Walk the manifest: classify each declared field.
for (const f of defs) {
if (!f.foundry_path) {
// A field with no foundry_path may still be MAPPED via a collection
// (e.g. abilities_json → actor.items[type=ability], or class → the one
// class item). Only a field with neither is genuinely unmapped.
if (f.foundry_collection) {
const types = Array.isArray(f.foundry_item_type) ? f.foundry_item_type.join(',') : (f.foundry_item_type || '');
const desc = `${f.foundry_collection}[${types}]${f.foundry_item_single ? ' (single)' : ''}`;
rows.push({
status: CAP_STATUS.COLLECTION_MAPPED,
key: f.key,
foundry_path: desc,
type: f.type || null,
writable: f.foundry_writable !== false,
sample: undefined,
});
continue;
}
rows.push({
status: CAP_STATUS.DECLARED_UNMAPPED,
key: f.key,
Expand Down Expand Up @@ -233,8 +258,9 @@ export function buildCapabilityReport(snapshot, fieldDefs) {
source: 'actor.items',
count: snapshot.items.count,
types: snapshot.items.types,
note: 'Embedded items (abilities/inventory) — not pullable by the dot-path adapter yet (WS-3).',
note: 'Embedded items. Pullable via a collection mapping (foundry_collection + foundry_item_type[, foundry_item_single]); per-type schemas below show the projectable fields.',
sample: snapshot.items.sample,
samples: snapshot.items.samples || [],
});
}
if (snapshot.effects && snapshot.effects.available) {
Expand Down Expand Up @@ -267,6 +293,7 @@ function _summarize(rows, collections) {
synced: 0,
mappedMissing: 0,
declaredUnmapped: 0,
collectionMapped: 0,
availableUnmapped: 0,
collectionsAvailable: collections.length,
totalDeclared: 0,
Expand All @@ -275,9 +302,10 @@ function _summarize(rows, collections) {
if (r.status === CAP_STATUS.SYNCED) c.synced++;
else if (r.status === CAP_STATUS.MAPPED_MISSING) c.mappedMissing++;
else if (r.status === CAP_STATUS.DECLARED_UNMAPPED) c.declaredUnmapped++;
else if (r.status === CAP_STATUS.COLLECTION_MAPPED) c.collectionMapped++;
else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) c.availableUnmapped++;
}
c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped;
c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped + c.collectionMapped;
return c;
}

Expand All @@ -296,7 +324,8 @@ export function renderCapabilityMarkdown(report) {
lines.push(`Sampled actor: **${report.source.actorName}** (type \`${report.source.actorType}\`)`);
lines.push('');
lines.push(`- Synced: **${s.synced}** / ${s.totalDeclared} declared`);
lines.push(`- Declared but unmapped (no foundry_path): **${s.declaredUnmapped}**`);
lines.push(`- Collection-mapped (items, e.g. abilities/class): **${s.collectionMapped}**`);
lines.push(`- Declared but unmapped (no foundry_path/collection): **${s.declaredUnmapped}**`);
lines.push(`- Mapped but missing on this actor: **${s.mappedMissing}**`);
lines.push(`- Available but not mapped (candidates): **${s.availableUnmapped}**`);
lines.push(`- Collections available (items/effects): **${s.collectionsAvailable}**`);
Expand All @@ -308,9 +337,15 @@ export function renderCapabilityMarkdown(report) {
}
if (report.collections.length) {
lines.push('');
lines.push('## Collections (not yet pullable)');
lines.push('## Collections');
for (const c of report.collections) {
lines.push(`- \`${c.source}\` — ${c.count} item(s), types: ${c.types.join(', ') || '—'} — ${c.note}`);
// Per-type item schemas — the projectable fields for each item type, so a
// collection mapping can target the right foundry_item_fields paths.
for (const sample of (c.samples || [])) {
const paths = (sample.schema || []).map((r) => `\`${r.path}\` (${r.type})`).join(', ');
lines.push(` - **${sample.type}** — e.g. "${sample.name}": ${paths || '(no system fields)'}`);
}
}
}
return lines.join('\n') + '\n';
Expand Down
1 change: 1 addition & 0 deletions templates/sync-dashboard.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@
</div>
<div class="diagnostics-detail">
<div class="system-info-row"><span class="system-label">Synced:</span><span class="system-value">{{capability.summary.synced}} / {{capability.summary.totalDeclared}} declared</span></div>
<div class="system-info-row"><span class="system-label">Collection-mapped (items):</span><span class="system-value">{{capability.summary.collectionMapped}}</span></div>
<div class="system-info-row"><span class="system-label">Declared, unmapped:</span><span class="system-value">{{capability.summary.declaredUnmapped}}</span></div>
<div class="system-info-row"><span class="system-label">Mapped, missing on actor:</span><span class="system-value">{{capability.summary.mappedMissing}}</span></div>
<div class="system-info-row"><span class="system-label">Available, not mapped:</span><span class="system-value">{{capability.summary.availableUnmapped}}</span></div>
Expand Down
33 changes: 33 additions & 0 deletions tools/test-capability-inspector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,39 @@ test('buildCapabilityReport classifies synced / mapped-missing / declared-unmapp
assert.ok(report.summary.availableUnmapped >= 2);
});

test('buildCapabilityReport: a foundry_collection field is collection-mapped, not declared-unmapped', () => {
const defs = {
preset_slug: 'drawsteel-character',
foundry_actor_type: 'hero',
fields: [
{ key: 'abilities_json', type: 'string', foundry_collection: 'items', foundry_item_type: ['ability'] },
{ key: 'class', type: 'string', foundry_collection: 'items', foundry_item_type: ['class'], foundry_item_single: true },
{ key: 'immunities', type: 'string' }, // genuinely unmapped (no path, no collection)
],
};
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), defs);
const byKey = Object.fromEntries(report.fields.filter((r) => r.key).map((r) => [r.key, r]));
assert.equal(byKey.abilities_json.status, CAP_STATUS.COLLECTION_MAPPED);
assert.equal(byKey.class.status, CAP_STATUS.COLLECTION_MAPPED);
assert.match(byKey.class.foundry_path, /items\[class\] \(single\)/); // descriptor surfaced
assert.equal(byKey.immunities.status, CAP_STATUS.DECLARED_UNMAPPED);
assert.equal(report.summary.collectionMapped, 2);
assert.equal(report.summary.declaredUnmapped, 1);
});

test('collections expose a sample item schema per type (enables item-field mapping)', () => {
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS);
const items = report.collections.find((c) => c.source === 'actor.items');
assert.ok(items, 'expected actor.items collection');
const byType = Object.fromEntries((items.samples || []).map((s) => [s.type, s]));
assert.ok(byType.ability && byType.kit, 'expected one sample per distinct type');
// the ability sample exposes its system fields (e.g. keywords) for projection
assert.ok(byType.ability.schema.some((r) => r.path === 'system.keywords'));
// and the markdown renders the per-type schema lines
const md = renderCapabilityMarkdown(report);
assert.match(md, /\*\*ability\*\* — e\.g\. "Ashfall Strike"/);
});

test('renderers produce a titled markdown report and valid JSON; bad input tolerated', () => {
const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS);
const md = renderCapabilityMarkdown(report);
Expand Down
33 changes: 33 additions & 0 deletions tools/test-generic-adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ function makeActor() {
{ id: 'i1', name: 'Ashfall Strike', type: 'ability', system: { keywords: ['melee'], heroic: 3 } },
{ id: 'i2', name: 'Mountain Stance', type: 'ability', system: { keywords: ['stance'] } },
{ id: 'i3', name: 'Ashbrand', type: 'equipment', system: { equipped: true } },
{ id: 'i4', name: 'Conduit', type: 'class', system: { level: 4 } },
{ id: 'i5', name: 'Hakaan', type: 'ancestry', system: {} },
],
},
};
Expand Down Expand Up @@ -71,6 +73,37 @@ test('extractCollectionField supports multiple types + default projection + raw
assert.deepEqual(arr[0], { id: 'i3', name: 'Ashbrand', type: 'equipment' });
});

test('extractCollectionField single → first matching item name (class/ancestry/kit)', () => {
const cls = extractCollectionField(makeActor(), {
key: 'class', type: 'string',
foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true,
});
assert.equal(cls, 'Conduit'); // plain string, not a JSON array

const anc = extractCollectionField(makeActor(), {
key: 'ancestry', type: 'string',
foundry_collection: 'items', foundry_item_type: ['ancestry'], foundry_item_single: true,
});
assert.equal(anc, 'Hakaan');
});

test('extractCollectionField single honors a projection path and is empty when none match', () => {
// projection → use the first projected path instead of name
const lvl = extractCollectionField(makeActor(), {
key: 'class_level', type: 'string',
foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true,
foundry_item_fields: { level: 'system.level' },
});
assert.equal(lvl, 4);

// no matching item → empty string (a string field, not '[]')
const none = extractCollectionField(makeActor(), {
key: 'kit', type: 'string',
foundry_collection: 'items', foundry_item_type: 'kit', foundry_item_single: true,
});
assert.equal(none, '');
});

test('extractCollectionField is defensive (missing collection / bad actor → empty)', () => {
assert.equal(extractCollectionField({}, { key: 'x', type: 'string', foundry_collection: 'items' }), '[]');
assert.deepEqual(extractCollectionField({}, { key: 'x', type: 'array', foundry_collection: 'items' }), []);
Expand Down