Skip to content

Implement Gameplay Domain Foundation with Game Orchestration #51

@bartul

Description

@bartul

Summary

Build the Gameplay bounded context to orchestrate game flow, including nation definitions, turn management, and integration with Rondel and Accounting domains.

Motivation

Currently:

  • Rondel operates in isolation without game context
  • Nations are free-form strings with no validation
  • No turn order or game progression
  • No connection between starting a game and initializing rondel/accounting

The Gameplay domain provides:

  • Canonical nation definitions matching Imperial rules
  • Game lifecycle management (create, start, end)
  • Turn order tracking
  • Orchestration of cross-BC initialization
  • Foundation for victory conditions

Game Flow Overview

                    ┌─────────────────────────────────────────┐
                    │            Gameplay Domain              │
                    │                                         │
 [CreateGame] ─────►│  GameState                              │
                    │    • GameId                             │
 [StartGame] ──────►│    • Players (investor assignments)     │
                    │    • Turn order                         │
 [EndTurn] ────────►│    • Current nation                     │
                    │    • Game phase                         │
                    └──────────────┬──────────────────────────┘
                                   │
                    ┌──────────────┼──────────────┐
                    │              │              │
                    ▼              ▼              ▼
            ┌─────────────┐ ┌───────────┐ ┌─────────────────┐
            │   Rondel    │ │Accounting │ │ (Future: Map,   │
            │             │ │           │ │  Combat, etc.)  │
            └─────────────┘ └───────────┘ └─────────────────┘

Implementation Plan

1. Nation Types (Gameplay.fsi)

Define the six nations from Imperial:

// src/Imperium/Gameplay.fsi

namespace Imperium

