Add side quest job board with deterministic postings and ledger
- Introduce js/quests/SideQuests.js: seeded posting generation (patrol/delivery/bounty/escort), placeholder resolution, reward scaling, and a SideQuestState ledger with save round-trip + job-space validation - Wire the feature into SaveData capture/prepareLoad/resetRunState so accepted/completed contract ids ride saves while postings regenerate from the galaxy seed - Replace JobBoardWindow placeholders with live AVAILABLE JOBS rows and JOB DETAILS readout (contract copy, payout, ACCEPT door) driven by scene seams; refresh() repaints list/detail/status off those seams - Add GameScene.sideQuestPostings/acceptSideQuest and SurfaceScene.acceptSurfaceJob so the accept lands on the sleeping game scene's ledger with a surface console note - Ship data/sidequests.json (category gates all OFFLINE, description pools split neutral vs faction, reward bands, UI copy) and register it in manifest.json - Add dev/sidequests.test.mjs (gate, determinism, voice, rewards, ledger) and extend dev/jobboard.test.mjs with live postings/selection/accept/refresh coverage
This commit is contained in:
parent
1b9b269b80
commit
081a086658
|
|
@ -31,6 +31,7 @@
|
|||
"creditshud.json",
|
||||
"map.json",
|
||||
"quests.json",
|
||||
"sidequests.json",
|
||||
"comms.json",
|
||||
"vignettes.json",
|
||||
"goods.json",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
{
|
||||
"_comment": "SIDE QUESTS — the contracts posted on the JOB BOARD (the Explore drawer's Job Board slot — every system EXCEPT the home system, data/actionbar.json → surface.explore.items). The board is a VIEW over js/quests/SideQuests.js: `sideQuestPostingsFor(seed, record)` derives 3–4 deterministic postings from the galaxy seed + the system record (same seed + system ⇒ same contracts, across visits, scene restarts, and saves), and the run's accepted/completed set rides the saves as the SideQuestState ledger (registry 'sidequests', record.sidequests). CATEGORIES — the four job types (→ categories): patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR (its slot machinery, its description pool below, its reward band, the window's row + contract readout, the ledger, the accept seam) behind its OWN `enabled` gate — and all four start OFFLINE (enabled:false), because none of the four objective/completion rule sets is built yet. The gate is the contract: a category's jobs appear on the board ONLY when its rules land and its flag flips (flip enabled:true in this file — the board's standby plate lifts and its postings stand). FLAVOR — the variant pools split by SYSTEM CONTEXT (→ descriptions): NEUTRAL systems post about keeping their system safe from PIRATES; a FACTION's systems post about pushing the OTHER factions out of their space (the pools carry {faction}/{issuer}/{enemies} placeholders — {enemies} resolves to the other two factions' labels; {issuer} to the faction's board name below; {system} to the system's name — resolved at generation, js/quests/SideQuests.js → resolveSideQuestText). Each pool holds 3+ variants so different systems read different contracts.",
|
||||
"enabled": true,
|
||||
"board": {
|
||||
"_comment": "perSystem = the postings-per-system roll [min,max] (seeded per system — Rng.derive(seed, seedLabel, <sysId>); the roll itself is stable for the run). seedLabel = the Rng fork label under the galaxy seed (the worldgen determinism contract: a labelled fork, independent of call order).",
|
||||
"perSystem": [3, 4],
|
||||
"seedLabel": "sidequests"
|
||||
},
|
||||
"categories": {
|
||||
"_comment": "The four job types. ORDER = the canonical slot order (patrol, delivery, bounty, escort). `enabled` is the per-type availability gate — OFFLINE until that type's objective + completion logic is built (all four are stubs for now); flip a category to true and its jobs stand on the board with their pool + reward band already wired. `accent` = the row's category chip + the contract title (data colors, js/quests/SideQuests.js reads them through).",
|
||||
"patrol": { "enabled": false, "label": "PATROL", "accent": "#00e5ff" },
|
||||
"delivery": { "enabled": false, "label": "DELIVERY", "accent": "#ffc94d" },
|
||||
"bounty": { "enabled": false, "label": "BOUNTY", "accent": "#ff2d6f" },
|
||||
"escort": { "enabled": false, "label": "ESCORT", "accent": "#7ce8a4" }
|
||||
},
|
||||
"rewards": {
|
||||
"_comment": "The per-category CREDITS band [min,max] at the home rim — the base payout; it scales OUT with the system's home→far diagonal d (record.d) as (1 + d * distanceScale) — the economy's 'a hop is a hop, and danger pays' rule (the far systems' boards pay more for the same risk). The band is rolled seeded per posting (round to 5 CR, floor 5 CR). Tune per category as the types' rules land; a category with no band here falls back to [40,80] in code.",
|
||||
"distanceScale": 0.75,
|
||||
"patrol": [40, 70],
|
||||
"delivery": [50, 90],
|
||||
"bounty": [80, 140],
|
||||
"escort": [70, 110]
|
||||
},
|
||||
"factions": {
|
||||
"_comment": "The faction boards' ISSUER names (the {issuer} placeholder in the faction pools — data/factions.json's faction ids: apex / mindweave / zenith).",
|
||||
"apex": "Apex War Ministry",
|
||||
"mindweave": "MindWeave Chorus",
|
||||
"zenith": "Zenith Concord"
|
||||
},
|
||||
"descriptions": {
|
||||
"_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, category, slot) — a system's contract is STABLE (the board doesn't reshuffle between visits or saves), and different systems draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice.",
|
||||
"neutral": {
|
||||
"patrol": [
|
||||
{
|
||||
"title": "Lane Watch",
|
||||
"issuer": "The Harbormaster",
|
||||
"body": "Three raiders keep running the outer gate at night, and the toll keepers are done pretending they aren't there. Work the lane, keep your lights up, and make the run a bad idea. We don't want their bodies, we want them gone — the board pays for the silence, and the board pays on time."
|
||||
},
|
||||
{
|
||||
"title": "Quiet the Field",
|
||||
"issuer": "The Warden Guild",
|
||||
"body": "The {system} belt is a graveyard with a population. Something in the debris has been firing at passing hulls, and the Guild is paying for a field where the wrecks are the only thing that shoots. Work the rocks until they're quiet — and mind your hull out here, the field is as honest as the rest of this system."
|
||||
},
|
||||
{
|
||||
"title": "The Lighthouse Run",
|
||||
"issuer": "The Station Keeper",
|
||||
"body": "The buoy out past the ice line keeps going dark, and it isn't breaking — someone is cutting its fuel line. Find out who and convince them to stop, in whatever order you like. That light runs every safe lane in the system, and the pirates out here only like it for one reason: it's on. Keep it on."
|
||||
}
|
||||
],
|
||||
"delivery": [
|
||||
{
|
||||
"title": "Dead Freight",
|
||||
"issuer": "The Tally",
|
||||
"body": "A sealed crate for the keeper at the edge of the system. It isn't marked, and that isn't an accident. The raiders who want it are already circling the lane, and they're better funded than they look. Get it there before they get to you — or get it there through them. The Tally pays either way the crate arrives."
|
||||
},
|
||||
{
|
||||
"title": "The Long Way Home",
|
||||
"issuer": "The Harbormaster",
|
||||
"body": "Supply drops for the outer watch posts. The direct lane has been a pirate kill-box for a week, so you take the long way, fly slow, and keep your engines quiet. Nobody asks what's in the crates, and nobody asks what the watch posts are for either. That's the whole job — the cargo moves, the posts stay lit."
|
||||
},
|
||||
{
|
||||
"title": "Fuel for the Wards",
|
||||
"issuer": "The Warden Guild",
|
||||
"body": "Two drums of fuel for the sentinel posts, and the posts need them before the moon turns. A raider skiff was seen nosing around the fuel depot an hour ago — assume it was a scout and move the fuel like it was. The Guild's accounts are open and the Guild's patience isn't. Get the drums there and the wardens keep the lanes honest."
|
||||
}
|
||||
],
|
||||
"bounty": [
|
||||
{
|
||||
"title": "Salt the Raiders",
|
||||
"issuer": "The Tally",
|
||||
"body": "A crew has been taking ships in this system for a month, and the bounties on their heads have grown into a small library. Bring us the leader's hull, its drive, and one of their names — in that order, or in whatever order you can manage. The Tally doesn't ask how you finish a pirate. The Tally asks that it be finished."
|
||||
},
|
||||
{
|
||||
"title": "One Name on the List",
|
||||
"issuer": "The Warden Guild",
|
||||
"body": "The Guild is paying for a single name on the wanted board, and no questions about the rest of the crew. The mark flies a stolen hull with a blue stripe on the bow and a habit for boarding from the dark side. You'll know it when you see it. Bring the mark down and the name comes off the list — the rest stays yours."
|
||||
},
|
||||
{
|
||||
"title": "The Last Skiff",
|
||||
"issuer": "The Harbormaster",
|
||||
"body": "Every raider in the system has been driven out but one. A little black skiff, half the speed of its owners' nerve, and a cargo hold the harbor can't get a read on. Bring it in whole if you can — the wardens want to see what it's carrying — and if it breaks before it bows, bring the pieces. Either way the board is closed when it's done."
|
||||
}
|
||||
],
|
||||
"escort": [
|
||||
{
|
||||
"title": "See Her Home",
|
||||
"issuer": "The Station Keeper",
|
||||
"body": "A refugger out of the outer belt is asking for a pilot who can outrun pirates, and the lane is theirs right now. She's carrying people, not cargo, and the keeper is paying in gratitude first and credits after. Fly her through to the gate, keep her hull in one piece, and stay on her wing long enough that she forgets to be afraid. That's the job."
|
||||
},
|
||||
{
|
||||
"title": "Convoy Night",
|
||||
"issuer": "The Tally",
|
||||
"body": "Three lighters move out of the debris line tonight, and every pirate in the system knows it — they've heard it the way you hear a storm. Fly the point, hold the line, and put them through the far gate. The Tally pays for hulls that arrive, and the Tally counts them at the gate. Count them yourself too."
|
||||
},
|
||||
{
|
||||
"title": "The Long Dark",
|
||||
"issuer": "The Warden Guild",
|
||||
"body": "A courier ship has a sealed manifest and a hard deadline, and the lane she has to cross is pirate country. Escort her to the system edge and stay on her wing until the gate swallows her. Whatever she's carrying, it's worth more than your ship — that's probably why the Guild wants you, not their own pilots."
|
||||
}
|
||||
]
|
||||
},
|
||||
"faction": {
|
||||
"patrol": [
|
||||
{
|
||||
"title": "Sweep the Lanes",
|
||||
"body": "A foreign surveyor has been working the outer gate for a week, and it doesn't belong here. {enemies} think this system is a neutral route — it isn't. Sweep the lane until there's nothing left on it but our own flag. Report the wrecks to the {issuer} and leave the survivors to them. That's the whole job."
|
||||
},
|
||||
{
|
||||
"title": "No Foreign Lights",
|
||||
"body": "We've logged foreign transponders on the approach lanes since the last moon. {enemies} are running their people through our space like it's a corridor they paid for. It isn't, and it won't stay that way. Show them the difference, one hull at a time, until the lanes run clean under {faction} lights again."
|
||||
},
|
||||
{
|
||||
"title": "The Gate Watch",
|
||||
"body": "The gate is the only honest road in this system, and foreign traffic keeps trying to use it. Keep the approach lanes free of {enemies} flags — the {issuer} is paying for the lesson, and it would rather never have to teach it. If you meet them out there, remember which side of the line you're on."
|
||||
}
|
||||
],
|
||||
"delivery": [
|
||||
{
|
||||
"title": "Flagged Cargo",
|
||||
"body": "Parts for the gate works, under full {faction} flag. {enemies} runners will smell it and come for it, because that's what they do. Fly the direct lane, keep the flag on the hull, and don't let it off your wing. The works don't wait for delays, the {issuer} doesn't wait for excuses, and neither does the flag."
|
||||
},
|
||||
{
|
||||
"title": "The Wrong Kind of Freight",
|
||||
"body": "The crate is sealed and the manifest is sealed, and that's the whole job. {enemies} have two scouts in the system and they're bad at this — that's the good news. Deliver the crate to the depot before the scouts find out what it is. After that the crate belongs to them, and the job belongs to you. The {issuer} is paying for the order of operations."
|
||||
},
|
||||
{
|
||||
"title": "Supply Run, War Terms",
|
||||
"body": "The outer posts run on our rations and your nerve. {enemies} want the lane to starve them out, and they've said so in the intercepts. Run the lane anyway. Put the posts in the dark they wanted or in the light we pay for — the {issuer} is paying for the light, and only the light."
|
||||
}
|
||||
],
|
||||
"bounty": [
|
||||
{
|
||||
"title": "A Foreign Flag",
|
||||
"body": "There is a {enemies} vessel working the system with its lights off, and it's been here longer than is polite. Bring us its hull. The {issuer} is paying for the wreckage and the silence that follows, and the {issuer} has never once paid twice for the same silence. Bring the flag home in pieces."
|
||||
},
|
||||
{
|
||||
"title": "One Operative",
|
||||
"body": "A {enemies} operative has been buying our people and selling their secrets. We have a face, we have a name, and we don't have time. Bring us one or the other and the {issuer} will tell you which. The job ends when the system stops leaking — this is usually the last step, but not always. Be the last step."
|
||||
},
|
||||
{
|
||||
"title": "The Surveyor",
|
||||
"body": "They've been charting our lanes. {enemies} surveyors chart what they intend to take, and this system isn't for sale. Bring us the surveyor and its instruments, intact enough to ask questions. The {issuer} is paying extra for intact — the answers are worth more than the hull, and the hull is worth a fair sum."
|
||||
}
|
||||
],
|
||||
"escort": [
|
||||
{
|
||||
"title": "Convoy Under Flag",
|
||||
"body": "A convoy moves through the system under {faction} colors, and {enemies} scouts have been seen in the approach lanes. Fly the point, hold the line, and keep the colors in the lead. The {issuer} pays for the convoy that arrives — not for the story of the one that didn't. The story is for the dead, and we can afford the convoy."
|
||||
},
|
||||
{
|
||||
"title": "The Minister's Courier",
|
||||
"body": "The courier carries a seal, not cargo, and the lane is foreign country tonight. {enemies} will know the moment it launches, because they've been watching our docks since the last moon. Stay on the wing, keep the seal aboard, and put the courier through. What's in the case is the difference between a system and a battlefield — deliver it and keep the system a system."
|
||||
},
|
||||
{
|
||||
"title": "Long Haul, Short Fuse",
|
||||
"body": "Three lighters, one lane, and {enemies} scouts already circling the fuel line. Escort them to the gate and keep the line intact — no drops, no detours, no negotiations. The {issuer} counts hulls at the gate and pays per hull that arrives. Count them yourself too, and keep the extra for your nerve."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"_comment": "The board's contract copy (js/ui/JobBoardWindow.js). standby* = the state where NO category is live (the current one — the board's face until the first type flips on). accept* = the ACCEPT action's button + the scene's toasts (SurfaceScene.acceptSurfaceJob — the claimSurfaceQuest precedent: the ledger + sfx are the GameScene's, the note is the surface's).",
|
||||
"standbyMain": "NO CONTRACTS ON FILE",
|
||||
"standbySub": "THE BOARD’S POSTINGS LAND HERE",
|
||||
"selectMain": "SELECT A JOB",
|
||||
"selectSub": "PICK A LISTING TO READ ITS CONTRACT",
|
||||
"statusStandby": "BOARD STANDBY — NO CONTRACTS ON FILE",
|
||||
"statusLive": "{n} CONTRACTS ON FILE",
|
||||
"rewardUnit": "CR",
|
||||
"payout": "PAYOUT — {reward} CR",
|
||||
"accept": "ACCEPT CONTRACT",
|
||||
"accepted": "CONTRACT ACCEPTED",
|
||||
"completed": "CONTRACT COMPLETE",
|
||||
"acceptToast": { "glyph": "◆", "color": "#ffc94d", "text": "CONTRACT ACCEPTED — {title}" },
|
||||
"acceptDenyToast": { "glyph": "✗", "color": "#ff5c77", "text": "COMMISSION DENIED — CONTRACT NOT ON FILE" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// 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:
|
||||
// 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
|
||||
|
|
@ -8,14 +8,22 @@
|
|||
// - 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 placeholder sections).
|
||||
// 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 copy
|
||||
// + the payout + the ACCEPT door), the ACCEPT press fires the
|
||||
// onAccept seam, and the ledger’s seams repaint the states (the row
|
||||
// shows CONTRACT ACCEPTED / CONTRACT COMPLETE; the door’s label
|
||||
// follows, and its door closes once the contract is taken).
|
||||
// - GEOMETRY — the right column sits right of the feed; the body runs
|
||||
// to the window's bottom padding (nearly full-screen, like the build
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
|
|
@ -58,7 +66,7 @@ const { JobBoardWindow } = await import(
|
|||
pathToFileURL(join(__dirname, '../js/ui/JobBoardWindow.js')).href
|
||||
);
|
||||
|
||||
// --- The headless scene (dev/explore.test.mjs's stubObject recipe, plus
|
||||
// --- 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 {
|
||||
|
|
@ -162,20 +170,32 @@ console.log('== BUILD — the console constructs (top bar, feed, right column) =
|
|||
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 placeholder
|
||||
// sections (the postings + the contract).
|
||||
// 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 seam is wired (all categories
|
||||
// OFFLINE â data/sidequests.json’s gate).
|
||||
assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)');
|
||||
assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS');
|
||||
assert.match(win.jobsSection.mainTxt.text, /NO CONTRACTS ON FILE/);
|
||||
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.match(win.detailSection.mainTxt.text, /SELECT A JOB/);
|
||||
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 k of ['headTxt', 'bg', 'mainTxt', 'subTxt']) {
|
||||
assert.ok(win.children.includes(win.jobsSection[k]), `jobsSection.${k} is a window child (depth 10, not scene-level depth 0)`);
|
||||
assert.ok(win.children.includes(win.detailSection[k]), `detailSection.${k} is a window child (depth 10, not scene-level depth 0)`);
|
||||
// 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
|
||||
|
|
@ -204,7 +224,8 @@ console.log('== BUILD — the console constructs (top bar, feed, right column) =
|
|||
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 — a no-op for now, no throw.
|
||||
// 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).
|
||||
|
|
@ -212,4 +233,127 @@ console.log('== BUILD — the console constructs (top bar, feed, right column) =
|
|||
assert.ok(!win.video, 'the feed video is released');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fires ==');
|
||||
{
|
||||
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 completed = new Set();
|
||||
const accepts = [];
|
||||
const mkPost = (cat, title, n) => ({
|
||||
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 },
|
||||
});
|
||||
const posts = [mkPost('patrol', 'Lane Watch', 1), 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),
|
||||
onAccept: (id) => accepts.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 body +
|
||||
// the payout + the ACCEPT 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 && /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 the ledger’s mark, the door stands ACCEPTED and
|
||||
// closed (no more pointerdown — a double-take is a no-op).
|
||||
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)');
|
||||
const dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'CONTRACT ACCEPTED', 'the door stands ACCEPTED after the repaint');
|
||||
const dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.equal(dG.__on?.pointerdown, undefined, 'the door is CLOSED once taken (no double-take)');
|
||||
const again = [...accepts];
|
||||
// a fresh open board for the same job (the ledger forgot — the seam
|
||||
// says it’s open again) would re-fire; the point is the door’s state
|
||||
// tracks the seams.
|
||||
assert.deepEqual(accepts, again, 'nothing fired on its own');
|
||||
|
||||
// â¦and COMPLETE, when the type’s objective stands (the seam’s truth
|
||||
// again — the window just reads it back).
|
||||
completed.add(posts[0].id);
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)),
|
||||
'the completed row shows CONTRACT COMPLETE');
|
||||
assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'CONTRACT COMPLETE', 'the door stands COMPLETE');
|
||||
|
||||
// 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('\nAll Job Board console checks passed ✔');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
// dev/sidequests.test.mjs â the JOB BOARD’s model, verified headless
|
||||
// (node dev/sidequests.test.mjs — no browser, no Phaser render).
|
||||
//
|
||||
// What this pins (the board’s contract â data/sidequests.json):
|
||||
// - the CATEGORY GATE — the four types (patrol / delivery / bounty /
|
||||
// escort) are all accounted for, all OFFLINE by default, and a
|
||||
// category’s jobs appear on the board ONLY when its `enabled` flag
|
||||
// flips (flip one â only that type’s postings; flip all â the
|
||||
// per-system count; flip the feature’s master switch â an empty
|
||||
// board).
|
||||
// - DETERMINISM — the same contract stands on the same system across
|
||||
// calls (seed + record → the same postings — visits, scene restarts,
|
||||
// saves), a different seed draws its own board, and different
|
||||
// systems draw different boards (no global call-order dependence).
|
||||
// - The per-system COUNT (3â4 by design, within the data’s range),
|
||||
// unique ids, and the slots spreading across the live types.
|
||||
// - The CONTEXT voice — the NEUTRAL pools are the pirate-safety voice
|
||||
// (the data’s contract: 2 of the 3 variants per category name the
|
||||
// pirates/raiders), the FACTION pools are the push-the-other-
|
||||
// factions-out voice (every variant carries {enemies}), and the
|
||||
// placeholders ({system}/{faction}/{issuer}/{enemies}) resolve to
|
||||
// the system’s values (no brace pair ever ships to the player).
|
||||
// - The REWARDS â inside the category’s band (scaled by the system’s
|
||||
// distance d), in 5 CR steps, floored at 5 CR.
|
||||
// - The LEDGER (SideQuestState) — the accepted/completed marks: the
|
||||
// idempotent accept, the completed ⊆ accepted invariant, the save
|
||||
// round-trip (toJSON/fromJSON), the malformed-record degradation
|
||||
// (a save predating the feature loads empty; a foreign id drops
|
||||
// against the save’s galaxy job space), and the job space itself
|
||||
// (allSideQuestJobIds = systems × slots × the four types).
|
||||
//
|
||||
// The model is pure ES (js/quests/SideQuests.js); the data-driven
|
||||
// contract lives in data/sidequests.json. The scene’s seams
|
||||
// (GameScene.sideQuestPostings / acceptSideQuest) and the window’s
|
||||
// rendering run over the same functions this pins â the window’s
|
||||
// headless pass stands in dev/jobboard.test.mjs.
|
||||
|
||||
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';
|
||||
|
||||
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);
|
||||
const DATA = configData;
|
||||
|
||||
const {
|
||||
SIDE_QUEST_CATEGORIES,
|
||||
sideQuestsEnabled,
|
||||
sideQuestCategoryMeta,
|
||||
enabledSideQuestCategories,
|
||||
sideQuestCountRange,
|
||||
sideQuestPostingsFor,
|
||||
resolveSideQuestText,
|
||||
rollSideQuestReward,
|
||||
allSideQuestJobIds,
|
||||
SideQuestState,
|
||||
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
|
||||
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
||||
|
||||
const SEED = 20491;
|
||||
// The same hand records the other dev tests use — one neutral system
|
||||
// (the pirate-voice system) and one per faction (the push-out-voice
|
||||
// systems), each with a different home→far diagonal d (the reward scale).
|
||||
const REC_NEUTRAL = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
|
||||
const REC_ZENITH = { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 60, zone: 'mid', type: 'void', capital: false };
|
||||
const REC_APEX = { id: 'sys-apex', name: 'Apex Threshold', faction: 'apex', d: 80, zone: 'far', type: 'void', capital: false };
|
||||
const RECORDS = [
|
||||
{ id: 'home', name: 'Cradle', faction: null, d: 0, zone: 'home', type: 'void', capital: true },
|
||||
REC_NEUTRAL,
|
||||
REC_ZENITH,
|
||||
REC_APEX,
|
||||
];
|
||||
|
||||
// Flip a category’s gate by mutating the live config data (the data file
|
||||
// is the source of truth in the game; this test exercises the SAME
|
||||
// config.get path the model reads).
|
||||
const setEnabled = (map) => {
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
config.data.sidequests.categories[cat].enabled = !!map[cat];
|
||||
}
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the category gate — the four types, all OFFLINE by default ==');
|
||||
{
|
||||
assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order');
|
||||
assert.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)');
|
||||
assert.deepEqual(enabledSideQuestCategories(), [], 'all four OFFLINE until their rules land â the board’s face is standby');
|
||||
assert.deepEqual(sideQuestPostingsFor(SEED, REC_NEUTRAL), [], 'the default board is the standby (no postings)');
|
||||
assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)');
|
||||
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const meta = sideQuestCategoryMeta(cat);
|
||||
assert.equal(meta.id, cat, `${cat}: its meta stands`);
|
||||
assert.equal(meta.enabled, false, `${cat}: OFFLINE by default`);
|
||||
assert.ok(meta.label.length > 0, `${cat}: a label for the board’s rows`);
|
||||
assert.match(meta.accent, /^#[0-9a-f]{6}$/i, `${cat}: a row-chip accent`);
|
||||
const band = DATA.sidequests.rewards[cat];
|
||||
assert.ok(Array.isArray(band) && band.length === 2 && band.every((n) => Number.isFinite(n)), `${cat}: a reward band in the data`);
|
||||
for (const ctx of ['neutral', 'faction']) {
|
||||
const pool = DATA.sidequests.descriptions[ctx][cat];
|
||||
assert.ok(Array.isArray(pool) && pool.length >= 3, `${cat}.${ctx}: a variant pool of 3+ (the boards don’t read as one repeated ad)`);
|
||||
for (const v of pool) {
|
||||
assert.ok(typeof v.title === 'string' && v.title.length > 0, `${cat}.${ctx}: a title per variant`);
|
||||
assert.ok(typeof v.body === 'string' && v.body.length > 0, `${cat}.${ctx}: the contract’s body per variant`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the gate flips — one type live → only that type posts ==');
|
||||
{
|
||||
setEnabled({ patrol: true });
|
||||
try {
|
||||
assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category');
|
||||
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
assert.ok(lo >= 1 && hi >= lo, 'the per-system count range stands (3–4 by design)');
|
||||
assert.ok(posts.length >= lo && posts.length <= hi, `${posts.length} postings within [${lo},${hi}]`);
|
||||
assert.ok(posts.every((p) => p.category === 'patrol'), 'only the live type posts');
|
||||
for (const p of posts) {
|
||||
assert.equal(p.systemId, 'sys-free', 'the posting’s system id');
|
||||
assert.equal(p.systemName, 'Free Reach', 'the posting’s system name');
|
||||
assert.equal(p.faction, null, 'a neutral system’s postings carry no faction');
|
||||
assert.match(p.id, /^sq-sys-free-patrol-\d+$/, 'a stable, save-safe id (the ledger’s key)');
|
||||
assert.equal(p.label, 'PATROL', 'the row’s label (the data’s category label)');
|
||||
assert.match(p.accent, /^#[0-9a-f]{6}$/i, 'the row’s accent');
|
||||
assert.ok(p.title.length > 0, 'the title');
|
||||
assert.ok(p.issuer.length > 0, 'the issuer (the neutral pool’s board name)');
|
||||
assert.ok(p.description.length > 20, 'the contract’s body');
|
||||
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'no placeholder ships to the player');
|
||||
assert.equal(p.reward.credits % 5, 0, 'credits in 5 CR steps');
|
||||
assert.ok(p.reward.credits >= 5, 'the 5 CR floor');
|
||||
assert.deepEqual(p.objective, { type: 'patrol' }, 'the completion socket stands typed (the type’s rules fill it in)');
|
||||
}
|
||||
} finally {
|
||||
setEnabled({});
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== all four live — the count, the determinism, the voice ==');
|
||||
{
|
||||
setEnabled({ patrol: true, delivery: true, bounty: true, escort: true });
|
||||
try {
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
for (const rec of RECORDS) {
|
||||
const posts = sideQuestPostingsFor(SEED, rec);
|
||||
assert.ok(posts.length >= lo && posts.length <= hi, `${rec.id}: ${posts.length} postings within [${lo},${hi}]`);
|
||||
const ids = new Set(posts.map((p) => p.id));
|
||||
assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`);
|
||||
assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(3, posts.length), `${rec.id}: the slots spread across the live types`);
|
||||
}
|
||||
|
||||
// DETERMINISM — same seed + record → the same board across calls
|
||||
// (visits / scene restarts / saves); a different seed draws its own.
|
||||
const a = sideQuestPostingsFor(SEED, REC_ZENITH);
|
||||
const b = sideQuestPostingsFor(SEED, REC_ZENITH);
|
||||
assert.deepEqual(b, a, 'the same contract stands across calls');
|
||||
assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH), a, 'a different seed → a different board');
|
||||
// â¦and the systems don’t share one ad (the variant pools + the
|
||||
// per-posting draws stand per system).
|
||||
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX), a, 'a different system draws its own board');
|
||||
|
||||
// NEUTRAL voice â the pirate-safety pools (the data’s contract: the
|
||||
// majority of each neutral pool names the pirates/raiders).
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const pool = DATA.sidequests.descriptions.neutral[cat];
|
||||
const themed = pool.filter((v) => /pirate|raider/i.test(v.title + v.body)).length;
|
||||
assert.ok(themed >= 2, `neutral ${cat}: ${themed}/${pool.length} variants carry the pirate-safety voice`);
|
||||
}
|
||||
const neutralPosts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
|
||||
for (const p of neutralPosts) {
|
||||
assert.equal(p.faction, null, 'the neutral postings carry no faction');
|
||||
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the neutral copy is placeholder-free');
|
||||
}
|
||||
|
||||
// FACTION voice — pushing the OTHER factions out (every faction
|
||||
// variant carries {enemies}; the resolved copy names both the
|
||||
// system’s own faction and at least one of the other two).
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const pool = DATA.sidequests.descriptions.faction[cat];
|
||||
assert.ok(pool.every((v) => v.body.includes('{enemies}')), `faction ${cat}: every variant carries {enemies}`);
|
||||
}
|
||||
const z = sideQuestPostingsFor(SEED, REC_ZENITH);
|
||||
for (const p of z) {
|
||||
assert.equal(p.faction, 'zenith', 'the posting carries its system’s faction');
|
||||
assert.match(p.issuer, /Zenith/, 'the faction’s ISSUER names the faction (the data’s factions table)');
|
||||
const others = (p.description.match(/Apex|MindWeave/g) ?? []).length;
|
||||
assert.ok(others >= 1, 'the other factions stand in the push-out copy');
|
||||
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the faction copy is placeholder-free');
|
||||
}
|
||||
|
||||
// The REWARDS — inside the band (scaled by d), in 5 CR steps.
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const [b0, b1] = DATA.sidequests.rewards[cat];
|
||||
const scale = 1 + 0.5 * DATA.sidequests.rewards.distanceScale;
|
||||
const r = Rng.derive(SEED, 'bandtest', cat);
|
||||
const { credits } = rollSideQuestReward(cat, 0.5, r);
|
||||
assert.ok(credits >= 5, `${cat}: the 5 CR floor holds (d=0.5)`);
|
||||
assert.ok(credits <= Math.ceil((b1 * scale) / 5) * 5, `${cat}: the scaled ceiling holds (d=0.5)`);
|
||||
assert.ok(b0 <= b1, `${cat}: the band is ordered`);
|
||||
}
|
||||
|
||||
// The FEATURE’s master switch â off â the board empties regardless
|
||||
// of the category flags (the kill-switch contract).
|
||||
config.data.sidequests.enabled = false;
|
||||
try {
|
||||
assert.deepEqual(sideQuestPostingsFor(SEED, REC_ZENITH), [], 'sidequests.enabled:false → no postings (the kill-switch)');
|
||||
} finally {
|
||||
config.data.sidequests.enabled = true;
|
||||
}
|
||||
} finally {
|
||||
setEnabled({});
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the placeholder resolution (the pool’s {â¦} keys â the context) ==');
|
||||
{
|
||||
const ctx = { context: 'faction', system: 'Zenith Verge', faction: 'Zenith', issuer: 'Zenith Concord', enemies: 'Apex and MindWeave' };
|
||||
assert.equal(resolveSideQuestText('Holds {system} against the {enemies}.', ctx), 'Holds Zenith Verge against the Apex and MindWeave.', 'every placeholder resolves to the context');
|
||||
assert.equal(resolveSideQuestText('{issuer} pays.', { context: 'faction', system: 'X', faction: 'Zenith', issuer: 'Zenith Concord', enemies: 'A and B' }), 'Zenith Concord pays.', 'the issuer resolves to the faction’s board name');
|
||||
assert.equal(resolveSideQuestText('{faction} keeps {faction}.', { context: 'neutral', system: 'X', faction: '', issuer: '', enemies: '' }), ' keeps .', 'a value-less placeholder blanks (never ships a brace)');
|
||||
assert.equal(resolveSideQuestText('plain', null), 'plain', 'a null context is safe');
|
||||
assert.equal(resolveSideQuestText('', {}), '', 'empty in, empty out');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the ledger â SideQuestState (the run’s accepted/completed marks) ==');
|
||||
{
|
||||
const st = new SideQuestState();
|
||||
assert.equal(st.accept('sq-a-patrol-0'), true, 'a new accept lands');
|
||||
assert.equal(st.accept('sq-a-patrol-0'), false, 'the idempotent accept (a double-press can’t double-take)');
|
||||
assert.equal(st.isAccepted('sq-a-patrol-0'), true, 'isAccepted');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), false, 'only an ACCEPTED contract can complete (completed ⊆ accepted)');
|
||||
assert.equal(st.accept('sq-a-bounty-0'), true, 'a second contract is accepted');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), true, 'a taken contract completes');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), false, 'complete is idempotent too');
|
||||
assert.equal(st.isCompleted('sq-a-bounty-0'), true, 'isCompleted');
|
||||
assert.equal(st.isCompleted('sq-a-patrol-0'), false, '…while its sibling stays accepted, not complete');
|
||||
|
||||
// The save round-trip â the ledger is the save’s side-quest state
|
||||
// (the postings themselves regenerate from the seed: a save = seed +
|
||||
// player state).
|
||||
const json = st.toJSON();
|
||||
assert.deepEqual(json, { accepted: ['sq-a-patrol-0', 'sq-a-bounty-0'], completed: ['sq-a-bounty-0'] }, 'the toJSON shape');
|
||||
const back = SideQuestState.fromJSON(json);
|
||||
assert.deepEqual(back.toJSON(), json, 'fromJSON(toJSON) is the identity');
|
||||
|
||||
// A save predating the feature — no field → an empty ledger, no throw.
|
||||
assert.deepEqual(SideQuestState.fromJSON(null).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty (old saves load)');
|
||||
assert.deepEqual(SideQuestState.fromJSON('garbage').toJSON(), { accepted: [], completed: [] }, 'a malformed record degrades, not throws');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: 'x', completed: 42 }).toJSON(), { accepted: [], completed: [] }, 'wrong shapes are dropped');
|
||||
|
||||
// completed ⊆ accepted is RE-ESTABLISHED on restore.
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-z-bounty-9'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: [] }, 'a completed id with no accept drops');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }, 'a completed-inside-accepted entry survives');
|
||||
|
||||
// The job-space guard â ids the save’s galaxy never posted are dropped
|
||||
// (a hand-edited or foreign ledger degrades instead of sticking).
|
||||
const known = allSideQuestJobIds(RECORDS);
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
assert.equal(known.size, RECORDS.length * hi * SIDE_QUEST_CATEGORIES.length, 'the space = systems × the slot range × the four types');
|
||||
assert.ok(known.has('sq-sys-zenith-patrol-0'), 'a real job id stands in the space');
|
||||
assert.ok(!known.has('sq-nowhere-patrol-0'), 'a foreign system id is outside the space');
|
||||
const guarded = SideQuestState.fromJSON({ accepted: ['sq-sys-zenith-patrol-0', 'sq-nowhere-patrol-0'], completed: [] }, known);
|
||||
assert.deepEqual(guarded.toJSON().accepted, ['sq-sys-zenith-patrol-0'], 'the foreign id drops; the real one stands');
|
||||
assert.deepEqual(SideQuestState.fromJSON(null, known).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty even with a job space');
|
||||
}
|
||||
|
||||
console.log('\nsidequests: OK â the board’s model (the gate, the determinism, the voice, the ledger)');
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
/**
|
||||
* SideQuests — the job board's model: the contracts posted on every
|
||||
* system EXCEPT the player's home (safe space — the Explore drawer's
|
||||
* own gate, data/actionbar.json → surface.explore.items), and the run's
|
||||
* ledger of the contracts it has taken.
|
||||
*
|
||||
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
|
||||
* - the WINDOW renders sideQuestPostingsFor(seed, record) (the current
|
||||
* system's postings) and hands the player's ACCEPT to the scene;
|
||||
* - the SCENE (GameScene.acceptSideQuest — the claimSurfaceQuest
|
||||
* precedent: state + effects there, the note on the surface) lands it
|
||||
* on the SideQuestState ledger (registry 'sidequests' — rides the
|
||||
* saves like the quest ledger, SaveData capture/prepareLoad);
|
||||
* - this module owns the CONTRACTS: which ones stand, what they say,
|
||||
* what they pay, and the accepted/completed record — pure, seeded,
|
||||
* Node-testable (dev/sidequests.test.mjs).
|
||||
*
|
||||
* CATEGORIES — the four job types (data/sidequests.json → categories):
|
||||
* patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR —
|
||||
* its slot machinery here, its description pool + reward band in the
|
||||
* data, the window's row + contract readout, the ledger, the accept
|
||||
* seam — behind its OWN `enabled` gate. All four start OFFLINE
|
||||
* (enabled:false) because none of the four objective/completion rule
|
||||
* sets is built yet, and the gate is the contract: a category's jobs
|
||||
* appear on the board ONLY when its rules land and its flag flips. A
|
||||
* posted job is therefore always a job whose category is live (the
|
||||
* completion wiring for the type arrives with the type — posting
|
||||
* posting.objective, below, is its typed socket).
|
||||
*
|
||||
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
|
||||
* postings derive from the galaxy seed + the system record through
|
||||
* LABELLED forks: Rng.derive(seed, 'sidequests', <sysId>, …). The same
|
||||
* contract stands on the same system for the whole run — across visits,
|
||||
* scene restarts, and saves — and a different system draws its own (the
|
||||
* variant pool keeps the galaxy's boards from reading as one repeated
|
||||
* ad). Nothing here depends on call order.
|
||||
*
|
||||
* FLAVOR — the variant pools split by SYSTEM CONTEXT
|
||||
* (data/sidequests.json → descriptions): NEUTRAL systems post about
|
||||
* keeping their system safe from PIRATES; a FACTION's systems post about
|
||||
* pushing the OTHER factions out of their space. The faction pools carry
|
||||
* placeholders, resolved at generation (never stored): {system} = the
|
||||
* system's name, {faction} = the system's faction label
|
||||
* (data/factions.json), {issuer} = that faction's board name
|
||||
* (data/sidequests.json → factions), {enemies} = the other two
|
||||
* factions' labels ("MindWeave and Zenith").
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
|
||||
/** The four job types, canonical slot order (the data's category keys). */
|
||||
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
|
||||
|
||||
/** The three player factions (data/factions.json minus 'neutral'). */
|
||||
const FACTIONS = ['apex', 'mindweave', 'zenith'];
|
||||
|
||||
const clamp01 = (v) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||||
|
||||
/** The feature's master switch (data/sidequests.json → enabled). */
|
||||
export function sideQuestsEnabled() {
|
||||
return config.get('sidequests.enabled', true) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The postings-per-system roll range (data/sidequests.json →
|
||||
* board.perSystem). A malformed range falls back to the design's 3–4.
|
||||
* @returns {[number, number]} [min, max] (integers, min ≤ max)
|
||||
*/
|
||||
export function sideQuestCountRange() {
|
||||
const raw = config.get('sidequests.board.perSystem', null);
|
||||
if (!Array.isArray(raw) || raw.length < 2) return [3, 4];
|
||||
let lo = Math.floor(Number(raw[0]));
|
||||
let hi = Math.floor(Number(raw[1]));
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 4];
|
||||
lo = Math.max(1, lo);
|
||||
hi = Math.max(lo, hi);
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
/** The Rng fork label under the galaxy seed (data/sidequests.json → board.seedLabel). */
|
||||
function seedLabel() {
|
||||
const s = String(config.get('sidequests.board.seedLabel', 'sidequests') || 'sidequests').trim();
|
||||
return s || 'sidequests';
|
||||
}
|
||||
|
||||
/**
|
||||
* A category's meta (data/sidequests.json → categories.<id>).
|
||||
* @returns {{id:string, enabled:boolean, label:string, accent:string}|null} null when unknown
|
||||
*/
|
||||
export function sideQuestCategoryMeta(id) {
|
||||
if (!SIDE_QUEST_CATEGORIES.includes(id)) return null;
|
||||
const c = config.get(`sidequests.categories.${id}`, null);
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
return {
|
||||
id,
|
||||
enabled: c.enabled === true, // strict — a missing flag is OFFLINE (never fabricate jobs)
|
||||
label: String(c.label ?? id.toUpperCase()),
|
||||
accent: String(c.accent ?? '#00e5ff'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The LIVE categories (enabled:true, canonical order). The board's gate:
|
||||
* only these ever generate a posting — a category is accounted for but
|
||||
* unavailable until its rules are built and its flag flipped.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function enabledSideQuestCategories() {
|
||||
return SIDE_QUEST_CATEGORIES.filter((id) => sideQuestCategoryMeta(id)?.enabled === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Faction id → label (data/factions.json → factions), the three player
|
||||
* factions only. A missing entry falls back to the id's own casing so a
|
||||
* hand-edited table can't blank the board's copy.
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function sideQuestFactionLabels() {
|
||||
const table = config.get('factions.factions', null);
|
||||
const out = {};
|
||||
for (const id of FACTIONS) {
|
||||
const entry = table && typeof table === 'object' ? table[id] : null;
|
||||
const label = entry && typeof entry.label === 'string' && entry.label ? entry.label : id;
|
||||
out[id] = label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pool's placeholders in a line of copy (resolveSideQuestText's
|
||||
* input context). {system} / {faction} / {issuer} / {enemies} — the
|
||||
* neutral context leaves the faction-side keys empty (its pools don't
|
||||
* carry them).
|
||||
* @param {object} rec a system record ({ id, name, faction, … })
|
||||
*/
|
||||
function postingContext(rec) {
|
||||
const system = String(rec?.name ?? rec?.id ?? 'this system');
|
||||
const faction = typeof rec?.faction === 'string' && rec.faction !== '' ? rec.faction : null;
|
||||
if (!faction) return { context: 'neutral', system, faction: '', issuer: '', enemies: '' };
|
||||
const labels = sideQuestFactionLabels();
|
||||
const label = labels[faction] ?? faction;
|
||||
const issuer =
|
||||
String(config.get(`sidequests.factions.${faction}`, '') || '').trim() || `${label} Authority`;
|
||||
const others = FACTIONS.filter((f) => f !== faction).map((f) => labels[f] ?? f);
|
||||
const enemies =
|
||||
others.length === 2 ? `${others[0]} and ${others[1]}` : others.join(', ');
|
||||
return { context: 'faction', system, faction: label, issuer, enemies };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pool's placeholder resolution (the data's contract): {system},
|
||||
* {faction}, {issuer}, {enemies} → the context's values. split/join (no
|
||||
* $-quirks). A placeholder with no value in the context (a {faction} in
|
||||
* a neutral pool — a data bug) resolves to '' rather than leaking its
|
||||
* braces.
|
||||
* @param {string} text the pool line
|
||||
* @param {object} ctx the postingContext() shape
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveSideQuestText(text, ctx = null) {
|
||||
if (typeof text !== 'string' || text === '') return '';
|
||||
ctx = ctx && typeof ctx === 'object' ? ctx : {};
|
||||
const map = {
|
||||
system: ctx.system ?? '',
|
||||
faction: ctx.faction ?? '',
|
||||
issuer: ctx.issuer ?? '',
|
||||
enemies: ctx.enemies ?? '',
|
||||
};
|
||||
let out = text;
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (!value) continue;
|
||||
out = out.split(`{${key}}`).join(String(value));
|
||||
}
|
||||
// Any placeholder that still stands (no value for this context) is
|
||||
// blanked — the board never ships a brace pair to the player.
|
||||
out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/g, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The posting's CREDITS payout (data/sidequests.json → rewards): the
|
||||
* category's band [min,max] rolled seeded per posting, scaled OUT with
|
||||
* the system's home→far diagonal d as (1 + d * distanceScale) — the
|
||||
* far systems' boards pay more for the same risk (the economy's 'a hop
|
||||
* is a hop' rule). A missing band falls back to [40,80]; the result is
|
||||
* rounded to 5 CR, floored at 5 CR.
|
||||
* @param {string} cat the category id
|
||||
* @param {number} d the system's home→far diagonal (0..1)
|
||||
* @param {Rng} r the posting's seeded stream
|
||||
* @returns {{credits:number}}
|
||||
*/
|
||||
export function rollSideQuestReward(cat, d, r) {
|
||||
let band = config.get(`sidequests.rewards.${cat}`, null);
|
||||
if (!Array.isArray(band) || band.length < 2 || !Number.isFinite(Number(band[0])) || !Number.isFinite(Number(band[1]))) {
|
||||
band = [40, 80];
|
||||
}
|
||||
const scale = 1 + clamp01(d) * (Number(config.get('sidequests.rewards.distanceScale', 0.75)) || 0);
|
||||
const lo = Math.min(Number(band[0]), Number(band[1]));
|
||||
const hi = Math.max(Number(band[0]), Number(band[1]));
|
||||
const credits = Math.max(5, Math.round((r.range(lo, hi) * scale) / 5) * 5);
|
||||
return { credits };
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's LIVE postings (the board's list) — 3–4 by design
|
||||
* (sideQuestCountRange), drawn from the LIVE categories only
|
||||
* (enabledSideQuestCategories): distinct categories while they last,
|
||||
* then repeats into the variant pools (the same category can hold more
|
||||
* than one posting once the count outruns the live set).
|
||||
*
|
||||
* Deterministic in (seed, record) — the labelled forks under
|
||||
* Rng.derive(seed, seedLabel, <sysId>) make the roll, the category
|
||||
* order, and every per-posting draw independent of call order, so the
|
||||
* board stands the same across visits, scene restarts, and saves.
|
||||
*
|
||||
* A posting's shape (the window's row + detail + the scene's accept):
|
||||
* { id, systemId, systemName, faction (null = neutral), category,
|
||||
* label, accent, title, issuer, description, reward: { credits },
|
||||
* objective: { type } } — objective is the typed socket the
|
||||
* category's completion rules fill in when the type lands.
|
||||
*
|
||||
* @param {string|number} seed the galaxy's seed
|
||||
* @param {object} rec a system record ({ id, name, faction, d, … })
|
||||
* @returns {object[]} [] when the feature is off or no category is live
|
||||
*/
|
||||
export function sideQuestPostingsFor(seed, rec) {
|
||||
if (!sideQuestsEnabled()) return [];
|
||||
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
|
||||
const cats = enabledSideQuestCategories();
|
||||
if (cats.length === 0) return [];
|
||||
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
const base = Rng.derive(seed, seedLabel(), rec.id);
|
||||
const count = base.int(lo, hi);
|
||||
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
|
||||
const ctx = postingContext(rec);
|
||||
|
||||
const out = [];
|
||||
for (let slot = 0; slot < count; slot++) {
|
||||
const cat = order[slot % order.length];
|
||||
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
|
||||
const posting = buildPosting(seed, rec, ctx, cat, slot, r);
|
||||
if (posting) out.push(posting);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
|
||||
function buildPosting(seed, rec, ctx, cat, slot, r) {
|
||||
const meta = sideQuestCategoryMeta(cat);
|
||||
if (!meta) return null;
|
||||
|
||||
const poolPath = `sidequests.descriptions.${ctx.context}.${cat}`;
|
||||
let pool = config.get(poolPath, null);
|
||||
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
|
||||
pool = Array.isArray(pool) ? pool : [];
|
||||
const v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
|
||||
|
||||
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx);
|
||||
// The row's issuer line: the pool's own name (the neutral pools' —
|
||||
// “The Harbormaster”), or the faction's board name (the faction pools'
|
||||
// {issuer} placeholder — the data's sidequests.factions table), so a
|
||||
// faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”.
|
||||
const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board');
|
||||
const issuer = resolveSideQuestText(issuerRaw, ctx) || 'The Board';
|
||||
const description = resolveSideQuestText(v?.body ?? '', ctx);
|
||||
|
||||
return {
|
||||
id: `sq-${rec.id}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
|
||||
systemId: rec.id,
|
||||
systemName: String(rec.name ?? rec.id),
|
||||
faction: ctx.context === 'faction' ? rec.faction : null,
|
||||
category: cat,
|
||||
label: meta.label,
|
||||
accent: meta.accent,
|
||||
title,
|
||||
issuer,
|
||||
description,
|
||||
reward: rollSideQuestReward(cat, rec.d, r),
|
||||
// The completion socket: the type's objective spec (its steps, its
|
||||
// witness, its payout rule) is built WITH the type and rides here —
|
||||
// the UI already renders objective.text when one stands.
|
||||
objective: { type: cat },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The seed's JOB SPACE — every id the ledger could lawfully hold: each
|
||||
* system × the full slot range (the range's max — a superset of what any
|
||||
* enabled set can post) × all four categories. Used by SideQuestState.
|
||||
* fromJSON to drop ledger ids the save's galaxy never posted (a
|
||||
* hand-edited or foreign ledger degrades instead of sticking).
|
||||
*
|
||||
* @param {object[]} records the galaxy's system records (Galaxy.records)
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function allSideQuestJobIds(records) {
|
||||
const set = new Set();
|
||||
if (!Array.isArray(records)) return set;
|
||||
const [, hi] = sideQuestCountRange();
|
||||
for (const rec of records) {
|
||||
if (!rec || typeof rec.id !== 'string' || rec.id === '') continue;
|
||||
for (let slot = 0; slot < hi; slot++) {
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
set.add(`sq-${rec.id}-${cat}-${slot}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's SIDE QUEST ledger (pure; save-ready): which posted contracts
|
||||
* the player has ACCEPTED and which of those have COMPLETED. The ids are
|
||||
* postings' ids (sq-<sysId>-<cat>-<slot>) — the postings themselves
|
||||
* regenerate from the seed, so the save carries only the marks (a save =
|
||||
* seed + player state). Invariants: completed ⊆ accepted; accept is
|
||||
* idempotent (a double-press or a re-open can't double-take a contract);
|
||||
* a save predating the feature (no field) loads an empty ledger (old
|
||||
* saves load); ids the save's galaxy never posted are dropped, not
|
||||
* propagated.
|
||||
*/
|
||||
export class SideQuestState {
|
||||
constructor() {
|
||||
this.accepted = new Set();
|
||||
this.completed = new Set(); // completed ⊆ accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the contract (idempotent — re-accepting is a no-op).
|
||||
* @returns {boolean} true when NEWLY accepted
|
||||
*/
|
||||
accept(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (this.accepted.has(id)) return false;
|
||||
this.accepted.add(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
isAccepted(id) {
|
||||
return this.accepted.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a taken contract COMPLETE (the type's objective stood — the
|
||||
* scene's call when a category lands; only ACCEPTED contracts can
|
||||
* complete — completed ⊆ accepted).
|
||||
* @returns {boolean} true when NEWLY completed
|
||||
*/
|
||||
complete(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!this.accepted.has(id)) return false;
|
||||
if (this.completed.has(id)) return false;
|
||||
this.completed.add(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
isCompleted(id) {
|
||||
return this.completed.has(id);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
accepted: [...this.accepted],
|
||||
completed: [...this.completed],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a ledger from a save record. A malformed record yields an
|
||||
* empty ledger rather than a throw (contracts are a progression
|
||||
* nicety, not a crash vector — the QuestState precedent). With a
|
||||
* `known` job space (allSideQuestJobIds of the restored galaxy) only
|
||||
* ids inside it survive — a foreign or hand-edited id degrades.
|
||||
* @param {object} data the record.sidequests shape
|
||||
* @param {Set<string>|null} [known] the save's galaxy job space
|
||||
*/
|
||||
static fromJSON(data, known = null) {
|
||||
const st = new SideQuestState();
|
||||
if (!data || typeof data !== 'object') return st;
|
||||
const list = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string' && x !== '') : []);
|
||||
const ok = (id) => (known ? known.has(id) : true);
|
||||
for (const id of list(data.accepted)) {
|
||||
if (!ok(id)) continue;
|
||||
st.accepted.add(id);
|
||||
}
|
||||
for (const id of list(data.completed)) {
|
||||
if (!st.accepted.has(id)) continue; // completed ⊆ accepted
|
||||
if (!ok(id)) continue;
|
||||
st.completed.add(id);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,12 @@
|
|||
* quests: QuestState.toJSON() | null, // held + claimed quest ids
|
||||
* // + the completed checklist
|
||||
* // steps (monotonic witness)
|
||||
* sidequests: SideQuestState.toJSON() | null, // the JOB BOARD's ledger —
|
||||
* // the accepted + completed
|
||||
* // contract ids (the postings
|
||||
* // themselves regenerate from
|
||||
* // the seed — js/quests/
|
||||
* // SideQuests.js)
|
||||
* totalMined: number, // lifetime asteroid mining
|
||||
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
|
||||
|
|
@ -62,6 +68,7 @@ import { Galaxy } from '../galaxy/Galaxy.js';
|
|||
import { Discovery } from '../galaxy/Discovery.js';
|
||||
import { Reputation } from '../reputation/Reputation.js';
|
||||
import { QuestState } from '../quests/QuestState.js';
|
||||
import { SideQuestState, allSideQuestJobIds } from '../quests/SideQuests.js';
|
||||
import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
|
||||
|
||||
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
|
||||
|
|
@ -183,6 +190,13 @@ export function captureState(scene, now) {
|
|||
// stages null and the starter quest is granted onto the fresh ledger
|
||||
// (old saves load).
|
||||
quests: scene.questState ? scene.questState.toJSON() : null,
|
||||
// SIDE QUESTS — the run's JOB BOARD ledger (data/sidequests.json,
|
||||
// js/quests/SideQuests.js): which posted contracts are ACCEPTED and
|
||||
// which of those have COMPLETED (the postings themselves are seed
|
||||
// derivations — a save = seed + player state). A save predating the
|
||||
// feature has no field (and the restore stages null — the fresh run's
|
||||
// GameScene starts an empty ledger; old saves load).
|
||||
sidequests: scene.sideQuestState ? scene.sideQuestState.toJSON() : null,
|
||||
// Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once`
|
||||
// comms the run has heard, so a load doesn't re-fire them and a new
|
||||
// game re-plays the welcome — INCLUDING Trestle's 'Leaving the Nest'
|
||||
|
|
@ -362,6 +376,18 @@ export function prepareLoad(registry, record) {
|
|||
// corrupted ledger from breaking the load (claimed ⊆ granted, ids must
|
||||
// exist in data/quests.json).
|
||||
registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null);
|
||||
// The run's SIDE QUEST ledger (the job board's accepted/completed
|
||||
// contract ids — a save predating the feature has no field → null;
|
||||
// the fresh run's GameScene starts an empty ledger; old saves load).
|
||||
// Validated against the save's OWN job space (allSideQuestJobIds of the
|
||||
// rebuilt galaxy) so a foreign or hand-edited id degrades instead of
|
||||
// sticking; the completed ⊆ accepted invariant is re-established.
|
||||
registry.set(
|
||||
'sidequests',
|
||||
record.sidequests
|
||||
? SideQuestState.fromJSON(record.sidequests, allSideQuestJobIds(galaxy.records))
|
||||
: null,
|
||||
);
|
||||
// Lifetime asteroid mining (a save predating it has no field → 0).
|
||||
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0)));
|
||||
// THE RUN'S STORY LEDGER (a save predating it has no field → the fresh
|
||||
|
|
@ -478,6 +504,7 @@ export function resetRunState(registry) {
|
|||
registry.set('destination', null);
|
||||
registry.set('routeNotice', null);
|
||||
registry.set('quests', null);
|
||||
registry.set('sidequests', null); // the new run's contracts stand fresh
|
||||
registry.set('totalMined', null);
|
||||
registry.set('depletedClusters', null);
|
||||
registry.set('economyStock', null); // the new run's batches are full
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { MapWindow } from '../ui/MapWindow.js';
|
|||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
||||
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
|
||||
import { SideQuestState, sideQuestPostingsFor } from '../quests/SideQuests.js';
|
||||
import { tutorialTarget } from '../quests/TutorialGuide.js';
|
||||
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
|
||||
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
|
||||
|
|
@ -1342,6 +1343,20 @@ export class GameScene extends Phaser.Scene {
|
|||
this.questState = new QuestState();
|
||||
this.registry.set('quests', this.questState);
|
||||
}
|
||||
|
||||
// ---- SIDE QUESTS (the JOB BOARD's ledger — data/sidequests.json) ----
|
||||
// The contracts the run has taken on the job board (every system
|
||||
// except home — the Explore drawer's own gate): accepted + completed
|
||||
// ids (the postings themselves are seed derivations — a save = seed +
|
||||
// player state, js/quests/SideQuests.js). The board's window renders
|
||||
// sideQuestPostings() over the SLEEPING scene (the QuestWindow
|
||||
// precedent — state + effects here, the view on the surface), and the
|
||||
// ACCEPT lands here (acceptSideQuest below).
|
||||
this.sideQuestState = this.registry.get('sidequests') ?? null;
|
||||
if (!this.sideQuestState) {
|
||||
this.sideQuestState = new SideQuestState();
|
||||
this.registry.set('sidequests', this.sideQuestState);
|
||||
}
|
||||
this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each
|
||||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.questSnapshot(),
|
||||
|
|
@ -4457,6 +4472,42 @@ export class GameScene extends Phaser.Scene {
|
|||
return String(rw ?? '');
|
||||
}
|
||||
|
||||
// ------------------------------------------------- SIDE QUESTS (board)
|
||||
/**
|
||||
* The current system's LIVE postings (the job board's list — the
|
||||
* surface's window renders this over the sleeping scene, the
|
||||
* QuestWindow precedent). Deterministic in (galaxy seed, system
|
||||
* record) — the same contracts stand here across visits, scene
|
||||
* restarts, and saves (js/quests/SideQuests.js). [] when the feature
|
||||
* is off or no category is live (the board's standby state).
|
||||
* @returns {object[]}
|
||||
*/
|
||||
sideQuestPostings() {
|
||||
const rec = this.systemRecord ?? null;
|
||||
if (!this.galaxy || !rec) return [];
|
||||
return sideQuestPostingsFor(this.galaxy.seed, rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's ACCEPT (the surface's window → SurfaceScene.
|
||||
* acceptSurfaceJob → here — the claimSurfaceQuest precedent: the
|
||||
* ledger + sfx are the scene's, the toast is the surface's). Takes the
|
||||
* contract ONTO the run's SideQuestState ledger exactly once (accept()
|
||||
* is idempotent — a double-press or a re-open can't double-take it) and
|
||||
* returns the posting so the caller can note + repaint. A posting the
|
||||
* board doesn't list (a foreign id, or a category since gone) is a
|
||||
* refusal, not a record.
|
||||
* @returns {{ok:boolean, posting?:object}}
|
||||
*/
|
||||
acceptSideQuest(jobId) {
|
||||
if (!this.sideQuestState) return { ok: false };
|
||||
const posting = this.sideQuestPostings().find((p) => p.id === jobId) ?? null;
|
||||
if (!posting) return { ok: false };
|
||||
if (!this.sideQuestState.accept(jobId)) return { ok: false };
|
||||
this.playSfx('construct'); // the power-up tick — the contract lands
|
||||
return { ok: true, posting };
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLAIM REWARD action (QuestWindow's button → here). The window
|
||||
* only offers the button when the checklist reads complete; this is
|
||||
|
|
|
|||
|
|
@ -1239,12 +1239,18 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
// (60) stay live on top — the drawer's slot is the way-out (JOB BOARD
|
||||
// ↔ LEAVE; toggleJobBoard owns the relabel + lock). The left feed is
|
||||
// assets/videos/jobboard.mp4 (GameScene's preload, shared cache, muted
|
||||
// loop). The AVAILABLE JOBS + JOB DETAILS sections stand as
|
||||
// placeholders for now — the postings + the selection land later
|
||||
// (JobBoardWindow.refresh is the repaint seam).
|
||||
// loop). Right: the AVAILABLE JOBS list (the current system's live
|
||||
// postings — the SLEEPING GameScene's seam, the QuestWindow
|
||||
// precedent: state + effects there, the view here) + the JOB DETAILS
|
||||
// contract readout, with the ACCEPT door onto GameScene.
|
||||
// acceptSideQuest (the ledger + sfx) — the note is ours.
|
||||
this.jobBoardWindow = new JobBoardWindow(this, {
|
||||
planetName: this.planetName,
|
||||
onClose: () => this.leaveJobBoard(),
|
||||
postings: () => this.gameScene?.sideQuestPostings?.() ?? [],
|
||||
isAccepted: (id) => this.gameScene?.sideQuestState?.isAccepted?.(id) ?? false,
|
||||
isCompleted: (id) => this.gameScene?.sideQuestState?.isCompleted?.(id) ?? false,
|
||||
onAccept: (id) => this.acceptSurfaceJob(id),
|
||||
});
|
||||
|
||||
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
||||
|
|
@ -1482,6 +1488,35 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The JOB BOARD's ACCEPT (the console's button → here — the
|
||||
* claimSurfaceQuest precedent, over the sleeping GameScene's ledger):
|
||||
* the scene takes the contract onto its SideQuestState (the idempotent
|
||||
* accept + the sfx) and hands back the posting; the NOTE is ours (we
|
||||
* are the scene the player reads — the game scene's console toast
|
||||
* would land behind the sleeping deck), and the console repaints its
|
||||
* row (the contract's ACCEPTED state) off the scene's fresh ledger.
|
||||
*/
|
||||
acceptSurfaceJob(jobId) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.acceptSideQuest) return;
|
||||
const res = g.acceptSideQuest(jobId);
|
||||
const t = config.get('sidequests.ui.acceptToast', {});
|
||||
if (res?.ok) {
|
||||
this.consoleNote(
|
||||
String(t.text ?? 'CONTRACT ACCEPTED — {title}').replace('{title}', String(res.posting.title).toUpperCase()),
|
||||
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
} else {
|
||||
const d = config.get('sidequests.ui.acceptDenyToast', {});
|
||||
this.consoleNote(String(d.text ?? 'COMMISSION DENIED'), {
|
||||
glyph: d.glyph ?? '✗',
|
||||
glyphColor: d.color ? toCss(d.color) : undefined,
|
||||
});
|
||||
}
|
||||
this.jobBoardWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* SET PRIORITY from the surface console — the ledger lives on the
|
||||
* sleeping GameScene (the claimSurfaceQuest precedent), and the
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@
|
|||
* ├───────────────────────┬──────────────────────────────────────────────┤
|
||||
* │ ● BOARD FEED // LIVE │ AVAILABLE JOBS │
|
||||
* │ ┌─────────────────┐ │ ┌────────────────────────────────────────┐ │
|
||||
* │ │ looping video │ │ │ NO CONTRACTS ON FILE — the postings │ │
|
||||
* │ │ (muted, 2:3) │ │ │ list lands here (later) │ │
|
||||
* │ │ + scanlines │ │ └────────────────────────────────────────┘ │
|
||||
* │ │ + sweep band │ │ JOB DETAILS │
|
||||
* │ └─────────────────┘ │ ┌────────────────────────────────────────┐ │
|
||||
* │ BOARD STANDBY — … │ │ SELECT A JOB — the contract readout │ │
|
||||
* │ │ │ lands here (later) │ │
|
||||
* │ │ looping video │ │ │ · the live postings — one row per job │ │
|
||||
* │ │ (muted, 2:3) │ │ │ (the standby plate while no type │ │
|
||||
* │ │ + scanlines │ │ │ is live — the data's category gate) │ │
|
||||
* │ │ + sweep band │ │ └────────────────────────────────────────┘ │
|
||||
* │ └─────────────────┘ │ JOB DETAILS │
|
||||
* │ BOARD STANDBY — … │ ┌────────────────────────────────────────┐ │
|
||||
* │ │ │ the selected job's contract — the │ │
|
||||
* │ │ │ copy, the payout, the ACCEPT door │ │
|
||||
* │ │ └────────────────────────────────────────┘ │
|
||||
* └───────────────────────┴──────────────────────────────────────────────┘
|
||||
*
|
||||
|
|
@ -26,10 +27,12 @@
|
|||
* the board's feed — assets/videos/jobboard.mp4, a 2:3 portrait that
|
||||
* LOOPS MUTED while the console is up (the build feed's twin;
|
||||
* data/actionbar.json → surface.explore.video). Right: the AVAILABLE
|
||||
* JOBS section (the board's postings — flexing from the body's top
|
||||
* edge) and the JOB DETAILS section (the selected job's contract) —
|
||||
* both PLACEHOLDERS for now; the postings + the selection land later
|
||||
* (refresh() is the repaint seam).
|
||||
* JOBS section (the board's LIVE postings — the scene's seam: the
|
||||
* current system's contracts) over the JOB DETAILS section (the
|
||||
* selected job's contract + the ACCEPT door). The window is a pure
|
||||
* VIEW over js/quests/SideQuests.js: refresh() repaints both off the
|
||||
* seams (postings / isAccepted / isCompleted / onAccept); the ledger
|
||||
* and the accept effects live on the scene.
|
||||
*
|
||||
* Depth 80 — the build screen's: nearly full-screen, ABOVE the deck
|
||||
* (50) + drawer (60) + the upper-left surface HUD (60), so it covers
|
||||
|
|
@ -63,10 +66,32 @@ const C = {
|
|||
neon: themeColor('neon', 0x00e5ff),
|
||||
neon2: themeColor('neon2', 0xff2d6f),
|
||||
amber: themeColor('amber', 0xffc94d),
|
||||
green: themeColor('green', 0x67e863),
|
||||
panel: themeColor('panel', 0x0a1120),
|
||||
bg: themeColor('bg', 0x04060d),
|
||||
};
|
||||
|
||||
/** '#rrggbb' → a Phaser hex number (a category accent from the data). */
|
||||
function hexNum(str, fallback = 0xffffff) {
|
||||
const m = String(str ?? '').trim().match(/^#?([0-9a-f]{6})$/i);
|
||||
return m ? parseInt(m[1], 16) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy's line fit (deterministic — no Phaser measure, so the headless
|
||||
* stub and the game agree): cut at a word boundary + '…' when the text
|
||||
* would run past maxLines at ~charsPerLine.
|
||||
*/
|
||||
function fitText(str, charsPerLine, maxLines) {
|
||||
let s = String(str ?? '');
|
||||
const cap = Math.max(20, Math.floor(charsPerLine)) * Math.max(1, Math.floor(maxLines));
|
||||
if (s.length <= cap) return s;
|
||||
s = s.slice(0, cap - 1);
|
||||
const sp = s.lastIndexOf(' ');
|
||||
if (sp > cap * 0.6) s = s.slice(0, sp);
|
||||
return s.trimEnd() + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a cut-corner plate with its top-left at (x, y) into an existing
|
||||
* Graphics (CyberShape.points is centred on (0,0) — translate by the
|
||||
|
|
@ -133,6 +158,18 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
|
||||
this.planetName = String(o.planetName ?? '').trim();
|
||||
this.onClose = typeof o.onClose === 'function' ? o.onClose : null;
|
||||
// The board's data + state seams (the scene's — the QuestWindow
|
||||
// precedent: state + effects there, the view here): the current
|
||||
// system's live postings (GameScene.sideQuestPostings), the ledger's
|
||||
// accepted/completed marks (GameScene.sideQuestState), and the ACCEPT
|
||||
// door (GameScene.acceptSideQuest via the surface's note). Missing
|
||||
// seams stand the board on its STANDBY plate (a test scene, or a
|
||||
// pre-feature save's load) — the console still works.
|
||||
this.postings = typeof o.postings === 'function' ? o.postings : () => [];
|
||||
this.isAccepted = typeof o.isAccepted === 'function' ? o.isAccepted : () => false;
|
||||
this.isCompleted = typeof o.isCompleted === 'function' ? o.isCompleted : () => false;
|
||||
this.onAccept = typeof o.onAccept === 'function' ? o.onAccept : null;
|
||||
this.selectedId = null; // the list's selection (the detail's contract)
|
||||
|
||||
this.openState = 'closed'; // closed | opening | open | closing
|
||||
this.booted = false;
|
||||
|
|
@ -204,7 +241,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
const headH = 12; // a section's header line (10 px font)
|
||||
const headGap = 10; // header → panel
|
||||
const secGap = 16; // available panel → details header
|
||||
const detailH = 150; // the details panel (the build's detail height)
|
||||
const detailH = 190; // the details panel — title + issuer + the contract's copy + the payout + the ACCEPT bar
|
||||
const availHeadY = bodyY + 8; // the AVAILABLE JOBS header (just under the top bar)
|
||||
const availTop = availHeadY + headH + headGap;
|
||||
const detailPanelY = bodyBottom - detailH;
|
||||
|
|
@ -350,23 +387,24 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
return t;
|
||||
}
|
||||
|
||||
// ── right: the two placeholder sections ────────────────────────────
|
||||
// ── right: the two live sections ─────────────────────────────────
|
||||
/**
|
||||
* AVAILABLE JOBS (the board's postings) over JOB DETAILS (the selected
|
||||
* job's contract) — PLACEHOLDERS for now: framed, labelled, and
|
||||
* standing by. The postings list + the selection land later; refresh()
|
||||
* is the repaint seam (the build console's rows/detail shape is the
|
||||
* model to copy when they arrive).
|
||||
* AVAILABLE JOBS (the board's postings — the current system's live
|
||||
* contracts) over JOB DETAILS (the selected job's readout + the
|
||||
* ACCEPT door). The frames + headers are STATIC geometry; the content
|
||||
* inside each is DYNAMIC — refresh() repaints both off the seams
|
||||
* (postings / isAccepted / isCompleted / onAccept — the scene's, the
|
||||
* QuestWindow precedent). While no category is live (the board's gate
|
||||
* — data/sidequests.json → categories' `enabled` flags), the list
|
||||
* stands on its standby plate (the console's face until the first
|
||||
* type flips on) — same copy as the original placeholder.
|
||||
*/
|
||||
_buildSections() {
|
||||
const s = this.scene.add;
|
||||
const { rightX, rightW } = this.geo;
|
||||
const mk = (headY, panelY, panelH, head, main, sub) => {
|
||||
// NOTE: added to THE CONTAINER (this.add), not left scene-level —
|
||||
// the container carries the window's depth (10), and a scene-level
|
||||
// object at depth 0 hides behind the window's own opaque backdrop.
|
||||
const headTxt = s
|
||||
.text(rightX + 6, headY, head, {
|
||||
const headTxt = (headY, text) => {
|
||||
const t = s
|
||||
.text(rightX + 6, headY, text, {
|
||||
fontFamily: HEADER,
|
||||
fontSize: '10px',
|
||||
color: toCss(C.dim),
|
||||
|
|
@ -374,7 +412,13 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
letterSpacing: 3,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
this.add(headTxt);
|
||||
this.add(t);
|
||||
return t;
|
||||
};
|
||||
const frame = (panelY, panelH) => {
|
||||
// NOTE: added to THE CONTAINER (this.add), not scene-level —
|
||||
// the container carries the window's depth (10), and a scene-level
|
||||
// object at depth 0 hides behind the window's own opaque backdrop.
|
||||
const bg = s.graphics().setScrollFactor(0);
|
||||
this.add(bg);
|
||||
bg.clear();
|
||||
|
|
@ -387,8 +431,75 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
});
|
||||
bg.fillStyle(C.neon, 0.3);
|
||||
bg.fillRect(rightX + 12, panelY + 1, rightW - 24, 1.5);
|
||||
const mainTxt = s
|
||||
.text(rightX + rightW / 2, panelY + panelH / 2 - 8, main, {
|
||||
return bg;
|
||||
};
|
||||
|
||||
const g = this.geo;
|
||||
this.jobsSection = {
|
||||
headTxt: headTxt(g.availHeadY, 'AVAILABLE JOBS'),
|
||||
bg: frame(g.availTop, g.availH),
|
||||
content: [],
|
||||
};
|
||||
this.detailSection = {
|
||||
headTxt: headTxt(g.detailHeadY, 'JOB DETAILS'),
|
||||
bg: frame(g.detailPanelY, g.detailH),
|
||||
content: [],
|
||||
};
|
||||
this._rows = []; // the list's interactive rows ({ post, hover, paint })
|
||||
this.refresh(); // the initial paint (the standby plate, or the live postings)
|
||||
}
|
||||
|
||||
// ── the list + the detail readout (the dynamic halves) ──────────────
|
||||
/** The live postings off the seam (a seam fault stands an empty board, never a crash). */
|
||||
_livePostings() {
|
||||
let posts = null;
|
||||
try {
|
||||
posts = this.postings();
|
||||
} catch {
|
||||
posts = null;
|
||||
}
|
||||
return Array.isArray(posts) ? posts.filter((p) => p && typeof p.id === 'string') : [];
|
||||
}
|
||||
|
||||
/** Tear a section's rendered content down (it is rebuilt on the repaint). */
|
||||
_destroyContent(list) {
|
||||
for (const o of list) {
|
||||
try {
|
||||
o?.destroy?.();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
list.length = 0;
|
||||
}
|
||||
|
||||
/** The selection's row repaint (all rows re-read the selected state). */
|
||||
_repaintList() {
|
||||
for (const row of this._rows) {
|
||||
row.selected = this.selectedId === row.post.id;
|
||||
row.paint();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the AVAILABLE JOBS list: the postings' rows (chip + title +
|
||||
* issuer line, the payout — or the row's LEDGER state once the contract
|
||||
* is taken), selectable, or the standby plate while no category is live.
|
||||
*/
|
||||
_renderList() {
|
||||
const s = this.scene.add;
|
||||
const { rightX, rightW, availTop, availH } = this.geo;
|
||||
this._destroyContent(this.jobsSection.content);
|
||||
this.jobsSection.content.length = 0;
|
||||
this._rows = [];
|
||||
|
||||
const posts = this._livePostings();
|
||||
const ui = config.get('sidequests.ui', {});
|
||||
if (posts.length === 0) {
|
||||
// STANDBY — the board's face while no category is live (data/
|
||||
// sidequests.json → categories); the same copy the placeholder wore.
|
||||
const main = s
|
||||
.text(rightX + rightW / 2, availTop + availH / 2 - 8, String(ui.standbyMain ?? 'NO CONTRACTS ON FILE'), {
|
||||
fontFamily: HEADER,
|
||||
fontSize: '12px',
|
||||
color: toCss(C.faint),
|
||||
|
|
@ -398,9 +509,8 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
this.add(mainTxt);
|
||||
const subTxt = s
|
||||
.text(rightX + rightW / 2, panelY + panelH / 2 + 12, sub, {
|
||||
const sub = s
|
||||
.text(rightX + rightW / 2, availTop + availH / 2 + 12, String(ui.standbySub ?? 'THE BOARD’S POSTINGS LAND HERE'), {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10px',
|
||||
color: toCss(C.faint),
|
||||
|
|
@ -409,22 +519,264 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
this.add(subTxt);
|
||||
return { headTxt, bg, mainTxt, subTxt };
|
||||
this.add(main);
|
||||
this.add(sub);
|
||||
this.jobsSection.content.push(main, sub);
|
||||
return;
|
||||
}
|
||||
|
||||
const pad = 10;
|
||||
const gap = 8;
|
||||
const n = posts.length;
|
||||
const rowH = Math.max(34, Math.min(58, (availH - 2 * pad - (n - 1) * gap) / n));
|
||||
const unit = String(ui.rewardUnit ?? 'CR');
|
||||
|
||||
posts.forEach((p, i) => {
|
||||
const y = availTop + pad + i * (rowH + gap);
|
||||
const g = s.graphics().setScrollFactor(0);
|
||||
this.add(g);
|
||||
const row = { post: p, hover: false, selected: false, hit: g };
|
||||
const chipColor = hexNum(p.accent, C.neon);
|
||||
const title = s
|
||||
.text(rightX + 32, y + 9, String(p.title ?? '').toUpperCase(), {
|
||||
fontFamily: HEADER,
|
||||
fontSize: '11px',
|
||||
color: toCss(C.ink),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 1.5,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
const sub = s
|
||||
.text(
|
||||
rightX + 32,
|
||||
y + rowH - 16,
|
||||
`${String(p.label ?? '').toUpperCase()} — ${String(p.issuer ?? '').toUpperCase()}`,
|
||||
{ fontFamily: BODY, fontSize: '9px', color: toCss(C.faint), letterSpacing: 2 },
|
||||
)
|
||||
.setScrollFactor(0);
|
||||
// The row's right end: the payout while open, the ledger's mark once
|
||||
// taken (the state is read off the seams at repaint — the ledger is
|
||||
// the truth, the scene's refresh() lands it after the accept).
|
||||
let rightTxt = `${p.reward?.credits ?? 0} ${unit}`;
|
||||
let rightColor = C.amber;
|
||||
if (this.isCompleted(p.id)) {
|
||||
rightTxt = String(ui.completed ?? 'CONTRACT COMPLETE');
|
||||
rightColor = C.neon;
|
||||
} else if (this.isAccepted(p.id)) {
|
||||
rightTxt = String(ui.accepted ?? 'CONTRACT ACCEPTED');
|
||||
rightColor = C.green;
|
||||
}
|
||||
const right = s
|
||||
.text(rightX + rightW - 16, y + 10, rightTxt, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10px',
|
||||
color: toCss(rightColor),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 2,
|
||||
})
|
||||
.setOrigin(1, 0)
|
||||
.setScrollFactor(0);
|
||||
|
||||
const paint = () => {
|
||||
g.clear();
|
||||
panel(g, rightX + 2, y, rightW - 4, rowH, {
|
||||
notch: 8,
|
||||
fill: row.selected ? 0x0d1c30 : 0x081120,
|
||||
fillAlpha: row.selected ? 0.95 : 0.5,
|
||||
stroke: row.selected ? chipColor : 0x1b3a5a,
|
||||
strokeAlpha: row.selected ? 1 : row.hover ? 0.95 : 0.4,
|
||||
});
|
||||
if (row.selected) {
|
||||
g.fillStyle(chipColor, 1);
|
||||
g.fillRect(rightX + 3, y + 7, 3, rowH - 14);
|
||||
}
|
||||
g.fillStyle(chipColor, 0.95);
|
||||
g.fillRect(rightX + 15, y + rowH / 2 - 6, 11, 11);
|
||||
};
|
||||
|
||||
const hit = new Phaser.Geom.Rectangle(rightX + 2, y, rightW - 4, rowH);
|
||||
g.setInteractive({ useHandCursor: true, hitArea: hit, hitAreaCallback: (area, px, py) => area.contains(px, py) });
|
||||
g.on('pointerover', () => {
|
||||
row.hover = true;
|
||||
paint();
|
||||
this.sfx('ui_hover');
|
||||
});
|
||||
g.on('pointerout', () => {
|
||||
row.hover = false;
|
||||
paint();
|
||||
});
|
||||
g.on('pointerdown', () => {
|
||||
this.sfx('ui_click');
|
||||
this.selectedId = p.id;
|
||||
this._repaintList();
|
||||
this._renderDetail();
|
||||
});
|
||||
|
||||
row.paint = paint;
|
||||
this._rows.push(row);
|
||||
this.jobsSection.content.push(g, title, sub, right);
|
||||
});
|
||||
this._repaintList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the JOB DETAILS readout: the selection's contract (title,
|
||||
* issuer, the copy, the payout) + the ACCEPT door — the door's state
|
||||
* off the ledger's seams (open → accepted → complete; the accept
|
||||
* lands on the scene, which repaints us — the refresh() seam).
|
||||
*/
|
||||
_renderDetail() {
|
||||
const s = this.scene.add;
|
||||
const { rightX, rightW, detailPanelY: py, detailH: ph } = this.geo;
|
||||
this._destroyContent(this.detailSection.content);
|
||||
this.detailSection.content.length = 0;
|
||||
|
||||
const posts = this._livePostings();
|
||||
const ui = config.get('sidequests.ui', {});
|
||||
const centered = (main, sub) => {
|
||||
const m = s
|
||||
.text(rightX + rightW / 2, py + ph / 2 - 8, main, {
|
||||
fontFamily: HEADER,
|
||||
fontSize: '12px',
|
||||
color: toCss(C.faint),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 3,
|
||||
align: 'center',
|
||||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
const t = s
|
||||
.text(rightX + rightW / 2, py + ph / 2 + 12, sub, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10px',
|
||||
color: toCss(C.faint),
|
||||
letterSpacing: 2,
|
||||
align: 'center',
|
||||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
this.add(m);
|
||||
this.add(t);
|
||||
this.detailSection.content.push(m, t);
|
||||
};
|
||||
|
||||
const g = this.geo;
|
||||
this.jobsSection = mk(
|
||||
g.availHeadY, g.availTop, g.availH,
|
||||
'AVAILABLE JOBS',
|
||||
'NO CONTRACTS ON FILE',
|
||||
'THE BOARD’S POSTINGS LAND HERE',
|
||||
);
|
||||
this.detailSection = mk(
|
||||
g.detailHeadY, g.detailPanelY, g.detailH,
|
||||
'JOB DETAILS',
|
||||
'SELECT A JOB',
|
||||
'PICK A LISTING TO READ ITS CONTRACT',
|
||||
if (posts.length === 0) {
|
||||
centered(String(ui.standbyMain ?? 'NO CONTRACTS ON FILE'), String(ui.standbySub ?? 'THE BOARD’S POSTINGS LAND HERE'));
|
||||
return;
|
||||
}
|
||||
const sel = posts.find((p) => p.id === this.selectedId) ?? null;
|
||||
if (!sel) {
|
||||
centered(String(ui.selectMain ?? 'SELECT A JOB'), String(ui.selectSub ?? 'PICK A LISTING TO READ ITS CONTRACT'));
|
||||
return;
|
||||
}
|
||||
|
||||
const accent = hexNum(sel.accent, C.neon);
|
||||
const wrapW = Math.max(80, rightW - 36);
|
||||
const title = s
|
||||
.text(rightX + 14, py + 12, String(sel.title ?? '').toUpperCase(), {
|
||||
fontFamily: HEADER,
|
||||
fontSize: '13px',
|
||||
color: toCss(accent),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 2,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
const issuer = s
|
||||
.text(rightX + 14, py + 34, `ISSUER — ${String(sel.issuer ?? '').toUpperCase()}`, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '9px',
|
||||
color: toCss(C.faint),
|
||||
letterSpacing: 2,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
const body = s
|
||||
.text(rightX + 14, py + 52, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10.5px',
|
||||
color: toCss(C.dim),
|
||||
lineSpacing: 3,
|
||||
wordWrap: { width: wrapW },
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
const payout = s
|
||||
.text(rightX + 14, py + ph - 46, `PAYOUT — ${sel.reward?.credits ?? 0} ${String(ui.rewardUnit ?? 'CR')}`, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10px',
|
||||
color: toCss(C.amber),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 2,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
|
||||
// the ACCEPT door (full-width bar — the console's action shape; its
|
||||
// state off the ledger's seams: open → accepted → complete)
|
||||
const state = this.isCompleted(sel.id) ? 'completed' : this.isAccepted(sel.id) ? 'accepted' : 'open';
|
||||
const label =
|
||||
state === 'completed'
|
||||
? String(ui.completed ?? 'CONTRACT COMPLETE')
|
||||
: state === 'accepted'
|
||||
? String(ui.accepted ?? 'CONTRACT ACCEPTED')
|
||||
: String(ui.accept ?? 'ACCEPT CONTRACT');
|
||||
const color = state === 'open' ? accent : state === 'accepted' ? C.green : C.neon;
|
||||
const bh = 26;
|
||||
const by = py + ph - 34;
|
||||
const bx = rightX + 14;
|
||||
const bw = rightW - 28;
|
||||
const btn = { hover: false };
|
||||
const g = s.graphics().setScrollFactor(0);
|
||||
this.add(g);
|
||||
const txt = s
|
||||
.text(bx + bw / 2, by + bh / 2 + 1, label, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '11px',
|
||||
color: toCss(color),
|
||||
fontStyle: 'bold',
|
||||
letterSpacing: 3,
|
||||
align: 'center',
|
||||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
const paint = () => {
|
||||
g.clear();
|
||||
panel(g, bx, by, bw, bh, {
|
||||
notch: 7,
|
||||
fill: C.panel,
|
||||
fillAlpha: state === 'open' ? (btn.hover ? 0.9 : 0.5) : 0.35,
|
||||
stroke: color,
|
||||
strokeAlpha: state === 'open' ? (btn.hover ? 1 : 0.8) : 0.45,
|
||||
});
|
||||
txt.setAlpha(state === 'open' ? (btn.hover ? 1 : 0.9) : 0.7);
|
||||
};
|
||||
paint();
|
||||
if (state === 'open') {
|
||||
const hit = new Phaser.Geom.Rectangle(bx, by, bw, bh);
|
||||
g.setInteractive({ useHandCursor: true, hitArea: hit, hitAreaCallback: (area, px, py2) => area.contains(px, py2) });
|
||||
g.on('pointerover', () => {
|
||||
btn.hover = true;
|
||||
paint();
|
||||
this.sfx('ui_hover');
|
||||
});
|
||||
g.on('pointerout', () => {
|
||||
btn.hover = false;
|
||||
paint();
|
||||
});
|
||||
g.on('pointerdown', () => {
|
||||
this.sfx('ui_click');
|
||||
if (this.onAccept) this.onAccept(sel.id);
|
||||
});
|
||||
}
|
||||
this.add(txt);
|
||||
this.detailSection.content.push(title, issuer, body, payout, g, txt);
|
||||
}
|
||||
|
||||
/** The feed's status strip (the board's face — the standby, or the live count). */
|
||||
_paintStatus() {
|
||||
const ui = config.get('sidequests.ui', {});
|
||||
const n = this._livePostings().length;
|
||||
this.statusTxt.setText(
|
||||
n > 0
|
||||
? String(ui.statusLive ?? '{n} CONTRACTS ON FILE').replace('{n}', String(n))
|
||||
: String(ui.statusStandby ?? 'BOARD STANDBY — NO CONTRACTS ON FILE'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -596,10 +948,17 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
this.closeTxt.setAlpha(b.hover ? 1 : 0.8);
|
||||
}
|
||||
|
||||
/** Repaint the placeholder sections (the seam — the postings + the
|
||||
* selected job's contract repaint here when they land). */
|
||||
/**
|
||||
* The repaint seam — the list + the detail readout + the feed's status
|
||||
* strip, all off the scene's seams (postings / isAccepted /
|
||||
* isCompleted). Called on open (the seams are re-read — the board is
|
||||
* for the scene's CURRENT system) and after the scene's accept (the
|
||||
* row's ACCEPTED state lands off the fresh ledger).
|
||||
*/
|
||||
refresh() {
|
||||
// The sections stand as built (NO CONTRACTS ON FILE / SELECT A JOB).
|
||||
this._renderList();
|
||||
this._renderDetail();
|
||||
this._paintStatus();
|
||||
}
|
||||
|
||||
// ── open / close ─────────────────────────────────────────────────────────
|
||||
|
|
@ -615,6 +974,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
// re-open of a paused clip.
|
||||
this.video?.resume?.();
|
||||
this.sfx('ui_window');
|
||||
this.refresh(); // the seams' fresh read (the board is for the CURRENT system)
|
||||
this._startReveal();
|
||||
}
|
||||
|
||||
|
|
@ -662,12 +1022,10 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
push(this.rightTitle, 300, 300, 'rise', this.rightTitle.y);
|
||||
push(this.jobsSection.headTxt, 420, 240, 'fade');
|
||||
push(this.jobsSection.bg, 440, 260, 'rise', 0);
|
||||
push(this.jobsSection.mainTxt, 480, 240, 'fade');
|
||||
push(this.jobsSection.subTxt, 520, 240, 'fade');
|
||||
push(this.detailSection.headTxt, 560, 240, 'fade');
|
||||
push(this.detailSection.bg, 580, 260, 'rise', 0);
|
||||
push(this.detailSection.mainTxt, 620, 240, 'fade');
|
||||
push(this.detailSection.subTxt, 660, 240, 'fade');
|
||||
this.jobsSection.content.forEach((o, i) => push(o, 470 + i * 45, 220, 'fade'));
|
||||
push(this.detailSection.headTxt, 600, 240, 'fade');
|
||||
push(this.detailSection.bg, 620, 260, 'rise', 0);
|
||||
this.detailSection.content.forEach((o, i) => push(o, 660 + i * 45, 220, 'fade'));
|
||||
this.booted = true;
|
||||
this.glitch.next = now + rand(6000, 13000);
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue