Record per-bay cargo purchase costs and use them as the profit baseline
- CargoHold gains a parallel `costs` ledger (credits/unit actually paid, or null when no cost is on file), kept aligned across add/remove/set/capacity/dropFirstNot and serialized via toJSON/fromJSON - ShopView stamps each bought bay with the trade's unit price so the console can compare exit prices against what was really paid - ShipScreenModel/ShipWindow surface per-bay costs, a total-cost hold meta line, and a "PAID" chip + reference line in the bay manifest; bays with no cost read "NO COST" instead of inventing one - GameScene passes the bay's recorded cost into scoutGood() so exit profits re-baseline against the purchase (falling back to the catalog value when absent), and restore/quest-handoff paths load null costs cleanly for legacy saves - Adds arid-shop-02 video asset and updates cargo/saves/ship-window/shop-ui tests to cover the new ledger
This commit is contained in:
parent
1c8287a354
commit
2e9b1ace5a
Binary file not shown.
|
|
@ -335,9 +335,11 @@
|
|||
}
|
||||
},
|
||||
"cargo": {
|
||||
"_comment": "The CARGO HOLDS tab. The hold is trade goods only \u2014 one good per bay, in fill order (js/economy/CargoHold.js); locked bays are the hold's upgrade room. MINERALS is the aggregate aboard vs capacity (the Mineral HUD's numbers \u2014 js/entities/Ship.js). A filled bay is tappable: it opens the good's summary + best exits (data below \u2192 detail).",
|
||||
"_comment": "The CARGO HOLDS tab. The hold is trade goods only \u2014 one good per bay, in fill order (js/economy/CargoHold.js); locked bays are the hold's upgrade room. Each bay also carries the PURCHASE COST the player actually paid for it (recorded in CargoHold when the shop trade lands; null where no cost is on file \u2014 the quest package's handoff): the hold meta prints the load's total cost, each bay tile its own, and the bay manifest prices its exits against it. MINERALS is the aggregate aboard vs capacity (the Mineral HUD's numbers \u2014 js/entities/Ship.js). A filled bay is tappable: it opens the good's summary + best exits (data below \u2192 detail).",
|
||||
"holdTitle": "TRADE BAYS",
|
||||
"holdMeta": "{used} / {capacity} BAYS \u00b7 {room} FREE",
|
||||
"costMeta": " \u00b7 {cost} CR PAID",
|
||||
"unknownCost": "NO COST",
|
||||
"emptyNote": "HOLD EMPTY \u2014 EVERY BAY IS CLEAN",
|
||||
"noBaysNote": "NO CARGO BAYS INSTALLED \u2014 UPGRADE THE HOLD",
|
||||
"lockedTag": "LOCKED",
|
||||
|
|
@ -348,8 +350,11 @@
|
|||
"emptyMineralsNote": "NO ORE ABOARD",
|
||||
"creditsLabel": "CREDITS",
|
||||
"detail": {
|
||||
"_comment": "The selected good's summary (the shop scout's report \u2014 js/economy/TradeScout.js scoutGood). `baseline` is the profit reference with no shop under it: baseValue = the catalog value (data/goods.json), buy = this system's own buy price.",
|
||||
"_comment": "The selected good's summary (the shop scout's report \u2014 js/economy/TradeScout.js scoutGood). THE PROFIT BASELINE: the bay's own PURCHASE COST when it is on file (the credits/unit the player actually paid \u2014 recorded in js/economy/CargoHold.js at the moment of purchase; paidLabel + costLine say so on the plate). A bay with no cost on file (the quest package's handoff, a save predating the record) falls back to `baseline`: baseValue = the catalog value (data/goods.json), buy = this system's own buy price \u2014 baselineLine says which.",
|
||||
"title": "BAY MANIFEST",
|
||||
"paidLabel": "PAID",
|
||||
"costLine": "PAID {cost} CR/UNIT \u2014 EXITS ARE PRICED AGAINST THAT PURCHASE",
|
||||
"baselineLine": "REF {ref} CR \u00b7 LOCAL BUY {buy}",
|
||||
"categoryLabel": "CLASS",
|
||||
"tierLabel": "T",
|
||||
"valueLabel": "BASE VALUE",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
* - CARGOHOLD (js/economy/CargoHold.js) — the ship's trade-goods hold as a
|
||||
* pure object: fixed bays (one good each), capacity = unlocked bays,
|
||||
* add/remove/count/clear, a capacity drop sheds the oldest goods, save/
|
||||
* load round-trips, and the invariant items.length ≤ capacity.
|
||||
* load round-trips, and the invariant items.length ≤ capacity — plus the
|
||||
* per-bay PURCHASE-COST ledger (parallel to the items: the credits/unit
|
||||
* actually paid at the shop, or null where a bay has no cost on file),
|
||||
* kept aligned by every mutation.
|
||||
* - TRADE PREVIEW (js/economy/Economy.js → tradePreview / resultingCargo)
|
||||
* — the shop UI's calculator: sell side priced at sellPrice, buy side at
|
||||
* buyPrice, net = sell − buy, the two caps (credits can't go negative,
|
||||
|
|
@ -108,6 +111,62 @@ console.log('== CARGOHOLD — the ship\u2019s trade-goods hold ==');
|
|||
assert.deepEqual(copy.items, ['q']);
|
||||
}
|
||||
|
||||
// --- the PURCHASE-COST ledger (parallel to items) ------------------------
|
||||
{
|
||||
const h = new CargoHold(4);
|
||||
assert.equal(h.costAt(0), null, 'an empty bay has no cost on file');
|
||||
assert.equal(h.totalCost(), 0, '… and nothing paid');
|
||||
h.add('frost-diamond', 30);
|
||||
h.add(['iron-ore', 'iron-ore'], 21);
|
||||
assert.deepEqual(h.costs, [30, 21, 21], 'add() stamps the paid unit cost onto every bay added');
|
||||
assert.equal(h.costAt(1), 21, 'costAt() reads the bay\u2019s recorded cost');
|
||||
assert.equal(h.totalCost(), 72, 'totalCost() = the credits actually paid');
|
||||
assert.equal(h.costBays, 3, 'costBays counts the recorded bays');
|
||||
assert.deepEqual(h.list(), [
|
||||
{ id: 'frost-diamond', cost: 30 },
|
||||
{ id: 'iron-ore', cost: 21 },
|
||||
{ id: 'iron-ore', cost: 21 },
|
||||
], 'list() pairs each bay with its cost');
|
||||
h.remove('iron-ore');
|
||||
assert.deepEqual(h.costs, [30, 21], 'remove() drops the sold bay\u2019s cost with the good');
|
||||
h.add('grain-crates');
|
||||
assert.deepEqual(h.costs, [30, 21, null], 'a bay added without a price reads a null cost (the package handoff)');
|
||||
assert.equal(h.totalCost(), 51, '… and null-cost bays count as 0 (never invented)');
|
||||
}
|
||||
|
||||
// --- the cost ledger follows the mutations -------------------------------
|
||||
{
|
||||
// a capacity drop sheds the bay\u2019s cost with its good
|
||||
const h = new CargoHold(4);
|
||||
h.add(['a', 'b', 'c', 'd'], 10);
|
||||
h.capacity = 2;
|
||||
assert.equal(h.used, 2);
|
||||
assert.deepEqual(h.costs, [10, 10], 'a shed bay takes its cost with it');
|
||||
// set() keeps the two arrays aligned (a save/load round-trip)
|
||||
const s = new CargoHold(3);
|
||||
s.set(['a', 'b', 'c'], [5, null]);
|
||||
assert.deepEqual(s.items, ['a', 'b', 'c']);
|
||||
assert.deepEqual(s.costs, [5, null, null], 'set() aligns costs to the kept bays (short → null)');
|
||||
const round = CargoHold.fromJSON(s.toJSON());
|
||||
assert.deepEqual(round.costs, [5, null, null], 'save/load round-trips the ledger');
|
||||
const legacy = CargoHold.fromJSON({ capacity: 3, items: ['a', 'b'] });
|
||||
assert.deepEqual(legacy.costs, [null, null], 'a save predating the ledger loads with no costs on file');
|
||||
// dropFirstNot — the package handoff makes room (the package outranks trade)
|
||||
const d = new CargoHold(2);
|
||||
d.add(['trade-a', 'trade-b'], 9);
|
||||
assert.equal(d.dropFirstNot('special-package'), 'trade-a', 'it sheds the first bay that isn\u2019t the package');
|
||||
assert.equal(d.dropFirstNot('special-package'), 'trade-b', '… then the next');
|
||||
assert.equal(d.dropFirstNot('special-package'), null, '… a hold of nothing else reads null');
|
||||
assert.deepEqual(d.costs, [], '… and the shed costs leave with the goods');
|
||||
d.add('special-package');
|
||||
assert.deepEqual(d.costs, [null], 'the package itself arrives with no cost on file');
|
||||
// a bad cost never leaks a NaN into the ledger
|
||||
const bad = new CargoHold(2);
|
||||
bad.add('x', 'not-a-number');
|
||||
bad.add('y', -5);
|
||||
assert.deepEqual(bad.costs, [null, null], 'non-finite / negative costs normalize to null');
|
||||
}
|
||||
|
||||
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone: OK');
|
||||
|
||||
console.log('\n== TRADE PREVIEW — the shop UI\u2019s calculator ==');
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ const system = galaxy.byId.get(galaxy.currentSystemId); // the starting system
|
|||
|
||||
const fakeScene = () => {
|
||||
const cargo = new CargoHold(4);
|
||||
cargo.add(['frost-diamond', 'iron-ore']);
|
||||
// … with the per-bay cost ledger (the shop's prices, stamped at purchase)
|
||||
cargo.add('frost-diamond', 30);
|
||||
cargo.add('iron-ore', 21);
|
||||
return {
|
||||
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
|
||||
galaxy,
|
||||
|
|
@ -228,6 +230,8 @@ const makeStorage = (fail = false) => {
|
|||
check('capture: the credit ledger is carried (ship.credits)', rec.ship.credits === 1250);
|
||||
check('capture: the trade hold is carried (ship.cargo, fill order)',
|
||||
JSON.stringify(rec.ship.cargo) === JSON.stringify(['frost-diamond', 'iron-ore']));
|
||||
check('capture: the per-bay cost ledger is carried (ship.cargoCosts — what was actually paid)',
|
||||
JSON.stringify(rec.ship.cargoCosts) === JSON.stringify([30, 21]));
|
||||
check('capture: the unlocked bays are carried (ship.cargoHold — the DONE beat is ship state)',
|
||||
rec.ship.cargoHold === 4);
|
||||
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
|
||||
|
|
@ -265,6 +269,8 @@ const makeStorage = (fail = false) => {
|
|||
check('prepare: the credit ledger rides along (ship.credits)', staged.ship.credits === 1250);
|
||||
check('prepare: the trade hold rides along (ship.cargo)',
|
||||
JSON.stringify(staged.ship.cargo) === JSON.stringify(['frost-diamond', 'iron-ore']));
|
||||
check('prepare: the cost ledger rides along (ship.cargoCosts)',
|
||||
JSON.stringify(staged.ship.cargoCosts) === JSON.stringify([30, 21]));
|
||||
check('prepare: the comms ledger is restored (a Set, same ids)',
|
||||
reg.get('commsPlayed') instanceof Set
|
||||
&& reg.get('commsPlayed').has('oac-intro')
|
||||
|
|
@ -291,6 +297,8 @@ const makeStorage = (fail = false) => {
|
|||
check('prepare: a legacy record (no credits/cargo) still loads, fields absent',
|
||||
regLegacy.get(PENDING_RESTORE_KEY).ship.credits === undefined
|
||||
&& regLegacy.get(PENDING_RESTORE_KEY).ship.cargo === undefined);
|
||||
check('prepare: a legacy record (no cost ledger) still loads, field absent',
|
||||
regLegacy.get(PENDING_RESTORE_KEY).ship.cargoCosts === undefined);
|
||||
check('prepare: a legacy record (no depletedClusters) stages an empty set',
|
||||
regLegacy.get('depletedClusters') instanceof Set && regLegacy.get('depletedClusters').size === 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@
|
|||
* - the journey's ledger is XpManager alone: over the REAL progression curve
|
||||
* the plate shows the 0→5 timeline below data classAt and CHOOSE YOUR
|
||||
* CLASS exactly at it (and a class on file wins over both);
|
||||
* - the hold summary matches CargoHold (one good per bay, fill order) +
|
||||
* economy.cargo.maxSlots, with minerals aggregate-only;
|
||||
* - TradeScout.scoutGood() prices a held good's exits so the bay manifest has
|
||||
* ranked data to draw (price + profit per hop, bestExit first).
|
||||
* - the hold summary matches CargoHold (one good per bay, fill order,
|
||||
* each bay with its recorded purchase cost — null where none is on
|
||||
* file) + economy.cargo.maxSlots, with minerals aggregate-only;
|
||||
* - TradeScout.scoutGood() prices a held good's exits so the bay manifest
|
||||
* has ranked data to draw (price + profit per hop, bestExit first) —
|
||||
* and a recorded purchase cost re-baselines those profits (exit price
|
||||
* vs what the player actually paid, not the catalog value).
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
|
|
@ -67,7 +70,10 @@ function snapshot(o = {}) {
|
|||
// and the grid drift apart, which is exactly what must not happen.
|
||||
const capacity = o.capacity ?? data.ship.stats.cargoHold;
|
||||
const hold = new CargoHold(capacity);
|
||||
if (o.items?.length) hold.add(o.items);
|
||||
if (o.items?.length) {
|
||||
if (o.costs) for (let i = 0; i < o.items.length; i++) hold.add(o.items[i], o.costs[i] ?? null);
|
||||
else hold.add(o.items);
|
||||
}
|
||||
// Armor maxima come from the combat system (data/combat.json → playerShip —
|
||||
// note ship.stats.shields is authored 0, so the plate MUST read combat's
|
||||
// numbers; GameScene folds combat.maxHull/maxShields into the snapshot).
|
||||
|
|
@ -84,7 +90,7 @@ function snapshot(o = {}) {
|
|||
stats: { ...data.ship.stats, cargoHold: capacity, ...(o.stats || {}) },
|
||||
minerals: o.minerals ?? 0,
|
||||
credits: o.credits ?? 12480,
|
||||
cargo: { used: hold.used, capacity: hold.capacity, items: hold.snapshot() },
|
||||
cargo: { used: hold.used, capacity: hold.capacity, items: hold.snapshot(), costs: [...hold.costs] },
|
||||
},
|
||||
level: o.level ?? null,
|
||||
playerClass: o.playerClass ?? null,
|
||||
|
|
@ -207,6 +213,10 @@ const byId = (rows, id) => rows.find((r) => r.id === id);
|
|||
const sum = M.cargoSummary({ ship: snapshot({ capacity: 4, items, minerals: 125 }).ship }, catalog);
|
||||
check(`the grid follows the hold's fill order (${sum.bays.map((b) => b.state).join(',')})`, sum.bays.map((b) => b.state).join(',') === 'filled,filled,filled,empty');
|
||||
check('duplicate goods occupy one bay each', sum.bays[0].goodId === 'fresh-produce' && sum.bays[2].goodId === 'fresh-produce');
|
||||
check('a hold with no cost ledger reads all-null costs (old saves load clean)', sum.bays.every((b) => b.cost === null) && sum.totalCost === 0 && sum.costBays === 0);
|
||||
const priced = M.cargoSummary({ ship: snapshot({ capacity: 4, items, costs: [30, 21, null], minerals: 125 }).ship }, catalog);
|
||||
check('each bay carries its recorded purchase cost (null where there is none)', priced.bays[0].cost === 30 && priced.bays[1].cost === 21 && priced.bays[2].cost === null);
|
||||
check('the cost basis sums only the recorded bays (nulls count as 0 — never invented)', priced.totalCost === 51 && priced.costBays === 2);
|
||||
check(`the locked upgrade room is reported up to economy.cargo.maxSlots (${sum.lockedBays})`, sum.lockedBays === config.get('economy.cargo.maxSlots', 16) - 4);
|
||||
check('names/icons come from data/goods.json', sum.bays[0].name === catalog.find((g) => g.id === items[0]).name && Number.isInteger(sum.bays[0].icon));
|
||||
check('used/room counts agree with the hold', sum.used === 3 && sum.room === 1 && sum.capacity === 4 && sum.empty === false);
|
||||
|
|
@ -248,6 +258,14 @@ const byId = (rows, id) => rows.find((r) => r.id === id);
|
|||
check(`topN caps the list (${rep.exits.length} ≤ ${CFG.cargo.detail.topN ?? 3})`, rep.exits.length <= (CFG.cargo.detail.topN ?? 3));
|
||||
check('rows carry what the manifest draws (price/hops/system)', rep.exits.every((e) => Number.isFinite(e.price) && Number.isFinite(e.hops) && !!e.systemName));
|
||||
|
||||
// THE COST BASIS (the manifest's whole point): a bay whose purchase cost is
|
||||
// ON FILE — the credits/unit the player actually paid, recorded by
|
||||
// CargoHold at the moment of purchase — re-baselines every exit profit.
|
||||
const paid = 37;
|
||||
const repPaid = scoutGood(goodId, { galaxy: g, systemId: sysId, buyUnit: paid, topN: CFG.cargo.detail.topN ?? 3 });
|
||||
check(`a recorded purchase cost re-baselines every exit profit (price − ${paid})`, repPaid.exits.length > 0 && repPaid.exits.every((e) => e.profit === e.price - paid));
|
||||
check('… the paid cost stands where the catalog value would (localBuy = paid)', repPaid.localBuy === paid);
|
||||
|
||||
// THE BASELINE (data: cargo.detail.baseline). This screen has no shop, so
|
||||
// data picks the reference the exits' profits are measured against — the
|
||||
// catalog value (goods.json → baseValue) or the local buy price. The panel
|
||||
|
|
|
|||
|
|
@ -578,6 +578,12 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
|
|||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||||
assert.equal(ship.credits, before - buyC * 2, 'credits fall by the spend');
|
||||
assert.deepEqual(ship.cargo.snapshot(), [C, C], '… and the two C fill the hold');
|
||||
// THE COST RECORD — the bays now carry the credits/unit actually paid
|
||||
// (the shop's own price): the ship console's bay manifest compares its
|
||||
// exit sell prices against this number, not a guess.
|
||||
assert.deepEqual(ship.cargo.costs, [buyC, buyC], '… each bought bay records the shop price it was bought at');
|
||||
assert.equal(ship.cargo.costAt(0), buyC, 'costAt() reads the bay\u2019s recorded cost');
|
||||
assert.equal(ship.cargo.totalCost(), buyC * 2, 'totalCost() = the credits actually spent');
|
||||
assert.equal(gs.refreshed, 1, '… and the HUD refreshes');
|
||||
}
|
||||
console.log('BUY: OK');
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@
|
|||
* stores ids, one per filled bay, in fill order. Duplicates are fine (two
|
||||
* frost-diamonds = two bays).
|
||||
*
|
||||
* The hold is the carrier, not a price ledger — pricing lives in
|
||||
* js/economy/Economy.js (buyPrice/sellPrice, per good + shop).
|
||||
* COST RECORD: alongside the ids the hold keeps a PARALLEL array of purchase
|
||||
* costs — the credits/unit the player ACTUALLY paid when each bay was bought
|
||||
* (js/ui/shop/ShopView.js → _applyTrade stamps the trade's unit price). It is
|
||||
* the per-bay cost BASIS (what the ship console's manifest compares the exit
|
||||
* sell prices against), not a price engine: sell/buy PRICING still lives in
|
||||
* js/economy/Economy.js. A bay with no recorded cost (the quest package's
|
||||
* handoff, a save predating the field) reads null — "no cost on file".
|
||||
*/
|
||||
export class CargoHold {
|
||||
/**
|
||||
|
|
@ -27,6 +32,20 @@ export class CargoHold {
|
|||
// Filled bays, in fill order. Each entry is a good id (string).
|
||||
// Invariant: items.length ≤ _capacity (enforced by add/set).
|
||||
this.items = [];
|
||||
// PARALLEL to items (index-aligned, same length): the purchase cost of
|
||||
// each bay in credits/unit — a non-negative integer, or null when the
|
||||
// cost isn't on file (the quest package's handoff; a save that predates
|
||||
// the field). Every mutation below keeps the two arrays aligned.
|
||||
this.costs = [];
|
||||
}
|
||||
|
||||
/** A bay's cost: a non-negative integer (credits/unit) or null (unknown).
|
||||
* null in → null out — "no cost on file" is a RECORDED absence, not a 0
|
||||
* price (Number(null) === 0 would blur the two). */
|
||||
static normalizeCost(v) {
|
||||
if (v == null) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
/** The number of unlocked bays (the hold's capacity). */
|
||||
|
|
@ -41,7 +60,10 @@ export class CargoHold {
|
|||
*/
|
||||
set capacity(n) {
|
||||
this._capacity = Math.max(0, Math.floor(Number(n) || 0));
|
||||
while (this.items.length > this._capacity) this.items.pop();
|
||||
while (this.items.length > this._capacity) {
|
||||
this.items.pop();
|
||||
this.costs.pop(); // the shed bay's cost goes with it
|
||||
}
|
||||
}
|
||||
|
||||
/** How many bays are filled (0 … capacity). */
|
||||
|
|
@ -72,15 +94,22 @@ export class CargoHold {
|
|||
/**
|
||||
* Load goods into the hold (buying, or a hand-off). `goodIds` is a good id
|
||||
* or an array of them (duplicates allowed — one per bay). Fills until the
|
||||
* hold is full or the list is exhausted. Returns the number actually added.
|
||||
* hold is full or the list is exhausted. `unitCost` (optional) is the
|
||||
* credits/unit the caller PAID for these units (the shop's trade price,
|
||||
* the buyback's sale price) and is stamped onto every bay added — the
|
||||
* per-bay cost the ship console's manifest prices its exits against. Omit
|
||||
* it (or pass null) for a bay with no cost on file (the quest package).
|
||||
* Returns the number actually added.
|
||||
*/
|
||||
add(goodIds) {
|
||||
add(goodIds, unitCost = null) {
|
||||
const ids = Array.isArray(goodIds) ? goodIds : [goodIds];
|
||||
const cost = CargoHold.normalizeCost(unitCost);
|
||||
let added = 0;
|
||||
for (const id of ids) {
|
||||
if (this.room <= 0) break;
|
||||
if (typeof id !== 'string' || !id) continue;
|
||||
this.items.push(id);
|
||||
this.costs.push(cost);
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
|
|
@ -96,6 +125,7 @@ export class CargoHold {
|
|||
for (let i = 0; i < this.items.length && removed < k; i++) {
|
||||
if (this.items[i] === goodId) {
|
||||
this.items.splice(i, 1);
|
||||
this.costs.splice(i, 1); // the sold bay's cost leaves with it
|
||||
i--;
|
||||
removed++;
|
||||
}
|
||||
|
|
@ -103,21 +133,40 @@ export class CargoHold {
|
|||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shed the first bay that is NOT `exceptId` (the quest package's handoff
|
||||
* makes room — the package outranks trade). The bay's cost leaves with it.
|
||||
* Returns the shed good's id, or null when there is nothing else aboard.
|
||||
*/
|
||||
dropFirstNot(exceptId) {
|
||||
const i = this.items.findIndex((x) => x !== exceptId);
|
||||
if (i < 0) return null;
|
||||
const id = this.items[i];
|
||||
this.items.splice(i, 1);
|
||||
this.costs.splice(i, 1);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Empty the hold entirely. */
|
||||
clear() {
|
||||
this.items.length = 0;
|
||||
this.costs.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the hold's contents wholesale (the SAVE/LOAD path). `items` is an
|
||||
* array of good ids; it is filtered to real ids and truncated to capacity
|
||||
* (a record can never overfill the hold). Returns the number kept.
|
||||
* (a record can never overfill the hold). `costs` (optional) is the
|
||||
* parallel purchase-cost array — missing/short entries read null (a save
|
||||
* predating the field loads with no costs on file). Returns the number kept.
|
||||
*/
|
||||
set(items) {
|
||||
set(items, costs = null) {
|
||||
const clean = (Array.isArray(items) ? items : []).filter(
|
||||
(id) => typeof id === 'string' && id,
|
||||
);
|
||||
const src = Array.isArray(costs) ? costs : clean.map(() => null);
|
||||
this.items = clean.slice(0, this._capacity);
|
||||
this.costs = this.items.map((_id, i) => CargoHold.normalizeCost(src[i]));
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
|
|
@ -126,18 +175,41 @@ export class CargoHold {
|
|||
return [...this.items];
|
||||
}
|
||||
|
||||
/** Serialize (SaveData.js → captureState → record.ship.cargo). */
|
||||
/** The purchase cost (credits/unit) recorded for bay `i` — null when the
|
||||
* bay is empty or its cost isn't on file. */
|
||||
costAt(i) {
|
||||
const c = this.costs[i];
|
||||
return c != null && Number.isFinite(c) ? c : null;
|
||||
}
|
||||
|
||||
/** The bays in fill order, each with its recorded cost (or null). */
|
||||
list() {
|
||||
return this.items.map((id, i) => ({ id, cost: this.costAt(i) }));
|
||||
}
|
||||
|
||||
/** The credits PAID for the bays aboard (null-cost bays count as 0). */
|
||||
totalCost() {
|
||||
return this.costs.reduce((s, c) => s + (c != null && Number.isFinite(c) ? c : 0), 0);
|
||||
}
|
||||
|
||||
/** How many bays aboard carry a recorded cost. */
|
||||
get costBays() {
|
||||
return this.costs.filter((c) => c != null && Number.isFinite(c)).length;
|
||||
}
|
||||
|
||||
/** Serialize (SaveData.js → captureState → record.ship.cargo/cargoCosts). */
|
||||
toJSON() {
|
||||
return { capacity: this._capacity, items: [...this.items] };
|
||||
return { capacity: this._capacity, items: [...this.items], costs: [...this.costs] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize (GameScene.applyRestore). Missing fields degrade to an empty
|
||||
* hold (a save predating cargo loads as an empty one).
|
||||
* hold (a save predating cargo loads as an empty one) and missing costs to
|
||||
* null (a save predating the cost record loads with no costs on file).
|
||||
*/
|
||||
static fromJSON(o) {
|
||||
const h = new CargoHold(o?.capacity ?? 0);
|
||||
h.set(o?.items ?? []);
|
||||
h.set(o?.items ?? [], o?.costs ?? null);
|
||||
return h;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@ export function captureState(scene, now) {
|
|||
// The trade-goods hold (data/economy.json → cargo) — the good ids in
|
||||
// the filled bays, in fill order (one good per bay).
|
||||
cargo: scene.ship.cargo ? [...scene.ship.cargo.items] : [],
|
||||
// The per-bay purchase-cost ledger (PARALLEL to cargo — the credits/unit
|
||||
// the player actually paid for each bay, null where a bay has no cost
|
||||
// on file: the quest package's handoff). The ship console's bay
|
||||
// manifest compares its exit sell prices against it. A save predating
|
||||
// the field lacks it; the restore treats the absence as "no costs on
|
||||
// file" (old saves load).
|
||||
cargoCosts: scene.ship.cargo ? [...scene.ship.cargo.costs] : null,
|
||||
// The UNLOCKED cargo bays (ship.stats.cargoHold) — the run's ship
|
||||
// starts with 0 (data/ship.json); the bar scene's DONE press builds
|
||||
// it out (GameScene.onVignetteComplete). It is SHIP state (like the
|
||||
|
|
|
|||
|
|
@ -1383,7 +1383,10 @@ export class GameScene extends Phaser.Scene {
|
|||
// exits (js/economy/TradeScout.js — the planet shop's own report).
|
||||
this.shipWindow = new ShipWindow(this, {
|
||||
getSnapshot: () => this.shipScreenSnapshot(),
|
||||
getScoutReport: (goodId) => this.shipScreenScoutReport(goodId),
|
||||
// The bay manifest's report is passed the bay's RECORDED purchase cost
|
||||
// (CargoHold.costs), so its exits compare against what the player
|
||||
// actually paid for that bay.
|
||||
getScoutReport: (goodId, cost) => this.shipScreenScoutReport(goodId, cost),
|
||||
// A BEST-EXIT row on the bay manifest was CONFIRMED (the console's own
|
||||
// SET DESTINATION dialog, js/ui/ConfirmOverlay.js): the exit's system +
|
||||
// its WORLD become the route target through the map's seam. The console
|
||||
|
|
@ -3737,10 +3740,11 @@ export class GameScene extends Phaser.Scene {
|
|||
if (!id) return false; // the quest data is gone — the handoff is off too
|
||||
if (ship.cargo.has(id)) return false; // already aboard — idempotent
|
||||
if (ship.cargo.room <= 0) {
|
||||
const i = ship.cargo.items.findIndex((x) => x !== id);
|
||||
if (i < 0) return false; // a hold of nothing but the package (it can't be)
|
||||
ship.cargo.items.splice(i, 1); // make room — the package outranks trade
|
||||
if (!ship.cargo.dropFirstNot(id)) return false; // a hold of nothing but the package (it can't be)
|
||||
}
|
||||
// The package is a handoff, not a purchase — it arrives with no cost on
|
||||
// file (add() stamps null), so its manifest prices exits against the
|
||||
// catalog's own value, never a guessed price.
|
||||
return ship.cargo.add(id) > 0;
|
||||
}
|
||||
|
||||
|
|
@ -5903,6 +5907,10 @@ export class GameScene extends Phaser.Scene {
|
|||
used: Math.max(0, Math.round(Number(s.cargo?.used) || 0)),
|
||||
capacity: Math.max(0, Math.round(Number(s.cargo?.capacity) || 0)),
|
||||
items: Array.isArray(s.cargo?.items) ? [...s.cargo.items] : [],
|
||||
// The per-bay purchase costs (parallel to items — the credits/unit
|
||||
// the player actually paid; null where a bay has no cost on file).
|
||||
// The console's bay manifest compares its exit prices against this.
|
||||
costs: Array.isArray(s.cargo?.costs) ? [...s.cargo.costs] : [],
|
||||
},
|
||||
},
|
||||
level: typeof c?.xp?.stats === 'function' ? c.xp.stats() : null,
|
||||
|
|
@ -5916,23 +5924,37 @@ export class GameScene extends Phaser.Scene {
|
|||
/**
|
||||
* A held good's report for the SHIP console's bay manifest — the SAME
|
||||
* scoutGood() the planet shop's scout panel renders (js/ui/shop/ScoutPanel.js),
|
||||
* over the run's chart (galaxy + current system + visitedSystems). There is
|
||||
* no shop under the ship, so the profit baseline is the catalog's own value
|
||||
* (data/goods.json → baseValue — data/shipScreen.json → cargo.detail.baseline
|
||||
* documents the choice); the exits themselves are priced exactly as the
|
||||
* shop prices them.
|
||||
* over the run's chart (galaxy + current system + visitedSystems).
|
||||
*
|
||||
* THE PROFIT BASELINE: the bay's own PURCHASE COST when it is on file
|
||||
* (`cost` — the credits/unit the player actually paid, recorded by
|
||||
* js/economy/CargoHold.js at the moment of purchase): the manifest exists
|
||||
* to show how each exit's sell price compares to what the player PAID.
|
||||
* A bay with no cost on file (the quest package's handoff, a save that
|
||||
* predates the cost record) falls back to the catalog's own value
|
||||
* (data/goods.json → baseValue — data/shipScreen.json →
|
||||
* cargo.detail.baseline documents the choice). The exits themselves are
|
||||
* priced exactly as the shop prices them.
|
||||
*/
|
||||
shipScreenScoutReport(goodId) {
|
||||
shipScreenScoutReport(goodId, cost = null) {
|
||||
if (!goodId || !this.galaxy) return null;
|
||||
const good = (config.get('goods.goods', []) ?? []).find((g) => g?.id === goodId) ?? null;
|
||||
const baseline = String(config.get('shipScreen.cargo.detail.baseline', 'baseValue'));
|
||||
const base = Math.max(0, Math.round(Number(good?.baseValue) || 0));
|
||||
// The bay's recorded purchase cost (credits/unit), or null (no cost on
|
||||
// file). A recorded cost is the reference — full stop; the data baseline
|
||||
// only stands in where the hold has nothing recorded. (null in → null
|
||||
// out: Number(null) === 0 would silently price against a 0-credit cost.)
|
||||
const paid =
|
||||
cost != null && Number.isFinite(Number(cost)) && Number(cost) >= 0
|
||||
? Math.max(0, Math.round(Number(cost)))
|
||||
: null;
|
||||
return scoutGood(goodId, {
|
||||
galaxy: this.galaxy,
|
||||
systemId: this.systemRecord?.id ?? null,
|
||||
// no local market → the catalog value is the reference (a non-finite
|
||||
// buyUnit would make TradeScout price against the current system)
|
||||
buyUnit: baseline === 'buy' ? undefined : base,
|
||||
buyUnit: paid ?? (baseline === 'buy' ? undefined : base),
|
||||
visited: this.visitedSystems,
|
||||
topN: Math.max(1, Math.floor(Number(config.get('shipScreen.cargo.detail.topN', 3)) || 3)),
|
||||
});
|
||||
|
|
@ -7320,7 +7342,16 @@ export class GameScene extends Phaser.Scene {
|
|||
// The trade-goods hold (data/economy.json → cargo) — a save predating
|
||||
// cargo has no field; set() degrades to an empty hold (old saves load).
|
||||
// Capacity is the restored stat above (0, 4, or an upgraded hold).
|
||||
if (this.ship.cargo) this.ship.cargo.set(r.ship.cargo ?? []);
|
||||
// cargoCosts is the parallel cost ledger (the credits/unit actually
|
||||
// paid per bay, null where no cost is on file) — a save predating the
|
||||
// record has no field, so every bay loads as "no cost on file" and
|
||||
// its manifest prices exits against the catalog's own value.
|
||||
if (this.ship.cargo) {
|
||||
this.ship.cargo.set(
|
||||
Array.isArray(r.ship.cargo) ? r.ship.cargo : [],
|
||||
Array.isArray(r.ship.cargoCosts) ? r.ship.cargoCosts : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The corner readouts (upper right) were built with the FRESH hold (0)
|
||||
// earlier in create() — push the restored values through them. No-op
|
||||
|
|
|
|||
|
|
@ -193,15 +193,24 @@ export function journeyState(stats, cfg = {}, playerClass = null) {
|
|||
|
||||
/**
|
||||
* The CARGO HOLDS summary (js/economy/CargoHold.js — one good per bay, in
|
||||
* fill order — plus the aggregate mineral load the Mineral HUD reads).
|
||||
* fill order, each with its RECORDED purchase cost (or null where the bay
|
||||
* has no cost on file) — plus the aggregate mineral load the Mineral HUD
|
||||
* reads).
|
||||
* @param {object} snap the console snapshot ({ ship: { stats, minerals,
|
||||
* cargo: { used, items }, credits } })
|
||||
* cargo: { used, items, costs }, credits } })
|
||||
* @param {Array<{id:string,name:string,icon?:number}>} [catalog] goods.json
|
||||
* (icon frame + display name; ShopView's texture is keyed off it)
|
||||
*/
|
||||
export function cargoSummary(snap = {}, catalog = []) {
|
||||
const hold = snap?.ship?.cargo ?? null;
|
||||
const items = Array.isArray(hold?.items) ? hold.items : [];
|
||||
// The per-bay purchase-cost ledger (parallel to items — the credits/unit
|
||||
// the player actually paid; null where a bay has no cost on file). The
|
||||
// bay manifest's exit prices are compared against this, so the numbers
|
||||
// here are the ones the console prints — never invented. (null in → null
|
||||
// out: Number(null) === 0 would count a missing cost as a 0-credit one.)
|
||||
const costs = Array.isArray(hold?.costs) ? hold.costs : [];
|
||||
const bayCost = (v) => (v != null && Number.isFinite(Number(v)) ? Math.max(0, Math.floor(Number(v))) : null);
|
||||
const capacity = Math.max(0, Math.round(num(snap?.ship?.stats?.cargoHold) ?? 0));
|
||||
const slots = Math.max(1, Math.round(num(config.get('economy.cargo.maxSlots', 16)) || 16));
|
||||
const used = Math.max(0, Math.min(capacity, Math.round(num(hold?.used) ?? items.length)));
|
||||
|
|
@ -218,6 +227,10 @@ export function cargoSummary(snap = {}, catalog = []) {
|
|||
name: g?.name ?? id ?? '',
|
||||
icon: Number.isFinite(Number(g?.icon)) ? Number(g.icon) : null,
|
||||
state: id ? 'filled' : i < capacity ? 'empty' : 'locked',
|
||||
// The bay's recorded purchase cost (credits/unit) — null when there
|
||||
// is none on file (the quest package's handoff, a save predating the
|
||||
// cost record); the manifest then falls back to the catalog value.
|
||||
cost: bayCost(costs[i]),
|
||||
};
|
||||
}),
|
||||
used,
|
||||
|
|
@ -235,6 +248,12 @@ export function cargoSummary(snap = {}, catalog = []) {
|
|||
? Math.min(1, Math.max(0, num(snap?.ship?.minerals) ?? 0) / num(snap?.ship?.stats?.mineralStorage))
|
||||
: 0,
|
||||
credits: Math.max(0, Math.round(num(snap?.ship?.credits) ?? 0)),
|
||||
// The load's cost BASIS — the credits actually paid for the bays aboard
|
||||
// (null-cost bays count as 0: the plate never invents a number) and how
|
||||
// many bays carry a recorded cost (the hold meta prints it only when at
|
||||
// least one bay does — an all-null hold stays quiet).
|
||||
totalCost: items.reduce((s, _id, i) => s + (bayCost(costs[i]) ?? 0), 0),
|
||||
costBays: items.filter((_id, i) => bayCost(costs[i]) != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,10 +36,13 @@
|
|||
* SKILL TREE a STANDBY socket until the class layer lands — press it and
|
||||
* the tab shakes, the scene toasts (the locked plate stays
|
||||
* reachable so the console's final shape is visible).
|
||||
* CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay)
|
||||
* + the aggregate mineral load; a filled bay opens its manifest
|
||||
* with the charted exits (js/economy/TradeScout.js), the same
|
||||
* report the planet shop's scout panel renders. A row is
|
||||
* CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay,
|
||||
* each with the purchase cost the player actually paid) + the
|
||||
* aggregate mineral load; a filled bay opens its manifest with
|
||||
* the charted exits (js/economy/TradeScout.js), the same report
|
||||
* the planet shop's scout panel renders — its exit prices are
|
||||
* compared against the bay's RECORDED cost (the catalog's own
|
||||
* value only where no cost is on file). A row is
|
||||
* TAPPABLE: it asks (the map console's ConfirmOverlay, hosted by
|
||||
* this window so it always paints above the console) and CONFIRM
|
||||
* sets that exit's world as the destination through the scene's
|
||||
|
|
@ -1117,10 +1120,17 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
const w = plateW - 36;
|
||||
|
||||
// ---- TRADE BAYS ---------------------------------------------------
|
||||
const meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
|
||||
let meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
|
||||
.replace('{used}', sum.used)
|
||||
.replace('{capacity}', sum.capacity)
|
||||
.replace('{room}', sum.room);
|
||||
// The load's cost basis — the credits the player actually PAID for the
|
||||
// bays aboard (CargoHold's cost ledger; null-cost bays count as 0). The
|
||||
// plate prints it only when at least one bay carries a recorded cost —
|
||||
// an all-unknown hold (the quest package) stays quiet, never invented.
|
||||
if (sum.costBays > 0) {
|
||||
meta += String(cfg.costMeta ?? ' · {cost} CR PAID').replace('{cost}', sum.totalCost);
|
||||
}
|
||||
this.head(x, plateY + 20, w, cfg.holdTitle ?? 'TRADE BAYS', meta, C.neon);
|
||||
|
||||
// the grid: filled bays first, then empty, then the LOCKED room left by
|
||||
|
|
@ -1213,6 +1223,16 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
this.icon(bx + 26, by + tileH / 2 + 6, b.goodId, 32);
|
||||
const name = this.txt(bx + 46, by + tileH / 2 - 1, b.name, { color: C.ink, size: 9.5, ls: 0.6, wrap: Math.floor((tileW - 56) / 5.6) });
|
||||
name.setOrigin(0, 0.5);
|
||||
// The bay's purchase cost (CargoHold's cost ledger — the credits/unit
|
||||
// the player actually paid for it). The manifest prices its exits
|
||||
// against this number. A bay with no cost on file (the quest package's
|
||||
// handoff) reads NO COST — never a fabricated price.
|
||||
this.txt(
|
||||
bx + 7,
|
||||
by + tileH - 9,
|
||||
b.cost != null ? `${b.cost} CR` : String(cfg.unknownCost ?? 'NO COST'),
|
||||
{ color: b.cost != null ? C.amber : C.faint, size: 7, ls: 1.2 },
|
||||
);
|
||||
this.txt(bx + tileW - 7, by + tileH - 9, String(cfg.manifestTag ?? 'MANIFEST ▸'), { color: C.neon, size: 7, ls: 1.2, origin: [1, 0] });
|
||||
} else if (locked) {
|
||||
g.lineStyle(1, C.faint, 0.4);
|
||||
|
|
@ -1250,13 +1270,18 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
/** A held good's manifest — the product summary + the charted exits (the
|
||||
* same scoutGood() report the planet shop's scout panel renders). Each row
|
||||
* is a tap surface: it asks SET DESTINATION through _askSetDestination. */
|
||||
* same scoutGood() report the planet shop's scout panel renders), with the
|
||||
* exit prices compared against the bay's RECORDED purchase cost — what the
|
||||
* player actually paid (the catalog's own value only where no cost is on
|
||||
* file). Each row is a tap surface: it asks SET DESTINATION through
|
||||
* _askSetDestination. */
|
||||
_paintManifest(bay, cfg, snap) {
|
||||
const { plateX, plateY, plateW, plateH } = this.geo;
|
||||
const x = plateX + 18;
|
||||
const w = plateW - 36;
|
||||
const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId)) : null;
|
||||
// bay.cost is the hold's recorded purchase cost (or null — no cost on
|
||||
// file); the scene's seam prices the exits against it.
|
||||
const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId, bay.cost)) : null;
|
||||
|
||||
this.head(x, plateY + 20, w, cfg.title ?? 'BAY MANIFEST', `BAY ${String(bay.bay + 1).padStart(2, '0')}`, C.neon);
|
||||
// the good itself
|
||||
|
|
@ -1265,6 +1290,10 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
this.icon(x + 46, plateY + 83, bay.goodId, 56);
|
||||
this.txt(x + 84, plateY + 52, String(rep?.name ?? bay.name), { color: C.ink, size: 17, head: true, bold: true });
|
||||
const chips = [
|
||||
// THE COST BASIS — the credits/unit the player actually paid for this
|
||||
// bay (recorded in CargoHold at purchase). It leads: the exits below
|
||||
// are all measured against it.
|
||||
bay.cost != null ? `${String(cfg.paidLabel ?? 'PAID').toUpperCase()} ${bay.cost} CR` : '',
|
||||
rep?.category ? `${String(cfg.categoryLabel ?? 'CLASS').toUpperCase()} ${String(rep.category).toUpperCase()}` : '',
|
||||
rep?.tier != null ? `${String(cfg.tierLabel ?? 'T')}${rep.tier}` : '',
|
||||
rep?.baseValue != null ? `${String(cfg.valueLabel ?? 'BASE VALUE')} ${rep.baseValue}` : '',
|
||||
|
|
@ -1399,11 +1428,18 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
this.sfx('ui_click');
|
||||
this._buildBody(this._snap);
|
||||
});
|
||||
// the local reference line (what it cost / would pay HERE)
|
||||
// the reference line — what the exit profits are measured against. A
|
||||
// bay with a RECORDED purchase cost says exactly that: the exits are
|
||||
// priced against what the player PAID. A bay with no cost on file falls
|
||||
// back to the documented catalog reference (data → detail.baseline).
|
||||
if (rep) {
|
||||
const baseKey = cfg.baseline === 'buy' ? 'localBuy' : 'baseValue';
|
||||
const base = rep[baseKey] ?? rep.baseValue ?? 0;
|
||||
this.txt(x, plateY + plateH - 24, `REF ${base} · LOCAL BUY ${rep.localBuy ?? '—'}${rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : ''}`, {
|
||||
const ref =
|
||||
bay.cost != null
|
||||
? String(cfg.costLine ?? 'PAID {cost} CR/UNIT — EXITS ARE PRICED AGAINST THAT PURCHASE').replace('{cost}', bay.cost)
|
||||
: String(cfg.baselineLine ?? 'REF {ref} CR · LOCAL BUY {buy}')
|
||||
.replace('{ref}', rep.localBuy ?? rep.baseValue ?? '—')
|
||||
.replace('{buy}', rep.localBuy ?? '—') + (rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : '');
|
||||
this.txt(x, plateY + plateH - 24, ref, {
|
||||
color: C.faint, size: 9, ls: 1.8,
|
||||
});
|
||||
}
|
||||
|
|
@ -1684,6 +1720,9 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
rows.map((r) => `${r.id}=${r.text}`).join(','),
|
||||
j ? `${j.mode}:${j.level}:${j.xp}:${Math.round(j.step * 100)}:${j.className ?? '-'}` : 'none',
|
||||
c.used,
|
||||
// the hold's cost ledger — a buy/sell at the same bays (a re-bought
|
||||
// unit, a sale) must repaint the grid + manifest the same poll
|
||||
c.totalCost,
|
||||
sum(c.bays.filter((b) => b.goodId).map((b) => (b.goodId === null ? '' : String(b.goodId).length))),
|
||||
c.minerals,
|
||||
c.credits,
|
||||
|
|
|
|||
|
|
@ -762,7 +762,11 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
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
|
||||
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id));
|
||||
// THE COST RECORD — b.unit is what the player ACTUALLY paid for these
|
||||
// units (the shop's buy price, or the sale price on a buyback): stamp it
|
||||
// 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);
|
||||
}
|
||||
|
||||
_afterTrade(pv) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue