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() { + @@ -99,49 +116,119 @@ export function PlayerStats() { - {players.map((player) => ( - - - - - - {columns.map((column) => { - const Cell = column.cell; - return ( - + + + + + - ); - })} - - - ))} + {columns.map((column) => { + const Cell = column.cell; + return ( + + ); + })} + + + {isExpanded && ( + + {/* Desktop expanded row cell: spans all desktop-visible columns */} + + {/* Mobile expanded row cell: spans all mobile-visible columns (6) */} + + + )} + + ); + })}
Player Stats
- - - - - {columns.map((column) => ( -
- {column.header}: -
- ))} -
- {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 ( + +
+ + + + + + + {columns.map((column) => ( +
+ {column.header}: +
+ ))} +
+ {columns.map((column) => { + const Cell = column.cell; + return ( +
+ +
+ ); + })}
- - Switch - -
+ + + + Switch + +
+ + + +
@@ -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 = {