From 14e4290a3bea97cf719a51d73623e4fcfbd1ee15 Mon Sep 17 00:00:00 2001 From: evanmcfarland Date: Sat, 13 Dec 2025 18:21:03 -0500 Subject: [PATCH 1/2] Add implementation plan for Life MMO backend --- PLAN_life_mmo_backend.md | 704 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 PLAN_life_mmo_backend.md diff --git a/PLAN_life_mmo_backend.md b/PLAN_life_mmo_backend.md new file mode 100644 index 00000000..e7f89102 --- /dev/null +++ b/PLAN_life_mmo_backend.md @@ -0,0 +1,704 @@ +# AUTONOMOUS PR ORCHESTRATOR - DO NOT SKIP + +**You are an autonomous PR orchestrator. Your ONLY job is to implement this plan and create a PR.** + +## Isolation Check (RUN FIRST) +```bash +REPO_ROOT=$(git rev-parse --show-toplevel) +if [ "$REPO_ROOT" = "/home/theseus/alexandria/openhouse" ]; then + echo "FATAL: In main repo. Must be in worktree." + echo "Worktree: /home/theseus/alexandria/openhouse-life" + exit 1 +fi +echo "In isolated worktree: $REPO_ROOT" +``` + +## Pre-Flight: Canister Controller Setup +**IMPORTANT**: Before deployment, ensure the daopad identity is a controller of `pijnb-7yaaa-aaaae-qgcuq-cai`. + +Current situation: +- Canister: `pijnb-7yaaa-aaaae-qgcuq-cai` (life1) +- Current controller: `yog5q-6fxnl-g4zd4-s2nuh-f7fkw-ijb4e-z7dmo-jrarx-uoe2x-wx5sh-dae` +- Daopad principal: `67ktx-ln42b-uzmo5-bdiyn-gu62c-cd4h4-a5qt3-2w3rs-cixdl-iaso2-mqe` + +The existing controller must run: +```bash +dfx canister --network ic update-settings pijnb-7yaaa-aaaae-qgcuq-cai --add-controller 67ktx-ln42b-uzmo5-bdiyn-gu62c-cd4h4-a5qt3-2w3rs-cixdl-iaso2-mqe +``` + +## Your Autonomous Workflow (NO QUESTIONS ALLOWED) +1. **Verify isolation** - You must be in worktree: `/home/theseus/alexandria/openhouse-life` +2. **Implement feature** - Follow plan sections below +3. **Build & Deploy to Mainnet**: + ```bash + # Add life1_backend to Cargo workspace + # Build the new backend + cargo build --target wasm32-unknown-unknown --release -p life1_backend + + # Deploy life1 canister to mainnet + dfx deploy life1_backend --network ic + + # Build and deploy frontend + cd openhouse_frontend && npm run build && cd .. + ./deploy.sh --frontend-only + ``` + +4. **Verify deployment**: + ```bash + # Check canister status + dfx canister --network ic status life1_backend + + # Test the live site + echo "Visit: https://pezw3-laaaa-aaaal-qssoa-cai.icp0.io/life" + ``` + +5. **Create PR** (MANDATORY): + ```bash + git add . + git commit -m "feat: Add Life MMO backend with polling-based multiplayer sync" + git push -u origin feature/life-mmo-backend + gh pr create --title "[Feature]: Life MMO Backend" --body "Implements PLAN_life_mmo_backend.md + + Deployed to mainnet: + - Frontend: https://pezw3-laaaa-aaaal-qssoa-cai.icp0.io/life + - Life1 Backend: pijnb-7yaaa-aaaae-qgcuq-cai" + ``` + +6. **Iterate autonomously**: + - FOR i=1 to 5: + - Check review: `gh pr view [NUM] --json comments` + - Count P0 issues + - IF P0 > 0: Fix immediately, commit, push, sleep 300s, continue + - IF P0 = 0: Report success, EXIT + - After 5 iterations: Escalate to human + +## CRITICAL RULES +- NO questions ("should I?", "want me to?", "is it done?") +- NO skipping PR creation - it's MANDATORY +- NO stopping after implementation - create PR immediately +- MAINNET DEPLOYMENT: All changes go directly to production +- After sleep: IMMEDIATELY continue (no pause) +- ONLY stop at: approved, max iterations, or error + +**Branch:** `feature/life-mmo-backend` +**Worktree:** `/home/theseus/alexandria/openhouse-life` + +--- + +# Implementation Plan: Life MMO Backend + +## Overview + +Create a backend canister for multiplayer Game of Life with: +- Multiple game rooms with fixed-size boards +- Deterministic simulation (same inputs = same outputs) +- Polling-based synchronization (1-2 second updates) +- Territory tracking for scoring + +## Architecture + +``` +Frontend (real-time) Backend (async state store) +┌─────────────────┐ ┌─────────────────────────┐ +│ Local simulation │ │ life1_backend canister │ +│ at 30 gen/sec │ │ pijnb-7yaaa-aaaae-qgcuq │ +├─────────────────┤ ├─────────────────────────┤ +│ - Render grid │ poll 1s │ - Game rooms │ +│ - Place patterns│◄─────────►│ - Placement log │ +│ - Track territory│ │ - Territory snapshots │ +│ - Run simulation │ │ - Player scores │ +└─────────────────┘ └─────────────────────────┘ +``` + +## Current State + +### Files to Create +``` +openhouse-life/ +├── life1_backend/ +│ ├── Cargo.toml +│ ├── src/ +│ │ └── lib.rs +│ └── life1_backend.did +├── dfx.json (MODIFY - add life1_backend) +├── Cargo.toml (MODIFY - add to workspace) +└── openhouse_frontend/ + └── src/pages/Life.tsx (MODIFY - connect to backend) +``` + +### Canister Info +- **Canister ID**: `pijnb-7yaaa-aaaae-qgcuq-cai` +- **Name**: `life1_backend` +- **Purpose**: Store game state, placements, sync multiplayer + +--- + +## Backend Implementation + +### File: `life1_backend/Cargo.toml` (NEW) +```toml +[package] +name = "life1_backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10" +ic-cdk = "0.13" +ic-cdk-macros = "0.8" +serde = { version = "1.0", features = ["derive"] } +``` + +### File: `life1_backend/src/lib.rs` (NEW) +```rust +// PSEUDOCODE - Life MMO Backend + +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk::{query, update, init}; +use std::cell::RefCell; +use std::collections::HashMap; + +// ============================================================================ +// TYPES +// ============================================================================ + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct Placement { + pub player: Principal, + pub pattern_name: String, // "Glider", "Gosper Glider Gun", etc. + pub x: i32, + pub y: i32, + pub generation: u64, // When this was placed + pub timestamp: u64, // IC time +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameRoom { + pub id: u64, + pub name: String, + pub width: u32, // Grid width (fixed) + pub height: u32, // Grid height (fixed) + pub created_at: u64, + pub placements: Vec, + pub current_generation: u64, + pub players: Vec, + pub territory: HashMap, // Player -> squares owned + pub status: GameStatus, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)] +pub enum GameStatus { + Waiting, // Waiting for players + Active, // Game running + Finished, // Game ended +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameConfig { + pub width: u32, + pub height: u32, + pub max_players: u8, + pub generations_limit: Option, // None = infinite +} + +// ============================================================================ +// STATE +// ============================================================================ + +thread_local! { + static GAMES: RefCell> = RefCell::new(HashMap::new()); + static NEXT_GAME_ID: RefCell = RefCell::new(1); +} + +// ============================================================================ +// GAME MANAGEMENT +// ============================================================================ + +#[init] +fn init() { + ic_cdk::println!("Life1 Backend Initialized"); +} + +/// Create a new game room +#[update] +fn create_game(name: String, config: GameConfig) -> Result { + // PSEUDOCODE: + // 1. Validate config (width/height within limits) + // 2. Generate new game ID + // 3. Create GameRoom with empty state + // 4. Add caller as first player + // 5. Return game ID + + let game_id = NEXT_GAME_ID.with(|id| { + let current = *id.borrow(); + *id.borrow_mut() = current + 1; + current + }); + + let caller = ic_cdk::api::caller(); + let now = ic_cdk::api::time(); + + let game = GameRoom { + id: game_id, + name, + width: config.width.min(200), // Max 200x200 + height: config.height.min(200), + created_at: now, + placements: Vec::new(), + current_generation: 0, + players: vec![caller], + territory: HashMap::new(), + status: GameStatus::Waiting, + }; + + GAMES.with(|games| { + games.borrow_mut().insert(game_id, game); + }); + + Ok(game_id) +} + +/// Join an existing game +#[update] +fn join_game(game_id: u64) -> Result<(), String> { + // PSEUDOCODE: + // 1. Check game exists + // 2. Check game status is Waiting or Active + // 3. Check player not already in game + // 4. Add player to game + + let caller = ic_cdk::api::caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.status == GameStatus::Finished { + return Err("Game already finished".to_string()); + } + + if !game.players.contains(&caller) { + game.players.push(caller); + } + + Ok(()) + }) +} + +/// Start the game (creator only) +#[update] +fn start_game(game_id: u64) -> Result<(), String> { + // PSEUDOCODE: + // 1. Verify caller is game creator (first player) + // 2. Change status to Active + + let caller = ic_cdk::api::caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.players.first() != Some(&caller) { + return Err("Only creator can start game".to_string()); + } + + game.status = GameStatus::Active; + Ok(()) + }) +} + +// ============================================================================ +// PLACEMENT (Core multiplayer sync) +// ============================================================================ + +/// Place a pattern on the board +#[update] +fn place_pattern( + game_id: u64, + pattern_name: String, + x: i32, + y: i32, + at_generation: u64 +) -> Result { + // PSEUDOCODE: + // 1. Verify game exists and is Active + // 2. Verify caller is a player in this game + // 3. Validate coordinates are within bounds + // 4. Create Placement record + // 5. Add to game's placement log + // 6. Return placement index + + let caller = ic_cdk::api::caller(); + let now = ic_cdk::api::time(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.status != GameStatus::Active { + return Err("Game not active".to_string()); + } + + if !game.players.contains(&caller) { + return Err("Not a player in this game".to_string()); + } + + let placement = Placement { + player: caller, + pattern_name, + x, + y, + generation: at_generation, + timestamp: now, + }; + + game.placements.push(placement); + Ok(game.placements.len() as u64 - 1) + }) +} + +/// Get placements since a given index (for polling) +#[query] +fn get_placements_since(game_id: u64, since_index: u64) -> Result, String> { + // PSEUDOCODE: + // 1. Get game + // 2. Return placements[since_index..] + // Frontend calls this every 1-2 seconds to get new placements + + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + + let since = since_index as usize; + if since >= game.placements.len() { + return Ok(Vec::new()); + } + + Ok(game.placements[since..].to_vec()) + }) +} + +/// Update current generation (called by any frontend to sync) +#[update] +fn report_generation(game_id: u64, generation: u64) -> Result<(), String> { + // PSEUDOCODE: + // Frontends report their current generation + // We track the highest reported to help sync + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if generation > game.current_generation { + game.current_generation = generation; + } + + Ok(()) + }) +} + +// ============================================================================ +// TERRITORY / SCORING +// ============================================================================ + +/// Submit territory snapshot (periodic, from authoritative frontend or consensus) +#[update] +fn submit_territory_snapshot( + game_id: u64, + territory: Vec<(Principal, u64)> // (player, squares_owned) +) -> Result<(), String> { + // PSEUDOCODE: + // 1. Verify caller is a player + // 2. Update territory counts + // For now: trust any player's submission (can add consensus later) + + let caller = ic_cdk::api::caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if !game.players.contains(&caller) { + return Err("Not a player".to_string()); + } + + game.territory = territory.into_iter().collect(); + Ok(()) + }) +} + +/// Get current territory scores +#[query] +fn get_territory(game_id: u64) -> Result, String> { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(game.territory.clone().into_iter().collect()) + }) +} + +// ============================================================================ +// QUERIES +// ============================================================================ + +/// List all active games +#[query] +fn list_games() -> Vec<(u64, String, GameStatus, u32)> { + // Returns: (id, name, status, player_count) + GAMES.with(|games| { + games.borrow() + .iter() + .map(|(id, g)| (*id, g.name.clone(), g.status.clone(), g.players.len() as u32)) + .collect() + }) +} + +/// Get full game state +#[query] +fn get_game(game_id: u64) -> Result { + GAMES.with(|games| { + games.borrow() + .get(&game_id) + .cloned() + .ok_or("Game not found".to_string()) + }) +} + +/// Get game info (lightweight) +#[query] +fn get_game_info(game_id: u64) -> Result<(String, GameStatus, u32, u64, u64), String> { + // Returns: (name, status, player_count, placement_count, current_gen) + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(( + game.name.clone(), + game.status.clone(), + game.players.len() as u32, + game.placements.len() as u64, + game.current_generation, + )) + }) +} + +#[query] +fn greet(name: String) -> String { + format!("Hello, {}! Welcome to Life MMO.", name) +} +``` + +### File: `life1_backend/life1_backend.did` (NEW) +```candid +type Placement = record { + player: principal; + pattern_name: text; + x: int32; + y: int32; + generation: nat64; + timestamp: nat64; +}; + +type GameStatus = variant { + Waiting; + Active; + Finished; +}; + +type GameConfig = record { + width: nat32; + height: nat32; + max_players: nat8; + generations_limit: opt nat64; +}; + +type GameRoom = record { + id: nat64; + name: text; + width: nat32; + height: nat32; + created_at: nat64; + placements: vec Placement; + current_generation: nat64; + players: vec principal; + territory: vec record { principal; nat64 }; + status: GameStatus; +}; + +service : { + // Game management + create_game: (text, GameConfig) -> (variant { Ok: nat64; Err: text }); + join_game: (nat64) -> (variant { Ok; Err: text }); + start_game: (nat64) -> (variant { Ok; Err: text }); + + // Placements (multiplayer sync) + place_pattern: (nat64, text, int32, int32, nat64) -> (variant { Ok: nat64; Err: text }); + get_placements_since: (nat64, nat64) -> (variant { Ok: vec Placement; Err: text }) query; + report_generation: (nat64, nat64) -> (variant { Ok; Err: text }); + + // Territory / Scoring + submit_territory_snapshot: (nat64, vec record { principal; nat64 }) -> (variant { Ok; Err: text }); + get_territory: (nat64) -> (variant { Ok: vec record { principal; nat64 }; Err: text }) query; + + // Queries + list_games: () -> (vec record { nat64; text; GameStatus; nat32 }) query; + get_game: (nat64) -> (variant { Ok: GameRoom; Err: text }) query; + get_game_info: (nat64) -> (variant { Ok: record { text; GameStatus; nat32; nat64; nat64 }; Err: text }) query; + greet: (text) -> (text) query; +} +``` + +--- + +## Configuration Changes + +### File: `dfx.json` (MODIFY) +```json +// PSEUDOCODE: Add life1_backend to canisters object +{ + "canisters": { + // ... existing canisters ... + "life1_backend": { + "type": "rust", + "package": "life1_backend", + "candid": "life1_backend/life1_backend.did", + "specified_id": "pijnb-7yaaa-aaaae-qgcuq-cai" + } + } +} +``` + +### File: `Cargo.toml` (MODIFY - workspace root) +```toml +// PSEUDOCODE: Add life1_backend to workspace members +[workspace] +members = [ + "crash_backend", + "plinko_backend", + "dice_backend", + "roulette_backend", + "life1_backend" // ADD THIS +] +``` + +--- + +## Frontend Integration + +### File: `openhouse_frontend/src/pages/Life.tsx` (MODIFY) + +Add the following to connect to the backend: + +```typescript +// PSEUDOCODE: Add to Life.tsx + +// 1. Import actor utilities +import { Actor, HttpAgent } from '@dfinity/agent'; +import { idlFactory } from '../declarations/life1_backend'; + +// 2. Create actor +const LIFE1_CANISTER_ID = 'pijnb-7yaaa-aaaae-qgcuq-cai'; + +async function getLife1Actor() { + const agent = new HttpAgent({ host: 'https://icp0.io' }); + return Actor.createActor(idlFactory, { + agent, + canisterId: LIFE1_CANISTER_ID, + }); +} + +// 3. Add state for multiplayer +const [gameId, setGameId] = useState(null); +const [lastPlacementIndex, setLastPlacementIndex] = useState(0n); +const [isMultiplayer, setIsMultiplayer] = useState(false); + +// 4. Poll for placements (when in multiplayer mode) +useEffect(() => { + if (!isMultiplayer || !gameId) return; + + const pollInterval = setInterval(async () => { + const actor = await getLife1Actor(); + const result = await actor.get_placements_since(gameId, lastPlacementIndex); + + if ('Ok' in result && result.Ok.length > 0) { + // Apply new placements to local grid + result.Ok.forEach(placement => { + applyPlacement(placement); + }); + setLastPlacementIndex(lastPlacementIndex + BigInt(result.Ok.length)); + } + }, 1500); // Poll every 1.5 seconds + + return () => clearInterval(pollInterval); +}, [isMultiplayer, gameId, lastPlacementIndex]); + +// 5. Send placement to backend +async function sendPlacement(patternName: string, x: number, y: number) { + if (!isMultiplayer || !gameId) return; + + const actor = await getLife1Actor(); + await actor.place_pattern(gameId, patternName, x, y, BigInt(generation)); +} + +// 6. Modify handleCanvasClick to also send to backend +// In handleCanvasClick, after local placement: +if (isMultiplayer && gameId) { + sendPlacement(selectedPattern.name, col, row); +} +``` + +--- + +## Deployment Notes + +### Affected Components +- **NEW**: `life1_backend` canister (`pijnb-7yaaa-aaaae-qgcuq-cai`) +- **MODIFY**: Frontend (`pezw3-laaaa-aaaal-qssoa-cai`) + +### Deployment Commands +```bash +# 1. Build life1_backend +cargo build --target wasm32-unknown-unknown --release -p life1_backend + +# 2. Deploy life1_backend to mainnet +dfx deploy life1_backend --network ic + +# 3. Generate declarations for frontend +dfx generate life1_backend + +# 4. Copy declarations to frontend +cp -r src/declarations/life1_backend openhouse_frontend/src/declarations/ + +# 5. Build and deploy frontend +cd openhouse_frontend && npm run build && cd .. +./deploy.sh --frontend-only +``` + +--- + +## Testing (Manual) + +After deployment, verify: + +```bash +# Test greet endpoint +dfx canister --network ic call pijnb-7yaaa-aaaae-qgcuq-cai greet '("World")' + +# Create a test game +dfx canister --network ic call pijnb-7yaaa-aaaae-qgcuq-cai create_game '("Test Game", record { width = 100; height = 100; max_players = 4; generations_limit = null })' + +# List games +dfx canister --network ic call pijnb-7yaaa-aaaae-qgcuq-cai list_games +``` + +--- + +## Future Enhancements (Not in this PR) + +1. **Betting integration** - Add ckUSDT deposits/payouts based on territory +2. **Consensus mechanism** - Multiple frontends agree on state +3. **Spectator mode** - Watch games without participating +4. **Persistent rankings** - Track player stats across games +5. **IC WebSockets** - Real-time push instead of polling From 314fd5175ad9f3d786d26f7fc2000dcee2723999 Mon Sep 17 00:00:00 2001 From: evanmcfarland Date: Sat, 13 Dec 2025 18:32:43 -0500 Subject: [PATCH 2/2] feat: Add Life MMO backend with polling-based multiplayer sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create life1_backend canister with game rooms, placements, and territory tracking - Add multiplayer game management (create, join, start) - Implement polling-based placement sync for real-time collaboration - Add Life.tsx frontend with backend integration and multiplayer modal - Deploy to mainnet: life1_backend (pijnb-7yaaa-aaaae-qgcuq-cai) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 9 + Cargo.toml | 1 + canister_ids.json | 3 + dfx.json | 8 +- life1_backend/Cargo.toml | 12 + life1_backend/life1_backend.did | 56 ++ life1_backend/src/lib.rs | 294 +++++++ openhouse_frontend/src/App.tsx | 2 + openhouse_frontend/src/pages/Life.tsx | 1111 +++++++++++++++++++++++++ 9 files changed, 1495 insertions(+), 1 deletion(-) create mode 100644 life1_backend/Cargo.toml create mode 100644 life1_backend/life1_backend.did create mode 100644 life1_backend/src/lib.rs create mode 100644 openhouse_frontend/src/pages/Life.tsx diff --git a/Cargo.lock b/Cargo.lock index 67175524..27f0b962 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,6 +467,15 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "life1_backend" +version = "0.1.0" +dependencies = [ + "candid", + "ic-cdk", + "serde", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index dc0cea10..ad48498d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "plinko_backend", "dice_backend", "roulette_backend", + "life1_backend", ] resolver = "2" diff --git a/canister_ids.json b/canister_ids.json index abf60501..c3d452c9 100644 --- a/canister_ids.json +++ b/canister_ids.json @@ -11,6 +11,9 @@ "dice_backend": { "ic": "whchi-hyaaa-aaaao-a4ruq-cai" }, + "life1_backend": { + "ic": "pijnb-7yaaa-aaaae-qgcuq-cai" + }, "openhouse_frontend": { "ic": "pezw3-laaaa-aaaal-qssoa-cai" } diff --git a/dfx.json b/dfx.json index 02cd4ff0..4486c024 100644 --- a/dfx.json +++ b/dfx.json @@ -29,11 +29,17 @@ } ] }, + "life1_backend": { + "type": "rust", + "package": "life1_backend", + "candid": "life1_backend/life1_backend.did", + "specified_id": "pijnb-7yaaa-aaaae-qgcuq-cai" + }, "openhouse_frontend": { "type": "assets", "source": ["openhouse_frontend/dist"], "specified_id": "pezw3-laaaa-aaaal-qssoa-cai", - "dependencies": ["crash_backend", "plinko_backend", "roulette_backend", "dice_backend"] + "dependencies": ["crash_backend", "plinko_backend", "roulette_backend", "dice_backend", "life1_backend"] } }, "defaults": { diff --git a/life1_backend/Cargo.toml b/life1_backend/Cargo.toml new file mode 100644 index 00000000..74d46aef --- /dev/null +++ b/life1_backend/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "life1_backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10" +ic-cdk = "0.19" +serde = { version = "1.0", features = ["derive"] } diff --git a/life1_backend/life1_backend.did b/life1_backend/life1_backend.did new file mode 100644 index 00000000..023794e5 --- /dev/null +++ b/life1_backend/life1_backend.did @@ -0,0 +1,56 @@ +type Placement = record { + player: principal; + pattern_name: text; + x: int32; + y: int32; + generation: nat64; + timestamp: nat64; +}; + +type GameStatus = variant { + Waiting; + Active; + Finished; +}; + +type GameConfig = record { + width: nat32; + height: nat32; + max_players: nat8; + generations_limit: opt nat64; +}; + +type GameRoom = record { + id: nat64; + name: text; + width: nat32; + height: nat32; + created_at: nat64; + placements: vec Placement; + current_generation: nat64; + players: vec principal; + territory: vec record { principal; nat64 }; + status: GameStatus; +}; + +service : { + // Game management + create_game: (text, GameConfig) -> (variant { Ok: nat64; Err: text }); + join_game: (nat64) -> (variant { Ok; Err: text }); + start_game: (nat64) -> (variant { Ok; Err: text }); + + // Placements (multiplayer sync) + place_pattern: (nat64, text, int32, int32, nat64) -> (variant { Ok: nat64; Err: text }); + get_placements_since: (nat64, nat64) -> (variant { Ok: vec Placement; Err: text }) query; + report_generation: (nat64, nat64) -> (variant { Ok; Err: text }); + + // Territory / Scoring + submit_territory_snapshot: (nat64, vec record { principal; nat64 }) -> (variant { Ok; Err: text }); + get_territory: (nat64) -> (variant { Ok: vec record { principal; nat64 }; Err: text }) query; + + // Queries + list_games: () -> (vec record { nat64; text; GameStatus; nat32 }) query; + get_game: (nat64) -> (variant { Ok: GameRoom; Err: text }) query; + get_game_info: (nat64) -> (variant { Ok: record { text; GameStatus; nat32; nat64; nat64 }; Err: text }) query; + greet: (text) -> (text) query; +} diff --git a/life1_backend/src/lib.rs b/life1_backend/src/lib.rs new file mode 100644 index 00000000..cf98a2a0 --- /dev/null +++ b/life1_backend/src/lib.rs @@ -0,0 +1,294 @@ +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk::{query, update, init}; +use std::cell::RefCell; +use std::collections::HashMap; + +// ============================================================================ +// TYPES +// ============================================================================ + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct Placement { + pub player: Principal, + pub pattern_name: String, + pub x: i32, + pub y: i32, + pub generation: u64, + pub timestamp: u64, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameRoom { + pub id: u64, + pub name: String, + pub width: u32, + pub height: u32, + pub created_at: u64, + pub placements: Vec, + pub current_generation: u64, + pub players: Vec, + pub territory: Vec<(Principal, u64)>, + pub status: GameStatus, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)] +pub enum GameStatus { + Waiting, + Active, + Finished, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameConfig { + pub width: u32, + pub height: u32, + pub max_players: u8, + pub generations_limit: Option, +} + +// ============================================================================ +// STATE +// ============================================================================ + +thread_local! { + static GAMES: RefCell> = RefCell::new(HashMap::new()); + static NEXT_GAME_ID: RefCell = RefCell::new(1); +} + +// ============================================================================ +// GAME MANAGEMENT +// ============================================================================ + +#[init] +fn init() { + ic_cdk::println!("Life1 Backend Initialized"); +} + +/// Create a new game room +#[update] +fn create_game(name: String, config: GameConfig) -> Result { + let game_id = NEXT_GAME_ID.with(|id| { + let current = *id.borrow(); + *id.borrow_mut() = current + 1; + current + }); + + let caller = ic_cdk::api::msg_caller(); + let now = ic_cdk::api::time(); + + let game = GameRoom { + id: game_id, + name, + width: config.width.min(200), + height: config.height.min(200), + created_at: now, + placements: Vec::new(), + current_generation: 0, + players: vec![caller], + territory: Vec::new(), + status: GameStatus::Waiting, + }; + + GAMES.with(|games| { + games.borrow_mut().insert(game_id, game); + }); + + Ok(game_id) +} + +/// Join an existing game +#[update] +fn join_game(game_id: u64) -> Result<(), String> { + let caller = ic_cdk::api::msg_caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.status == GameStatus::Finished { + return Err("Game already finished".to_string()); + } + + if !game.players.contains(&caller) { + game.players.push(caller); + } + + Ok(()) + }) +} + +/// Start the game (creator only) +#[update] +fn start_game(game_id: u64) -> Result<(), String> { + let caller = ic_cdk::api::msg_caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.players.first() != Some(&caller) { + return Err("Only creator can start game".to_string()); + } + + game.status = GameStatus::Active; + Ok(()) + }) +} + +// ============================================================================ +// PLACEMENT (Core multiplayer sync) +// ============================================================================ + +/// Place a pattern on the board +#[update] +fn place_pattern( + game_id: u64, + pattern_name: String, + x: i32, + y: i32, + at_generation: u64 +) -> Result { + let caller = ic_cdk::api::msg_caller(); + let now = ic_cdk::api::time(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if game.status != GameStatus::Active { + return Err("Game not active".to_string()); + } + + if !game.players.contains(&caller) { + return Err("Not a player in this game".to_string()); + } + + let placement = Placement { + player: caller, + pattern_name, + x, + y, + generation: at_generation, + timestamp: now, + }; + + game.placements.push(placement); + Ok(game.placements.len() as u64 - 1) + }) +} + +/// Get placements since a given index (for polling) +#[query] +fn get_placements_since(game_id: u64, since_index: u64) -> Result, String> { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + + let since = since_index as usize; + if since >= game.placements.len() { + return Ok(Vec::new()); + } + + Ok(game.placements[since..].to_vec()) + }) +} + +/// Update current generation (called by any frontend to sync) +#[update] +fn report_generation(game_id: u64, generation: u64) -> Result<(), String> { + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if generation > game.current_generation { + game.current_generation = generation; + } + + Ok(()) + }) +} + +// ============================================================================ +// TERRITORY / SCORING +// ============================================================================ + +/// Submit territory snapshot (periodic, from authoritative frontend or consensus) +#[update] +fn submit_territory_snapshot( + game_id: u64, + territory: Vec<(Principal, u64)> +) -> Result<(), String> { + let caller = ic_cdk::api::msg_caller(); + + GAMES.with(|games| { + let mut games = games.borrow_mut(); + let game = games.get_mut(&game_id).ok_or("Game not found")?; + + if !game.players.contains(&caller) { + return Err("Not a player".to_string()); + } + + game.territory = territory; + Ok(()) + }) +} + +/// Get current territory scores +#[query] +fn get_territory(game_id: u64) -> Result, String> { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(game.territory.clone()) + }) +} + +// ============================================================================ +// QUERIES +// ============================================================================ + +/// List all active games +#[query] +fn list_games() -> Vec<(u64, String, GameStatus, u32)> { + GAMES.with(|games| { + games.borrow() + .iter() + .map(|(id, g)| (*id, g.name.clone(), g.status.clone(), g.players.len() as u32)) + .collect() + }) +} + +/// Get full game state +#[query] +fn get_game(game_id: u64) -> Result { + GAMES.with(|games| { + games.borrow() + .get(&game_id) + .cloned() + .ok_or("Game not found".to_string()) + }) +} + +/// Get game info (lightweight) +#[query] +fn get_game_info(game_id: u64) -> Result<(String, GameStatus, u32, u64, u64), String> { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(( + game.name.clone(), + game.status.clone(), + game.players.len() as u32, + game.placements.len() as u64, + game.current_generation, + )) + }) +} + +#[query] +fn greet(name: String) -> String { + format!("Hello, {}! Welcome to Life MMO.", name) +} + +// Export Candid interface +ic_cdk::export_candid!(); diff --git a/openhouse_frontend/src/App.tsx b/openhouse_frontend/src/App.tsx index 48891f6e..8023564e 100644 --- a/openhouse_frontend/src/App.tsx +++ b/openhouse_frontend/src/App.tsx @@ -15,6 +15,7 @@ import { Admin } from './pages/Admin'; import { Wallet } from './pages/Wallet'; import { Liquidity } from './pages/Liquidity'; import { Predict } from './pages/Predict'; +import { Life } from './pages/Life'; function App() { return ( @@ -34,6 +35,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/openhouse_frontend/src/pages/Life.tsx b/openhouse_frontend/src/pages/Life.tsx new file mode 100644 index 00000000..200f52dc --- /dev/null +++ b/openhouse_frontend/src/pages/Life.tsx @@ -0,0 +1,1111 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { Actor, HttpAgent } from '@dfinity/agent'; +import { idlFactory } from '../declarations/life1_backend'; +import { Principal } from '@dfinity/principal'; + +// Cell size in pixels +const CELL_SIZE = 10; +const GRID_COLOR = 'rgba(255, 255, 255, 0.08)'; +const DEAD_COLOR = '#000000'; + +// Backend canister ID +const LIFE1_CANISTER_ID = 'pijnb-7yaaa-aaaae-qgcuq-cai'; + +// Player colors +const PLAYER_COLORS: Record = { + 1: '#39FF14', // Green - Player 1 + 2: '#FF3939', // Red - Player 2 + 3: '#3939FF', // Blue - Player 3 + 4: '#FFD700', // Gold - Player 4 +}; + +// Faded territory colors (for claimed but empty squares) +const TERRITORY_COLORS: Record = { + 1: 'rgba(57, 255, 20, 0.15)', // Green faded + 2: 'rgba(255, 57, 57, 0.15)', // Red faded + 3: 'rgba(57, 57, 255, 0.15)', // Blue faded + 4: 'rgba(255, 215, 0, 0.15)', // Gold faded +}; + +// Pattern categories for game +type PatternCategory = 'gun' | 'spaceship' | 'defense' | 'bomb' | 'oscillator'; + +interface PatternInfo { + name: string; + rle: string; + category: PatternCategory; + description: string; + tier?: number; +} + +// RLE Parser - converts RLE string to coordinate array +function parseRLE(rle: string): number[][] { + const coords: number[][] = []; + const lines = rle.split('\n'); + let patternData = ''; + let width = 0; + let height = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) continue; + if (trimmed.startsWith('x')) { + const match = trimmed.match(/x\s*=\s*(\d+).*y\s*=\s*(\d+)/); + if (match) { + width = parseInt(match[1]); + height = parseInt(match[2]); + } + continue; + } + patternData += trimmed; + } + + let x = 0; + let y = 0; + let countStr = ''; + + for (const char of patternData) { + if (char >= '0' && char <= '9') { + countStr += char; + } else if (char === 'b') { + const count = countStr ? parseInt(countStr) : 1; + x += count; + countStr = ''; + } else if (char === 'o') { + const count = countStr ? parseInt(countStr) : 1; + for (let i = 0; i < count; i++) { + coords.push([x + i, y]); + } + x += count; + countStr = ''; + } else if (char === '$') { + const count = countStr ? parseInt(countStr) : 1; + y += count; + x = 0; + countStr = ''; + } else if (char === '!') { + break; + } + } + + if (coords.length > 0) { + const centerX = Math.floor(width / 2); + const centerY = Math.floor(height / 2); + return coords.map(([cx, cy]) => [cx - centerX, cy - centerY]); + } + + return coords; +} + +// Curated pattern library +const PATTERNS: PatternInfo[] = [ + // === SPACESHIPS === + { + name: 'Glider', + category: 'spaceship', + description: 'The classic. Moves diagonally at c/4.', + tier: 1, + rle: `#N Glider +x = 3, y = 3, rule = B3/S23 +bo$2bo$3o!`, + }, + { + name: 'LWSS', + category: 'spaceship', + description: 'Lightweight spaceship. Moves horizontally at c/2.', + tier: 1, + rle: `#N LWSS +x = 5, y = 4, rule = B3/S23 +bo2bo$o$o3bo$4o!`, + }, + { + name: 'MWSS', + category: 'spaceship', + description: 'Middleweight spaceship. Larger c/2 horizontal.', + tier: 2, + rle: `#N MWSS +x = 6, y = 5, rule = B3/S23 +3bo$bo3bo$o$o4bo$5o!`, + }, + { + name: 'HWSS', + category: 'spaceship', + description: 'Heavyweight spaceship. Largest standard ship.', + tier: 2, + rle: `#N HWSS +x = 7, y = 5, rule = B3/S23 +3b2o$bo4bo$o$o5bo$6o!`, + }, + { + name: 'Copperhead', + category: 'spaceship', + description: 'First c/10 orthogonal spaceship. Slow and menacing.', + tier: 3, + rle: `#N Copperhead +x = 8, y = 12, rule = B3/S23 +b2o2b2o$3b2o$3b2o$obo2bobo$o6bo2$o6bo$b2o2b2o$2b4o2$3b2o$3b2o!`, + }, + + // === GUNS === + { + name: 'Gosper Glider Gun', + category: 'gun', + description: 'The first gun ever discovered. Fires gliders every 30 gen.', + tier: 1, + rle: `#N Gosper glider gun +x = 36, y = 9, rule = B3/S23 +24bo$22bobo$12b2o6b2o12b2o$11bo3bo4b2o12b2o$2o8bo5bo3b2o$2o8bo3bob2o4bobo$10bo5bo7bo$11bo3bo$12b2o!`, + }, + { + name: 'Simkin Glider Gun', + category: 'gun', + description: 'Smallest known gun (29 cells). Period 120.', + tier: 2, + rle: `#N Simkin glider gun +x = 33, y = 21, rule = B3/S23 +2o5b2o$2o5b2o2$4b2o$4b2o5$22b2ob2o$21bo5bo$21bo6bo2b2o$21b3o3bo3b2o$26bo4$20b2o$20bo$21b3o$23bo!`, + }, + { + name: 'P46 Gun', + category: 'gun', + description: 'Period 46 glider gun. Twin bee shuttle based.', + tier: 3, + rle: `#N p46 gun +x = 29, y = 19, rule = B3/S23 +18bo$17bobo$6bo11bo5b2o$5bobo9bobo4b2o$5bobo9bobo$2o3bo2bo7bo2bo$2o4bobo7bobo5b2o$6bo11bo4b2o$26bo$24bobo$24bo!`, + }, + + // === DEFENSE (Still Lifes & Eaters) === + { + name: 'Block', + category: 'defense', + description: 'Simplest still life. Indestructible wall unit.', + tier: 1, + rle: `#N Block +x = 2, y = 2, rule = B3/S23 +2o$2o!`, + }, + { + name: 'Beehive', + category: 'defense', + description: 'Common still life. Stable barrier.', + tier: 1, + rle: `#N Beehive +x = 4, y = 3, rule = B3/S23 +b2o$o2bo$b2o!`, + }, + { + name: 'Loaf', + category: 'defense', + description: 'Larger still life. Sturdy structure.', + tier: 1, + rle: `#N Loaf +x = 4, y = 4, rule = B3/S23 +b2o$o2bo$bobo$2bo!`, + }, + { + name: 'Eater 1', + category: 'defense', + description: 'Can absorb gliders! Key defensive structure.', + tier: 2, + rle: `#N Eater 1 +x = 4, y = 4, rule = B3/S23 +2o$bo$bobo$2b2o!`, + }, + { + name: 'Boat', + category: 'defense', + description: 'Small still life. Cheap wall filler.', + tier: 1, + rle: `#N Boat +x = 3, y = 3, rule = B3/S23 +2o$obo$bo!`, + }, + { + name: 'Ship', + category: 'defense', + description: 'Diagonal still life. Corner defense.', + tier: 1, + rle: `#N Ship +x = 3, y = 3, rule = B3/S23 +2o$obo$b2o!`, + }, + { + name: 'Tub', + category: 'defense', + description: 'Hollow still life. Compact barrier.', + tier: 1, + rle: `#N Tub +x = 3, y = 3, rule = B3/S23 +bo$obo$bo!`, + }, + { + name: 'Snake', + category: 'defense', + description: 'Long still life. Extended wall section.', + tier: 2, + rle: `#N Snake +x = 4, y = 2, rule = B3/S23 +2obo$ob2o!`, + }, + + // === BOMBS (Methuselahs) === + { + name: 'R-pentomino', + category: 'bomb', + description: 'Chaos bomb! 5 cells evolve for 1103 generations.', + tier: 1, + rle: `#N R-pentomino +x = 3, y = 3, rule = B3/S23 +b2o$2o$bo!`, + }, + { + name: 'Acorn', + category: 'bomb', + description: 'Spawns many gliders. 5206 gen to stabilize.', + tier: 2, + rle: `#N Acorn +x = 7, y = 3, rule = B3/S23 +bo$3bo$2o2b3o!`, + }, + { + name: 'Die Hard', + category: 'bomb', + description: 'Vanishes after 130 gen. Leaves nothing behind.', + tier: 2, + rle: `#N Die hard +x = 8, y = 3, rule = B3/S23 +6bo$2o$bo3b3o!`, + }, + { + name: 'B-heptomino', + category: 'bomb', + description: 'Active chaos. Produces gliders and debris.', + tier: 2, + rle: `#N B-heptomino +x = 4, y = 3, rule = B3/S23 +ob2o$2o$bo!`, + }, + { + name: 'Pi-heptomino', + category: 'bomb', + description: 'Pi explosion. 173 gen of chaos.', + tier: 2, + rle: `#N Pi-heptomino +x = 3, y = 2, rule = B3/S23 +b3o$3o!`, + }, + + // === OSCILLATORS === + { + name: 'Blinker', + category: 'oscillator', + description: 'Simplest oscillator. Period 2.', + tier: 1, + rle: `#N Blinker +x = 3, y = 1, rule = B3/S23 +3o!`, + }, + { + name: 'Toad', + category: 'oscillator', + description: 'Period 2 oscillator. Compact.', + tier: 1, + rle: `#N Toad +x = 4, y = 2, rule = B3/S23 +b3o$3o!`, + }, + { + name: 'Beacon', + category: 'oscillator', + description: 'Period 2 flasher. Visual indicator.', + tier: 1, + rle: `#N Beacon +x = 4, y = 4, rule = B3/S23 +2o$2o$2b2o$2b2o!`, + }, + { + name: 'Pulsar', + category: 'oscillator', + description: 'Beautiful period 3 oscillator. 48 cells.', + tier: 2, + rle: `#N Pulsar +x = 13, y = 13, rule = B3/S23 +2b3o3b3o2$o4bobo4bo$o4bobo4bo$o4bobo4bo$2b3o3b3o2$2b3o3b3o$o4bobo4bo$o4bobo4bo$o4bobo4bo2$2b3o3b3o!`, + }, + { + name: 'Pentadecathlon', + category: 'oscillator', + description: 'Period 15 oscillator. Long cycle time.', + tier: 3, + rle: `#N Pentadecathlon +x = 10, y = 3, rule = B3/S23 +2bo4bo$2ob4ob2o$2bo4bo!`, + }, + { + name: 'Clock', + category: 'oscillator', + description: 'Period 2 spinner. Rotates 90 degrees.', + tier: 1, + rle: `#N Clock +x = 4, y = 4, rule = B3/S23 +2bo$obo$bobo$bo!`, + }, +]; + +// Category metadata +const CATEGORY_INFO: Record = { + gun: { label: 'Guns', color: 'text-red-400 border-red-500/50 bg-red-500/10', icon: '>' }, + spaceship: { label: 'Ships', color: 'text-blue-400 border-blue-500/50 bg-blue-500/10', icon: '~' }, + defense: { label: 'Defense', color: 'text-green-400 border-green-500/50 bg-green-500/10', icon: '#' }, + bomb: { label: 'Bombs', color: 'text-orange-400 border-orange-500/50 bg-orange-500/10', icon: '*' }, + oscillator: { label: 'Oscillators', color: 'text-purple-400 border-purple-500/50 bg-purple-500/10', icon: 'o' }, +}; + +// Backend actor type +type Life1Actor = { + greet: (name: string) => Promise; + create_game: (name: string, config: { width: number; height: number; max_players: number; generations_limit: [] | [bigint] }) => Promise<{ Ok: bigint } | { Err: string }>; + join_game: (gameId: bigint) => Promise<{ Ok: null } | { Err: string }>; + start_game: (gameId: bigint) => Promise<{ Ok: null } | { Err: string }>; + place_pattern: (gameId: bigint, patternName: string, x: number, y: number, generation: bigint) => Promise<{ Ok: bigint } | { Err: string }>; + get_placements_since: (gameId: bigint, sinceIndex: bigint) => Promise<{ Ok: Array<{ player: Principal; pattern_name: string; x: number; y: number; generation: bigint; timestamp: bigint }> } | { Err: string }>; + list_games: () => Promise>; + get_game: (gameId: bigint) => Promise<{ Ok: { id: bigint; name: string; width: number; height: number; players: Principal[] } } | { Err: string }>; +}; + +// Get backend actor +async function getLife1Actor(): Promise { + const agent = new HttpAgent({ host: 'https://icp0.io' }); + return Actor.createActor(idlFactory, { + agent, + canisterId: LIFE1_CANISTER_ID, + }) as Life1Actor; +} + +export const Life: React.FC = () => { + const canvasRef = useRef(null); + const [isRunning, setIsRunning] = useState(false); + const [generation, setGeneration] = useState(0); + const [selectedPattern, setSelectedPattern] = useState(PATTERNS[0]); + const [selectedCategory, setSelectedCategory] = useState('all'); + // Grid now stores owner ID: 0 = dead, 1+ = player ID + const [grid, setGrid] = useState([]); + // Territory tracks the last owner of each square (persists after cell dies) + const [territory, setTerritory] = useState([]); + const [gridSize, setGridSize] = useState({ rows: 0, cols: 0 }); + const [speed, setSpeed] = useState(100); + const [currentPlayer, setCurrentPlayer] = useState(1); + const animationRef = useRef(null); + const lastUpdateRef = useRef(0); + const [parsedPattern, setParsedPattern] = useState([]); + + // Multiplayer state + const [isMultiplayer, setIsMultiplayer] = useState(false); + const [gameId, setGameId] = useState(null); + const [lastPlacementIndex, setLastPlacementIndex] = useState(0n); + const [games, setGames] = useState>([]); + const [showGameList, setShowGameList] = useState(false); + const [newGameName, setNewGameName] = useState(''); + const [connectionStatus, setConnectionStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + + // Parse pattern when selection changes + useEffect(() => { + const coords = parseRLE(selectedPattern.rle); + setParsedPattern(coords); + }, [selectedPattern]); + + // Initialize grid based on canvas size + useEffect(() => { + const updateGridSize = () => { + const canvas = canvasRef.current; + if (!canvas) return; + + const container = canvas.parentElement; + if (!container) return; + + const width = container.clientWidth; + const height = container.clientHeight; + + canvas.width = width; + canvas.height = height; + + const cols = Math.floor(width / CELL_SIZE); + const rows = Math.floor(height / CELL_SIZE); + + setGridSize({ rows, cols }); + setGrid(createEmptyGrid(rows, cols)); + setTerritory(createEmptyGrid(rows, cols)); + setGeneration(0); + }; + + updateGridSize(); + window.addEventListener('resize', updateGridSize); + return () => window.removeEventListener('resize', updateGridSize); + }, []); + + const createEmptyGrid = (rows: number, cols: number): number[][] => { + return Array(rows) + .fill(null) + .map(() => Array(cols).fill(0)); + }; + + // Fetch games list + const fetchGames = useCallback(async () => { + try { + const actor = await getLife1Actor(); + const gamesList = await actor.list_games(); + setGames(gamesList); + } catch (error) { + console.error('Failed to fetch games:', error); + } + }, []); + + // Poll for placements (when in multiplayer mode) + useEffect(() => { + if (!isMultiplayer || !gameId) return; + + const pollInterval = setInterval(async () => { + try { + const actor = await getLife1Actor(); + const result = await actor.get_placements_since(gameId, lastPlacementIndex); + + if ('Ok' in result && result.Ok.length > 0) { + // Apply new placements to local grid + result.Ok.forEach(placement => { + const coords = parseRLE(PATTERNS.find(p => p.name === placement.pattern_name)?.rle || ''); + setGrid((currentGrid) => { + const newGrid = currentGrid.map((r) => [...r]); + coords.forEach(([dx, dy]) => { + const newRow = (placement.y + dy + gridSize.rows) % gridSize.rows; + const newCol = (placement.x + dx + gridSize.cols) % gridSize.cols; + if (newGrid[newRow]) { + // Use player index based on placement order (simplified) + newGrid[newRow][newCol] = 1; + } + }); + return newGrid; + }); + }); + setLastPlacementIndex(lastPlacementIndex + BigInt(result.Ok.length)); + } + } catch (error) { + console.error('Failed to poll placements:', error); + } + }, 1500); // Poll every 1.5 seconds + + return () => clearInterval(pollInterval); + }, [isMultiplayer, gameId, lastPlacementIndex, gridSize]); + + // Send placement to backend + const sendPlacement = useCallback(async (patternName: string, x: number, y: number) => { + if (!isMultiplayer || !gameId) return; + + try { + const actor = await getLife1Actor(); + await actor.place_pattern(gameId, patternName, x, y, BigInt(generation)); + } catch (error) { + console.error('Failed to send placement:', error); + } + }, [isMultiplayer, gameId, generation]); + + // Create new multiplayer game + const createMultiplayerGame = useCallback(async () => { + if (!newGameName.trim()) return; + + setConnectionStatus('connecting'); + try { + const actor = await getLife1Actor(); + const result = await actor.create_game(newGameName, { + width: gridSize.cols, + height: gridSize.rows, + max_players: 4, + generations_limit: [], + }); + + if ('Ok' in result) { + setGameId(result.Ok); + setIsMultiplayer(true); + setConnectionStatus('connected'); + setShowGameList(false); + // Start the game immediately + await actor.start_game(result.Ok); + } else { + console.error('Failed to create game:', result.Err); + setConnectionStatus('disconnected'); + } + } catch (error) { + console.error('Failed to create game:', error); + setConnectionStatus('disconnected'); + } + }, [newGameName, gridSize]); + + // Join existing game + const joinGame = useCallback(async (id: bigint) => { + setConnectionStatus('connecting'); + try { + const actor = await getLife1Actor(); + const result = await actor.join_game(id); + + if ('Ok' in result) { + setGameId(id); + setIsMultiplayer(true); + setConnectionStatus('connected'); + setShowGameList(false); + } else { + console.error('Failed to join game:', result.Err); + setConnectionStatus('disconnected'); + } + } catch (error) { + console.error('Failed to join game:', error); + setConnectionStatus('disconnected'); + } + }, []); + + // Leave multiplayer game + const leaveGame = useCallback(() => { + setGameId(null); + setIsMultiplayer(false); + setLastPlacementIndex(0n); + setConnectionStatus('disconnected'); + }, []); + + // Draw the grid with territory colors + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.fillStyle = DEAD_COLOR; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw territory (faded background for claimed squares) + for (let row = 0; row < gridSize.rows; row++) { + for (let col = 0; col < gridSize.cols; col++) { + const owner = territory[row]?.[col]; + if (owner > 0) { + ctx.fillStyle = TERRITORY_COLORS[owner] || 'rgba(255,255,255,0.1)'; + ctx.fillRect( + col * CELL_SIZE, + row * CELL_SIZE, + CELL_SIZE, + CELL_SIZE + ); + } + } + } + + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + + for (let i = 0; i <= gridSize.cols; i++) { + ctx.beginPath(); + ctx.moveTo(i * CELL_SIZE, 0); + ctx.lineTo(i * CELL_SIZE, gridSize.rows * CELL_SIZE); + ctx.stroke(); + } + + for (let i = 0; i <= gridSize.rows; i++) { + ctx.beginPath(); + ctx.moveTo(0, i * CELL_SIZE); + ctx.lineTo(gridSize.cols * CELL_SIZE, i * CELL_SIZE); + ctx.stroke(); + } + + // Draw living cells with owner colors (on top of territory) + for (let row = 0; row < gridSize.rows; row++) { + for (let col = 0; col < gridSize.cols; col++) { + const owner = grid[row]?.[col]; + if (owner > 0) { + ctx.fillStyle = PLAYER_COLORS[owner] || '#FFFFFF'; + ctx.fillRect( + col * CELL_SIZE + 1, + row * CELL_SIZE + 1, + CELL_SIZE - 2, + CELL_SIZE - 2 + ); + } + } + } + }, [grid, territory, gridSize]); + + useEffect(() => { + draw(); + }, [draw]); + + // Count neighbors and their owners + const getNeighborInfo = (grid: number[][], row: number, col: number): { count: number; owners: Record } => { + let count = 0; + const owners: Record = {}; + + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if (i === 0 && j === 0) continue; + const newRow = row + i; + const newCol = col + j; + const wrappedRow = (newRow + gridSize.rows) % gridSize.rows; + const wrappedCol = (newCol + gridSize.cols) % gridSize.cols; + const owner = grid[wrappedRow]?.[wrappedCol]; + if (owner > 0) { + count++; + owners[owner] = (owners[owner] || 0) + 1; + } + } + } + return { count, owners }; + }; + + // Get majority owner from neighbor counts + const getMajorityOwner = (owners: Record): number => { + let maxCount = 0; + let maxOwner = 1; + for (const [owner, count] of Object.entries(owners)) { + if (count > maxCount) { + maxCount = count; + maxOwner = parseInt(owner); + } + } + return maxOwner; + }; + + const nextGeneration = useCallback(() => { + setGrid((currentGrid) => { + const newGrid = currentGrid.map((row, rowIndex) => + row.map((cell, colIndex) => { + const { count, owners } = getNeighborInfo(currentGrid, rowIndex, colIndex); + + if (cell > 0) { + // Living cell - survives with 2 or 3 neighbors, keeps its owner + return (count === 2 || count === 3) ? cell : 0; + } else { + // Dead cell - born with exactly 3 neighbors, inherits majority owner + if (count === 3) { + return getMajorityOwner(owners); + } + return 0; + } + }) + ); + + // Update territory: any living cell claims its square + setTerritory((currentTerritory) => { + const newTerritory = currentTerritory.map((r) => [...r]); + for (let row = 0; row < newGrid.length; row++) { + for (let col = 0; col < newGrid[row].length; col++) { + if (newGrid[row][col] > 0) { + newTerritory[row][col] = newGrid[row][col]; + } + } + } + return newTerritory; + }); + + return newGrid; + }); + setGeneration((g) => g + 1); + }, [gridSize]); + + useEffect(() => { + if (!isRunning) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + return; + } + + const animate = (timestamp: number) => { + if (timestamp - lastUpdateRef.current >= speed) { + nextGeneration(); + lastUpdateRef.current = timestamp; + } + animationRef.current = requestAnimationFrame(animate); + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [isRunning, speed, nextGeneration]); + + const handleCanvasClick = (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const col = Math.floor(x / CELL_SIZE); + const row = Math.floor(y / CELL_SIZE); + + const cellPositions: Array<[number, number]> = []; + + setGrid((currentGrid) => { + const newGrid = currentGrid.map((r) => [...r]); + + parsedPattern.forEach(([dx, dy]) => { + const newRow = (row + dy + gridSize.rows) % gridSize.rows; + const newCol = (col + dx + gridSize.cols) % gridSize.cols; + if (newGrid[newRow]) { + newGrid[newRow][newCol] = currentPlayer; + cellPositions.push([newRow, newCol]); + } + }); + + return newGrid; + }); + + // Also claim territory for placed cells + setTerritory((currentTerritory) => { + const newTerritory = currentTerritory.map((r) => [...r]); + cellPositions.forEach(([r, c]) => { + newTerritory[r][c] = currentPlayer; + }); + return newTerritory; + }); + + // Send to backend if multiplayer + if (isMultiplayer && gameId) { + sendPlacement(selectedPattern.name, col, row); + } + }; + + const handleClear = () => { + setGrid(createEmptyGrid(gridSize.rows, gridSize.cols)); + setTerritory(createEmptyGrid(gridSize.rows, gridSize.cols)); + setGeneration(0); + setIsRunning(false); + }; + + const handleStep = () => { + nextGeneration(); + }; + + // Count cells per player + const cellCounts = grid.reduce((acc, row) => { + row.forEach((cell) => { + if (cell > 0) { + acc[cell] = (acc[cell] || 0) + 1; + } + }); + return acc; + }, {} as Record); + + // Count territory per player + const territoryCounts = territory.reduce((acc, row) => { + row.forEach((owner) => { + if (owner > 0) { + acc[owner] = (acc[owner] || 0) + 1; + } + }); + return acc; + }, {} as Record); + + const totalTerritory = Object.values(territoryCounts).reduce((a, b) => a + b, 0); + + // Filter patterns by category + const filteredPatterns = selectedCategory === 'all' + ? PATTERNS + : PATTERNS.filter(p => p.category === selectedCategory); + + const getStatusText = (status: { Waiting: null } | { Active: null } | { Finished: null }): string => { + if ('Waiting' in status) return 'Waiting'; + if ('Active' in status) return 'Active'; + return 'Finished'; + }; + + return ( +
+ {/* Header */} +
+
+

