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