module Gameplay =

    // ──────────────────────────────────────────────────────────────────────────
    // Value Types
    // ──────────────────────────────────────────────────────────────────────────

    /// The six great powers in Imperial.
    [<RequireQualifiedAccess>]
    type NationId =
        | AustriaHungary
        | Italy
        | France
        | GreatBritain
        | Germany
        | Russia

    module NationId =
        /// All six nations in bond order (turn order for rondel).
        val all: NationId list
        
        /// Convert to display string.
        val toString: NationId -> string
        
        /// Parse from string (case-insensitive).
        val tryParse: string -> Result<NationId, string>
        
        /// Starting treasury for each nation (in millions).
        val startingTreasury: NationId -> int

    // ──────────────────────────────────────────────────────────────────────────
    // Domain State
    // ──────────────────────────────────────────────────────────────────────────

    /// Game phase indicating current state.
    [<RequireQualifiedAccess>]
    type GamePhase =
        | Setup          // Players joining, assigning bonds
        | InProgress     // Active gameplay
        | Finished       // Game complete

    /// A player in the game (human or AI).
    type Player = {
        PlayerId: Primitives.Id
        Name: string
    }

    /// Assignment of initial bonds to players.
    type BondAssignment = {
        Nation: NationId
        Player: Player
        BondValue: int  // 2, 4, 6, 9, 12, 16, 20, 25, 30
    }

    /// Persistent game state.
    type GameState = {
        GameId: Primitives.Id
        Phase: GamePhase
        Players: Player list
        /// Initial bond assignments (determines investor relationships)
        BondAssignments: BondAssignment list
        /// Nations in turn order
        TurnOrder: NationId list
        /// Index into TurnOrder for current nation
        CurrentTurnIndex: int
        /// Number of completed rounds
        RoundNumber: int
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Commands
    // ──────────────────────────────────────────────────────────────────────────

    /// Create a new game (enters Setup phase).
    type CreateGameCommand = {
        GameId: Primitives.Id
        /// Players joining the game
        Players: Player list
    }

    /// Start the game after setup (enters InProgress phase).
    /// Triggers initialization of Rondel and Accounting.
    type StartGameCommand = {
        GameId: Primitives.Id
        /// Bond assignments must cover all nations
        BondAssignments: BondAssignment list
    }

    /// Advance to next nation's turn.
    type EndTurnCommand = {
        GameId: Primitives.Id
    }

    /// Union of all gameplay commands.
    type GameplayCommand =
        | CreateGame of CreateGameCommand
        | StartGame of StartGameCommand
        | EndTurn of EndTurnCommand

    // ──────────────────────────────────────────────────────────────────────────
    // Events
    // ──────────────────────────────────────────────────────────────────────────

    /// Game was created and is in setup phase.
    type GameCreatedEvent = {
        GameId: Primitives.Id
        Players: Player list
    }

    /// Game started, all nations initialized.
    type GameStartedEvent = {
        GameId: Primitives.Id
        TurnOrder: NationId list
        /// First nation to act
        FirstNation: NationId
    }

    /// Turn advanced to next nation.
    type TurnAdvancedEvent = {
        GameId: Primitives.Id
        PreviousNation: NationId
        CurrentNation: NationId
        RoundNumber: int
    }

    /// Game ended (victory condition met).
    type GameEndedEvent = {
        GameId: Primitives.Id
        /// Final scores per player
        FinalScores: Map<Primitives.Id, int>
    }

    /// Union of all gameplay events.
    type GameplayEvent =
        | GameCreated of GameCreatedEvent
        | GameStarted of GameStartedEvent
        | TurnAdvanced of TurnAdvancedEvent
        | GameEnded of GameEndedEvent

    // ──────────────────────────────────────────────────────────────────────────
    // Outbound Commands (to other BCs)
    // ──────────────────────────────────────────────────────────────────────────

    /// Initialize rondel for a new game.
    type InitializeRondelOutboundCommand = {
        GameId: Primitives.Id
        Nations: Set<string>
    }

    /// Initialize accounting balances for a new game.
    type InitializeAccountingOutboundCommand = {
        GameId: Primitives.Id
        InitialBalances: Map<string, int>
    }

    /// Union of outbound commands.
    type GameplayOutboundCommand =
        | InitializeRondel of InitializeRondelOutboundCommand
        | InitializeAccounting of InitializeAccountingOutboundCommand

    // ──────────────────────────────────────────────────────────────────────────
    // Dependencies
    // ──────────────────────────────────────────────────────────────────────────

    /// Load game state.
    type LoadGameState = Primitives.Id -> Async<GameState option>

    /// Save game state.
    type SaveGameState = GameState -> Async<Result<unit, string>>

    /// Publish gameplay events.
    type PublishGameplayEvent = GameplayEvent -> Async<unit>

    /// Dispatch commands to other bounded contexts.
    type DispatchOutboundCommand = GameplayOutboundCommand -> Async<Result<unit, string>>

    /// Unified dependencies for Gameplay handlers.
    type GameplayDependencies = {
        Load: LoadGameState
        Save: SaveGameState
        Publish: PublishGameplayEvent
        Dispatch: DispatchOutboundCommand
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Queries
    // ──────────────────────────────────────────────────────────────────────────

    /// Query for current game state.
    type GetGameStateQuery = { GameId: Primitives.Id }

    /// Current game status view.
    type GameStateView = {
        GameId: Primitives.Id
        Phase: GamePhase
        CurrentNation: NationId option
        RoundNumber: int
        Players: Player list
    }

    /// Query dependencies.
    type GameplayQueryDependencies = {
        Load: LoadGameState
    }

    /// Get current game state.
    val getGameState: GameplayQueryDependencies -> GetGameStateQuery -> Async<GameStateView option>

    // ──────────────────────────────────────────────────────────────────────────
    // Handlers
    // ──────────────────────────────────────────────────────────────────────────

    /// Execute a gameplay command.
    val execute: GameplayDependencies -> GameplayCommand -> Async<unit>

2. Implementation (Gameplay.fs)

// src/Imperium/Gameplay.fs

namespace Imperium

open Imperium.Primitives

module Gameplay =

    // ──────────────────────────────────────────────────────────────────────────
    // Value Types
    // ──────────────────────────────────────────────────────────────────────────

    [<RequireQualifiedAccess>]
    type NationId =
        | AustriaHungary
        | Italy
        | France
        | GreatBritain
        | Germany
        | Russia

    module NationId =
        /// Nations in bond/turn order (per Imperial rules)
        let all = [
            NationId.AustriaHungary
            NationId.Italy
            NationId.France
            NationId.GreatBritain
            NationId.Germany
            NationId.Russia
        ]
        
        let toString = function
            | NationId.AustriaHungary -> "Austria-Hungary"
            | NationId.Italy -> "Italy"
            | NationId.France -> "France"
            | NationId.GreatBritain -> "Great Britain"
            | NationId.Germany -> "Germany"
            | NationId.Russia -> "Russia"
        
        let tryParse (raw: string) : Result<NationId, string> =
            if String.IsNullOrWhiteSpace raw then
                Error "Nation cannot be empty"
            else
                let normalized = raw.Trim().ToLowerInvariant().Replace("-", "").Replace(" ", "")
                match normalized with
                | "austriahungary" | "austria" -> Ok NationId.AustriaHungary
                | "italy" -> Ok NationId.Italy
                | "france" -> Ok NationId.France
                | "greatbritain" | "britain" | "gb" | "uk" -> Ok NationId.GreatBritain
                | "germany" -> Ok NationId.Germany
                | "russia" -> Ok NationId.Russia
                | _ -> 
                    let valid = all |> List.map toString |> String.concat ", "
                    Error $"Unknown nation '{raw}'. Valid nations: {valid}"
        
        /// Starting treasury per Imperial rules
        let startingTreasury = function
            | NationId.AustriaHungary -> 2
            | NationId.Italy -> 6
            | NationId.France -> 8
            | NationId.GreatBritain -> 6
            | NationId.Germany -> 10
            | NationId.Russia -> 4

    // ──────────────────────────────────────────────────────────────────────────
    // Domain State
    // ──────────────────────────────────────────────────────────────────────────

    [<RequireQualifiedAccess>]
    type GamePhase =
        | Setup
        | InProgress
        | Finished

    type Player = {
        PlayerId: Id
        Name: string
    }

    type BondAssignment = {
        Nation: NationId
        Player: Player
        BondValue: int
    }

    type GameState = {
        GameId: Id
        Phase: GamePhase
        Players: Player list
        BondAssignments: BondAssignment list
        TurnOrder: NationId list
        CurrentTurnIndex: int
        RoundNumber: int
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Commands
    // ──────────────────────────────────────────────────────────────────────────

    type CreateGameCommand = {
        GameId: Id
        Players: Player list
    }

    type StartGameCommand = {
        GameId: Id
        BondAssignments: BondAssignment list
    }

    type EndTurnCommand = {
        GameId: Id
    }

    type GameplayCommand =
        | CreateGame of CreateGameCommand
        | StartGame of StartGameCommand
        | EndTurn of EndTurnCommand

    // ──────────────────────────────────────────────────────────────────────────
    // Events
    // ──────────────────────────────────────────────────────────────────────────

    type GameCreatedEvent = {
        GameId: Id
        Players: Player list
    }

    type GameStartedEvent = {
        GameId: Id
        TurnOrder: NationId list
        FirstNation: NationId
    }

    type TurnAdvancedEvent = {
        GameId: Id
        PreviousNation: NationId
        CurrentNation: NationId
        RoundNumber: int
    }

    type GameEndedEvent = {
        GameId: Id
        FinalScores: Map<Id, int>
    }

    type GameplayEvent =
        | GameCreated of GameCreatedEvent
        | GameStarted of GameStartedEvent
        | TurnAdvanced of TurnAdvancedEvent
        | GameEnded of GameEndedEvent

    // ──────────────────────────────────────────────────────────────────────────
    // Outbound Commands
    // ──────────────────────────────────────────────────────────────────────────

    type InitializeRondelOutboundCommand = {
        GameId: Id
        Nations: Set<string>
    }

    type InitializeAccountingOutboundCommand = {
        GameId: Id
        InitialBalances: Map<string, int>
    }

    type GameplayOutboundCommand =
        | InitializeRondel of InitializeRondelOutboundCommand
        | InitializeAccounting of InitializeAccountingOutboundCommand

    // ──────────────────────────────────────────────────────────────────────────
    // Dependencies
    // ──────────────────────────────────────────────────────────────────────────

    type LoadGameState = Id -> Async<GameState option>
    type SaveGameState = GameState -> Async<Result<unit, string>>
    type PublishGameplayEvent = GameplayEvent -> Async<unit>
    type DispatchOutboundCommand = GameplayOutboundCommand -> Async<Result<unit, string>>

    type GameplayDependencies = {
        Load: LoadGameState
        Save: SaveGameState
        Publish: PublishGameplayEvent
        Dispatch: DispatchOutboundCommand
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Queries
    // ──────────────────────────────────────────────────────────────────────────

    type GetGameStateQuery = { GameId: Id }

    type GameStateView = {
        GameId: Id
        Phase: GamePhase
        CurrentNation: NationId option
        RoundNumber: int
        Players: Player list
    }

    type GameplayQueryDependencies = {
        Load: LoadGameState
    }

    let getGameState (deps: GameplayQueryDependencies) (q: GetGameStateQuery) : Async<GameStateView option> =
        async {
            let! stateOpt = deps.Load q.GameId
            return stateOpt |> Option.map (fun s ->
                let currentNation =
                    if s.Phase = GamePhase.InProgress && s.TurnOrder.Length > 0 then
                        Some s.TurnOrder.[s.CurrentTurnIndex]
                    else
                        None
                { GameId = s.GameId
                  Phase = s.Phase
                  CurrentNation = currentNation
                  RoundNumber = s.RoundNumber
                  Players = s.Players })
        }

    // ──────────────────────────────────────────────────────────────────────────
    // Internal Handlers
    // ──────────────────────────────────────────────────────────────────────────

    /// Create a new game in Setup phase.
    let internal createGame (deps: GameplayDependencies) (cmd: CreateGameCommand) : Async<unit> =
        async {
            let! existing = deps.Load cmd.GameId
            match existing with
            | Some _ -> 
                // Idempotent: already exists
                ()
            | None ->
                if List.isEmpty cmd.Players then
                    failwith "Game must have at least one player"
                
                let state: GameState = {
                    GameId = cmd.GameId
                    Phase = GamePhase.Setup
                    Players = cmd.Players
                    BondAssignments = []
                    TurnOrder = NationId.all
                    CurrentTurnIndex = 0
                    RoundNumber = 0
                }
                
                let! saveResult = deps.Save state
                match saveResult with
                | Ok () ->
                    let event: GameCreatedEvent = {
                        GameId = cmd.GameId
                        Players = cmd.Players
                    }
                    do! deps.Publish (GameCreated event)
                | Error msg ->
                    failwith $"Failed to save game state: {msg}"
        }

    /// Start the game, initializing Rondel and Accounting.
    let internal startGame (deps: GameplayDependencies) (cmd: StartGameCommand) : Async<unit> =
        async {
            let! stateOpt = deps.Load cmd.GameId
            match stateOpt with
            | None ->
                failwith $"Game {Id.toString cmd.GameId} not found"
            | Some state ->
                if state.Phase <> GamePhase.Setup then
                    failwith "Game can only be started from Setup phase"
                
                // Validate bond assignments cover all nations
                let assignedNations = 
                    cmd.BondAssignments 
                    |> List.map (fun a -> a.Nation) 
                    |> Set.ofList
                let allNations = NationId.all |> Set.ofList
                if assignedNations <> allNations then
                    let missing = Set.difference allNations assignedNations |> Set.map NationId.toString
                    failwith $"Bond assignments missing nations: {missing}"
                
                // Update state
                let newState = { 
                    state with 
                        Phase = GamePhase.InProgress
                        BondAssignments = cmd.BondAssignments
                        RoundNumber = 1 
                }
                
                let! saveResult = deps.Save newState
                match saveResult with
                | Error msg -> failwith $"Failed to save game state: {msg}"
                | Ok () ->
                    // Initialize Rondel
                    let nationNames = NationId.all |> List.map NationId.toString |> Set.ofList
                    let rondelCmd: InitializeRondelOutboundCommand = {
                        GameId = cmd.GameId
                        Nations = nationNames
                    }
                    let! rondelResult = deps.Dispatch (InitializeRondel rondelCmd)
                    match rondelResult with
                    | Error msg -> failwith $"Failed to initialize Rondel: {msg}"
                    | Ok () -> ()
                    
                    // Initialize Accounting with starting treasuries
                    let balances = 
                        NationId.all 
                        |> List.map (fun n -> NationId.toString n, NationId.startingTreasury n)
                        |> Map.ofList
                    let accountingCmd: InitializeAccountingOutboundCommand = {
                        GameId = cmd.GameId
                        InitialBalances = balances
                    }
                    let! accountingResult = deps.Dispatch (InitializeAccounting accountingCmd)
                    match accountingResult with
                    | Error msg -> failwith $"Failed to initialize Accounting: {msg}"
                    | Ok () -> ()
                    
                    // Publish game started event
                    let event: GameStartedEvent = {
                        GameId = cmd.GameId
                        TurnOrder = newState.TurnOrder
                        FirstNation = newState.TurnOrder.[0]
                    }
                    do! deps.Publish (GameStarted event)
        }

    /// End current nation's turn, advance to next.
    let internal endTurn (deps: GameplayDependencies) (cmd: EndTurnCommand) : Async<unit> =
        async {
            let! stateOpt = deps.Load cmd.GameId
            match stateOpt with
            | None ->
                failwith $"Game {Id.toString cmd.GameId} not found"
            | Some state ->
                if state.Phase <> GamePhase.InProgress then
                    failwith "Can only end turn during InProgress phase"
                
                let previousNation = state.TurnOrder.[state.CurrentTurnIndex]
                let nextIndex = (state.CurrentTurnIndex + 1) % state.TurnOrder.Length
                let isNewRound = nextIndex = 0
                
                let newState = {
                    state with
                        CurrentTurnIndex = nextIndex
                        RoundNumber = if isNewRound then state.RoundNumber + 1 else state.RoundNumber
                }
                
                let! saveResult = deps.Save newState
                match saveResult with
                | Error msg -> failwith $"Failed to save game state: {msg}"
                | Ok () ->
                    let event: TurnAdvancedEvent = {
                        GameId = cmd.GameId
                        PreviousNation = previousNation
                        CurrentNation = newState.TurnOrder.[nextIndex]
                        RoundNumber = newState.RoundNumber
                    }
                    do! deps.Publish (TurnAdvanced event)
        }

    // ──────────────────────────────────────────────────────────────────────────
    // Public Router
    // ──────────────────────────────────────────────────────────────────────────

    let execute (deps: GameplayDependencies) (cmd: GameplayCommand) : Async<unit> =
        match cmd with
        | CreateGame c -> createGame deps c
        | StartGame c -> startGame deps c
        | EndTurn c -> endTurn deps c

3. Contract Types (Contract.Gameplay.fs)

// src/Imperium/Contract.Gameplay.fs

namespace Imperium.Contract

module Gameplay =

    /// Serializable player for persistence/API.
    type Player = {
        PlayerId: System.Guid
        Name: string
    }

    /// Serializable bond assignment.
    type BondAssignment = {
        Nation: string
        PlayerId: System.Guid
        BondValue: int
    }

    /// Serializable game state.
    type GameState = {
        GameId: System.Guid
        Phase: string  // "Setup", "InProgress", "Finished"
        Players: Player list
        BondAssignments: BondAssignment list
        TurnOrder: string list
        CurrentTurnIndex: int
        RoundNumber: int
    }

    /// Create game command contract.
    type CreateGameCommand = {
        GameId: System.Guid
        Players: Player list
    }

    /// Start game command contract.
    type StartGameCommand = {
        GameId: System.Guid
        BondAssignments: BondAssignment list
    }

    /// End turn command contract.
    type EndTurnCommand = {
        GameId: System.Guid
    }

    /// Game created event contract.
    type GameCreatedEvent = {
        GameId: System.Guid
        Players: Player list
    }

    /// Game started event contract.
    type GameStartedEvent = {
        GameId: System.Guid
        TurnOrder: string list
        FirstNation: string
    }

    /// Turn advanced event contract.
    type TurnAdvancedEvent = {
        GameId: System.Guid
        PreviousNation: string
        CurrentNation: string
        RoundNumber: int
    }

4. Terminal Host (Imperium.Terminal)

// src/Imperium.Terminal/Gameplay/Host.fs

namespace Imperium.Terminal.Gameplay

open Imperium.Primitives
open Imperium.Gameplay
open Imperium.Terminal

/// In-memory gameplay store.
type GameplayStore = {
    Load: Id -> Async<GameState option>
    Save: GameState -> Async<Result<unit, string>>
}

module InMemoryGameplayStore =
    open System.Collections.Concurrent
    
    let create () : GameplayStore =
        let states = ConcurrentDictionary<Id, GameState>()
        { Load = fun id -> async {
              return match states.TryGetValue(id) with
                     | true, s -> Some s
                     | false, _ -> None }
          Save = fun s -> async {
              states.[s.GameId] <- s
              return Ok () } }

/// Gameplay host record.
type GameplayHost = {
    Execute: GameplayCommand -> unit
    Query: GetGameStateQuery -> GameStateView option
}

module GameplayHost =
    let create
        (store: GameplayStore)
        (bus: IBus)
        (dispatchToRondel: unit -> (Rondel.RondelCommand -> Async<unit>))
        (dispatchToAccounting: unit -> (Accounting.AccountingCommand -> Async<unit>))
        : GameplayHost =
        
        let deps: GameplayDependencies = {
            Load = store.Load
            Save = store.Save
            Publish = fun event ->
                async {
                    do! bus.Publish event
                }
            Dispatch = fun cmd ->
                async {
                    match cmd with
                    | InitializeRondel c ->
                        let rondelCmd: Rondel.SetToStartingPositionsCommand = {
                            GameId = c.GameId
                            Nations = c.Nations
                        }
                        do! dispatchToRondel () (Rondel.SetToStartingPositions rondelCmd)
                        return Ok ()
                    | InitializeAccounting c ->
                        let accountingCmd: Accounting.InitializeNationBalancesCommand = {
                            GameId = c.GameId
                            InitialBalances = c.InitialBalances |> Map.map (fun _ v -> Amount.create v |> Result.defaultValue Unchecked.defaultof<_>)
                        }
                        do! dispatchToAccounting () (Accounting.InitializeNationBalances accountingCmd)
                        return Ok ()
                }
        }
        
        let queryDeps: GameplayQueryDependencies = { Load = store.Load }
        
        { Execute = fun cmd -> 
              Gameplay.execute deps cmd |> Async.RunSynchronously
          Query = fun q -> 
              Gameplay.getGameState queryDeps q |> Async.RunSynchronously }

Test Plan

Unit Tests (GameplayTests.fs)

[<Tests>]
let gameplayTests =
    testList "Gameplay" [
        testList "NationId" [
            testCase "all contains six nations" <| fun _ ->
                Expect.equal (List.length NationId.all) 6 "Should have 6 nations"
                
            testCase "toString returns display names" <| fun _ ->
                Expect.equal (NationId.toString NationId.AustriaHungary) "Austria-Hungary" ""
                Expect.equal (NationId.toString NationId.GreatBritain) "Great Britain" ""
                
            testCase "tryParse is case insensitive" <| fun _ ->
                Expect.isOk (NationId.tryParse "france") ""
                Expect.isOk (NationId.tryParse "FRANCE") ""
                Expect.isOk (NationId.tryParse "France") ""
                
            testCase "tryParse handles aliases" <| fun _ ->
                Expect.isOk (NationId.tryParse "austria") ""
                Expect.isOk (NationId.tryParse "britain") ""
                Expect.isOk (NationId.tryParse "gb") ""
                
            testCase "tryParse rejects unknown nation" <| fun _ ->
                Expect.isError (NationId.tryParse "spain") ""
                
            testCase "startingTreasury matches Imperial rules" <| fun _ ->
                Expect.equal (NationId.startingTreasury NationId.Germany) 10 "Germany starts with 10M"
                Expect.equal (NationId.startingTreasury NationId.France) 8 "France starts with 8M"
                Expect.equal (NationId.startingTreasury NationId.AustriaHungary) 2 "Austria-Hungary starts with 2M"
        ]
        
        testList "createGame" [
            testCase "creates game in Setup phase" <| fun _ ->
                let gameplay, events = createGameplay ()
                let gameId = Id.newId ()
                let player = { PlayerId = Id.newId (); Name = "Alice" }
                
                gameplay.Execute <| CreateGame { GameId = gameId; Players = [player] }
                
                let state = gameplay.Query { GameId = gameId }
                Expect.isSome state ""
                Expect.equal state.Value.Phase GamePhase.Setup ""
                
            testCase "publishes GameCreated event" <| fun _ ->
                let gameplay, events = createGameplay ()
                let gameId = Id.newId ()
                
                gameplay.Execute <| CreateGame { GameId = gameId; Players = [{ PlayerId = Id.newId (); Name = "Bob" }] }
                
                Expect.equal events.Count 1 ""
                match events.[0] with
                | GameCreated e -> Expect.equal e.GameId gameId ""
                | _ -> failtest "Expected GameCreated"
                
            testCase "rejects empty player list" <| fun _ ->
                let gameplay, _ = createGameplay ()
                
                Expect.throws (fun () ->
                    gameplay.Execute <| CreateGame { GameId = Id.newId (); Players = [] }
                ) ""
        ]
        
        testList "startGame" [
            testCase "transitions to InProgress phase" <| fun _ ->
                // Setup
                let gameplay, _ = createGameplay ()
                let gameId = Id.newId ()
                let player = { PlayerId = Id.newId (); Name = "Alice" }
                gameplay.Execute <| CreateGame { GameId = gameId; Players = [player] }
                
                // Create bond assignments for all nations
                let bonds = NationId.all |> List.map (fun n -> { Nation = n; Player = player; BondValue = 9 })
                gameplay.Execute <| StartGame { GameId = gameId; BondAssignments = bonds }
                
                let state = gameplay.Query { GameId = gameId }
                Expect.equal state.Value.Phase GamePhase.InProgress ""
                
            testCase "initializes Rondel via dispatch" <| fun _ ->
                // Verify SetToStartingPositions command is dispatched
                ()
                
            testCase "initializes Accounting via dispatch" <| fun _ ->
                // Verify InitializeNationBalances command is dispatched
                ()
                
            testCase "rejects incomplete bond assignments" <| fun _ ->
                let gameplay, _ = createGameplay ()
                let gameId = Id.newId ()
                let player = { PlayerId = Id.newId (); Name = "Alice" }
                gameplay.Execute <| CreateGame { GameId = gameId; Players = [player] }
                
                // Only assign 3 nations
                let bonds = NationId.all |> List.take 3 |> List.map (fun n -> { Nation = n; Player = player; BondValue = 9 })
                
                Expect.throws (fun () ->
                    gameplay.Execute <| StartGame { GameId = gameId; BondAssignments = bonds }
                ) ""
        ]
        
        testList "endTurn" [
            testCase "advances to next nation" <| fun _ ->
                // Start game, end first turn, verify Austria-Hungary -> Italy
                ()
                
            testCase "wraps around after last nation" <| fun _ ->
                // End 6 turns, verify back to Austria-Hungary with RoundNumber = 2
                ()
                
            testCase "publishes TurnAdvanced event" <| fun _ ->
                ()
        ]
    ]

Acceptance Criteria

  • NationId type with all six nations
  • NationId.all, toString, tryParse, startingTreasury functions
  • GamePhase enum (Setup, InProgress, Finished)
  • GameState with turn tracking
  • CreateGame command creates game in Setup phase
  • StartGame command validates bond assignments and dispatches to Rondel/Accounting
  • EndTurn command advances turn order
  • GameplayEvent types published correctly
  • Contract types for persistence
  • Terminal host with in-memory store
  • Unit tests for all commands and queries

Dependencies

Files to Create/Modify

File Action
src/Imperium/Contract.Gameplay.fs Create (new file)
src/Imperium/Gameplay.fsi Replace placeholder
src/Imperium/Gameplay.fs Replace placeholder
src/Imperium/Imperium.fsproj Add Contract.Gameplay.fs to build order
src/Imperium.Terminal/Gameplay/Store.fs Create (new file)
src/Imperium.Terminal/Gameplay/Host.fs Create (new file)
src/Imperium.Terminal/Program.fs Wire up Gameplay host
tests/Imperium.UnitTests/GameplayTests.fs Expand from placeholder

Notes

  • Nation turn order follows Imperial bond order: AH, IT, FR, GB, DE, RU
  • Starting treasuries from Imperial rules (varies by nation)
  • Bond values: 2, 4, 6, 9, 12, 16, 20, 25, 30 (not implemented in this issue)
  • Victory conditions (25 power points) deferred to future issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions