Compare commits
2 Commits
b1ae21e95e
...
9b7bf334e4
| Author | SHA1 | Date |
|---|---|---|
|
|
9b7bf334e4 | |
|
|
ce26b38df9 |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
|
@ -15,8 +15,8 @@
|
|||
{ "id": "research", "label": "Research", "accent": "#00e5ff" },
|
||||
{ "id": "scan", "label": "Scan", "accent": "#ffc94d" },
|
||||
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||
{ "id": "quests", "label": "Quests", "accent": "#ffc94d" },
|
||||
{ "id": "map", "label": "Map", "accent": "#c084fc" },
|
||||
{ "id": null, "label": null },
|
||||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||
],
|
||||
"surface": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
{ "id": "shop", "label": "Shop", "labelActive": "Leave Shop", "accent": "#00e5ff" },
|
||||
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
|
||||
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||
{ "id": null, "label": null },
|
||||
{ "id": "quests", "label": "Quests", "accent": "#ffc94d" },
|
||||
{ "id": "takeoff", "label": "Take Off", "accent": "#7ce8a4" },
|
||||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"signalCompass.json",
|
||||
"save.json",
|
||||
"mineralhud.json",
|
||||
"map.json"
|
||||
"map.json",
|
||||
"quests.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"_comment": "The QUESTS console (deck slot right of SHIP, main deck + surface deck). The window mirrors the RESEARCH/MAP consoles (js/ui/QuestWindow.js): a 2:3 archive feed (assets/videos/quests.mp4 — the same aspect as assets/videos/research.mp4; NO SIGNAL plate until the clip is authored) on the left, and on the right the category tabs (Main Story / Side Quests — side quests are not defined yet: the tab is a STANDBY socket), a THIN list of active quests, and the big detail plate below it: title, issuer, description, the requirement checklist, and the reward, with the CLAIM action. A quest's progress is computed LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) — js/quests/QuestState.js only tracks who holds which quest and whether its reward has been claimed, and that is what gets saved.",
|
||||
"enabled": true,
|
||||
"title": "OPERATIONS DOSSIER",
|
||||
"meta": "MISSION LOG // ACTIVE CONTRACTS",
|
||||
"video": {
|
||||
"file": "quests.mp4",
|
||||
"_comment": "The archive feed (assets/videos/). quests.mp4 mirrors the 2:3 aspect of research.mp4 — fit the same way.",
|
||||
"aspect": [2, 3]
|
||||
},
|
||||
"sweep": {
|
||||
"_comment": "The band that crawls down the feed (the research/map console's sweep, tuned for this window).",
|
||||
"enabled": true,
|
||||
"intervalMs": [5200, 9800],
|
||||
"durationMs": [1400, 2200]
|
||||
},
|
||||
"glitch": {
|
||||
"enabled": true,
|
||||
"intervalMs": [4200, 9200],
|
||||
"durationMs": [110, 260],
|
||||
"slices": [1, 4],
|
||||
"maxOffset": 9
|
||||
},
|
||||
"tabs": [
|
||||
{
|
||||
"id": "main",
|
||||
"label": "Main Story",
|
||||
"accent": "#00e5ff",
|
||||
"_comment": "The campaign's spine. Live — the starter quest is granted on the run's first boot."
|
||||
},
|
||||
{
|
||||
"id": "side",
|
||||
"label": "Side Quests",
|
||||
"accent": "#ffc94d",
|
||||
"standby": true,
|
||||
"_comment": "The socket for side quests — deferred by design. Pressing it toasts the standby note until side quests are authored (quests.json → quests, category 'side')."
|
||||
}
|
||||
],
|
||||
"sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET",
|
||||
"starterToast": {
|
||||
"_comment": "The one-shot note when the starter quest lands on a fresh ledger (GameScene.create).",
|
||||
"glyph": "◆",
|
||||
"color": "#ffc94d",
|
||||
"text": "NEW QUEST — {title}"
|
||||
},
|
||||
"readyToast": {
|
||||
"glyph": "◆",
|
||||
"color": "#ffc94d",
|
||||
"text": "QUEST READY — {title} · CLAIM ITS REWARD"
|
||||
},
|
||||
"claimToast": {
|
||||
"glyph": "✓",
|
||||
"color": "#67e863",
|
||||
"text": "REWARD CLAIMED — +{n} MINERALS"
|
||||
},
|
||||
"claimDenyToast": {
|
||||
"glyph": "✗",
|
||||
"color": "#ff5c77",
|
||||
"text": "REQUIREMENTS INCOMPLETE — {missing}"
|
||||
},
|
||||
"quests": [
|
||||
{
|
||||
"id": "start_your_journey",
|
||||
"category": "main",
|
||||
"starter": true,
|
||||
"icon": "diamond",
|
||||
"title": "Start Your Journey",
|
||||
"description": "The universe is cold, and it ain't gonna do you any favors kid. The sooner you discover that, the sooner you'll be able to start taking care of yourself. And what better way to discover the universe, than to discover the universe.",
|
||||
"issuer": "A Friend",
|
||||
"_comment": "The first quest, granted on the run's first boot (js/quests/QuestState.js → starters). Its checklist is the run's opening arc — research, mine, build, discover. The checks are LIVE: 'research' resolves against data/research/exploration.json (the Tether Level 2 node), 'mineralsMined' against the run's lifetime asteroid mining (the Mining sequence's onOre), 'tetherBuilt' against the home world's standing tether level, 'discoveredWorld' against the discovery ledger (any discovered world besides the home).",
|
||||
"checks": [
|
||||
{ "type": "research", "category": "exploration", "node": "tether_l2", "label": "Research Tether Level 2" },
|
||||
{ "type": "mineralsMined", "amount": 200, "label": "Mine 200 Minerals from asteroids" },
|
||||
{ "type": "tetherBuilt", "planet": "home", "level": 2, "label": "Build a Level 2 Tether on your Home world" },
|
||||
{ "type": "discoveredWorld", "count": 1, "label": "Discover another planet or space station" }
|
||||
],
|
||||
"reward": {
|
||||
"minerals": 200,
|
||||
"_comment": "The completion reward — 200 Minerals, claimed via the CLAIM REWARD action (fits the ship's hold: ship.stats.mineralStorage)."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Quests data-layer test (dev tool, run with Node — no browser):
|
||||
*
|
||||
* node dev/quests.test.mjs
|
||||
*
|
||||
* Pins the contract behind the QUESTS console (the deck's QUESTS button
|
||||
* — right of SHIP, flight deck + surface deck → js/ui/QuestWindow.js):
|
||||
* - quests.json is registered in data/manifest.json;
|
||||
* - the window's knobs are present (title, the 2:3 video feed — the
|
||||
* same dimensions as the research feed, sweep/glitch, the two
|
||||
* category tabs with SIDE marked standby, the toasts);
|
||||
* - the starter quest is well-formed: Main Story, one starter flag,
|
||||
* a non-empty checklist of KNOWN requirement types, a reward that
|
||||
* fits the ship's hold;
|
||||
* - the 'research' check points at a REAL node of its category's tree
|
||||
* (data/research/<category>.json — the check would never complete
|
||||
* otherwise);
|
||||
* - the quest ledger (js/quests/QuestState.js) behaves: idempotent
|
||||
* give/claim, claimed ⊆ granted, a save round-trip, and a
|
||||
* corrupted save degrades to an empty ledger (not a throw);
|
||||
* - both command decks carry the QUESTS slot, right of SHIP.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const dataDir = join(root, 'data');
|
||||
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
|
||||
|
||||
const manifest = read('manifest.json');
|
||||
const quests = read('quests.json');
|
||||
const actionbar = read('actionbar.json');
|
||||
const ship = read('ship.json');
|
||||
|
||||
// The real code (config singleton → the quest helpers + the ledger).
|
||||
const { config } = await import(join(root, 'js/config/Config.js'));
|
||||
config.init({ quests, ship });
|
||||
const { QuestState, questDefs, questDef, starterQuestIds } = await import(
|
||||
join(root, 'js/quests/QuestState.js')
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
const hex = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. Manifest registration
|
||||
// ----------------------------------------------------------------------
|
||||
check('manifest registers quests.json', manifest.files.includes('quests.json'));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. quests.json — the window's knobs
|
||||
// ----------------------------------------------------------------------
|
||||
check('quests: master switch present', typeof quests.enabled === 'boolean');
|
||||
check('quests: enabled (the console is live)', quests.enabled === true);
|
||||
check('quests: title present', typeof quests.title === 'string' && quests.title.length > 0);
|
||||
check('quests: video file named', typeof quests.video?.file === 'string' && quests.video.file.length > 0);
|
||||
check('quests: video aspect is 2:3 (the research feed\u2019s dimensions)',
|
||||
JSON.stringify(quests.video?.aspect) === JSON.stringify([2, 3]));
|
||||
check('quests: sweep configured (interval + duration ranges)',
|
||||
Array.isArray(quests.sweep?.intervalMs) && quests.sweep.intervalMs.length === 2 &&
|
||||
Array.isArray(quests.sweep?.durationMs) && quests.sweep.durationMs.length === 2);
|
||||
check('quests: glitch configured', typeof quests.glitch?.enabled === 'boolean' &&
|
||||
Array.isArray(quests.glitch?.intervalMs) && Array.isArray(quests.glitch?.slices));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. The category tabs — Main Story live, Side Quests the standby socket
|
||||
// ----------------------------------------------------------------------
|
||||
const tabs = quests.tabs ?? [];
|
||||
check('quests: two category tabs', tabs.length === 2);
|
||||
const mainTab = tabs.find((t) => t.id === 'main');
|
||||
const sideTab = tabs.find((t) => t.id === 'side');
|
||||
check('quests: the MAIN tab is live', !!mainTab && mainTab.standby !== true && typeof mainTab.label === 'string' && hex.test(mainTab.accent ?? ''));
|
||||
check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby === true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? ''));
|
||||
check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 4. The toasts — starter / ready / claim / claim-denied
|
||||
// ----------------------------------------------------------------------
|
||||
for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast']) {
|
||||
const t = quests[key];
|
||||
check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? ''));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 5. The starter quest — well-formed, one starter, real checks, fair reward
|
||||
// ----------------------------------------------------------------------
|
||||
check('quests: at least one quest defined', Array.isArray(quests.quests) && quests.quests.length >= 1);
|
||||
const starters = (quests.quests ?? []).filter((q) => q.starter === true);
|
||||
check('quests: exactly ONE starter quest', starters.length === 1);
|
||||
const starter = starters[0];
|
||||
check('starter: sits in the MAIN story', starter?.category === 'main');
|
||||
check('starter: has id/title/description/issuer', typeof starter?.id === 'string' && typeof starter?.title === 'string' && typeof starter?.description === 'string' && typeof starter?.issuer === 'string');
|
||||
check('starter: icon names a research icon', typeof starter?.icon === 'string' && starter.icon.length > 0);
|
||||
check('starter: a non-empty checklist', Array.isArray(starter?.checks) && starter.checks.length >= 1);
|
||||
check('starter: every check has a type + a player-facing label',
|
||||
(starter?.checks ?? []).every((c) => typeof c.type === 'string' && typeof c.label === 'string' && c.label.length > 0));
|
||||
check('starter: reward is a positive integer (minerals)',
|
||||
Number.isInteger(starter?.reward?.minerals) && starter.reward.minerals > 0);
|
||||
check('starter: reward fits the ship\u2019s hold',
|
||||
starter.reward.minerals <= (ship.stats?.mineralStorage ?? 0));
|
||||
|
||||
// The requirement types the scene evaluates (GameScene._questCheckEval)
|
||||
// — a typo here would be a checklist row that never completes.
|
||||
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']);
|
||||
check('starter: every check type is a known live check',
|
||||
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||||
|
||||
// Each check shape — the parameters _questCheckEval reads.
|
||||
for (const c of starter?.checks ?? []) {
|
||||
if (c.type === 'research') {
|
||||
check(`starter check "${c.label}": names category + node`, typeof c.category === 'string' && typeof c.node === 'string');
|
||||
// The node must exist in its category\u2019s tree — otherwise the
|
||||
// requirement could never read DONE (the research.json registry
|
||||
// knows which categories are static file trees).
|
||||
const tree = read(`research/${c.category}.json`);
|
||||
check(`starter check "${c.label}": the node exists in data/research/${c.category}.json`,
|
||||
!!tree?.nodes && typeof tree.nodes[c.node] === 'object');
|
||||
} else if (c.type === 'mineralsMined') {
|
||||
check(`starter check "${c.label}": a positive integer amount`, Number.isInteger(c.amount) && c.amount > 0);
|
||||
} else if (c.type === 'tetherBuilt') {
|
||||
check(`starter check "${c.label}": planet + level set`,
|
||||
(c.planet === 'home' || typeof c.planet === 'string') && Number.isInteger(c.level) && c.level >= 1);
|
||||
} else if (c.type === 'discoveredWorld') {
|
||||
check(`starter check "${c.label}": a positive integer count`, Number.isInteger(c.count) && c.count >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6. The quest ledger — QuestState (give/claim/save-restore)
|
||||
// ----------------------------------------------------------------------
|
||||
check('helpers: questDefs() sees the defined quests', questDefs().length === (quests.quests ?? []).length);
|
||||
check('helpers: questDef() finds the starter by id', questDef(starter.id)?.title === starter.title);
|
||||
check('helpers: questDef() is null for an unknown id', questDef('no_such_quest') === null);
|
||||
check('helpers: starterQuestIds() is exactly [the starter]',
|
||||
JSON.stringify(starterQuestIds()) === JSON.stringify([starter.id]));
|
||||
|
||||
{
|
||||
const st = new QuestState();
|
||||
check('ledger: fresh — nothing granted, nothing claimed', !st.isGranted(starter.id) && !st.isClaimed(starter.id));
|
||||
check('ledger: give() grants a known quest', st.give(starter.id) === true);
|
||||
check('ledger: give() is idempotent', st.give(starter.id) === false);
|
||||
check('ledger: give() ignores an unknown id', st.give('no_such_quest') === false && !st.isGranted('no_such_quest'));
|
||||
check('ledger: claim() pays out once', st.claim(starter.id) === true && st.isClaimed(starter.id));
|
||||
check('ledger: claim() is idempotent', st.claim(starter.id) === false);
|
||||
const fresh = new QuestState();
|
||||
check('ledger: claim() refuses a quest not held', fresh.claim(starter.id) === false && !fresh.isClaimed(starter.id));
|
||||
}
|
||||
|
||||
// The save round-trip (SaveData captures toJSON(), prepareLoad reads fromJSON).
|
||||
{
|
||||
const st = new QuestState();
|
||||
st.give(starter.id);
|
||||
st.claim(starter.id);
|
||||
const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())));
|
||||
check('ledger: save round-trip keeps granted + claimed',
|
||||
restored.isGranted(starter.id) && restored.isClaimed(starter.id));
|
||||
}
|
||||
// A corrupted save degrades — never throws, never pays out a quest the
|
||||
// player does not hold.
|
||||
{
|
||||
check('ledger: fromJSON(malformed) → empty ledger', (() => {
|
||||
try {
|
||||
const st = QuestState.fromJSON({ granted: 'x', claimed: [starter.id] });
|
||||
return !st.isGranted(starter.id) && !st.isClaimed(starter.id);
|
||||
} catch { return false; }
|
||||
})());
|
||||
check('ledger: fromJSON drops ids the data no longer defines', (() => {
|
||||
const st = QuestState.fromJSON({ granted: ['no_such_quest'], claimed: ['no_such_quest'] });
|
||||
return st.granted.size === 0 && st.claimed.size === 0;
|
||||
})());
|
||||
check('ledger: fromJSON keeps claimed ⊆ granted', (() => {
|
||||
const st = QuestState.fromJSON({ granted: [starter.id], claimed: [] });
|
||||
return st.isGranted(starter.id) && !st.isClaimed(starter.id);
|
||||
})());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 7. The command decks — the QUESTS slot, right of SHIP (both decks)
|
||||
// ----------------------------------------------------------------------
|
||||
for (const [deck, slots] of [
|
||||
['flight', actionbar.buttons ?? []],
|
||||
['surface', actionbar.surface?.buttons ?? []],
|
||||
]) {
|
||||
const iShip = slots.findIndex((s) => s.id === 'ship');
|
||||
check(`actionbar (${deck}): the QUESTS slot sits right of the SHIP slot`,
|
||||
iShip >= 0 && slots[iShip + 1]?.id === 'quests');
|
||||
check(`actionbar (${deck}): the QUESTS slot carries a label + accent`, (() => {
|
||||
const q = slots.find((s) => s.id === 'quests');
|
||||
return q && typeof q.label === 'string' && q.label.length > 0 && hex.test(q.accent ?? '');
|
||||
})());
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -285,14 +285,18 @@ check('builds._template.requires is an array', Array.isArray(bt.requires));
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6. Command deck — six evenly spaced slots, right order
|
||||
// (Quests sits on the flight deck to the right of Ship — the
|
||||
// mission dossier; the surface deck carries the same slot.)
|
||||
// ----------------------------------------------------------------------
|
||||
const slots = actionbar.buttons ?? [];
|
||||
check('actionbar: exactly six slots', slots.length === 6);
|
||||
check('actionbar: slot ids in order (Research, Scan, Ship, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu']));
|
||||
check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu']));
|
||||
check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })());
|
||||
check('actionbar: slot ids in order (Research, Scan, Ship, Quests, Map, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'quests', 'map', 'menu']));
|
||||
check('actionbar: labels (Research / Scan / Ship / Quests / Map / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Quests', 'Map', 'Menu']));
|
||||
check('actionbar: the QUESTS slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'quests'; })());
|
||||
check('actionbar: the QUESTS slot carries a label + accent', (() => { const q = slots.find((s) => s.id === 'quests'); return q && typeof q.label === 'string' && hex.test(q.accent ?? ''); })());
|
||||
check('actionbar: the MAP slot sits right of the QUESTS slot', (() => { const iQ = slots.findIndex((s) => s.id === 'quests'); return iQ >= 0 && slots[iQ + 1]?.id === 'map'; })());
|
||||
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
|
||||
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
|
||||
check('actionbar: no reserved (empty) slots — every slot is a button', slots.every((s) => s.id !== null && typeof s.label === 'string'));
|
||||
check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');
|
||||
check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
|
||||
check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
||||
|
|
@ -300,7 +304,8 @@ check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height =
|
|||
// the surface deck (SurfaceScene) — Shop for Research, Build, Take Off
|
||||
const sSlots = actionbar.surface?.buttons ?? [];
|
||||
check('actionbar: surface deck has six slots', sSlots.length === 6);
|
||||
check('actionbar: surface slot ids in order (Shop, Build, Ship, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', null, 'takeoff', 'menu']));
|
||||
check('actionbar: surface slot ids in order (Shop, Build, Ship, Quests, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', 'quests', 'takeoff', 'menu']));
|
||||
check('actionbar: the QUESTS slot is on the surface deck (right of SHIP)', (() => { const iShip = sSlots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && sSlots[iShip + 1]?.id === 'quests'; })());
|
||||
check('actionbar: the BUILD slot is on the surface deck', sSlots.some((s) => s.id === 'build'));
|
||||
check('actionbar: the BUILD slot carries a label + accent', (() => { const b = sSlots.find((s) => s.id === 'build'); return b && typeof b.label === 'string' && hex.test(b.accent ?? ''); })());
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* The quest ledger — the run's quest STATE (pure; the data is
|
||||
* data/quests.json).
|
||||
*
|
||||
* A quest's REQUIREMENTS are not tracked here — they are computed LIVE
|
||||
* from the run when the scene snapshots the dossier (GameScene.
|
||||
* questSnapshot): research progress, lifetime minerals mined from
|
||||
* asteroids, the home world's tether standing, the discovery ledger.
|
||||
* Only the two things the run owns are state:
|
||||
*
|
||||
* granted — which quest ids the player HOLDS (the starter quest is
|
||||
* granted on the run's first boot; future quests are granted
|
||||
* by whatever event issues them)
|
||||
* claimed — which of the held quests have PAID OUT their reward
|
||||
*
|
||||
* …and that is exactly what gets saved (SaveData capture/prepare), so a
|
||||
* save can never hand out a reward twice or un-grant a quest.
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
|
||||
/** @returns {Array<object>} every quest defined (data/quests.json → quests) */
|
||||
export function questDefs() {
|
||||
const list = config.get('quests.quests', []);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** @returns {object|null} the quest definition with this id (or null) */
|
||||
export function questDef(id) {
|
||||
return questDefs().find((q) => q && q.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** @returns {string[]} the ids granted on a FRESH run (quests.json → starter) */
|
||||
export function starterQuestIds() {
|
||||
return questDefs()
|
||||
.filter((q) => q && q.starter === true)
|
||||
.map((q) => q.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A held-quest ledger. The ids come from data/quests.json (validated
|
||||
* against it on load — a save pointing at a removed quest just drops it).
|
||||
*/
|
||||
export class QuestState {
|
||||
constructor() {
|
||||
this.granted = new Set();
|
||||
this.claimed = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a quest (idempotent — re-granting one already held is a no-op).
|
||||
* Unknown ids are ignored. @returns {boolean} true when NEWLY granted
|
||||
*/
|
||||
give(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!questDef(id)) return false;
|
||||
if (this.granted.has(id)) return false;
|
||||
this.granted.add(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
isGranted(id) {
|
||||
return this.granted.has(id);
|
||||
}
|
||||
|
||||
isClaimed(id) {
|
||||
return this.claimed.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a quest's reward as paid out (idempotent). Only quests that are
|
||||
* held + fully satisfied should be claimed — the satisfaction is the
|
||||
* scene's call (GameScene.claimQuest checks the live snapshot).
|
||||
* @returns {boolean} true when NEWLY claimed
|
||||
*/
|
||||
claim(id) {
|
||||
if (!this.granted.has(id)) return false;
|
||||
if (this.claimed.has(id)) return false;
|
||||
this.claimed.add(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { granted: [...this.granted], claimed: [...this.claimed] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a ledger from a save record (SaveData prepareLoad). A
|
||||
* malformed record yields an empty ledger rather than a throw — quests
|
||||
* are a progression nicety, not a crash vector.
|
||||
*/
|
||||
static fromJSON(data) {
|
||||
const st = new QuestState();
|
||||
if (!data || typeof data !== 'object') return st;
|
||||
const grant = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : []);
|
||||
for (const id of grant(data.granted)) {
|
||||
if (questDef(id)) st.granted.add(id);
|
||||
}
|
||||
for (const id of grant(data.claimed)) {
|
||||
if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted
|
||||
}
|
||||
return st;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@
|
|||
* // the bright lanes)
|
||||
* research: ResearchState.toJSON() | null,
|
||||
* builds: BuildState.toJSON() | null,
|
||||
* quests: QuestState.toJSON() | null, // held + claimed quest ids
|
||||
* totalMined: number, // lifetime asteroid mining
|
||||
* playTimeMs }
|
||||
*
|
||||
* captureState(scene) — GameScene → record (the Save panel calls it)
|
||||
|
|
@ -46,6 +48,7 @@ import { config } from '../config/Config.js';
|
|||
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 { SAVE_FORMAT, SaveManager } from './SaveManager.js';
|
||||
|
||||
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
|
||||
|
|
@ -110,6 +113,16 @@ export function captureState(scene, now) {
|
|||
builds: scene.buildState
|
||||
? scene.buildState.toJSON(buildNow)
|
||||
: null,
|
||||
// Quests — the run's mission ledger: which quests are HELD (granted)
|
||||
// and which rewards have been CLAIMED. Progress itself is NOT saved —
|
||||
// it is computed live from the run's state (GameScene.questSnapshot).
|
||||
// A save predating quests has no field; the restore stages null and
|
||||
// the starter quest is granted onto the fresh ledger (old saves load).
|
||||
quests: scene.questState ? scene.questState.toJSON() : null,
|
||||
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
|
||||
// requirement's input — the hold is cargo, this is the record).
|
||||
// A save predating it has no field; the restore stages 0.
|
||||
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)),
|
||||
// The SYSTEM research category's activation keys (which jump gates —
|
||||
// incl. other systems' return gates — the run has activated; the
|
||||
// per-run world state GameScene keeps in its registry-backed set).
|
||||
|
|
@ -196,6 +209,14 @@ export function prepareLoad(registry, record) {
|
|||
? { systemId: record.destination.systemId, objectId: record.destination.objectId ?? null }
|
||||
: null,
|
||||
);
|
||||
// The quest ledger (a save predating quests has no field → null — the
|
||||
// fresh run's GameScene starts an empty ledger and grants the starter
|
||||
// quest; old saves load). The validation inside fromJSON keeps a
|
||||
// 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);
|
||||
// Lifetime asteroid mining (a save predating it has no field → 0).
|
||||
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0)));
|
||||
registry.set(PENDING_RESTORE_KEY, {
|
||||
ship: record.ship,
|
||||
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
||||
|
|
@ -229,6 +250,8 @@ export function resetRunState(registry) {
|
|||
registry.set('usedGates', null);
|
||||
registry.set('destination', null);
|
||||
registry.set('routeNotice', null);
|
||||
registry.set('quests', null);
|
||||
registry.set('totalMined', null);
|
||||
registry.set(PENDING_RESTORE_KEY, null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
|
|||
import { CommsPanel } from '../ui/CommsPanel.js';
|
||||
import { ResearchWindow } from '../ui/ResearchWindow.js';
|
||||
import { MapWindow } from '../ui/MapWindow.js';
|
||||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { QuestState, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
|
||||
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
|
||||
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
|
||||
import { planRoute } from '../galaxy/Route.js';
|
||||
|
|
@ -201,6 +203,20 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// The quests console's mission feed (data/quests.json → video): a muted
|
||||
// 2:3 loop behind the QUESTS window — the same dimensions as the
|
||||
// research feed. Same contract — a missing file just leaves the
|
||||
// window's NO SIGNAL plate up.
|
||||
if (config.get('quests.enabled', true)) {
|
||||
const questVideo = String(config.get('quests.video.file') ?? '');
|
||||
if (questVideo) {
|
||||
const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/')
|
||||
? questVideo
|
||||
: `assets/videos/${questVideo}`;
|
||||
this.load.video(QuestWindow.VIDEO_KEY, url);
|
||||
}
|
||||
}
|
||||
|
||||
// The build console's feed (data/builds.json → video): a muted 2:3
|
||||
// loop behind the BUILD window on a planet surface (SurfaceScene's
|
||||
// BuildWindow reads the shared cache). A missing file just leaves the
|
||||
|
|
@ -604,9 +620,10 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
// The command deck — the cyberpunk action bar across the bottom of the
|
||||
// screen (config: data/actionbar.json). Six evenly spaced slots:
|
||||
// Research, Scan, Ship, two reserved, Menu. The slots are the seams
|
||||
// Research, Scan, Ship, Quests, Map, Menu. The slots are the seams
|
||||
// for the player's loop — research (time-based, one at a time, see
|
||||
// data/research.json) and scan (its behavior and panel come next).
|
||||
// data/research.json), scan, the ship, the quest dossier (see
|
||||
// data/quests.json) and the star map.
|
||||
this.actionBar = deckEnabled
|
||||
? new ActionBar(this, {
|
||||
onAction: (id) => this.deckAction(id),
|
||||
|
|
@ -656,7 +673,14 @@ export class GameScene extends Phaser.Scene {
|
|||
this.mining = new Mining(this, {
|
||||
onPhase: (p) => this.onMiningPhase(p),
|
||||
onEvent: (name, data) => this.onMiningEvent(name, data),
|
||||
onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks
|
||||
onOre: (gained) => {
|
||||
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
|
||||
// Lifetime asteroid mining — the quest ledger's input (the
|
||||
// 'Mine 200 Minerals from asteroids' requirement counts this,
|
||||
// not the hold — the hold is cargo, this is the record).
|
||||
this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0);
|
||||
this._questRefresh();
|
||||
},
|
||||
});
|
||||
this.miningPopup = new MiningPopup(this, {
|
||||
onAction: (id, rock) => this.miningAction(id, rock),
|
||||
|
|
@ -742,6 +766,44 @@ export class GameScene extends Phaser.Scene {
|
|||
},
|
||||
),
|
||||
});
|
||||
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
|
||||
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
|
||||
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
|
||||
// dimensions as the research feed), right the MAIN STORY / SIDE QUESTS
|
||||
// tabs (side is a STANDBY socket — side quests are deferred by design),
|
||||
// a THIN list of the active quests, and the big detail plate below:
|
||||
// name, issuer, description, the requirement checklist, and the reward
|
||||
// with the CLAIM action. The surface deck's QUESTS slot opens the same
|
||||
// window over the paused GameScene's state (SurfaceScene.buildDeck).
|
||||
// A quest's progress is computed LIVE (questSnapshot below — research,
|
||||
// lifetime asteroid mining, the home tether's standing, the discovery
|
||||
// ledger); the ledger itself — which quests are held and which rewards
|
||||
// have been claimed — is the QuestState in the shared registry (like
|
||||
// discovery/research) and rides the saves (record.quests, plus the
|
||||
// lifetime counter record.totalMined the mining requirement reads).
|
||||
this.questState = this.registry.get('quests') ?? null;
|
||||
if (!this.questState) {
|
||||
this.questState = new QuestState();
|
||||
this.registry.set('quests', this.questState);
|
||||
}
|
||||
this._questAnnounced = new Set(); // the 'QUEST READY' toasts, once each
|
||||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.questSnapshot(),
|
||||
onClaim: (id) => this.claimQuest(id),
|
||||
onLocked: (tabId) =>
|
||||
this.consoleToast(
|
||||
tabId === 'side'
|
||||
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
|
||||
: 'NO SIGNAL',
|
||||
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
|
||||
),
|
||||
});
|
||||
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
|
||||
// requirement's input — the hold is cargo, this is the ledger). Registry
|
||||
// -backed like discovery: a load stages the saved value (prepareLoad),
|
||||
// a fresh run starts at zero, and the New Game reset clears it.
|
||||
this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0));
|
||||
|
||||
// deck progress bar — drawn over the RESEARCH button while a project runs
|
||||
this._researchDeckBar = {
|
||||
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
|
||||
|
|
@ -782,6 +844,34 @@ export class GameScene extends Phaser.Scene {
|
|||
this.applyRestore(this._pendingRestore);
|
||||
this._pendingRestore = null;
|
||||
}
|
||||
// The starter quest — granted ONCE onto a fresh ledger (a loaded save's
|
||||
// ledger already holds whatever was granted back then — and give() is
|
||||
// idempotent, so a jump-cut restart that rebuilds this scene off the
|
||||
// registry's ledger stays silent). It is the run's opening arc: the
|
||||
// checklist (data/quests.json) is the first loop — research, mine,
|
||||
// build, discover.
|
||||
if (config.get('quests.enabled', true) !== false) {
|
||||
for (const id of starterQuestIds()) {
|
||||
if (this.questState.give(id)) {
|
||||
const def = questDef(id);
|
||||
this.playSfx('discovery'); // the 'something new is here' voice
|
||||
// The toast lands AFTER the opening 'DISCOVERED — HOME WORLD'
|
||||
// toast (one toast slot — the later call replaces the earlier):
|
||||
// ~2.4s is that toast's life, so +0.3s keeps the order readable.
|
||||
this.time.delayedCall(2700, () => {
|
||||
this.consoleToast(
|
||||
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
|
||||
.replace('{title}', String(def?.title ?? id).toUpperCase()),
|
||||
{
|
||||
glyph: config.get('quests.starterToast.glyph', '◆'),
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
durationMs: 3200,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// The field's native set (home + the activated gates) is above; the
|
||||
// tethers the player INSTALLED on this system's other worlds are
|
||||
// world state (built records) — re-anchor them on their worlds, since
|
||||
|
|
@ -878,6 +968,10 @@ export class GameScene extends Phaser.Scene {
|
|||
if (this.researchWindow && this.researchWindow.isOpen) return;
|
||||
// The map console (depth 80) — the same full-screen contract.
|
||||
if (this.mapWindow && this.mapWindow.isOpen) return;
|
||||
// The quests console (depth 80) — the same full-screen contract.
|
||||
if (this.questWindow && this.questWindow.isOpen) return;
|
||||
// The quests console (depth 80) — the same full-screen contract.
|
||||
if (this.questWindow && this.questWindow.isOpen) return;
|
||||
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
|
||||
// its buttons — the buttons fire on their own pointerdown). ANY
|
||||
// click OUTSIDE — world, deck, HUD, compass — folds it back down
|
||||
|
|
@ -1374,6 +1468,7 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
this.researchWindow?.update(_time); // the console's living details (open state)
|
||||
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
|
||||
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
|
||||
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
|
||||
// Builds: a build started on a surface keeps running in space if the
|
||||
// player took off mid-build (game-loop clock — see beginBuild); when
|
||||
|
|
@ -2387,6 +2482,7 @@ export class GameScene extends Phaser.Scene {
|
|||
glyphColor: toCss(neon),
|
||||
durationMs: 2400,
|
||||
});
|
||||
this._questRefresh(); // a 'Discover another world' requirement may have just landed
|
||||
}
|
||||
|
||||
showTargetMarker(x, y) {
|
||||
|
|
@ -2435,6 +2531,18 @@ export class GameScene extends Phaser.Scene {
|
|||
this.researchWindow?.open();
|
||||
return;
|
||||
}
|
||||
// The quests console (full-screen, depth 80) — the deck's third console.
|
||||
if (id === 'quests') {
|
||||
if (this.questWindow && this.questWindow.isOpen) {
|
||||
this.questWindow.close();
|
||||
return;
|
||||
}
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
||||
this.questWindow?.open();
|
||||
return;
|
||||
}
|
||||
// The map console (full-screen, depth 80) — the deck's other console.
|
||||
if (id === 'map') {
|
||||
if (this.mapWindow && this.mapWindow.isOpen) {
|
||||
|
|
@ -2458,6 +2566,225 @@ export class GameScene extends Phaser.Scene {
|
|||
console.info(`[orbit] command deck: ${id}`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ quests
|
||||
|
||||
/**
|
||||
* The live dossier snapshot (QuestWindow polls it while open):
|
||||
* the categories (tabs) + every HELD quest with its checklist
|
||||
* evaluated against the run RIGHT NOW — research unlocks, lifetime
|
||||
* asteroid mining, the home tether's standing, the discovery ledger.
|
||||
* The window is a pure view over this; the scene owns the effects
|
||||
* (claimQuest) — the same split as the research/map consoles.
|
||||
* @returns {{categories: Array<object>, quests: Array<object>}|null}
|
||||
*/
|
||||
questSnapshot() {
|
||||
if (!this.questState) return null;
|
||||
const tabs = config.get('quests.tabs', []);
|
||||
const categories = (Array.isArray(tabs) ? tabs : []).map((c) => ({
|
||||
id: String(c?.id ?? ''),
|
||||
label: String(c?.label ?? c?.id ?? ''),
|
||||
standby: c?.standby === true,
|
||||
accent: c?.accent ?? null,
|
||||
}));
|
||||
const quests = [];
|
||||
for (const def of questDefs()) {
|
||||
if (!def || !this.questState.isGranted(def.id)) continue;
|
||||
const checks = (Array.isArray(def.checks) ? def.checks : []).map((c) => this._questCheckEval(c));
|
||||
const doneCount = checks.filter((c) => c.done).length;
|
||||
const complete = checks.length > 0 && doneCount === checks.length;
|
||||
quests.push({
|
||||
id: def.id,
|
||||
category: def.category ?? 'main',
|
||||
title: def.title ?? def.id,
|
||||
description: def.description ?? '',
|
||||
issuer: def.issuer ?? '',
|
||||
icon: def.icon ?? 'diamond',
|
||||
checks,
|
||||
reward: this._questRewardLabel(def),
|
||||
complete,
|
||||
claimed: this.questState.isClaimed(def.id),
|
||||
progress: `${doneCount}/${checks.length}`,
|
||||
});
|
||||
}
|
||||
return { categories, quests };
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist requirement, evaluated LIVE (data/quests.json →
|
||||
* quests[].checks[].type). Pure reads — no effects here.
|
||||
* research — the node is unlocked (data/research/<cat>.json)
|
||||
* mineralsMined — lifetime asteroid mining >= amount (this.totalMined)
|
||||
* tetherBuilt — the world's tether standing >= level ('home' = the home world)
|
||||
* discoveredWorld — discovered planets/stations (not the home world) >= count
|
||||
*/
|
||||
_questCheckEval(check) {
|
||||
const label = String(check?.label ?? '');
|
||||
const type = String(check?.type ?? '');
|
||||
if (type === 'research') {
|
||||
const cat = String(check.category ?? '');
|
||||
const node = String(check.node ?? '');
|
||||
const done = !!this.researchState?.isUnlocked?.(cat, node);
|
||||
return { label, done, detail: done ? 'RESEARCHED' : 'PENDING' };
|
||||
}
|
||||
if (type === 'mineralsMined') {
|
||||
const amount = Math.max(0, Math.floor(Number(check.amount) || 0));
|
||||
const got = Math.min(amount, Math.floor(this.totalMined ?? 0));
|
||||
const done = amount > 0 && got >= amount;
|
||||
return { label, done, detail: `${got}/${amount}` };
|
||||
}
|
||||
if (type === 'tetherBuilt') {
|
||||
const level = Math.max(1, Math.floor(Number(check.level) || 1));
|
||||
const name = check.planet === 'home' ? this.homeWorldName : String(check.planet ?? '');
|
||||
const have = Math.max(0, this.tetherLevelFor(name) ?? 0);
|
||||
const done = have >= level;
|
||||
return { label, done, detail: `L${have}/L${level}` };
|
||||
}
|
||||
if (type === 'discoveredWorld') {
|
||||
const count = Math.max(1, Math.floor(Number(check.count) || 1));
|
||||
const have = Math.min(count, this.discoveredWorldCount());
|
||||
const done = have >= count;
|
||||
return { label, done, detail: `${have}/${count}` };
|
||||
}
|
||||
return { label, done: false, detail: '??' };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many DISCOVERED worlds are planets or space stations (the
|
||||
* 'Discover another planet or space station' requirement). The home
|
||||
* world never counts (it is the origin, not a discovery), and neither
|
||||
* do asteroid clusters / jump gates (the requirement is a WORLD or a
|
||||
* STATION). The ledger is per-system (Discovery), so the count walks
|
||||
* every system the player has found something in: the current system's
|
||||
* objects are the scene's own entities; other systems resolve their
|
||||
* ids against the generated content (galaxy.contentOf — deterministic,
|
||||
* cached once generated).
|
||||
*/
|
||||
discoveredWorldCount() {
|
||||
const disc = this.discovery;
|
||||
if (!disc || !disc.bySystem) return 0;
|
||||
const cur = this.systemRecord?.id ?? null;
|
||||
let n = 0;
|
||||
for (const [sysId, ids] of disc.bySystem) {
|
||||
let planetIds = null;
|
||||
let stationIds = null;
|
||||
if (sysId === cur) {
|
||||
planetIds = new Set((this.systemPlanets ?? []).map((p) => p.discoveryId));
|
||||
stationIds = new Set((this.systemStations ?? []).map((st) => st.discoveryId));
|
||||
} else if (this.galaxy && typeof this.galaxy.contentOf === 'function') {
|
||||
let content = null;
|
||||
try {
|
||||
content = this.galaxy.contentOf(sysId);
|
||||
} catch {
|
||||
content = null;
|
||||
}
|
||||
planetIds = new Set(((content?.planets ?? []).map((r) => r?.name)).filter(Boolean));
|
||||
stationIds = new Set(
|
||||
((content?.settlements ?? []).filter((s) => s?.anchor?.type === 'space').map((s) => s?.id)).filter(Boolean),
|
||||
);
|
||||
}
|
||||
if (!planetIds && !stationIds) continue;
|
||||
for (const id of ids) {
|
||||
if (!id || id === 'home') continue;
|
||||
if (planetIds.has(id) || stationIds.has(id)) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The reward line for a quest def (data/quests.json → reward object). */
|
||||
_questRewardLabel(def) {
|
||||
const rw = def?.reward;
|
||||
if (rw && typeof rw === 'object') {
|
||||
const parts = [];
|
||||
if (typeof rw.minerals === 'number') parts.push(`${rw.minerals} MINERALS`);
|
||||
if (typeof rw.credits === 'number') parts.push(`${rw.credits} CREDITS`);
|
||||
return parts.length ? parts.join(' · ') : '';
|
||||
}
|
||||
return String(rw ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLAIM REWARD action (QuestWindow's button → here). The window
|
||||
* only offers the button when the checklist reads complete; this is
|
||||
* the belt-and-braces pass — re-evaluates the live checklist and pays
|
||||
* the reward exactly once (the ledger's claim() is idempotent, so a
|
||||
* double-press or a re-open can never pay twice). The scene owns the
|
||||
* effect (the minerals land in the hold); the ledger owns the record.
|
||||
*/
|
||||
claimQuest(id) {
|
||||
if (!this.questState || !this.questState.isGranted(id)) return;
|
||||
if (this.questState.isClaimed(id)) {
|
||||
this.questWindow?.refresh();
|
||||
return;
|
||||
}
|
||||
const def = questDef(id);
|
||||
if (!def) return;
|
||||
const snap = this.questSnapshot();
|
||||
const q = snap?.quests?.find((x) => x.id === id);
|
||||
if (!q || !q.complete) {
|
||||
// Requirements still open — a refusal, in the console's voice.
|
||||
const missing = (q?.checks ?? []).filter((c) => !c.done).map((c) => c.label.toUpperCase());
|
||||
const t = config.get('quests.claimDenyToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'REQUIREMENTS INCOMPLETE — {missing}').replace('{missing}', (missing.slice(0, 2).join(' · ') || 'REQUIREMENTS')),
|
||||
{
|
||||
glyph: t.glyph ?? '✗',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
|
||||
},
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
return;
|
||||
}
|
||||
if (!this.questState.claim(id)) return; // someone beat us to it — idempotent
|
||||
// The reward: minerals into the hold (clamped to the ship's storage —
|
||||
// addMinerals hands back what fit, so a full hold just wastes the
|
||||
// excess, the game's rule for mined ore).
|
||||
const rw = def.reward ?? {};
|
||||
if (typeof rw.minerals === 'number' && this.ship) {
|
||||
this.ship.addMinerals(rw.minerals);
|
||||
this.refreshMineralHud();
|
||||
}
|
||||
this.playSfx('construct'); // the power-up tick — the reward lands
|
||||
const t = config.get('quests.claimToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'REWARD CLAIMED — +{n} MINERALS').replace('{n}', String(rw.minerals ?? 0)),
|
||||
{
|
||||
glyph: t.glyph ?? '✓',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
|
||||
},
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* A quest-relevant event happened (research completed, ore mined, a
|
||||
* build finished, a world discovered, a reward claimed). Announce each
|
||||
* quest that just became claimable ONCE (the console's voice), and kick
|
||||
* the open console's poll so it repaints without waiting the interval.
|
||||
*/
|
||||
_questRefresh() {
|
||||
if (!this.questState) return;
|
||||
const snap = this.questSnapshot();
|
||||
if (snap) {
|
||||
for (const q of snap.quests) {
|
||||
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
|
||||
this._questAnnounced.add(q.id);
|
||||
const t = config.get('quests.readyToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
|
||||
.replace('{title}', String(q.title).toUpperCase()),
|
||||
{
|
||||
glyph: t.glyph ?? '◆',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('amber', 0xffc94d)),
|
||||
durationMs: 3200,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled
|
||||
* the same 500 ms the system chart is — js/ui/MapWindow.js). Pure data
|
||||
|
|
@ -3013,6 +3340,7 @@ export class GameScene extends Phaser.Scene {
|
|||
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
||||
});
|
||||
this.researchWindow?.refresh();
|
||||
this._questRefresh(); // a 'Research …' requirement may have just landed
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3121,6 +3449,7 @@ export class GameScene extends Phaser.Scene {
|
|||
completeBuild(planetName, buildId) {
|
||||
const def = buildDefs()[buildId];
|
||||
if (def?.effects) this._applyBuildEffects(planetName, def.effects);
|
||||
this._questRefresh(); // a 'Build a Level 2 Tether' requirement may have just landed
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3463,6 +3792,11 @@ export class GameScene extends Phaser.Scene {
|
|||
this.mapWindow.close();
|
||||
return;
|
||||
}
|
||||
// The quests console (depth 80) — the same contract.
|
||||
if (this.questWindow && this.questWindow.isOpen) {
|
||||
this.questWindow.close();
|
||||
return;
|
||||
}
|
||||
// The mining context menu is the topmost open thing after the save UI.
|
||||
if (this.miningPopup && this.miningPopup.isOpen) {
|
||||
this.miningPopup.close();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js';
|
|||
import { SavePanel } from '../ui/SavePanel.js';
|
||||
import { SaveManager } from '../save/SaveManager.js';
|
||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { buildDefs } from '../research/ResearchModel.js';
|
||||
|
||||
/**
|
||||
|
|
@ -543,6 +544,24 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
})
|
||||
: null;
|
||||
|
||||
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
||||
// SAME window as the flight deck's (js/ui/QuestWindow.js), opened
|
||||
// over the SLEEPING GameScene's dossier — the same split as the build
|
||||
// console (state + effects on the GameScene, the view here). The
|
||||
// snapshot/claim delegates to the game scene; the console note is
|
||||
// ours (we are the scene the player sees).
|
||||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null,
|
||||
onClaim: (id) => this.claimSurfaceQuest(id),
|
||||
onLocked: (tabId) =>
|
||||
this.consoleNote(
|
||||
tabId === 'side'
|
||||
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
|
||||
: 'NO SIGNAL',
|
||||
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
|
||||
),
|
||||
});
|
||||
|
||||
// ESC — topmost open thing first (mirror of GameScene.escAction).
|
||||
// (The pointerdown binding lives in create() — it must also be
|
||||
// live on the landing stage, before the deck exists.)
|
||||
|
|
@ -573,6 +592,14 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.buildWindow?.open();
|
||||
return;
|
||||
}
|
||||
if (id === 'quests') {
|
||||
if (this.questWindow?.isOpen) {
|
||||
this.questWindow.close();
|
||||
} else {
|
||||
this.questWindow?.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === 'shop') {
|
||||
this.toggleShop();
|
||||
return;
|
||||
|
|
@ -652,12 +679,46 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
playSfxOn(this, name, o);
|
||||
}
|
||||
|
||||
/** ESC: build console → confirm dialog → save pop-up → sub-bar. */
|
||||
/**
|
||||
* The CLAIM REWARD action from the surface console — the effect lands
|
||||
* on the SLEEPING GameScene (the quest ledger + the ship's hold are
|
||||
* its; the beginBuild precedent), and the console note is ours (we are
|
||||
* the scene the player sees — a sleeping scene's toasts don't animate).
|
||||
*/
|
||||
claimSurfaceQuest(id) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.claimQuest) return;
|
||||
const before = g.questState?.isClaimed(id) ?? false;
|
||||
g.claimQuest(id);
|
||||
const now = g.questState?.isClaimed(id) ?? false;
|
||||
if (!before && now) {
|
||||
const t = config.get('quests.claimToast', {});
|
||||
this.consoleNote(String(t.text ?? 'REWARD CLAIMED'), {
|
||||
glyph: t.glyph ?? '✓',
|
||||
glyphColor: t.color ? toCss(t.color) : undefined,
|
||||
});
|
||||
} else if (before !== now) {
|
||||
// Still open — the refusal, in the console's voice (the game
|
||||
// scene's own toast is behind the sleeping scene — this is the one
|
||||
// the player reads).
|
||||
const t = config.get('quests.claimDenyToast', {});
|
||||
this.consoleNote(
|
||||
String(t.text ?? 'REQUIREMENTS INCOMPLETE').replace('{missing}', 'REQUIREMENTS'),
|
||||
{ glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */
|
||||
escAction() {
|
||||
if (this.buildWindow?.isOpen) {
|
||||
this.buildWindow.close();
|
||||
return;
|
||||
}
|
||||
if (this.questWindow?.isOpen) {
|
||||
this.questWindow.close();
|
||||
return;
|
||||
}
|
||||
if (this.savePanel) {
|
||||
if (this.savePanel.confirm.isOpen) {
|
||||
this.savePanel.confirm.cancel();
|
||||
|
|
@ -769,6 +830,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.lastClickT = now;
|
||||
|
||||
if (this.buildWindow?.isOpen) return; // the console owns the click
|
||||
if (this.questWindow?.isOpen) return; // the quests console owns the click
|
||||
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
|
||||
if (this.menuSubBar?.isOpen) {
|
||||
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
|
||||
|
|
@ -905,6 +967,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
this.buildWindow?.update(time);
|
||||
this.questWindow?.update(time);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue