Add XP rewards for sidequest patrols and cargo sale profit

- Sidequest patrol contracts now pay an XP reward alongside credits, fixed at accept as the experience worth of defeated contacts (contacts × wave total × roster xp) and granted via XpManager with a 'sidequest' source tag on claim
- Cargo sales earn 1 XP per credit above each bay's recorded purchase cost; bays with no cost basis or sold at/below cost earn nothing, granted via the same pipe with a 'trade' source tag
- New CargoHold.costsFor() returns FIFO per-bay costs for the profit basis without mutating the hold
- Job board payout/claim lines and console toasts display both credits and XP when present; old saves without an xp field derive it from the encounters spec or fall back to 0
This commit is contained in:
Brian Fertig 2026-09-19 20:44:48 -06:00
parent 4595950fb9
commit 658babe216
13 changed files with 448 additions and 45 deletions

View File

@ -33,7 +33,7 @@
} }
}, },
"rewards": { "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.", "_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). The XP payout rides beside the credits: it is NOT a band — it is the contract's combat's WORTH, fixed on the route record at the accept (route.xp — js/quests/SideQuests.js → patrolEncounterXp: the patrol's defeated contacts × the wave's total × the band's roster xp, data/combat.json → enemy.xp / enemyTypes), and the claim (GameScene.claimSideQuest) grants it into the run's XP bank (js/combat/XpManager.js — the 'sidequest' source, beside the kills' 'kill' grants) in addition to paying the credits. A category without a route pays credits-only (its XP payout is 0 — the old saves' shape). Tune per category as the types' rules land; a category with no band here falls back to [40,80] in code.",
"distanceScale": 0.75, "distanceScale": 0.75,
"patrol": [ "patrol": [
40, 40,
@ -71,7 +71,7 @@
"neighborMinD": 0.4, "neighborMinD": 0.4,
"neighborChance": 0.5, "neighborChance": 0.5,
"encounters": { "encounters": {
"_comment": "The patrol's ENEMY ENCOUNTERS — the contract's hostile CONTACTS standing on route waypoints (the data contract: data/sidequests.json → patrol.encounters; the logic: js/quests/SideQuests.js → derivePatrolEncounters + patrolEncounterScript; the firing: the scene's updatePatrol → _firePatrolContact — the wave starts when the ship checks in at a CONTACT's waypoint, and the cleared mark lands on the wave run's COMPLETION — the witness is the completion, not the attempt: a mid-fight quit re-arms the contact on return, the combatDone precedent — and the route's completion, with the reward's door, waits for every contact). COUNTS — the odds of how many CONTACTS a patrol carries (1 contact on 20% of patrols, 2 on 20%, 3 on 60%): [count, odds] pairs, drawn SEED-STABLE at the accept from the labelled fork (seed, 'sidequests', <targetSystemId>, 'patrol', <jobId>, 'encounters') and fixed on the route record — a signed contract is a signed contract: a later re-tune of the odds can't re-roll a standing contract's combat. WAVE — each contact's fight: ONE rolling wave of the band's roster (equal weights — a single-type band is a pure type + total), the data's shape — six hostiles, three at a time, each kill replaced — the combat.waves rolling mechanic (js/combat/Waves.js); the script is built by patrolEncounterScript (no named entry in data/combat.json — the roster comes from the band below). BANDS — the difficulty's first axis (the player's COMBAT level × the target system's HOPS from home — the jump network's tree depth, galaxy.jumpNetwork.depth — the 'systems 1–2 hops out' rule): the FIRST band in list order whose levels AND hops windows both contain the player stands, and its enemyTypes are the hostiles its contacts fly (validated against combat.enemyTypes — js/combat/EnemyTypes.js — an unknown id never ships). No band stands, or a roster empties, → fallbackEnemyTypes. TODAY the low band — levels 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider — the base craft's named id, the 'Pirate Problems' wave's own type); the farther/higher bands — with the enemy types they introduce — append to this list when those types land, and the other difficulty axes (the distance's own scaling, the level's bonus) wire onto this same seam. POSITIONING — the contacts stand on route waypoints SPREAD evenly across the route (patrolEncounterPositions): for ≥ 2 contacts, the most evenly spread subset of the route's waypoints (scored by its MINIMUM pairwise separation — maximin — then its total: on a near-linear route that's the ends and the middle; a 3–5-point route's contacts never cluster); a single contact stands on the waypoint nearest the route's centroid (the patrol's middle). Deterministic — a pure function of the seed, the contract, and the route's geometry.", "_comment": "The patrol's ENEMY ENCOUNTERS — the contract's hostile CONTACTS standing on route waypoints (the data contract: data/sidequests.json → patrol.encounters; the logic: js/quests/SideQuests.js → derivePatrolEncounters + patrolEncounterScript; the firing: the scene's updatePatrol → _firePatrolContact — the wave starts when the ship checks in at a CONTACT's waypoint, and the cleared mark lands on the wave run's COMPLETION — the witness is the completion, not the attempt: a mid-fight quit re-arms the contact on return, the combatDone precedent — and the route's completion, with the reward's door, waits for every contact). COUNTS — the odds of how many CONTACTS a patrol carries (1 contact on 20% of patrols, 2 on 20%, 3 on 60%): [count, odds] pairs, drawn SEED-STABLE at the accept from the labelled fork (seed, 'sidequests', <targetSystemId>, 'patrol', <jobId>, 'encounters') and fixed on the route record — a signed contract is a signed contract: a later re-tune of the odds can't re-roll a standing contract's combat. WAVE — each contact's fight: ONE rolling wave of the band's roster (equal weights — a single-type band is a pure type + total), the data's shape — six hostiles, three at a time, each kill replaced — the combat.waves rolling mechanic (js/combat/Waves.js); the script is built by patrolEncounterScript (no named entry in data/combat.json — the roster comes from the band below). BANDS — the difficulty's first axis (the player's COMBAT level × the target system's HOPS from home — the jump network's tree depth, galaxy.jumpNetwork.depth — the 'systems 1–2 hops out' rule): the FIRST band in list order whose levels AND hops windows both contain the player stands, and its enemyTypes are the hostiles its contacts fly (validated against combat.enemyTypes — js/combat/EnemyTypes.js — an unknown id never ships). No band stands, or a roster empties, → fallbackEnemyTypes. TODAY the low band — levels 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider — the base craft's named id, the 'Pirate Problems' wave's own type); the farther/higher bands — with the enemy types they introduce — append to this list when those types land, and the other difficulty axes (the distance's own scaling, the level's bonus) wire onto this same seam. POSITIONING — the contacts stand on route waypoints SPREAD evenly across the route (patrolEncounterPositions): for ≥ 2 contacts, the most evenly spread subset of the route's waypoints (scored by its MINIMUM pairwise separation — maximin — then its total: on a near-linear route that's the ends and the middle; a 3–5-point route's contacts never cluster); a single contact stands on the waypoint nearest the route's centroid (the patrol's middle). Deterministic — a pure function of the seed, the contract, and the route's geometry. XP REWARD — beside the credits, the claim pays the XP these defeated contacts are WORTH (js/quests/SideQuests.js → patrolEncounterXp: the contacts × the wave's total × the roster's type xp, data/combat.json → enemy.xp / enemyTypes): fixed on the route record at the accept (route.xp, beside route.credits — a signed contract is a signed contract) and granted into the run's XP bank on the claim (XpManager's 'sidequest' source — the kills' 'kill' grants' sibling).",
"enabled": true, "enabled": true,
"counts": [ "counts": [
[1, 0.2], [1, 0.2],
@ -254,12 +254,14 @@
"statusLive": "{n} CONTRACTS ON FILE", "statusLive": "{n} CONTRACTS ON FILE",
"rewardUnit": "CR", "rewardUnit": "CR",
"payout": "PAYOUT — {reward} CR", "payout": "PAYOUT — {reward} CR",
"payoutXp": "PAYOUT — {reward} CR · +{xp} XP",
"accept": "ACCEPT CONTRACT", "accept": "ACCEPT CONTRACT",
"accepted": "CONTRACT ACCEPTED", "accepted": "CONTRACT ACCEPTED",
"active": "PATROL ACTIVE", "active": "PATROL ACTIVE",
"activate": "SET PATROL ACTIVE", "activate": "SET PATROL ACTIVE",
"complete": "CONTRACT COMPLETE", "complete": "CONTRACT COMPLETE",
"claim": "CLAIM REWARD — {reward} CR", "claim": "CLAIM REWARD — {reward} CR",
"claimXp": "CLAIM REWARD — {reward} CR · +{xp} XP",
"claimed": "REWARD CLAIMED", "claimed": "REWARD CLAIMED",
"destination": "DESTINATION — {name}", "destination": "DESTINATION — {name}",
"routeProgress": "ROUTE — {visited}/{total} WAYPOINTS LOGGED", "routeProgress": "ROUTE — {visited}/{total} WAYPOINTS LOGGED",
@ -292,7 +294,8 @@
"claimToast": { "claimToast": {
"glyph": "✓", "glyph": "✓",
"color": "#67e863", "color": "#67e863",
"text": "REWARD CLAIMED — +{reward} CR" "text": "REWARD CLAIMED — +{reward} CR",
"textXp": "REWARD CLAIMED — +{reward} CR · +{xp} XP"
}, },
"claimDenyToast": { "claimDenyToast": {
"glyph": "✗", "glyph": "✗",

View File

@ -167,7 +167,33 @@ console.log('== CARGOHOLD — the ship\u2019s trade-goods hold ==');
assert.deepEqual(bad.costs, [null, null], 'non-finite / negative costs normalize to null'); assert.deepEqual(bad.costs, [null, null], 'non-finite / negative costs normalize to null');
} }
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone: OK'); // --- costsFor — the sale's per-bay PROFIT BASIS (FIFO, matches remove) ----
{
const h = new CargoHold(6);
h.add(['gem', 'gem', 'gem'], 30); // three bays of 'gem' at 30 each
h.add(['iron-ore', 'iron-ore'], 21); // two bays of 'iron-ore' at 21 each
h.add('gem'); // one 'gem' with NO cost on file
assert.deepEqual(h.items, ['gem', 'gem', 'gem', 'iron-ore', 'iron-ore', 'gem']);
assert.deepEqual(h.costs, [30, 30, 30, 21, 21, null]);
// costsFor returns the FIRST n bays of the good, in fill order (the same
// bays remove() would take) — each its recorded cost or null.
assert.deepEqual(h.costsFor('gem', 2), [30, 30], 'two gem bays → their costs, in order');
assert.deepEqual(h.costsFor('gem', 5), [30, 30, 30, null], 'asking for more returns all that exist (incl. the null-cost bay)');
assert.deepEqual(h.costsFor('iron-ore', 1), [21], 'a different good → its own cost');
assert.deepEqual(h.costsFor('nope', 3), [], 'a good that\u2019s not aboard → no basis');
assert.deepEqual(h.costsFor('gem', 0), [], 'n = 0 → nothing');
assert.deepEqual(h.costsFor('gem', 'not-a-number'), [], 'a bad n → nothing (no throw)');
// … and it is READ-ONLY (the hold is untouched).
assert.deepEqual(h.items, ['gem', 'gem', 'gem', 'iron-ore', 'iron-ore', 'gem'], 'costsFor never mutates the hold');
assert.deepEqual(h.costs, [30, 30, 30, 21, 21, null], '… the ledger is unchanged too');
// remove() then agrees: the same FIFO bays leave with the same costs.
h.remove('gem', 2);
assert.deepEqual(h.items, ['gem', 'iron-ore', 'iron-ore', 'gem']);
assert.deepEqual(h.costs, [30, 21, 21, null]);
}
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone, costsFor: OK');
console.log('\n== TRADE PREVIEW — the shop UI\u2019s calculator =='); console.log('\n== TRADE PREVIEW — the shop UI\u2019s calculator ==');
@ -343,4 +369,58 @@ const CAP = 4; // the player's starting unlocked bays
console.log('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK'); console.log('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK');
console.log('\n== PROFIT XP — 1 XP per 1 credit above the good\u2019s purchase cost ==');
// tradeProfitXp(sell, costsFor) is the pure core of the sale's XP: each bay
// earns max(0, sell unit − its recorded cost); bays with no cost on file
// earn nothing; the total is whole credits, clamped ≥ 0.
{
// A single good sold 2 bays at 50, bought at 30 and 20 → (50−30)+(50−20)=50.
const sell = [{ id: 'gem', unit: 50, qty: 2 }];
const costsFor = (id, n) => (id === 'gem' ? [30, 20].slice(0, n) : []);
assert.equal(E.tradeProfitXp(sell, costsFor), 50, 'profit = Σ max(0, unit − cost) per bay (20 + 30)');
// Sold at/below cost earns NOTHING (no negative XP, no loss).
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 30, qty: 2 }], costsFor), 10, 'at-cost bay earns 0; the 20-cost bay earns its 10 margin');
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 20, qty: 2 }], costsFor), 0, 'a sale below every cost earns 0');
// A bay with no cost on file (null) earns nothing — no basis, no profit.
const someCost = (id, n) => (id === 'gem' ? [null, 10].slice(0, n) : []);
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], someCost), 40, 'the null-cost bay earns 0; the 10-cost bay earns 40');
// A good not aboard / no cost basis at all → 0.
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], () => []), 0, 'no cost on file anywhere → 0');
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], null), 0, 'a missing costsFor → 0 (never throws)');
// Multiple goods sum independently; each bay is clamped to its own margin.
const multi = (id, n) => ({ gem: [10, 60], ore: [40] }[id]?.slice(0, n) ?? []);
const sell2 = [
{ id: 'gem', unit: 50, qty: 2 }, // (50−10)=40 + (50−60)→0 = 40
{ id: 'ore', unit: 50, qty: 1 }, // (50−40)=10 = 10
];
assert.equal(E.tradeProfitXp(sell2, multi), 50, 'each good/bay earns its own margin; losses don\u2019t offset gains');
// Fractional credits floor to whole XP; the total is never negative.
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50.9, qty: 1 }], () => [50.2]), 0, 'sub-1-credit margins floor out (50.9−50.2 < 1 → 0)');
assert.equal(E.tradeProfitXp([], () => [10]), 0, 'an empty sale earns nothing');
assert.equal(E.tradeProfitXp(null, () => [10]), 0, 'a null sale earns nothing (no throw)');
}
// The REAL seam: CargoHold.costsFor is the basis tradeProfitXp reads, and
// together they price the exact bays the sale removes (FIFO).
{
const h = new CargoHold(4);
h.add(['gem', 'gem'], 30); // bought at 30 each
h.add(['gem', 'gem']); // two more with NO cost on file (a handoff)
const sellLine = { id: 'gem', unit: 50, qty: 2 }; // the shop sells the FIRST two (30-cost)
const xp = E.tradeProfitXp([sellLine], (id, n) => h.costsFor(id, n));
assert.equal(xp, 40, 'the two 30-cost bays earn (50−30)×2 = 40; the null-cost bays are untouched');
// … and selling the null-cost bays (FIFO after the two priced ones) earns 0.
h.remove('gem', 2);
assert.deepEqual(h.costs, [null, null], '… the two priced bays are gone');
assert.equal(E.tradeProfitXp([sellLine], (id, n) => h.costsFor(id, n)), 0, '… the remaining null-cost bays earn nothing');
}
console.log('PROFIT XP: per-bay margin, loss/none→0, multi-good, floor, FIFO basis: OK');
console.log('\nAll cargo checks passed \u2714'); console.log('\nAll cargo checks passed \u2714');

