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; }