// screens_market.jsx : Market Screen
window.S = window.S || {};
(() => {
const { useState, useMemo } = React;
const { PORTS, RESOURCES } = window.D;
const L = window.L;
const A = window.E.A;
const { T, panelStyle, Bar, Pill, Btn, SectionTitle, EmptyState, TutorialPopup, BackButton,
IconAnchor,
IconFood, IconWater, IconRhum, IconSugar, IconTimber, IconCloth,
IconSpice, IconSilk, IconCoffee, IconCocoa, IconSpear, IconTobacco,
IconGoblet, IconPerson,TransferLayout,
} = window.UI;
const { shouldShowTutorial, markTutorialSeen } = window.L;
// Map good keys to their icon components (use IconSpear for weapons, IconPerson for slaves)
const GOOD_ICONS = {
food: IconFood,
water: IconWater,
rum: IconRhum,
sugar: IconSugar,
timber: IconTimber,
cloth: IconCloth,
spices: IconSpice,
silk: IconSilk,
coffee: IconCoffee,
cocoa: IconCocoa,
tobacco: IconTobacco,
weapons: IconSpear,
silver: IconGoblet,
slaves: IconPerson,
};
const getGoodIcon = (good) => {
const IconComponent = GOOD_ICONS[good];
if (!IconComponent) return null;
return React.createElement(IconComponent, { size: 14, color: T.textDim, style: { marginRight: 8 } });
};
// ── Sort helper: groups legal goods first, illegal goods last.
// Array.prototype.sort is stable in modern JS, so within each group
// the original colOrder is preserved.
const sortLegalFirst = (a, b) => {
const aIllegal = RESOURCES[a]?.illegal ? 1 : 0;
const bIllegal = RESOURCES[b]?.illegal ? 1 : 0;
return aIllegal - bIllegal;
};
const MarketScreen = ({ state, dispatch }) => {
const market = state.portMarket;
const portName = PORTS[state.currentPort]?.name || "Port";
const [buyPending, setBuyPending] = useState({});
const [sellPending, setSellPending] = useState({});
const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state,"market"));
if (!market) return (
);
const flavourLines = useMemo(
() => G.generateMarketFlavour(state, state.currentPort),
[state.currentPort]
);
const holdItems = state.hold?.items || {};
const capacity = L.getHoldCapacity(state) || 0;
const previewItems = { ...holdItems };
Object.entries(buyPending).forEach(([good, qty]) => { previewItems[good] = (previewItems[good] || 0) + (qty || 0); });
Object.entries(sellPending).forEach(([good, qty]) => { previewItems[good] = (previewItems[good] || 0) - (qty || 0); });
const used = L.getHoldUsed(previewItems);
const loadPct = L.getHoldLoadPct(previewItems, capacity);
const speedMult = L.getHoldSpeedMultiplier(loadPct);
// ── Gold delta: iterate from pending sets (not market.goods) ──
// After the generator fix, market.goods has all 14 goods with real prices,
// so selling a good the port doesn't stock now contributes correctly.
const goldDelta = (() => {
let total = 0;
Object.entries(sellPending).forEach(([good, qty]) => {
if (!qty || qty <= 0) return;
const pg = market.goods[good];
if (!pg) return;
total += qty * pg.sellToPort;
});
Object.entries(buyPending).forEach(([good, qty]) => {
if (!qty || qty <= 0) return;
const pg = market.goods[good];
if (!pg) return;
total -= qty * pg.buyFromPort;
});
return total;
})();
const hasPending = Object.values(buyPending).some(v => (v || 0) > 0) || Object.values(sellPending).some(v => (v || 0) > 0);
const confirmTrade = () => {
const buys = {}, sells = {};
Object.entries(buyPending).forEach(([g, qty]) => { if (qty > 0) buys[g] = qty; });
Object.entries(sellPending).forEach(([g, qty]) => { if (qty > 0) sells[g] = qty; });
dispatch({ type: A.CONFIRM_TRADE, buys, sells });
setBuyPending({}); setSellPending({});
};
const adjustBuy = (good, delta) => {
setBuyPending(prev => {
const cur = prev[good] || 0;
const newVal = Math.max(0, cur + delta);
const pg = market.goods[good];
if (!pg) return prev;
const freeSpace = capacity - used + (sellPending[good] || 0);
const max = Math.min(pg.available, freeSpace + cur);
return { ...prev, [good]: Math.min(newVal, max) };
});
};
const adjustSell = (good, delta) => {
setSellPending(prev => {
const cur = prev[good] || 0;
const newVal = Math.max(0, cur + delta);
const max = (holdItems[good] || 0) + (buyPending[good] || 0);
return { ...prev, [good]: Math.min(newVal, max) };
});
};
// ── Top summary: list of goods in pending trade ───────────────
const pendingBuyGoods = Object.keys(buyPending).filter(g => (buyPending[g]||0) > 0);
const pendingSellGoods = Object.keys(sellPending).filter(g => (sellPending[g]||0) > 0);
return (
{showTutorial && (
{
markTutorialSeen("market", disableAll);
setShowTutorial(false);
}}
>
Buy goods to trade at other ports for profit. A few things to know:
- Prices vary between ports. Buy where it's cheap, sell where it's rare
- Loading your hold above 50% slows your ship
- Goods marked illegal risk confiscation by patrol inspection at sea
Check the port gossips for hints about good deals to be made.
)}
<> MARKET — {portName}>
{flavourLines.length > 0 && (
{flavourLines.join(" ")}
)}
{/* ── Top summary panel ──────────────────────────────────── */}
Hold: {used} / {capacity}
{Math.round(loadPct * 100)}% full
0.75 ? T.redBr : T.greenBr} h={10} />
{speedMult > 1 && (
⚠ Hold over 50% : voyages take {Math.round((speedMult - 1) * 100)}% longer.
)}
{/* Pending trade summary */}
{(pendingBuyGoods.length > 0 || pendingSellGoods.length > 0) && (
{pendingBuyGoods.map(good => (
{getGoodIcon(good, { size: 12, style: { marginRight: 2 } })}
+{buyPending[good]} {window.D.RESOURCES[good]?.name || good}
))}
{pendingSellGoods.map(good => (
{getGoodIcon(good, { size: 12, style: { marginRight: 2 } })}
-{sellPending[good]} {window.D.RESOURCES[good]?.name || good}
))}
= 0 ? T.greenBr : T.redBr, fontSize: T.narrativeFontSize, fontWeight: "bold", marginLeft: 8 }}>
{goldDelta >= 0 ? "+" : ""}{goldDelta}g
)}
{/* Reset / Confirm */}
{ setBuyPending({}); setSellPending({}); }} disabled={!hasPending}>Reset
Confirm Trade
{/* ── Two‑column transfer layout ────────────────────────── */}
(holdItems[good]||0) > 0 || (sellPending[good]||0) > 0).length === 0 ? (
) : (
{Object.keys(holdItems)
.filter(good => (holdItems[good]||0) > 0 || (sellPending[good]||0) > 0)
.sort(sortLegalFirst)
.map(good => {
const pg = market.goods[good];
// After generator fix, pg always exists for any of the 14 goods.
// The fallback is defensive only.
const price = pg ? pg.sellToPort : 0;
const inHold = holdItems[good] || 0;
const pending = sellPending[good] || 0;
const max = inHold + (buyPending[good] || 0);
return (
{getGoodIcon(good, { size: 12, style: { marginRight: 4 } })}
{window.D.RESOURCES[good]?.name || good}
×{inHold}
adjustSell(good, -1)} disabled={(sellPending[good]||0) <= 0}>-
{
const v = parseInt(e.target.value, 10) || 0;
setSellPending(prev => ({ ...prev, [good]: Math.max(0, Math.min(v, max)) }));
}}
style={{ width:40, textAlign:"center", background:T.panel, border:`1px solid ${T.border}`, color:T.text, borderRadius:2, fontSize:11, fontFamily:T.font, minHeight: 32 }}
/>
adjustSell(good, 1)} disabled={(sellPending[good]||0) >= max}>+
{price}g
(Base: {window.D.RESOURCES[good]?.basePrice || 0}g)
);
})}
)
}
rightTitle="PORT MARKET"
rightContent={
Object.keys(market.goods).filter(g => market.goods[g].available > 0).length === 0 ? (
) : (
(() => {
let illegalDividerShown = false;
// Filter to only goods the port actually has stock of.
// After the generator fix, all 14 goods are in market.goods,
// but many have available: 0 (port doesn't trade them).
// Sort puts illegal goods last so the divider correctly separates them.
return Object.keys(market.goods)
.filter(good => market.goods[good].available > 0)
.sort(sortLegalFirst)
.map(good => {
const pg = market.goods[good];
const price = pg.buyFromPort;
const available = pg.available;
const pending = buyPending[good] || 0;
const freeSpace = capacity - used + (sellPending[good] || 0);
const max = Math.min(available, freeSpace + pending);
const isIllegal = RESOURCES[good]?.illegal;
const showDivider = isIllegal && !illegalDividerShown;
if (showDivider) illegalDividerShown = true;
return (
{showDivider && (
)}
{getGoodIcon(good, { size: 12, style: { marginRight: 4 } })}
{RESOURCES[good]?.name || good}
{isIllegal && (Illegal)}
×{available}
{/* buy controls unchanged */}
adjustBuy(good, -1)} disabled={(buyPending[good]||0) <= 0}>-
{
const v = parseInt(e.target.value, 10) || 0;
setBuyPending(prev => ({ ...prev, [good]: Math.max(0, Math.min(v, max)) }));
}}
style={{ width:40, textAlign:"center", background:T.panel, border:`1px solid ${T.border}`, color:T.text, borderRadius:2, fontSize:11, fontFamily:T.font, minHeight: 32 }}
/>
adjustBuy(good, 1)} disabled={(buyPending[good]||0) >= max}>+
{price}g
(Base: {pg.basePrice}g)
);
});
})()
)
}
/>
{/* ── Bottom confirm panel ───────────────────────────────── */}
= 0 ? T.greenBr : T.redBr, fontSize:13 }}>
{goldDelta >= 0 ? "+" : ""}{goldDelta}g
{ setBuyPending({}); setSellPending({}); }} disabled={!hasPending}>Reset
Confirm Trade
);
};
Object.assign(window.S, { MarketScreen });
})();