View File

@ -248,7 +248,7 @@ console.log('== LIVE — the postings stand, the selection reads, the door walks
const accepts = []; const accepts = [];
const activates = []; const activates = [];
const claims = []; const claims = [];
const routeState = { visited: 0, total: 3 }; const routeState = { visited: 0, total: 3, xp: 0 };
const mkPost = (cat, title, n, extra = {}) => ({ const mkPost = (cat, title, n, extra = {}) => ({
id: `sq-test-${cat}-${n}`, id: `sq-test-${cat}-${n}`,
systemId: 'sys-free', systemId: 'sys-free',
@ -280,7 +280,7 @@ console.log('== LIVE — the postings stand, the selection reads, the door walks
isCompleted: (id) => completed.has(id), isCompleted: (id) => completed.has(id),
isActive: (id) => active.has(id), isActive: (id) => active.has(id),
isClaimed: (id) => claimed.has(id), isClaimed: (id) => claimed.has(id),
routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total } : null), routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total, xp: routeState.xp } : null),
onAccept: (id) => accepts.push(id), onAccept: (id) => accepts.push(id),
onActivate: (id) => activates.push(id), onActivate: (id) => activates.push(id),
onClaim: (id) => claims.push(id), onClaim: (id) => claims.push(id),
@ -392,12 +392,16 @@ console.log('== LIVE — the postings stand, the selection reads, the door walks
// REWARD (live — it fires the onClaim seam, the payout named). // REWARD (live — it fires the onClaim seam, the payout named).
active.delete(posts[0].id); // the done contract drops out of the active slot active.delete(posts[0].id); // the done contract drops out of the active slot
completed.add(posts[0].id); completed.add(posts[0].id);
routeState.xp = 60; // the route’s XP payout (the accept’s fix) stands on the seam
win.refresh(); win.refresh();
assert.ok( assert.ok(
win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)), win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)),
'the completed row shows CONTRACT COMPLETE'); 'the completed row shows CONTRACT COMPLETE');
assert.ok(
win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR · \+60 XP/.test(t.text)),
'the payout line names the XP payout (the route’s fix, beside the credits)');
dTxt = win.detailSection.content[win.detailSection.content.length - 1]; dTxt = win.detailSection.content[win.detailSection.content.length - 1];
assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR', 'the door stands CLAIM (the payout named)'); assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR · +60 XP', 'the door stands CLAIM (the payout named — credits + the XP)');
dG = win.detailSection.content[win.detailSection.content.length - 2]; dG = win.detailSection.content[win.detailSection.content.length - 2];
assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)'); assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)');
dG.__on.pointerdown(); dG.__on.pointerdown();

