diff --git a/.gitignore b/.gitignore
index cbc0dc64..bea5e686 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ builds
buildmeta.*
*.swp
*.tsbuildinfo
+src/e2e/artifacts/
scripts/seed_map_ratings_from_csv.js
diff --git a/src/client/game/player_expanded_row.module.css b/src/client/game/player_expanded_row.module.css
index 87b065dd..a2575cd3 100644
--- a/src/client/game/player_expanded_row.module.css
+++ b/src/client/game/player_expanded_row.module.css
@@ -132,6 +132,18 @@
color: #d1d5db;
}
+.panelSubsectionTitle {
+ font-weight: 600;
+ font-size: 13px;
+ color: #6b7280;
+ margin-top: 8px;
+ margin-bottom: 6px;
+}
+
+:global(.dark-mode) .panelSubsectionTitle {
+ color: #9ca3af;
+}
+
.panelCard {
background: white;
padding: 12px 16px;
@@ -371,6 +383,69 @@
margin-top: 4px;
}
+/* Monsoon scenarios */
+.monsoonScenariosGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 12px;
+ margin-top: 8px;
+}
+
+.monsoonScenario {
+ text-align: center;
+ padding: 8px;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+}
+
+:global(.dark-mode) .monsoonScenario {
+ border-color: #555;
+}
+
+.monsoonLabel {
+ font-size: 12px;
+ color: #6b7280;
+ margin-bottom: 4px;
+ font-weight: 500;
+}
+
+:global(.dark-mode) .monsoonLabel {
+ color: #9ca3af;
+}
+
+.monsoonValue {
+ font-weight: 600;
+ font-size: 14px;
+ margin-bottom: 4px;
+}
+
+.monsoonCost {
+ font-size: 11px;
+ color: #6b7280;
+ margin-bottom: 2px;
+}
+
+:global(.dark-mode) .monsoonCost {
+ color: #9ca3af;
+}
+
+.monsoonProbability {
+ font-size: 11px;
+ color: #6b7280;
+ font-style: italic;
+}
+
+:global(.dark-mode) .monsoonProbability {
+ color: #9ca3af;
+}
+
+.monsoonNeeds {
+ font-size: 11px;
+ color: #dc2626;
+ margin-top: 4px;
+ font-weight: 500;
+}
+
/* Eliminated player expanded */
.eliminatedMessage {
padding: 48px 24px;
diff --git a/src/client/game/player_expanded_row.tsx b/src/client/game/player_expanded_row.tsx
index e5279989..9b8a5980 100644
--- a/src/client/game/player_expanded_row.tsx
+++ b/src/client/game/player_expanded_row.tsx
@@ -9,6 +9,7 @@ import {
useInjected,
useInjectedState,
usePhaseState,
+ useViewSettings,
} from "../utils/injection_context";
import { Icon } from "semantic-ui-react";
@@ -165,14 +166,17 @@ function WarningBanners({ player }: { player: PlayerData }) {
function FinancialDetailsPanel({ player }: { player: PlayerData }) {
const profitHelper = useInjected(ProfitHelper);
+ const viewSettings = useViewSettings();
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;
+ const customExpenseItems =
+ viewSettings.useExpenseBreakdownItems?.(player) ?? [];
+ const monsoonScenarios = viewSettings.useMonsoonScenarios?.(player) ?? [];
return (
@@ -194,6 +198,7 @@ function FinancialDetailsPanel({ player }: { player: PlayerData }) {
-{formatMoney(expenses)}
+ {/* TODO: Maps with zero expenses (Denmark) might benefit from an explanatory note instead of showing $0 items. Consider adding useExpenseBreakdownNote hook in future. */}
• Locomotive maintenance:
-{formatMoney(player.locomotive)}
@@ -202,6 +207,12 @@ function FinancialDetailsPanel({ player }: { player: PlayerData }) {
• Share interest:
-{formatMoney(player.shares)}
+ {customExpenseItems.map((item, index) => (
+
+ • {item.label}
+ -{formatMoney(item.value)}
+
+ ))}
@@ -222,6 +233,44 @@ function FinancialDetailsPanel({ player }: { player: PlayerData }) {
: `${formatMoney(endOfTurnMoney)} (needs ${formatMoney(Math.abs(endOfTurnMoney))})`}
+ {monsoonScenarios.length > 0 && (
+ <>
+
+
+ Monsoon Scenarios (next income phase):
+
+
+ {monsoonScenarios.map((scenario, index) => {
+ const resultingMoney = endOfTurnMoney - scenario.cost;
+ return (
+
+
+ {scenario.description}
+
+
= 0 ? styles.valuePositive : styles.valueNegative}`}
+ >
+ {formatMoney(resultingMoney)}
+
+
+ {scenario.cost === 0
+ ? "No cost"
+ : `Cost: ${formatMoney(scenario.cost)}`}
+
+
+ {scenario.probability}
+
+ {resultingMoney < 0 && (
+
+ Needs {formatMoney(Math.abs(resultingMoney))}
+
+ )}
+
+ );
+ })}
+
+ >
+ )}
);
@@ -319,19 +368,31 @@ function LocoUpgradeImpactPanel({ player }: { player: PlayerData }) {
function ScoreBreakdownPanel({ player }: { player: PlayerData }) {
const playerHelper = useInjected(PlayerHelper);
+ const viewSettings = useViewSettings();
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];
+ // Get multipliers from helper (handles maps with custom multipliers)
+ const incomeMultiplier = playerHelper.getIncomeMultiplier();
+ const sharesMultiplier = playerHelper.getSharesMultiplier();
+
+ const customScoreItems = viewSettings.useScoreBreakdownItems?.(player) ?? [];
+
+ // Hide score breakdown for maps with non-VP scoring (e.g., Detroit, Barbados)
+ if (viewSettings.hideScoreBreakdown) {
+ return null;
+ }
+
return (
Score Breakdown
- Income points ({player.income} × 3):
+ Income points ({player.income} × {incomeMultiplier}):
+{incomePoints}
@@ -339,7 +400,7 @@ function ScoreBreakdownPanel({ player }: { player: PlayerData }) {
- Share penalty ({player.shares} × -3):
+ Share penalty ({player.shares} × {sharesMultiplier}):
{sharePoints}
@@ -353,6 +414,20 @@ function ScoreBreakdownPanel({ player }: { player: PlayerData }) {
+{trackPoints}
+ {/* TODO: Maps with complex formulas (Puerto Rico) could add breakdown items explaining adjustments (e.g., black cube income penalty). */}
+ {customScoreItems.map((item, index) => (
+
+ {item.label}
+ = 0 ? styles.valuePositive : styles.valueNegative
+ }`}
+ >
+ {item.value >= 0 ? "+" : ""}
+ {item.value}
+
+
+ ))}
Total Score:
@@ -473,12 +548,23 @@ interface ScoreTooltipContentProps {
export function ScoreTooltipContent({ player }: ScoreTooltipContentProps) {
const playerHelper = useInjected(PlayerHelper);
+ const viewSettings = useViewSettings();
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];
+ // Get multipliers from helper (handles maps with custom multipliers)
+ const incomeMultiplier = playerHelper.getIncomeMultiplier();
+ const sharesMultiplier = playerHelper.getSharesMultiplier();
+
+ if (viewSettings.hideScoreBreakdown) {
+ return null;
+ }
+
+ const customScoreItems = viewSettings.useScoreBreakdownItems?.(player) ?? [];
+
return (
Score Breakdown
@@ -486,7 +572,7 @@ export function ScoreTooltipContent({ player }: ScoreTooltipContentProps) {
|
- Income points ({player.income} × 3):
+ Income points ({player.income} × {incomeMultiplier}):
|
+{incomePoints}
@@ -494,7 +580,7 @@ export function ScoreTooltipContent({ player }: ScoreTooltipContentProps) {
|
|
- Share penalty ({player.shares} × -3):
+ Share penalty ({player.shares} × {sharesMultiplier}):
|
{sharePoints}
@@ -508,6 +594,19 @@ export function ScoreTooltipContent({ player }: ScoreTooltipContentProps) {
+{trackPoints}
|
+ {customScoreItems.map((item, index) => (
+
+ | {item.label} |
+ = 0 ? styles.valuePositive : styles.valueNegative
+ }`}
+ >
+ {item.value >= 0 ? "+" : ""}
+ {item.value}
+ |
+
+ ))}
| Total: |
{totalScore} |
diff --git a/src/e2e/e2e_test.ts b/src/e2e/e2e_test.ts
index 99418246..e68060fa 100644
--- a/src/e2e/e2e_test.ts
+++ b/src/e2e/e2e_test.ts
@@ -1,5 +1,6 @@
import { buildingTrack } from "./build_track_test";
import { creatingGame } from "./create_game_test";
+import { playerOverviewBreakdowns } from "./player_overview_breakdowns_test";
import { setUpServer } from "./util/server";
import { setTestTimeout } from "./util/timeout";
import { Driver, setUpWebDriver } from "./util/webdriver";
@@ -19,4 +20,6 @@ describe("e2e tests", () => {
describe("Building track", () => buildingTrack(driver));
describe("creating game", () => creatingGame(driver));
+ describe("player overview breakdowns", () =>
+ playerOverviewBreakdowns(driver));
});
diff --git a/src/e2e/player_overview_breakdowns_test.ts b/src/e2e/player_overview_breakdowns_test.ts
new file mode 100644
index 00000000..8a6638b2
--- /dev/null
+++ b/src/e2e/player_overview_breakdowns_test.ts
@@ -0,0 +1,239 @@
+import { By } from "selenium-webdriver";
+import { GameDao } from "../server/game/dao";
+import { UserDao } from "../server/user/dao";
+import { assert } from "../utils/validate";
+import { initializeUsers } from "./util/game_data";
+import { Driver } from "./util/webdriver";
+
+export function playerOverviewBreakdowns(driver: Driver) {
+ let users: UserDao[];
+ let game: GameDao | undefined | null;
+ const shouldCapture = process.env.E2E_SCREENSHOTS === "true";
+
+ beforeEach(async function setUpUsers() {
+ users = await initializeUsers();
+ });
+
+ afterEach(async function cleanUpGameData() {
+ if (game != null) {
+ await GameDao.destroy({ where: { id: game.id }, force: true });
+ }
+ });
+
+ it("shows x2 multiplier labels on London", async () => {
+ const creator = users[0];
+ game = await createGameForMap(creator, "london", "London multiplier smoke");
+
+ await fillAndStartGame(game, "london-seed");
+
+ await driver.goToGame(game.id, creator.id);
+ await expandFirstPlayer();
+
+ await driver.waitForElement(
+ By.xpath("//*[contains(., 'Income points') and contains(., '× 2')]"),
+ );
+ await driver.waitForElement(
+ By.xpath("//*[contains(., 'Share penalty') and contains(., '× -2')]"),
+ );
+ await maybeElementScreenshot(
+ "london-multiplier-breakdown",
+ By.xpath("//*[contains(., 'Score Breakdown')]/following-sibling::*[1]"),
+ );
+ });
+
+ it("hides score breakdown on Detroit", async () => {
+ const creator = users[0];
+ game = await createGameForMap(
+ creator,
+ "detroit-bankruptcy",
+ "Detroit score hidden smoke",
+ );
+
+ await fillAndStartGame(game, "detroit-seed");
+
+ await driver.goToGame(game.id, creator.id);
+ await expandFirstPlayer();
+
+ await driver.waitForElement(
+ By.xpath("//*[contains(text(), 'Financial Details')]"),
+ );
+ const scoreBreakdownHeaders = await driver.driver.findElements(
+ By.xpath("//*[contains(text(), 'Score Breakdown')]"),
+ );
+ expect(scoreBreakdownHeaders.length).toBe(0);
+ await maybeElementScreenshot(
+ "detroit-score-hidden",
+ By.xpath("//*[contains(., 'Financial Details')]/following-sibling::*[1]"),
+ );
+ });
+
+ it("hides score breakdown on Barbados", async () => {
+ const creator = users[0];
+ game = await createGameForMap(
+ creator,
+ "barbados",
+ "Barbados score hidden smoke",
+ );
+
+ await fillAndStartGame(game, "barbados-seed");
+
+ await driver.goToGame(game.id, creator.id);
+ await expandFirstPlayer();
+
+ await driver.waitForElement(
+ By.xpath("//*[contains(., 'Financial Details')]"),
+ );
+ const scoreBreakdownHeaders = await driver.driver.findElements(
+ By.xpath("//*[contains(., 'Score Breakdown')]"),
+ );
+ expect(scoreBreakdownHeaders.length).toBe(0);
+ await maybeElementScreenshot(
+ "barbados-score-hidden",
+ By.xpath("//*[contains(., 'Financial Details')]/following-sibling::*[1]"),
+ );
+ });
+
+ it("shows monsoon scenarios with probabilities on India", async () => {
+ const creator = users[0];
+ game = await createGameForMap(creator, "india", "India monsoon smoke");
+
+ await fillAndStartGame(game, "india-seed");
+
+ await driver.goToGame(game.id, creator.id);
+ await expandFirstPlayer();
+
+ await driver.waitForElement(
+ By.xpath("//*[contains(., 'Monsoon Scenarios (next income phase):')]"),
+ );
+ await driver.waitForElement(By.xpath("//*[contains(., 'No monsoon')]"));
+ await driver.waitForElement(By.xpath("//*[contains(., '17%')]"));
+ await driver.waitForElement(By.xpath("//*[contains(., 'Light monsoon')]"));
+ await driver.waitForElement(By.xpath("//*[contains(., '67%')]"));
+ await driver.waitForElement(By.xpath("//*[contains(., 'Heavy monsoon')]"));
+ await maybeElementScreenshot(
+ "india-monsoon-scenarios",
+ By.xpath(
+ "//*[contains(., 'Monsoon Scenarios (next income phase):')]/ancestor::*[contains(@class, 'panelCard')][1]",
+ ),
+ );
+ });
+
+ async function createGameForMap(
+ creationUser: UserDao,
+ mapKey: string,
+ name: string,
+ ): Promise {
+ await driver.goTo("/app/games/create", creationUser.id);
+ const currentUrl = new URL(await driver.driver.getCurrentUrl());
+ currentUrl.pathname = "/app/games/create";
+ currentUrl.search = `?map=${encodeURIComponent(mapKey)}`;
+ await driver.driver.get(currentUrl.toString());
+ await driver.waitForElement(By.name("name")).sendKeys(name);
+ await driver.waitForElement(By.xpath("//*[@data-auto-start]")).click();
+ await driver.waitForElement(By.xpath("//*[@data-create-button]")).click();
+ await driver.waitForElement(By.xpath("//*[@data-game-card]"));
+
+ const createdGame = await GameDao.findByPk(await driver.getGameId());
+ assert(createdGame != null);
+ expect(createdGame.gameKey).toBe(mapKey);
+ return createdGame;
+ }
+
+ async function joinGame(user: UserDao, createdGame: GameDao) {
+ await driver.goToGame(createdGame.id, user.id);
+ for (let i = 0; i < 40; i++) {
+ const joinButtons = await driver.driver.findElements(
+ By.xpath("//*[@data-join-button]"),
+ );
+ if (joinButtons.length > 0) {
+ await joinButtons[0].click();
+ return true;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ return false;
+ }
+
+ async function startGame(
+ createdGame: GameDao,
+ seedValue: string,
+ ): Promise {
+ await driver.goToGame(createdGame.id, createdGame.playerIds[0]);
+ let startButtonFound = false;
+ for (let i = 0; i < 40; i++) {
+ const startButtons = await driver.driver.findElements(
+ By.xpath("//*[@data-start-button]"),
+ );
+ if (startButtons.length > 0) {
+ startButtonFound = true;
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ if (!startButtonFound) {
+ return;
+ }
+
+ const seedEl = await driver.waitForElement(By.xpath('//*[@name="seed"]'));
+ await driver.driver.executeScript(
+ `arguments[0].value = arguments[1];`,
+ seedEl,
+ seedValue,
+ );
+ await driver.waitForElement(By.xpath("//*[@data-start-button]")).click();
+ await driver.waitForSuccess();
+ }
+
+ async function fillAndStartGame(
+ createdGame: GameDao,
+ seedValue: string,
+ ): Promise {
+ for (const user of users.slice(1)) {
+ const didJoin = await joinGame(user, createdGame);
+ if (!didJoin) {
+ break;
+ }
+ }
+ await startGame(createdGame, seedValue);
+ }
+
+ async function expandFirstPlayer(): Promise {
+ const button = await driver.waitForElement(
+ By.css("button[aria-label='Expand player details']"),
+ );
+ await button.click();
+ // Wait for expansion animation to complete
+ await new Promise((r) => setTimeout(r, 500));
+ }
+
+ async function maybeElementScreenshot(name: string, by: By): Promise {
+ if (!shouldCapture) {
+ return;
+ }
+
+ // Set a taller window size to capture expanded content without scrolling
+ await driver.driver
+ .manage()
+ .window()
+ .setRect({ width: 1920, height: 1500 });
+
+ // Wait for the target element to ensure it exists and center it in viewport
+ const targetElement = await driver.waitForElement(by);
+ await driver.driver.executeScript(
+ "arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });",
+ targetElement,
+ );
+
+ // Small delay for any animations to complete
+ await new Promise((r) => setTimeout(r, 500));
+
+ // Take full page screenshot
+ await driver.saveScreenshot(name);
+
+ // Reset to normal size
+ await driver.driver
+ .manage()
+ .window()
+ .setRect({ width: 1920, height: 1080 });
+ }
+}
diff --git a/src/e2e/util/webdriver.ts b/src/e2e/util/webdriver.ts
index 87132851..9c376d14 100644
--- a/src/e2e/util/webdriver.ts
+++ b/src/e2e/util/webdriver.ts
@@ -1,3 +1,5 @@
+import { mkdir, writeFile } from "fs/promises";
+import { resolve } from "path";
import {
Browser,
Builder,
@@ -96,6 +98,42 @@ export class Driver {
return url.pathname;
}
+ async saveScreenshot(name: string): Promise {
+ const image = await this.driver.takeScreenshot();
+ const artifactsDir = resolve(__dirname, "../artifacts/screenshots");
+ await mkdir(artifactsDir, { recursive: true });
+
+ const safeName = name
+ .toLowerCase()
+ .replace(/[^a-z0-9-_]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ const path = resolve(artifactsDir, `${safeName}.png`);
+ await writeFile(path, image, "base64");
+ return path;
+ }
+
+ async saveElementScreenshot(name: string, by: By): Promise {
+ const element = await this.waitForElement(by);
+ await this.driver.executeScript(
+ "arguments[0].scrollIntoView({ block: 'center', inline: 'center' });",
+ element,
+ );
+ const image = await element.takeScreenshot();
+
+ const artifactsDir = resolve(__dirname, "../artifacts/screenshots");
+ await mkdir(artifactsDir, { recursive: true });
+
+ const safeName = name
+ .toLowerCase()
+ .replace(/[^a-z0-9-_]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ const path = resolve(artifactsDir, `${safeName}.png`);
+ await writeFile(path, image, "base64");
+ return path;
+ }
+
async buildTrack(
coordinates: Coordinates,
tileType: TileType,
diff --git a/src/engine/game/player.ts b/src/engine/game/player.ts
index cd948cf9..292f8f22 100644
--- a/src/engine/game/player.ts
+++ b/src/engine/game/player.ts
@@ -112,14 +112,22 @@ export class PlayerHelper {
);
}
+ getIncomeMultiplier(): number {
+ return 3;
+ }
+
+ getSharesMultiplier(): number {
+ return -3;
+ }
+
getScoreFromIncome(player: PlayerData): number {
if (player.outOfGame) return 0;
- return 3 * player.income;
+ return this.getIncomeMultiplier() * player.income;
}
getScoreFromShares(player: PlayerData): number {
if (player.outOfGame) return 0;
- return -3 * player.shares;
+ return this.getSharesMultiplier() * player.shares;
}
getScoreFromTrack(player: PlayerData): number {
diff --git a/src/engine/game/player_multipliers_test.ts b/src/engine/game/player_multipliers_test.ts
new file mode 100644
index 00000000..7ebbe0a4
--- /dev/null
+++ b/src/engine/game/player_multipliers_test.ts
@@ -0,0 +1,22 @@
+import { PlayerHelper } from "./player";
+import { InjectionHelper } from "../../testing/injection_helper";
+
+describe("PlayerHelper multipliers", () => {
+ InjectionHelper.install();
+
+ describe("getIncomeMultiplier", () => {
+ it("returns 3 for base game", () => {
+ const helper = new PlayerHelper();
+ const multiplier = helper.getIncomeMultiplier();
+ expect(multiplier).toBe(3);
+ });
+ });
+
+ describe("getSharesMultiplier", () => {
+ it("returns -3 for base game", () => {
+ const helper = new PlayerHelper();
+ const multiplier = helper.getSharesMultiplier();
+ expect(multiplier).toBe(-3);
+ });
+ });
+});
diff --git a/src/maps/barbados/view_settings.ts b/src/maps/barbados/view_settings.ts
index 911ffd00..72f2b259 100644
--- a/src/maps/barbados/view_settings.ts
+++ b/src/maps/barbados/view_settings.ts
@@ -7,4 +7,5 @@ export class BarbadosViewSettings
implements MapViewSettings
{
getMapRules = BarbadosRules;
+ hideScoreBreakdown = true; // Barbados uses money-as-score for solo play, not VPs
}
diff --git a/src/maps/ca-gold-rush/view_settings.ts b/src/maps/ca-gold-rush/view_settings.ts
index 7846ec43..7342989d 100644
--- a/src/maps/ca-gold-rush/view_settings.ts
+++ b/src/maps/ca-gold-rush/view_settings.ts
@@ -9,7 +9,11 @@ import { CaliforniaGoldRushMineAction } from "./mine_action";
import { Good } from "../../engine/state/good";
import { Space } from "../../engine/map/grid";
import { Land } from "../../engine/map/location";
-import { useCurrentPlayer } from "../../client/utils/injection_context";
+import {
+ useCurrentPlayer,
+ useInjectedState,
+} from "../../client/utils/injection_context";
+import { PlayerData } from "../../engine/state/player";
import {
getRowList,
RowFactory,
@@ -17,6 +21,7 @@ import {
} from "../../client/game/final_overview_row";
import { insertAfter } from "../../utils/functions";
import { GoldVps } from "./gold_vps";
+import { OwnedGold } from "./score";
export class CaliforniaGoldRushViewSettings
extends CaliforniaGoldRushMapSettings
@@ -36,12 +41,24 @@ export class CaliforniaGoldRushViewSettings
}
useOnMapClick = useMineGoldClick;
+ useScoreBreakdownItems = useCaliforniaGoldRushScoreBreakdown;
getFinalOverviewRows(): RowFactory[] {
return insertAfter(getRowList(), TrackVps, GoldVps);
}
}
+function useCaliforniaGoldRushScoreBreakdown(
+ player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const ownedGold = useInjectedState(OwnedGold);
+ const count = ownedGold.get(player.color) ?? 0;
+ const points = count * 15;
+
+ if (points === 0) return [];
+ return [{ label: `Gold bonus (${count} gold × 15):`, value: points }];
+}
+
function useMineGoldClick(on: OnClickRegister) {
const { canEmit, emit, isPending } = useAction(CaliforniaGoldRushMineAction);
const current = useCurrentPlayer();
diff --git a/src/maps/chesapeake-and-ohio/view_settings.ts b/src/maps/chesapeake-and-ohio/view_settings.ts
index 227353de..ac193da6 100644
--- a/src/maps/chesapeake-and-ohio/view_settings.ts
+++ b/src/maps/chesapeake-and-ohio/view_settings.ts
@@ -1,11 +1,14 @@
+import { useMemo } from "react";
import { ChesapeakeAndOhioRules } from "./rules";
import { ChesapeakeAndOhioMapSettings } from "./settings";
import { MapViewSettings } from "../view_settings";
import { Phase } from "../../engine/state/phase";
+import { PlayerData } from "../../engine/state/player";
import { BuildActionSummary } from "./build_action_summary";
import { ClickTarget, OnClickRegister } from "../../client/grid/click_target";
import { useAction } from "../../client/services/action";
-import { BuildFactoryAction } from "./build";
+import { useGrid } from "../../client/utils/injection_context";
+import { BuildFactoryAction, ChesapeakeAndOhioMapData } from "./build";
import {
ChesapeakeAndOhioOverlayLayer,
ChesapeakeAndOhioRivers,
@@ -27,6 +30,27 @@ export class ChesapeakeAndOhioViewSettings
}
}
useOnMapClick = useFactoryClick;
+ useExpenseBreakdownItems = useChesapeakeExpenseBreakdown;
+}
+
+function useChesapeakeExpenseBreakdown(
+ player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const grid = useGrid();
+
+ const factoryCount = useMemo(() => {
+ let count = 0;
+ for (const city of grid.cities()) {
+ const mapData = city.getMapSpecific(ChesapeakeAndOhioMapData.parse);
+ if (mapData && mapData.factoryColor === player.color) {
+ count += 1;
+ }
+ }
+ return count;
+ }, [grid, player.color]);
+
+ if (factoryCount === 0) return [];
+ return [{ label: "Factory maintenance:", value: factoryCount }];
}
function useFactoryClick(on: OnClickRegister) {
diff --git a/src/maps/chicago-l/score.ts b/src/maps/chicago-l/score.ts
index 09db3855..812b1aac 100644
--- a/src/maps/chicago-l/score.ts
+++ b/src/maps/chicago-l/score.ts
@@ -2,6 +2,14 @@ import { PlayerHelper } from "../../engine/game/player";
import { PlayerData } from "../../engine/state/player";
export class ChicagoLPlayerHelper extends PlayerHelper {
+ getIncomeMultiplier(): number {
+ return 2;
+ }
+
+ getSharesMultiplier(): number {
+ return -2;
+ }
+
getScoreFromIncome(player: PlayerData): number {
if (player.outOfGame) return 0;
return 2 * player.income;
diff --git a/src/maps/chicago-l/score_multipliers_test.ts b/src/maps/chicago-l/score_multipliers_test.ts
new file mode 100644
index 00000000..d7652e09
--- /dev/null
+++ b/src/maps/chicago-l/score_multipliers_test.ts
@@ -0,0 +1,22 @@
+import { ChicagoLPlayerHelper } from "./score";
+import { InjectionHelper } from "../../testing/injection_helper";
+
+describe("ChicagoLPlayerHelper multipliers", () => {
+ InjectionHelper.install();
+
+ describe("getIncomeMultiplier", () => {
+ it("returns 2 for Chicago-L (instead of 3)", () => {
+ const helper = new ChicagoLPlayerHelper();
+ const multiplier = helper.getIncomeMultiplier();
+ expect(multiplier).toBe(2);
+ });
+ });
+
+ describe("getSharesMultiplier", () => {
+ it("returns -2 for Chicago-L (instead of -3)", () => {
+ const helper = new ChicagoLPlayerHelper();
+ const multiplier = helper.getSharesMultiplier();
+ expect(multiplier).toBe(-2);
+ });
+ });
+});
diff --git a/src/maps/detroit/view_settings.ts b/src/maps/detroit/view_settings.ts
index 4c7a9e6f..b12b58a4 100644
--- a/src/maps/detroit/view_settings.ts
+++ b/src/maps/detroit/view_settings.ts
@@ -7,9 +7,14 @@ import {
TotalVps,
TrackVps,
} from "../../client/game/final_overview_row";
-import { useInject } from "../../client/utils/injection_context";
+import {
+ useInject,
+ useInjectedState,
+} from "../../client/utils/injection_context";
import { injectInitialPlayerCount } from "../../engine/game/state";
+import { ROUND } from "../../engine/game/round";
import { Action } from "../../engine/state/action";
+import { PlayerData } from "../../engine/state/player";
import { insertBefore } from "../../utils/functions";
import { getActionCaption } from "./action_caption";
import { SoloPlacement } from "./final_solo_situation";
@@ -20,6 +25,8 @@ import { DetroitBankruptcyMapSettings } from "./settings";
export class DetroitBankruptcyViewSettings extends DetroitBankruptcyMapSettings {
getMapRules = DetroitRules;
getActionCaption = getActionCaption;
+ hideScoreBreakdown = true; // Detroit uses survival-based tuple scoring, not VPs
+ useExpenseBreakdownItems = useDetroitExpenseBreakdown;
getFinalOverviewRows(): RowFactory[] {
const playerCount = useInject(() => injectInitialPlayerCount()(), []);
@@ -42,3 +49,10 @@ export class DetroitBankruptcyViewSettings extends DetroitBankruptcyMapSettings
}
}
}
+
+function useDetroitExpenseBreakdown(
+ _player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const round = useInjectedState(ROUND);
+ return [{ label: "Round expense:", value: round }];
+}
diff --git a/src/maps/india-steam-brothers/monsoon_scenarios.ts b/src/maps/india-steam-brothers/monsoon_scenarios.ts
new file mode 100644
index 00000000..3565fb94
--- /dev/null
+++ b/src/maps/india-steam-brothers/monsoon_scenarios.ts
@@ -0,0 +1,13 @@
+import { PlayerData } from "../../engine/state/player";
+
+export function useMonsoonScenarios(_player: PlayerData) {
+ // Monsoon costs based on die roll:
+ // 1 = $0 (1 in 6 = 17%)
+ // 2-5 = $1 (4 in 6 = 67%)
+ // 6 = $2 (1 in 6 = 17%)
+ return [
+ { description: "No monsoon", cost: 0, probability: "17% (1 in 6)" },
+ { description: "Light monsoon", cost: 1, probability: "67% (2 in 3)" },
+ { description: "Heavy monsoon", cost: 2, probability: "17% (1 in 6)" },
+ ];
+}
diff --git a/src/maps/india-steam-brothers/monsoon_scenarios_test.ts b/src/maps/india-steam-brothers/monsoon_scenarios_test.ts
new file mode 100644
index 00000000..27d5ff27
--- /dev/null
+++ b/src/maps/india-steam-brothers/monsoon_scenarios_test.ts
@@ -0,0 +1,29 @@
+import { PlayerColor, PlayerData } from "../../engine/state/player";
+import { useMonsoonScenarios } from "./monsoon_scenarios";
+
+describe("useMonsoonScenarios (India)", () => {
+ const COLOR = PlayerColor.BLUE;
+
+ function playerData(data: Partial): PlayerData {
+ return { color: COLOR, ...data } as PlayerData;
+ }
+
+ it("returns all three monsoon scenarios with correct probabilities", () => {
+ const mockPlayer = playerData({});
+ const scenarios = useMonsoonScenarios(mockPlayer);
+
+ expect(scenarios).toEqual([
+ { description: "No monsoon", cost: 0, probability: "17% (1 in 6)" },
+ { description: "Light monsoon", cost: 1, probability: "67% (2 in 3)" },
+ { description: "Heavy monsoon", cost: 2, probability: "17% (1 in 6)" },
+ ]);
+ });
+
+ it("have correct cost values representing monsoon expenses", () => {
+ const scenarios = useMonsoonScenarios(playerData({}));
+
+ expect(scenarios[0].cost).toBe(0);
+ expect(scenarios[1].cost).toBe(1);
+ expect(scenarios[2].cost).toBe(2);
+ });
+});
diff --git a/src/maps/india-steam-brothers/view_settings.ts b/src/maps/india-steam-brothers/view_settings.ts
index 7050ea9c..61088859 100644
--- a/src/maps/india-steam-brothers/view_settings.ts
+++ b/src/maps/india-steam-brothers/view_settings.ts
@@ -5,6 +5,7 @@ import { SelectCityAction } from "./production";
import { IndiaSteamBrothersRivers } from "./rivers";
import { IndiaSteamBrothersRules } from "./rules";
import { IndiaSteamBrothersMapSettings } from "./settings";
+import { useMonsoonScenarios } from "./monsoon_scenarios";
export class IndiaSteamBrothersViewSettings
extends IndiaSteamBrothersMapSettings
@@ -14,6 +15,7 @@ export class IndiaSteamBrothersViewSettings
getTexturesLayer = IndiaSteamBrothersRivers;
useOnMapClick = useSelectCityOnClick;
+ useMonsoonScenarios = useMonsoonScenarios;
}
function useSelectCityOnClick(on: OnClickRegister) {
diff --git a/src/maps/london/score.ts b/src/maps/london/score.ts
index a2481eac..5b78675d 100644
--- a/src/maps/london/score.ts
+++ b/src/maps/london/score.ts
@@ -2,6 +2,14 @@ import { PlayerHelper } from "../../engine/game/player";
import { PlayerData } from "../../engine/state/player";
export class LondonPlayerHelper extends PlayerHelper {
+ getIncomeMultiplier(): number {
+ return 2;
+ }
+
+ getSharesMultiplier(): number {
+ return -2;
+ }
+
getScoreFromIncome(player: PlayerData): number {
if (player.outOfGame) return 0;
return 2 * player.income;
diff --git a/src/maps/london/score_multipliers_test.ts b/src/maps/london/score_multipliers_test.ts
new file mode 100644
index 00000000..7249751b
--- /dev/null
+++ b/src/maps/london/score_multipliers_test.ts
@@ -0,0 +1,22 @@
+import { LondonPlayerHelper } from "./score";
+import { InjectionHelper } from "../../testing/injection_helper";
+
+describe("LondonPlayerHelper multipliers", () => {
+ InjectionHelper.install();
+
+ describe("getIncomeMultiplier", () => {
+ it("returns 2 for London (instead of 3)", () => {
+ const helper = new LondonPlayerHelper();
+ const multiplier = helper.getIncomeMultiplier();
+ expect(multiplier).toBe(2);
+ });
+ });
+
+ describe("getSharesMultiplier", () => {
+ it("returns -2 for London (instead of -3)", () => {
+ const helper = new LondonPlayerHelper();
+ const multiplier = helper.getSharesMultiplier();
+ expect(multiplier).toBe(-2);
+ });
+ });
+});
diff --git a/src/maps/montreal_metro/view_settings.ts b/src/maps/montreal_metro/view_settings.ts
index efa0d266..4f3f51bc 100644
--- a/src/maps/montreal_metro/view_settings.ts
+++ b/src/maps/montreal_metro/view_settings.ts
@@ -1,7 +1,10 @@
import { ClickTarget, OnClickRegister } from "../../client/grid/click_target";
import { useAction } from "../../client/services/action";
+import { useInjectedState } from "../../client/utils/injection_context";
+import { PlayerData } from "../../engine/state/player";
import { MapViewSettings } from "../view_settings";
import { GovtBuildOrder } from "./govt_build_order";
+import { GOVERNMENT_ENGINE_LEVEL } from "./government_engine_level";
import { LocoTrack } from "./loco_track";
import { MontrealMetroRules } from "./rules";
import { RepopulateAction } from "./select_action/repopulate";
@@ -19,6 +22,17 @@ export class MontrealMetroViewSettings
additionalSliders = [LocoTrack, GovtBuildOrder];
useOnMapClick = useRepopulateOnClick;
+ useExpenseBreakdownItems = useMontrealMetroExpenseBreakdown;
+}
+
+function useMontrealMetroExpenseBreakdown(
+ player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const govtEngineLevel = useInjectedState(GOVERNMENT_ENGINE_LEVEL);
+ const level = govtEngineLevel.get(player.color) ?? 0;
+
+ if (level === 0) return [];
+ return [{ label: "Government engine level:", value: level }];
}
function useRepopulateOnClick(on: OnClickRegister) {
diff --git a/src/maps/new_england/breakdown_test.ts b/src/maps/new_england/breakdown_test.ts
new file mode 100644
index 00000000..cd2e2810
--- /dev/null
+++ b/src/maps/new_england/breakdown_test.ts
@@ -0,0 +1,54 @@
+import { PlayerColor, PlayerData } from "../../engine/state/player";
+import { useNewEnglandScoreBreakdown } from "./view_settings";
+
+describe("useNewEnglandScoreBreakdown", () => {
+ const COLOR = PlayerColor.BLUE;
+
+ function playerData(data: Partial): PlayerData {
+ return { color: COLOR, ...data } as PlayerData;
+ }
+
+ describe("money bonus calculation", () => {
+ it("returns empty array when player has no money", () => {
+ const player = playerData({ money: 0 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([]);
+ });
+
+ it("returns correct bonus for money = 20", () => {
+ const player = playerData({ money: 20 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([{ label: "Money bonus ($20 ÷ 20):", value: 1 }]);
+ });
+
+ it("returns correct bonus for money = 40", () => {
+ const player = playerData({ money: 40 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([{ label: "Money bonus ($40 ÷ 20):", value: 2 }]);
+ });
+
+ it("returns correct bonus for money = 50", () => {
+ const player = playerData({ money: 50 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([{ label: "Money bonus ($50 ÷ 20):", value: 2 }]);
+ });
+
+ it("returns correct bonus for money = 99", () => {
+ const player = playerData({ money: 99 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([{ label: "Money bonus ($99 ÷ 20):", value: 4 }]);
+ });
+
+ it("returns correct bonus for money = 100", () => {
+ const player = playerData({ money: 100 });
+ const items = useNewEnglandScoreBreakdown(player);
+
+ expect(items).toEqual([{ label: "Money bonus ($100 ÷ 20):", value: 5 }]);
+ });
+ });
+});
diff --git a/src/maps/new_england/view_settings.ts b/src/maps/new_england/view_settings.ts
index a89d3c81..03a887c8 100644
--- a/src/maps/new_england/view_settings.ts
+++ b/src/maps/new_england/view_settings.ts
@@ -1,3 +1,4 @@
+import { PlayerData } from "../../engine/state/player";
import { MapViewSettings } from "../view_settings";
import { NewEnglandRules } from "./rules";
import { NewEnglandMapSettings } from "./settings";
@@ -7,4 +8,14 @@ export class NewEnglandViewSettings
implements MapViewSettings
{
getMapRules = NewEnglandRules;
+ useScoreBreakdownItems = useNewEnglandScoreBreakdown;
+}
+
+export function useNewEnglandScoreBreakdown(
+ player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const bonus = Math.floor(player.money / 20);
+
+ if (bonus === 0) return [];
+ return [{ label: `Money bonus ($${player.money} ÷ 20):`, value: bonus }];
}
diff --git a/src/maps/sweden/view_settings.ts b/src/maps/sweden/view_settings.ts
index 5a652b7e..69cd37a0 100644
--- a/src/maps/sweden/view_settings.ts
+++ b/src/maps/sweden/view_settings.ts
@@ -3,10 +3,13 @@ import {
RowFactory,
TrackVps,
} from "../../client/game/final_overview_row";
+import { useInjected } from "../../client/utils/injection_context";
+import { PlayerData } from "../../engine/state/player";
import { insertAfter } from "../../utils/functions";
import { MapViewSettings } from "../view_settings";
import { GarbageVps } from "./garbage_vps";
import { SwedenRules } from "./rules";
+import { SwedenPlayerHelper } from "./score";
import { SwedenRecyclingMapSettings } from "./settings";
export class SwedenRecyclingViewSettings
@@ -14,8 +17,20 @@ export class SwedenRecyclingViewSettings
implements MapViewSettings
{
getMapRules = SwedenRules;
+ useScoreBreakdownItems = useSwedenScoreBreakdown;
getFinalOverviewRows(): RowFactory[] {
return insertAfter(getRowList(), TrackVps, GarbageVps);
}
}
+
+function useSwedenScoreBreakdown(
+ player: PlayerData,
+): Array<{ label: string; value: number }> {
+ const playerHelper = useInjected(SwedenPlayerHelper);
+ const points = playerHelper.getScoreFromGarbage(player);
+ const count = points / 2;
+
+ if (points === 0) return [];
+ return [{ label: `Garbage (${count} cubes × 2):`, value: points }];
+}
diff --git a/src/maps/view_settings.ts b/src/maps/view_settings.ts
index 61288fb2..ff3069d4 100644
--- a/src/maps/view_settings.ts
+++ b/src/maps/view_settings.ts
@@ -45,5 +45,15 @@ export interface MapViewSettings extends MapSettings {
header: string;
cell: ({ player }: { player: PlayerData }) => ReactNode;
}>;
+ useExpenseBreakdownItems?: (
+ player: PlayerData,
+ ) => Array<{ label: string; value: number }>;
+ useScoreBreakdownItems?: (
+ player: PlayerData,
+ ) => Array<{ label: string; value: number }>;
+ hideScoreBreakdown?: boolean;
+ useMonsoonScenarios?: (
+ player: PlayerData,
+ ) => Array<{ description: string; cost: number; probability: string }>;
useOnMapClick?: OnClickFunction;
}