// Headless test for the JOB BOARD console (js/ui/JobBoardWindow.js) — the // build screen’s structure (the Explore drawer’s Job Board slot), driven // against a stub scene, like dev/explore.test.mjs’s MenuSubBar section: // // - CONFIG — data/actionbar.json → surface.explore: the jobBoard item // (label / labelActive / the isHomeSystem:false clause) and the // `video` block (assets/videos/jobboard.mp4, 2:3). // - BUILD — the window constructs headless: the top bar (meta + ✕ + // rail), the left feed (video from the scene cache, scanlines, sweep // band, status strip), the right column (the JOB BOARD title + the // AVAILABLE JOBS / JOB DETAILS sections — the standby plate while no // postings stand). // - LIVE — the postings seam renders the AVAILABLE JOBS rows (chip + // title + issuer line + the payout), a row’s press selects it (the // JOB DETAILS readout stands: title + issuer + the contract’s lines // (destination / route) + the copy + the payout + the ACTION door), // and the door walks the ledger’s states (ACCEPT → SET PATROL // ACTIVE → the active plate → CLAIM REWARD → the claimed plate — // each live door firing its seam, each plate standing closed), the // row’s mark following (CONTRACT ACCEPTED → PATROL ACTIVE → // CONTRACT COMPLETE → REWARD CLAIMED). // - GEOMETRY — the right column sits right of the feed; the body runs // to the window’s bottom padding (nearly full-screen, like the build // screen — the window is depth 80, above the HUD (60) + deck (50) + // drawer (60)). // - OPEN/CLOSE — openState transitions (closed → open → closing → // closed), the title decodes in (ScrambleDecode settles on 'JOB // BOARD'), the close door’s ✕ fires the onClose seam (the scene’s // full leave), update() ticks (cursor, sweep, glitch, REC pulse) // without throwing, and destroy() tears the feed down. process.env.NODE_ENV = 'dev'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Load the real config (data/*.json) into the config singleton ------ const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const fs = await import('node:fs'); 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); // =========================================================================== console.log('== CONFIG — the drawer item + the feed block =='); { const items = config.get('actionbar.surface.explore.items'); const job = items.find((x) => x.id === 'jobBoard'); assert.ok(job, 'the explore drawer has a jobBoard item'); assert.equal(job.label, 'Job Board', 'the slot reads JOB BOARD (static — it does not relabel)'); assert.equal(job.labelActive, undefined, 'no labelActive — the console’s ✕ is the way-out (the slot’s own press is the second door)'); assert.equal(job.where.isHomeSystem, false, 'every system EXCEPT the home (safe space)'); const video = config.get('actionbar.surface.explore.video'); assert.equal(video.file, 'assets/videos/jobboard.mp4', 'the feed file'); assert.deepEqual(video.aspect, [2, 3], 'a 2:3 portrait (the build feed’s shape)'); } const { JobBoardWindow } = await import( pathToFileURL(join(__dirname, '../js/ui/JobBoardWindow.js')).href ); // --- The headless scene (dev/explore.test.mjs’s stubObject recipe, plus // --- the video + the few extra object methods the window reaches for). 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 keeps .width fresh, like Phaser (the cursor tracks it). 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() {} }, }; } // =========================================================================== console.log('== BUILD — the console constructs (top bar, feed, right column) =='); { const scene = makeScene(); const closed = []; const win = new JobBoardWindow(scene, { planetName: 'Klaxoria', onClose: () => closed.push(1) }); assert.equal(win.openState, 'closed', 'starts closed'); assert.equal(win.isOpen, false, '…and not open'); assert.equal(win.depth, 80, 'depth 80 — the build screen’s: above the HUD (60) + deck (50) + drawer (60)'); // top bar: the meta line (the world’s name) + the ✕ plate + the rail. assert.match(win.topMeta.text, /KLAXORIA/); assert.match(win.topMeta.text, /SURFACE BOARD/); assert.equal(win.closeTxt.text, '✕'); // the left feed: the video (muted, looping), the scanlines + sweep band, // the status strip. assert.ok(win.video, 'the feed video is created from the scene cache'); assert.equal(win.video.volume, 0, 'muted (the console’s requirement)'); assert.equal(win.video.loop, true, 'looping'); assert.equal(win.video.video.muted, true, '…and the native element is muted'); assert.ok(win.scan, 'scanlines over the feed'); assert.ok(win.sweepBand, 'the sweep band'); assert.match(win.statusTxt.text, /NO CONTRACTS ON FILE/); // the right column: the title (decoded on open) + the two live // sections (the postings list + the contract readout) — the standby // plate stands while no postings stand (the window's postings seam is // not wired in this section — the LIVE section drives it). assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)'); assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS'); assert.ok( win.jobsSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)), 'the standby plate stands (NO CONTRACTS ON FILE)', ); assert.equal(win.detailSection.headTxt.text, 'JOB DETAILS'); assert.ok( win.detailSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)), 'the detail stands on the same standby plate (no postings → nothing to select)', ); // the section objects are CHILDREN of the window (which carries the // depth-10 layer) — a scene-level object at depth 0 would hide behind // the window’s own opaque backdrop (caught in the headless render). for (const sec of [win.jobsSection, win.detailSection]) { for (const k of ['headTxt', 'bg']) { assert.ok(win.children.includes(sec[k]), `${k} is a window child (depth 10, not scene-level depth 0)`); } for (const c of sec.content) { assert.ok(win.children.includes(c), 'the section’s content is a window child too (depth 10)'); } } // GEOMETRY: the right column is right of the feed, and the body stays // above the deck + drawer band (the way-out lives there). const g = win.geo; assert.ok(g.rightX > g.leftX + g.leftW, 'the right column sits right of the feed'); assert.equal(g.bodyBottom, 720 - 10 - 14, 'the body runs to the window’s bottom padding — nearly full-screen, like the build screen'); assert.ok(g.detailPanelY + g.detailH <= g.bodyBottom + 1, 'the details panel fits the body'); assert.ok(g.availH >= 80, 'the postings section has room to grow into'); // OPEN: closed → open; the title decodes in and settles on 'JOB BOARD'. win.open(); assert.ok(win.isOpen, 'open() counts as open (the guards hold)'); assert.equal(win.video.volume, 0, 'still muted after open'); for (let t = 1000; t <= 4000; t += 100) win.update(t); assert.equal(win.rightTitle.text, 'JOB BOARD', 'the title has settled on JOB BOARD'); // the ambient life ticks without throwing (cursor, sweep, REC pulse). for (let t = 4000; t <= 20000; t += 500) win.update(t); // CLOSE: the ✕ door fires the scene seam (the full leave), then the // close lands (the stub tween completes inline). win.closeG.__on.pointerdown(); assert.equal(closed.length, 1, 'the ✕ fired the onClose seam (the scene’s full leave)'); win.close(); assert.equal(win.openState, 'closed', 'the close animation lands on closed'); assert.equal(win.isOpen, false, '…and it no longer counts as open'); // refresh() is the repaint seam (the live board repaints off it after // the scene’s accept) — a no-throw pass with the standby board. win.refresh(); // destroy() tears the feed down (no crash; the video is gone). win.destroy(); assert.ok(!win.video, 'the feed video is released'); } // =========================================================================== console.log('== LIVE — the postings stand, the selection reads, the door walks the ledger =='); { const scene = makeScene(); // The board’s seams — the GameScene/SurfaceScene shape (the ledger’s // truth lives in the seams; the window just reads it back on repaint). const accepted = new Set(); const active = new Set(); const completed = new Set(); const claimed = new Set(); const accepts = []; const activates = []; const claims = []; const routeState = { visited: 0, total: 3, xp: 0 }; const mkPost = (cat, title, n, extra = {}) => ({ id: `sq-test-${cat}-${n}`, systemId: 'sys-free', systemName: 'Free Reach', faction: null, category: cat, label: cat.toUpperCase(), accent: cat === 'patrol' ? '#00e5ff' : cat === 'delivery' ? '#ffc94d' : '#ff2d6f', title, issuer: 'The Tally', description: `The ${cat} contract for the test board — the body stands in the readout.`, reward: { credits: 40 + n * 10 }, objective: { type: cat }, ...extra, }); // The patrol posts for a NEIGHBOR system (the push-out rule) — the // detail’s DESTINATION line has something to say; the delivery/bounty // stubs stand local (no target fields — their rules land later). const posts = [ mkPost('patrol', 'Lane Watch', 1, { targetSystemId: 'sys-apex', targetSystemName: 'Apex Threshold' }), mkPost('delivery', 'Dead Freight', 2), mkPost('bounty', 'Salt the Raiders', 3), ]; const win = new JobBoardWindow(scene, { planetName: 'Free Reach', onClose: () => {}, postings: () => posts, isAccepted: (id) => accepted.has(id), isCompleted: (id) => completed.has(id), isActive: (id) => active.has(id), isClaimed: (id) => claimed.has(id), routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total, xp: routeState.xp } : null), onAccept: (id) => accepts.push(id), onActivate: (id) => activates.push(id), onClaim: (id) => claims.push(id), }); // the list: three rows stand (three postings off the seam), and the // feed’s status strip counts them (the data’s {n} live status). assert.equal(win._rows.length, 3, 'three postings → three rows'); assert.ok( win.jobsSection.content.some((t) => t.text && /LANE WATCH/.test(t.text)), 'the first row’s title stands', ); assert.ok( win.jobsSection.content.some((t) => t.text && /PATROL — THE TALLY/.test(t.text)), 'the first row’s issuer line stands (label — issuer)', ); assert.ok( win.jobsSection.content.some((t) => t.text && /^50 CR$/.test(t.text)), 'the first row’s payout stands (50 CR)', ); assert.match(win.statusTxt.text, /3 CONTRACTS ON FILE/, 'the status strip counts the live postings'); // no selection yet — the detail stands on its SELECT A JOB plate. assert.ok( win.detailSection.content.some((t) => t.text && /SELECT A JOB/.test(t.text)), 'the detail waits for a selection', ); // a row’s press selects it (the seam’s pointerdown fires headless): // the detail’s readout stands — title + the ISSUER line + the // DESTINATION line (the patrol’s route stands in ANOTHER system) + // the body + the payout + the ACTION door (open state). win._rows[0].hit.__on.pointerdown(); assert.equal(win.selectedId, posts[0].id, 'the row’s press lands the selection'); assert.ok( win.detailSection.content.some((t) => t.text && t.text === 'LANE WATCH'), 'the detail’s title stands', ); assert.ok( win.detailSection.content.some((t) => t.text && /ISSUER — THE TALLY/.test(t.text)), 'the detail’s issuer line stands', ); assert.ok( win.detailSection.content.some((t) => t.text && /DESTINATION — APEX THRESHOLD/.test(t.text)), 'the patrol’s DESTINATION line stands (the route’s system, named)', ); assert.ok( !win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)), 'no ROUTE line while the contract is not taken (no route to count)', ); assert.ok( win.detailSection.content.some((t) => t.text && /the patrol contract/i.test(t.text)), 'the contract’s body stands in the readout', ); assert.ok( win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR/.test(t.text)), 'the payout stands', ); const doorTxt = win.detailSection.content[win.detailSection.content.length - 1]; assert.equal(doorTxt.text, 'ACCEPT CONTRACT', 'the door stands OPEN (the contract is not taken)'); assert.ok(win.detailSection.content[win.detailSection.content.length - 2].__on?.pointerdown, 'the door is interactive while open'); // the ACCEPT press fires the scene’s seam (the ledger lands there — // the surface’s note is theirs, not the window’s). win.detailSection.content[win.detailSection.content.length - 2].__on.pointerdown(); assert.deepEqual(accepts, [posts[0].id], 'the ACCEPT fired the onAccept seam with the job id'); // the ledger says ACCEPTED now (the seam’s truth) — the repaint // (refresh() — the scene’s call after its accept) lands the states: // the row shows CONTRACT ACCEPTED, the door stands SET PATROL ACTIVE // (live — it fires the onActivate seam), the ROUTE line stands (the // scene’s routeProgress seam: 0 of 3 logged). accepted.add(posts[0].id); win.refresh(); assert.ok( win.jobsSection.content.some((t) => t.text && /CONTRACT ACCEPTED/.test(t.text)), 'the taken row shows CONTRACT ACCEPTED (the ledger’s mark)'); assert.ok( win.detailSection.content.some((t) => t.text && /ROUTE — 0\/3 WAYPOINTS LOGGED/.test(t.text)), 'the ROUTE line stands off the routeProgress seam (0/3)'); let dTxt = win.detailSection.content[win.detailSection.content.length - 1]; assert.equal(dTxt.text, 'SET PATROL ACTIVE', 'the door stands SET ACTIVE after the accept'); let dG = win.detailSection.content[win.detailSection.content.length - 2]; assert.ok(dG.__on?.pointerdown, 'the door is live while accepted (the activate is a real press)'); dG.__on.pointerdown(); assert.deepEqual(activates, [posts[0].id], 'the activate press fired the onActivate seam'); // …the ledger says ACTIVE now — the door stands as the active PLATE // (PATROL ACTIVE — nothing to press), the row’s mark follows. active.add(posts[0].id); win.refresh(); assert.ok( win.jobsSection.content.some((t) => t.text && /PATROL ACTIVE/.test(t.text)), 'the active row shows PATROL ACTIVE (the ledger’s mark)'); dTxt = win.detailSection.content[win.detailSection.content.length - 1]; assert.equal(dTxt.text, 'PATROL ACTIVE', 'the door stands as the active plate'); dG = win.detailSection.content[win.detailSection.content.length - 2]; assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press while active)'); // …the route’s check-ins land (the scene’s seam advances) — the // ROUTE line repaints off it. routeState.visited = 1; win.refresh(); assert.ok( win.detailSection.content.some((t) => t.text && /ROUTE — 1\/3 WAYPOINTS LOGGED/.test(t.text)), 'the ROUTE line advances with the check-ins (1/3)'); // …and COMPLETE, when the type’s objective stands (the seam’s truth // again — the window just reads it back): the door stands CLAIM // REWARD (live — it fires the onClaim seam, the payout named). active.delete(posts[0].id); // the done contract drops out of the active slot completed.add(posts[0].id); routeState.xp = 60; // the route’s XP payout (the accept’s fix) stands on the seam win.refresh(); assert.ok( win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)), 'the completed row shows CONTRACT COMPLETE'); assert.ok( win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR · \+60 XP/.test(t.text)), 'the payout line names the XP payout (the route’s fix, beside the credits)'); dTxt = win.detailSection.content[win.detailSection.content.length - 1]; assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR · +60 XP', 'the door stands CLAIM (the payout named — credits + the XP)'); dG = win.detailSection.content[win.detailSection.content.length - 2]; assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)'); dG.__on.pointerdown(); assert.deepEqual(claims, [posts[0].id], 'the claim press fired the onClaim seam'); // …and CLAIMED — the door stands as the claimed PLATE (REWARD // CLAIMED — nothing to press; a double-pay is a no-op at the ledger, // and the door doesn’t even offer one). claimed.add(posts[0].id); win.refresh(); assert.ok( win.jobsSection.content.some((t) => t.text && /REWARD CLAIMED/.test(t.text)), 'the claimed row shows REWARD CLAIMED (the ledger’s mark)'); dTxt = win.detailSection.content[win.detailSection.content.length - 1]; assert.equal(dTxt.text, 'REWARD CLAIMED', 'the door stands as the claimed plate'); dG = win.detailSection.content[win.detailSection.content.length - 2]; assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press once claimed)'); // the stub categories (delivery/bounty) stand LOCAL — no target // fields, no route seam — and their open doors are still plain ACCEPT // (the window’s shape is one door; the state reads off the seams). win._rows[1].hit.__on.pointerdown(); assert.ok( !win.detailSection.content.some((t) => t.text && /DESTINATION —/.test(t.text)), 'a local posting carries no DESTINATION line', ); assert.ok( !win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)), 'a stub posting carries no ROUTE line (no route to count)', ); assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'ACCEPT CONTRACT', 'the stub’s open door is plain ACCEPT'); // SEAM FAULT — a postings seam that throws stands the standby board // (the console never crashes on a scene fault). const broken = new JobBoardWindow(scene, { postings: () => { throw new Error('scene fault'); }, planetName: 'X' }); assert.ok( broken.jobsSection.content.some((t) => t.text && /NO CONTRACTS ON FILE/.test(t.text)), 'a throwing postings seam stands the standby plate (degrade, not crash)'); assert.match(broken.statusTxt.text, /BOARD STANDBY/); broken.destroy(); } // =========================================================================== console.log('== FACTION VOICE — each board renders ITS OWN pool, the placeholders already resolved =='); { // The REAL model off the REAL data (no fixtures): one world per faction, // every gate open, so all three live categories post in rival ground. const { sideQuestPostingsFor } = await import( pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href ); const FAC = ['apex', 'mindweave', 'zenith']; const RECS = Object.fromEntries(FAC.map((f, i) => [f, { id: `sys-${f}`, name: `${f[0].toUpperCase()}${f.slice(1)} Prime`, faction: f, d: 40 + i * 15, zone: 'mid', type: 'void', capital: false, }])); const ALL = Object.values(RECS); const galaxy = { byId: new Map(ALL.map((r) => [r.id, r])), jumpGatesFor: (id) => ALL.filter((r) => r.id !== id) }; const LABELS = { apex: 'Apex', mindweave: 'MindWeave', zenith: 'Zenith' }; for (const f of FAC) { const posts = []; for (let s = 0; s < 40 && posts.length < 3; s++) { for (const p of sideQuestPostingsFor(5100 + s * 11, RECS[f], 'p1', galaxy, 10)) { if (!posts.some((x) => x.category === p.category)) posts.push(p); } } assert.equal(posts.length, 3, `${f}: the board stands a patrol, a hunt, and an escort (the faction’s three live categories)`); const win = new JobBoardWindow(makeScene(), { planetName: RECS[f].name, onClose: () => {}, postings: () => posts, isAccepted: () => false, isCompleted: () => false, isActive: () => false, isClaimed: () => false, routeProgress: () => null, onAccept: () => {}, onActivate: () => {}, onClaim: () => {}, }); // THE ROWS — every listing on the board reads in this faction’s voice: // its own pool wrote the title, and no placeholder brace survived. const rowText = win.jobsSection.content.map((t) => String(t.text ?? '')); assert.ok(!rowText.join(' ').includes('{'), `${f}: no brace pair survives in the rows`); for (const p of posts) { const enemy = p.enemyFaction ? LABELS[p.enemyFaction] : ''; const enemies = FAC.filter((x) => x !== f).map((x) => LABELS[x]).join(' and '); const render = (s) => String(s ?? '') .replace(/\{system\}/g, RECS[f].name).replace(/\{faction\}/g, LABELS[f]) .replace(/\{issuer\}/g, config.get(`sidequests.factions.${f}`, '')) .replace(/\{enemies\}/g, enemies).replace(/\{enemy\}/g, enemy); assert.ok( config.get(`sidequests.descriptions.faction.${f}.${p.category}`).some((v) => render(v.title) === p.title), `${f}: “${p.title}” came out of ${f}’s OWN pool (the board is not sharing a line)`, ); } const hunt = posts.find((p) => p.category === 'bounty'); assert.ok(posts.every((p) => p.issuer.includes(LABELS[f])), `${f}: every row’s issuer line is the faction’s own board`); // THE READOUT — select each row: body + (for a hunt) the dossier block, // all of it resolved to the seeded rival, never a brace. for (let i = 0; i < win._rows.length; i++) { win._rows[i].hit.__on.pointerdown(); const detail = win.detailSection.content.map((t) => String(t.text ?? '')); const sel = posts[i]; assert.ok(!detail.join(' ').includes('{'), `${f}: the ${sel.category} readout ships no brace pair`); assert.ok( detail.some((t) => t === sel.title.toUpperCase()), `${f}: the ${sel.category} title reads in the detail`, ); assert.ok( detail.some((t) => sel.description.slice(0, 40).length > 8 && t.includes(sel.description.slice(0, 40))), `${f}: the ${sel.category} body stands (the faction’s own words)`, ); const rival = sel.enemyFaction ? LABELS[sel.enemyFaction] : null; assert.ok(rival && detail.join(' ').includes(rival), `${f}: the ${sel.category} readout names the rival it fights (${rival})`); if (sel.category === 'bounty') { assert.ok(detail.some((t) => /WANTED —/.test(t)), `${f}: the hunt’s WANTED line stands`); assert.ok(detail.some((t) => t.includes(String(sel.dossier.name).toUpperCase())), `${f}: the dossier names its leader`); assert.ok(detail.some((t) => /CHARGES —/.test(t)), `${f}: the dossier reads its charges`); // The dossier declares no craft id (the frame rides the mark’s roster), // so the readout speaks the faction’s own airframe, never a pirate one. assert.ok(detail.some((t) => /CRAFT —/.test(t)), `${f}: the craft line stands from the seeded frame`); } } if (hunt) assert.ok(hunt.dossier.craft === undefined || hunt.dossier.craft === null, `${f}: a faction dossier declares no craft id`); win.destroy(); } } console.log('\nAll Job Board console checks passed ✔');