View File

@ -559,6 +559,77 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold
} }
console.log('SELL: OK'); console.log('SELL: OK');
// ===========================================================================
console.log('\n== PROFIT XP — a profitable sale pays 1 XP per 1 credit above cost ==');
{
const ship = makeShip(10000);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
assert.ok(sellA > 5, 'the sell price clears the cost (a profitable sale)');
const COST = 5; // the credits/unit the player PAID for these two bays
ship.cargo.add([A, A], COST);
const grants = [];
const { view, gs } = makeView(ship);
// The scene's XP pipe (GameScene.combat.xp) — the SAME pipe as kills /
// sidequests; a spy records every grant + its source tag.
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); return { gained: n, level: 1, levelsGained: 0, leveledUp: false }; } } };
view.sellGrid.toggleBay(0); // bay 0 holds A
view.sellGrid.toggleBay(1); // bay 1 holds A
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 2 }, 'both A bays are selected');
const pv = view.preview();
assert.equal(pv.sellTotal, sellA * 2, 'sellTotal = 2 × the A sale');
assert.equal(pv.valid, true, '… and it is legal');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before + sellA * 2, 'credits rise by the sale');
assert.deepEqual(ship.cargo.snapshot(), [], '… and the hold is empty');
// THE PROFIT XP — 1 XP per 1 credit above the purchase cost, per bay:
// (sellA − COST) × 2, paid through the scene's XP pipe (source 'trade').
const expectedXp = (sellA - COST) * 2;
assert.ok(expectedXp > 0, '… a positive profit (the sale beats the cost)');
assert.deepEqual(grants, [[expectedXp, 'trade']], `profit XP ${expectedXp} granted ONCE, tagged 'trade'`);
// The CONFIRMED line tells the player the XP the payout earned (the
// credit delta is unchanged; the XP is appended, not a separate grant).
assert.ok(view._lastStatus, 'the status line is recorded');
assert.ok(view._lastStatus.includes(`+${expectedXp} XP`), `the CONFIRMED line shows the +${expectedXp} XP payout`);
assert.ok(view._lastStatus.includes(`+${sellA * 2} cr`), '… and still shows the credit delta');
}
// …and a sale with NO profit basis (no cost on file; or at/below cost)
// grants NOTHING — the pipe is never called (no 0-grant, no negative XP).
{
// (a) a bay with no cost on file (a handoff) — no basis, no profit.
{
const ship = makeShip(10000);
ship.cargo.add([A, A]); // NO unitCost → null cost on file
const grants = [];
const { view, gs } = makeView(ship);
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
view.sellGrid.toggleBay(0);
view.sellGrid.toggleBay(1);
assert.equal(view.confirm(), true, 'Confirm goes through (the sale itself stands)');
assert.deepEqual(grants, [], 'no cost on file → no profit basis → NO XP grant');
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
}
// (b) a sale AT/BELOW cost — no margin, no XP.
{
const ship = makeShip(10000);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
ship.cargo.add([A, A], sellA); // bought at exactly the sell price → 0 margin
const grants = [];
const { view, gs } = makeView(ship);
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
view.sellGrid.toggleBay(0);
view.sellGrid.toggleBay(1);
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.deepEqual(grants, [], 'a zero-margin sale earns NO XP');
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
}
}
console.log('PROFIT XP: profitable sale pays 1 XP/credit above cost; no-basis / no-margin sells earn none: OK');
// =========================================================================== // ===========================================================================
console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays =='); console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays ==');
{ {

View File

@ -492,6 +492,31 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac
assert.equal(patrolRouteComplete(null), false, 'no route → not complete'); assert.equal(patrolRouteComplete(null), false, 'no route → not complete');
} }
// ===========================================================================
console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout) ==');
{
const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
// The payout's XP = the XP WORTH of the patrol's defeated contacts —
// the contacts × the wave's total × the roster's weighted-average xp
// (data/combat.json: the raider's 10 XP, the bomber's 25 XP).
const enc = (at, extra = {}) => ({
at,
cleared: [],
roster: ['raider'],
wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 },
...extra,
});
assert.equal(patrolEncounterXp(null), 0, 'no encounters (old saves, the feature off) → 0 (the credits stand alone)');
assert.equal(patrolEncounterXp({ at: [] }), 0, 'no contacts → 0');
assert.equal(patrolEncounterXp(enc([1])), 60, '1 contact × 6 raiders × 10 XP (the base hostile) = 60');
assert.equal(patrolEncounterXp(enc([1, 2])), 120, '2 contacts → 120 (the XP worth of every defeated contact)');
assert.equal(patrolEncounterXp(enc([0, 2, 4])), 180, '3 contacts → 180');
assert.equal(patrolEncounterXp(enc([1], { roster: ['bomber'] })), 150, 'a bomber band (25 XP each) → 6 × 25 = 150');
assert.equal(patrolEncounterXp(enc([1], { roster: ['raider', 'bomber'] })), 105, 'a mixed roster (equal weights) → 6 × (10 + 25) / 2 = 105');
assert.equal(patrolEncounterXp(enc([1], { wave: { kind: 'rolling', total: 4, maxConcurrent: 2, replacePerKill: 1 } })), 40, 'a smaller wave total rules: 4 × 10 = 40');
assert.equal(patrolEncounterXp(enc([1], { roster: ['not-a-type'] })), 60, 'an unknown roster type falls back to the established type’s wave (the script’s rule) → 60');
}
// =========================================================================== // ===========================================================================
console.log('== the ledger — SideQuestState (the run’s accepted/active/completed/claimed marks) =='); console.log('== the ledger — SideQuestState (the run’s accepted/active/completed/claimed marks) ==');
{ {
@ -588,6 +613,24 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save'); assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save');
assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore'); assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore');
// The route's XP PAYOUT (the claim's second reward — beside the
// credits): an explicit one (the accept's fix) stands on the record;
// a route without one (an old save) derives it from its own spec;
// a route without encounters (the old shape) degrades to 0 (the
// credits stand alone).
const specXp = { at: [1, 2], cleared: [], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } };
const rtx = new SideQuestState();
rtx.accept('sq-xp-patrol-0');
rtx.setRoute('sq-xp-patrol-0', { targetSystemId: 'sys-x', credits: 90, waypoints: [[1, 1], [2, 2], [3, 3]], visited: [], encounters: specXp, xp: 120 });
assert.equal(rtx.routeFor('sq-xp-patrol-0').xp, 120, 'an explicit XP payout (the accept’s fix) stands on the route');
rtx.setRoute('sq-xp-patrol-0', { targetSystemId: 'sys-x', credits: 90, waypoints: [[1, 1], [2, 2], [3, 3]], visited: [], encounters: specXp });
assert.equal(rtx.routeFor('sq-xp-patrol-0').xp, 120, 'a route without an XP field (an old save) derives it from its own spec (2 contacts × 6 × 10)');
const rtxOld = new SideQuestState();
rtxOld.accept('sq-xpo-patrol-0');
rtxOld.setRoute('sq-xpo-patrol-0', { targetSystemId: 'sys-x', credits: 60, waypoints: [[1, 1]], visited: [] });
assert.equal(rtxOld.routeFor('sq-xpo-patrol-0').xp, 0, 'a route without encounters (the old shape) → 0 XP (the credits stand alone)');
assert.equal(SideQuestState.fromJSON(rtx.toJSON()).routeFor('sq-xp-patrol-0').xp, 120, 'the XP payout survives the save round-trip (the contract’s payout)');
// The save round-trip — the ledger is the save’s side-quest state // The save round-trip — the ledger is the save’s side-quest state
// (the postings themselves regenerate from the seed: a save = seed + // (the postings themselves regenerate from the seed: a save = seed +
// player state). // player state).
@ -754,7 +797,7 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
call(GameScene.prototype.claimQuest, scene, job.id); call(GameScene.prototype.claimQuest, scene, job.id);
assert.equal(scene.state.credits, before + 77, 'the claim pays the contract’s payout (the route’s credits)'); assert.equal(scene.state.credits, before + 77, 'the claim pays the contract’s payout (the route’s credits)');
assert.equal(scene.sideQuestState.isClaimed(job.id), true, '…and marks it claimed (exactly once — the ledger’s idempotency)'); assert.equal(scene.sideQuestState.isClaimed(job.id), true, '…and marks it claimed (exactly once — the ledger’s idempotency)');
assert.ok(scene.state.toasts.some((t) => t.label.includes('+77')), '…with the console’s claim toast (the reward in the line)'); assert.ok(scene.state.toasts.some((t) => t.label === 'REWARD CLAIMED — +77 CR'), '…with the console’s claim toast (the credits’ payout — a route without encounters stands credits-only, no XP line)');
assert.ok(scene.state.sfx.includes('construct'), '…with the payout tick (the scene’s voice)'); assert.ok(scene.state.sfx.includes('construct'), '…with the payout tick (the scene’s voice)');
assert.ok(scene.state.refreshes > 0, '…and the open console repaints (refresh)'); assert.ok(scene.state.refreshes > 0, '…and the open console repaints (refresh)');
@ -829,6 +872,13 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } }; sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } };
sceneC.systemRecord = REC_NEUTRAL; sceneC.systemRecord = REC_NEUTRAL;
sceneC.combat = combat; sceneC.combat = combat;
const xpGrants = []; // the run's XP bank's witness (the claim's XP payout)
combat.xp = {
grant(n, src = 'kill') {
xpGrants.push({ n: Math.max(0, Math.floor(Number(n) || 0)), src });
return { gained: n, level: 1, levelsGained: 0, leveledUp: false };
},
};
const jobC = posts.find((p) => p.category === 'patrol'); const jobC = posts.find((p) => p.category === 'patrol');
sceneC.sideQuestState.accept(jobC.id); sceneC.sideQuestState.accept(jobC.id);
// The route: three collinear waypoints (the contact on the middle), // The route: three collinear waypoints (the contact on the middle),
@ -888,12 +938,15 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
sceneC.updatePatrol(); sceneC.updatePatrol();
assert.equal(sceneC.sideQuestState.isCompleted(jobC.id), true, 'every waypoint logged + every contact cleared → the route COMPLETES (the reward ready)'); assert.equal(sceneC.sideQuestState.isCompleted(jobC.id), true, 'every waypoint logged + every contact cleared → the route COMPLETES (the reward ready)');
assert.ok(sceneC.state.toasts.some((t) => t.label.toUpperCase().includes('PATROL ROUTE COMPLETE')), '…with the completion toast (the console’s voice)'); assert.ok(sceneC.state.toasts.some((t) => t.label.toUpperCase().includes('PATROL ROUTE COMPLETE')), '…with the completion toast (the console’s voice)');
assert.deepEqual(sceneC.patrolProgress(jobC.id), { visited: 3, total: 3, contacts: { cleared: 1, total: 1 } }, 'the board’s seam reads the finished route (3/3 + 1/1)'); assert.deepEqual(sceneC.patrolProgress(jobC.id), { visited: 3, total: 3, xp: 60, contacts: { cleared: 1, total: 1 } }, 'the board’s seam reads the finished route (3/3 + 1/1) + the contract’s XP payout (1 contact × 6 × 10)');
viewC = sceneC._sideQuestView(jobC.id); viewC = sceneC._sideQuestView(jobC.id);
assert.equal(viewC.checks[3].done, true, '…and the checklist’s contact reads CLEARED'); assert.equal(viewC.checks[3].done, true, '…and the checklist’s contact reads CLEARED');
assert.equal(viewC.reward, '140 CR · +60 XP', '…and the console row’s reward names both payouts (the credits + the XP)');
const beforeC = sceneC.state.credits; const beforeC = sceneC.state.credits;
call(GameScene.prototype.claimQuest, sceneC, jobC.id); call(GameScene.prototype.claimQuest, sceneC, jobC.id);
assert.equal(sceneC.state.credits, beforeC + 140, '…and the claim pays the contract’s payout (the route’s credits)'); assert.equal(sceneC.state.credits, beforeC + 140, '…and the claim pays the contract’s payout (the route’s credits)');
assert.deepEqual(xpGrants, [{ n: 60, src: 'sidequest' }], '…and the claim banks the XP reward (the patrol’s defeated contacts’ worth) in the run’s XP bank, under the ‘sidequest’ source, beside the credits');
assert.ok(sceneC.state.toasts.some((t) => t.label.includes('+140') && t.label.includes('+60 XP')), '…and the console’s claim toast names both payouts (the credits + the XP)');
// The combat’s completion GATES the route (the combat can’t be // The combat’s completion GATES the route (the combat can’t be
// outrun): waypoints all logged, the contact’s wave fired but NOT // outrun): waypoints all logged, the contact’s wave fired but NOT

