diff --git a/src/client/game/action_summary.tsx b/src/client/game/action_summary.tsx index 5d161193..15ab8973 100644 --- a/src/client/game/action_summary.tsx +++ b/src/client/game/action_summary.tsx @@ -95,6 +95,7 @@ export function ActionSummary() { return ; case Phase.GOVERNMENT_BUILD: return ; + case Phase.STALINIST_LOCOMOTIVE: case Phase.ROLE_SELECTION: case Phase.GOODS_GROWTH: case Phase.INCOME: diff --git a/src/engine/state/action.ts b/src/engine/state/action.ts index b36f1424..f043331d 100644 --- a/src/engine/state/action.ts +++ b/src/engine/state/action.ts @@ -59,6 +59,9 @@ export enum Action { // Central New England SMUGGLE = 27, + + // Stalinist Russia + POLITBURO_DIRECTIVE = 28, } export const ActionZod = z.nativeEnum(Action); @@ -122,6 +125,8 @@ export class ActionNamingProvider { return "Goldsmith"; case Action.SMUGGLE: return "Smuggle"; + case Action.POLITBURO_DIRECTIVE: + return "Politburo Directive"; default: assertNever(action); } @@ -183,6 +188,8 @@ export class ActionNamingProvider { return "Deliver gold without spending mining expertise or increase income for gold deliveries."; case Action.SMUGGLE: return "You may make both of your deliveries within a single state."; + case Action.POLITBURO_DIRECTIVE: + return "For one of your deliveries, you may start from Moscow."; default: assertNever(action); } diff --git a/src/engine/state/phase.ts b/src/engine/state/phase.ts index e40dda2c..3b177b42 100644 --- a/src/engine/state/phase.ts +++ b/src/engine/state/phase.ts @@ -33,6 +33,9 @@ export enum Phase { // Mexico, Cyprus ROLE_SELECTION = 17, + + // Stalinist Russia + STALINIST_LOCOMOTIVE = 18, } export const PhaseZod = z.nativeEnum(Phase); @@ -71,6 +74,8 @@ export function getPhaseString(phase: Phase): string { return "Government build"; case Phase.ROLE_SELECTION: return "Role selection"; + case Phase.STALINIST_LOCOMOTIVE: + return "Locomotive"; default: assertNever(phase); } diff --git a/src/maps/registry.ts b/src/maps/registry.ts index a6ec4265..8158a38a 100644 --- a/src/maps/registry.ts +++ b/src/maps/registry.ts @@ -50,6 +50,7 @@ import { SicilyMapSettings } from "./sicily/settings"; import { SoulTrainMapSettings } from "./soultrain/settings"; import { SouthernUsMapSettings } from "./southern_us/settings"; import { StLuciaMapSettings } from "./st-lucia/settings"; +import { StalinistRussiaMapSettings } from "./stalinist_russia/settings"; import { SwedenRecyclingMapSettings } from "./sweden/settings"; import { TrislandMapSettings } from "./trisland/settings"; import { UnionPacificExpressMapSettings } from "./union_pacific_express/settings"; @@ -108,6 +109,7 @@ export class MapRegistry { this.add(new SoulTrainMapSettings()); this.add(new SouthernUsMapSettings()); this.add(new StLuciaMapSettings()); + this.add(new StalinistRussiaMapSettings()); this.add(new SwedenRecyclingMapSettings()); this.add(new TrislandMapSettings()); this.add(new UnionPacificExpressMapSettings()); diff --git a/src/maps/stalinist_russia/actions.ts b/src/maps/stalinist_russia/actions.ts new file mode 100644 index 00000000..fe011660 --- /dev/null +++ b/src/maps/stalinist_russia/actions.ts @@ -0,0 +1,34 @@ +import { Set as ImmutableSet } from "immutable"; +import { injectInitialPlayerCount } from "../../engine/game/state"; +import { AllowedActions } from "../../engine/select_action/allowed_actions"; +import { SelectAction } from "../../engine/select_action/select"; +import { Action, ActionNamingProvider } from "../../engine/state/action"; + +export class StalinistRussiaAllowedActions extends AllowedActions { + private readonly playerCount = injectInitialPlayerCount(); + + getActions(): ImmutableSet { + let actions = super.getActions().add(Action.POLITBURO_DIRECTIVE); + // The Engineer action is only available at five players. + if (this.playerCount() !== 5) { + actions = actions.delete(Action.ENGINEER); + } + return actions; + } +} + +export class StalinistRussiaSelectAction extends SelectAction { + protected applyLocomotive(): void { + // The Locomotive action does not immediately raise the locomotive on this + // map. Instead it grants going first during the dedicated locomotive phase. + } +} + +export class StalinistRussiaActionNamingProvider extends ActionNamingProvider { + getActionDescription(action: Action): string { + if (action === Action.LOCOMOTIVE) { + return "Go first during the Locomotive phase."; + } + return super.getActionDescription(action); + } +} diff --git a/src/maps/stalinist_russia/build.ts b/src/maps/stalinist_russia/build.ts new file mode 100644 index 00000000..9ccb2c75 --- /dev/null +++ b/src/maps/stalinist_russia/build.ts @@ -0,0 +1,38 @@ +import { Coordinates } from "../../utils/coordinates"; +import { injectState } from "../../engine/framework/execution_context"; +import { injectCurrentPlayer, TURN_ORDER } from "../../engine/game/state"; +import { BuildCostCalculator } from "../../engine/build/cost"; +import { SpaceType } from "../../engine/state/location_type"; +import { LandType } from "../../engine/state/space"; +import { Direction, TileType } from "../../engine/state/tile"; + +export class StalinistRussiaBuildCostCalculator extends BuildCostCalculator { + private readonly currentPlayer = injectCurrentPlayer(); + private readonly turnOrder = injectState(TURN_ORDER); + + costOf( + coordinates: Coordinates, + newTileType: TileType, + orientation: Direction, + ): number { + let cost = super.costOf(coordinates, newTileType, orientation); + // The player who is last in turn order (who received Stalin's disfavor this + // round) pays $1 extra per tile lay during the build phase. + const order = this.turnOrder(); + if ( + order.length > 0 && + order[order.length - 1] === this.currentPlayer().color + ) { + cost += 1; + } + return cost; + } + + protected getCostOfLandTypeForTown(type: LandType): number { + // Towns with rivers cost an extra $1 on top of the usual cost. + if (type === SpaceType.RIVER) { + return super.getCostOfLandTypeForTown(type) + 1; + } + return super.getCostOfLandTypeForTown(type); + } +} diff --git a/src/maps/stalinist_russia/disfavor.ts b/src/maps/stalinist_russia/disfavor.ts new file mode 100644 index 00000000..de372e34 --- /dev/null +++ b/src/maps/stalinist_russia/disfavor.ts @@ -0,0 +1,40 @@ +import { injectState } from "../../engine/framework/execution_context"; +import { PlayerHelper } from "../../engine/game/player"; +import { PlayerColor, PlayerData } from "../../engine/state/player"; +import { TurnOrderHelper } from "../../engine/turn_order/helper"; +import { DISFAVOR_TRACK, DISFAVOR_VALUES, MAX_DISFAVOR } from "./state"; + +export class StalinistRussiaTurnOrderHelper extends TurnOrderHelper { + private readonly disfavorTrack = injectState(DISFAVOR_TRACK); + + pass(player: PlayerData): void { + // The first player to pass becomes the last player in turn order and earns + // Stalin's disfavor. nextTurnOrder is still empty at that point. + const becomesLastPlayer = this.turnOrderState().nextTurnOrder.length === 0; + super.pass(player); + if (becomesLastPlayer) { + this.disfavorTrack.update((track) => { + const current = track.get(player.color) ?? 0; + track.set(player.color, Math.min(current + 1, MAX_DISFAVOR)); + }); + this.log.player(player, "advances on Stalin's disfavor track"); + } + } +} + +export class StalinistRussiaPlayerHelper extends PlayerHelper { + private readonly disfavorTrack = injectState(DISFAVOR_TRACK); + + protected calculateScore(player: PlayerData): number { + return super.calculateScore(player) + this.getScoreFromDisfavor(player); + } + + getScoreFromDisfavor(player: PlayerData): number { + if (player.outOfGame) return 0; + return DISFAVOR_VALUES[this.disfavorPosition(player.color)]; + } + + disfavorPosition(color: PlayerColor): number { + return this.disfavorTrack().get(color) ?? 0; + } +} diff --git a/src/maps/stalinist_russia/disfavor_track.tsx b/src/maps/stalinist_russia/disfavor_track.tsx new file mode 100644 index 00000000..e30c7def --- /dev/null +++ b/src/maps/stalinist_russia/disfavor_track.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionTitle, + Menu, + MenuItem, + Table, + TableBody, + TableCell, + TableRow, +} from "semantic-ui-react"; +import { getPlayerColorCss } from "../../client/components/player_color"; +import { useInjectedState } from "../../client/utils/injection_context"; +import { PlayerColor } from "../../engine/state/player"; +import * as styles from "./loco_track.module.css"; +import { DISFAVOR_TRACK, DISFAVOR_VALUES } from "./state"; + +export function DisfavorTrack() { + const [expanded, setExpanded] = useState(false); + const disfavorTrack = useInjectedState(DISFAVOR_TRACK); + + const positions = DISFAVOR_VALUES.map((_, index) => index); + + const playersAt = (position: number): PlayerColor[] => { + const result: PlayerColor[] = []; + for (const [color, index] of disfavorTrack) { + if (index === position) { + result.push(color); + } + } + return result; + }; + + return ( + + + setExpanded(!expanded)} + content="Stalin's Disfavor Track" + /> + + + + + Points + {positions.map((position) => ( + + {DISFAVOR_VALUES[position]} + + ))} + + + Players + {positions.map((position) => ( + + {playersAt(position).map((color) => ( + + ))} + + ))} + + +
+
+
+
+ ); +} + +function PlayerBlock({ color }: { color: PlayerColor }) { + return ( +
+ ); +} diff --git a/src/maps/stalinist_russia/disfavor_vps.tsx b/src/maps/stalinist_russia/disfavor_vps.tsx new file mode 100644 index 00000000..950f874f --- /dev/null +++ b/src/maps/stalinist_russia/disfavor_vps.tsx @@ -0,0 +1,18 @@ +import * as styles from "../../client/game/final_overview.module.css"; +import { RowProps } from "../../client/game/final_overview_row"; +import { useInjected } from "../../client/utils/injection_context"; +import { StalinistRussiaPlayerHelper } from "./disfavor"; + +export function DisfavorVps({ players }: RowProps) { + const playerHelper = useInjected(StalinistRussiaPlayerHelper); + return ( + + Stalin's Disfavor VPs + {players.map(({ player }) => ( + + {playerHelper.getScoreFromDisfavor(player)} + + ))} + + ); +} diff --git a/src/maps/stalinist_russia/expenses.ts b/src/maps/stalinist_russia/expenses.ts new file mode 100644 index 00000000..d5d48980 --- /dev/null +++ b/src/maps/stalinist_russia/expenses.ts @@ -0,0 +1,9 @@ +import { ProfitHelper } from "../../engine/income_and_expenses/helper"; +import { PlayerData } from "../../engine/state/player"; + +export class StalinistRussiaProfitHelper extends ProfitHelper { + getExpenses(player: PlayerData): number { + // The locomotive track does not contribute to expenses; only shares do. + return player.shares; + } +} diff --git a/src/maps/stalinist_russia/grid.ts b/src/maps/stalinist_russia/grid.ts new file mode 100644 index 00000000..31b1cc73 --- /dev/null +++ b/src/maps/stalinist_russia/grid.ts @@ -0,0 +1,207 @@ +import { BLACK, BLUE, PURPLE, RED, YELLOW } from "../../engine/state/good"; +import { LandData, SpaceData } from "../../engine/state/space"; +import { + black, + city, + grid, + PLAIN, + RIVER, + town, + UNPASSABLE, + WATER, + white, +} from "../factory"; + +export const MOSCOW = "Moscow"; + +function riverTown(name: string): LandData { + return { + ...RIVER, + townName: name, + }; +} + +export const map = grid([ + [ + UNPASSABLE, + PLAIN, + town(""), + PLAIN, + PLAIN, + PLAIN, + city("", BLACK, white(2), 3), + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + town(""), + PLAIN, + WATER, + ], + [ + city("", BLUE, white(1), 4), + PLAIN, + PLAIN, + town(""), + PLAIN, + PLAIN, + RIVER, + riverTown(""), + RIVER, + city("", RED, white(4), 3), + RIVER, + PLAIN, + PLAIN, + city("", YELLOW, white(6), 3), + ], + [ + UNPASSABLE, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + town(""), + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + riverTown(""), + PLAIN, + PLAIN, + WATER, + ], + [ + town(""), + PLAIN, + town(""), + RIVER, + PLAIN, + RIVER, + PLAIN, + town(""), + PLAIN, + PLAIN, + PLAIN, + RIVER, + PLAIN, + RIVER, + ], + [ + PLAIN, + PLAIN, + PLAIN, + RIVER, + PLAIN, + RIVER, + city("", YELLOW, white(3), 3), + PLAIN, + PLAIN, + PLAIN, + city("", YELLOW, white(5), 3), + PLAIN, + riverTown(""), + RIVER, + WATER, + ], + [ + PLAIN, + WATER, + RIVER, + city(MOSCOW, [], [], 0), + RIVER, + PLAIN, + RIVER, + RIVER, + RIVER, + RIVER, + PLAIN, + PLAIN, + PLAIN, + WATER, + ], + [ + city("", BLACK, black(1), 3), + PLAIN, + RIVER, + PLAIN, + RIVER, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + town(""), + RIVER, + town(""), + PLAIN, + city("", RED, black(6), 3), + WATER, + ], + [ + PLAIN, + town(""), + RIVER, + RIVER, + PLAIN, + town(""), + PLAIN, + city("", RED, black(4), 3), + PLAIN, + PLAIN, + RIVER, + PLAIN, + PLAIN, + RIVER, + ], + [ + PLAIN, + PLAIN, + RIVER, + RIVER, + town(""), + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + RIVER, + town(""), + RIVER, + PLAIN, + ], + [ + town(""), + PLAIN, + city("", YELLOW, black(2), 5), + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + PLAIN, + town(""), + PLAIN, + RIVER, + RIVER, + PLAIN, + ], + [ + PLAIN, + PLAIN, + PLAIN, + RIVER, + PLAIN, + PLAIN, + city("", BLACK, black(3), 3), + RIVER, + RIVER, + RIVER, + RIVER, + RIVER, + city("", PURPLE, black(5), 4), + PLAIN, + PLAIN, + ], +]); diff --git a/src/maps/stalinist_russia/loco_helper.ts b/src/maps/stalinist_russia/loco_helper.ts new file mode 100644 index 00000000..66abc0c0 --- /dev/null +++ b/src/maps/stalinist_russia/loco_helper.ts @@ -0,0 +1,35 @@ +import { injectState } from "../../engine/framework/execution_context"; +import { City } from "../../engine/map/city"; +import { Space } from "../../engine/map/grid"; +import { PlayerColor } from "../../engine/state/player"; +import { LOCO_TRACK, LocoTrackPosition } from "./state"; +import { describeBox, engineLevel, LocoRow, multiplier } from "./track_data"; +import { MOSCOW } from "./grid"; + +export function isMoscow(space: Space | undefined): space is City { + return space instanceof City && space.name() === MOSCOW; +} + +export class StalinistRussiaLocoHelper { + private readonly locoTrack = injectState(LOCO_TRACK); + + getPosition(color: PlayerColor): LocoTrackPosition { + // Box 0 is identical on both rows, so the row choice is irrelevant there. + return this.locoTrack().get(color) ?? { box: 0, row: LocoRow.MANY }; + } + + getEngineLevel(color: PlayerColor): number { + const { box, row } = this.getPosition(color); + return engineLevel(box, row); + } + + getMultiplier(color: PlayerColor): number { + const { box, row } = this.getPosition(color); + return multiplier(box, row); + } + + describe(color: PlayerColor): string { + const { box, row } = this.getPosition(color); + return describeBox(box, row); + } +} diff --git a/src/maps/stalinist_russia/loco_track.module.css b/src/maps/stalinist_russia/loco_track.module.css new file mode 100644 index 00000000..7fab610c --- /dev/null +++ b/src/maps/stalinist_russia/loco_track.module.css @@ -0,0 +1,8 @@ +.playerBlock { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 0.5em; + background-color: var(--player-color); + margin: 0 0.1em; +} diff --git a/src/maps/stalinist_russia/loco_track.tsx b/src/maps/stalinist_russia/loco_track.tsx new file mode 100644 index 00000000..016e8e9c --- /dev/null +++ b/src/maps/stalinist_russia/loco_track.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionTitle, + Menu, + MenuItem, + Table, + TableBody, + TableCell, + TableRow, +} from "semantic-ui-react"; +import { getPlayerColorCss } from "../../client/components/player_color"; +import { useInjectedState } from "../../client/utils/injection_context"; +import { PlayerColor } from "../../engine/state/player"; +import * as styles from "./loco_track.module.css"; +import { LOCO_TRACK } from "./state"; +import { + costToAdvanceInto, + describeBox, + LocoRow, + MAX_LOCO_BOX, +} from "./track_data"; + +export function LocoTrack() { + const [expanded, setExpanded] = useState(false); + const locoTrack = useInjectedState(LOCO_TRACK); + + const boxes = Array.from({ length: MAX_LOCO_BOX + 1 }, (_, box) => box); + + const playersAt = (box: number, row: LocoRow): PlayerColor[] => { + const result: PlayerColor[] = []; + for (const [color, position] of locoTrack) { + if (position.box === box && position.row === row) { + result.push(color); + } + } + return result; + }; + + const renderRow = (row: LocoRow, label: string) => ( + + {label} + {boxes.map((box) => ( + +
{describeBox(box, row)}
+
+ {playersAt(box, row).map((color) => ( + + ))} +
+
+ ))} +
+ ); + + return ( + + + setExpanded(!expanded)} + content="Locomotive Track" + /> + + + + + Round + {boxes.map((box) => ( + + {box} + + ))} + + + Cost + {boxes.map((box) => ( + + {box === 0 ? "—" : `$${costToAdvanceInto(box)}`} + + ))} + + {renderRow(LocoRow.MANY, "Many players")} + {renderRow(LocoRow.SINGLE, "Single player")} + +
+
+
+
+ ); +} + +function PlayerBlock({ color }: { color: PlayerColor }) { + return ( +
+ ); +} diff --git a/src/maps/stalinist_russia/locomotive_phase.ts b/src/maps/stalinist_russia/locomotive_phase.ts new file mode 100644 index 00000000..c6e64f96 --- /dev/null +++ b/src/maps/stalinist_russia/locomotive_phase.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; +import { remove } from "../../utils/functions"; +import { assert } from "../../utils/validate"; +import { inject, injectState } from "../../engine/framework/execution_context"; +import { + ActionProcessor, + EmptyActionProcessor, +} from "../../engine/game/action"; +import { Log } from "../../engine/game/log"; +import { MoneyManager } from "../../engine/game/money_manager"; +import { ActionBundle, PhaseModule } from "../../engine/game/phase_module"; +import { ROUND } from "../../engine/game/round"; +import { + injectCurrentPlayer, + injectPlayerAction, +} from "../../engine/game/state"; +import { Action } from "../../engine/state/action"; +import { Phase } from "../../engine/state/phase"; +import { PlayerColor } from "../../engine/state/player"; +import { StalinistRussiaLocoHelper } from "./loco_helper"; +import { LOCO_TRACK } from "./state"; +import { + costToAdvanceInto, + describeBox, + LocoRow, + LocoRowZod, + MAX_LOCO_BOX, +} from "./track_data"; + +export class StalinistRussiaLocomotivePhase extends PhaseModule { + static readonly phase = Phase.STALINIST_LOCOMOTIVE; + + private readonly locoPlayer = injectPlayerAction(Action.LOCOMOTIVE); + private readonly currentPlayer = injectCurrentPlayer(); + private readonly round = injectState(ROUND); + private readonly locoHelper = inject(StalinistRussiaLocoHelper); + + configureActions(): void { + this.installAction(LocoAdvanceAction); + this.installAction(LocoSkipAction); + } + + getPlayerOrder(): PlayerColor[] { + const playerOrder = super.getPlayerOrder(); + const locoPlayer = this.locoPlayer(); + if (locoPlayer != null) { + return [locoPlayer.color, ...remove(playerOrder, locoPlayer.color)]; + } + return playerOrder; + } + + forcedAction(): ActionBundle | undefined { + const player = this.currentPlayer(); + const { box } = this.locoHelper.getPosition(player.color); + const targetBox = box + 1; + const canAdvance = + box < MAX_LOCO_BOX && + targetBox <= this.round() && + player.money >= costToAdvanceInto(targetBox); + if (!canAdvance) { + return { action: LocoSkipAction, data: {} }; + } + return undefined; + } +} + +export const LocoAdvanceData = z.object({ + targetBox: z.number(), + row: LocoRowZod, +}); +export type LocoAdvanceData = z.infer; + +export class LocoAdvanceAction implements ActionProcessor { + static readonly action = "locoAdvance"; + + private readonly currentPlayer = injectCurrentPlayer(); + private readonly round = injectState(ROUND); + private readonly locoTrack = injectState(LOCO_TRACK); + private readonly locoHelper = inject(StalinistRussiaLocoHelper); + private readonly moneyManager = inject(MoneyManager); + private readonly log = inject(Log); + + readonly assertInput = LocoAdvanceData.parse; + + canEmit(): boolean { + return true; + } + + validate({ targetBox, row }: LocoAdvanceData): void { + const player = this.currentPlayer(); + const { box } = this.locoHelper.getPosition(player.color); + + // A player may advance any number of boxes forward on the track. + assert(targetBox > box, { + invalidInput: "must advance forward on the locomotive track", + }); + assert(targetBox <= MAX_LOCO_BOX, { + invalidInput: "beyond the end of the locomotive track", + }); + assert(targetBox <= this.round(), { + invalidInput: "cannot advance beyond the current round", + }); + + // Regardless of current position, the cost is the destination column's cost. + const cost = costToAdvanceInto(targetBox); + assert(player.money >= cost, { + invalidInput: `cannot afford to advance (costs $${cost})`, + }); + + if (row === LocoRow.SINGLE) { + const occupied = [...this.locoTrack().values()].some( + (pos) => pos.row === LocoRow.SINGLE && pos.box === targetBox, + ); + assert(!occupied, { + invalidInput: "another player already occupies that single-player box", + }); + } + } + + process({ targetBox, row }: LocoAdvanceData): boolean { + const player = this.currentPlayer(); + const cost = costToAdvanceInto(targetBox); + + this.moneyManager.addMoney(player.color, -cost); + this.locoTrack.update((track) => { + track.set(player.color, { box: targetBox, row }); + }); + this.log.currentPlayer( + `pays $${cost} to advance to ${describeBox(targetBox, row)} on the locomotive track`, + ); + return true; + } +} + +export class LocoSkipAction extends EmptyActionProcessor { + static readonly action = "locoSkip"; + + private readonly log = inject(Log); + + process(): boolean { + this.log.currentPlayer("does not advance on the locomotive track"); + return true; + } +} diff --git a/src/maps/stalinist_russia/locomotive_summary.tsx b/src/maps/stalinist_russia/locomotive_summary.tsx new file mode 100644 index 00000000..ea11b5a4 --- /dev/null +++ b/src/maps/stalinist_russia/locomotive_summary.tsx @@ -0,0 +1,123 @@ +import { + Button, + Table, + TableBody, + TableCell, + TableRow, +} from "semantic-ui-react"; +import { Username } from "../../client/components/username"; +import { GenericMessage } from "../../client/game/action_summary"; +import { useAction, useEmptyAction } from "../../client/services/action"; +import { + useCurrentPlayer, + useInjectedState, +} from "../../client/utils/injection_context"; +import { ROUND } from "../../engine/game/round"; +import { LocoAdvanceAction, LocoSkipAction } from "./locomotive_phase"; +import { LOCO_TRACK } from "./state"; +import { + costToAdvanceInto, + describeBox, + LocoRow, + MAX_LOCO_BOX, +} from "./track_data"; + +export function StalinistRussiaLocomotiveSummary() { + const { + canEmit, + canEmitUserId, + emit: emitAdvance, + isPending: advancePending, + } = useAction(LocoAdvanceAction); + const { emit: emitSkip, isPending: skipPending } = + useEmptyAction(LocoSkipAction); + + const currentPlayer = useCurrentPlayer(); + const round = useInjectedState(ROUND); + const locoTrack = useInjectedState(LOCO_TRACK); + + const isPending = advancePending || skipPending; + + if (canEmitUserId == null) { + return <>; + } + + if (!canEmit || currentPlayer == null) { + return ( + + may advance on the locomotive track. + + ); + } + + const currentBox = locoTrack.get(currentPlayer.color)?.box ?? 0; + const money = currentPlayer.money; + + const occupiedSingleBoxes = new Set(); + for (const [color, position] of locoTrack) { + if (position.row === LocoRow.SINGLE && color != null) { + occupiedSingleBoxes.add(position.box); + } + } + + const boxes = Array.from({ length: MAX_LOCO_BOX + 1 }, (_, box) => box); + + function isAvailable(box: number, row: LocoRow): boolean { + if (box <= currentBox) return false; + if (box > round) return false; + if (money < costToAdvanceInto(box)) return false; + if (row === LocoRow.SINGLE && occupiedSingleBoxes.has(box)) return false; + return true; + } + + function renderRow(row: LocoRow, label: string) { + return ( + + {label} + {boxes.map((box) => ( + + + + ))} + + ); + } + + return ( +
+

You may advance on the locomotive track, or pass.

+ + + + Round + {boxes.map((box) => ( + + {box} + + ))} + + + Cost + {boxes.map((box) => ( + + {box === 0 ? "—" : `$${costToAdvanceInto(box)}`} + + ))} + + {renderRow(LocoRow.MANY, "Many players")} + {renderRow(LocoRow.SINGLE, "Single player")} + +
+ +
+ ); +} diff --git a/src/maps/stalinist_russia/move.ts b/src/maps/stalinist_russia/move.ts new file mode 100644 index 00000000..a99480a5 --- /dev/null +++ b/src/maps/stalinist_russia/move.ts @@ -0,0 +1,128 @@ +import { peek } from "../../utils/functions"; +import { assert } from "../../utils/validate"; +import { inject, injectState } from "../../engine/framework/execution_context"; +import { injectCurrentPlayer } from "../../engine/game/state"; +import { City } from "../../engine/map/city"; +import { MoveHelper } from "../../engine/move/helper"; +import { MoveAction, MoveData } from "../../engine/move/move"; +import { MovePassAction } from "../../engine/move/pass"; +import { MovePhase } from "../../engine/move/phase"; +import { MoveValidator } from "../../engine/move/validator"; +import { Action } from "../../engine/state/action"; +import { Good } from "../../engine/state/good"; +import { SpaceType } from "../../engine/state/location_type"; +import { PlayerData } from "../../engine/state/player"; +import { isMoscow, StalinistRussiaLocoHelper } from "./loco_helper"; +import { POLITBURO_USED } from "./state"; +import { NUM_MOVE_ROUNDS } from "./track_data"; + +export class StalinistRussiaMoveHelper extends MoveHelper { + private readonly locoHelper = inject(StalinistRussiaLocoHelper); + + getLocomotive(player: PlayerData): number { + return this.locoHelper.getEngineLevel(player.color); + } + + getLocomotiveDisplay(player: PlayerData): string { + // Show engine level x delivery multiplier, e.g. "3x2". + return this.locoHelper.describe(player.color); + } + + canDeliverTo(city: City, good: Good): boolean { + if (isMoscow(city)) { + // Moscow only accepts colors of cubes not already present in the city. + return !city.getGoods().includes(good); + } + return super.canDeliverTo(city, good); + } +} + +export class StalinistRussiaMoveValidator extends MoveValidator { + private readonly politburoUsed = injectState(POLITBURO_USED); + + validatePartial(player: PlayerData, action: MoveData): void { + if (isMoscow(this.grid().get(action.startingCity))) { + assert(player.selectedAction === Action.POLITBURO_DIRECTIVE, { + invalidInput: + "goods can only be moved out of Moscow with the Politburo Directive", + }); + const used = this.politburoUsed.isInitialized() + ? this.politburoUsed() + : []; + assert(!used.includes(player.color), { + invalidInput: + "the Politburo Directive may only be applied to a single delivery", + }); + } + super.validatePartial(player, action); + } +} + +export class StalinistRussiaMoveAction extends MoveAction { + private readonly politburoUsed = injectState(POLITBURO_USED); + + process(action: MoveData): boolean { + const startedFromMoscow = isMoscow(this.grid().get(action.startingCity)); + const result = super.process(action); + if (startedFromMoscow) { + this.politburoUsed.update((used) => + used.push(this.currentPlayer().color), + ); + } + return result; + } + + protected returnToBag(action: MoveData): void { + const endingStop = peek(action.path).endingStop; + if (isMoscow(this.grid().get(endingStop))) { + // Cubes delivered to Moscow are left on the city rather than returned. + this.gridHelper.update(endingStop, (space) => { + assert(space.type === SpaceType.CITY); + space.goods.push(action.good); + }); + return; + } + super.returnToBag(action); + } +} + +export class StalinistRussiaMovePhase extends MovePhase { + private readonly currentPlayerData = injectCurrentPlayer(); + private readonly locoHelper = inject(StalinistRussiaLocoHelper); + private readonly politburo = injectState(POLITBURO_USED); + + configureActions(): void { + // Note: LocoAction is intentionally not installed. On this map players + // cannot skip deliveries to increase their locomotive. + this.installAction(MoveAction); + this.installAction(MovePassAction); + } + + onStart(): void { + super.onStart(); + this.politburo.initState([]); + } + + onEnd(): void { + this.politburo.delete(); + super.onEnd(); + } + + numMoveRounds(): number { + return NUM_MOVE_ROUNDS; + } + + checkSkipTurn(): boolean { + // A player participates in delivery round R (0-indexed) only if their + // locomotive multiplier allows that many deliveries. + const multiplier = this.locoHelper.getMultiplier( + this.currentPlayerData().color, + ); + return this.moveState().moveRound >= multiplier; + } + + protected getAutoAction(): undefined { + // The locomotive auto-action is not available on this map. + return undefined; + } +} diff --git a/src/maps/stalinist_russia/player_stats.tsx b/src/maps/stalinist_russia/player_stats.tsx new file mode 100644 index 00000000..f5e00ccf --- /dev/null +++ b/src/maps/stalinist_russia/player_stats.tsx @@ -0,0 +1,9 @@ +import { useInjectedState } from "../../client/utils/injection_context"; +import { PlayerData } from "../../engine/state/player"; +import { DISFAVOR_TRACK, DISFAVOR_VALUES } from "./state"; + +export function DisfavorCell({ player }: { player: PlayerData }) { + const disfavorTrack = useInjectedState(DISFAVOR_TRACK); + const index = disfavorTrack.get(player.color) ?? 0; + return <>{DISFAVOR_VALUES[index]}; +} diff --git a/src/maps/stalinist_russia/rivers.tsx b/src/maps/stalinist_russia/rivers.tsx new file mode 100644 index 00000000..c471650e --- /dev/null +++ b/src/maps/stalinist_russia/rivers.tsx @@ -0,0 +1,40 @@ +import * as styles from "../../client/grid/hex.module.css"; + +export function StalinistRussiaRivers() { + return ( + <> + + + + + + + + + + ); +} diff --git a/src/maps/stalinist_russia/rules.tsx b/src/maps/stalinist_russia/rules.tsx new file mode 100644 index 00000000..eb2a9775 --- /dev/null +++ b/src/maps/stalinist_russia/rules.tsx @@ -0,0 +1,71 @@ +export function StalinistRussiaRules() { + return ( +
+

Turn-Order Auction

+

+ When you pass and this makes you last player, you advance on the + Stalin's disfavor track which will give you negative points at the + end of the game. Additionally, every single build during the build phase + costs one extra dollar; see the build phase section below. +

+

Special Actions

+
    +
  • + Engineer: Is only available at five players, but otherwise + functions normally. +
  • +
  • + Politburo Directive: When you have selected this action, as one + of your deliveries you may start from Moscow. See additional delivery + rules concerning Moscow below. +
  • +
  • + Locomotive: When you select this action, you go first during + the Locomotive phase (see below). +
  • +
+

Locomotive Phase

+

+ This map does not use the usual Locomotive track, and instead uses a + special locomotive track. Advancing on this track may change both the + length of your deliviries (the usual engine level) as well as the number + of deliveries you can make. On this map, there are four delivery rounds + every move goods phase, with players participating in later phases only + if their position on the Locomotive track allows it. +

+

+ On this map, you cannot skip deliveries to increase your Locomotive. + Instead, there is an additional Locomotive phase after choosing special + actions and before the build phase. During this phase, players choose in + turn order (with the player who selected the Locomotive special action, + if any, going first) if they wish to increase their position on the + Locomotive track. Only a single player may be in at a given box on the + single-player row at any time. Players cannot increase to a box beyond + the current round. Regardless of a player's current position on the + Locomotive track, the cost to move on the Locomotive track is equal to + the cost listed on the column. Locomotive does not contribute to + expenses during the income and expenses phase. +

+

Building

+

+ Towns with rivers do cost an extra $1 on top of the usual cost. The + player who is last in turn order (who received Stalin's Disfavor + this round) pays $1 extra per tile lay during the build phase. +

+

Moving Goods

+

+ Moscow is a special city and will only accept colors of cubes that are + not already present in the city. When cubes are delivered to Moscow, + they are left on Moscow rather than being returned to the bag. Cubes + starting in Moscow cannot be moved out of Moscow unless the player has + taken the Politburo Directive special action; note that the Politburo + Directive special action can only be applied to a single delivery. +

+

Income and Expenses

+

+ Your locomotive level does not contribute towards expenses, only the + number of shares you have taken. +

+
+ ); +} diff --git a/src/maps/stalinist_russia/settings.ts b/src/maps/stalinist_russia/settings.ts new file mode 100644 index 00000000..c5995c68 --- /dev/null +++ b/src/maps/stalinist_russia/settings.ts @@ -0,0 +1,82 @@ +import { + CORTEXBOMB, + JACK, + MapSettings, + PlayerCountRating, + ReleaseStage, +} from "../../engine/game/map_settings"; +import { Phase } from "../../engine/state/phase"; +import { PhasesModule } from "../../modules/phases"; +import { + StalinistRussiaActionNamingProvider, + StalinistRussiaAllowedActions, + StalinistRussiaSelectAction, +} from "./actions"; +import { StalinistRussiaBuildCostCalculator } from "./build"; +import { + StalinistRussiaPlayerHelper, + StalinistRussiaTurnOrderHelper, +} from "./disfavor"; +import { StalinistRussiaProfitHelper } from "./expenses"; +import { map } from "./grid"; +import { StalinistRussiaLocomotivePhase } from "./locomotive_phase"; +import { + StalinistRussiaMoveAction, + StalinistRussiaMoveHelper, + StalinistRussiaMovePhase, + StalinistRussiaMoveValidator, +} from "./move"; +import { StalinistRussiaStarter } from "./starter"; + +export class StalinistRussiaMapSettings implements MapSettings { + readonly key = "stalinist-russia"; + readonly name = "Stalinist Russia"; + readonly designer = "Michael Webb"; + readonly implementerId = JACK; + readonly minPlayers = 4; + readonly maxPlayers = 5; + readonly playerCountRatings = { + 1: PlayerCountRating.NOT_SUPPORTED, + 2: PlayerCountRating.NOT_SUPPORTED, + 3: PlayerCountRating.NOT_SUPPORTED, + 4: PlayerCountRating.RECOMMENDED, + 5: PlayerCountRating.RECOMMENDED, + 6: PlayerCountRating.NOT_SUPPORTED, + 7: PlayerCountRating.NOT_SUPPORTED, + 8: PlayerCountRating.NOT_SUPPORTED, + }; + readonly startingGrid = map; + readonly stage = ReleaseStage.DEVELOPMENT; + readonly developmentAllowlist = [JACK, CORTEXBOMB]; + + getOverrides() { + return [ + StalinistRussiaStarter, + StalinistRussiaTurnOrderHelper, + StalinistRussiaPlayerHelper, + StalinistRussiaAllowedActions, + StalinistRussiaSelectAction, + StalinistRussiaActionNamingProvider, + StalinistRussiaBuildCostCalculator, + StalinistRussiaProfitHelper, + StalinistRussiaMoveHelper, + StalinistRussiaMoveValidator, + StalinistRussiaMoveAction, + StalinistRussiaMovePhase, + ]; + } + + getModules() { + return [ + new PhasesModule({ + newPhases: [StalinistRussiaLocomotivePhase], + replace: (phases) => { + const result = [...phases]; + const index = result.indexOf(Phase.ACTION_SELECTION); + result.splice(index + 1, 0, Phase.STALINIST_LOCOMOTIVE); + return result; + }, + }), + ]; + } +} diff --git a/src/maps/stalinist_russia/smoke_test.ts b/src/maps/stalinist_russia/smoke_test.ts new file mode 100644 index 00000000..8de2dd35 --- /dev/null +++ b/src/maps/stalinist_russia/smoke_test.ts @@ -0,0 +1,82 @@ +import { EngineDelegator } from "../../engine/framework/engine"; +import { LimitedGame } from "../../engine/game/game_memory"; +import { PlayerUser } from "../../engine/game/starter"; +import { Action } from "../../engine/state/action"; + +// Drives a real Stalinist Russia game from the start through the issue shares, +// turn order, action selection and (new) locomotive phases, exercising the +// custom machinery end-to-end. +describe("Stalinist Russia", () => { + const GAME_KEY = "stalinist-russia"; + + function newGame(gameData?: string): LimitedGame { + return { id: 1, gameKey: GAME_KEY, gameData, variant: {} }; + } + + it("runs through the locomotive phase and assigns Stalin's disfavor", () => { + const players: PlayerUser[] = [1, 2, 3, 4].map((playerId) => ({ + playerId, + })); + + let state = EngineDelegator.singleton.start({ + game: newGame(), + players, + seed: "smoke-seed", + }); + + const allLogs: string[] = [...state.logs]; + const actionsToAssign = [ + Action.LOCOMOTIVE, + Action.FIRST_MOVE, + Action.FIRST_BUILD, + Action.PRODUCTION, + ]; + let advancedOnLoco = false; + + function summary(): string { + return EngineDelegator.singleton.readSummary(newGame(state.gameData)); + } + + function emit(actionName: string, actionData: object): void { + state = EngineDelegator.singleton.processAction(GAME_KEY, { + game: newGame(state.gameData), + actionName, + actionData, + seed: state.seed ?? undefined, + }); + allLogs.push(...state.logs); + } + + // Walk the game forward until we have entered (and passed through) the + // locomotive phase into building. + let guard = 0; + while (!summary().includes("Build track") && guard++ < 100) { + const phase = summary(); + if (phase.includes("Issue shares")) { + emit("takeShares", { numShares: 0 }); + } else if (phase.includes("Bid for turn order")) { + emit("pass", {}); + } else if (phase.includes("Select actions")) { + const action = actionsToAssign.shift() ?? Action.URBANIZATION; + emit("select", { action }); + } else if (phase.includes("Locomotive")) { + if (!advancedOnLoco) { + advancedOnLoco = true; + // Round 1 caps advancement at box 1. + emit("locoAdvance", { targetBox: 1, row: "many" }); + } + emit("locoSkip", {}); + } else { + throw new Error(`Unexpected phase: ${phase}`); + } + } + + expect(summary()).toContain("Build track"); + expect(allLogs.some((log) => log.includes("Stalin's disfavor track"))).toBe( + true, + ); + expect(allLogs.some((log) => log.includes("on the locomotive track"))).toBe( + true, + ); + }); +}); diff --git a/src/maps/stalinist_russia/starter.ts b/src/maps/stalinist_russia/starter.ts new file mode 100644 index 00000000..8adb9e83 --- /dev/null +++ b/src/maps/stalinist_russia/starter.ts @@ -0,0 +1,22 @@ +import { injectState } from "../../engine/framework/execution_context"; +import { GameStarter } from "../../engine/game/starter"; +import { DISFAVOR_TRACK, LOCO_TRACK } from "./state"; +import { LocoRow } from "./track_data"; + +export class StalinistRussiaStarter extends GameStarter { + private readonly locoTrack = injectState(LOCO_TRACK); + private readonly disfavorTrack = injectState(DISFAVOR_TRACK); + + protected onStartGame(): void { + super.onStartGame(); + + this.locoTrack.initState( + new Map( + this.turnOrder().map((color) => [color, { box: 0, row: LocoRow.MANY }]), + ), + ); + this.disfavorTrack.initState( + new Map(this.turnOrder().map((color) => [color, 0])), + ); + } +} diff --git a/src/maps/stalinist_russia/state.ts b/src/maps/stalinist_russia/state.ts new file mode 100644 index 00000000..d7a6797a --- /dev/null +++ b/src/maps/stalinist_russia/state.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { Key, MapKey } from "../../engine/framework/key"; +import { PlayerColorZod } from "../../engine/state/player"; +import { LocoRowZod } from "./track_data"; + +// Each player's position on the locomotive track: which box they occupy and +// which row they chose. +export const LocoTrackPosition = z.object({ + box: z.number(), + row: LocoRowZod, +}); +export type LocoTrackPosition = z.infer; + +export const LOCO_TRACK = new MapKey( + "StalinLocoTrack", + PlayerColorZod.parse, + LocoTrackPosition.parse, +); + +// Stalin's disfavor track. Stores each player's position (an index into +// DISFAVOR_VALUES) which yields negative points at the end of the game. +export const DISFAVOR_VALUES: readonly number[] = [ + 0, -3, -9, -18, -30, -45, -63, -84, -108, +]; + +export const MAX_DISFAVOR = DISFAVOR_VALUES.length - 1; + +export const DISFAVOR_TRACK = new MapKey( + "StalinDisfavorTrack", + PlayerColorZod.parse, + z.number().parse, +); + +// Players who have already used their Politburo Directive (start from Moscow) +// during the current move goods phase. The directive may only be applied to a +// single delivery. +export const POLITBURO_USED = new Key("StalinPolitburoUsed", { + parse: PlayerColorZod.array().parse, +}); diff --git a/src/maps/stalinist_russia/track_data.ts b/src/maps/stalinist_russia/track_data.ts new file mode 100644 index 00000000..bd342b10 --- /dev/null +++ b/src/maps/stalinist_russia/track_data.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +// The locomotive track has two rows. When a player advances they choose which +// row to occupy and gain only that row's benefit. Each box lists the engine +// level (the length of a delivery) and the multiplier (the number of +// deliveries the player may make during the move goods phase). +export enum LocoRow { + MANY = "many", + SINGLE = "single", +} + +export const LocoRowZod = z.nativeEnum(LocoRow); + +interface LocoBox { + // The engine level, i.e. how many links a single delivery may cross. + readonly engine: number; + // The number of deliveries the player may make per move goods phase. + readonly multiplier: number; +} + +// Indexed by box (0-8). +const MANY_PLAYERS_ROW: readonly LocoBox[] = [ + { engine: 1, multiplier: 1 }, + { engine: 2, multiplier: 1 }, + { engine: 2, multiplier: 2 }, + { engine: 3, multiplier: 2 }, + { engine: 4, multiplier: 2 }, + { engine: 5, multiplier: 2 }, + { engine: 6, multiplier: 2 }, + { engine: 6, multiplier: 3 }, + { engine: 7, multiplier: 3 }, +]; + +const SINGLE_PLAYER_ROW: readonly LocoBox[] = [ + { engine: 1, multiplier: 1 }, + { engine: 2, multiplier: 2 }, + { engine: 3, multiplier: 2 }, + { engine: 3, multiplier: 3 }, + { engine: 5, multiplier: 2 }, + { engine: 6, multiplier: 2 }, + { engine: 4, multiplier: 4 }, + { engine: 5, multiplier: 4 }, + { engine: 8, multiplier: 3 }, +]; + +// Cost to advance into a given box. There is no cost to occupy the starting box +// (0), so LOCO_COSTS[box - 1] is the cost to advance into `box`. +const LOCO_COSTS: readonly number[] = [4, 5, 7, 10, 12, 15, 17, 20]; + +export const MAX_LOCO_BOX = MANY_PLAYERS_ROW.length - 1; + +// Four delivery rounds during the move goods phase. +export const NUM_MOVE_ROUNDS = 4; + +function rowData(row: LocoRow): readonly LocoBox[] { + return row === LocoRow.SINGLE ? SINGLE_PLAYER_ROW : MANY_PLAYERS_ROW; +} + +export function engineLevel(box: number, row: LocoRow): number { + return rowData(row)[box].engine; +} + +export function multiplier(box: number, row: LocoRow): number { + return rowData(row)[box].multiplier; +} + +export function costToAdvanceInto(box: number): number { + return LOCO_COSTS[box - 1]; +} + +export function describeBox(box: number, row: LocoRow): string { + const data = rowData(row)[box]; + return `${data.engine} (x${data.multiplier})`; +} diff --git a/src/maps/stalinist_russia/view_settings.ts b/src/maps/stalinist_russia/view_settings.ts new file mode 100644 index 00000000..6a1a5bcb --- /dev/null +++ b/src/maps/stalinist_russia/view_settings.ts @@ -0,0 +1,47 @@ +import { ReactNode } from "react"; +import { + getRowList, + RowFactory, + TrackVps, +} from "../../client/game/final_overview_row"; +import { Phase } from "../../engine/state/phase"; +import { insertAfter } from "../../utils/functions"; +import { MapViewSettings } from "../view_settings"; +import { DisfavorTrack } from "./disfavor_track"; +import { DisfavorVps } from "./disfavor_vps"; +import { LocoTrack } from "./loco_track"; +import { StalinistRussiaLocomotiveSummary } from "./locomotive_summary"; +import { DisfavorCell } from "./player_stats"; +import { StalinistRussiaRules } from "./rules"; +import { StalinistRussiaMapSettings } from "./settings"; +import { StalinistRussiaRivers } from "./rivers"; + +export class StalinistRussiaViewSettings + extends StalinistRussiaMapSettings + implements MapViewSettings +{ + getMapRules = StalinistRussiaRules; + getTexturesLayer = StalinistRussiaRivers; + + additionalSliders = [LocoTrack, DisfavorTrack]; + + getFinalOverviewRows(): RowFactory[] { + return insertAfter(getRowList(), TrackVps, DisfavorVps); + } + + getActionSummary(phase: Phase | undefined): (() => ReactNode) | undefined { + if (phase === Phase.STALINIST_LOCOMOTIVE) { + return StalinistRussiaLocomotiveSummary; + } + return undefined; + } + + getPlayerStatColumns() { + return [ + { + header: "Stalin's Disfavor", + cell: DisfavorCell, + }, + ]; + } +} diff --git a/src/maps/view_registry.ts b/src/maps/view_registry.ts index 20d73e7c..6c78c9ae 100644 --- a/src/maps/view_registry.ts +++ b/src/maps/view_registry.ts @@ -50,6 +50,7 @@ import { SicilyViewSettings } from "./sicily/view_settings"; import { SoulTrainViewSettings } from "./soultrain/view_settings"; import { SouthernUsViewSettings } from "./southern_us/view_settings"; import { StLuciaViewSettings } from "./st-lucia/view_settings"; +import { StalinistRussiaViewSettings } from "./stalinist_russia/view_settings"; import { SwedenRecyclingViewSettings } from "./sweden/view_settings"; import { TrislandViewSettings } from "./trisland/view_settings"; import { UnionPacificExpressViewSettings } from "./union_pacific_express/view_settings"; @@ -108,6 +109,7 @@ export class ViewRegistry { this.add(new SoulTrainViewSettings()); this.add(new SouthernUsViewSettings()); this.add(new StLuciaViewSettings()); + this.add(new StalinistRussiaViewSettings()); this.add(new SwedenRecyclingViewSettings()); this.add(new TrislandViewSettings()); this.add(new UnionPacificExpressViewSettings());