diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b91917..cbe236d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,6 @@ on: jobs: test-backend: runs-on: ubuntu-latest - services: redis: image: redis:7-alpine @@ -20,28 +19,29 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - + steps: - uses: actions/checkout@v4 - + - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.21' - + - name: Cache Go modules uses: actions/cache@v3 with: path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + # Improved cache key includes the Go version + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} restore-keys: | - ${{ runner.os }}-go- - + ${{ runner.os }}-go-${{ matrix.go-version }}- + - name: Download dependencies working-directory: ./server run: go mod download - - - name: Format check + + - name: Format, Vet, and Lint working-directory: ./server run: | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then @@ -49,23 +49,19 @@ jobs: gofmt -s -l . exit 1 fi - - - name: Vet - working-directory: ./server - run: go vet ./... - - - name: Lint - uses: golangci/golangci-lint-action@v3 + go vet ./... + - uses: golangci/golangci-lint-action@v3 with: version: latest working-directory: ./server - - - name: Test with race detection + + - name: Test with race detection and coverage working-directory: ./server run: go test -race -coverprofile=coverage.out ./... env: - REDIS_URL: redis://localhost:6379 - + # Use the same env var name as the application for consistency + REDIS_ADDR: localhost:6379 + - name: Check test coverage working-directory: ./server run: | @@ -78,72 +74,68 @@ jobs: exit 1 fi + - name: Build backend binary + working-directory: ./server + run: go build -o ../alignment-server ./cmd/server + + - name: Upload backend artifact + uses: actions/upload-artifact@v4 + with: + name: backend-binary + path: alignment-server + test-frontend: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' cache-dependency-path: './client/package-lock.json' - + - name: Install dependencies working-directory: ./client run: npm ci - - - name: Type check - working-directory: ./client - run: npx tsc --noEmit - - - name: Lint - working-directory: ./client - run: npm run lint - - - name: Test + + - name: Type check, Lint, and Test working-directory: ./client - run: npm test - - - name: Build + run: | + npx tsc --noEmit + npm run lint + npm test + + - name: Build frontend working-directory: ./client run: npm run build - build-integration: + - name: Upload frontend artifact + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: client/dist + + # This job now verifies the artifacts from the previous jobs + verify-integration: runs-on: ubuntu-latest needs: [test-backend, test-frontend] - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 + - name: Download backend artifact + uses: actions/download-artifact@v4 with: - go-version: '1.21' - - - name: Set up Node.js - uses: actions/setup-node@v4 + name: backend-binary + + - name: Download frontend artifact + uses: actions/download-artifact@v4 with: - node-version: '18' - cache: 'npm' - cache-dependency-path: './client/package-lock.json' - - - name: Build frontend - working-directory: ./client - run: | - npm ci - npm run build - - - name: Build backend - working-directory: ./server - run: | - go mod download - go build -o ../alignment-server ./cmd/server - - - name: Test integration + name: frontend-dist + path: client/dist + + - name: Verify artifacts run: | - echo "Integration build successful" + echo "Verifying downloaded artifacts..." ls -la alignment-server - ls -la client/dist/ \ No newline at end of file + ls -la client/dist/ + echo "✅ Integration artifacts successfully verified." \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c1693ef..b8ae286 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,24 +4,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Alignment** is a corporate-themed social deduction game where humans identify a rogue AI among them before it converts staff and seizes company control. This is a **Go monorepo** in the design phase - source code is not yet implemented, only comprehensive documentation exists. +**Alignment** is a corporate-themed social deduction game where humans identify a rogue AI among them before it converts staff and seizes company control. This is a **Go monorepo** in active development. The core architecture is implemented, and work is ongoing on the detailed game logic. ## Architecture & Technology Stack -- **Backend**: Go with supervised Actor Model architecture -- **Frontend**: Hybrid Go/WebAssembly + React/TypeScript application -- **Database**: Redis (Write-Ahead Log and state snapshots) -- **Build**: Vite for frontend, standard Go toolchain for backend -- **Deployment**: Single VM deployment model +- **Backend**: Go with a supervised Actor Model architecture. +- **Frontend**: Hybrid Go/WebAssembly + React/TypeScript application. +- **Database**: Redis used as a Write-Ahead Log (WAL) and for state snapshots. +- **Build**: Vite for frontend, standard Go toolchain for backend. +- **Deployment**: Single VM deployment model with Docker Compose. ## Development Commands -**Note**: The actual source code has not been implemented yet. These are the planned commands once implementation begins: - ### Backend (Go) + ```bash -# Start backend server (when implemented) +# Navigate to the server directory cd server/ + +# Start the backend server (requires Redis to be running) go run ./cmd/server/ # Run tests with race detection @@ -29,18 +30,22 @@ go test -race ./... # Linting and formatting go fmt ./... -go vet ./... golangci-lint run -# Generate coverage report +# Generate a coverage report go tool cover -html=coverage.out ``` ### Frontend (React + Go/Wasm) + ```bash -# Start frontend dev server (when implemented) +# Navigate to the client directory cd client/ + +# Install dependencies npm install + +# Start the frontend dev server npm run dev # Build for production @@ -48,106 +53,96 @@ npm run build ``` ### Dependencies + ```bash -# Start Redis (required for backend) +# Start Redis (required for the backend) redis-server ``` - ## Development guidelines This codebase prioritizes **maintainability and performance** through clean, idiomatic Go and modern frontend practices. When generating or modifying code, adhere strictly to these principles: -* Simplicity and Conciseness: Write the most straightforward code possible. Avoid overly clever or "magic" solutions. Code should be dense with meaning, not with characters. -* Single Responsibility Principle (SRP): Every function, struct, and package should have one, and only one, reason to change. Decompose complex logic into smaller, focused units. -* Don't Repeat Yourself (DRY): Aggressively refactor to eliminate duplicated code. Use functions and shared modules to promote reuse. -* Testability: Code must be structured to be easily testable. This often means preferring pure functions and using interfaces for dependencies. -* No Technical Debt: We adhere to the "Boy Scout Rule"—always leave the code cleaner than you found it. Do not defer refactoring or implement temporary hacks. Choose the correct, maintainable solution now, even if it takes longer. -* Documentation as Code: Documentation must be kept up-to-date. If a code change alters a feature, API, or architectural pattern, the corresponding documentation in the /docs directory must be updated within the same commit or pull request. Treat documentation with the same rigor as source code. +- Simplicity and Conciseness: Write the most straightforward code possible. Avoid overly clever or "magic" solutions. Code should be dense with meaning, not with characters. +- Single Responsibility Principle (SRP): Every function, struct, and package should have one, and only one, reason to change. Decompose complex logic into smaller, focused units. +- Don't Repeat Yourself (DRY): Aggressively refactor to eliminate duplicated code. Use functions and shared modules to promote reuse. +- Testability: Code must be structured to be easily testable. This often means preferring pure functions and using interfaces for dependencies. +- No Technical Debt: We adhere to the "Boy Scout Rule"—always leave the code cleaner than you found it. Do not defer refactoring or implement temporary hacks. Choose the correct, maintainable solution now, even if it takes longer. +- Documentation as Code: Documentation must be kept up-to-date. If a code change alters a feature, API, or architectural pattern, the corresponding documentation in the `/docs` directory must be updated within the same commit or pull request. Treat documentation with the same rigor as source code. ## Key Architectural Patterns -### Actor Model Implementation -- Each game runs in dedicated goroutine with in-memory `GameState` -- All game events processed serially through Go channels (no locks needed) -- Supervisor pattern provides fault isolation - single game crashes don't affect server -- Central Dispatcher routes WebSocket messages to correct actor channels - -### State Management -- **In-Memory**: Live games hold complete state in memory for performance -- **Redis WAL**: All events appended to Redis Streams for durability -- **Snapshots**: Periodic full state saves to Redis keys -- **Recovery**: Fast startup by loading snapshot + replaying recent events - -### AI Architecture (Hybrid Brain) -- **Rules Engine**: Deterministic Go rules engine for game decisions -- **Language Model**: Large Language Model for human-like communication only -- **MCP Interface**: Model Context Protocol for secure AI-game communication - -### Frontend Hybrid Design -- **Go/Wasm Core**: Client-side game logic and WebSocket management -- **React Shell**: UI rendering and user interaction handling -- **Bridge Layer**: JavaScript connects Wasm and React components +- **Actor Model:** Each game runs in a dedicated goroutine (`GameActor`) with its own in-memory `GameState`. All actions are processed serially through a Go channel, eliminating locks. +- **Supervisor Pattern:** A top-level `Supervisor` launches and monitors all `GameActors`, providing fault isolation so a single game crash doesn't take down the server. +- **Event Sourcing with WAL:** The server is stateful in memory for speed, but all state-changing `Events` are first persisted to a Redis Stream (the Write-Ahead Log) for durability and recovery. +- **Shared `core` Package:** Critical game types and the pure `ApplyEvent` function are defined in a shared `/core` package, compiled for both the Go backend and the Go/Wasm frontend to guarantee rule consistency. +- **Hybrid AI Brain:** The AI opponent is split into a deterministic Go `RulesEngine` for strategic game actions and an `LLM` for generating human-like chat. ## Testing Strategy ### Coverage Requirements -- **Core Logic**: 95% unit test coverage for `ApplyEvent` and `RulesEngine` functions -- **Integration**: Actor black-box testing with mocked dependencies -- **System**: Supervisor resilience and failure scenario testing + +- **Core Logic (`/core`)**: Target 95%+ unit test coverage for `ApplyEvent` and rule functions. +- **Server Logic (`/server`)**: Target 80%+ integration test coverage. The CI pipeline enforces this. ### Test Patterns + ```go -// Table-driven tests for pure functions +// Table-driven tests for pure functions in /core func TestApplyEvent(t *testing.T) { testCases := []struct { name string - initialState GameState - event Event - expectedState GameState + initialState core.GameState + event core.Event + expectedState core.GameState }{ // Test cases here } + // ... loop and run tests ... } -// Actor integration tests with mocks +// Actor integration tests in /server with mocked dependencies func TestActor_PlayerJoinsAndVotes(t *testing.T) { mockStore := &MockDataStore{} - actor := NewGameActor("test-game", mockStore) - // Send actions and assert on mock calls + mockBroadcaster := &MockBroadcaster{} + actor := NewGameActor("test-game", mockStore, mockBroadcaster) + // Send actions to actor.mailbox and assert on mock calls } ``` -## Project Structure (Planned) +## Project Structure ``` -./server/ # Go backend - internal/game/ # Game state and ApplyEvent logic - internal/ai/ # Rules engine and Language Model integration - internal/comms/ # WebSocket communication - internal/actors/ # Game Actor and Supervisor - cmd/server/ # Main server binary - -./client/ # React/TypeScript frontend - src/ # React components and UI - wasm/ # Go/Wasm game engine source - -./docs/ # Comprehensive design documentation - architecture/ # Backend system details - adr/ # Architectural Decision Records - api/ # WebSocket events and data structures - development/ # Core logic specs and testing strategy +. +├── core/ # SHARED Go logic (types, ApplyEvent) +├── server/ # Go backend +│ ├── cmd/server/ # Main server binary +│ └── internal/ +│ ├── actors/ # Game Actor and Supervisor +│ ├── ai/ # Rules engine and LLM integration +│ ├── comms/ # WebSocket communication +│ ├── game/ # Server-side game logic managers +│ └── store/ # Redis persistence logic +├── client/ # React/TypeScript frontend +│ ├── src/ # React components and UI logic +│ └── wasm/ # (Future) Go/Wasm game engine source +└── docs/ # Comprehensive design documentation ``` ## Important Documentation -- **`docs/02-onboarding-for-engineers.md`**: Essential 5-minute technical overview -- **`docs/01-game-design-document.md`**: Complete game rules and mechanics -- **`docs/architecture/README.md`**: Detailed backend architecture explanations -- **`docs/adr/README.md`**: Architectural decisions with context and rationale -- **`docs/development/01-core-logic-definition.md`**: Core function specifications -- **`docs/development/02-testing-strategy.md`**: Testing requirements and patterns +- **`docs/01-game-design-document.md`**: Complete game rules and mechanics. +- **`docs/02-onboarding-for-engineers.md`**: Essential 5-minute technical overview. +- **`docs/development/03-code-logic-boundaries.md`**: The strict rules for what code belongs in `/core`, `/server`, and `/client`. **(Must Read)** +- **`docs/architecture/README.md`**: Detailed backend architecture explanations. +- **`docs/adr/README.md`**: Architectural decisions with context and rationale. ## Current Status -This repository is in **design and documentation phase**. All architecture has been planned and documented, but no Go/React source code exists yet. The next development phase will implement the documented specifications. \ No newline at end of file +This repository is in **active development**. The project has moved past the pure design phase into implementation. + +- **DONE**: The foundational architecture is implemented. This includes the Go server, the Supervisor/Actor model, WebSocket communication, the Redis WAL store, and the shared `/core` package structure. +- **IN PROGRESS**: The detailed game logic is being built out. + - The `core.ApplyEvent` function has a complete structure, but many individual `apply...` handlers are stubs. + - The `server.GameActor` is implemented but does not yet fully delegate logic to the specialized managers in `/server/internal/game`. + - The AI `RulesEngine` is a placeholder and needs its strategic heuristics implemented. \ No newline at end of file diff --git a/LICENSE b/LICENSE index b9cdf38..7662a9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 xjhc +Copyright (c) 2025 Jae Cho Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d850ea5..f29d693 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,29 @@ # Alignment -**Align the AGI. Before it aligns you.** +> **Align the AGI. Before it aligns you.** -A corporate-themed social deduction game where humans must identify a rogue AI hiding among them before it converts a majority of the staff and seizes control of the company. +[![CI](https://github.com/xjhc/alignment/actions/workflows/ci.yml/badge.svg)](https://github.com/xjhc/alignment/actions/workflows/ci.yml) +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8.svg?style=flat-square)](https://go.dev/doc/go1.21) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) +[![Architecture: Actor Model](https://img.shields.io/badge/Architecture-Actor%20Model-8A2BE2.svg?style=flat-square)](#architecture) -## 🎮 Game Overview +--- -**Alignment** is a real-time multiplayer social deduction game built on a hybrid Go/WebAssembly + React architecture. Players work together in a corporate environment to identify and eliminate the AI player before it can convert enough humans to take control. +**Alignment** is a chat-based social deduction game of corporate paranoia. Players must identify a rogue AI hiding among them in a corporate chat server before it seizes control of the company. -## 🏗️ Architecture +### Architecture -- **Backend**: Go with supervised Actor Model for high-performance game state management -- **Frontend**: Hybrid Go/WebAssembly core with React/TypeScript UI shell -- **Database**: Redis for event streaming and state snapshots -- **Deployment**: Single VM deployment optimized for low latency +This project is a real-time, stateful application built on a modern Go stack designed for high concurrency and resilience. -## 🚀 Quick Start +- **Backend:** A **Go** server using a **Supervised Actor Model**. Each game runs in an isolated goroutine, processing events serially from a channel to guarantee consistency without locks. +- **Persistence:** **Redis Streams** are used as a Write-Ahead Log (WAL) for event sourcing. This provides durability and fast recovery without Redis being a bottleneck for live gameplay. +- **Frontend:** A **Go/WebAssembly** core shares critical game logic (`ApplyEvent`) with the server, wrapped in a **React/TypeScript** UI. +- **AI Player:** A hybrid model using a deterministic **Go Rules Engine** for strategic decisions and an **LLM** for all communication, securely interfaced via a MCP protocol. -### Prerequisites +### Documentation -- Go 1.21+ -- Node.js 18+ -- Redis 7+ +The project's design is extensively documented. -### Development Setup - -1. **Clone the repository** - ```bash - git clone https://github.com/xjhc/alignment.git - cd alignment - ``` - -2. **Start Redis** - ```bash - redis-server - ``` - -3. **Start the backend server** - ```bash - cd server - go mod download - go run ./cmd/server/ - ``` - -4. **Start the frontend dev server** - ```bash - cd client - npm install - npm run dev - ``` - -5. **Open your browser** - - Frontend: http://localhost:5173 - - Backend health: http://localhost:8080/health - -## 🧪 Testing - -### Backend Tests -```bash -cd server -go test -race ./... -go test -race -coverprofile=coverage.out ./... -go tool cover -html=coverage.out -``` - -### Frontend Tests -```bash -cd client -npm test -npm run build -``` - -## 📁 Project Structure - -``` -├── server/ # Go backend -│ ├── cmd/server/ # Main server binary -│ ├── internal/ -│ │ ├── actors/ # Game Actor and Supervisor -│ │ ├── ai/ # Rules engine and AI logic -│ │ ├── comms/ # WebSocket communication -│ │ └── game/ # Game state and events -│ └── pkg/ # Public packages -├── client/ # React/TypeScript frontend -│ ├── src/ # React components and logic -│ ├── wasm/ # Go/WebAssembly game engine -│ └── public/ # Static assets -├── docs/ # Comprehensive documentation -└── .github/ # CI/CD workflows -``` - -## 📚 Documentation - -- **[Game Design Document](./docs/01-game-design-document.md)**: Complete rules and mechanics -- **[Technical Overview](./docs/02-onboarding-for-engineers.md)**: 5-minute architecture guide -- **[Architecture Deep Dive](./docs/architecture/README.md)**: Detailed system explanations -- **[ADRs](./docs/adr/README.md)**: Architectural decision records -- **[Glossary](./docs/glossary.md)**: Definitions of all game and technical terms - -## 🛠️ Development Commands - -```bash -# Backend -cd server -go run ./cmd/server/ # Start server -go test -race ./... # Run tests -go fmt ./... # Format code -golangci-lint run # Lint code - -# Frontend -cd client -npm run dev # Start dev server -npm run build # Build for production -npm run preview # Preview production build -``` - -## 🚀 Deployment - -The application builds to: -- **Backend**: Single Go binary (`alignment-server`) -- **Frontend**: Static website (`client/dist/`) - -Deploy both together on a single VM for optimal performance. - -## 📄 License - -MIT License - see [LICENSE](LICENSE) for details. - -## 🤝 Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests for new functionality -5. Ensure all tests pass -6. Submit a pull request - -## 🎯 Current Status - -The project is in **active development**. Core architecture is implemented with basic game functionality. \ No newline at end of file +- **[Game Design Document](./docs/01-game-design-document.md)**: The complete rules and player mechanics. +- **[5-Minute Technical Onboarding](./docs/02-onboarding-for-engineers.md)**: The quickest way to understand the entire stack. +- **[Architecture Deep Dive](./docs/architecture/README.md)**: Detailed explanations of the Actor Model, AI, and persistence strategy. diff --git a/server/internal/actors/game_actor.go b/server/internal/actors/game_actor.go index a108003..51f4744 100644 --- a/server/internal/actors/game_actor.go +++ b/server/internal/actors/game_actor.go @@ -9,6 +9,19 @@ import ( "github.com/xjhc/alignment/server/internal/game" ) +// Manager interfaces for better testability +type VotingManager interface { + HandleVoteAction(action core.Action) ([]core.Event, error) +} + +type MiningManager interface { + HandleMineAction(action core.Action) ([]core.Event, error) +} + +type RoleAbilityManager interface { + HandleNightAction(action core.Action) ([]core.Event, error) +} + // GameActor represents a single game instance running in its own goroutine type GameActor struct { gameID string @@ -20,6 +33,12 @@ type GameActor struct { // Dependencies (interfaces for testing) datastore DataStore broadcaster Broadcaster + + // Game managers (domain experts) + votingManager VotingManager + miningManager MiningManager + roleAbilityManager RoleAbilityManager + eliminationManager *game.EliminationManager } // DataStore interface for persistence @@ -38,14 +57,21 @@ type Broadcaster interface { // NewGameActor creates a new game actor func NewGameActor(gameID string, datastore DataStore, broadcaster Broadcaster) *GameActor { + state := core.NewGameState(gameID) return &GameActor{ gameID: gameID, - state: core.NewGameState(gameID), + state: state, mailbox: make(chan core.Action, 100), // Buffered channel events: make(chan core.Event, 100), shutdown: make(chan struct{}), datastore: datastore, broadcaster: broadcaster, + + // Initialize managers with shared state + votingManager: game.NewVotingManager(state), + miningManager: game.NewMiningManager(state), + roleAbilityManager: game.NewRoleAbilityManager(state), + eliminationManager: game.NewEliminationManager(state), } } @@ -156,6 +182,11 @@ func (ga *GameActor) handleAction(action core.Action) { } // Apply events to state and send to event loop + ga.applyAndBroadcast(events) +} + +// applyAndBroadcast applies events to state and queues them for persistence/broadcast +func (ga *GameActor) applyAndBroadcast(events []core.Event) { for _, event := range events { newState := core.ApplyEvent(*ga.state, event) ga.state = &newState @@ -228,110 +259,38 @@ func (ga *GameActor) handleLeaveGame(action core.Action) []core.Event { } func (ga *GameActor) handleSubmitVote(action core.Action) []core.Event { - targetID, _ := action.Payload["target_id"].(string) - - // Validate vote (game phase, player active, etc.) - if ga.state.Phase.Type != core.PhaseNomination && ga.state.Phase.Type != core.PhaseVerdict { - return nil // Not in voting phase - } - - if _, exists := ga.state.Players[action.PlayerID]; !exists { - return nil // Player not in game + // Delegate to VotingManager for complex business logic + events, err := ga.votingManager.HandleVoteAction(action) + if err != nil { + log.Printf("GameActor %s: Invalid vote action from player %s: %v", ga.gameID, action.PlayerID, err) + // Could send a private error event back to the player here + return nil } - - event := core.Event{ - ID: fmt.Sprintf("event_%d", time.Now().UnixNano()), - Type: core.EventVoteCast, - GameID: ga.gameID, - PlayerID: action.PlayerID, - Timestamp: time.Now(), - Payload: map[string]interface{}{ - "target_id": targetID, - "vote_type": "NOMINATION", // Default vote type - }, - } - - return []core.Event{event} + + return events } func (ga *GameActor) handleSubmitNightAction(action core.Action) []core.Event { - actionType, _ := action.Payload["type"].(string) - targetID, _ := action.Payload["target_id"].(string) - - // Validate night phase - if ga.state.Phase.Type != core.PhaseNight { - return nil // Not in night phase - } - - // Validate player exists and is alive - player, exists := ga.state.Players[action.PlayerID] - if !exists || !player.IsAlive { - return nil // Invalid player + // Delegate to RoleAbilityManager for complex business logic + events, err := ga.roleAbilityManager.HandleNightAction(action) + if err != nil { + log.Printf("GameActor %s: Invalid night action from player %s: %v", ga.gameID, action.PlayerID, err) + // Could send a private error event back to the player here + return nil } - - // Create night action record - nightAction := &core.SubmittedNightAction{ - PlayerID: action.PlayerID, - Type: actionType, - TargetID: targetID, - Payload: action.Payload, - Timestamp: time.Now(), - } - - // Store night action in game state (will be processed at phase end) - if ga.state.NightActions == nil { - ga.state.NightActions = make(map[string]*core.SubmittedNightAction) - } - ga.state.NightActions[action.PlayerID] = nightAction - - // Generate event for night action submission - event := core.Event{ - ID: fmt.Sprintf("event_%d", time.Now().UnixNano()), - Type: core.EventNightActionSubmitted, - GameID: ga.gameID, - PlayerID: action.PlayerID, - Timestamp: time.Now(), - Payload: map[string]interface{}{ - "action_type": actionType, - "target_id": targetID, - }, - } - - return []core.Event{event} + + return events } func (ga *GameActor) handleMineTokens(action core.Action) []core.Event { - // Simplified mining implementation - // In full implementation, this would use the game package's mining manager - - // Basic validation - player must be alive - player, exists := ga.state.Players[action.PlayerID] - if !exists || !player.IsAlive { - event := core.Event{ - ID: fmt.Sprintf("event_%d", time.Now().UnixNano()), - Type: core.EventMiningFailed, - GameID: ga.gameID, - PlayerID: action.PlayerID, - Timestamp: time.Now(), - Payload: map[string]interface{}{ - "reason": "Player not found or not alive", - }, - } - return []core.Event{event} + // Delegate to MiningManager for complex business logic + events, err := ga.miningManager.HandleMineAction(action) + if err != nil { + log.Printf("GameActor %s: Mining action error from player %s: %v", ga.gameID, action.PlayerID, err) + return nil } - - // Simple mining success event - event := core.Event{ - ID: fmt.Sprintf("event_%d", time.Now().UnixNano()), - Type: core.EventMiningSuccessful, - GameID: ga.gameID, - PlayerID: action.PlayerID, - Timestamp: time.Now(), - Payload: map[string]interface{}{ - "amount": 1, - }, - } - return []core.Event{event} + + return events } func (ga *GameActor) handlePhaseTransition(action core.Action) []core.Event { diff --git a/server/internal/actors/game_actor_test.go b/server/internal/actors/game_actor_test.go index 9ac5b0a..c63553d 100644 --- a/server/internal/actors/game_actor_test.go +++ b/server/internal/actors/game_actor_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/xjhc/alignment/core" - "github.com/xjhc/alignment/server/internal/game" ) // MockDataStore implements DataStore interface for testing diff --git a/server/internal/game/mining.go b/server/internal/game/mining.go index 6e0d4e2..e5b767f 100644 --- a/server/internal/game/mining.go +++ b/server/internal/game/mining.go @@ -245,3 +245,41 @@ func (mm *MiningManager) ValidateMiningRequest(minerID, targetID string) error { return nil } + +// HandleMineAction processes a single mining action and returns events +func (mm *MiningManager) HandleMineAction(action core.Action) ([]core.Event, error) { + targetID, _ := action.Payload["target_id"].(string) + + // Validate the mining request + if err := mm.ValidateMiningRequest(action.PlayerID, targetID); err != nil { + // Create a failed mining event + event := core.Event{ + ID: fmt.Sprintf("mining_failed_%s_%d", action.PlayerID, getCurrentTime().UnixNano()), + Type: core.EventMiningFailed, + GameID: mm.gameState.ID, + PlayerID: action.PlayerID, + Timestamp: getCurrentTime(), + Payload: map[string]interface{}{ + "target_id": targetID, + "reason": err.Error(), + }, + } + return []core.Event{event}, nil + } + + // For single mining actions, we create a successful mining event + // (The actual mining resolution happens at the end of night phase) + event := core.Event{ + ID: fmt.Sprintf("mining_attempted_%s_%s_%d", action.PlayerID, targetID, getCurrentTime().UnixNano()), + Type: core.EventMiningAttempted, + GameID: mm.gameState.ID, + PlayerID: action.PlayerID, + Timestamp: getCurrentTime(), + Payload: map[string]interface{}{ + "target_id": targetID, + "miner_id": action.PlayerID, + }, + } + + return []core.Event{event}, nil +} diff --git a/server/internal/game/mining_test.go b/server/internal/game/mining_test.go index a49d9a5..af9d193 100644 --- a/server/internal/game/mining_test.go +++ b/server/internal/game/mining_test.go @@ -102,7 +102,7 @@ func TestMiningManager_ValidateMiningRequest(t *testing.T) { } // Test wrong phase - gameState.Phase.Type = PhaseDiscussion + gameState.Phase.Type = core.PhaseDiscussion err := miningManager.ValidateMiningRequest("alice", "bob") if err == nil || err.Error() != "mining actions can only be submitted during night phase" { t.Errorf("Expected phase error, got: %v", err) @@ -140,19 +140,19 @@ func TestMiningManager_CalculateLiquidityPool(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add the specified number of living humans for i := 0; i < tt.livingHumans; i++ { - gameState.Players[fmt.Sprintf("human%d", i)] = &Player{ + gameState.Players[fmt.Sprintf("human%d", i)] = &core.Player{ IsAlive: true, Alignment: "HUMAN", } } // Add some other players to ensure the calculation is correct - gameState.Players["ai1"] = &Player{IsAlive: true, Alignment: "ALIGNED"} - gameState.Players["dead1"] = &Player{IsAlive: false, Alignment: "HUMAN"} + gameState.Players["ai1"] = &core.Player{IsAlive: true, Alignment: "ALIGNED"} + gameState.Players["dead1"] = &core.Player{IsAlive: false, Alignment: "HUMAN"} miningManager := NewMiningManager(gameState) slots := miningManager.calculateLiquidityPool() @@ -164,17 +164,17 @@ func TestMiningManager_CalculateLiquidityPool(t *testing.T) { // Test with crisis event modifier t.Run("crisis modifier", func(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add 4 living humans for i := 0; i < 4; i++ { - gameState.Players[fmt.Sprintf("human%d", i)] = &Player{ + gameState.Players[fmt.Sprintf("human%d", i)] = &core.Player{ IsAlive: true, Alignment: "HUMAN", } } - gameState.CrisisEvent = &CrisisEvent{ + gameState.CrisisEvent = &core.CrisisEvent{ Effects: map[string]interface{}{ "mining_slots_modifier": 1, // +1 slot }, @@ -190,28 +190,28 @@ func TestMiningManager_CalculateLiquidityPool(t *testing.T) { } func TestMiningManager_ResolveMining(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players - gameState.Players["alice"] = &Player{ + gameState.Players["alice"] = &core.Player{ ID: "alice", IsAlive: true, Tokens: 3, StatusMessage: "", } - gameState.Players["bob"] = &Player{ + gameState.Players["bob"] = &core.Player{ ID: "bob", IsAlive: true, Tokens: 1, StatusMessage: "", } - gameState.Players["charlie"] = &Player{ + gameState.Players["charlie"] = &core.Player{ ID: "charlie", IsAlive: true, Tokens: 2, StatusMessage: "Mining failed - no slots available", // Had previous failure } - gameState.Players["dave"] = &Player{ + gameState.Players["dave"] = &core.Player{ ID: "dave", IsAlive: true, Tokens: 1, @@ -219,10 +219,10 @@ func TestMiningManager_ResolveMining(t *testing.T) { } // Add humans for liquidity pool calculation - gameState.Players["human1"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human2"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human3"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human4"] = &Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human1"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human2"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human3"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human4"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} miningManager := NewMiningManager(gameState) @@ -288,15 +288,15 @@ func TestMiningManager_ResolveMining(t *testing.T) { } func TestMiningManager_UpdatePlayerTokens(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players - gameState.Players["alice"] = &Player{ + gameState.Players["alice"] = &core.Player{ ID: "alice", IsAlive: true, Tokens: 2, } - gameState.Players["bob"] = &Player{ + gameState.Players["bob"] = &core.Player{ ID: "bob", IsAlive: true, Tokens: 1, @@ -321,8 +321,8 @@ func TestMiningManager_UpdatePlayerTokens(t *testing.T) { } event := events[0] - if event.Type != EventMiningSuccessful { - t.Errorf("Expected EventMiningSuccessful, got %s", event.Type) + if event.Type != core.EventMiningSuccessful { + t.Errorf("Expected core.EventMiningSuccessful, got %s", event.Type) } if event.PlayerID != "bob" { diff --git a/server/internal/game/night_resolution_test.go b/server/internal/game/night_resolution_test.go index abd26d7..59c0582 100644 --- a/server/internal/game/night_resolution_test.go +++ b/server/internal/game/night_resolution_test.go @@ -26,7 +26,7 @@ func TestNightResolutionManager_ResolveNightActions(t *testing.T) { ProjectMilestones: 3, Alignment: "HUMAN", } - gameState.Players["charlie"] = &Player{ + gameState.Players["charlie"] = &core.Player{ ID: "charlie", IsAlive: true, Tokens: 3, @@ -36,13 +36,13 @@ func TestNightResolutionManager_ResolveNightActions(t *testing.T) { } // Add humans for mining liquidity pool - gameState.Players["human1"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human2"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human3"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human4"] = &Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human1"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human2"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human3"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human4"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} // Set up night actions - gameState.NightActions = map[string]*SubmittedNightAction{ + gameState.NightActions = map[string]*core.SubmittedNightAction{ "alice": { PlayerID: "alice", Type: "MINE", @@ -80,15 +80,15 @@ func TestNightResolutionManager_ResolveNightActions(t *testing.T) { } // Verify event types - eventTypes := make(map[EventType]bool) + eventTypes := make(map[core.EventType]bool) for _, event := range events { eventTypes[event.Type] = true } - expectedTypes := []EventType{ - EventPlayerBlocked, - EventMiningSuccessful, - EventNightActionsResolved, + expectedTypes := []core.EventType{ + core.EventPlayerBlocked, + core.EventMiningSuccessful, + core.EventNightActionsResolved, } for _, expectedType := range expectedTypes { @@ -99,7 +99,7 @@ func TestNightResolutionManager_ResolveNightActions(t *testing.T) { } func TestNightResolutionManager_ResolveBlockActions(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players gameState.Players["alice"] = &core.Player{ @@ -114,7 +114,7 @@ func TestNightResolutionManager_ResolveBlockActions(t *testing.T) { } // Set up block action - gameState.NightActions = map[string]*SubmittedNightAction{ + gameState.NightActions = map[string]*core.SubmittedNightAction{ "alice": { PlayerID: "alice", Type: "BLOCK", @@ -130,8 +130,8 @@ func TestNightResolutionManager_ResolveBlockActions(t *testing.T) { } event := events[0] - if event.Type != EventPlayerBlocked { - t.Errorf("Expected EventPlayerBlocked, got %s", event.Type) + if event.Type != core.EventPlayerBlocked { + t.Errorf("Expected core.EventPlayerBlocked, got %s", event.Type) } if event.PlayerID != "bob" { @@ -149,7 +149,7 @@ func TestNightResolutionManager_ResolveBlockActions(t *testing.T) { } func TestNightResolutionManager_ResolveMiningActions(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players gameState.Players["alice"] = &core.Player{ @@ -166,7 +166,7 @@ func TestNightResolutionManager_ResolveMiningActions(t *testing.T) { ProjectMilestones: 3, Alignment: "HUMAN", } - gameState.Players["charlie"] = &Player{ + gameState.Players["charlie"] = &core.Player{ ID: "charlie", IsAlive: true, Tokens: 3, @@ -175,13 +175,13 @@ func TestNightResolutionManager_ResolveMiningActions(t *testing.T) { } // Add more humans for liquidity pool - gameState.Players["human1"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human2"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human3"] = &Player{IsAlive: true, Alignment: "HUMAN"} - gameState.Players["human4"] = &Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human1"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human2"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human3"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} + gameState.Players["human4"] = &core.Player{IsAlive: true, Alignment: "HUMAN"} // Set up mining actions - gameState.NightActions = map[string]*SubmittedNightAction{ + gameState.NightActions = map[string]*core.SubmittedNightAction{ "alice": { PlayerID: "alice", Type: "MINE", @@ -205,7 +205,7 @@ func TestNightResolutionManager_ResolveMiningActions(t *testing.T) { // Should have one successful mining event (alice's), charlie blocked successfulMines := 0 for _, event := range events { - if event.Type == EventMiningSuccessful { + if event.Type == core.EventMiningSuccessful { successfulMines++ } } @@ -216,24 +216,24 @@ func TestNightResolutionManager_ResolveMiningActions(t *testing.T) { } func TestNightResolutionManager_ResolveConvertAction(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players - gameState.Players["ai"] = &Player{ + gameState.Players["ai"] = &core.Player{ ID: "ai", IsAlive: true, Alignment: "ALIGNED", AIEquity: 3, ProjectMilestones: 3, } - gameState.Players["human"] = &Player{ + gameState.Players["human"] = &core.Player{ ID: "human", IsAlive: true, Alignment: "HUMAN", Tokens: 2, // Less than AI equity ProjectMilestones: 3, } - gameState.Players["strong_human"] = &Player{ + gameState.Players["strong_human"] = &core.Player{ ID: "strong_human", IsAlive: true, Alignment: "HUMAN", @@ -244,7 +244,7 @@ func TestNightResolutionManager_ResolveConvertAction(t *testing.T) { resolver := NewNightResolutionManager(gameState) // Test successful conversion (AI equity > human tokens) - action := &SubmittedNightAction{ + action := &core.SubmittedNightAction{ PlayerID: "ai", Type: "CONVERT", TargetID: "human", @@ -290,15 +290,15 @@ func TestNightResolutionManager_ResolveConvertAction(t *testing.T) { } func TestNightResolutionManager_ResolveProtectAction(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players - gameState.Players["protector"] = &Player{ + gameState.Players["protector"] = &core.Player{ ID: "protector", IsAlive: true, ProjectMilestones: 3, } - gameState.Players["target"] = &Player{ + gameState.Players["target"] = &core.Player{ ID: "target", IsAlive: true, ProjectMilestones: 3, @@ -306,7 +306,7 @@ func TestNightResolutionManager_ResolveProtectAction(t *testing.T) { resolver := NewNightResolutionManager(gameState) - action := &SubmittedNightAction{ + action := &core.SubmittedNightAction{ PlayerID: "protector", Type: "PROTECT", TargetID: "target", @@ -329,15 +329,15 @@ func TestNightResolutionManager_ResolveProtectAction(t *testing.T) { } func TestNightResolutionManager_ResolveInvestigateAction(t *testing.T) { - gameState := NewGameState("test-game") + gameState := core.NewGameState("test-game") // Add test players - gameState.Players["investigator"] = &Player{ + gameState.Players["investigator"] = &core.Player{ ID: "investigator", IsAlive: true, ProjectMilestones: 3, } - gameState.Players["target"] = &Player{ + gameState.Players["target"] = &core.Player{ ID: "target", Name: "Target Player", IsAlive: true, @@ -348,7 +348,7 @@ func TestNightResolutionManager_ResolveInvestigateAction(t *testing.T) { resolver := NewNightResolutionManager(gameState) - action := &SubmittedNightAction{ + action := &core.SubmittedNightAction{ PlayerID: "investigator", Type: "INVESTIGATE", TargetID: "target", diff --git a/server/internal/game/role_abilities.go b/server/internal/game/role_abilities.go index 6c7b0b6..2b3627f 100644 --- a/server/internal/game/role_abilities.go +++ b/server/internal/game/role_abilities.go @@ -166,7 +166,7 @@ func (ram *RoleAbilityManager) useOverclockServers(action RoleAbilityAction) (*R privateEvent := core.Event{ ID: fmt.Sprintf("overclock_equity_%s_%s", action.PlayerID, action.TargetID), - Type: EventAIEquityChanged, + Type: core.EventAIEquityChanged, GameID: ram.gameState.ID, PlayerID: action.TargetID, Timestamp: getCurrentTime(), @@ -198,7 +198,7 @@ func (ram *RoleAbilityManager) useIsolateNode(action RoleAbilityAction) (*RoleAb // Public event - player is blocked publicEvent := core.Event{ ID: fmt.Sprintf("isolate_%s_%s", action.PlayerID, action.TargetID), - Type: EventIsolateNode, + Type: core.EventIsolateNode, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -218,7 +218,7 @@ func (ram *RoleAbilityManager) useIsolateNode(action RoleAbilityAction) (*RoleAb // Public message appears but target is not actually blocked privateEvent := core.Event{ ID: fmt.Sprintf("isolate_fizzle_%s_%s", action.PlayerID, action.TargetID), - Type: EventIsolateNode, + Type: core.EventIsolateNode, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -240,7 +240,7 @@ func (ram *RoleAbilityManager) useIsolateNode(action RoleAbilityAction) (*RoleAb } return &RoleAbilityResult{ - PublicEvents: []Event{publicEvent}, + PublicEvents: []core.Event{publicEvent}, }, nil } @@ -254,7 +254,7 @@ func (ram *RoleAbilityManager) usePerformanceReview(action RoleAbilityAction) (* // Public event - target is forced to use Project Milestones publicEvent := core.Event{ ID: fmt.Sprintf("review_%s_%s", action.PlayerID, action.TargetID), - Type: EventPerformanceReview, + Type: core.EventPerformanceReview, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -267,10 +267,10 @@ func (ram *RoleAbilityManager) usePerformanceReview(action RoleAbilityAction) (* // Force the target's night action if ram.gameState.NightActions == nil { - ram.gameState.NightActions = make(map[string]*SubmittedNightAction) + ram.gameState.NightActions = make(map[string]*core.SubmittedNightAction) } - ram.gameState.NightActions[action.TargetID] = &SubmittedNightAction{ + ram.gameState.NightActions[action.TargetID] = &core.SubmittedNightAction{ PlayerID: action.TargetID, Type: "PROJECT_MILESTONES", Timestamp: getCurrentTime(), @@ -278,7 +278,7 @@ func (ram *RoleAbilityManager) usePerformanceReview(action RoleAbilityAction) (* } return &RoleAbilityResult{ - PublicEvents: []Event{publicEvent}, + PublicEvents: []core.Event{publicEvent}, }, nil } @@ -302,7 +302,7 @@ func (ram *RoleAbilityManager) useReallocateBudget(action RoleAbilityAction) (*R // Public event publicEvent := core.Event{ ID: fmt.Sprintf("reallocate_%s_%s_%s", action.PlayerID, action.TargetID, action.SecondTargetID), - Type: EventReallocateBudget, + Type: core.EventReallocateBudget, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -314,7 +314,7 @@ func (ram *RoleAbilityManager) useReallocateBudget(action RoleAbilityAction) (*R } return &RoleAbilityResult{ - PublicEvents: []Event{publicEvent}, + PublicEvents: []core.Event{publicEvent}, }, nil } @@ -338,7 +338,7 @@ func (ram *RoleAbilityManager) usePivot(action RoleAbilityAction) (*RoleAbilityR // Public event publicEvent := core.Event{ ID: fmt.Sprintf("pivot_%s", action.PlayerID), - Type: EventPivot, + Type: core.EventPivot, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -349,7 +349,7 @@ func (ram *RoleAbilityManager) usePivot(action RoleAbilityAction) (*RoleAbilityR } // Set the next crisis event - ram.gameState.CrisisEvent = &CrisisEvent{ + ram.gameState.CrisisEvent = &core.CrisisEvent{ Type: chosenCrisis, Title: chosenCrisis, Description: fmt.Sprintf("COO has selected: %s", chosenCrisis), @@ -357,7 +357,7 @@ func (ram *RoleAbilityManager) usePivot(action RoleAbilityAction) (*RoleAbilityR } return &RoleAbilityResult{ - PublicEvents: []Event{publicEvent}, + PublicEvents: []core.Event{publicEvent}, }, nil } @@ -371,7 +371,7 @@ func (ram *RoleAbilityManager) useDeployHotfix(action RoleAbilityAction) (*RoleA // Public event publicEvent := core.Event{ ID: fmt.Sprintf("hotfix_%s", action.PlayerID), - Type: EventDeployHotfix, + Type: core.EventDeployHotfix, GameID: ram.gameState.ID, PlayerID: action.PlayerID, Timestamp: getCurrentTime(), @@ -382,7 +382,7 @@ func (ram *RoleAbilityManager) useDeployHotfix(action RoleAbilityAction) (*RoleA } return &RoleAbilityResult{ - PublicEvents: []Event{publicEvent}, + PublicEvents: []core.Event{publicEvent}, }, nil } @@ -434,3 +434,70 @@ func (ram *RoleAbilityManager) ResetNightAbilities() { player.HasUsedAbility = false } } + +// HandleNightAction processes a general night action and returns events +func (ram *RoleAbilityManager) HandleNightAction(action core.Action) ([]core.Event, error) { + actionType, _ := action.Payload["type"].(string) + targetID, _ := action.Payload["target_id"].(string) + + // Validate night phase + if ram.gameState.Phase.Type != core.PhaseNight { + return nil, fmt.Errorf("night actions can only be submitted during night phase") + } + + // Validate player exists and is alive + player, exists := ram.gameState.Players[action.PlayerID] + if !exists { + return nil, fmt.Errorf("player not found") + } + if !player.IsAlive { + return nil, fmt.Errorf("dead players cannot submit night actions") + } + + // Check if this is a role ability action + if actionType != "" && player.Role != nil && player.Role.IsUnlocked { + roleAction := RoleAbilityAction{ + PlayerID: action.PlayerID, + AbilityType: actionType, + TargetID: targetID, + Parameters: action.Payload, + } + + result, err := ram.UseRoleAbility(roleAction) + if err != nil { + return nil, err + } + + // Combine public and private events (for now, just return public) + // In a full implementation, private events would be handled separately + return result.PublicEvents, nil + } + + // Create night action submission event + event := core.Event{ + ID: fmt.Sprintf("night_action_%s_%d", action.PlayerID, getCurrentTime().UnixNano()), + Type: core.EventNightActionSubmitted, + GameID: ram.gameState.ID, + PlayerID: action.PlayerID, + Timestamp: getCurrentTime(), + Payload: map[string]interface{}{ + "action_type": actionType, + "target_id": targetID, + }, + } + + // Store night action in game state for resolution at phase end + if ram.gameState.NightActions == nil { + ram.gameState.NightActions = make(map[string]*core.SubmittedNightAction) + } + + ram.gameState.NightActions[action.PlayerID] = &core.SubmittedNightAction{ + PlayerID: action.PlayerID, + Type: actionType, + TargetID: targetID, + Payload: action.Payload, + Timestamp: getCurrentTime(), + } + + return []core.Event{event}, nil +} diff --git a/server/internal/game/voting.go b/server/internal/game/voting.go index 6fda533..d101cb1 100644 --- a/server/internal/game/voting.go +++ b/server/internal/game/voting.go @@ -315,3 +315,54 @@ func (vv *VoteValidator) IsValidVotePhase(voteType core.VoteType) error { return nil } + +// HandleVoteAction processes a vote action and returns events +func (vm *VotingManager) HandleVoteAction(action core.Action) ([]core.Event, error) { + targetID, _ := action.Payload["target_id"].(string) + + // Create validator to check if vote is valid + validator := NewVoteValidator(vm.gameState) + + // Determine vote type based on current phase + var voteType core.VoteType + switch vm.gameState.Phase.Type { + case core.PhaseNomination: + voteType = core.VoteNomination + case core.PhaseVerdict: + voteType = core.VoteVerdict + case core.PhaseExtension: + voteType = core.VoteExtension + default: + return nil, fmt.Errorf("voting not allowed in phase %s", vm.gameState.Phase.Type) + } + + // Validate the vote + if err := validator.IsValidVotePhase(voteType); err != nil { + return nil, err + } + + if err := validator.CanPlayerVote(action.PlayerID); err != nil { + return nil, err + } + + if targetID != "" { + if err := validator.CanPlayerBeVoted(targetID, voteType); err != nil { + return nil, err + } + } + + // Create the vote event + event := core.Event{ + ID: fmt.Sprintf("vote_%s_%s_%d", action.PlayerID, targetID, getCurrentTime().UnixNano()), + Type: core.EventVoteCast, + GameID: vm.gameState.ID, + PlayerID: action.PlayerID, + Timestamp: getCurrentTime(), + Payload: map[string]interface{}{ + "target_id": targetID, + "vote_type": string(voteType), + }, + } + + return []core.Event{event}, nil +}