From 312c35b6cd69a28c9ad7522a7a6fb86133fd7260 Mon Sep 17 00:00:00 2001 From: evanmcfarland Date: Mon, 15 Dec 2025 11:04:31 -0500 Subject: [PATCH 1/2] Add implementation plan for life economics system --- PLAN_LIFE_ECONOMICS.md | 664 +++++++++++++++++++++++++++++++++++++++++ live_economics.md | 138 +++++---- 2 files changed, 748 insertions(+), 54 deletions(-) create mode 100644 PLAN_LIFE_ECONOMICS.md 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/live_economics.md b/live_economics.md index 53a9ff38..39ae4250 100644 --- a/live_economics.md +++ b/live_economics.md @@ -2,82 +2,112 @@ ## Overview -A perpetually running Game of Life where players pay to place cells and compete for territory growth rewards. The economic model ensures money in equals money out with a house edge on placements. +A perpetually running Game of Life where players spend points to place cells. Points are stored IN the territory cells themselves. When another player captures your territory, they capture your points. 100% efficient system - no house rake. -## Core Economic Rules +## Core Concepts -### Input: Cell Placement -- **Cost**: 1 cent per cell placed -- **Destination**: Goes to the pot (minus house rake) -- **No refunds**: Placed cells cannot be reclaimed +### Points (Not Real Money Yet) +- Each player starts with **1,000 points** +- Points will become cents later when real money is added +- For now, it's a free game to test mechanics -### Output: Territory Growth Rewards -- **Frequency**: Once per minute (60 seconds) -- **Metric**: Territory delta (change in squares owned by each player) -- **Winner**: Player with highest positive delta wins -- **Payout**: 1% of pot goes to winner +### Territory = Wallet +- Points don't sit in a player's "balance" - they live in territory cells +- Each cell can hold 0 or more points +- Your wealth = sum of points in cells you own -### Edge Cases +## Placement Rules -| Scenario | Resolution | -|----------|------------| -| Tie (multiple players same delta) | Split winnings equally among tied players | -| All negative growth | Least negative player wins | -| All zero growth | No payout, pot accumulates | -| Pot ≤ 10 cents | No payout until pot exceeds threshold | +### Cost +- **1 point per cell placed** +- Place 5 cells → costs 5 points + +### Point Distribution +- Points spent get **randomly distributed across your existing territory** +- If you have no territory yet, points go into the cells you just placed +- Points can stack (a cell could hold 2+ points if doubled up) + +### Placement Restrictions +- **Cannot place on alive cells** (yours or enemy's) +- If any cell in your pattern overlaps a living cell, **entire placement fails** +- Can place anywhere else (empty cells, including "dead" territory) + +## Earning Points + +### Territory Capture +- When your cells take over enemy territory containing points, **you capture those points** +- Captured points go directly to **your balance** (spendable on new placements) + +### Anti-Whale Mechanic +- **You cannot harvest your own points** +- If you own the entire board, you have no one to capture from = no income +- Forces competition, prevents runaway dominance ## Money Flow ``` -┌─────────────────┐ -│ Player Places │ -│ Cell (1¢) │ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ POT │◄──── Accumulates from placements -│ (grows over │ -│ time) │ -└────────┬────────┘ - │ Every 60 seconds - │ (if pot > 10¢) - ▼ -┌─────────────────┐ -│ 1% of Pot to │ -│ Growth Winner │ -└─────────────────┘ +┌──────────────────────┐ +│ Player Places Cells │ +│ (5 cells = 5 pts) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Points distributed │ +│ across player's │ +│ existing territory │ +│ (stored in cells) │ +└──────────┬───────────┘ + │ + │ Enemy captures territory + ▼ +┌──────────────────────┐ +│ Points transfer to │ +│ capturing player │ +└──────────────────────┘ ``` ## Accounting Invariant ``` -pot_balance = Σ(placement_fees) - Σ(payouts) - Σ(house_rake) -``` +total_points_in_system = Σ(player_starting_points) = constant -The pot can never go negative. Worst case: pot drains to ≤10¢ and payouts pause until new placements occur. +Where points exist: +- In territory cells (as bounties) +- In player balances (unspent points) -## Why This Model Works +No points created or destroyed. 100% efficient. +``` -1. **Only pay when placing** - No background fees or upkeep -2. **Pot is self-regulating** - Low pot = low rewards = less competition = pot rebuilds -3. **Growth, not dominance, wins** - Stable empires earn nothing; must actively expand -4. **No runaway winner** - Expanding costs money (placements), and growth is hard to sustain in chaotic GoL -5. **Always solvent** - Payouts are % of pot, can't exceed pot +## Visual Feedback (Frontend) -## Territory Tracking +- Cells with points show **gold borders** +- Border thickness varies based on point value (thicker = more points) +- Players can visually identify high-value targets -- Each cell has an owner (player who placed it, or inherited from parent cells) -- Track `cells_per_player` at each minute boundary -- Delta = `cells_now[player] - cells_60sec_ago[player]` +## Cell Ownership Inheritance -### Cell Ownership Inheritance When a new cell is born (exactly 3 neighbors): - New cell's owner = majority owner among the 3 parent cells +- **New cells have 0 points** (only placed cells get points) - Ties: Random selection or oldest placement wins -## Decisions +## Why This Model Works + +1. **Only pay when placing** - No background fees or upkeep +2. **Anti-whale** - Can't harvest own points, dominance doesn't equal income +3. **Zero-sum** - Total points constant, your gain = someone's loss +4. **Territorial incentive** - Points live in territory, must expand to capture +5. **Visual clarity** - Gold borders show where the money is + +## Implementation Notes + +### Data Structure +```rust +// Per cell: owner (u8) + points (u16 or u32) +// 1000x1000 grid = 1M cells +// ~3-5 bytes per cell = 3-5 MB total +``` -- **House rake**: % of each placement (simplest for auditability) -- **Withdrawals**: Winnings go to player's chip balance, withdrawable anytime -- **Fine-tuning**: House edge %, spam prevention, etc. to be determined during implementation +### Files to Modify +- `life1_backend/src/lib.rs` - Add points tracking per cell, placement cost, capture logic From fdbe00104aa84e3a01a31b3ad9d4f0da5a3d571f Mon Sep 17 00:00:00 2001 From: evanmcfarland Date: Mon, 15 Dec 2025 11:19:48 -0500 Subject: [PATCH 2/2] feat(life): add points-based economics system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Players start with 1,000 points - Placing cells costs 1 point per cell (distributed across territory) - Capturing enemy territory with points transfers those points to your balance - Cannot place on alive cells (placement fails if any overlap) - Gold borders on cells indicate point value - Balance and cost display in UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- life1_backend/life1_backend.did | 14 +++ life1_backend/src/lib.rs | 152 ++++++++++++++++++++++++-- openhouse_frontend/src/pages/Life.tsx | 113 +++++++++++++++++-- 3 files changed, 257 insertions(+), 22 deletions(-) diff --git a/life1_backend/life1_backend.did b/life1_backend/life1_backend.did index 93114959..07bec423 100644 --- a/life1_backend/life1_backend.did +++ b/life1_backend/life1_backend.did @@ -19,6 +19,8 @@ type GameRoom = record { created_at: nat64; grid: vec vec nat8; territory: vec vec nat8; + points: vec vec nat16; + player_balances: vec nat64; generation: nat64; players: vec principal; status: GameStatus; @@ -33,6 +35,16 @@ type GameState = record { 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; @@ -57,6 +69,8 @@ service : { // 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 a44d0833..4409c520 100644 --- a/life1_backend/src/lib.rs +++ b/life1_backend/src/lib.rs @@ -18,6 +18,10 @@ pub struct GameRoom { pub grid: Vec>, // Territory tracks ownership (persists after cell dies) 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, pub status: GameStatus, @@ -49,6 +53,18 @@ pub struct GameState { pub is_running: bool, } +/// 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 { @@ -76,6 +92,10 @@ fn create_empty_grid(width: u32, height: u32) -> Vec> { vec![vec![0u8; width as usize]; height as usize] } +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(grid: &[Vec], row: usize, col: usize, height: usize, width: usize) -> (u8, [u8; 5]) { let mut count = 0u8; @@ -113,12 +133,15 @@ fn get_majority_owner(owner_counts: &[u8; 5]) -> u8 { max_owner } -/// Run one generation of Conway's Game of Life with ownership +/// 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(); + for row in 0..height { for col in 0..width { let (count, owner_counts) = get_neighbor_info(&game.grid, row, col, height, width); @@ -129,15 +152,36 @@ fn step_generation(game: &mut GameRoom) { if count == 2 || count == 3 { new_grid[row][col] = current; } + // 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[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; + } } } } } + // 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: any living cell claims its square for row in 0..height { for col in 0..width { @@ -183,6 +227,8 @@ fn create_game(name: String, config: GameConfig) -> Result { 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, @@ -220,6 +266,7 @@ fn join_game(game_id: u64) -> Result { } game.players.push(caller); + game.player_balances.push(1000); // New player gets 1000 points Ok(game.players.len() as u8) }) } @@ -246,7 +293,7 @@ fn start_game(game_id: u64) -> Result<(), String> { // CELL PLACEMENT // ============================================================================ -/// Place cells on the grid. Frontend sends parsed pattern coords. +/// Place cells on the grid with economics. Costs 1 point per cell. #[update] fn place_cells(game_id: u64, cells: Vec<(i32, i32)>) -> Result { let caller = ic_cdk::api::msg_caller(); @@ -259,28 +306,71 @@ fn place_cells(game_id: u64, cells: Vec<(i32, i32)>) -> Result { return Err("Game not active".to_string()); } - // Find player number - let player_num = game.players + // Find player index + let player_idx = game.players .iter() .position(|p| *p == caller) - .map(|i| (i + 1) as u8) .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; - let mut placed = 0u32; + // 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 { - // Wrap coordinates (toroidal) 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 += 1; + placed_cells.push((row as usize, col as usize)); } - Ok(placed) + // 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) }) } @@ -333,7 +423,7 @@ fn set_running(game_id: u64, running: bool) -> Result<(), String> { }) } -/// Clear the grid (keep game active) +/// Clear the grid (keep game active, reset points) #[update] fn clear_grid(game_id: u64) -> Result<(), String> { let caller = ic_cdk::api::msg_caller(); @@ -348,6 +438,11 @@ fn clear_grid(game_id: u64) -> Result<(), String> { 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(()) @@ -374,6 +469,39 @@ fn get_state(game_id: u64) -> Result { }) } +/// 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 { diff --git a/openhouse_frontend/src/pages/Life.tsx b/openhouse_frontend/src/pages/Life.tsx index ac9bb797..96392093 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, GameState, GameInfo, GameStatus } 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'; @@ -30,6 +30,10 @@ const TERRITORY_COLORS: Record = { 4: 'rgba(255, 215, 0, 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'; @@ -157,9 +161,11 @@ export const Life: React.FC = () => { const [error, setError] = useState(null); // Game state from backend - const [gameState, setGameState] = 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); @@ -263,11 +269,18 @@ export const Life: React.FC = () => { if ('Ok' in result) { setCurrentGameId(gameId); setMyPlayerNum(result.Ok); - // Fetch initial state - const stateResult = await actor.get_state(gameId); + // 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 { @@ -337,15 +350,33 @@ export const Life: React.FC = () => { try { if (isRunning) { // Step 5 generations at a time for faster playback - const result = await actor.step(currentGameId, 5); - if ('Ok' in result && !cancelled) { - setGameState(result.Ok); + 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(currentGameId); + 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() + ); + 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); @@ -371,7 +402,7 @@ export const Life: React.FC = () => { cancelled = true; clearTimeout(timeoutId); }; - }, [actor, currentGameId, mode, isRunning]); + }, [actor, currentGameId, mode, isRunning, myPrincipal]); // Draw function const draw = useCallback(() => { @@ -444,6 +475,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; @@ -496,10 +552,27 @@ 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 { - await actor.place_cells(currentGameId, cells); + 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); } }; @@ -531,6 +604,7 @@ export const Life: React.FC = () => { try { await actor.clear_grid(currentGameId); setIsRunning(false); + setMyBalance(1000); // Reset balance } catch (err) { console.error('Clear error:', err); } @@ -690,6 +764,11 @@ export const Life: React.FC = () => {
+ {/* Points balance */} +
+ Points: {myBalance} +
+
|
Gen: {gameState?.generation.toString() || 0}
@@ -810,6 +889,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}

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