diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000..1d5f1e1b
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,74 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Docker: Start (watch)",
+ "type": "shell",
+ "command": "docker-compose up --watch",
+ "isBackground": true,
+ "problemMatcher": [],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated",
+ "focus": false
+ }
+ },
+ {
+ "label": "Docker: Stop",
+ "type": "shell",
+ "command": "docker-compose down",
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared"
+ }
+ },
+ {
+ "label": "Docker: Rebuild",
+ "type": "shell",
+ "command": "docker-compose build",
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared"
+ }
+ },
+ {
+ "label": "Docker: Rebuild and Start (watch)",
+ "type": "shell",
+ "command": "docker-compose down && docker-compose up --build --watch",
+ "isBackground": true,
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated",
+ "focus": false
+ }
+ },
+ {
+ "label": "Docker: View Logs",
+ "type": "shell",
+ "command": "docker-compose logs -f",
+ "isBackground": true,
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated"
+ }
+ },
+ {
+ "label": "Docker: Restart Containers",
+ "type": "shell",
+ "command": "docker-compose restart",
+ "problemMatcher": [],
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared"
+ }
+ }
+ ]
+}
diff --git a/README.md b/README.md
index 0c2c4912..4e314599 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,15 @@ docker-compose up --watch
Visit http://localhost:3001 to see the application.
+#### Rebuilding Containers
+
+When you need to rebuild the Docker containers (after pulling updates, merging branches, or changing dependencies):
+
+```bash
+docker-compose down
+docker-compose up --build --watch
+```
+
**Note:**
- If you encounter port conflicts, check for existing services with `lsof -i :PORT_NUMBER`
diff --git a/docker-compose.yml b/docker-compose.yml
index 675bf282..a407ee84 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -55,7 +55,9 @@ services:
path: ./src
target: /app/src
- action: rebuild
- path: ./package*.json
+ path: ./package.json
+ - action: rebuild
+ path: ./package-lock.json
volumes:
postgres_data:
diff --git a/src/client/game/player_expanded_row.module.css b/src/client/game/player_expanded_row.module.css
new file mode 100644
index 00000000..87b065dd
--- /dev/null
+++ b/src/client/game/player_expanded_row.module.css
@@ -0,0 +1,459 @@
+/* Expanded row backgrounds */
+.expandedRow {
+ background-color: #f9fafb;
+}
+
+.expandedRowCurrent {
+ background-color: #eff6ff;
+}
+
+:global(.dark-mode) .expandedRow {
+ background-color: #1a1a1a;
+}
+
+:global(.dark-mode) .expandedRowCurrent {
+ background-color: #1a2332;
+}
+
+.expandedContent {
+ padding: 16px 20px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* Warning banners */
+.warningBanner {
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 12px;
+}
+
+.warningBannerRed {
+ background-color: #fef2f2;
+ border: 1px solid #fecaca;
+}
+
+.warningBannerAmber {
+ background-color: #fffbeb;
+ border: 1px solid #fde68a;
+}
+
+:global(.dark-mode) .warningBannerRed {
+ background-color: #3b1111;
+ border-color: #7f1d1d;
+}
+
+:global(.dark-mode) .warningBannerAmber {
+ background-color: #3b2e0a;
+ border-color: #78350f;
+}
+
+.warningBannerContent {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.warningBannerIcon {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.warningBannerText {
+ font-size: 14px;
+}
+
+.warningTitleRed {
+ font-weight: 600;
+ color: #7f1d1d;
+ margin-bottom: 4px;
+}
+
+.warningBodyRed {
+ color: #991b1b;
+}
+
+.warningTitleAmber {
+ font-weight: 600;
+ color: #78350f;
+ margin-bottom: 4px;
+}
+
+.warningBodyAmber {
+ color: #92400e;
+}
+
+:global(.dark-mode) .warningTitleRed {
+ color: #fca5a5;
+}
+
+:global(.dark-mode) .warningBodyRed {
+ color: #fca5a5;
+}
+
+:global(.dark-mode) .warningTitleAmber {
+ color: #fcd34d;
+}
+
+:global(.dark-mode) .warningBodyAmber {
+ color: #fcd34d;
+}
+
+/* Two-column grid */
+.twoColumnGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ margin-bottom: 12px;
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .twoColumnGrid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Panel styles */
+.panelSection {
+ margin-bottom: 8px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.panelTitle {
+ font-weight: 600;
+ font-size: 14px;
+ color: #374151;
+ margin-bottom: 8px;
+}
+
+:global(.dark-mode) .panelTitle {
+ color: #d1d5db;
+}
+
+.panelCard {
+ background: white;
+ padding: 12px 16px;
+ border-radius: 4px;
+ border: 1px solid #e5e7eb;
+ font-size: 14px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+:global(.dark-mode) .panelCard {
+ background: #2d2d2d;
+ border-color: #555;
+}
+
+.panelRow {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.panelRow:last-child {
+ margin-bottom: 0;
+}
+
+.panelLabel {
+ color: #6b7280;
+}
+
+:global(.dark-mode) .panelLabel {
+ color: #9ca3af;
+}
+
+.panelValue {
+ font-weight: 500;
+}
+
+.valuePositive {
+ color: #16a34a;
+}
+
+.valueNegative {
+ color: #dc2626;
+}
+
+.panelSubrow {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: #6b7280;
+ padding-left: 16px;
+ margin-bottom: 4px;
+}
+
+:global(.dark-mode) .panelSubrow {
+ color: #9ca3af;
+}
+
+.panelDivider {
+ border-top: 1px solid #e5e7eb;
+ padding-top: 6px;
+ margin-top: 6px;
+}
+
+:global(.dark-mode) .panelDivider {
+ border-top-color: #555;
+}
+
+.panelHighlight {
+ background-color: #fffbeb;
+ margin-left: -12px;
+ margin-right: -12px;
+ padding-left: 12px;
+ padding-right: 12px;
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+
+:global(.dark-mode) .panelHighlight {
+ background-color: #3b2e0a;
+}
+
+.panelBold {
+ font-weight: 600;
+}
+
+/* Income Track */
+.trackLabel {
+ font-size: 12px;
+ color: #6b7280;
+ margin-bottom: 12px;
+}
+
+:global(.dark-mode) .trackLabel {
+ color: #9ca3af;
+}
+
+.trackLabels {
+ display: flex;
+ margin-bottom: 4px;
+}
+
+.trackLabelItem {
+ flex: 1;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 500;
+ color: #6b7280;
+}
+
+:global(.dark-mode) .trackLabelItem {
+ color: #9ca3af;
+}
+
+.trackLabelDanger {
+ color: #dc2626;
+}
+
+.trackVisual {
+ position: relative;
+ height: 32px;
+ display: flex;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+:global(.dark-mode) .trackVisual {
+ border-color: #555;
+}
+
+.trackZone {
+ flex: 1;
+ border-right: 1px solid #d1d5db;
+}
+
+:global(.dark-mode) .trackZone {
+ border-right-color: #555;
+}
+
+.trackZone:last-of-type {
+ border-right: none;
+}
+
+.trackPip {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ background-color: var(--player-color);
+}
+
+.trackCurrentValue {
+ text-align: center;
+ font-size: 12px;
+ color: #6b7280;
+ margin-top: 8px;
+}
+
+:global(.dark-mode) .trackCurrentValue {
+ color: #9ca3af;
+}
+
+.trackCurrentValue span {
+ font-weight: 500;
+}
+
+/* Bidding impact */
+.biddingSection {
+ margin-top: 12px;
+}
+
+.biddingHeader {
+ font-size: 12px;
+ color: #6b7280;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #e5e7eb;
+ margin-bottom: 8px;
+}
+
+:global(.dark-mode) .biddingHeader {
+ color: #9ca3af;
+ border-bottom-color: #555;
+}
+
+.biddingGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 12px;
+}
+
+.biddingScenario {
+ text-align: center;
+}
+
+.biddingScenarioBordered {
+ border-left: 1px solid #e5e7eb;
+ border-right: 1px solid #e5e7eb;
+}
+
+:global(.dark-mode) .biddingScenarioBordered {
+ border-left-color: #555;
+ border-right-color: #555;
+}
+
+.scenarioLabel {
+ font-size: 12px;
+ color: #6b7280;
+ margin-bottom: 4px;
+}
+
+:global(.dark-mode) .scenarioLabel {
+ color: #9ca3af;
+}
+
+.scenarioValue {
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.scenarioDescription {
+ font-size: 12px;
+ color: #6b7280;
+ margin-top: 2px;
+}
+
+:global(.dark-mode) .scenarioDescription {
+ color: #9ca3af;
+}
+
+.scenarioNeeds {
+ font-size: 12px;
+ color: #dc2626;
+ margin-top: 4px;
+}
+
+/* Eliminated player expanded */
+.eliminatedMessage {
+ padding: 48px 24px;
+ text-align: center;
+}
+
+.eliminatedIconWrapper {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background-color: #fee2e2;
+ margin-bottom: 12px;
+}
+
+:global(.dark-mode) .eliminatedIconWrapper {
+ background-color: #3b1111;
+}
+
+.eliminatedTitle {
+ font-size: 18px;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 4px;
+}
+
+:global(.dark-mode) .eliminatedTitle {
+ color: #f3f4f6;
+}
+
+.eliminatedDescription {
+ font-size: 14px;
+ color: #6b7280;
+}
+
+:global(.dark-mode) .eliminatedDescription {
+ color: #9ca3af;
+}
+
+/* Score tooltip */
+.tooltipContent {
+ min-width: 240px;
+ font-family: Roboto, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.tooltipTitle {
+ font-weight: 600;
+ font-size: 14px;
+ margin-bottom: 8px;
+}
+
+.tooltipTable {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.tooltipTable td {
+ padding: 3px 0;
+ font-size: 14px;
+}
+
+.tooltipLabel {
+ text-align: left;
+ padding-right: 16px;
+ white-space: nowrap;
+ color: inherit;
+}
+
+.tooltipValue {
+ text-align: right;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.tooltipTotalRow {
+ font-weight: 600;
+}
+
+.tooltipTotalRow td {
+ padding-top: 6px;
+ border-top: 1px solid rgba(128, 128, 128, 0.3);
+}
diff --git a/src/client/game/player_expanded_row.tsx b/src/client/game/player_expanded_row.tsx
new file mode 100644
index 00000000..606d89d7
--- /dev/null
+++ b/src/client/game/player_expanded_row.tsx
@@ -0,0 +1,516 @@
+import { PlayerHelper } from "../../engine/game/player";
+import { PHASE } from "../../engine/game/phase";
+import { ProfitHelper } from "../../engine/income_and_expenses/helper";
+import { Phase } from "../../engine/state/phase";
+import { PlayerData } from "../../engine/state/player";
+import { TURN_ORDER_STATE } from "../../engine/turn_order/state";
+import { getPlayerColorCss } from "../components/player_color";
+import {
+ useInjected,
+ useInjectedState,
+ usePhaseState,
+} from "../utils/injection_context";
+import { Icon } from "semantic-ui-react";
+
+import * as styles from "./player_expanded_row.module.css";
+
+const TRACK_ZONE_COLORS = [
+ "#d4c2a8",
+ "#9a8d85",
+ "#929164",
+ "#6f7e7b",
+ "#ba6e3b",
+ "#a71b27",
+];
+
+const TRACK_ZONE_LABELS = ["-$0", "-$2", "-$4", "-$6", "-$8", "-$10"];
+
+function calculateIncomeReduction(income: number): number {
+ if (income <= 10) return 0;
+ if (income >= 51) return 10;
+ return Math.floor((income - 1) / 10) * 2;
+}
+
+interface PlayerWarning {
+ hasIncomeLoss: boolean;
+ hasEliminationRisk: boolean;
+ deficit: number;
+ newIncome: number;
+}
+
+export function getPlayerWarning(player: PlayerData): PlayerWarning {
+ const expenses = player.shares + player.locomotive;
+ const profit = player.income - expenses;
+ const endOfTurnMoney = player.money + profit;
+
+ if (endOfTurnMoney >= 0 || player.outOfGame) {
+ return {
+ hasIncomeLoss: false,
+ hasEliminationRisk: false,
+ deficit: 0,
+ newIncome: player.income - calculateIncomeReduction(player.income),
+ };
+ }
+
+ const deficit = Math.abs(endOfTurnMoney);
+ const incomeAfterPayment = player.income - deficit;
+
+ return {
+ hasIncomeLoss: incomeAfterPayment >= 0,
+ hasEliminationRisk: incomeAfterPayment < 0,
+ deficit,
+ newIncome: incomeAfterPayment,
+ };
+}
+
+function formatMoney(amount: number): string {
+ return amount >= 0 ? `$${amount}` : `-$${Math.abs(amount)}`;
+}
+
+function formatNet(amount: number): string {
+ return amount >= 0 ? `+$${amount}` : `-$${Math.abs(amount)}`;
+}
+
+interface PlayerExpandedRowProps {
+ player: PlayerData;
+ isCurrentPlayer: boolean;
+}
+
+export function PlayerExpandedRow({
+ player,
+}: PlayerExpandedRowProps) {
+ if (player.outOfGame) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ {player.locomotive < 6 && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function WarningBanners({ player }: { player: PlayerData }) {
+ const warning = getPlayerWarning(player);
+ const expenses = player.shares + player.locomotive;
+
+ if (warning.hasEliminationRisk) {
+ return (
+
+
+
+
+
+ ⚠️ Elimination Risk
+
+
+ Cannot fully pay {formatMoney(expenses)} in expenses with{" "}
+ {formatMoney(player.money)} cash. Income would drop below $0.
+
+ Income will be: {formatMoney(player.income)} →{" "}
+
+ {formatMoney(warning.newIncome)}
+
+
+
+
+
+
+ );
+ }
+
+ if (warning.hasIncomeLoss) {
+ return (
+
+
+
+
+
+ ⚠️ Income Loss Warning
+
+
+ Cannot fully pay {formatMoney(expenses)} in expenses with{" "}
+ {formatMoney(player.money)} cash. Will pay all cash and reduce
+ income by {formatMoney(warning.deficit)}.
+
+ New income will be: {formatMoney(player.income)} →{" "}
+
+ {formatMoney(warning.newIncome)}
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
+
+function FinancialDetailsPanel({ player }: { player: PlayerData }) {
+ const profitHelper = useInjected(ProfitHelper);
+ const income = profitHelper.getIncome(player);
+ const expenses = profitHelper.getExpenses(player);
+ const profit = profitHelper.getProfit(player);
+ const endOfTurnMoney = player.money + profit;
+ const warning = getPlayerWarning(player);
+
+ const netIncomeHighlight = warning.hasIncomeLoss || warning.hasEliminationRisk;
+
+ return (
+
+
Financial Details
+
+
+ Current Money:
+ {formatMoney(player.money)}
+
+
+ Income:
+
+ +{formatMoney(income)}
+
+
+
+ Expenses:
+
+ -{formatMoney(expenses)}
+
+
+
+ • Locomotive maintenance:
+ -{formatMoney(player.locomotive)}
+
+
+ • Share interest:
+ -{formatMoney(player.shares)}
+
+
+ Net Income:
+ = 0 ? styles.valuePositive : styles.valueNegative}`}
+ >
+ {formatNet(profit)}
+
+
+
+
+ End-of-Turn Money:
+
+
+ {endOfTurnMoney >= 0
+ ? formatMoney(endOfTurnMoney)
+ : `${formatMoney(endOfTurnMoney)} (needs ${formatMoney(Math.abs(endOfTurnMoney))})`}
+
+
+
+
+ );
+}
+
+function IncomeTrackVisualization({ player }: { player: PlayerData }) {
+ const income = player.income;
+ // Position pip: 0-60 scale mapped to 0-100%
+ const pipPosition = Math.min(Math.max((income / 60) * 100, 2), 97);
+
+ return (
+
+
+
Income Track - End of Round
+
+ {TRACK_ZONE_LABELS.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+ {TRACK_ZONE_COLORS.map((color, i) => (
+
+ ))}
+
+
+
+ Current income: {formatMoney(income)}
+
+
+
+ );
+}
+
+function LocoUpgradeImpactPanel({ player }: { player: PlayerData }) {
+ const profitHelper = useInjected(ProfitHelper);
+ const currentProfit = profitHelper.getProfit(player);
+ const currentMaintenance = player.locomotive;
+ const afterUpgradeMaintenance = player.locomotive + 1;
+ const profitAfterUpgrade = currentProfit - 1; // one more loco expense
+
+ return (
+
+
+
Locomotive Upgrade Impact
+
+ Current links:
+ {player.locomotive}
+
+
+ Current maintenance:
+
+ -{formatMoney(currentMaintenance)}/round
+
+
+
+
+ After upgrade to {player.locomotive + 1} links:
+
+
+ -{formatMoney(afterUpgradeMaintenance)}/round
+
+
+
+ Current net income:
+ = 0 ? styles.valuePositive : styles.valueNegative}`}
+ >
+ {formatNet(currentProfit)}
+
+
+
+ Net income after upgrade:
+ = 0 ? styles.valuePositive : styles.valueNegative}`}
+ >
+ {formatNet(profitAfterUpgrade)}
+
+
+
+
+ );
+}
+
+function ScoreBreakdownPanel({ player }: { player: PlayerData }) {
+ const playerHelper = useInjected(PlayerHelper);
+ const incomePoints = playerHelper.getScoreFromIncome(player);
+ const sharePoints = playerHelper.getScoreFromShares(player);
+ const trackPoints = playerHelper.getScoreFromTrack(player);
+ const trackCount = playerHelper.countTrack(player.color);
+ const totalScore = playerHelper.getScore(player)[0];
+
+ return (
+
+
Score Breakdown
+
+
+
+ Income points ({player.income} × 3):
+
+
+ +{incomePoints}
+
+
+
+
+ Share penalty ({player.shares} × -3):
+
+
+ {sharePoints}
+
+
+
+
+ Track points ({trackCount} sections × 1):
+
+
+ +{trackPoints}
+
+
+
+
+ Total Score:
+
+
+ {totalScore}
+
+
+
+
+ );
+}
+
+function BiddingImpactSection({ player }: { player: PlayerData }) {
+ const phase = useInjectedState(PHASE);
+ const isAuctionPhase =
+ phase === Phase.TURN_ORDER || phase === Phase.ST_LUCIA_TURN_ORDER;
+
+ const turnOrderState = usePhaseState(
+ isAuctionPhase ? phase : Phase.TURN_ORDER,
+ TURN_ORDER_STATE
+ );
+ const profitHelper = useInjected(ProfitHelper);
+
+ if (!isAuctionPhase || turnOrderState == null) return null;
+
+ const bid = turnOrderState.previousBids[player.color];
+ if (bid == null || bid === 0) return null;
+
+ const profit = profitHelper.getProfit(player);
+ const endOfTurnMoney = player.money + profit;
+ const worstCase = endOfTurnMoney - bid;
+ const likelyCase = endOfTurnMoney - Math.ceil(bid / 2);
+ const bestCase = endOfTurnMoney;
+
+ return (
+
+
+ Bidding Impact on End-of-Turn Money (Current Bid: {formatMoney(bid)})
+
+
+
+ End-of-turn money without bid:{" "}
+ {formatMoney(endOfTurnMoney)}
+
+
+
+
+
+
+
+
+ );
+}
+
+function BiddingScenario({
+ label,
+ value,
+ description,
+ bordered,
+}: {
+ label: string;
+ value: number;
+ description: string;
+ bordered?: boolean;
+}) {
+ return (
+
+
{label}
+
= 0 ? styles.valuePositive : styles.valueNegative}`}
+ >
+ {formatMoney(value)}
+
+
{description}
+ {value < 0 && (
+
+ Needs {formatMoney(Math.abs(value))}
+
+ )}
+
+ );
+}
+
+function EliminatedExpandedView() {
+ return (
+
+
+
+
+
Eliminated (Insolvency)
+
+ This player was unable to pay their expenses and their income dropped
+ below $0.
+
+
+ );
+}
+
+interface ScoreTooltipContentProps {
+ player: PlayerData;
+}
+
+export function ScoreTooltipContent({ player }: ScoreTooltipContentProps) {
+ const playerHelper = useInjected(PlayerHelper);
+ const incomePoints = playerHelper.getScoreFromIncome(player);
+ const sharePoints = playerHelper.getScoreFromShares(player);
+ const trackPoints = playerHelper.getScoreFromTrack(player);
+ const trackCount = playerHelper.countTrack(player.color);
+ const totalScore = playerHelper.getScore(player)[0];
+
+ return (
+
+
Score Breakdown
+
+
+
+ Income points ({player.income} × 3):
+ +{incomePoints}
+
+
+ Share penalty ({player.shares} × -3):
+ {sharePoints}
+
+
+ Track points ({trackCount} sections × 1):
+ +{trackPoints}
+
+
+ Total:
+ {totalScore}
+
+
+
+
+ );
+}
diff --git a/src/client/game/player_stats.module.css b/src/client/game/player_stats.module.css
index ac8d2193..264684b7 100644
--- a/src/client/game/player_stats.module.css
+++ b/src/client/game/player_stats.module.css
@@ -1,5 +1,11 @@
.playerStats {
overflow-y: auto;
+ width: 100%;
+}
+
+.playerStats table {
+ width: 100%;
+ table-layout: auto;
}
.tableRow td,
@@ -33,3 +39,155 @@
/* background-color: var(--player-color-text);
border-radius: 50%; */
}
+
+/* Chevron toggle button */
+.chevronBtn {
+ padding: 4px;
+ border: none;
+ background: transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: inherit;
+}
+
+.chevronBtn:hover {
+ background-color: #f3f4f6;
+}
+
+:global(.dark-mode) .chevronBtn:hover {
+ background-color: #333;
+}
+
+/* Player name cell */
+.playerCell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.playerNameEliminated {
+ text-decoration: line-through;
+}
+
+.badgeEliminated {
+ font-size: 11px;
+ background-color: #fee2e2;
+ color: #991b1b;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+}
+
+:global(.dark-mode) .badgeEliminated {
+ background-color: #3b1111;
+ color: #fca5a5;
+}
+
+/* Warning icons */
+:global(i.icon).warningRed {
+ color: #dc2626;
+ margin: 0;
+}
+
+:global(i.icon).warningAmber {
+ color: #f59e0b;
+ margin: 0;
+}
+
+/* Money column */
+.moneyDisplay {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.moneyValue {
+ font-weight: 500;
+}
+
+.moneyNetPositive {
+ color: #16a34a;
+}
+
+.moneyNetNegative {
+ color: #dc2626;
+}
+
+/* Score column */
+.scoreCell {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+:global(i.icon).infoIcon {
+ color: #9ca3af;
+ margin: 0;
+ cursor: help;
+}
+
+:global(i.icon).infoIcon:hover {
+ background-color: #f3f4f6;
+ border-radius: 4px;
+}
+
+:global(.dark-mode i.icon).infoIcon:hover {
+ background-color: #333;
+}
+
+/* Muted text for eliminated cells */
+.textMuted {
+ color: #9ca3af;
+}
+
+/* Eliminated row */
+.eliminatedRow {
+ opacity: 0.6;
+}
+
+/* Current player row */
+.currentPlayerRow {
+ background-color: #eff6ff;
+}
+
+:global(.dark-mode) .currentPlayerRow {
+ background-color: #1a2332;
+}
+
+/* Expanded row backgrounds */
+.expandedRow {
+ background-color: #f9fafb;
+}
+
+.expandedRowCurrent {
+ background-color: #eff6ff;
+}
+
+:global(.dark-mode) .expandedRow {
+ background-color: #1a1a1a;
+}
+
+:global(.dark-mode) .expandedRowCurrent {
+ background-color: #1a2332;
+}
+
+.expandedRowCell {
+ padding: 0 !important;
+ white-space: normal !important;
+}
+
+/* Score tooltip dark mode — Popup renders in a portal so needs global selectors */
+:global(.dark-mode .ui.popup.score-tooltip) {
+ background: #1f1f1f;
+ color: #e5e7eb;
+ border-color: #444;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+}
+
+:global(.dark-mode .ui.popup.score-tooltip::before) {
+ background: #1f1f1f !important;
+ box-shadow: 1px 1px 0 0 #444 !important;
+}
diff --git a/src/client/game/player_stats.tsx b/src/client/game/player_stats.tsx
index 76b5bf06..e0016ae7 100644
--- a/src/client/game/player_stats.tsx
+++ b/src/client/game/player_stats.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { Fragment, useMemo, useState } from "react";
import { GameStatus } from "../../api/game";
import { PlayerHelper } from "../../engine/game/player";
import {
@@ -26,6 +26,11 @@ import {
} from "../utils/injection_context";
import { FinalOverview } from "./final_overview";
import { LoginButton } from "./login_button";
+import {
+ getPlayerWarning,
+ PlayerExpandedRow,
+ ScoreTooltipContent,
+} from "./player_expanded_row";
import * as styles from "./player_stats.module.css";
import {
@@ -35,6 +40,7 @@ import {
Icon,
Menu,
MenuItem,
+ Popup,
} from "semantic-ui-react";
export function PlayerStats() {
@@ -42,6 +48,9 @@ export function PlayerStats() {
const playerOrder = useInjectedState(TURN_ORDER);
const currentPlayer = useActiveGameState(CURRENT_PLAYER);
const [expanded, setExpanded] = useState(true);
+ const [expandedPlayer, setExpandedPlayer] = useState(
+ null,
+ );
const viewSettings = useViewSettings();
const outOfGamePlayers = playerData
.filter((p) => p.outOfGame)
@@ -72,6 +81,13 @@ export function PlayerStats() {
: []),
];
+ // Total columns: chevron + color indicator + player name + collapsed cols (2) + expanded cols (columns.length) + login button
+ const totalColSpan = columns.length + 6;
+
+ function toggleExpandedPlayer(color: PlayerColor) {
+ setExpandedPlayer((prev) => (prev === color ? null : color));
+ }
+
return (
@@ -86,6 +102,7 @@ export function PlayerStats() {
+
Player
Stats
@@ -99,49 +116,119 @@ export function PlayerStats() {
- {players.map((player) => (
-
-
-
-
-
-
-
-
- {columns.map((column) => (
-
- {column.header}:
-
- ))}
-
-
- {columns.map((column) => {
- const Cell = column.cell;
- return (
-
- |
-
- );
- })}
-
- {columns.map((column) => {
- const Cell = column.cell;
- return (
-
- |
+ {players.map((player) => {
+ const isCurrentPlayer = player.color === currentPlayer;
+ const isExpanded = expandedPlayer === player.color;
+ const rowClasses = [
+ styles.tableRow,
+ player.outOfGame ? styles.eliminatedRow : "",
+ isCurrentPlayer ? styles.currentPlayerRow : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+
+
+ toggleExpandedPlayer(player.color)}
+ aria-expanded={isExpanded}
+ aria-label="Expand player details"
+ >
+
+
+
+
+
+
+
+
+
+
+ {columns.map((column) => (
+
+ {column.header}:
+
+ ))}
+
+
+ {columns.map((column) => {
+ const Cell = column.cell;
+ return (
+
+ |
+
+ );
+ })}
- );
- })}
-
-
- Switch
-
-
-
- ))}
+ {columns.map((column) => {
+ const Cell = column.cell;
+ return (
+
+ |
+
+ );
+ })}
+
+
+ Switch
+
+
+
+ {isExpanded && (
+
+ {/* Desktop expanded row cell: spans all desktop-visible columns */}
+
+
+
+ {/* Mobile expanded row cell: spans all mobile-visible columns (6) */}
+
+
+
+
+ )}
+
+ );
+ })}
@@ -155,6 +242,41 @@ interface PlayerStatColumnProps {
player: PlayerData;
}
+function PlayerNameCell({ player }: PlayerStatColumnProps) {
+ const warning = getPlayerWarning(player);
+
+ return (
+
+
+
+
+ {player.outOfGame && (
+
Eliminated
+ )}
+ {!player.outOfGame && warning.hasEliminationRisk && (
+
+ }
+ position="bottom center"
+ size="small"
+ />
+ )}
+ {!player.outOfGame && warning.hasIncomeLoss && (
+
+ }
+ position="bottom center"
+ size="small"
+ />
+ )}
+
+ );
+}
+
const actionColumn = {
header: "Action",
cell: ActionCell,
@@ -162,6 +284,7 @@ const actionColumn = {
function ActionCell({ player }: PlayerStatColumnProps) {
const actionNamingProvider = useInjected(ActionNamingProvider);
+ if (player.outOfGame) return — ;
return <>{actionNamingProvider.getActionString(player.selectedAction)}>;
}
@@ -172,11 +295,20 @@ const moneyColumn = {
function MoneyCell({ player }: PlayerStatColumnProps) {
const profitHelper = useInjected(ProfitHelper);
+ if (player.outOfGame) return — ;
+ const profit = profitHelper.getProfit(player);
return (
- <>
- ${player.money} ({toNet(profitHelper.getProfit(player))})
- >
+
+ ${player.money}
+ = 0 ? styles.moneyNetPositive : styles.moneyNetNegative
+ }
+ >
+ ({toNet(profit)})
+
+
);
}
@@ -186,6 +318,7 @@ const incomeColumn = {
};
function IncomeCell({ player }: PlayerStatColumnProps) {
+ if (player.outOfGame) return — ;
return <>${player.income}>;
}
@@ -195,6 +328,7 @@ const sharesColumn = {
};
function SharesCell({ player }: PlayerStatColumnProps) {
+ if (player.outOfGame) return — ;
return <>{player.shares}>;
}
@@ -205,6 +339,7 @@ const locoColumn = {
function LocoCell({ player }: PlayerStatColumnProps) {
const moveHelper = useInjected(MoveHelper);
+ if (player.outOfGame) return — ;
return <>{moveHelper.getLocomotiveDisplay(player)}>;
}
@@ -225,7 +360,21 @@ const scoreColumn = {
function ScoreCell({ player }: PlayerStatColumnProps) {
const helper = useInjected(PlayerHelper);
- return <>{helper.getScore(player)[0]}>;
+ if (player.outOfGame) {
+ return E ;
+ }
+ return (
+
+
{helper.getScore(player)[0]}
+
}
+ trigger={
}
+ position="left center"
+ size="small"
+ className="score-tooltip"
+ />
+
+ );
}
const cyprusRoleColumn = {