+
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