diff --git a/data/sidequests.json b/data/sidequests.json index 2562fdc..92e02d0 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -33,7 +33,7 @@ } }, "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, "patrol": [ 40, @@ -71,7 +71,7 @@ "neighborMinD": 0.4, "neighborChance": 0.5, "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', , 'patrol', , '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', , 'patrol', , '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, "counts": [ [1, 0.2], @@ -254,12 +254,14 @@ "statusLive": "{n} CONTRACTS ON FILE", "rewardUnit": "CR", "payout": "PAYOUT — {reward} CR", + "payoutXp": "PAYOUT — {reward} CR · +{xp} XP", "accept": "ACCEPT CONTRACT", "accepted": "CONTRACT ACCEPTED", "active": "PATROL ACTIVE", "activate": "SET PATROL ACTIVE", "complete": "CONTRACT COMPLETE", "claim": "CLAIM REWARD — {reward} CR", + "claimXp": "CLAIM REWARD — {reward} CR · +{xp} XP", "claimed": "REWARD CLAIMED", "destination": "DESTINATION — {name}", "routeProgress": "ROUTE — {visited}/{total} WAYPOINTS LOGGED", @@ -292,7 +294,8 @@ "claimToast": { "glyph": "✓", "color": "#67e863", - "text": "REWARD CLAIMED — +{reward} CR" + "text": "REWARD CLAIMED — +{reward} CR", + "textXp": "REWARD CLAIMED — +{reward} CR · +{xp} XP" }, "claimDenyToast": { "glyph": "✗", diff --git a/dev/cargo.test.mjs b/dev/cargo.test.mjs index 853a354..5da1df0 100644 --- a/dev/cargo.test.mjs +++ b/dev/cargo.test.mjs @@ -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'); } -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 =='); @@ -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('\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'); diff --git a/dev/jobboard.test.mjs b/dev/jobboard.test.mjs index 9249de7..1c7286e 100644 --- a/dev/jobboard.test.mjs +++ b/dev/jobboard.test.mjs @@ -248,7 +248,7 @@ console.log('== LIVE — the postings stand, the selection reads, the door walks const accepts = []; const activates = []; const claims = []; - const routeState = { visited: 0, total: 3 }; + const routeState = { visited: 0, total: 3, xp: 0 }; const mkPost = (cat, title, n, extra = {}) => ({ id: `sq-test-${cat}-${n}`, systemId: 'sys-free', @@ -280,7 +280,7 @@ console.log('== LIVE — the postings stand, the selection reads, the door walks isCompleted: (id) => completed.has(id), isActive: (id) => active.has(id), isClaimed: (id) => claimed.has(id), - routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total } : 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), onActivate: (id) => activates.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). active.delete(posts[0].id); // the done contract drops out of the active slot completed.add(posts[0].id); + routeState.xp = 60; // the route’s XP payout (the accept’s fix) stands on the seam win.refresh(); assert.ok( win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)), 'the completed row shows CONTRACT COMPLETE'); + assert.ok( + win.detailSection.content.some((t) => t.text && /PAYOUT — 50 CR · \+60 XP/.test(t.text)), + 'the payout line names the XP payout (the route’s fix, beside the credits)'); dTxt = win.detailSection.content[win.detailSection.content.length - 1]; - assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR', '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]; assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)'); dG.__on.pointerdown(); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 39b4970..d394448 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -559,6 +559,77 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold } 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 =='); { diff --git a/dev/sidequests.test.mjs b/dev/sidequests.test.mjs index 7940e66..c9d014b 100644 --- a/dev/sidequests.test.mjs +++ b/dev/sidequests.test.mjs @@ -492,6 +492,31 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac 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) =='); { @@ -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.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 postings themselves regenerate from the seed: a save = seed + // 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); 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.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.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.systemRecord = REC_NEUTRAL; 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'); sceneC.sideQuestState.accept(jobC.id); // 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(); 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.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); 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; 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.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 // outrun): waypoints all logged, the contact’s wave fired but NOT diff --git a/js/combat/XpManager.js b/js/combat/XpManager.js index 413254a..929175f 100644 --- a/js/combat/XpManager.js +++ b/js/combat/XpManager.js @@ -114,9 +114,11 @@ export class XpManager { * 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 * fires 'grant' last, with the settled state. - * @param {number} amount the XP earned (a hostile's type `xp`) - * @param {string} [source] the ledger's source tag ('kill' for now; - * quests/dev probes flow through the same pipe later) + * @param {number} amount the XP earned (a hostile's type `xp`, a sidequest's + * payout, a cargo sale's profit — whatever the caller banks) + * @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}} */ grant(amount, source = 'kill') { diff --git a/js/economy/CargoHold.js b/js/economy/CargoHold.js index b2f4767..91539ff 100644 --- a/js/economy/CargoHold.js +++ b/js/economy/CargoHold.js @@ -182,6 +182,28 @@ export class CargoHold { 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). */ list() { return this.items.map((id, i) => ({ id, cost: this.costAt(i) })); diff --git a/js/economy/Economy.js b/js/economy/Economy.js index f2e6a2a..baf2397 100644 --- a/js/economy/Economy.js +++ b/js/economy/Economy.js @@ -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} 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) // --------------------------------------------------------------------------- diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index 7aa098c..98b98b7 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -65,10 +65,11 @@ * - LEDGER — SideQuestState carries the accepted order, the ONE * active contract (reconcileActive: the top accepted contract * stands active until it is done and claimed), the route record - * (waypoints + visited), and the claimed marks. The scene's - * updatePatrol checks the ship in (visitRadius, any order), - * completes the contract, and pays the reward exactly once on - * claim. + * (waypoints + visited + the contract's payout — credits AND the + * XP reward, patrolEncounterXp), and the claimed marks. The + * scene's updatePatrol checks the ship in (visitRadius, any + * 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") — * postings derive from the galaxy seed + the system record through @@ -91,7 +92,7 @@ import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.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). */ export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort']; @@ -667,6 +668,46 @@ export function patrolEncounterScript(encounters) { 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 * 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.claimed = new Set(); // claimed ⊆ completed 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, ), )]; + const encounters = normalizePatrolEncounters(route.encounters, waypoints.length); this.routes.set(id, { 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, visited, - encounters: normalizePatrolEncounters(route.encounters, waypoints.length), + encounters, }); return true; } @@ -1049,8 +1097,8 @@ export class SideQuestState { /** * A contract's route record (the scene's check-ins + the board's route - * line read it). - * @returns {{targetSystemId:string, credits:number, waypoints:number[][], visited:number[]}|null} + * line + the claim's payout read it). + * @returns {{targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object}|null} */ routeFor(id) { return this.routes.get(id) ?? null; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 8420260..fa9f72a 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -63,7 +63,7 @@ import { ShipWindow } from '../ui/ShipWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; import { QuestTrackerHud } from '../ui/QuestTrackerHud.js'; import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; -import { SideQuestState, sideQuestPostingsFor, 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 { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.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.credits : (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 = waypoints.length > 0 && visited.length === waypoints.length && (!enc || enc.at.every((i) => clearedSet.has(i))); @@ -4507,7 +4512,7 @@ export class GameScene extends Phaser.Scene { icon: 'diamond', active: st.isActive(id), 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 // derived route state stands in for it in the transient frame between // 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 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); + // 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); } @@ -4961,19 +4973,27 @@ export class GameScene extends Phaser.Scene { * precedent, the side-quest shape): the contract taken + COMPLETE, paid * 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 - * route record's credits (fixed at the accept); a category without a - * route (the stubs, when they land) falls back to the posting's reward - * (regenerated from the seed — the contract stands). The scene owns - * the effect (the credits land); the ledger owns the record. - * @returns {{ok:boolean, credits?:number}} + * route record's credits (fixed at the accept) + its XP (the + * experience worth of the patrol's defeated contacts — fixed there + * too); a category without a route (the stubs, when they land) falls + * back to the posting's reward (regenerated from the seed — the + * 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) { const st = this.sideQuestState; 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 let credits = 0; + let xp = 0; 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) { // A category without a route: the posting's reward (the seed's // contract stands — sq---). @@ -4989,18 +5009,27 @@ export class GameScene extends Phaser.Scene { this.ship.addCredits(credits); 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 - return { ok: true, credits }; + return { ok: true, credits, xp }; } /** * The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS - * LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED"): the - * accepted patrol's checkpoint + contact counts. Null when the + * LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED" + 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 * stood; `contacts` null when the route carries no encounters (old * 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) { const route = this.sideQuestState?.routeFor(jobId); @@ -5009,6 +5038,7 @@ export class GameScene extends Phaser.Scene { return { visited: route.visited.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, }; } @@ -5250,8 +5280,10 @@ export class GameScene extends Phaser.Scene { const res = this.claimSideQuest(id); if (res?.ok) { 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( - 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 ?? '✓', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)), diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 2d94925..8a9175e 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -1521,13 +1521,22 @@ export class SurfaceScene extends Phaser.Scene { const now = claimedOf(g); if (!before && now) { const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {}); - let text = String(t.text ?? 'REWARD CLAIMED'); if (isSide) { - text = text.replace('{reward}', `+${g.sideQuestState?.routeFor?.(id)?.credits ?? 0} CR`); - } else { - const label = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.reward ?? ''; - if (label) text = text.replace('{reward}', String(label).toUpperCase()); + // The SIDE contract's payout — the route's credits + its XP + // (the claim's second reward — the experience worth of the + // patrol's defeated contacts, fixed at the accept). + 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, { glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined, @@ -1610,8 +1619,10 @@ export class SurfaceScene extends Phaser.Scene { const res = g.claimSideQuest(jobId); if (res?.ok) { 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( - 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 }, ); } else { diff --git a/js/ui/JobBoardWindow.js b/js/ui/JobBoardWindow.js index 6aa733a..8ec0a3a 100644 --- a/js/ui/JobBoardWindow.js +++ b/js/ui/JobBoardWindow.js @@ -801,8 +801,19 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { }) .setScrollFactor(0); 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 - .text(rightX + 14, py + ph - 46, `PAYOUT — ${sel.reward?.credits ?? 0} ${String(ui.rewardUnit ?? 'CR')}`, { + .text(rightX + 14, py + ph - 46, payoutText, { fontFamily: BODY, fontSize: '10px', color: toCss(C.amber), @@ -832,7 +843,10 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { label = String(ui.active ?? 'PATROL ACTIVE'); color = this._activeColor(); } 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; action = 'claim'; } else { diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index 3a62f0c..a95ee11 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -759,6 +759,14 @@ export class ShopView extends Phaser.GameObjects.Container { _applyTrade(pv) { 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.buyTotal > 0) ship.spendCredits(pv.buyTotal); 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 // 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); + // 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) { @@ -797,7 +811,12 @@ export class ShopView extends Phaser.GameObjects.Container { this.refreshHud(); this._syncPreview(); 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) { @@ -951,7 +970,10 @@ export class ShopView extends Phaser.GameObjects.Container { _setStatus(msg, bad) { // 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') { this._statusText.setText(msg); if (typeof this._statusText.setColor === 'function')