orbit/dev/jobboard-voice-shot.mjs

243 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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`);