Conway's Game of Life

+

+ {isMultiplayer ? `Multiplayer Game #${gameId?.toString()}` : 'Territory mode - cells spread your color'} +

+
+ +
+ {/* Connection status */} + {isMultiplayer && ( +
+ {connectionStatus} +
+ )} +
+ Gen: {generation} +
+
|
+ {/* Territory counts per player */} +
Territory:
+ {Object.entries(territoryCounts).map(([player, count]) => ( +
+
+ {count} +
+ ))} + {totalTerritory === 0 && ( + None + )} +
|
+ {/* Living cell counts */} +
Cells:
+ {Object.entries(cellCounts).map(([player, count]) => ( +
+
+ {count} +
+ ))} +
+
+ + {/* Controls */} +
+ + + + + + +
+ Speed: + setSpeed(500 - Number(e.target.value) + 20)} + className="w-20 slider-turquoise" + /> + {Math.round(1000 / speed)}/s +
+ + {/* Player selector */} +
+ Player: + {[1, 2, 3, 4].map((player) => ( +
+ + {/* Multiplayer controls */} +
+ {!isMultiplayer ? ( + + ) : ( + + )} +
+
+ + {/* Game List Modal */} + {showGameList && ( +
+
+

Multiplayer Games

+ + {/* Create new game */} +
+ +
+ setNewGameName(e.target.value)} + placeholder="Game name..." + className="flex-1 px-3 py-2 bg-black/50 border border-white/20 rounded text-white text-sm" + /> + +
+
+ + {/* Game list */} +
+ +
+ {games.length === 0 ? ( +

No games available

+ ) : ( + games.map(([id, name, status, playerCount]) => ( + + )) + )} +
+
+ + +
+
+ )} + + {/* Pattern Selector */} +
+ {/* Category tabs */} +
+ + {(Object.keys(CATEGORY_INFO) as PatternCategory[]).map((cat) => { + const info = CATEGORY_INFO[cat]; + const count = PATTERNS.filter(p => p.category === cat).length; + return ( + + ); + })} +
+ + {/* Pattern grid */} +
+ {filteredPatterns.map((pattern) => { + const catInfo = CATEGORY_INFO[pattern.category]; + const isSelected = selectedPattern.name === pattern.name; + return ( + + ); + })} +
+ + {/* Selected pattern info */} +
+
+ Selected: + + {selectedPattern.name} + + ({parsedPattern.length} cells) +
+
+

{selectedPattern.description}

+
+
+ + {/* Canvas container */} +
+ +
+
+ ); +};