View File

@ -114,9 +114,11 @@ export class XpManager {
* Bank XP. Crosses as many levels as the grant buys (one event each — * Bank XP. Crosses as many levels as the grant buys (one event each —
* see the class doc), caps at maxLevel (the bank keeps counting), and * see the class doc), caps at maxLevel (the bank keeps counting), and
* fires 'grant' last, with the settled state. * fires 'grant' last, with the settled state.
* @param {number} amount the XP earned (a hostile's type `xp`) * @param {number} amount the XP earned (a hostile's type `xp`, a sidequest's
* @param {string} [source] the ledger's source tag ('kill' for now; * payout, a cargo sale's profit — whatever the caller banks)
* quests/dev probes flow through the same pipe later) * @param {string} [source] the ledger's source tag ('kill'; 'sidequest' —
* the board's claim, the contract's XP payout; 'trade' — the profit on a
* cargo sale, js/ui/shop/ShopView.js — all flow through the same pipe)
* @returns {{gained:number, level:number, levelsGained:number, leveledUp:boolean}} * @returns {{gained:number, level:number, levelsGained:number, leveledUp:boolean}}
*/ */
grant(amount, source = 'kill') { grant(amount, source = 'kill') {

View File

@ -182,6 +182,28 @@ export class CargoHold {
return c != null && Number.isFinite(c) ? c : null; return c != null && Number.isFinite(c) ? c : null;
} }
/**
* The purchase costs of the FIRST `n` bays of `goodId` (fill order — the
* same bays `remove(goodId, n)` would take), each a non-negative integer
* or null (no cost on file). The sale's PROFIT BASIS: the shop's sell
* price minus each bay's recorded cost is what that unit earned above
* what the player paid for it (js/ui/shop/ShopView.js → the 'trade' XP).
* Asking for more than are aboard returns however many exist (0 … n).
* Read-only — it never mutates the hold.
*/
costsFor(goodId, n) {
const k = Math.max(0, Math.floor(Number(n) || 0));
if (k === 0) return [];
const out = [];
for (let i = 0; i < this.items.length && out.length < k; i++) {
if (this.items[i] === goodId) {
const c = this.costs[i];
out.push(c != null && Number.isFinite(c) ? c : null);
}
}
return out;
}
/** The bays in fill order, each with its recorded cost (or null). */ /** The bays in fill order, each with its recorded cost (or null). */
list() { list() {
return this.items.map((id, i) => ({ id, cost: this.costAt(i) })); return this.items.map((id, i) => ({ id, cost: this.costAt(i) }));

View File

@ -534,6 +534,47 @@ export function tradePreview(o) {
}; };
} }
/**
* The SELL side's PROFIT — the XP a confirmed trade earns (1 XP per 1 credit
* earned ABOVE each good's original purchase price). `sell` is tradePreview's
* sell array (each { id, unit, qty }); `costsFor(goodId, n)` returns the
* per-unit PURCHASE costs of the n bays of that good the sale removes (FIFO —
* the same bays CargoHold.remove() takes; a null entry = no cost on file).
*
* Each bay contributes max(0, unit − cost): selling at or below cost yields
* nothing (no negative XP, no loss offset), and a bay with no cost on file
* (the quest package's handoff; a save predating the cost record) contributes
* nothing — no basis, no profit claimed. The total is floored to whole
* credits (XP is an integer) and clamped to ≥ 0. Pure — the caller
* (ShopView) grants it through the scene's XP pipe (source 'trade').
*
* @param {Array<{id:string, unit:number, qty:number}>} sell — tradePreview's sell lines
* @param {(goodId: string, n: number) => Array<number|null>} costsFor — the sale's per-bay cost basis
* @returns {number} the sale's profit in whole credits (≥ 0) — the XP to grant
*/
export function tradeProfitXp(sell, costsFor) {
if (!Array.isArray(sell)) return 0;
const basis = (id, n) => {
const arr = (typeof costsFor === 'function') ? costsFor(id, n) : null;
return Array.isArray(arr) ? arr : [];
};
let profit = 0;
for (const s of sell) {
if (!s || typeof s !== 'object') continue;
const unit = Math.max(0, Number(s.unit) || 0);
const qty = Math.max(0, Math.floor(Number(s.qty) || 0));
if (qty <= 0) continue;
const costs = basis(s.id, qty);
for (let i = 0; i < qty; i++) {
const c = costs[i];
if (c == null || !Number.isFinite(c)) continue; // no cost on file → no basis
const bay = unit - c;
if (bay > 0) profit += bay; // at/below cost earns nothing
}
}
return Math.max(0, Math.floor(profit));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// THE GENERATION PASS — galaxy.economy (classBySystem + producerHops) // THE GENERATION PASS — galaxy.economy (classBySystem + producerHops)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -65,10 +65,11 @@
* - LEDGER — SideQuestState carries the accepted order, the ONE * - LEDGER — SideQuestState carries the accepted order, the ONE
* active contract (reconcileActive: the top accepted contract * active contract (reconcileActive: the top accepted contract
* stands active until it is done and claimed), the route record * stands active until it is done and claimed), the route record
* (waypoints + visited), and the claimed marks. The scene's * (waypoints + visited + the contract's payout — credits AND the
* updatePatrol checks the ship in (visitRadius, any order), * XP reward, patrolEncounterXp), and the claimed marks. The
* completes the contract, and pays the reward exactly once on * scene's updatePatrol checks the ship in (visitRadius, any
* claim. * order), completes the contract, and pays the reward exactly
* once on claim (the credits + the XP, the claim's two payouts).
* *
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") — * DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
* postings derive from the galaxy seed + the system record through * postings derive from the galaxy seed + the system record through
@ -91,7 +92,7 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js'; import { Rng } from '../utils/Rng.js';
import { Tether } from '../tether/Tether.js'; import { Tether } from '../tether/Tether.js';
import { enemyTypeIds } from '../combat/EnemyTypes.js'; import { enemyTypeConfig, enemyTypeIds } from '../combat/EnemyTypes.js';
/** The four job types, canonical slot order (the data's category keys). */ /** The four job types, canonical slot order (the data's category keys). */
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort']; export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
@ -667,6 +668,46 @@ export function patrolEncounterScript(encounters) {
return { waves: [wave] }; return { waves: [wave] };
} }
/**
* The XP REWARD — the contract's SECOND payout (the claim pays it
* beside the credits): the experience WORTH of the patrol's defeated
* contacts. Completing the route requires every contact's wave to run
* to completion, so the enemies the player defeats during the quest
* ARE the contacts' waves — `at.length` × the wave's `total` — each
* enemy worth its type's `xp` (data/combat.json — the roster's weighted
* average; today's equal-weight specs are a plain average). Zero when
* the route carries no encounters (old saves, the feature off) — the
* reward degrades to credits-only, the route's waypoints alone
* completing it. Pure in the spec — it reads the SAME wave shape +
* roster patrolEncounterScript hands to the fight, so the reward and
* the combat can't drift apart (a re-tune of the bands re-tunes both).
*
* @param {object|null} encounters the route record's encounters spec
* @returns {number} the payout's XP (0 for a route without encounters)
*/
export function patrolEncounterXp(encounters) {
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
const at = e && Array.isArray(e.at) ? e.at.length : 0;
if (at === 0) return 0;
const wave = patrolEncounterScript(e).waves[0];
const total = Math.max(0, Math.floor(numOr(wave?.total, 0)));
if (total === 0) return 0;
const roster =
wave?.roster && typeof wave.roster === 'object' && !Array.isArray(wave.roster) ? wave.roster : null;
let weightSum = 0;
let xpWeighted = 0;
if (roster) {
for (const [typeId, w] of Object.entries(roster)) {
const weight = Math.max(0, Number(w) || 0);
if (weight === 0) continue;
weightSum += weight;
xpWeighted += weight * Math.max(0, Number(enemyTypeConfig(String(typeId))?.xp) || 0);
}
}
if (weightSum === 0) return 0;
return Math.round(at * total * (xpWeighted / weightSum));
}
/** /**
* A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every * A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every
* contact CLEARED (the contract's combat is part of the job — the * contact CLEARED (the contract's combat is part of the job — the
@ -893,7 +934,7 @@ export class SideQuestState {
this.completed = new Set(); // completed ⊆ accepted this.completed = new Set(); // completed ⊆ accepted
this.claimed = new Set(); // claimed ⊆ completed this.claimed = new Set(); // claimed ⊆ completed
this.activeId = null; // the ONE contract being flown (taken, not done, not claimed) this.activeId = null; // the ONE contract being flown (taken, not done, not claimed)
this.routes = new Map(); // id → { targetSystemId, credits, waypoints, visited } this.routes = new Map(); // id → { targetSystemId, credits, xp, waypoints, visited, encounters }
} }
/** /**
@ -1020,12 +1061,19 @@ export class SideQuestState {
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length, (i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
), ),
)]; )];
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
this.routes.set(id, { this.routes.set(id, {
targetSystemId: String(route.targetSystemId ?? ''), targetSystemId: String(route.targetSystemId ?? ''),
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's payout — fixed at the accept credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept
// The contract's XP payout (the claim's second reward — beside the
// credits): the accept fixes it on the record (patrolEncounterXp,
// the scene's _establishPatrol); a route that stands without one
// (an old save) derives it from its own spec — the same contract,
// a signed contract is a signed contract.
xp: Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0 ? Number(route.xp) : patrolEncounterXp(encounters),
waypoints, waypoints,
visited, visited,
encounters: normalizePatrolEncounters(route.encounters, waypoints.length), encounters,
}); });
return true; return true;
} }
@ -1049,8 +1097,8 @@ export class SideQuestState {
/** /**
* A contract's route record (the scene's check-ins + the board's route * A contract's route record (the scene's check-ins + the board's route
* line read it). * line + the claim's payout read it).
* @returns {{targetSystemId:string, credits:number, waypoints:number[][], visited:number[]}|null} * @returns {{targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object}|null}
*/ */
routeFor(id) { routeFor(id) {
return this.routes.get(id) ?? null; return this.routes.get(id) ?? null;

View File

@ -63,7 +63,7 @@ import { ShipWindow } from '../ui/ShipWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js';
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js'; import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete } from '../quests/SideQuests.js'; import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, patrolEncounterXp, patrolRouteComplete } from '../quests/SideQuests.js';
import { tutorialTarget } from '../quests/TutorialGuide.js'; import { tutorialTarget } from '../quests/TutorialGuide.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js'; import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
@ -4495,6 +4495,11 @@ export class GameScene extends Phaser.Scene {
route && Number.isFinite(route.credits) && route.credits > 0 route && Number.isFinite(route.credits) && route.credits > 0
? route.credits ? route.credits
: (posting.reward?.credits ?? 0); : (posting.reward?.credits ?? 0);
// The XP payout (the claim's second reward — beside the credits):
// the route's fixed XP (the experience worth of the patrol's
// defeated contacts — the accept's patrolEncounterXp). A route
// without one (old saves, the stubs) stands credits-only.
const xp = route && Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0;
const complete = const complete =
waypoints.length > 0 && visited.length === waypoints.length && waypoints.length > 0 && visited.length === waypoints.length &&
(!enc || enc.at.every((i) => clearedSet.has(i))); (!enc || enc.at.every((i) => clearedSet.has(i)));
@ -4507,7 +4512,7 @@ export class GameScene extends Phaser.Scene {
icon: 'diamond', icon: 'diamond',
active: st.isActive(id), active: st.isActive(id),
checks, checks,
reward: `${credits} CR`, reward: xp > 0 ? `${credits} CR · +${xp} XP` : `${credits} CR`,
// The ledger's mark is the claim's gate (the scene's claim path); the // The ledger's mark is the claim's gate (the scene's claim path); the
// derived route state stands in for it in the transient frame between // derived route state stands in for it in the transient frame between
// the last contact's clearance and the mark's landing. // the last contact's clearance and the mark's landing.
@ -4938,6 +4943,13 @@ export class GameScene extends Phaser.Scene {
const depth = this.galaxy?.jumpNetwork?.depth; const depth = this.galaxy?.jumpNetwork?.depth;
const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home) const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home)
route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops); route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops);
// The contract's XP PAYOUT (the claim's second reward — beside the
// credits): the experience WORTH of the patrol's defeated contacts
// (patrolEncounterXp — the contacts × the wave's total × the band's
// roster xp), fixed on the route with the credits (a signed
// contract is a signed contract). The claim pays it into the run's
// XP bank (claimSideQuest — the 'sidequest' source).
route.xp = patrolEncounterXp(route.encounters);
this.sideQuestState.setRoute(jobId, route); this.sideQuestState.setRoute(jobId, route);
} }
@ -4961,19 +4973,27 @@ export class GameScene extends Phaser.Scene {
* precedent, the side-quest shape): the contract taken + COMPLETE, paid * precedent, the side-quest shape): the contract taken + COMPLETE, paid
* exactly once (the ledger's claim() is idempotent — a double-press or * exactly once (the ledger's claim() is idempotent — a double-press or
* a re-open can't pay twice). The payout is the contract's OWN: the * a re-open can't pay twice). The payout is the contract's OWN: the
* route record's credits (fixed at the accept); a category without a * route record's credits (fixed at the accept) + its XP (the
* route (the stubs, when they land) falls back to the posting's reward * experience worth of the patrol's defeated contacts — fixed there
* (regenerated from the seed — the contract stands). The scene owns * too); a category without a route (the stubs, when they land) falls
* the effect (the credits land); the ledger owns the record. * back to the posting's reward (regenerated from the seed — the
* @returns {{ok:boolean, credits?:number}} * contract stands). The scene owns the effect (the credits land in
* the hold, the XP lands in the run's XP bank — the 'sidequest'
* source, beside the kills' 'kill' grants); the ledger owns the
* record.
* @returns {{ok:boolean, credits?:number, xp?:number}}
*/ */
claimSideQuest(jobId) { claimSideQuest(jobId) {
const st = this.sideQuestState; const st = this.sideQuestState;
if (!st || !st.isAccepted(jobId) || !st.isCompleted(jobId) || st.isClaimed(jobId)) return { ok: false }; if (!st || !st.isAccepted(jobId) || !st.isCompleted(jobId) || st.isClaimed(jobId)) return { ok: false };
if (!st.claim(jobId)) return { ok: false }; // someone beat us to it — idempotent if (!st.claim(jobId)) return { ok: false }; // someone beat us to it — idempotent
let credits = 0; let credits = 0;
let xp = 0;
const route = st.routeFor(jobId); const route = st.routeFor(jobId);
if (route && Number.isFinite(route.credits)) credits = route.credits; if (route) {
if (Number.isFinite(route.credits)) credits = route.credits;
if (Number.isFinite(route.xp)) xp = Math.max(0, route.xp);
}
if (!credits) { if (!credits) {
// A category without a route: the posting's reward (the seed's // A category without a route: the posting's reward (the seed's
// contract stands — sq-<sysId>-<cat>-<slot>). // contract stands — sq-<sysId>-<cat>-<slot>).
@ -4989,18 +5009,27 @@ export class GameScene extends Phaser.Scene {
this.ship.addCredits(credits); this.ship.addCredits(credits);
this.refreshCreditsHud(); this.refreshCreditsHud();
} }
// The XP reward (the claim's second payout): the run's experience
// worth of the contract's defeated contacts, banked through the
// same pipe as the kills (XpManager.grant — its 'levelup' events
// stand the marquee + the armor bump, its 'grant' the listeners)
// under its own 'sidequest' source (the ledger's trail). No bank
// (the stubs' shape, a combat-less boot) — the credits stand alone.
if (xp > 0 && this.combat?.xp?.grant) this.combat.xp.grant(Math.floor(xp), 'sidequest');
this.playSfx('construct'); // the payout tick — the reward lands this.playSfx('construct'); // the payout tick — the reward lands
return { ok: true, credits }; return { ok: true, credits, xp };
} }
/** /**
* The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS * The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS
* LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED"): the * LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED" + the
* accepted patrol's checkpoint + contact counts. Null when the * payout's XP — "PAYOUT — 65 CR · +60 XP"): the accepted patrol's
* checkpoint + contact counts + the contract's XP payout (fixed at the
* accept — the board reads it once the route stands). Null when the
* contract carries no route (the other categories) or its route never * contract carries no route (the other categories) or its route never
* stood; `contacts` null when the route carries no encounters (old * stood; `contacts` null when the route carries no encounters (old
* saves load). * saves load).
* @returns {?{visited:number, total:number, contacts:?{cleared:number, total:number}}} * @returns {?{visited:number, total:number, xp:number, contacts:?{cleared:number, total:number}}}
*/ */
patrolProgress(jobId) { patrolProgress(jobId) {
const route = this.sideQuestState?.routeFor(jobId); const route = this.sideQuestState?.routeFor(jobId);
@ -5009,6 +5038,7 @@ export class GameScene extends Phaser.Scene {
return { return {
visited: route.visited.length, visited: route.visited.length,
total: route.waypoints.length, total: route.waypoints.length,
xp: Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0, // the contract's XP payout (the claim's second reward)
contacts: enc ? { cleared: (Array.isArray(enc.cleared) ? enc.cleared : []).length, total: enc.at.length } : null, contacts: enc ? { cleared: (Array.isArray(enc.cleared) ? enc.cleared : []).length, total: enc.at.length } : null,
}; };
} }
@ -5250,8 +5280,10 @@ export class GameScene extends Phaser.Scene {
const res = this.claimSideQuest(id); const res = this.claimSideQuest(id);
if (res?.ok) { if (res?.ok) {
const t = config.get('sidequests.ui.claimToast', {}); const t = config.get('sidequests.ui.claimToast', {});
const xp = Number.isFinite(res.xp) ? res.xp : 0; // the claim's second payout (the XP)
const tpl = xp > 0 ? (t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : (t.text ?? 'REWARD CLAIMED — +{reward} CR');
this.consoleToast( this.consoleToast(
String(t.text ?? 'REWARD CLAIMED — +{reward} CR').replace('{reward}', String(res.credits ?? 0)), String(tpl).replace('{reward}', String(res.credits ?? 0)).replace('{xp}', String(xp)),
{ {
glyph: t.glyph ?? '✓', glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)), glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),

View File

@ -1521,13 +1521,22 @@ export class SurfaceScene extends Phaser.Scene {
const now = claimedOf(g); const now = claimedOf(g);
if (!before && now) { if (!before && now) {
const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {}); const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {});
let text = String(t.text ?? 'REWARD CLAIMED');
if (isSide) { if (isSide) {
text = text.replace('{reward}', `+${g.sideQuestState?.routeFor?.(id)?.credits ?? 0} CR`); // The SIDE contract's payout — the route's credits + its XP
} else { // (the claim's second reward — the experience worth of the
const label = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.reward ?? ''; // patrol's defeated contacts, fixed at the accept).
if (label) text = text.replace('{reward}', String(label).toUpperCase()); const route = g.sideQuestState?.routeFor?.(id);
const xp = route && Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0;
const tpl = xp > 0 ? String(t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : String(t.text ?? 'REWARD CLAIMED');
this.consoleNote(
tpl.replace('{reward}', `+${route?.credits ?? 0} CR`).replace('{xp}', String(xp)),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
return;
} }
const label = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.reward ?? '';
let text = String(t.text ?? 'REWARD CLAIMED');
if (label) text = text.replace('{reward}', String(label).toUpperCase());
this.consoleNote(text, { this.consoleNote(text, {
glyph: t.glyph ?? '✓', glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : undefined, glyphColor: t.color ? toCss(t.color) : undefined,
@ -1610,8 +1619,10 @@ export class SurfaceScene extends Phaser.Scene {
const res = g.claimSideQuest(jobId); const res = g.claimSideQuest(jobId);
if (res?.ok) { if (res?.ok) {
const t = config.get('sidequests.ui.claimToast', {}); const t = config.get('sidequests.ui.claimToast', {});
const xp = Number.isFinite(res?.xp) ? res.xp : 0; // the claim's second payout (the XP)
const tpl = xp > 0 ? String(t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : String(t.text ?? 'REWARD CLAIMED — +{reward} CR');
this.consoleNote( this.consoleNote(
String(t.text ?? 'REWARD CLAIMED — +{reward} CR').replace('{reward}', String(res.credits ?? 0)), tpl.replace('{reward}', String(res.credits ?? 0)).replace('{xp}', String(xp)),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined }, { glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
); );
} else { } else {

View File

@ -801,8 +801,19 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
}) })
.setScrollFactor(0); .setScrollFactor(0);
this.add(body); this.add(body);
// The payout's XP (the claim's second reward — the experience worth
// of the patrol's defeated contacts, fixed on the route at the
// accept): it stands once the contract is TAKEN (the routeProgress
// seam carries the route's xp — a posting not yet accepted has no
// route, so the line holds credits-only until the sign).
const credits = sel.reward?.credits ?? 0;
const routeXp = prog && Number.isFinite(prog.xp) ? Math.max(0, prog.xp) : 0;
const payoutText =
routeXp > 0
? String(ui.payoutXp ?? 'PAYOUT — {reward} CR · +{xp} XP').replace('{reward}', String(credits)).replace('{xp}', String(routeXp))
: `PAYOUT — ${credits} ${String(ui.rewardUnit ?? 'CR')}`;
const payout = s const payout = s
.text(rightX + 14, py + ph - 46, `PAYOUT — ${sel.reward?.credits ?? 0} ${String(ui.rewardUnit ?? 'CR')}`, { .text(rightX + 14, py + ph - 46, payoutText, {
fontFamily: BODY, fontFamily: BODY,
fontSize: '10px', fontSize: '10px',
color: toCss(C.amber), color: toCss(C.amber),
@ -832,7 +843,10 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
label = String(ui.active ?? 'PATROL ACTIVE'); label = String(ui.active ?? 'PATROL ACTIVE');
color = this._activeColor(); color = this._activeColor();
} else if (state === 'complete') { } else if (state === 'complete') {
label = String(ui.claim ?? 'CLAIM REWARD — {reward} CR').replace('{reward}', String(sel.reward?.credits ?? 0)); label =
routeXp > 0
? String(ui.claimXp ?? 'CLAIM REWARD — {reward} CR · +{xp} XP').replace('{reward}', String(credits)).replace('{xp}', String(routeXp))
: String(ui.claim ?? 'CLAIM REWARD — {reward} CR').replace('{reward}', String(credits));
color = C.green; color = C.green;
action = 'claim'; action = 'claim';
} else { } else {

View File

@ -759,6 +759,14 @@ export class ShopView extends Phaser.GameObjects.Container {
_applyTrade(pv) { _applyTrade(pv) {
const ship = this.ship; const ship = this.ship;
// THE PROFIT XP (1 XP per 1 credit earned above each good's original
// purchase price) — computed from the per-bay cost ledger BEFORE the
// bays are removed (the FIFO basis remove() uses). A sale at/below cost,
// or a bay with no cost on file (the quest package; an old save), yields
// 0 — the grant below simply doesn't fire.
const profitXp = E.tradeProfitXp(pv.sell, (id, n) =>
(ship.cargo && typeof ship.cargo.costsFor === 'function') ? ship.cargo.costsFor(id, n) : []);
this._lastProfitXp = profitXp; // the sale's XP (the CONFIRMED line shows it)
if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal); if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal);
if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal); if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal);
for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first
@ -767,6 +775,12 @@ export class ShopView extends Phaser.GameObjects.Container {
// onto every bay added, so the ship console's manifest later compares // onto every bay added, so the ship console's manifest later compares
// the exit sell prices against the real purchase cost (not a guess). // the exit sell prices against the real purchase cost (not a guess).
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id), b.unit); for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id), b.unit);
// The sale's profit, paid out in XP — the SAME pipe as kills and sidequests
// (the level-up marquee + armor bump fire downstream; the 'trade' tag is
// the ledger's source). A 0-profit sale grants nothing.
if (profitXp > 0 && this.gameScene?.combat?.xp?.grant) {
this.gameScene.combat.xp.grant(Math.floor(profitXp), 'trade');
}
} }
_afterTrade(pv) { _afterTrade(pv) {
@ -797,7 +811,12 @@ export class ShopView extends Phaser.GameObjects.Container {
this.refreshHud(); this.refreshHud();
this._syncPreview(); this._syncPreview();
this._refreshTabs(); this._refreshTabs();
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr → hold ${pv.cargoAfter}/${this.capacity}`, false); // The CONFIRMED line — the credit delta, the hold, and (only when the
// sale made a profit) the XP that payout just earned (1 XP / credit
// above cost; a no-profit or buyback confirm shows no XP).
const xp = Math.max(0, Math.floor(this._lastProfitXp ?? 0));
const xpPart = xp > 0 ? ` · +${xp} XP` : '';
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr${xpPart} → hold ${pv.cargoAfter}/${this.capacity}`, false);
} }
_addSold(id, qty, price, name) { _addSold(id, qty, price, name) {
@ -951,7 +970,10 @@ export class ShopView extends Phaser.GameObjects.Container {
_setStatus(msg, bad) { _setStatus(msg, bad) {
// Best-effort status line (headless no-ops). The credits HUD count-up is // Best-effort status line (headless no-ops). The credits HUD count-up is
// the primary feedback; this is the in-panel confirmation. // the primary feedback; this is the in-panel confirmation. `this._lastStatus`
// always records the latest line (the tests read it; the text object is
// the on-screen surface).
this._lastStatus = msg;
if (this._statusText && typeof this._statusText.setText === 'function') { if (this._statusText && typeof this._statusText.setText === 'function') {
this._statusText.setText(msg); this._statusText.setText(msg);
if (typeof this._statusText.setColor === 'function') if (typeof this._statusText.setColor === 'function')