// screens_combat.jsx — Combat & resolution screens
// Event, Intercept, Battle, Plunder — the screens where the world acts on you
// and you must resolve the current situation before navigating freely.
//
// Depends on: window.D, window.L, window.E, window.UI, window.S
// Exposes: EventScreen, InterceptScreen, BattleScreen, PlunderScreen on window.S
//
// Loaded after screens_voyage.jsx so it can pick up MapScreen/SailingScreen
// dependencies if needed (currently none — fully self-contained).
window.S = window.S || {};
(() => {
const { useState, useRef, useEffect, useMemo } = React;
const { PORTS, SHIPS, FACTIONS } = window.D;
const L = window.L;
const A = window.E.A;
const {
T, panelStyle, Bar, Pill, Btn, SectionTitle, EmptyState,
TutorialPopup, BackButton, Panel,
IconSailboat, IconAnchor, IconSwords, IconCannon, IconTarget,
IconGrapple, IconWind, IconSkull,
getGoodIcon, useFlashOnChange,
TransferLayout, ShipSideSprite,
FactionPill, ShipSprite,
} = window.UI;
const { shouldShowTutorial, markTutorialSeen } = window.L;
// Import distance-based action lookup
const { LEGAL_ACTIONS_BY_DISTANCE } = window.D;
// ── Action preview helper ──────────────────────────────────────────────
function getActionPreview(state, action, distance, enemy, battle = null) {
const shipStats = L.getShipStats(state);
const cannons = shipStats.cannons;
const mult = window.D.DISTANCE_DAMAGE_MULTIPLIERS[action]?.[distance] || 1.0;
const hullDmgPct = L.getEquipmentEffect(state, "hullDmgPct") || 0;
const crewDmgPct = L.getEquipmentEffect(state, "crewDmgPct") || 0;
// Broadside: cannons * (0.8–1.2) * mult
if (action === "broadside") {
const baseMin = cannons * 0.8 * mult;
const baseMax = cannons * 1.2 * mult;
const hullMin = Math.max(1, Math.floor(baseMin * 0.6 * (1 + hullDmgPct)));
const hullMax = Math.max(1, Math.floor(baseMax * 0.6 * (1 + hullDmgPct)));
const crewMin = Math.floor(baseMin * 0.4 / 3 * (1 + crewDmgPct));
const crewMax = Math.floor(baseMax * 0.4 / 3 * (1 + crewDmgPct));
return {
description: "Full cannon volley. Reliable damage.",
hullRange: [hullMin, hullMax],
crewRange: [crewMin, crewMax],
hitChance: 1.0,
};
}
// Precision: cannons * (1.2–1.8) * mult (fixed)
if (action === "precision") {
const hitChance = Math.min(1, 0.7 + (L.getEquipmentEffect(state, "precisionHitPct") || 0));
const baseMin = cannons * 1.2 * mult;
const baseMax = cannons * 1.8 * mult;
const hullMin = Math.max(1, Math.floor(baseMin * 0.9 * (1 + hullDmgPct)));
const hullMax = Math.max(1, Math.floor(baseMax * 0.9 * (1 + hullDmgPct)));
const crewMin = Math.floor(baseMin * 0.1 / 3 * (1 + crewDmgPct));
const crewMax = Math.floor(baseMax * 0.1 / 3 * (1 + crewDmgPct));
return {
description: "Aimed shot. High damage if it hits.",
hullRange: [hullMin, hullMax],
crewRange: [crewMin, crewMax],
hitChance,
};
}
// Continue Fighting – deterministic (fixed)
if (action === "continue_fighting" && battle) {
const ratio = L.getBoardingRatio(state, battle, enemy);
const crew = battle.playerCrew;
const enemyCrew = battle.enemyCrew;
const playerLoss = Math.ceil(crew * 0.15 * (1 - ratio));
const enemyLoss = Math.ceil(enemyCrew * 0.15 * ratio);
return {
description: "Press the attack in boarding.",
crewLossPlayer: playerLoss,
crewLossEnemy: enemyLoss,
advantage: Math.round(ratio * 100),
hitChance: null,
};
}
// All other actions (static descriptions)
const staticDescriptions = {
grapple: "Board the enemy ship. Requires Close range.",
evade: "Attempt to flee. Speed check.",
close_distance: "Move closer to the enemy.",
open_distance: "Move further away.",
fall_back: "Return to naval combat. Costs crew.",
demand_surrender: "Force them to yield (requires advantage).",
surrender: "Yield to the enemy.",
};
return {
description: staticDescriptions[action] || "",
hullRange: null,
crewRange: null,
hitChance: null,
};
}
// ── Detect if the player's action missed or failed ──────────────────
const MISS_PHRASES = [
"splashes harmlessly",
"goes wide",
"overcorrect and miss",
"flies past the enemy",
"Your grapple fails",
"repels your boarders",
"thrown back",
];
const isPlayerMissOrFail = (text) => {
if (!text) return false;
return MISS_PHRASES.some(phrase => text.includes(phrase));
};
// Equipment that has visual representation on the ship sprite
const VISUAL_EQUIPMENT = ["war_pennants", "extra_sails", "lateen_rig"];
const getVisualEquipment = (state) => {
const allEquipped = [
...(state.ship.equipment?.hull || []),
...(state.ship.equipment?.armament || []),
...(state.ship.equipment?.rigging || []),
...(state.ship.equipment?.special || []),
];
return allEquipped.filter(key => VISUAL_EQUIPMENT.includes(key));
};
// ── EVENT SCREEN ─────────────────────────────────────────────────────
function EventScreen({ state, dispatch }) {
const ev = state.activeEvent;
if (!ev) return null;
const typeColor = {
hazard: T.redBr,
choice: T.gold,
reward: T.greenBr,
crew: T.blueBr,
faction: T.purpleBr,
};
return (
{/* ── SVG Illustration ── */}
{ev.svg && (

{
// Hide the container if the SVG fails to load
e.currentTarget.parentElement.style.display = "none";
}}
/>
)}
{/* ── Existing Event UI ── */}
{ev.title}
{ev.desc}
{ev.choices.map((c, i) => (
dispatch({ type: A.RESOLVE_EVENT, choiceIndex: i })}
onMouseEnter={e => e.currentTarget.style.borderColor = T.borderBr}
onMouseLeave={e => e.currentTarget.style.borderColor = T.border}
>
{c.label}
{c.outcome.log}
))}
);
}
// ── INTERCEPT SCREEN ──────────────────────────────────────────────────
const InterceptScreen = ({ state, dispatch }) => {
const session = state.encounterSession;
if (!session || session.phase !== "intercept") return null;
const { enemy, intercept } = session;
const enemyShip = SHIPS[enemy.shipType || L.guessShipType(enemy)] || {};
// Memoize combat flavour lines to avoid regeneration on re-renders
const combatFlavourLines = React.useMemo(() => {
const disposition = session.aiDisposition
?? L.computeAIDisposition(state, enemy, session.type);
return window.G.generateCombatFlavour(disposition);
}, [session, state, enemy]);
return (
⚠ ENCOUNTER
{/* Merged Flavour Panel: existing + AI combat flavour */}
{intercept.flavourText}
{combatFlavourLines.map((line, i) => (
{line}
))}
{/* Enemy details with ship sprite, faction & risk pills */}
{/* Ship Sprite */}
{[
["Hull", `${enemy.hull}/${enemy.maxHull || enemy.hull}`],
["Cannons", enemy.cannons],
["Crew", enemy.crew],
["Speed", enemyShip.speed ?? "?"],
].map(([l, v]) => (
))}
{/* Options */}
CHOOSE YOUR RESPONSE:
{intercept.options.map(opt => (
opt.available && dispatch(opt.action)}
style={{ width: "100%", textAlign: "left", opacity: opt.available ? 1 : 0.45 }}
>
{opt.label}
{!opt.available && opt.reason && (
✗ {opt.reason}
)}
{opt.id === "flee" && opt.available && opt.speedCheck && (
Speed check: your {opt.speedCheck.player} vs their {opt.speedCheck.enemy}
)}
))}
);
};
// ── BATTLE SCREEN ─────────────────────────────────────────────────────
function BattleScreen({ state, dispatch }) {
const session = state.encounterSession;
if (!session || session.phase !== "battle" || !session.battle) return null;
const battle = session.battle;
const enemy = session.enemy;
const done = ["victory", "defeat", "fled"].includes(battle.phase);
const isBoarding = battle.subPhase === "boarding";
const playerPct = battle.playerHull / SHIPS[state.ship.type].maxHull;
const enemyPct = battle.enemyHull / enemy.hull;
const [showTutorial, setShowTutorial] = React.useState(
() => shouldShowTutorial(state, "battle")
);
const [pulsedAction, setPulsedAction] = useState(null);
// ── Boarding ratio (now using the same function as the resolver) ──
const ratio = L.getBoardingRatio(state, battle, enemy);
const playerRatioPct = Math.round(ratio * 100);
const enemyRatioPct = 100 - playerRatioPct;
// ── Boarding action availability ──────────────────────────────
const canDemandSurrender = isBoarding && ratio >= 0.65;
const demandSurrenderTooltip = !isBoarding
? "Not in boarding phase"
: ratio < 0.65
? `Need a clear advantage (${Math.round(ratio * 100)}% / 65% required)`
: "";
// ── Action previews ──────────────────────────────────────────────
const actionPreviews = useMemo(() => {
const previews = {};
const actions = ["broadside", "precision", "grapple", "evade", "close_distance", "open_distance"];
if (isBoarding) {
actions.push("continue_fighting", "fall_back", "demand_surrender", "surrender");
}
actions.forEach(a => {
if (isBoarding && ["broadside", "precision", "grapple", "evade", "close_distance", "open_distance"].includes(a)) {
previews[a] = { description: "Not available in boarding.", hullRange: null, crewRange: null, hitChance: null };
} else {
previews[a] = getActionPreview(state, a, battle.distance, enemy, isBoarding ? battle : null);
}
});
return previews;
}, [state, battle.distance, enemy, isBoarding, battle]);
const [missFlash, setMissFlash] = useState(false);
const prevLogLen = useRef(battle.log?.length || 0);
useEffect(() => {
if (!battle) return;
const newLen = battle.log.length;
if (newLen > prevLogLen.current) {
const latest = battle.log[newLen - 1] || "";
if (isPlayerMissOrFail(latest)) {
setMissFlash(true);
const timer = setTimeout(() => setMissFlash(false), 600);
return () => clearTimeout(timer);
}
}
prevLogLen.current = newLen;
}, [battle?.log?.length]);
const [isNarrowBattle, setIsNarrowBattle] = React.useState(window.innerWidth < 700);
React.useEffect(() => {
const handle = () => setIsNarrowBattle(window.innerWidth < 700);
window.addEventListener("resize", handle);
return () => window.removeEventListener("resize", handle);
}, []);
// ── Distance gating (naval only) ──────────────────────────────
const legalActions = LEGAL_ACTIONS_BY_DISTANCE[battle.distance] || [];
const isActionLegal = (action) => legalActions.includes(action);
// ── Tooltip with crew check for Grapple ────────────────────────
const getActionTooltip = (action) => {
if (action === "grapple") {
const messages = [];
const legal = isActionLegal(action);
const isCrewZero = battle.playerCrew === 0;
if (!legal) messages.push("Grapple requires Close distance.");
if (isCrewZero) messages.push("No crew left to board with.");
return messages.length > 0 ? messages.join(" ") : null;
}
if (isActionLegal(action)) return null;
const map = {
broadside: "Broadside is available at all distances",
precision: "Precision is available at all distances",
close_distance: "Close Distance requires Far or Medium distance",
open_distance: "Open Distance requires Medium or Close distance",
evade: "Evade requires Far distance",
};
return map[action] || "Not available at this distance";
};
// ── Distance indicator component ────────────────────────────────
const DistanceIndicator = () => {
const distances = ["far", "medium", "close"];
const labels = ["Far", "Medium", "Close"];
const currentIndex = distances.indexOf(battle.distance);
return (
Distance:
{battle.distance.toUpperCase()}
{distances.map((d, i) => {
const isActive = i === currentIndex;
const isPast = i < currentIndex;
const dotColor = isActive ? T.gold : isPast ? T.goldDim : T.textFaint;
return (
{i < distances.length - 1 && (
)}
);
})}
{battle.distance === "far" && "Long range – cannons at full spread"}
{battle.distance === "medium" && "Standard engagement range"}
{battle.distance === "close" && "Point-blank – boarding range"}
);
};
// ── Advantage Bar (boarding phase) ──────────────────────────────
const AdvantageBar = () => {
if (!isBoarding) return null;
// Use the same ratio as the resolver
const pPct = Math.round(ratio * 100);
const ePct = 100 - pPct;
// For display: effective crew counts (same as resolver)
const playerEffective = Math.round(battle.playerCrew * (0.5 + state.crew.morale / 200));
const enemyMoraleStandin = { low: 50, medium: 65, high: 80, assault: 90 }[enemy.risk] ?? 60;
const enemyEffective = Math.round(battle.enemyCrew * (0.5 + enemyMoraleStandin / 200));
return (
Your crew: {battle.playerCrew}
Morale: {state.crew.morale}%
Effective: {playerEffective}
Enemy crew: {battle.enemyCrew}
Morale: {enemyMoraleStandin}%
Effective: {enemyEffective}
);
};
// ── Color maps for action buttons ──────────────────────────────────
const navalColors = {
broadside: T.redBr,
precision: T.yellow,
grapple: T.blueBr,
evade: T.greenBr,
open_distance: T.greenBr,
close_distance: T.greenBr,
};
const boardingColors = {
continue_fighting: T.greenBr,
fall_back: T.goldDim,
demand_surrender: T.blueBr,
surrender: T.redBr,
};
return (
{showTutorial && (
{
markTutorialSeen("battle", disableAll);
setShowTutorial(false);
}}
>
{isBoarding ? (
<>
You've grappled the enemy ship! Choose your action:
- Continue Fighting — keep pressing the attack
- Fall Back — retreat and return to naval combat
- Demand Surrender — force them to yield (requires clear advantage)
- Surrender — yield to the enemy
The advantage bar shows your relative boarding strength based on crew count and morale.
>
) : (
<>
Choose an action each round:
- Broadside — reliable cannon volley
- Precision — risky but devastating if it hits
- Close Distance — move closer to the enemy
- Open Distance — move further away
- Grapple — board the enemy and move to Boarding phase of the combat.
- Evade — attempt to flee the battle, depend on your ship speed.
Watch your hull and crew. If your hull reaches zero, you lose. Loosing all crew results in capture.
>
)}
)}
{isBoarding ? (
<> BOARDING ACTION — ROUND {battle.round}>
) : (
<> NAVAL BATTLE — ROUND {battle.round}>
)}
{/* ── Ship panels (naval only) ────────────────────────────── */}
{!isBoarding && (
(() => {
const playerType = state.ship.type;
const enemyType = L.guessShipType(enemy);
const playerVisual = window.D.SHIP_VISUALS?.[playerType];
const enemyVisual = window.D.SHIP_VISUALS?.[enemyType];
const playerLen = playerVisual?.hullLength || 400;
const enemyLen = enemyVisual?.hullLength || 400;
const maxLen = Math.max(playerLen, enemyLen);
const playerSize = playerLen / maxLen;
const enemySize = enemyLen / maxLen;
const baseW = isNarrowBattle ? 150 : 270;
const baseH = isNarrowBattle ? 100 : 175;
return (
{/* Player ship panel */}
{state.ship.name}
Hull: {battle.playerHull} / {SHIPS[state.ship.type].maxHull}
= 0.6 ? T.greenBr : playerPct >= 0.3 ? T.gold : T.redBr} h={10} />
{battle.convoyHull !== undefined && (
<>
Convoy Hull: {battle.convoyHull} / 50
= 0.6 ? T.greenBr : battle.convoyHull / 50 >= 0.3 ? T.gold : T.redBr} h={8} />
>
)}
{state.crew.roster.length} crew · {L.getShipStats(state).cannons} cannons
⚡
{/* Enemy ship panel */}
{enemy.name}
Hull: {battle.enemyHull} / {enemy.hull}
= 0.6 ? T.greenBr : enemyPct >= 0.3 ? T.gold : T.redBr} h={10} />
{battle.enemyCrew} crew · {enemy.cannons} cannons
);
})()
)}
{/* ── Boarding: Advantage Bar ────────────────────────────── */}
{isBoarding &&
}
{/* ── Distance indicator (naval only) ────────────────────── */}
{!isBoarding &&
}
{isNarrowBattle && window.innerWidth < 400 && !isBoarding && (
Tip: rotate your phone to landscape for a better battle view
)}
{/* ── Log panel ──────────────────────────────────────────── */}
{[...battle.log].reverse().map((e, i) => {
const isLatest = i === 0;
const isMissFlash = isLatest && missFlash && isPlayerMissOrFail(e);
return (
{e}
);
})}
{!done ? (
{isBoarding ? (
// ── Boarding Actions ──────────────────────────────────
BOARDING ACTIONS — {playerRatioPct}% advantage
{[
{ a: "continue_fighting", label: "Continue Fighting", desc: actionPreviews.continue_fighting?.description || "Press the attack" },
{ a: "fall_back", label: "Fall Back", desc: actionPreviews.fall_back?.description || "Return to naval combat" },
{ a: "demand_surrender", label: "Demand Surrender", desc: actionPreviews.demand_surrender?.description || "Force them to yield", disabled: !canDemandSurrender, tooltip: demandSurrenderTooltip },
{ a: "surrender", label: "Surrender", desc: actionPreviews.surrender?.description || "Yield to the enemy" },
].map(({ a, label, desc, disabled = false, tooltip = "" }) => {
const preview = actionPreviews[a] || {};
const playerLoss = preview.crewLossPlayer;
const enemyLoss = preview.crewLossEnemy;
const adv = preview.advantage !== undefined ? `Advantage: ${preview.advantage}%` : "";
const info = a === "continue_fighting"
? `You lose: ${playerLoss} crew · Enemy loses: ${enemyLoss} crew ${adv ? ` · ${adv}` : ''}`
: a === "fall_back"
? `You lose: ${playerLoss} crew`
: "";
return (
{
if (disabled) return;
dispatch({ type: A.BATTLE_ACTION, action: a });
setPulsedAction(a);
setTimeout(() => setPulsedAction(null), 150);
}}
onMouseEnter={e => {
if (disabled) return;
const color = boardingColors[a];
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 0 14px ${color}55`;
e.currentTarget.style.transform = "scale(1.03)";
}}
onMouseLeave={e => {
if (disabled) return;
e.currentTarget.style.borderColor = '';
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.transform = "scale(1)";
}}
title={tooltip || ""}
>
{label}
{desc}
{info && (
{info}
)}
{disabled && tooltip && (
✗ {tooltip}
)}
);
})}
) : (
// ── Naval Actions ────────────────────────────────────
CHOOSE YOUR ACTION:
{[
{ a: "broadside", label:
, lbl: " Broadside", desc: actionPreviews.broadside?.description || "Full cannon volley" },
{ a: "precision", label:
, lbl: " Precision", desc: actionPreviews.precision?.description || "Aimed shot" },
{ a: "grapple", label:
, lbl: " Grapple", desc: actionPreviews.grapple?.description || "Board the enemy" },
].map(({ a, label, lbl, desc }) => {
const legal = isActionLegal(a);
const isCrewZero = a === "grapple" && battle.playerCrew === 0;
const disabled = !legal || isCrewZero;
// Determine the tooltip and inline message
let tooltip = null;
if (disabled) {
const messages = [];
if (!legal) messages.push("Grapple requires Close distance.");
if (isCrewZero) messages.push("No crew left to board with.");
tooltip = messages.join(" ");
}
const preview = actionPreviews[a] || {};
const hull = preview.hullRange ? `${preview.hullRange[0]}–${preview.hullRange[1]}` : null;
const crew = preview.crewRange ? `${preview.crewRange[0]}–${preview.crewRange[1]}` : null;
const hit = preview.hitChance !== null && preview.hitChance < 1 ? `Hit: ${Math.round(preview.hitChance*100)}%` : null;
const infoParts = [hull ? `Hull: ${hull}` : null, crew ? `Crew: ${crew}` : null, hit].filter(Boolean);
const info = infoParts.join(' · ');
return (
{
if (disabled) return;
dispatch({ type: A.BATTLE_ACTION, action: a });
setPulsedAction(a);
setTimeout(() => setPulsedAction(null), 150);
}}
onMouseEnter={e => {
if (disabled) return;
const color = navalColors[a];
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 0 14px ${color}55`;
e.currentTarget.style.transform = "scale(1.03)";
}}
onMouseLeave={e => {
if (disabled) return;
e.currentTarget.style.borderColor = '';
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.transform = "scale(1)";
}}
title={tooltip || ""}
>
{label}{lbl}
{desc}
{info && (
{info}
)}
{disabled && tooltip && (
✗ {tooltip}
)}
);
})}
{[
{ a: "evade", label:
, lbl: " Evade", desc: actionPreviews.evade?.description || "Flee if faster" },
{ a: "open_distance", label:
, lbl: " Open Distance", desc: actionPreviews.open_distance?.description || "Move further away" },
{ a: "close_distance", label:
, lbl: " Close Distance", desc: actionPreviews.close_distance?.description || "Move closer" },
].map(({ a, label, lbl, desc }) => {
const legal = isActionLegal(a);
const tooltip = !legal ? (() => {
const map = {
close_distance: "Close Distance requires Far or Medium distance",
open_distance: "Open Distance requires Medium or Close distance",
evade: "Evade requires Far distance",
};
return map[a] || "Not available at this distance";
})() : null;
const preview = actionPreviews[a] || {};
return (
{
if (!legal) return;
dispatch({ type: A.BATTLE_ACTION, action: a });
setPulsedAction(a);
setTimeout(() => setPulsedAction(null), 150);
}}
onMouseEnter={e => {
if (!legal) return;
const color = navalColors[a];
e.currentTarget.style.borderColor = color;
e.currentTarget.style.boxShadow = `0 0 14px ${color}55`;
e.currentTarget.style.transform = "scale(1.03)";
}}
onMouseLeave={e => {
if (!legal) return;
e.currentTarget.style.borderColor = '';
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.transform = "scale(1)";
}}
title={tooltip || ""}
>
{label}{lbl}
{desc}
{!legal && tooltip && (
✗ {tooltip}
)}
);
})}
)}
) : (
// ── Victory / Defeat / Fled ──────────────────────────────
{battle.phase === "victory" && (<> VICTORY!>)}
{battle.phase === "fled" && (<> ESCAPED>)}
{battle.phase === "defeat" && (<> DEFEATED>)}
{battle.phase === "victory" && battle.canPlunder ? (
dispatch({ type: A.NAVIGATE, screen: "plunder" })}>
Plunder the Ship
dispatch({ type: A.DISMISS_BATTLE })}>
Sail Away
) : (
<>
{battle.phase === "victory" && battle.goldReward > 0 && (
+{battle.goldReward} gold
)}
dispatch({ type: A.DISMISS_BATTLE })}>
{session.returnScreen === "sailing" && state.destination && state.sailingDaysLeft > 0
? "Continue Voyage"
: session.returnScreen === "arrive" && state.destination
? "Enter Port"
: "Return to Port"}
>
)}
)}
);
}
// ── PLUNDER SCREEN ────────────────────────────────────────────────────
function PlunderScreen({ state, dispatch }) {
const session = state.encounterSession;
if (!session || session.phase !== "plunder") return null;
const battle = session.battle;
if (!battle || !battle.canPlunder) return null;
const enemyCargo = battle.enemyCargo || {};
const goldReward = battle.goldReward || 0;
const holdCapacity = L.getHoldCapacity(state) || 200;
const [playerItems, setPlayerItems] = React.useState({ ...(state.hold?.items || {}) });
const [enemyItems, setEnemyItems] = React.useState({ ...enemyCargo });
const used = Object.values(playerItems).reduce((s, q) => s + q, 0);
const free = Math.max(0, holdCapacity - used);
const goodsValue = Object.entries(enemyItems).reduce((sum, [good, qty]) => {
const res = window.D.RESOURCES[good];
const price = res?.basePrice ?? 0;
return sum + price * (qty || 0);
}, 0);
const totalValue = goldReward + goodsValue;
const totalFlash = useFlashOnChange(totalValue, { direction: 'up' });
const hasIllegal = Object.keys(enemyItems).some(
g => window.D.RESOURCES[g]?.illegal
);
const enemyTotal = Object.values(enemyItems).reduce((s, q) => s + q, 0);
const moveToPlayer = (good) => {
const available = enemyItems[good] || 0;
if (available <= 0 || free < 1) return;
setEnemyItems(prev => ({ ...prev, [good]: prev[good] - 1 }));
setPlayerItems(prev => ({ ...prev, [good]: (prev[good] || 0) + 1 }));
};
const moveToEnemy = (good) => {
const available = playerItems[good] || 0;
if (available <= 0) return;
setPlayerItems(prev => ({ ...prev, [good]: prev[good] - 1 }));
setEnemyItems(prev => ({ ...prev, [good]: (prev[good] || 0) + 1 }));
};
const takeAll = () => {
const priority = Object.entries(enemyItems)
.map(([good, qty]) => ({ good, qty, price: window.D.RESOURCES[good]?.basePrice ?? 0 }))
.filter(g => g.qty > 0)
.sort((a, b) => b.price - a.price);
let remainingFree = free;
const newPlayer = { ...playerItems };
for (const { good, qty } of priority) {
const takeQty = Math.min(qty, remainingFree);
if (takeQty > 0) {
newPlayer[good] = (newPlayer[good] || 0) + takeQty;
remainingFree -= takeQty;
}
}
dispatch({ type: window.E.A.TAKE_PLUNDER, holdItems: newPlayer });
};
const handleConfirm = () => {
dispatch({ type: window.E.A.TAKE_PLUNDER, holdItems: playerItems });
};
return (
Plunder the {session.enemy.name}
{/* ── Top summary panel ──────────────────────────────────── */}
Plunder gold
+{goldReward}g
Cargo value
{goodsValue}g
Take All
{hasIllegal && (
⚠ Illegal goods detected — patrols may inspect
)}
{/* ── Two‑column transfer layout ─────────────────────────── */}
holdCapacity * 0.8 ? T.redBr : T.greenBr} h={8} />
{Object.keys(playerItems).length === 0 ? (
) : (
Object.entries(playerItems).map(([good, qty]) => (
{getGoodIcon(good)}
{window.D.RESOURCES[good]?.name || good}
×{qty}
moveToEnemy(good)}>Jettison
))
)}
}
rightTitle="ENEMY CARGO"
rightContent={
Object.keys(enemyItems).length === 0 ? (
) : (
(() => {
let illegalDividerShown = false;
return Object.entries(enemyItems).map(([good, qty]) => {
const isIllegal = window.D.RESOURCES[good]?.illegal;
const showDivider = isIllegal && !illegalDividerShown;
if (showDivider) illegalDividerShown = true;
return (
{showDivider && (
)}
{getGoodIcon(good)}
{window.D.RESOURCES[good]?.name || good}
{isIllegal && ⚠}
×{qty}
moveToPlayer(good)} disabled={free < 1}>+ Take
);
});
})()
)
}
/>
{/* ── Confirm ────────────────────────────────────────────── */}
Plunder gold: +{goldReward}g
Confirm Plunder
);
}
Object.assign(window.S, { EventScreen, InterceptScreen, BattleScreen, PlunderScreen });
})();