From 95f2955ac9ca3012071f80d524480e85b610df82 Mon Sep 17 00:00:00 2001 From: Ian Haken Date: Fri, 5 Jun 2026 13:15:33 -0700 Subject: [PATCH] Backport custom role selection to Cyprus. --- src/client/game/action_summary.tsx | 2 +- src/engine/state/phase.ts | 6 +- src/maps/cyprus/action_summary.tsx | 63 +++++++++++ src/maps/cyprus/role_selection.ts | 172 +++++++++++++++++++++++++++++ src/maps/cyprus/roles.ts | 2 + src/maps/cyprus/settings.ts | 3 + src/maps/cyprus/view_settings.ts | 12 ++ src/maps/mexico/role_selection.ts | 4 +- src/maps/mexico/view_settings.ts | 2 +- 9 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 src/maps/cyprus/action_summary.tsx create mode 100644 src/maps/cyprus/role_selection.ts diff --git a/src/client/game/action_summary.tsx b/src/client/game/action_summary.tsx index 0a8c33cb..5d161193 100644 --- a/src/client/game/action_summary.tsx +++ b/src/client/game/action_summary.tsx @@ -95,7 +95,7 @@ export function ActionSummary() { return ; case Phase.GOVERNMENT_BUILD: return ; - case Phase.MEXICO_ROLE_SELECTION: + case Phase.ROLE_SELECTION: case Phase.GOODS_GROWTH: case Phase.INCOME: case Phase.EXPENSES: diff --git a/src/engine/state/phase.ts b/src/engine/state/phase.ts index 1b26a985..e40dda2c 100644 --- a/src/engine/state/phase.ts +++ b/src/engine/state/phase.ts @@ -31,8 +31,8 @@ export enum Phase { // Montreal Metro GOVERNMENT_BUILD = 16, - // Mexico - MEXICO_ROLE_SELECTION = 17, + // Mexico, Cyprus + ROLE_SELECTION = 17, } export const PhaseZod = z.nativeEnum(Phase); @@ -69,7 +69,7 @@ export function getPhaseString(phase: Phase): string { return "Earth to Heaven"; case Phase.GOVERNMENT_BUILD: return "Government build"; - case Phase.MEXICO_ROLE_SELECTION: + case Phase.ROLE_SELECTION: return "Role selection"; default: assertNever(phase); diff --git a/src/maps/cyprus/action_summary.tsx b/src/maps/cyprus/action_summary.tsx new file mode 100644 index 00000000..0e2add1b --- /dev/null +++ b/src/maps/cyprus/action_summary.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { Button } from "semantic-ui-react"; +import { GenericMessage } from "../../client/game/action_summary"; +import { useAction } from "../../client/services/action"; +import { useActiveGameState } from "../../client/utils/injection_context"; +import { Username } from "../../client/components/username"; +import { + CyprusPickCountryAction, + CYPRUS_SELECTION_STATE, +} from "./role_selection"; +import { ALL_COUNTRIES, countryName } from "./roles"; + +export function CyprusRoleSelectionSummary() { + const { canEmit, canEmitUserId, emit, isPending } = useAction( + CyprusPickCountryAction, + ); + const selectionState = useActiveGameState(CYPRUS_SELECTION_STATE); + + if (canEmitUserId == null) return <>; + + if (!canEmit) { + return ( + + must choose a country. + + ); + } + + const taken = new Set(selectionState?.assignments.map((a) => a.country)); + const available = ALL_COUNTRIES.filter((c) => !taken.has(c)); + + return ( +
+

Choose your country:

+

+ If you haven't coordinated countries with the other players, select + Randomize. +

+ + {available.map((country, i) => ( + + {i > 0 && } + + + ))} + + + +
+ ); +} diff --git a/src/maps/cyprus/role_selection.ts b/src/maps/cyprus/role_selection.ts new file mode 100644 index 00000000..041027c3 --- /dev/null +++ b/src/maps/cyprus/role_selection.ts @@ -0,0 +1,172 @@ +import z from "zod"; +import { inject, injectState } from "../../engine/framework/execution_context"; +import { Key } from "../../engine/framework/key"; +import { ActionProcessor } from "../../engine/game/action"; +import { Log } from "../../engine/game/log"; +import { PhaseModule } from "../../engine/game/phase_module"; +import { PhaseDelegator } from "../../engine/game/phase_delegator"; +import { PhaseEngine } from "../../engine/game/phase"; +import { + injectAllPlayersUnsafe, + injectCurrentPlayer, + TURN_ORDER, +} from "../../engine/game/state"; +import { ROUND } from "../../engine/game/round"; +import { Random } from "../../engine/game/random"; +import { Phase } from "../../engine/state/phase"; +import { PlayerColor, PlayerColorZod } from "../../engine/state/player"; +import { assert } from "../../utils/validate"; +import { ALL_COUNTRIES, countryName } from "./roles"; + +const CyprusSelectionStateZod = z.object({ + assignments: z.array( + z.object({ + playerId: z.number(), + country: PlayerColorZod, + }), + ), +}); + +export const CYPRUS_SELECTION_STATE = new Key("cyprusRoleSelections", { + parse: CyprusSelectionStateZod.parse, +}); + +const PickCountryData = z.object({ + country: z.union([PlayerColorZod, z.literal("randomize")]), +}); +type PickCountryData = z.infer; + +export class CyprusPickCountryAction + implements ActionProcessor +{ + static readonly action = "cyprusPickCountry"; + readonly assertInput = PickCountryData.parse; + + private readonly currentPlayer = injectCurrentPlayer(); + private readonly selectionState = injectState(CYPRUS_SELECTION_STATE); + private readonly random = inject(Random); + private readonly log = inject(Log); + + validate({ country }: PickCountryData): void { + if (country === "randomize") return; + const taken = this.selectionState().assignments.map((a) => a.country); + assert(!taken.includes(country as PlayerColor), { + invalidInput: "Country already chosen", + }); + } + + process({ country }: PickCountryData): boolean { + const current = this.currentPlayer(); + const taken = new Set( + this.selectionState().assignments.map((a) => a.country), + ); + const available = ALL_COUNTRIES.filter((c) => !taken.has(c)); + const assigned = + country === "randomize" + ? available[this.random.random(available.length)] + : country; + this.selectionState.update((state) => { + state.assignments.push({ playerId: current.playerId, country: assigned }); + }); + if (country === "randomize") { + this.log.currentPlayer( + `is randomly assigned to play as ${countryName(assigned)}`, + ); + } else if (available.length === 1) { + this.log.currentPlayer( + `is assigned to the last remaining country, ${countryName(assigned)}`, + ); + } else { + this.log.currentPlayer(`chooses to play as ${countryName(assigned)}`); + } + return true; + } +} + +class CyprusRoleSelectionPhase extends PhaseModule { + static readonly phase = Phase.ROLE_SELECTION; + + private readonly round = injectState(ROUND); + private readonly selectionState = injectState(CYPRUS_SELECTION_STATE); + private readonly allPlayers = injectAllPlayersUnsafe(); + private readonly turnOrderState = injectState(TURN_ORDER); + private readonly random = inject(Random); + + configureActions(): void { + this.installAction(CyprusPickCountryAction); + } + + onStart(): void { + if (this.round() === 1) { + this.selectionState.initState({ assignments: [] }); + } + } + + getPlayerOrder(): PlayerColor[] { + return this.round() === 1 ? this.turnOrder() : []; + } + + forcedAction() { + if (this.round() !== 1) return undefined; + const taken = new Set( + this.selectionState().assignments.map((a) => a.country), + ); + const available = ALL_COUNTRIES.filter((c) => !taken.has(c)); + if (available.length === 1) { + return { + action: CyprusPickCountryAction, + data: { country: available[0] }, + }; + } + return undefined; + } + + onEnd(): void { + if (this.round() === 1) { + const assignments = this.selectionState().assignments; + const colorByPlayerId = new Map( + assignments.map((a) => [a.playerId, a.country]), + ); + + // Capture original turn order as playerIds before colors change + const orderedPlayerIds = this.turnOrder().map( + (color) => this.allPlayers().find((p) => p.color === color)!.playerId, + ); + + // Apply all country assignments to player colors at once + this.allPlayers.update((players) => { + for (const player of players) { + const newColor = colorByPlayerId.get(player.playerId); + if (newColor != null) { + player.color = newColor; + } + } + }); + + // Shuffle turn order after role selection is complete and player colors have been re-assigned + this.turnOrderState.set( + this.random.shuffle( + orderedPlayerIds.map( + (id) => this.allPlayers().find((p) => p.playerId === id)!.color, + ), + ), + ); + + this.selectionState.delete(); + } + super.onEnd(); + } +} + +export class CyprusPhaseDelegator extends PhaseDelegator { + constructor() { + super(); + this.install(CyprusRoleSelectionPhase); + } +} + +export class CyprusPhaseEngine extends PhaseEngine { + phaseOrder(): Phase[] { + return [Phase.ROLE_SELECTION, ...super.phaseOrder()]; + } +} diff --git a/src/maps/cyprus/roles.ts b/src/maps/cyprus/roles.ts index f5e02c8e..32adff44 100644 --- a/src/maps/cyprus/roles.ts +++ b/src/maps/cyprus/roles.ts @@ -5,6 +5,8 @@ export const UN = PlayerColor.GREEN; export const GREECE = PlayerColor.BLUE; export const TURKEY = PlayerColor.RED; +export const ALL_COUNTRIES = [UN, GREECE, TURKEY]; + export function countryName(country: PlayerColor): string { switch (country) { case UN: diff --git a/src/maps/cyprus/settings.ts b/src/maps/cyprus/settings.ts index f80b989d..14b65225 100644 --- a/src/maps/cyprus/settings.ts +++ b/src/maps/cyprus/settings.ts @@ -8,6 +8,7 @@ import { import { map } from "./grid"; import { CyprusAllowedActions } from "./limitted_selection"; import { CyprusMoveAction } from "./move_goods"; +import { CyprusPhaseDelegator, CyprusPhaseEngine } from "./role_selection"; import { CyprusCostCalculator, ShortBuild } from "./short_build"; import { CyprusStarter } from "./starter"; import { CyprusVariantConfig } from "./variant_config"; @@ -43,6 +44,8 @@ export class CyprusMapSettings implements MapSettings { CyprusStarter, CyprusMoveAction, CyprusCostCalculator, + CyprusPhaseDelegator, + CyprusPhaseEngine, ]; } } diff --git a/src/maps/cyprus/view_settings.ts b/src/maps/cyprus/view_settings.ts index 0fcfc132..f6cd4dc2 100644 --- a/src/maps/cyprus/view_settings.ts +++ b/src/maps/cyprus/view_settings.ts @@ -1,9 +1,12 @@ +import React from "react"; import { VariantConfig } from "../../api/variant_config"; import { CyprusVariantConfig } from "./variant_config"; import { MapViewSettings } from "../view_settings"; import { CyprusRules } from "./rules"; import { CyprusMapSettings } from "./settings"; import { CyprusVariantEditor } from "./variant_editor"; +import { Phase } from "../../engine/state/phase"; +import { CyprusRoleSelectionSummary } from "./action_summary"; export class CyprusViewSettings extends CyprusMapSettings @@ -19,4 +22,13 @@ export class CyprusViewSettings getVariantString(variant: VariantConfig): string[] | undefined { return [(variant as CyprusVariantConfig).variant2020 ? "2020" : "2012"]; } + + getActionSummary( + phase: Phase | undefined, + ): (() => React.ReactNode) | undefined { + if (phase === Phase.ROLE_SELECTION) { + return CyprusRoleSelectionSummary; + } + return undefined; + } } diff --git a/src/maps/mexico/role_selection.ts b/src/maps/mexico/role_selection.ts index 2206d85e..7097b1c1 100644 --- a/src/maps/mexico/role_selection.ts +++ b/src/maps/mexico/role_selection.ts @@ -59,7 +59,7 @@ export class MexicoPickRoleAction implements ActionProcessor { } class MexicoRoleSelectionPhase extends PhaseModule { - static readonly phase = Phase.MEXICO_ROLE_SELECTION; + static readonly phase = Phase.ROLE_SELECTION; private readonly round = injectState(ROUND); private readonly random = inject(Random); @@ -89,6 +89,6 @@ export class MexicoPhaseDelegator extends PhaseDelegator { export class MexicoPhaseEngine extends PhaseEngine { phaseOrder(): Phase[] { - return [Phase.MEXICO_ROLE_SELECTION, ...super.phaseOrder()]; + return [Phase.ROLE_SELECTION, ...super.phaseOrder()]; } } diff --git a/src/maps/mexico/view_settings.ts b/src/maps/mexico/view_settings.ts index 65ec9ea6..37f05d90 100644 --- a/src/maps/mexico/view_settings.ts +++ b/src/maps/mexico/view_settings.ts @@ -59,7 +59,7 @@ export class MexicoViewSettings phase: Phase | undefined, ): (() => React.ReactNode) | undefined { switch (phase) { - case Phase.MEXICO_ROLE_SELECTION: + case Phase.ROLE_SELECTION: return MexicoRoleSelectionSummary; case Phase.GOODS_GROWTH: return MexicoProductionSummary;