diff --git a/PLAN_LIFE_ECONOMICS.md b/PLAN_LIFE_ECONOMICS.md new file mode 100644 index 00000000..fec9468f --- /dev/null +++ b/PLAN_LIFE_ECONOMICS.md @@ -0,0 +1,664 @@ +# 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-economics" + exit 1 +fi +echo "In isolated worktree: $REPO_ROOT" +``` + +## Your Autonomous Workflow (NO QUESTIONS ALLOWED) +1. **Verify isolation** - You must be in worktree: `/home/theseus/alexandria/openhouse-life-economics` +2. **Implement feature** - Follow plan sections below +3. **Build & Deploy to Mainnet**: + ```bash + cargo build --target wasm32-unknown-unknown --release + cd openhouse_frontend && npm run build && cd .. + ./deploy.sh + ``` +4. **Verify deployment**: + ```bash + dfx canister --network ic status life1_backend + echo "Visit: https://pezw3-laaaa-aaaal-qssoa-cai.icp0.io" + ``` +5. **Create PR** (MANDATORY): + ```bash + git add . + git commit -m "feat(life): add points-based economics system" + git push -u origin feature/life-economics + gh pr create --title "Life Economics: Points-Based Territory System" --body "$(cat <<'EOF' +## Summary +- Players start with 1,000 points +- Placing cells costs 1 point per cell (distributed across your territory) +- Capturing enemy territory with points transfers those points to your balance +- Cannot place on alive cells (placement fails if any overlap) +- Cannot harvest your own points (anti-whale mechanic) +- Gold borders on cells indicate point value + +See `live_economics.md` for full design. + +## Test Plan +- [ ] Create game, verify starting balance is 1000 +- [ ] Place cells, verify balance decreases and points appear in territory +- [ ] Run simulation, verify point capture on territory takeover +- [ ] Verify placement fails on occupied cells +- [ ] Verify gold border rendering on cells with points + +Deployed to mainnet: +- Frontend: https://pezw3-laaaa-aaaal-qssoa-cai.icp0.io +- Life Backend: life1_backend + +Generated with [Claude Code](https://claude.ai/claude-code) +EOF +)" + ``` +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-economics` +**Worktree:** `/home/theseus/alexandria/openhouse-life-economics` + +--- + +# Implementation Plan: Life Economics System + +## Overview + +Add a points-based economics system to the Game of Life where: +- Players start with 1,000 points +- Placing cells costs 1 point per cell +- Points are stored IN territory cells +- Capturing enemy territory captures their points +- Gold borders indicate cells with points + +## Current State + +### Backend: `life1_backend/src/lib.rs` + +**Existing structures:** +```rust +// Line 11-25: GameRoom has grid and territory as Vec> +pub struct GameRoom { + pub grid: Vec>, // owner ID per cell (0 = dead, 1-4 = player) + pub territory: Vec>, // ownership tracking + // ... +} + +// Line 43-50: GameState for frontend polling +pub struct GameState { + pub grid: Vec>, + pub territory: Vec>, + // ... +} +``` + +**Key functions:** +- `place_cells` (line 251): Places cells without cost/validation +- `step_generation` (line 117): Runs Conway's rules, updates territory ownership +- `get_state` (line 363): Returns grid/territory to frontend + +### Frontend: `openhouse_frontend/src/pages/Life.tsx` + +**Existing rendering:** +- Line 377-453: `draw()` function renders grid, territory, cells +- Line 480-504: `handleCanvasClick()` places patterns +- Line 540-548: Cell counts and territory counts displayed + +--- + +## Implementation + +### Backend Changes: `life1_backend/src/lib.rs` + +#### 1. Add Points Tracking Types + +```rust +// PSEUDOCODE - Add after existing types (around line 60) + +/// Player balance and stats +#[derive(CandidType, Deserialize, Clone, Debug, Default)] +pub struct PlayerStats { + pub principal: Principal, + pub balance: u64, // Spendable points + pub total_earned: u64, // Lifetime points captured + pub total_spent: u64, // Lifetime points placed +} + +/// Extended game state with points +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameStateWithPoints { + pub grid: Vec>, + pub territory: Vec>, + pub points: Vec>, // Points stored in each cell + pub generation: u64, + pub players: Vec, + pub balances: Vec, // Balance per player (index matches players) + pub is_running: bool, +} +``` + +#### 2. Modify GameRoom Structure + +```rust +// PSEUDOCODE - Modify GameRoom (around line 11) + +pub struct GameRoom { + // ... existing fields ... + pub points: Vec>, // Points per cell (NEW) + pub player_balances: Vec, // Balance per player (NEW) +} +``` + +#### 3. Modify create_game + +```rust +// PSEUDOCODE - In create_game function (around line 165) + +fn create_game(name: String, config: GameConfig) -> Result { + // ... existing setup ... + + let game = GameRoom { + // ... existing fields ... + points: create_empty_points_grid(width, height), // NEW: all zeros + player_balances: vec![1000], // NEW: creator starts with 1000 points + }; + // ... +} + +fn create_empty_points_grid(width: u32, height: u32) -> Vec> { + vec![vec![0u16; width as usize]; height as usize] +} +``` + +#### 4. Modify join_game + +```rust +// PSEUDOCODE - In join_game function (around line 200) + +fn join_game(game_id: u64) -> Result { + // ... existing logic ... + + if !game.players.contains(&caller) { + // ... existing check for full game ... + game.players.push(caller); + game.player_balances.push(1000); // NEW: new player gets 1000 points + } + // ... +} +``` + +#### 5. Modify place_cells with Economics + +```rust +// PSEUDOCODE - Replace place_cells function (around line 251) + +#[update] +fn place_cells(game_id: u64, cells: Vec<(i32, i32)>) -> Result { + 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::Active { + return Err("Game not active".to_string()); + } + + // Find player index + let player_idx = game.players + .iter() + .position(|p| *p == caller) + .ok_or("Not a player in this game")?; + let player_num = (player_idx + 1) as u8; + + let cost = cells.len() as u64; + + // Check balance + if game.player_balances[player_idx] < cost { + return Err(format!("Insufficient points. Need {}, have {}", + cost, game.player_balances[player_idx])); + } + + let width = game.width as i32; + let height = game.height as i32; + + // Pre-validate: check for overlaps with alive cells + for (x, y) in &cells { + let col = ((*x % width) + width) % width; + let row = ((*y % height) + height) % height; + if game.grid[row as usize][col as usize] > 0 { + return Err("Cannot place on alive cells".to_string()); + } + } + + // Deduct cost from balance + game.player_balances[player_idx] -= cost; + + // Find all cells owned by this player (for point distribution) + let mut my_cells: Vec<(usize, usize)> = Vec::new(); + for row in 0..game.height as usize { + for col in 0..game.width as usize { + if game.grid[row][col] == player_num { + my_cells.push((row, col)); + } + } + } + + // Place new cells + let mut placed_cells: Vec<(usize, usize)> = Vec::new(); + for (x, y) in cells { + let col = ((x % width) + width) % width; + let row = ((y % height) + height) % height; + game.grid[row as usize][col as usize] = player_num; + game.territory[row as usize][col as usize] = player_num; + placed_cells.push((row as usize, col as usize)); + } + + // Distribute points across territory (including newly placed cells) + my_cells.extend(placed_cells.iter().cloned()); + + if !my_cells.is_empty() { + // Randomly distribute points across owned cells + let points_per_cell = cost / my_cells.len() as u64; + let remainder = cost % my_cells.len() as u64; + + for (i, (row, col)) in my_cells.iter().enumerate() { + let extra = if (i as u64) < remainder { 1 } else { 0 }; + game.points[*row][*col] += (points_per_cell + extra) as u16; + } + } + + Ok(placed_cells.len() as u32) + }) +} +``` + +#### 6. Modify step_generation for Point Capture + +```rust +// PSEUDOCODE - Modify step_generation (around line 117) + +fn step_generation(game: &mut GameRoom) { + let height = game.height as usize; + let width = game.width as usize; + let mut new_grid = create_empty_grid(game.width, game.height); + + // Track points to transfer (from_player_idx, to_player_idx, amount) + let mut point_transfers: Vec<(usize, usize, u16)> = Vec::new(); + + for row in 0..height { + for col in 0..width { + let (count, owner_counts) = get_neighbor_info(&game.grid, row, col, height, width); + let current = game.grid[row][col]; + + if current > 0 { + // Living cell survives with 2 or 3 neighbors + if count == 2 || count == 3 { + new_grid[row][col] = current; + } else { + // Cell dies - points stay in cell (territory doesn't change on death) + } + } else { + // Dead cell born with exactly 3 neighbors + if count == 3 { + let new_owner = get_majority_owner(&owner_counts); + new_grid[row][col] = new_owner; + + // Check if this cell had points from another player + let old_territory_owner = game.territory[row][col]; + let cell_points = game.points[row][col]; + + if cell_points > 0 && old_territory_owner > 0 && old_territory_owner != new_owner { + // Capture! Transfer points to new owner + let from_idx = (old_territory_owner - 1) as usize; + let to_idx = (new_owner - 1) as usize; + point_transfers.push((from_idx, to_idx, cell_points)); + // Clear points from cell (they go to balance) + game.points[row][col] = 0; + } + } + } + } + } + + // Apply point transfers to balances + for (from_idx, to_idx, amount) in point_transfers { + if to_idx < game.player_balances.len() { + game.player_balances[to_idx] += amount as u64; + } + // Note: from_idx player loses nothing from balance - points were in the cell + } + + // Update territory: any living cell claims its square + for row in 0..height { + for col in 0..width { + if new_grid[row][col] > 0 { + game.territory[row][col] = new_grid[row][col]; + } + } + } + + game.grid = new_grid; + game.generation += 1; +} +``` + +#### 7. Add New Query Methods + +```rust +// PSEUDOCODE - Add after get_state (around line 375) + +/// Get game state including points (main polling endpoint) +#[query] +fn get_state_with_points(game_id: u64) -> Result { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(GameStateWithPoints { + grid: game.grid.clone(), + territory: game.territory.clone(), + points: game.points.clone(), + generation: game.generation, + players: game.players.clone(), + balances: game.player_balances.clone(), + is_running: game.is_running, + }) + }) +} + +/// Get player balance +#[query] +fn get_balance(game_id: u64) -> Result { + let caller = ic_cdk::api::msg_caller(); + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + let player_idx = game.players + .iter() + .position(|p| *p == caller) + .ok_or("Not a player")?; + Ok(game.player_balances[player_idx]) + }) +} +``` + +#### 8. Update Candid Interface + +```candid +// life1_backend.did - Add new types and methods + +type GameStateWithPoints = record { + grid: vec vec nat8; + territory: vec vec nat8; + points: vec vec nat16; + generation: nat64; + players: vec principal; + balances: vec nat64; + is_running: bool; +}; + +service : { + // ... existing methods ... + + // NEW methods + get_state_with_points: (nat64) -> (variant { Ok: GameStateWithPoints; Err: text }) query; + get_balance: (nat64) -> (variant { Ok: nat64; Err: text }) query; +} +``` + +--- + +### Frontend Changes: `openhouse_frontend/src/pages/Life.tsx` + +#### 1. Update Type Imports + +```typescript +// PSEUDOCODE - Update imports (around line 6) +import type { + _SERVICE, + GameState, + GameStateWithPoints, // NEW + GameInfo, + GameStatus +} from '../declarations/life1_backend/life1_backend.did.d'; +``` + +#### 2. Add Gold Border Color Constant + +```typescript +// PSEUDOCODE - Add after TERRITORY_COLORS (around line 32) +const GOLD_BORDER_COLOR = '#FFD700'; +const GOLD_BORDER_MIN_OPACITY = 0.3; +const GOLD_BORDER_MAX_OPACITY = 1.0; +``` + +#### 3. Update State to Use Points + +```typescript +// PSEUDOCODE - Update game state (around line 160) +const [gameState, setGameState] = useState(null); +const [myBalance, setMyBalance] = useState(1000); +``` + +#### 4. Update Polling to Use get_state_with_points + +```typescript +// PSEUDOCODE - Update tick function (around line 334) +const tick = async () => { + if (cancelled) return; + + try { + if (isRunning) { + const result = await actor.step(currentGameId, 5); + if ('Ok' in result && !cancelled) { + // Fetch full state with points after stepping + const stateResult = await actor.get_state_with_points(currentGameId); + if ('Ok' in stateResult) { + setGameState(stateResult.Ok); + // Update my balance + const myIdx = stateResult.Ok.players.findIndex( + p => p.toText() === myPrincipal?.toText() + ); + if (myIdx >= 0) { + setMyBalance(Number(stateResult.Ok.balances[myIdx])); + } + } + } + } else { + const result = await actor.get_state_with_points(currentGameId); + if ('Ok' in result && !cancelled) { + setGameState(result.Ok); + // Update balance and running state + const myIdx = result.Ok.players.findIndex( + p => p.toText() === myPrincipal?.toText() + ); + if (myIdx >= 0) { + setMyBalance(Number(result.Ok.balances[myIdx])); + } + if (result.Ok.is_running !== isRunning) { + setIsRunning(result.Ok.is_running); + } + } + } + } catch (err) { + console.error('Tick error:', err); + } + // ... rest of function +}; +``` + +#### 5. Update Draw Function for Gold Borders + +```typescript +// PSEUDOCODE - Modify draw function (around line 430, after drawing cells) + +// Draw gold borders for cells with points +if (gameState.points) { + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + const points = gameState.points[row]?.[col] || 0; + if (points > 0) { + // Calculate border opacity based on points (more points = more visible) + const opacity = Math.min( + GOLD_BORDER_MAX_OPACITY, + GOLD_BORDER_MIN_OPACITY + (points / 10) * 0.1 + ); + ctx.strokeStyle = `rgba(255, 215, 0, ${opacity})`; + ctx.lineWidth = Math.min(3, 1 + Math.floor(points / 5)); + ctx.strokeRect( + col * cellSize + 1, + row * cellSize + 1, + cellSize - 2, + cellSize - 2 + ); + } + } + } +} +``` + +#### 6. Update Place Cells Handler + +```typescript +// PSEUDOCODE - Modify handleCanvasClick (around line 480) + +const handleCanvasClick = async (e: React.MouseEvent) => { + if (isPanning || !actor || currentGameId === null) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const cellSize = BASE_CELL_SIZE * zoom; + const col = Math.floor((x - panOffset.x) / cellSize); + const row = Math.floor((y - panOffset.y) / cellSize); + + if (col < 0 || col >= gridSize.cols || row < 0 || row >= gridSize.rows) return; + + const cells: [number, number][] = parsedPattern.map(([dx, dy]) => [col + dx, row + dy]); + + // Check if player has enough points + const cost = cells.length; + if (myBalance < cost) { + setError(`Not enough points. Need ${cost}, have ${myBalance}`); + return; + } + + try { + const result = await actor.place_cells(currentGameId, cells); + if ('Err' in result) { + setError(result.Err); + } else { + setMyBalance(prev => prev - cost); // Optimistic update + setError(null); + } + } catch (err) { + console.error('Place error:', err); + setError(`Failed to place: ${err}`); + } +}; +``` + +#### 7. Add Balance Display in Header + +```typescript +// PSEUDOCODE - Add to header section (around line 692) + +
+ {/* NEW: Balance display */} +
+ Points: {myBalance} +
+
|
+ + {/* Existing generation display */} +
+ Gen: {gameState?.generation.toString() || 0} +
+ // ... rest of stats +
+``` + +#### 8. Add Pattern Cost Display + +```typescript +// PSEUDOCODE - Add to pattern selector footer (around line 806) + +
+
+ Selected: + + {selectedPattern.name} + + ({parsedPattern.length} cells) + {/* NEW: Cost indicator */} + = parsedPattern.length ? 'text-green-400' : 'text-red-400' + }`}> + Cost: {parsedPattern.length} pts + +
+

{selectedPattern.description}

+
+``` + +#### 9. Add Error Display + +```typescript +// PSEUDOCODE - Add error toast near canvas (around line 819) + +{error && ( +
+ {error} + +
+)} +``` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `life1_backend/src/lib.rs` | Add points tracking, modify place_cells, step_generation, add queries | +| `life1_backend/life1_backend.did` | Add GameStateWithPoints type, new query methods | +| `openhouse_frontend/src/pages/Life.tsx` | Add balance display, gold borders, placement cost validation | + +## Deployment Notes + +- **Canister**: `life1_backend` (canister ID will be assigned on deploy) +- **Frontend**: `pezw3-laaaa-aaaal-qssoa-cai` +- Deploy with: `./deploy.sh` +- This is a NEW canister, so no upgrade concerns for existing state + +## Test Scenarios + +1. **New player join**: Verify balance starts at 1000 +2. **Place pattern**: Verify points deducted, distributed to territory +3. **Place on occupied**: Verify placement fails with error +4. **Territory capture**: Verify points transfer to capturing player's balance +5. **Insufficient balance**: Verify placement blocked with error +6. **Gold borders**: Verify visual rendering scales with point value diff --git a/life1_backend/life1_backend.did b/life1_backend/life1_backend.did index 4c9d8376..266fc9fd 100644 --- a/life1_backend/life1_backend.did +++ b/life1_backend/life1_backend.did @@ -1,8 +1,38 @@ type LifeState = record { grid: vec vec nat8; territory: vec vec nat8; + points: vec vec nat16; + player_balances: vec nat64; generation: nat64; players: vec principal; + status: GameStatus; + is_running: bool; +}; + +type GameState = record { + grid: vec vec nat8; + territory: vec vec nat8; + generation: nat64; + players: vec principal; + is_running: bool; +}; + +type GameStateWithPoints = record { + grid: vec vec nat8; + territory: vec vec nat8; + points: vec vec nat16; + generation: nat64; + players: vec principal; + balances: vec nat64; + is_running: bool; +}; + +type GameInfo = record { + id: nat64; + name: text; + status: GameStatus; + player_count: nat32; + generation: nat64; }; service : { @@ -15,6 +45,11 @@ service : { // Get current game state get_state: () -> (LifeState) query; - // Test endpoint + // Queries + get_state: (nat64) -> (variant { Ok: GameState; Err: text }) query; + get_state_with_points: (nat64) -> (variant { Ok: GameStateWithPoints; Err: text }) query; + get_balance: (nat64) -> (variant { Ok: nat64; Err: text }) query; + list_games: () -> (vec GameInfo) query; + get_game: (nat64) -> (variant { Ok: GameRoom; Err: text }) query; greet: (text) -> (text) query; } diff --git a/life1_backend/src/lib.rs b/life1_backend/src/lib.rs index 4a2bcd82..bb8440e3 100644 --- a/life1_backend/src/lib.rs +++ b/life1_backend/src/lib.rs @@ -33,6 +33,10 @@ type Memory = VirtualMemory; pub struct LifeState { pub grid: Vec>, pub territory: Vec>, + // Points stored in each cell (economics system) + pub points: Vec>, + // Player balances (index matches players array) + pub player_balances: Vec, pub generation: u64, pub players: Vec, } @@ -53,10 +57,26 @@ impl Storable for Metadata { candid::decode_one(&bytes).unwrap_or_default() } - const BOUND: Bound = Bound::Bounded { - max_size: 512, // Enough for 10 principals + generation - is_fixed_size: false, - }; +/// Extended game state with points (main polling endpoint for economics) +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameStateWithPoints { + pub grid: Vec>, + pub territory: Vec>, + pub points: Vec>, // Points stored in each cell + pub generation: u64, + pub players: Vec, + pub balances: Vec, // Balance per player (index matches players) + pub is_running: bool, +} + +/// Game info for lobby listing +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct GameInfo { + pub id: u64, + pub name: String, + pub status: GameStatus, + pub player_count: u32, + pub generation: u64, } // ============================================================================ @@ -178,6 +198,10 @@ fn set_territory_cell(row: usize, col: usize, value: u8) { }); } +fn create_empty_points_grid(width: u32, height: u32) -> Vec> { + vec![vec![0u16; width as usize]; height as usize] +} + /// Count neighbors and their owners for a cell fn get_neighbor_info(row: usize, col: usize) -> (u8, [u8; MAX_PLAYERS + 1]) { let mut count = 0u8; @@ -215,18 +239,19 @@ fn get_majority_owner(owner_counts: &[u8; MAX_PLAYERS + 1]) -> u8 { max_owner } -/// Run one generation of Conway's Game of Life -fn step_generation() { - // Read current grid into memory for processing - let mut current: Vec = Vec::with_capacity(GRID_SIZE); - GRID.with(|g| { - let g = g.borrow(); - for i in 0..GRID_SIZE as u64 { - current.push(g.get(i).unwrap_or(0)); - } - }); +/// Run one generation of Conway's Game of Life with ownership and point capture +fn step_generation(game: &mut GameRoom) { + let height = game.height as usize; + let width = game.width as usize; + let mut new_grid = create_empty_grid(game.width, game.height); + + // Track points to transfer (to_player_idx, amount) + let mut point_transfers: Vec<(usize, u16)> = Vec::new(); - let mut new_grid: Vec = vec![0u8; GRID_SIZE]; + for row in 0..height { + for col in 0..width { + let (count, owner_counts) = get_neighbor_info(&game.grid, row, col, height, width); + let current = game.grid[row][col]; for row in 0..GRID_HEIGHT { for col in 0..GRID_WIDTH { @@ -238,29 +263,41 @@ fn step_generation() { if count == 2 || count == 3 { new_grid[idx(row, col)] = current_val; } + // Cell dies - points stay in cell (territory doesn't change on death) } else { // Dead cell born with exactly 3 neighbors if count == 3 { - new_grid[idx(row, col)] = get_majority_owner(&owner_counts); + let new_owner = get_majority_owner(&owner_counts); + new_grid[row][col] = new_owner; + + // Check if this cell had points from another player + let old_territory_owner = game.territory[row][col]; + let cell_points = game.points[row][col]; + + if cell_points > 0 && old_territory_owner > 0 && old_territory_owner != new_owner { + // Capture! Transfer points to new owner's balance + let to_idx = (new_owner - 1) as usize; + point_transfers.push((to_idx, cell_points)); + // Clear points from cell (they go to balance) + game.points[row][col] = 0; + } } } } } - // Write new grid back to stable storage - GRID.with(|g| { - let g = g.borrow_mut(); - for (i, &val) in new_grid.iter().enumerate() { - let _ = g.set(i as u64, &val); + // Apply point transfers to balances + for (to_idx, amount) in point_transfers { + if to_idx < game.player_balances.len() { + game.player_balances[to_idx] += amount as u64; } - }); + } - // Update territory for living cells - for row in 0..GRID_HEIGHT { - for col in 0..GRID_WIDTH { - let owner = new_grid[idx(row, col)]; - if owner > 0 { - set_territory_cell(row, col, owner); + // Update territory: any living cell claims its square + for row in 0..height { + for col in 0..width { + if new_grid[row][col] > 0 { + game.territory[row][col] = new_grid[row][col]; } } } @@ -283,46 +320,30 @@ fn cleanup_inactive_players() { } }); - CACHED_METADATA.with(|cached| { - let mut m = cached.borrow_mut(); - let mut i = 0; - while i < m.players.len() { - if live_counts[i + 1] == 0 { - // Player has no live cells - remove them - m.players.remove(i); - // Remap colors for all cells with color > i+1 - let removed_color = (i + 1) as u8; - GRID.with(|g| { - let g = g.borrow_mut(); - for j in 0..GRID_SIZE as u64 { - if let Some(c) = g.get(j) { - if c > removed_color { - let _ = g.set(j, &(c - 1)); - } - } - } - }); - TERRITORY.with(|t| { - let t = t.borrow_mut(); - for j in 0..GRID_SIZE as u64 { - if let Some(c) = t.get(j) { - if c > removed_color { - let _ = t.set(j, &(c - 1)); - } else if c == removed_color { - // Territory of removed player stays (historical) - } - } - } - }); - // Re-check live counts after remap - for k in (removed_color as usize)..MAX_PLAYERS { - live_counts[k] = live_counts[k + 1]; - } - live_counts[MAX_PLAYERS] = 0; - } else { - i += 1; - } - } + let caller = ic_cdk::api::msg_caller(); + let now = ic_cdk::api::time(); + + let width = config.width.min(200).max(10); + let height = config.height.min(200).max(10); + + let game = GameRoom { + id: game_id, + name, + width, + height, + created_at: now, + grid: create_empty_grid(width, height), + territory: create_empty_grid(width, height), + points: create_empty_points_grid(width, height), + player_balances: vec![1000], // Creator starts with 1000 points + generation: 0, + players: vec![caller], + status: GameStatus::Waiting, + is_running: false, + }; + + GAMES.with(|games| { + games.borrow_mut().insert(game_id, game); }); } @@ -357,6 +378,10 @@ fn build_state() -> LifeState { generation: m.generation, players: m.players.clone(), } + + game.players.push(caller); + game.player_balances.push(1000); // New player gets 1000 points + Ok(game.players.len() as u8) }) } @@ -381,14 +406,86 @@ fn post_upgrade() { // UPDATE METHODS // ============================================================================ -/// Place cells on the grid +/// Place cells on the grid with economics. Costs 1 point per cell. #[update] fn place_cells(cells: Vec<(i32, i32)>) -> Result { let caller = ic_cdk::api::msg_caller(); - if caller == Principal::anonymous() { - return Err("Anonymous callers cannot place cells".to_string()); - } + 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()); + } + + // Find player index + let player_idx = game.players + .iter() + .position(|p| *p == caller) + .ok_or("Not a player in this game")?; + let player_num = (player_idx + 1) as u8; + + let cost = cells.len() as u64; + + // Check balance + if game.player_balances[player_idx] < cost { + return Err(format!("Insufficient points. Need {}, have {}", + cost, game.player_balances[player_idx])); + } + + let width = game.width as i32; + let height = game.height as i32; + + // Pre-validate: check for overlaps with alive cells + for (x, y) in &cells { + let col = ((*x % width) + width) % width; + let row = ((*y % height) + height) % height; + if game.grid[row as usize][col as usize] > 0 { + return Err("Cannot place on alive cells".to_string()); + } + } + + // Deduct cost from balance + game.player_balances[player_idx] -= cost; + + // Find all cells owned by this player (for point distribution) + let mut my_cells: Vec<(usize, usize)> = Vec::new(); + for row in 0..game.height as usize { + for col in 0..game.width as usize { + if game.grid[row][col] == player_num { + my_cells.push((row, col)); + } + } + } + + // Place new cells + let mut placed_cells: Vec<(usize, usize)> = Vec::new(); + for (x, y) in cells { + let col = ((x % width) + width) % width; + let row = ((y % height) + height) % height; + game.grid[row as usize][col as usize] = player_num; + game.territory[row as usize][col as usize] = player_num; + placed_cells.push((row as usize, col as usize)); + } + + // Distribute points across territory (including newly placed cells) + my_cells.extend(placed_cells.iter().cloned()); + + if !my_cells.is_empty() { + // Distribute points across owned cells + let points_per_cell = cost / my_cells.len() as u64; + let remainder = cost % my_cells.len() as u64; + + for (i, (row, col)) in my_cells.iter().enumerate() { + let extra = if (i as u64) < remainder { 1 } else { 0 }; + game.points[*row][*col] += (points_per_cell + extra) as u16; + } + } + + Ok(placed_cells.len() as u32) + }) +} ensure_grid_initialized(); @@ -425,7 +522,7 @@ fn place_cells(cells: Vec<(i32, i32)>) -> Result { Ok(placed) } -/// Advance the simulation by n generations +/// Clear the grid (keep game active, reset points) #[update] fn step(n: u32) -> Result { ensure_grid_initialized(); @@ -440,7 +537,17 @@ fn step(n: u32) -> Result { cleanup_inactive_players(); save_metadata(); - Ok(build_state()) + game.grid = create_empty_grid(game.width, game.height); + game.territory = create_empty_grid(game.width, game.height); + game.points = create_empty_points_grid(game.width, game.height); + // Reset all player balances to 1000 + for balance in game.player_balances.iter_mut() { + *balance = 1000; + } + game.generation = 0; + game.is_running = false; + Ok(()) + }) } // ============================================================================ @@ -449,8 +556,79 @@ fn step(n: u32) -> Result { /// Get current game state #[query] -fn get_state() -> LifeState { - build_state() +fn get_state(game_id: u64) -> Result { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(GameState { + grid: game.grid.clone(), + territory: game.territory.clone(), + generation: game.generation, + players: game.players.clone(), + is_running: game.is_running, + }) + }) +} + +/// Get game state including points (main polling endpoint for economics) +#[query] +fn get_state_with_points(game_id: u64) -> Result { + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + Ok(GameStateWithPoints { + grid: game.grid.clone(), + territory: game.territory.clone(), + points: game.points.clone(), + generation: game.generation, + players: game.players.clone(), + balances: game.player_balances.clone(), + is_running: game.is_running, + }) + }) +} + +/// Get player balance +#[query] +fn get_balance(game_id: u64) -> Result { + let caller = ic_cdk::api::msg_caller(); + GAMES.with(|games| { + let games = games.borrow(); + let game = games.get(&game_id).ok_or("Game not found")?; + let player_idx = game.players + .iter() + .position(|p| *p == caller) + .ok_or("Not a player")?; + Ok(game.player_balances[player_idx]) + }) +} + +/// List all games (for lobby) +#[query] +fn list_games() -> Vec { + GAMES.with(|games| { + games.borrow() + .iter() + .map(|(_, g)| GameInfo { + id: g.id, + name: g.name.clone(), + status: g.status.clone(), + player_count: g.players.len() as u32, + generation: g.generation, + }) + .collect() + }) +} + +/// Get full game details +#[query] +fn get_game(game_id: u64) -> Result { + GAMES.with(|games| { + games.borrow() + .get(&game_id) + .cloned() + .ok_or("Game not found".to_string()) + }) } #[query] diff --git a/openhouse_frontend/src/pages/Life.tsx b/openhouse_frontend/src/pages/Life.tsx index 8dc4ae15..eadb8ac1 100644 --- a/openhouse_frontend/src/pages/Life.tsx +++ b/openhouse_frontend/src/pages/Life.tsx @@ -3,7 +3,7 @@ import { AuthClient } from '@dfinity/auth-client'; import { Actor, HttpAgent, ActorSubclass } from '@dfinity/agent'; import { Principal } from '@dfinity/principal'; import { idlFactory } from '../declarations/life1_backend'; -import type { _SERVICE, LifeState } from '../declarations/life1_backend/life1_backend.did.d'; +import type { _SERVICE, GameState, GameStateWithPoints, GameInfo, GameStatus } from '../declarations/life1_backend/life1_backend.did.d'; const LIFE1_CANISTER_ID = 'pijnb-7yaaa-aaaae-qgcuq-cai'; @@ -46,6 +46,10 @@ const TERRITORY_COLORS: Record = { 10: 'rgba(163, 230, 53, 0.15)', }; +// Gold border for cells with points +const GOLD_BORDER_MIN_OPACITY = 0.3; +const GOLD_BORDER_MAX_OPACITY = 1.0; + // Pattern types type PatternCategory = 'gun' | 'spaceship' | 'defense' | 'bomb' | 'oscillator'; @@ -167,8 +171,14 @@ export const Life: React.FC = () => { const [error, setError] = useState(null); // Game state from backend - const [gameState, setGameState] = useState(null); - const [myPlayerNum, setMyPlayerNum] = useState(null); + const [gameState, setGameState] = useState(null); + const [myPlayerNum, setMyPlayerNum] = useState(1); + const [gridSize, setGridSize] = useState({ rows: 150, cols: 200 }); + const [myBalance, setMyBalance] = useState(1000); + const [placementError, setPlacementError] = useState(null); + + // Simulation control + const [isRunning, setIsRunning] = useState(false); const [, forceRender] = useState(0); // Parse pattern on selection change @@ -209,6 +219,97 @@ export const Life: React.FC = () => { setIsLoading(false); }; + // Fetch games for lobby + const fetchGames = useCallback(async () => { + if (!actor) return; + setIsLoading(true); + try { + const gamesList = await actor.list_games(); + setGames(gamesList); + setError(null); + } catch (err) { + setError(`Failed to fetch games: ${err}`); + } + setIsLoading(false); + }, [actor]); + + useEffect(() => { + if (isAuthenticated && actor) fetchGames(); + }, [isAuthenticated, actor, fetchGames]); + + // Create game + const handleCreateGame = async () => { + if (!actor || !newGameName.trim()) return; + const trimmedName = newGameName.trim(); + if (trimmedName.length > 50 || !/^[a-zA-Z0-9\s\-_]+$/.test(trimmedName)) { + setError('Invalid game name'); + return; + } + + setIsLoading(true); + setError(null); + try { + const result = await actor.create_game(trimmedName, { + width: 200, height: 150, max_players: 4, generations_limit: [] + }); + if ('Ok' in result) { + const gameId = result.Ok; + await actor.start_game(gameId); + setCurrentGameId(gameId); + setMyPlayerNum(1); + setGridSize({ rows: 150, cols: 200 }); + setMode('game'); + setNewGameName(''); + } else { + setError(`Failed: ${result.Err}`); + } + } catch (err) { + setError(`Failed: ${err}`); + } + setIsLoading(false); + }; + + // Join game + const handleJoinGame = async (gameId: bigint) => { + if (!actor) return; + setIsLoading(true); + setError(null); + try { + const result = await actor.join_game(gameId); + if ('Ok' in result) { + setCurrentGameId(gameId); + setMyPlayerNum(result.Ok); + // Fetch initial state with points + const stateResult = await actor.get_state_with_points(gameId); + if ('Ok' in stateResult) { + setGameState(stateResult.Ok); + setGridSize({ rows: stateResult.Ok.grid.length, cols: stateResult.Ok.grid[0]?.length || 200 }); + // Update my balance + const myIdx = stateResult.Ok.players.findIndex( + p => p.toText() === myPrincipal?.toText() + ); + if (myIdx >= 0) { + setMyBalance(Number(stateResult.Ok.balances[myIdx])); + } + } + setMode('game'); + } else { + setError(`Failed: ${result.Err}`); + } + } catch (err) { + setError(`Failed: ${err}`); + } + setIsLoading(false); + }; + + const handleLeaveGame = () => { + setMode('lobby'); + setCurrentGameId(null); + setGameState(null); + setIsRunning(false); + fetchGames(); + }; + // Canvas sizing useEffect(() => { if (!isAuthenticated) return; @@ -256,16 +357,39 @@ export const Life: React.FC = () => { if (cancelled) return; try { - // Always step - game runs forever - const result = await actor.step(5); - if ('Ok' in result && !cancelled) { - setGameState(result.Ok); - // Update my player number based on my principal in players list - if (myPrincipal) { - const idx = result.Ok.players.findIndex( - (p: Principal) => p.toText() === myPrincipal.toText() + if (isRunning) { + // Step 5 generations at a time for faster playback + const stepResult = await actor.step(currentGameId, 5); + if ('Ok' in stepResult && !cancelled) { + // Fetch full state with points after stepping + const stateResult = await actor.get_state_with_points(currentGameId); + if ('Ok' in stateResult && !cancelled) { + setGameState(stateResult.Ok); + // Update my balance + const myIdx = stateResult.Ok.players.findIndex( + p => p.toText() === myPrincipal?.toText() + ); + if (myIdx >= 0) { + setMyBalance(Number(stateResult.Ok.balances[myIdx])); + } + } + } + } else { + // Just poll for state (to see other players' placements) + const result = await actor.get_state_with_points(currentGameId); + if ('Ok' in result && !cancelled) { + setGameState(result.Ok); + // Update my balance + const myIdx = result.Ok.players.findIndex( + p => p.toText() === myPrincipal?.toText() ); - setMyPlayerNum(idx >= 0 ? idx + 1 : null); + if (myIdx >= 0) { + setMyBalance(Number(result.Ok.balances[myIdx])); + } + // Sync running state from backend (another player might have started) + if (result.Ok.is_running !== isRunning) { + setIsRunning(result.Ok.is_running); + } } } } catch (err) { @@ -284,7 +408,7 @@ export const Life: React.FC = () => { cancelled = true; clearTimeout(timeoutId); }; - }, [actor, isAuthenticated, myPrincipal]); + }, [actor, currentGameId, mode, isRunning, myPrincipal]); // Draw function const draw = useCallback(() => { @@ -357,6 +481,31 @@ export const Life: React.FC = () => { } } + // Draw gold borders for cells with points + const points = gameState.points; + if (points) { + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + const cellPoints = points[row]?.[col] || 0; + if (cellPoints > 0) { + // Calculate border opacity based on points (more points = more visible) + const opacity = Math.min( + GOLD_BORDER_MAX_OPACITY, + GOLD_BORDER_MIN_OPACITY + (cellPoints / 10) * 0.1 + ); + ctx.strokeStyle = `rgba(255, 215, 0, ${opacity})`; + ctx.lineWidth = Math.min(3, 1 + Math.floor(cellPoints / 5)); + ctx.strokeRect( + col * cellSize + 1, + row * cellSize + 1, + cellSize - 2, + cellSize - 2 + ); + } + } + } + } + // Boundary ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 2; @@ -409,8 +558,45 @@ export const Life: React.FC = () => { // Convert pattern to absolute coordinates const cells: [number, number][] = parsedPattern.map(([dx, dy]) => [col + dx, row + dy]); + // Check if player has enough points + const cost = cells.length; + if (myBalance < cost) { + setPlacementError(`Not enough points. Need ${cost}, have ${myBalance}`); + setTimeout(() => setPlacementError(null), 3000); + return; + } + + try { + const result = await actor.place_cells(currentGameId, cells); + if ('Err' in result) { + setPlacementError(result.Err); + setTimeout(() => setPlacementError(null), 3000); + } else { + setMyBalance(prev => prev - cost); // Optimistic update + setPlacementError(null); + } + } catch (err) { + console.error('Place error:', err); + setPlacementError(`Failed to place: ${err}`); + setTimeout(() => setPlacementError(null), 3000); + } + }; + + // Controls + const handlePlayPause = async () => { + if (!actor || currentGameId === null) return; try { - const result = await actor.place_cells(cells); + await actor.set_running(currentGameId, !isRunning); + setIsRunning(!isRunning); + } catch (err) { + console.error('Set running error:', err); + } + }; + + const handleStep = async () => { + if (!actor || currentGameId === null) return; + try { + const result = await actor.step(currentGameId, 1); if ('Ok' in result) { // Placement successful } else if ('Err' in result) { @@ -418,7 +604,18 @@ export const Life: React.FC = () => { setTimeout(() => setError(null), 3000); } } catch (err) { - console.error('Place error:', err); + console.error('Step error:', err); + } + }; + + const handleClear = async () => { + if (!actor || currentGameId === null) return; + try { + await actor.clear_grid(currentGameId); + setIsRunning(false); + setMyBalance(1000); // Reset balance + } catch (err) { + console.error('Clear error:', err); } }; @@ -476,6 +673,11 @@ export const Life: React.FC = () => {
+ {/* Points balance */} +
+ Points: {myBalance} +
+
|
Gen: {gameState?.generation.toString() || 0}
@@ -564,6 +766,12 @@ export const Life: React.FC = () => { {selectedPattern.name} ({parsedPattern.length} cells) + {/* Cost indicator */} + = parsedPattern.length ? 'text-green-400' : 'text-red-400' + }`}> + Cost: {parsedPattern.length} pts +

{selectedPattern.description} | Click to place

@@ -571,6 +779,14 @@ export const Life: React.FC = () => { {/* Canvas */}
+ {/* Placement error toast */} + {placementError && ( +
+ {placementError} + +
+ )} +