// screens_crew.jsx : Crew Screen
window.S = window.S || {};
(() => {
const { useState, useMemo, useRef, useEffect } = React;
const { SHIPS, FACTIONS, PORTS } = window.D;
const L = window.L;
const A = window.E.A;
const { T, panelStyle, Bar, Pill, Btn, StatBlock, SectionTitle, EmptyState, TutorialPopup, BackButton, Tooltip, Panel,
IconCheers,IconAnchor, IconCannon, IconHammer, IconChefHat, IconCompass, IconShield } = window.UI;
const G = window.G;
const { shouldShowTutorial, markTutorialSeen } = window.L;
// Helper: returns an SVG icon element for the given crew role
const getRoleIcon = (role) => {
const iconProps = { size: 14, color: T.text };
switch (role) {
case "deckhand": return ;
case "gunner": return ;
case "cook": return ;
case "carpenter": return ;
case "navigator": return ;
case "quartermaster":return ;
default: return ๐ค;
}
};
function CrewScreen({ state, dispatch }) {
const perk = L.getRepPerk(state.reputation[state.currentPort] ?? 50);
if (perk.servicesBlocked) {
return (
);
}
const open = SHIPS[state.ship.type].maxCrew - state.crew.roster.length;
const [selectedMember, setSelectedMember] = useState(null);
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(state, "crew"));
// โโ Filter & Sort state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const [filterFaction, setFilterFaction] = useState("all");
const [sortBy, setSortBy] = useState("name"); // "name" | "days"
const [sortAsc, setSortAsc] = useState(true);
const bioCache = useRef({});
useEffect(() => {
bioCache.current = {};
}, [state.currentPort]);
const getCrewBio = (member) => {
if (!member) return "";
if (member.bio) return member.bio;
if (bioCache.current[member.id]) return bioCache.current[member.id];
if (typeof G.generateCrewBio === 'function') {
const newBio = G.generateCrewBio(member, state);
bioCache.current[member.id] = newBio;
return newBio;
}
return `${member.firstName} is a crew member.`;
};
// โโ Filter & sort roster โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const filteredRoster = useMemo(() => {
let result = [...state.crew.roster];
// Filter by faction
if (filterFaction !== "all") {
result = result.filter(m => m.faction === filterFaction);
}
// Sort
if (sortBy === "name") {
result.sort((a, b) => {
const nameA = `${a.firstName} ${a.lastName}`;
const nameB = `${b.firstName} ${b.lastName}`;
return sortAsc ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
});
} else if (sortBy === "days") {
result.sort((a, b) => {
const daysA = a.daysAboard || 0;
const daysB = b.daysAboard || 0;
return sortAsc ? daysA - daysB : daysB - daysA;
});
}
return result;
}, [state.crew.roster, filterFaction, sortBy, sortAsc]);
// โโ Build faction options for dropdown โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const factionOptions = [
{ key: "all", label: "Show All" },
...Object.entries(FACTIONS).map(([key, f]) => ({ key, label: f.label, color: f.color })),
];
const toggleSort = (field) => {
if (sortBy === field) {
setSortAsc(!sortAsc);
} else {
setSortBy(field);
setSortAsc(true);
}
};
return (
{showTutorial && (
{
markTutorialSeen("crew", disableAll);
setShowTutorial(false);
}}
>
Click any crew member to see their story. Over time, they earn:
- Scars from battles and storms they survived
- Traits that reveal hidden personalities
- Loyalty status : veterans who've served 200+ days may pledge loyalty
Watch crew faction alignment. Attacking a crew member's home faction can make them upset and eventually desert.
)}
{/* โโ ROSTER PANEL โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
ROSTER
60 ? T.greenBr : state.crew.morale > 30 ? T.gold : T.redBr} />
MORALE
60 ? T.greenBr : state.crew.morale > 30 ? T.gold : T.redBr} h={10} />
{state.crew.morale < 50 && โ Low morale weakens combat effectiveness
}
{(() => {
const counts = {};
state.crew.roster.forEach(m => {
counts[m.faction] = (counts[m.faction] || 0) + 1;
});
return (
{Object.entries(counts).map(([faction, count]) => {
const fac = FACTIONS[faction];
return (
{fac?.label || faction}: {count}
);
})}
);
})()}
dispatch({ type: A.RAISE_MORALE })} disabled={state.crew.roster.length === 0 || state.gold < state.crew.roster.length * 5 || state.crew.morale >= 100}>
Buy Drinks ({state.crew.roster.length * 5}g) +5 Morale
{/* โโ HIRE PANEL โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
HIRE
50g per sailor. Your {SHIPS[state.ship.type].name} holds {state.crew.max}.
{[1, 5, 10].map(n => dispatch({ type: A.HIRE_CREW, count: n })} disabled={open < n || state.gold < n * 50}>+{n} ({n * 50}g))}
{open === 0 && }
{/* โโ CREW DETAIL / LEGEND PANEL โโโโโโโโโโโโโโโโโโ */}
CREW DETAILS
{selectedMember ? (
(() => {
const visibleTags = (selectedMember.tags || []).filter(t => !t.startsWith("hidden_"));
return (
{selectedMember.firstName} {selectedMember.lastName}
setSelectedMember(null)}>โ
Faction: {FACTIONS[selectedMember.faction]?.label || selectedMember.faction}
Role: {selectedMember.role}
Days aboard: {selectedMember.daysAboard ?? 0}
{visibleTags.length > 0 && (
{visibleTags.map(tag => {
const positiveTraits = ["loyal", "seasoned", "veteran"];
const isPositive = positiveTraits.includes(tag);
const color = isPositive ? T.greenBr : T.redBr;
const displayLabels = {
loyal: "Loyal Officer",
scar_battle: "Battle Scarred",
scar_storm: "Storm Survivor",
scar_shipwreck: "Shipwrecked",
seasoned: "Seasoned",
veteran: "Veteran",
mutineer: "Mutineer",
upset: "Upset",
revealed_drunkard: "Drunkard",
revealed_coward: "Coward",
revealed_greedy: "Greedy",
};
const label = displayLabels[tag] || tag
.replace("revealed_", "")
.replace(/_/g, " ")
.replace(/\b\w/g, c => c.toUpperCase());
return
;
})}
)}
{getCrewBio(selectedMember)}
{!L.hasTag(selectedMember, "protected") ? (
{
dispatch({ type: A.DISMISS_CREW, memberId: selectedMember.id });
setSelectedMember(null);
}}>
โ Dismiss
) : null}
);
})()
) : (
Click on any crew member icon for details.
)}
{/* โโ MANIFEST with Filter & Sort โโโโโโโโโโโโโโโโโโ */}
MANIFEST
{/* Faction Filter Dropdown */}
{/* Sort buttons */}
toggleSort("name")}
style={{ fontSize: T.captionFontSize, minHeight: 28, padding: "2px 10px" }}
>
Name {sortBy === "name" && (sortAsc ? "โ" : "โ")}
toggleSort("days")}
style={{ fontSize: T.captionFontSize, minHeight: 28, padding: "2px 10px" }}
>
Days {sortBy === "days" && (sortAsc ? "โ" : "โ")}
{filteredRoster.map(member => {
const visibleTags = (member.tags || []).filter(t => !t.startsWith("hidden_"));
const factionColor = FACTIONS[member.faction]?.color || T.textDim;
const isMutineer = L.hasTag(member, "mutineer");
const tagLabels = visibleTags.map(t => {
if (t === "upset") return "Upset";
if (t === "mutineer") return "Mutineer";
if (t === "scar_shipwreck") return "Shipwreck Survivor";
if (t === "loyal") return "Loyal Officer";
if (t === "seasoned") return "Seasoned";
if (t === "veteran") return "Veteran";
if (t.startsWith("revealed_")) return t.replace("revealed_", "").replace(/_/g, " ");
return t;
});
const tooltipText = `${member.firstName} ${member.lastName} ยท ${member.role} ยท ${member.faction} ยท ${member.daysAboard}d aboard` +
(tagLabels.length > 0 ? ` ยท ${tagLabels.join(", ")}` : "");
return (
setSelectedMember(selectedMember?.id === member.id ? null : member)}
style={{
width: 34, height: 34, borderRadius: 3,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: T.heading2FontSize, cursor: "pointer",
background: selectedMember?.id === member.id ? T.panelAlt : T.panel,
border: `2px solid ${selectedMember?.id === member.id ? T.gold : T.border}`,
position: "relative",
}}
>
{getRoleIcon(member.role)}
{isMutineer && (
โ
)}
);
})}
{filteredRoster.length === 0 && (
No crew members match the selected filter.
)}
);
}
Object.assign(window.S, { CrewScreen });
})();