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;