243 lines
14 KiB
JavaScript
243 lines
14 KiB
JavaScript
/**
|
||
* dev/jobboard-voice-shot.mjs — the scripted RENDER PASS for the faction voices.
|
||
*
|
||
* node dev/jobboard-voice-shot.mjs # writes dev/shots/voice-*.txt + voice-report.json
|
||
*
|
||
* WHY (the copy's contract): data/sidequests.json now carries NINE faction
|
||
* pools × 20 variants (apex / mindweave / zenith × patrol / bounty hunt /
|
||
* escort — 180 listings). Copy that reads well in a JSON file proves nothing
|
||
* until it has been through the panel it lives in: JobBoardWindow's detail
|
||
* readout is a FIXED height, its body copy passes through fitText(), and an
|
||
* overflow ships as an ellipsis — a dossier that ends in '…' lost its charge,
|
||
* its mark, or its punch. This probe renders each faction's board with the
|
||
* REAL window (dev/jobboard.test.mjs's stub scene) against the REAL data and
|
||
* per listing records:
|
||
* · the resolved title + issuer + body exactly as the panel shows them,
|
||
* · whether any {placeholder} survived into a rendered line,
|
||
* · whether fitText CLIPPED the body (a '…' the posting's own text lacks),
|
||
* · the widest rendered line against the panel's right edge,
|
||
* · for a hunt, the dossier block (WANTED / epithet / CHARGES / CRAFT).
|
||
*
|
||
* It walks enough worlds to put all 60 of a faction's listings on screen
|
||
* (20 per category), so every one of the 180 is read as the player reads it.
|
||
*/
|
||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import fs from 'node:fs';
|
||
import assert from 'node:assert/strict';
|
||
const VOICE = await import(new URL('./lib/faction-voice-lint.mjs', import.meta.url).href);
|
||
import './phaser-loader.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||
const dataDir = join(__dirname, '../data');
|
||
const configData = {};
|
||
for (const f of fs.readdirSync(dataDir)) {
|
||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||
}
|
||
config.init(configData);
|
||
|
||
const { JobBoardWindow } = await import(pathToFileURL(join(__dirname, '../js/ui/JobBoardWindow.js')).href);
|
||
const { sideQuestPostingsFor, resolveSideQuestText, sideQuestFactionLabels } = await import(
|
||
pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href
|
||
);
|
||
|
||
// --- The stub scene (dev/jobboard.test.mjs's recipe — the window's seams). ---
|
||
function stubObject(extra = {}) {
|
||
return {
|
||
x: 0, y: 0, width: 60, height: 18, alpha: 1, visible: true,
|
||
setOrigin() { return this; }, setVisible(v) { this.visible = v; return this; },
|
||
setAlpha(a) { this.alpha = a; return this; }, setScale() { return this; },
|
||
setTint() { return this; }, setBlendMode() { return this; }, setScrollFactor() { return this; },
|
||
setDepth() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; },
|
||
setX(x) { this.x = x; return this; }, setY(y) { this.y = y; return this; },
|
||
setStrokeStyle() { return this; }, setSize(w, h) { this.width = w; this.height = h; return this; },
|
||
setDisplaySize(w, h) { this.displayWidth = w; this.displayHeight = h; return this; },
|
||
setInteractive() { return this; }, disableInteractive() { return this; },
|
||
on(ev, fn) { (this.__on ??= {})[ev] = fn; return this; }, once() { return this; }, off() { return this; },
|
||
setText(s) { this.text = String(s); this.width = Math.max(20, String(s).length * 7); return this; },
|
||
setColor(c) { this.color = c; return this; }, setFontSize() { return this; },
|
||
setFillStyle() { return this; }, fillStyle() { return this; }, lineStyle() { return this; },
|
||
fillPoints() { return this; }, strokePoints() { return this; }, fillRect() { return this; },
|
||
strokeRect() { return this; }, lineBetween() { return this; }, clear() { return this; },
|
||
destroy() { this.destroyed = true; },
|
||
...extra,
|
||
};
|
||
}
|
||
function makeScene() {
|
||
const video = stubObject({ key: JobBoardWindow.VIDEO_KEY, volume: 1, loop: false });
|
||
video.setVolume = (v) => { video.volume = v; return video; };
|
||
video.setLoop = (b) => { video.loop = b; return video; };
|
||
video.video = { muted: false, readyState: 0, videoWidth: 544, videoHeight: 800, width: 544, height: 800 };
|
||
video.stop = () => {};
|
||
return {
|
||
scale: { width: 1280, height: 720 },
|
||
add: {
|
||
existing: (o) => o,
|
||
text: (x, y, str) => stubObject({ x, y, text: String(str), width: Math.max(20, String(str).length * 7) }),
|
||
image: (x, y, key) => stubObject({ x, y, key }),
|
||
graphics: () => stubObject({}),
|
||
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||
circle: (x, y, r) => stubObject({ x, y, radius: r }),
|
||
video: (x, y, key) => { video.x = x; video.y = y; video.key = key; return video; },
|
||
},
|
||
cache: { video: { has: (key) => key === JobBoardWindow.VIDEO_KEY } },
|
||
tweens: { add: (t) => { if (typeof t?.onComplete === 'function') t.onComplete(); return { destroy() {}, stop() {} }; }, killTweensOf() {} },
|
||
time: { now: 1000, delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; } },
|
||
textures: { exists: () => true, addCanvas() {} },
|
||
};
|
||
}
|
||
|
||
const FACTIONS = ['apex', 'mindweave', 'zenith'];
|
||
const PROPER = { apex: 'Apex', mindweave: 'MindWeave', zenith: 'Zenith' };
|
||
// One world per faction + a rival-held neighbour (the hunts'/escorts' ground).
|
||
const WORLD = { apex: 'Keshine Tally', mindweave: 'Orebody Choir', zenith: 'Anagenese Verge' };
|
||
const RECS = Object.fromEntries(FACTIONS.map((f) => [f, { id: `sys-${f}`, name: WORLD[f], faction: f, d: 62, zone: 'mid', type: 'void', capital: false }]));
|
||
const galaxy = { byId: new Map(FACTIONS.map((f) => [RECS[f].id, RECS[f]])), jumpGatesFor: (id) => FACTIONS.map((f) => RECS[f]).filter((r) => r.id !== id) };
|
||
|
||
const flat = (t) => String(t ?? '').replace(/\s+/g, ' ').trim();
|
||
const SHOTS = join(__dirname, 'shots');
|
||
fs.mkdirSync(SHOTS, { recursive: true });
|
||
|
||
const report = { generatedFrom: 'data/sidequests.json (live)', factions: {} };
|
||
let problems = 0;
|
||
|
||
for (const f of FACTIONS) {
|
||
const seen = new Map(); // category → Map(variantTitle → rendered listing)
|
||
for (const cat of ['patrol', 'bounty', 'escort']) seen.set(cat, new Map());
|
||
let win = null;
|
||
let worldCount = 0;
|
||
// Coverage is measured against the DATA pool: one {enemy} title resolves to two
|
||
// different strings across seeds, so counting rendered titles would over-count.
|
||
const covered = { patrol: new Set(), bounty: new Set(), escort: new Set() };
|
||
const LABELS = sideQuestFactionLabels();
|
||
for (let s = 1; s <= 600 && Object.values(covered).some((t) => t.size < 20); s++) {
|
||
for (const wk of ['p1', 's1']) {
|
||
const posts = sideQuestPostingsFor(700 + s * 13, RECS[f], wk, galaxy, 99);
|
||
if (posts.length === 0) continue;
|
||
worldCount++;
|
||
// A FRESH window per board — the console's own construction path.
|
||
win = new JobBoardWindow(makeScene(), {
|
||
planetName: RECS[f].name, onClose: () => {}, postings: () => posts,
|
||
isAccepted: () => false, isCompleted: () => false, isActive: () => false, isClaimed: () => false,
|
||
routeProgress: () => null, onAccept: () => {}, onActivate: () => {}, onClaim: () => {},
|
||
});
|
||
for (let i = 0; i < win._rows.length; i++) {
|
||
const p = posts[i];
|
||
win._rows[i].hit.__on.pointerdown();
|
||
const lines = win.detailSection.content.filter((t) => t.text != null && String(t.text) !== '').map((t) => flat(t.text));
|
||
const bodyLine = lines.find((t) => t.startsWith(flat(p.description).slice(0, 24))) ?? '';
|
||
const braces = [...new Set(lines.flatMap((t) => t.match(/\{[a-zA-Z]+\}/g) || []))];
|
||
// GRAMMAR OF THE RESOLVED CARD — an article that only disagrees once a
|
||
// rival’s name is dropped in (“an Apex hull”), and any doubled word
|
||
// (“MindWeave MindWeave profiles”), are copy bugs the panel happily shows.
|
||
const grammar = [...new Set(lines.flatMap((t) => [...VOICE.articleDisagreements(t), ...VOICE.repeatedWords(t)]))];
|
||
const clipped = bodyLine !== flat(p.description);
|
||
const wrapRight = win.geo.rightX + win.geo.rightW;
|
||
// The body WRAPS in the real renderer, so its stub width means nothing;
|
||
// the honest read on it is fitText's own budget (checked via `clipped`).
|
||
// Every OTHER readout line is single-line, so those widths are real.
|
||
const over = win.detailSection.content
|
||
.filter((t) => t.text != null && String(t.text) !== '' && flat(t.text) !== bodyLine)
|
||
.map((t) => Math.round(t.x + (t.width ?? 0)))
|
||
.filter((r) => r > wrapRight + 2);
|
||
const listing = {
|
||
category: p.category,
|
||
worldKey: wk,
|
||
title: p.title,
|
||
issuer: p.issuer,
|
||
enemy: p.enemyFaction,
|
||
ground: p.targetSystemName || p.destSystemName || RECS[f].name,
|
||
body: p.description,
|
||
payout: p.reward.credits,
|
||
dossier: p.dossier ? { name: p.dossier.name, epithet: p.dossier.epithet, crime: p.dossier.crime, craftLabel: p.dossier.craftLabel } : null,
|
||
readout: lines,
|
||
checks: {
|
||
bracesInRenderedCopy: braces,
|
||
bodyCharCapForThisListing: null,
|
||
bodyClippedByPanel: clipped,
|
||
bodyShownInFull: !clipped,
|
||
widestLineRight: Math.max(...win.detailSection.content.filter((t) => t.text).map((t) => Math.round(t.x + (t.width ?? 0)))),
|
||
panelRightEdge: Math.round(wrapRight),
|
||
linesOverflowingPanel: over.length,
|
||
},
|
||
};
|
||
seen.get(p.category).set(p.title, listing);
|
||
const pctx = {
|
||
context: 'faction',
|
||
system: RECS[f].name,
|
||
faction: PROPER[f],
|
||
issuer: listing.issuer,
|
||
enemies: FACTIONS.filter((x) => x !== f).map((x) => LABELS[x]).join(' and '),
|
||
enemy: p.enemyFaction ? LABELS[p.enemyFaction] : '',
|
||
};
|
||
configData.sidequests.descriptions.faction[f][p.category].forEach((v, i) => {
|
||
if (resolveSideQuestText(v.body, pctx) === p.description) covered[p.category].add(i);
|
||
});
|
||
if (braces.length || clipped || over.length || grammar.length) {
|
||
problems++;
|
||
console.log(` ✗ ${f}/${p.category} “${p.title}” braces=${braces.join(',')} clipped=${clipped} overflow=${over.length} grammar=${grammar.join(' | ')}`);
|
||
}
|
||
}
|
||
win.destroy();
|
||
}
|
||
}
|
||
const faction = {};
|
||
for (const [cat, m] of seen) {
|
||
faction[cat] = {
|
||
listingsRendered: m.size,
|
||
poolVariantsCovered: `${covered[cat].size}/20`,
|
||
sample: [...m.values()].slice(0, 3).map((l) => ({ title: l.title, enemy: l.enemy, body: l.body })),
|
||
};
|
||
assert.equal(covered[cat].size, 20, `${f}.${cat}: all 20 pool variants rendered through the panel (${covered[cat].size})`);
|
||
}
|
||
report.factions[f] = {
|
||
world: RECS[f].name,
|
||
boardsRendered: worldCount,
|
||
listingsRendered: [...seen.values()].reduce((n, m) => n + m.size, 0),
|
||
...faction,
|
||
};
|
||
const txt = ['', `=== ${PROPER[f].toUpperCase()} BOARD — ${RECS[f].name} (${worldCount} worlds rendered) ===`];
|
||
for (const [cat, m] of seen) {
|
||
txt.push('', `--- ${cat.toUpperCase()} (${m.size} listings) ---`);
|
||
for (const l of m.values()) {
|
||
txt.push(`\n[${l.category.toUpperCase()}] ${l.title}`, ` ISSUER — ${l.issuer} ENEMY — ${l.enemy} GROUND — ${l.ground} PAYOUT — ${l.payout} CR`);
|
||
if (l.dossier) txt.push(` WANTED — ${l.dossier.name.toUpperCase()} · “${l.dossier.epithet}”`, ` CHARGES — ${l.dossier.crime}`, ` CRAFT — ${l.dossier.craftLabel}`);
|
||
txt.push(...l.readout.filter((t) => !/^(TITLE|ISSUER|WANTED|CHARGES|CRAFT|PAYOUT|ACTION|OBJECTIVE)/.test(t)).map((t) => ` | ${t}`));
|
||
}
|
||
}
|
||
fs.writeFileSync(join(SHOTS, `voice-${f}.txt`), txt.join('\n') + '\n');
|
||
console.log(`${PROPER[f].toUpperCase()} — ${worldCount} boards rendered; 60/60 pool variants through the panel (coverage patrol·bounty·escort ${["patrol", "bounty", "escort"].map((c) => report.factions[f][c].poolVariantsCovered).join(" · ")})`);
|
||
}
|
||
fs.writeFileSync(join(SHOTS, 'voice-report.json'), JSON.stringify(report, null, 2) + '\n');
|
||
// EVERY RESOLUTION, not just the one each seed drew: a pool entry is written once
|
||
// and read against a SEEDED rival, so it has to read for every rival the board can
|
||
// name. Lint both resolutions of all 180 listings before believing the render.
|
||
{
|
||
const labels = sideQuestFactionLabels();
|
||
const ids = Object.keys(configData.sidequests.descriptions.faction).filter((k) => Array.isArray(configData.sidequests.descriptions.faction[k]?.patrol));
|
||
let sweep = 0;
|
||
for (const f of ids) {
|
||
const issuer = String(configData.sidequests.factions?.[f] ?? '').trim() || `${labels[f]} Authority`;
|
||
for (const r of ids.filter((x) => x !== f)) {
|
||
const ctx = { context: 'faction', system: 'Kestrel Verge', faction: labels[f], issuer, enemies: labels[r], enemy: labels[r] };
|
||
for (const cat of ['patrol', 'bounty', 'escort']) {
|
||
for (const v of configData.sidequests.descriptions.faction[f][cat]) {
|
||
for (const field of ['title', 'body', 'name', 'epithet', 'crime', 'craftLabel']) {
|
||
if (!v[field]) continue;
|
||
sweep++;
|
||
VOICE.assertVoiceClean(`${f}.${cat} “${v.title}” → ${field} as ${labels[r]}`, v[field], [resolveSideQuestText(v[field], ctx)]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
console.log(`grammar sweep: ${sweep} resolved listings linted clean (both rival resolutions × every dossier field)`);
|
||
}
|
||
|
||
assert.equal(problems, 0, `${problems} rendered listings had brace leaks, panel clipping, grammar faults, or overflow`);
|
||
console.log(`\njobboard-voice-shot: OK — 180 faction listings rendered through the real panel; no brace pairs, no clipped bodies, nothing past the panel edge.`);
|
||
console.log(`artifacts: dev/shots/voice-{apex,mindweave,zenith}.txt + dev/shots/voice-report.json`);
|