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:
parent
4595950fb9
commit
658babe216
|
|
@ -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": "✗",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 ==');
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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) }));
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue