Audit equipment icon quality
This commit is contained in:
81
docs/equipment-icon-quality-audit.md
Normal file
81
docs/equipment-icon-quality-audit.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Equipment Icon Quality Audit
|
||||
|
||||
Generated by `scripts/audit-equipment-icon-quality.mjs`.
|
||||
|
||||
## Summary
|
||||
|
||||
- Catalog items: 23
|
||||
- Generated procedural item textures: 23
|
||||
- Slots: accessory 7, armor 5, weapon 11
|
||||
- Ranks: common 11, treasure 12
|
||||
- Texture size: 64px
|
||||
- Drawing grid: 28 units scaled 2.29x into the texture
|
||||
- Runtime item icon scene: `src/game/scenes/CampScene.ts`
|
||||
- Observed runtime display sizes: `14px`, `20px`, `21px`, `22px`, `30px`
|
||||
- Direct item texture render sites found: 5
|
||||
|
||||
## Main Findings
|
||||
|
||||
- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.
|
||||
- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.
|
||||
- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.
|
||||
- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.
|
||||
- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.
|
||||
|
||||
## First Improvement Target
|
||||
|
||||
- Target: weapon spear/polearm family
|
||||
- Item ids: `green-dragon-glaive`, `serpent-spear`, `sky-piercer-halberd`, `iron-spear`, `western-cavalry-spear`
|
||||
- Reason: These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.
|
||||
|
||||
## Follow-Up Targets
|
||||
|
||||
- armor silhouettes: `oath-robe`, `reinforced-lamellar`, `cloth-armor`, `lamellar-armor`, `rebel-vest`
|
||||
- Armor and robes are readable but depend on simple triangular torsos and small color accents.
|
||||
- accessory charms and tokens: `bravery-token`, `mountain-guide-scroll`, `peach-charm`, `war-manual`, `wind-quiver`, `grain-pouch`, `yellow-scarf-charm`
|
||||
- Accessories are the smallest UI read and several use compact badge-like shapes.
|
||||
|
||||
## Highest-Risk Shared Silhouette Groups
|
||||
|
||||
| score | signature | slots | ranks | items |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 15 | `drawSpearIcon` | weapon | common, treasure | `serpent-spear`, `iron-spear`, `western-cavalry-spear` |
|
||||
| 8 | `drawArmorIcon` | armor | common, treasure | `reinforced-lamellar`, `lamellar-armor` |
|
||||
| 8 | `drawCharmIcon` | accessory | common, treasure | `peach-charm`, `yellow-scarf-charm` |
|
||||
| 8 | `drawPolearmIcon` | weapon | treasure | `green-dragon-glaive`, `sky-piercer-halberd` |
|
||||
| 8 | `drawRobeIcon` | armor | common, treasure | `oath-robe`, `cloth-armor` |
|
||||
|
||||
## Full Item Inventory
|
||||
|
||||
| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |
|
||||
| --- | --- | --- | --- | ---: | ---: | ---: | --- |
|
||||
| `green-dragon-glaive` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `serpent-spear` | weapon | treasure | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `sky-piercer-halberd` | weapon | treasure | `drawPolearmIcon` | 1 | 11 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `twin-oath-blades` | weapon | treasure | `drawSwordIcon`<br>`drawSwordIcon` | 2 | 32 | 3 | none |
|
||||
| `white-feather-fan` | weapon | treasure | `drawFanIcon` | 1 | 19 | 2 | single helper silhouette |
|
||||
| `iron-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `leader-axe` | weapon | common | `drawAxeIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `short-bow` | weapon | common | `drawBowIcon` | 1 | 18 | 2 | single helper silhouette |
|
||||
| `training-sword` | weapon | common | `drawSwordIcon` | 1 | 16 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `western-cavalry-spear` | weapon | common | `drawSpearIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `yellow-turban-saber` | weapon | common | `drawSaberIcon` | 1 | 12 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `oath-robe` | armor | treasure | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `reinforced-lamellar` | armor | treasure | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `cloth-armor` | armor | common | `drawRobeIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `lamellar-armor` | armor | common | `drawArmorIcon` | 1 | 12 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `rebel-vest` | armor | common | `drawVestIcon` | 1 | 4 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `bravery-token` | accessory | treasure | `drawTokenIcon` | 1 | 4 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `mountain-guide-scroll` | accessory | treasure | `drawMapScrollIcon` | 1 | 13 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `peach-charm` | accessory | treasure | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `war-manual` | accessory | treasure | `drawBookIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `wind-quiver` | accessory | treasure | `drawQuiverIcon` | 1 | 8 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `grain-pouch` | accessory | common | `drawPouchIcon` | 1 | 7 | 2 | single helper silhouette<br>low primitive detail |
|
||||
| `yellow-scarf-charm` | accessory | common | `drawCharmIcon` | 1 | 4 | 2 | single helper silhouette<br>low primitive detail |
|
||||
|
||||
## Recommended Next Batch Definition
|
||||
|
||||
- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable.
|
||||
- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.
|
||||
- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.
|
||||
- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser.
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "tsc && vite build",
|
||||
"generate:audio": "node scripts/generate-bgm.mjs",
|
||||
"generate:sfx": "node scripts/generate-sfx.mjs",
|
||||
"audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"measure:performance": "node scripts/measure-performance.mjs",
|
||||
"verify:performance": "node scripts/measure-performance.mjs",
|
||||
|
||||
421
scripts/audit-equipment-icon-quality.mjs
Normal file
421
scripts/audit-equipment-icon-quality.mjs
Normal file
@@ -0,0 +1,421 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const bootScenePath = 'src/game/scenes/BootScene.ts';
|
||||
const campScenePath = 'src/game/scenes/CampScene.ts';
|
||||
const reportPath = 'docs/equipment-icon-quality-audit.md';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
||||
const bootSceneSource = readFileSync(bootScenePath, 'utf8');
|
||||
const campSceneSource = readFileSync(campScenePath, 'utf8');
|
||||
|
||||
const iconSourceSize = numberConstant(bootSceneSource, 'itemIconSourceSize');
|
||||
const iconTextureSize = numberConstant(bootSceneSource, 'itemIconTextureSize');
|
||||
const drawFunctions = parseDrawFunctions(bootSceneSource);
|
||||
const iconDefinitions = parseIconDefinitions(bootSceneSource, drawFunctions);
|
||||
const sceneUsage = parseSceneUsage(campSceneSource);
|
||||
const itemRows = collectItemRows(itemCatalog, equipmentSlots, iconDefinitions);
|
||||
const groupedRows = groupBySignature(itemRows);
|
||||
const recommendations = buildRecommendations(itemRows, groupedRows);
|
||||
const report = renderReport({
|
||||
iconSourceSize,
|
||||
iconTextureSize,
|
||||
itemRows,
|
||||
groupedRows,
|
||||
recommendations,
|
||||
sceneUsage
|
||||
});
|
||||
|
||||
writeFileSync(reportPath, report, 'utf8');
|
||||
console.log(`Wrote ${reportPath}`);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function numberConstant(source, name) {
|
||||
const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`));
|
||||
if (!match) {
|
||||
throw new Error(`Could not find ${name}`);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function parseIconDefinitions(source, drawFunctions) {
|
||||
const definitions = new Map();
|
||||
const matcher = /this\.createItemIcon\('item-([^']+)'/g;
|
||||
let match;
|
||||
|
||||
while ((match = matcher.exec(source))) {
|
||||
const itemId = match[1];
|
||||
const callText = expressionSpan(source, match.index);
|
||||
const drawCalls = [...callText.matchAll(/this\.(draw[A-Za-z]+Icon)\(([^)]*)\)/g)].map((drawMatch) => ({
|
||||
functionName: drawMatch[1],
|
||||
colors: [...drawMatch[2].matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase())
|
||||
}));
|
||||
const colors = [...new Set(drawCalls.flatMap((call) => call.colors))].sort();
|
||||
const primitiveCount = drawCalls.reduce((sum, call) => sum + (drawFunctions.get(call.functionName)?.primitiveCount ?? 0), 0);
|
||||
const lineNumber = source.slice(0, match.index).split(/\r?\n/).length;
|
||||
|
||||
definitions.set(itemId, {
|
||||
itemId,
|
||||
lineNumber,
|
||||
drawCalls,
|
||||
drawFunctionNames: drawCalls.map((call) => call.functionName),
|
||||
colors,
|
||||
primitiveCount,
|
||||
signature: drawCalls.map((call) => call.functionName).sort().join('+')
|
||||
});
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function expressionSpan(source, startIndex) {
|
||||
const openIndex = source.indexOf('(', startIndex);
|
||||
if (openIndex < 0) {
|
||||
throw new Error(`Could not find call expression at ${startIndex}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = openIndex; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"' || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unclosed call expression at ${startIndex}`);
|
||||
}
|
||||
|
||||
function parseDrawFunctions(source) {
|
||||
const functions = new Map();
|
||||
const matcher = /private\s+(draw[A-Za-z]+Icon)\([^)]*\)\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = matcher.exec(source))) {
|
||||
const functionName = match[1];
|
||||
const body = blockSpan(source, source.indexOf('{', match.index));
|
||||
functions.set(functionName, {
|
||||
functionName,
|
||||
primitiveCount: countGraphicsPrimitives(body),
|
||||
colors: [...new Set([...body.matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()))].sort()
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
function blockSpan(source, openIndex) {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = openIndex; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"' || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(openIndex + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unclosed block at ${openIndex}`);
|
||||
}
|
||||
|
||||
function countGraphicsPrimitives(body) {
|
||||
return [
|
||||
...body.matchAll(
|
||||
/graphics\.(fillTriangle|fillRect|fillRoundedRect|fillEllipse|fillCircle|lineStyle|beginPath|moveTo|lineTo|strokePath|strokeRect|strokeRoundedRect|strokeCircle)/g
|
||||
)
|
||||
].length;
|
||||
}
|
||||
|
||||
function parseSceneUsage(source) {
|
||||
const sizes = new Set();
|
||||
const usageBlocks = [...source.matchAll(/add\.image\([^`]+`item-\$\{[^`]+`[\s\S]{0,240}?setDisplaySize\((\d+),\s*(\d+)\)/g)];
|
||||
const sortieSignature = source.match(/renderSortieEquipmentIcons\([^)]*iconSize\s*=\s*(\d+)/);
|
||||
|
||||
usageBlocks.forEach((match) => {
|
||||
if (match[1] === match[2]) {
|
||||
sizes.add(Number(match[1]));
|
||||
} else {
|
||||
sizes.add(`${match[1]}x${match[2]}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (sortieSignature) {
|
||||
sizes.add(Number(sortieSignature[1]));
|
||||
}
|
||||
|
||||
const directItemImageCount = [...source.matchAll(/add\.image\([^`]+`item-\$\{/g)].length;
|
||||
return {
|
||||
scene: campScenePath,
|
||||
displaySizes: [...sizes].sort((left, right) => Number(left) - Number(right)),
|
||||
directItemImageCount
|
||||
};
|
||||
}
|
||||
|
||||
function collectItemRows(itemCatalog, equipmentSlots, iconDefinitions) {
|
||||
const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index]));
|
||||
|
||||
return Object.entries(itemCatalog)
|
||||
.map(([itemId, item]) => {
|
||||
const icon = iconDefinitions.get(itemId);
|
||||
const concerns = [];
|
||||
if (!icon) {
|
||||
concerns.push('missing generated icon');
|
||||
} else {
|
||||
if (icon.drawCalls.length <= 1) {
|
||||
concerns.push('single helper silhouette');
|
||||
}
|
||||
if (icon.primitiveCount < 18) {
|
||||
concerns.push('low primitive detail');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
itemId,
|
||||
slot: item.slot,
|
||||
rank: item.rank,
|
||||
attackBonus: item.attackBonus ?? 0,
|
||||
defenseBonus: item.defenseBonus ?? 0,
|
||||
strategyBonus: item.strategyBonus ?? 0,
|
||||
icon,
|
||||
signature: icon?.signature ?? 'missing',
|
||||
drawFunctions: icon?.drawFunctionNames ?? [],
|
||||
drawCalls: icon?.drawCalls.length ?? 0,
|
||||
primitiveCount: icon?.primitiveCount ?? 0,
|
||||
colorCount: icon?.colors.length ?? 0,
|
||||
concerns
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const slotDiff = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99);
|
||||
if (slotDiff !== 0) {
|
||||
return slotDiff;
|
||||
}
|
||||
if (left.rank !== right.rank) {
|
||||
return left.rank === 'treasure' ? -1 : 1;
|
||||
}
|
||||
return left.itemId.localeCompare(right.itemId);
|
||||
});
|
||||
}
|
||||
|
||||
function groupBySignature(itemRows) {
|
||||
const groups = new Map();
|
||||
itemRows.forEach((row) => {
|
||||
const group = groups.get(row.signature) ?? [];
|
||||
group.push(row);
|
||||
groups.set(row.signature, group);
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([signature, rows]) => ({
|
||||
signature,
|
||||
rows,
|
||||
slots: [...new Set(rows.map((row) => row.slot))].sort(),
|
||||
ranks: [...new Set(rows.map((row) => row.rank))].sort(),
|
||||
itemIds: rows.map((row) => row.itemId),
|
||||
score: groupRiskScore(rows)
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature));
|
||||
}
|
||||
|
||||
function groupRiskScore(rows) {
|
||||
const slots = new Set(rows.map((row) => row.slot));
|
||||
const ranks = new Set(rows.map((row) => row.rank));
|
||||
let score = rows.length;
|
||||
|
||||
if (rows.length >= 3) {
|
||||
score += 3;
|
||||
}
|
||||
if (slots.has('weapon')) {
|
||||
score += 3;
|
||||
}
|
||||
if (ranks.has('treasure') && ranks.has('common')) {
|
||||
score += 3;
|
||||
}
|
||||
if (rows.some((row) => row.primitiveCount < 18)) {
|
||||
score += 2;
|
||||
}
|
||||
if (rows.some((row) => row.rank === 'treasure')) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function buildRecommendations(itemRows, groupedRows) {
|
||||
const weaponPolearmIds = itemRows
|
||||
.filter((row) => row.slot === 'weapon' && ['drawSpearIcon', 'drawPolearmIcon'].some((name) => row.drawFunctions.includes(name)))
|
||||
.map((row) => row.itemId);
|
||||
const armorIds = itemRows.filter((row) => row.slot === 'armor').map((row) => row.itemId);
|
||||
const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId);
|
||||
|
||||
return {
|
||||
firstTargetTitle: 'weapon spear/polearm family',
|
||||
firstTargetIds: weaponPolearmIds,
|
||||
firstTargetReason:
|
||||
'These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.',
|
||||
followUps: [
|
||||
{
|
||||
title: 'armor silhouettes',
|
||||
itemIds: armorIds,
|
||||
reason: 'Armor and robes are readable but depend on simple triangular torsos and small color accents.'
|
||||
},
|
||||
{
|
||||
title: 'accessory charms and tokens',
|
||||
itemIds: accessoryIds,
|
||||
reason: 'Accessories are the smallest UI read and several use compact badge-like shapes.'
|
||||
}
|
||||
],
|
||||
topRiskGroups: groupedRows.slice(0, 5)
|
||||
};
|
||||
}
|
||||
|
||||
function renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }) {
|
||||
const slotCounts = countBy(itemRows, (row) => row.slot);
|
||||
const rankCounts = countBy(itemRows, (row) => row.rank);
|
||||
const generatedCount = itemRows.filter((row) => row.icon).length;
|
||||
const scale = (iconTextureSize / iconSourceSize).toFixed(2);
|
||||
|
||||
return `${[
|
||||
'# Equipment Icon Quality Audit',
|
||||
'',
|
||||
'Generated by `scripts/audit-equipment-icon-quality.mjs`.',
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
`- Catalog items: ${itemRows.length}`,
|
||||
`- Generated procedural item textures: ${generatedCount}`,
|
||||
`- Slots: ${formatCounts(slotCounts)}`,
|
||||
`- Ranks: ${formatCounts(rankCounts)}`,
|
||||
`- Texture size: ${iconTextureSize}px`,
|
||||
`- Drawing grid: ${iconSourceSize} units scaled ${scale}x into the texture`,
|
||||
`- Runtime item icon scene: \`${sceneUsage.scene}\``,
|
||||
`- Observed runtime display sizes: ${sceneUsage.displaySizes.map((size) => `\`${size}px\``).join(', ') || 'none found'}`,
|
||||
`- Direct item texture render sites found: ${sceneUsage.directItemImageCount}`,
|
||||
'',
|
||||
'## Main Findings',
|
||||
'',
|
||||
'- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.',
|
||||
'- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.',
|
||||
'- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.',
|
||||
'- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.',
|
||||
'- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.',
|
||||
'',
|
||||
'## First Improvement Target',
|
||||
'',
|
||||
`- Target: ${recommendations.firstTargetTitle}`,
|
||||
`- Item ids: ${recommendations.firstTargetIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
|
||||
`- Reason: ${recommendations.firstTargetReason}`,
|
||||
'',
|
||||
'## Follow-Up Targets',
|
||||
'',
|
||||
...recommendations.followUps.flatMap((target) => [
|
||||
`- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`,
|
||||
` - ${target.reason}`
|
||||
]),
|
||||
'',
|
||||
'## Highest-Risk Shared Silhouette Groups',
|
||||
'',
|
||||
'| score | signature | slots | ranks | items |',
|
||||
'| ---: | --- | --- | --- | --- |',
|
||||
...recommendations.topRiskGroups.map(
|
||||
(group) =>
|
||||
`| ${group.score} | \`${group.signature}\` | ${group.slots.join(', ')} | ${group.ranks.join(', ')} | ${group.itemIds.map((itemId) => `\`${itemId}\``).join(', ')} |`
|
||||
),
|
||||
'',
|
||||
'## Full Item Inventory',
|
||||
'',
|
||||
'| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |',
|
||||
'| --- | --- | --- | --- | ---: | ---: | ---: | --- |',
|
||||
...itemRows.map((row) =>
|
||||
[
|
||||
`\`${row.itemId}\``,
|
||||
row.slot,
|
||||
row.rank,
|
||||
row.drawFunctions.map((name) => `\`${name}\``).join('<br>') || 'missing',
|
||||
row.drawCalls,
|
||||
row.primitiveCount,
|
||||
row.colorCount,
|
||||
row.concerns.join('<br>') || 'none'
|
||||
].join(' | ')
|
||||
).map((line) => `| ${line} |`),
|
||||
'',
|
||||
'## Recommended Next Batch Definition',
|
||||
'',
|
||||
'- Scope: replace or enrich the five spear/polearm weapon icons while keeping `item-${itemId}` texture keys stable.',
|
||||
'- Success criteria: each item reads as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.',
|
||||
'- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.',
|
||||
'- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser.',
|
||||
''
|
||||
].join('\n')}`;
|
||||
}
|
||||
|
||||
function countBy(rows, keySelector) {
|
||||
return rows.reduce((counts, row) => {
|
||||
const key = keySelector(row);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function formatCounts(counts) {
|
||||
return [...counts.entries()]
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right)))
|
||||
.map(([key, count]) => `${key} ${count}`)
|
||||
.join(', ');
|
||||
}
|
||||
Reference in New Issue
Block a user