From d94ca7f589cedc34b39c858537284eea9c77f50e Mon Sep 17 00:00:00 2001 From: Steven Wessel Date: Fri, 17 Jul 2026 13:47:55 -0400 Subject: [PATCH 1/4] fix: restore Flask blueprints on Python 3.9 and pin Pillow PEP604 union syntax blocked auth import so every /api route 404'd in local pytest. Pillow 12 is unavailable on py3.9; pin a compatible range for baseline health. Co-authored-by: Cursor --- backend/python/middleware/auth.py | 5 ++++- backend/python/requirements.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/python/middleware/auth.py b/backend/python/middleware/auth.py index f27b652..cde94cd 100644 --- a/backend/python/middleware/auth.py +++ b/backend/python/middleware/auth.py @@ -4,9 +4,12 @@ Falls back to dev mode when SUPABASE_URL is not configured. """ +from __future__ import annotations + import os import logging from functools import wraps +from typing import Optional from flask import request, jsonify, g, current_app logger = logging.getLogger(__name__) @@ -42,7 +45,7 @@ def _get_supabase_client(): return g._supabase -def _verify_token(token: str) -> dict | None: +def _verify_token(token: str) -> Optional[dict]: """Verify a JWT token against Supabase and return user data.""" try: sb = _get_supabase_client() diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt index 8427403..8b8af62 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -19,7 +19,7 @@ requests==2.31.0 urllib3==2.6.3 # Image processing (for OCR and drip check) -Pillow==12.1.1 +Pillow>=11.0.0,<12 # 12.x needs newer Python; pin compatible for py3.9 CI/local pytesseract==0.3.10 # Utilities (hashlib and uuid are part of the Python 3 standard library) From 5ba3c467be223be7e1693b73203adbfe16ec0313 Mon Sep 17 00:00:00 2001 From: Steven Wessel Date: Fri, 17 Jul 2026 13:47:55 -0400 Subject: [PATCH 2/4] feat: add Gate 0B BlockSceneManifest contracts and shared claim cost Introduce typed BlockSceneManifest, LiveBlockState, and AttackSnapshot on frontend and backend with unit tests, plus CLAIM_BLOCK_COST=5000 shared constant. Co-authored-by: Cursor --- backend/python/config/__init__.py | 1 + backend/python/config/game_constants.py | 5 + backend/python/schemas/__init__.py | 1 + backend/python/schemas/block_contracts.py | 184 ++++++++++++++++++ backend/python/tests/test_block_contracts.py | 67 +++++++ frontend/src/config/gameEconomy.ts | 5 + .../src/types/contracts/blockScene.test.ts | 87 +++++++++ .../src/types/contracts/blockScene.types.ts | 144 ++++++++++++++ 8 files changed, 494 insertions(+) create mode 100644 backend/python/config/__init__.py create mode 100644 backend/python/config/game_constants.py create mode 100644 backend/python/schemas/__init__.py create mode 100644 backend/python/schemas/block_contracts.py create mode 100644 backend/python/tests/test_block_contracts.py create mode 100644 frontend/src/config/gameEconomy.ts create mode 100644 frontend/src/types/contracts/blockScene.test.ts create mode 100644 frontend/src/types/contracts/blockScene.types.ts diff --git a/backend/python/config/__init__.py b/backend/python/config/__init__.py new file mode 100644 index 0000000..0e632e1 --- /dev/null +++ b/backend/python/config/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/backend/python/config/game_constants.py b/backend/python/config/game_constants.py new file mode 100644 index 0000000..f6048eb --- /dev/null +++ b/backend/python/config/game_constants.py @@ -0,0 +1,5 @@ +"""Shared game economy constants for Gate 0B+.""" + +CLAIM_BLOCK_COST = 5000 +CLAIM_HEAT_DELTA = 5 +STARTER_CASH = 10000 diff --git a/backend/python/schemas/__init__.py b/backend/python/schemas/__init__.py new file mode 100644 index 0000000..0e632e1 --- /dev/null +++ b/backend/python/schemas/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/backend/python/schemas/block_contracts.py b/backend/python/schemas/block_contracts.py new file mode 100644 index 0000000..fb2a65b --- /dev/null +++ b/backend/python/schemas/block_contracts.py @@ -0,0 +1,184 @@ +""" +Gate 0B typed contracts — mirror frontend/src/types/contracts/blockScene.types.ts + +Geometry decides gameplay; AI pixels decide atmosphere only. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Literal, Optional + + +SceneStatus = Literal['queued', 'extracting', 'rendering', 'generating', 'validating', 'review', 'ready', 'failed', 'fallback'] +ZoneType = Literal['street', 'curb', 'sidewalk', 'storefront', 'alley', 'parking', 'rooftop', 'building'] + + +@dataclass +class SceneExtent: + width_m: float = 80.0 + height_m: float = 80.0 + rotation_bearing_deg: float = 0.0 + center_lat: float = 0.0 + center_lng: float = 0.0 + bounds: Dict[str, float] = field(default_factory=dict) + + +@dataclass +class SceneAnchor: + id: str + local_x_m: float + local_y_m: float + normalized_x: float + normalized_y: float + zone_type: ZoneType + facing_deg: float = 0.0 + payout_multiplier: float = 1.0 + risk_multiplier: float = 1.0 + cover: float = 0.0 + playable: bool = True + + +@dataclass +class BlockSceneManifest: + """Immutable physical board version.""" + + block_id: str + scene_version: str + status: SceneStatus + address_display: str + address_canonical: Optional[str] + geocoder_feature_id: Optional[str] + extent: SceneExtent + grid_width: int = 8 + grid_height: int = 8 + cell_size_m: float = 10.0 + anchors: List[SceneAnchor] = field(default_factory=list) + grid_zone_types: List[List[ZoneType]] = field(default_factory=list) + topdown_texture_url: Optional[str] = None + street_strip_url: Optional[str] = None + provenance: Dict[str, Any] = field(default_factory=dict) + created_at: str = '' + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class LivePlacement: + member_id: str + anchor_id: str + role: str + local_offset_x_m: float = 0.0 + local_offset_y_m: float = 0.0 + facing_deg: float = 0.0 + health: int = 100 + loadout: Dict[str, Any] = field(default_factory=dict) + grid_x: Optional[int] = None + grid_y: Optional[int] = None + + +@dataclass +class LiveBlockState: + """Mutable ownership / crew / economy for a block.""" + + block_id: str + scene_version: str + revision: int + owner_id: str + claim_status: Literal['owned', 'npc', 'unclaimed', 'contested'] + placements: List[LivePlacement] = field(default_factory=list) + heat: int = 0 + morale: int = 80 + pending_income: int = 0 + income_per_tick: int = 0 + updated_at: str = '' + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class AttackSnapshot: + """Immutable defender board frozen at attack start.""" + + attack_id: str + block_id: str + scene_version: str + live_revision: int + rules_version: str + seed: str + started_at: str + defender_placements: List[LivePlacement] = field(default_factory=list) + attacker_loadout: Dict[str, Any] = field(default_factory=dict) + civilian_seed: str = '' + weather: str = 'clear' + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def grid_cell_to_anchor_id(x: int, y: int) -> str: + return f'cell-{x}-{y}' + + +def build_default_manifest( + block_id: str, + *, + scene_version: str, + address_display: str, + lat: float, + lng: float, + bounds: Optional[Dict[str, float]] = None, + created_at: str = '', +) -> BlockSceneManifest: + """Procedural fallback manifest until Overture ingest (Gate 3).""" + zone_row = [ + 'street', 'street', 'street', 'street', 'street', 'street', 'street', 'street', + ] + layout: List[List[ZoneType]] = [ + zone_row, # type: ignore[list-item] + ['curb'] * 8, # type: ignore[list-item] + ['sidewalk'] * 8, # type: ignore[list-item] + ['storefront', 'storefront', 'alley', 'storefront', 'storefront', 'alley', 'storefront', 'storefront'], # type: ignore[list-item] + ['storefront', 'storefront', 'alley', 'storefront', 'storefront', 'alley', 'storefront', 'storefront'], # type: ignore[list-item] + ['sidewalk'] * 8, # type: ignore[list-item] + ['curb'] * 8, # type: ignore[list-item] + zone_row, # type: ignore[list-item] + ] + anchors: List[SceneAnchor] = [] + for y, row in enumerate(layout): + for x, zone in enumerate(row): + playable = zone not in ('street', 'building') + anchors.append( + SceneAnchor( + id=grid_cell_to_anchor_id(x, y), + local_x_m=x * 10.0 + 5.0, + local_y_m=y * 10.0 + 5.0, + normalized_x=(x + 0.5) / 8.0, + normalized_y=(y + 0.5) / 8.0, + zone_type=zone, # type: ignore[arg-type] + payout_multiplier=1.2 if zone in ('curb', 'sidewalk') else 1.0, + risk_multiplier=1.5 if zone in ('street', 'curb') else 1.0, + cover=0.6 if zone in ('storefront', 'alley') else 0.2, + playable=playable, + ) + ) + extent = SceneExtent( + center_lat=lat, + center_lng=lng, + bounds=bounds or {}, + ) + return BlockSceneManifest( + block_id=block_id, + scene_version=scene_version, + status='fallback', + address_display=address_display, + address_canonical=address_display, + geocoder_feature_id=None, + extent=extent, + anchors=anchors, + grid_zone_types=layout, # type: ignore[arg-type] + provenance={'source': 'procedural-fallback', 'gate': '0B'}, + created_at=created_at, + ) diff --git a/backend/python/tests/test_block_contracts.py b/backend/python/tests/test_block_contracts.py new file mode 100644 index 0000000..8de985d --- /dev/null +++ b/backend/python/tests/test_block_contracts.py @@ -0,0 +1,67 @@ +"""Contract unit tests for BlockSceneManifest / LiveBlockState / AttackSnapshot.""" + +from schemas.block_contracts import ( + AttackSnapshot, + LiveBlockState, + LivePlacement, + build_default_manifest, + grid_cell_to_anchor_id, +) + + +def test_grid_cell_to_anchor_id(): + assert grid_cell_to_anchor_id(3, 5) == 'cell-3-5' + + +def test_build_default_manifest_has_64_anchors_and_outdoor_rules(): + manifest = build_default_manifest( + 'block-1', + scene_version='v1', + address_display='1208 Sample St', + lat=25.7617, + lng=-80.1918, + created_at='2026-07-17T00:00:00Z', + ) + assert manifest.status == 'fallback' + assert len(manifest.anchors) == 64 + street = next(a for a in manifest.anchors if a.id == 'cell-0-0') + assert street.zone_type == 'street' + assert street.playable is False + sidewalk = next(a for a in manifest.anchors if a.id == 'cell-0-2') + assert sidewalk.zone_type == 'sidewalk' + assert sidewalk.playable is True + + +def test_attack_snapshot_freezes_placements(): + placement = LivePlacement( + member_id='m1', + anchor_id='cell-2-3', + role='dealer', + health=100, + grid_x=2, + grid_y=3, + ) + live = LiveBlockState( + block_id='b1', + scene_version='v1', + revision=2, + owner_id='u1', + claim_status='owned', + placements=[placement], + heat=5, + ) + snap = AttackSnapshot( + attack_id='a1', + block_id=live.block_id, + scene_version=live.scene_version, + live_revision=live.revision, + rules_version='0B.1', + seed='s1', + started_at='2026-07-17T01:00:00Z', + defender_placements=[ + LivePlacement(**{**placement.__dict__}), + ], + ) + placement.health = 10 + assert snap.defender_placements[0].health == 100 + assert snap.to_dict()['scene_version'] == 'v1' diff --git a/frontend/src/config/gameEconomy.ts b/frontend/src/config/gameEconomy.ts new file mode 100644 index 0000000..54a3c27 --- /dev/null +++ b/frontend/src/config/gameEconomy.ts @@ -0,0 +1,5 @@ +// Shared economy constants — keep in sync with backend/python/config/game_constants.py + +export const CLAIM_BLOCK_COST = 5000; +export const CLAIM_HEAT_DELTA = 5; +export const STARTER_CASH = 10000; diff --git a/frontend/src/types/contracts/blockScene.test.ts b/frontend/src/types/contracts/blockScene.test.ts new file mode 100644 index 0000000..02dfa8e --- /dev/null +++ b/frontend/src/types/contracts/blockScene.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + createAttackSnapshot, + gridCellToAnchorId, + type BlockSceneManifest, + type LiveBlockState, +} from './blockScene.types'; + +describe('Gate 0B block contracts', () => { + it('maps grid cells to stable anchor ids', () => { + expect(gridCellToAnchorId(3, 5)).toBe('cell-3-5'); + }); + + it('freezes live placements into an AttackSnapshot without sharing mutation', () => { + const live: LiveBlockState = { + blockId: 'block-1', + sceneVersion: 'v1', + revision: 4, + ownerId: 'user-1', + claimStatus: 'owned', + placements: [ + { + memberId: 'm1', + anchorId: 'cell-2-3', + role: 'dealer', + localOffsetXM: 0, + localOffsetYM: 0, + facingDeg: 90, + health: 100, + loadout: { product: 'weed' }, + gridX: 2, + gridY: 3, + }, + ], + heat: 5, + morale: 80, + pendingIncome: 120, + incomePerTick: 40, + updatedAt: '2026-07-17T00:00:00.000Z', + }; + + const snap = createAttackSnapshot({ + attackId: 'atk-1', + live, + seed: 'seed-abc', + startedAt: '2026-07-17T01:00:00.000Z', + }); + + expect(snap.sceneVersion).toBe('v1'); + expect(snap.liveRevision).toBe(4); + expect(snap.defenderPlacements).toHaveLength(1); + expect(snap.defenderPlacements[0].memberId).toBe('m1'); + + live.placements[0].health = 10; + expect(snap.defenderPlacements[0].health).toBe(100); + }); + + it('requires manifest identity fields for an immutable scene', () => { + const manifest: BlockSceneManifest = { + blockId: 'b1', + sceneVersion: 'scene-1', + status: 'fallback', + addressDisplay: 'Sample Plaza', + addressCanonical: 'Sample Plaza', + geocoderFeatureId: null, + extent: { + widthM: 80, + heightM: 80, + rotationBearingDeg: 0, + centerLat: 25.76, + centerLng: -80.19, + bounds: {}, + }, + gridWidth: 8, + gridHeight: 8, + cellSizeM: 10, + anchors: [], + gridZoneTypes: [], + topdownTextureUrl: null, + streetStripUrl: null, + provenance: { source: 'test' }, + createdAt: '2026-07-17T00:00:00.000Z', + }; + expect(manifest.status).toBe('fallback'); + expect(manifest.extent.widthM).toBe(80); + }); +}); diff --git a/frontend/src/types/contracts/blockScene.types.ts b/frontend/src/types/contracts/blockScene.types.ts new file mode 100644 index 0000000..db5e142 --- /dev/null +++ b/frontend/src/types/contracts/blockScene.types.ts @@ -0,0 +1,144 @@ +/** + * Gate 0B contracts — geometry vs live state vs attack freeze. + * Geometry decides gameplay; AI pixels decide atmosphere only. + */ + +export type SceneStatus = + | 'queued' + | 'extracting' + | 'rendering' + | 'generating' + | 'validating' + | 'review' + | 'ready' + | 'failed' + | 'fallback'; + +export type ContractZoneType = + | 'street' + | 'curb' + | 'sidewalk' + | 'storefront' + | 'alley' + | 'parking' + | 'rooftop' + | 'building'; + +export interface SceneExtent { + widthM: number; + heightM: number; + rotationBearingDeg: number; + centerLat: number; + centerLng: number; + bounds: { + north?: number; + south?: number; + east?: number; + west?: number; + }; +} + +export interface SceneAnchor { + id: string; + localXM: number; + localYM: number; + normalizedX: number; + normalizedY: number; + zoneType: ContractZoneType; + facingDeg: number; + payoutMultiplier: number; + riskMultiplier: number; + cover: number; + playable: boolean; +} + +/** Immutable physical board version. */ +export interface BlockSceneManifest { + blockId: string; + sceneVersion: string; + status: SceneStatus; + addressDisplay: string; + addressCanonical: string | null; + geocoderFeatureId: string | null; + extent: SceneExtent; + gridWidth: number; + gridHeight: number; + cellSizeM: number; + anchors: SceneAnchor[]; + gridZoneTypes: ContractZoneType[][]; + topdownTextureUrl: string | null; + streetStripUrl: string | null; + provenance: Record; + createdAt: string; +} + +export interface LivePlacement { + memberId: string; + anchorId: string; + role: string; + localOffsetXM: number; + localOffsetYM: number; + facingDeg: number; + health: number; + loadout: Record; + /** Transitional 8×8 grid coords until Gate 3 anchors are authoritative. */ + gridX?: number; + gridY?: number; +} + +/** Mutable ownership / crew / economy. */ +export interface LiveBlockState { + blockId: string; + sceneVersion: string; + revision: number; + ownerId: string; + claimStatus: 'owned' | 'npc' | 'unclaimed' | 'contested'; + placements: LivePlacement[]; + heat: number; + morale: number; + pendingIncome: number; + incomePerTick: number; + updatedAt: string; +} + +/** Immutable defender board frozen at attack start. */ +export interface AttackSnapshot { + attackId: string; + blockId: string; + sceneVersion: string; + liveRevision: number; + rulesVersion: string; + seed: string; + startedAt: string; + defenderPlacements: LivePlacement[]; + attackerLoadout: Record; + civilianSeed: string; + weather: string; +} + +export function gridCellToAnchorId(x: number, y: number): string { + return `cell-${x}-${y}`; +} + +export function createAttackSnapshot(input: { + attackId: string; + live: LiveBlockState; + seed: string; + attackerLoadout?: Record; + rulesVersion?: string; + startedAt?: string; +}): AttackSnapshot { + return { + attackId: input.attackId, + blockId: input.live.blockId, + sceneVersion: input.live.sceneVersion, + liveRevision: input.live.revision, + rulesVersion: input.rulesVersion ?? '0B.1', + seed: input.seed, + startedAt: input.startedAt ?? new Date().toISOString(), + defenderPlacements: input.live.placements.map((p) => ({ ...p })), + attackerLoadout: input.attackerLoadout ?? {}, + civilianSeed: `${input.seed}-civ`, + weather: 'clear', + }; +} From 0a018f942e36b75ccdea15b0a862146d7529bb8a Mon Sep 17 00:00:00 2001 From: Steven Wessel Date: Fri, 17 Jul 2026 13:47:55 -0400 Subject: [PATCH 3/4] feat: make Flask DBAdapter authoritative for claim, place, earn, collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite block claim/my-blocks onto the mock/Supabase adapter, add player state and placement/income endpoints, and prove claim→deploy→earn→reload in pytest. Co-authored-by: Cursor --- backend/python/api/blocks.py | 584 +++++++++--------- backend/python/api/player.py | 28 + backend/python/app.py | 7 + backend/python/services/db.py | 211 ++++++- .../tests/test_gate_0b_vertical_slice.py | 110 ++++ 5 files changed, 652 insertions(+), 288 deletions(-) create mode 100644 backend/python/api/player.py create mode 100644 backend/python/tests/test_gate_0b_vertical_slice.py diff --git a/backend/python/api/blocks.py b/backend/python/api/blocks.py index 33b8cee..1d4297f 100644 --- a/backend/python/api/blocks.py +++ b/backend/python/api/blocks.py @@ -1,16 +1,21 @@ """ DEALT/SLIDE - Block API Routes -Handles block claiming, lookup, and management +Handles block claiming, lookup, and management via DBAdapter. """ -from flask import Blueprint, request, jsonify, current_app, g -from typing import Optional +from __future__ import annotations + +from flask import Blueprint, request, jsonify, g +from typing import Any, Dict import logging +import uuid -from services.geocoding_service import get_geocoding_service, GeocodingService -from services.grid_generator import generate_block_grid, GridConfig +from services.geocoding_service import get_geocoding_service +from services.grid_generator import generate_block_grid +from services.db import get_db from middleware.auth import require_auth -# from models.block import Block, SUPPORTED_CITIES # Commented out - using Supabase now +from config.game_constants import CLAIM_BLOCK_COST, CLAIM_HEAT_DELTA +from schemas.block_contracts import build_default_manifest, grid_cell_to_anchor_id SUPPORTED_CITIES = ['nyc', 'la', 'miami', 'chicago', 'detroit', 'nola'] @@ -19,35 +24,60 @@ blocks_bp = Blueprint('blocks', __name__, url_prefix='/api/blocks') -# ============================================================================ -# ROUTES -# ============================================================================ +def _serialize_block(block: Dict[str, Any], include_grid: bool = False) -> Dict[str, Any]: + """Normalize DBAdapter block records for the frontend.""" + out = { + 'id': block.get('id'), + 'ownerId': block.get('owner_id'), + 'address': block.get('address'), + 'city': block.get('city'), + 'coordinates': {'lat': block.get('lat'), 'lng': block.get('lng')}, + 'lat': block.get('lat'), + 'lng': block.get('lng'), + 'gangName': block.get('gang_name'), + 'trafficScore': block.get('traffic_score'), + 'incomePerHour': block.get('income_per_hour'), + 'incomePerTick': block.get('income_per_tick', 0), + 'pendingIncome': block.get('pending_income', 0), + 'heatLevel': block.get('heat_level', 0), + 'blockHash': block.get('block_hash'), + 'sceneVersion': block.get('scene_version'), + 'liveRevision': block.get('live_revision', 1), + 'claimedAt': block.get('claimed_at'), + 'bounds': { + 'north': block.get('bounds_north'), + 'south': block.get('bounds_south'), + 'east': block.get('bounds_east'), + 'west': block.get('bounds_west'), + }, + 'placements': block.get('placements') or [], + 'backgrounds': block.get('backgrounds') or {}, + } + if include_grid: + out['gridData'] = block.get('grid_data') or {} + out['sceneManifest'] = block.get('scene_manifest') or {} + return out + + +def _extract_coords(data: Dict[str, Any]): + coords = data.get('coordinates') or {} + lat = coords.get('lat', data.get('lat')) + lng = coords.get('lng', data.get('lng')) + return lat, lng + @blocks_bp.route('/search', methods=['GET']) def search_address(): - """ - Search for addresses - - Query params: - q: Search query (required) - limit: Max results (default 5) - - Returns: - List of address suggestions - """ + """Search for addresses.""" query = request.args.get('q', '') limit = min(int(request.args.get('limit', 5)), 10) - + if len(query) < 3: - return jsonify({ - 'results': [], - 'query': query, - }) - + return jsonify({'results': [], 'query': query}) + try: geocoding = get_geocoding_service() results = geocoding.search_address(query, limit=limit) - return jsonify({ 'results': [ { @@ -62,7 +92,6 @@ def search_address(): ], 'query': query, }) - except Exception as e: logger.error(f"Address search failed: {e}") return jsonify({'error': 'Search failed'}), 500 @@ -70,43 +99,25 @@ def search_address(): @blocks_bp.route('/preview', methods=['POST']) def get_block_preview(): - """ - Get preview of a block before claiming - - Body: - address: Optional address string - lat: Optional latitude - lng: Optional longitude - - Returns: - Block preview with satellite image and estimates - """ - data = request.get_json() - + """Get preview of a block before claiming.""" + data = request.get_json() or {} address = data.get('address') - lat = data.get('lat') - lng = data.get('lng') - + lat, lng = _extract_coords(data) + if not address and (lat is None or lng is None): return jsonify({'error': 'Address or coordinates required'}), 400 - + try: geocoding = get_geocoding_service() - location = geocoding.get_block_location( - address=address, - lat=lat, - lng=lng - ) - + location = geocoding.get_block_location(address=address, lat=lat, lng=lng) if not location: return jsonify({ 'error': 'Location not found or outside service area', - 'reason': 'outside_service_area' + 'reason': 'outside_service_area', }), 404 - - # Check if block already exists - existing = Block.find_by_hash(location.block_hash) - + + db = get_db() + existing = db.find_block_by_hash(location.block_hash) return jsonify({ 'address': location.address, 'formattedAddress': location.formatted_address, @@ -116,13 +127,13 @@ def get_block_preview(): 'satelliteImageUrl': location.satellite_url, 'estimatedTraffic': location.traffic_score, 'estimatedIncome': location.traffic_score * 10, - 'isAvailable': existing is None or not existing.is_claimed, + 'claimCost': CLAIM_BLOCK_COST, + 'isAvailable': existing is None, 'currentOwner': { - 'gangName': existing.owner_gang_name, - 'claimedAt': existing.claimed_at.isoformat(), - } if existing and existing.is_claimed else None, + 'gangName': existing.get('gang_name'), + 'claimedAt': existing.get('claimed_at'), + } if existing else None, }) - except Exception as e: logger.error(f"Block preview failed: {e}") return jsonify({'error': 'Preview failed'}), 500 @@ -131,104 +142,107 @@ def get_block_preview(): @blocks_bp.route('/claim', methods=['POST']) @require_auth def claim_block(): - """ - Claim a block for the user - - Body: - address: Full address string - coordinates: {lat, lng} - city: City code - - Returns: - Complete block data with grid - """ - data = request.get_json() + """Claim a block for the user (server-authoritative cost).""" + data = request.get_json() or {} user_id = g.user['id'] address = data.get('address') - coords = data.get('coordinates', {}) + lat, lng = _extract_coords(data) city = data.get('city') - - lat = coords.get('lat') - lng = coords.get('lng') - + gang_name = data.get('gangName') or data.get('gang_name') or 'Unknown Gang' + if not address or lat is None or lng is None: return jsonify({'error': 'Address and coordinates required'}), 400 - - if city not in SUPPORTED_CITIES: - return jsonify({ - 'error': f'City not supported. Valid cities: {", ".join(SUPPORTED_CITIES)}', - 'reason': 'outside_service_area' - }), 400 - + try: geocoding = get_geocoding_service() - location = geocoding.get_block_location(lat=lat, lng=lng) - + location = geocoding.get_block_location( + address=address, lat=float(lat), lng=float(lng), + ) if not location: return jsonify({ 'error': 'Could not verify location', - 'reason': 'invalid_address' + 'reason': 'invalid_address', }), 400 - - # Check if block exists - existing = Block.find_by_hash(location.block_hash) - - if existing and existing.is_claimed: + + city = city or location.city + if city not in SUPPORTED_CITIES: + return jsonify({ + 'error': f'City not supported. Valid cities: {", ".join(SUPPORTED_CITIES)}', + 'reason': 'outside_service_area', + }), 400 + + db = get_db() + existing = db.find_block_by_hash(location.block_hash) + if existing: return jsonify({ 'error': 'Block already claimed', 'reason': 'already_claimed', 'currentOwner': { - 'gangName': existing.owner_gang_name, - 'claimedAt': existing.claimed_at.isoformat(), - } + 'gangName': existing.get('gang_name'), + 'claimedAt': existing.get('claimed_at'), + }, }), 409 - - # Generate grid + + player = db.get_player_state(user_id) + if player['cash'] < CLAIM_BLOCK_COST: + return jsonify({ + 'error': 'Insufficient funds', + 'reason': 'insufficient_funds', + 'required': CLAIM_BLOCK_COST, + 'cash': player['cash'], + }), 400 + grid_result = generate_block_grid( city=city, traffic_score=location.traffic_score, - seed=location.block_hash, # Deterministic grid + seed=location.block_hash, ) - - # Create or update block - if existing: - block = existing - else: - block = Block( - address=address, - formatted_address=location.formatted_address, - city=city, - lat=lat, - lng=lng, - block_hash=location.block_hash, - ) - - # Set block properties - block.neighborhood = location.neighborhood - block.satellite_image_url = location.satellite_url - block.grid_data = grid_result.to_dict() - block.traffic_score = location.traffic_score - block.income_per_hour = location.traffic_score * 10 - block.cover_density = grid_result.stats.get('averageCover', 0.3) - - # Set bounds - block.bounds_north = location.bounds['north'] - block.bounds_south = location.bounds['south'] - block.bounds_east = location.bounds['east'] - block.bounds_west = location.bounds['west'] - - # Claim for user - # TODO: Get gang name from user profile - gang_name = data.get('gangName', 'Unknown Gang') - block.claim(user_id, gang_name) - - # Save to database - from extensions import db - db.session.add(block) - db.session.commit() - - return jsonify(block.to_dict(include_grid=True)), 201 - + + updated_player = db.apply_economy_delta( + user_id, + cash_delta=-CLAIM_BLOCK_COST, + heat_delta=CLAIM_HEAT_DELTA, + ) + + temp_id = str(uuid.uuid4()) + manifest = build_default_manifest( + temp_id, + scene_version=f'scene-{temp_id[:8]}-v1', + address_display=address, + lat=location.lat, + lng=location.lng, + bounds=location.bounds, + created_at='', + ) + + block = db.claim_block( + user_id=user_id, + address=address, + coords={'lat': location.lat, 'lng': location.lng}, + city=city, + bounds=location.bounds, + gang_name=gang_name, + grid_data=grid_result.to_dict(), + traffic_score=location.traffic_score, + block_hash=location.block_hash, + scene_manifest=manifest.to_dict(), + heat_level=CLAIM_HEAT_DELTA, + ) + + manifest.block_id = block['id'] + manifest.scene_version = block.get('scene_version') or manifest.scene_version + block['scene_manifest'] = manifest.to_dict() + if getattr(db, '_dev_mode', False): + from services.db import _mock_blocks + _mock_blocks[block['id']] = block + + return jsonify({ + 'success': True, + 'block': _serialize_block(block, include_grid=True), + 'player': updated_player, + 'claimCost': CLAIM_BLOCK_COST, + }), 201 + except Exception as e: logger.error(f"Block claim failed: {e}") return jsonify({'error': 'Claim failed'}), 500 @@ -236,116 +250,83 @@ def claim_block(): @blocks_bp.route('/availability/', methods=['GET']) def check_availability(block_hash: str): - """ - Check if a block is available for claiming - - Returns: - Availability status and current owner if claimed - """ + """Check if a block is available for claiming.""" try: - block = Block.find_by_hash(block_hash) - + db = get_db() + block = db.find_block_by_hash(block_hash) if not block: - return jsonify({ - 'isAvailable': True, - 'exists': False, - }) - + return jsonify({'isAvailable': True, 'exists': False, 'available': True}) return jsonify({ - 'isAvailable': not block.is_claimed, + 'isAvailable': False, + 'available': False, 'exists': True, 'currentOwner': { - 'gangName': block.owner_gang_name, - 'claimedAt': block.claimed_at.isoformat(), - } if block.is_claimed else None, + 'gangName': block.get('gang_name'), + 'claimedAt': block.get('claimed_at'), + }, }) - except Exception as e: logger.error(f"Availability check failed: {e}") return jsonify({'error': 'Check failed'}), 500 -@blocks_bp.route('/', methods=['GET']) -def get_block(block_id: str): - """ - Get block by ID - - Query params: - includeGrid: Include full grid data (default false) - - Returns: - Block data - """ - include_grid = request.args.get('includeGrid', 'false').lower() == 'true' - - try: - block = Block.query.get(block_id) - - if not block: - return jsonify({'error': 'Block not found'}), 404 - - return jsonify(block.to_dict(include_grid=include_grid)) - - except Exception as e: - logger.error(f"Block fetch failed: {e}") - return jsonify({'error': 'Fetch failed'}), 500 - - @blocks_bp.route('/my-blocks', methods=['GET']) @require_auth def get_my_blocks(): - """ - Get all blocks owned by current user - - Returns: - List of owned blocks - """ + """Get all blocks owned by current user.""" user_id = g.user['id'] - try: - blocks = Block.query.filter_by(owner_id=user_id).all() - + db = get_db() + blocks = db.get_user_blocks(user_id) + serialized = [] + for b in blocks: + item = _serialize_block(b, include_grid=True) + item['placements'] = db.get_placements(b['id']) + serialized.append(item) return jsonify({ - 'blocks': [b.to_dict() for b in blocks], - 'count': len(blocks), - 'totalIncome': sum(b.income_per_hour for b in blocks), + 'blocks': serialized, + 'count': len(serialized), + 'totalIncome': sum(float(b.get('incomePerHour') or 0) for b in serialized), }) - except Exception as e: logger.error(f"My blocks fetch failed: {e}") return jsonify({'error': 'Fetch failed'}), 500 +@blocks_bp.route('/', methods=['GET']) +def get_block(block_id: str): + """Get block by ID.""" + include_grid = request.args.get('includeGrid', 'false').lower() == 'true' + try: + db = get_db() + block = db.get_block(block_id) + if not block: + return jsonify({'error': 'Block not found'}), 404 + payload = _serialize_block(block, include_grid=include_grid) + payload['placements'] = db.get_placements(block_id) + return jsonify(payload) + except Exception as e: + logger.error(f"Block fetch failed: {e}") + return jsonify({'error': 'Fetch failed'}), 500 + + @blocks_bp.route('/nearby', methods=['GET']) def get_nearby_blocks(): - """ - Get blocks near a location - - Query params: - lat: Latitude (required) - lng: Longitude (required) - radius: Radius in km (default 1) - - Returns: - List of nearby blocks - """ + """Get blocks near a location (city filter for MVP).""" lat = request.args.get('lat', type=float) lng = request.args.get('lng', type=float) - radius = request.args.get('radius', 1.0, type=float) - if lat is None or lng is None: return jsonify({'error': 'Coordinates required'}), 400 - try: - blocks = Block.find_nearby(lat, lng, radius_km=radius) - + geocoding = get_geocoding_service() + city = geocoding._get_city_from_coordinates(lat, lng) + db = get_db() + blocks = db.get_blocks_for_city(city, limit=100) if city else [] return jsonify({ - 'blocks': [b.to_dict() for b in blocks], + 'blocks': [_serialize_block(b) for b in blocks], 'count': len(blocks), 'searchCenter': {'lat': lat, 'lng': lng}, - 'radiusKm': radius, }) - except Exception as e: logger.error(f"Nearby blocks fetch failed: {e}") return jsonify({'error': 'Fetch failed'}), 500 @@ -353,37 +334,18 @@ def get_nearby_blocks(): @blocks_bp.route('/city/', methods=['GET']) def get_city_blocks(city: str): - """ - Get blocks in a specific city - - Path params: - city: City code (nyc, la, etc.) - - Query params: - limit: Max results (default 100) - unclaimed: Only show unclaimed (default false) - - Returns: - List of blocks in city - """ + """Get blocks in a specific city.""" if city not in SUPPORTED_CITIES: return jsonify({'error': f'Invalid city. Valid: {SUPPORTED_CITIES}'}), 400 - limit = min(int(request.args.get('limit', 100)), 500) - unclaimed_only = request.args.get('unclaimed', 'false').lower() == 'true' - try: - if unclaimed_only: - blocks = Block.find_unclaimed(city=city, limit=limit) - else: - blocks = Block.find_by_city(city, limit=limit) - + db = get_db() + blocks = db.get_blocks_for_city(city, limit=limit) return jsonify({ - 'blocks': [b.to_dict() for b in blocks], + 'blocks': [_serialize_block(b) for b in blocks], 'count': len(blocks), 'city': city, }) - except Exception as e: logger.error(f"City blocks fetch failed: {e}") return jsonify({'error': 'Fetch failed'}), 500 @@ -391,61 +353,111 @@ def get_city_blocks(city: str): @blocks_bp.route('/cities', methods=['GET']) def get_supported_cities(): - """ - Get list of supported cities - - Returns: - List of city codes and names - """ + """Get list of supported cities.""" geocoding = get_geocoding_service() - return jsonify({ - 'cities': geocoding.get_supported_cities() - }) + return jsonify({'cities': geocoding.get_supported_cities()}) -@blocks_bp.route('//regenerate-grid', methods=['POST']) +@blocks_bp.route('//members/place', methods=['POST']) @require_auth -def regenerate_block_grid(block_id: str): - """ - Regenerate grid for a block (owner only) - - Returns: - Updated block with new grid - """ - # TODO: Implement with Supabase instead of SQLAlchemy - return jsonify({'error': 'Not implemented yet - use Supabase'}), 501 - - # user_id = request.user_id - # - # try: - # block = Block.query.get(block_id) - # - # if not block: - # return jsonify({'error': 'Block not found'}), 404 - # - # if str(block.owner_id) != user_id: - # return jsonify({'error': 'Not authorized'}), 403 - # - # # Generate new grid with new seed - # grid_result = generate_block_grid( - # city=block.city, - # traffic_score=block.traffic_score, - # ) - # - # block.grid_data = grid_result.to_dict() - # block.cover_density = grid_result.stats.get('averageCover', 0.3) - # block.generation_version = '1.0.1' # Increment version - # - # from extensions import db - # db.session.commit() - # - # return jsonify(block.to_dict(include_grid=True)) - # - # except Exception as e: - # logger.error(f"Grid regeneration failed: {e}") - # return jsonify({'error': 'Regeneration failed'}), 500 +def place_members(block_id: str): + """Replace crew placements on a block (owner only).""" + user_id = g.user['id'] + data = request.get_json() or {} + placements = data.get('placements') or [] + try: + db = get_db() + block = db.get_block(block_id) + if not block: + return jsonify({'error': 'Block not found'}), 404 + if block.get('owner_id') != user_id: + return jsonify({'error': 'Not authorized'}), 403 + + normalized = [] + for p in placements: + x = int(p.get('gridX', p.get('x', 0))) + y = int(p.get('gridY', p.get('y', 0))) + if not (0 <= x < 8 and 0 <= y < 8): + return jsonify({'error': f'Invalid grid cell ({x},{y})'}), 400 + if y in (0, 7): + return jsonify({'error': 'Cannot place on street lane'}), 400 + normalized.append({ + 'memberId': p.get('memberId') or p.get('member_id'), + 'memberName': p.get('memberName') or p.get('member_name') or 'Member', + 'role': p.get('role', 'dealer'), + 'anchorId': p.get('anchorId') or p.get('anchor_id') or grid_cell_to_anchor_id(x, y), + 'gridX': x, + 'gridY': y, + 'x': x, + 'y': y, + 'zoneType': p.get('zoneType') or p.get('zone_type') or 'sidewalk', + 'incomePerTick': int(p.get('incomePerTick') or p.get('income_per_tick') or 0), + 'exposureRisk': int(p.get('exposureRisk') or 50), + 'level': int(p.get('level') or 1), + 'health': int(p.get('health') or 100), + 'facingDeg': float(p.get('facingDeg') or 0), + 'loadout': p.get('loadout') or {}, + }) + saved = db.save_placements(block_id, normalized) + block = db.get_block(block_id) + return jsonify({ + 'success': True, + 'blockId': block_id, + 'placements': saved, + 'liveRevision': block.get('live_revision', 1) if block else 1, + 'incomePerTick': block.get('income_per_tick', 0) if block else 0, + }) + except Exception as e: + logger.error(f"Place members failed: {e}") + return jsonify({'error': 'Place failed'}), 500 + + +@blocks_bp.route('//tick-income', methods=['POST']) +@require_auth +def tick_income(block_id: str): + """Accumulate one income tick into pending_income (owner only).""" + user_id = g.user['id'] + try: + db = get_db() + block = db.get_block(block_id) + if not block: + return jsonify({'error': 'Block not found'}), 404 + if block.get('owner_id') != user_id: + return jsonify({'error': 'Not authorized'}), 403 + updated = db.tick_block_income(block_id) + return jsonify({'success': True, 'block': _serialize_block(updated or block)}) + except Exception as e: + logger.error(f"Tick income failed: {e}") + return jsonify({'error': 'Tick failed'}), 500 + +@blocks_bp.route('//collect', methods=['POST']) +@require_auth +def collect_income(block_id: str): + """Collect pending income into player cash.""" + user_id = g.user['id'] + try: + db = get_db() + result = db.collect_block_income(user_id, block_id) + if result is None: + return jsonify({'error': 'Block not found or not owned'}), 404 + return jsonify({ + 'success': True, + 'collected': result['collected'], + 'player': result['player'], + 'block': _serialize_block(result['block']), + }) + except Exception as e: + logger.error(f"Collect income failed: {e}") + return jsonify({'error': 'Collect failed'}), 500 + + +@blocks_bp.route('//regenerate-grid', methods=['POST']) +@require_auth +def regenerate_block_grid(block_id: str): + """Regenerate grid for a block (owner only) — deferred.""" + return jsonify({'error': 'Not implemented yet'}), 501 # ============================================================================ # BLOCK SNAPSHOT ROUTES (for BlockStateEngine integration) # ============================================================================ diff --git a/backend/python/api/player.py b/backend/python/api/player.py new file mode 100644 index 0000000..ea6d9c1 --- /dev/null +++ b/backend/python/api/player.py @@ -0,0 +1,28 @@ +""" +Player economy / state API — Gate 0B authority for cash and heat. +""" + +from __future__ import annotations + +from flask import Blueprint, jsonify, g +import logging + +from middleware.auth import require_auth +from services.db import get_db + +logger = logging.getLogger(__name__) + +player_bp = Blueprint('player', __name__, url_prefix='/api/player') + + +@player_bp.route('/state', methods=['GET']) +@require_auth +def get_player_state(): + """Return authoritative cash/heat for the authenticated user.""" + try: + db = get_db() + state = db.get_player_state(g.user['id']) + return jsonify({'player': state}) + except Exception as e: + logger.error(f"get_player_state failed: {e}") + return jsonify({'error': 'Failed to load player state'}), 500 diff --git a/backend/python/app.py b/backend/python/app.py index 54a9325..34040f5 100644 --- a/backend/python/app.py +++ b/backend/python/app.py @@ -77,6 +77,13 @@ def index(): logger.info("✓ Registered blocks blueprint") except Exception as e: logger.error(f"✗ Failed to register blocks blueprint: {e}") + + try: + from api.player import player_bp + app.register_blueprint(player_bp) + logger.info("✓ Registered player blueprint") + except Exception as e: + logger.error(f"✗ Failed to register player blueprint: {e}") # Register other blueprints as they're implemented try: diff --git a/backend/python/services/db.py b/backend/python/services/db.py index fe5ea96..5a6cd5f 100644 --- a/backend/python/services/db.py +++ b/backend/python/services/db.py @@ -26,6 +26,8 @@ _mock_inventory: Dict[str, List[Dict]] = {} _mock_combat_sessions: Dict[str, Dict] = {} _mock_entitlements: Dict[str, List[Dict]] = {} +_mock_placements: Dict[str, List[Dict]] = {} # block_id -> placements +_mock_player_heat: Dict[str, int] = {} def _make_id() -> str: @@ -55,11 +57,14 @@ def get_or_create_profile(self, user_id: str) -> Dict: 'id': user_id, 'username': f'player_{user_id[:6]}', 'cash': 10000, + 'heat': 0, 'level': 1, 'xp': 0, 'created_at': _now(), } - return _mock_profiles[user_id] + profile = _mock_profiles[user_id] + profile.setdefault('heat', _mock_player_heat.get(user_id, 0)) + return profile try: result = self._sb.table('profiles').select('*').eq('id', user_id).execute() @@ -79,6 +84,196 @@ def get_or_create_profile(self, user_id: str) -> Dict: logger.error(f"get_or_create_profile failed: {e}") raise + def get_player_state(self, user_id: str) -> Dict: + """Return authoritative cash/heat/level for the player.""" + profile = self.get_or_create_profile(user_id) + return { + 'user_id': user_id, + 'cash': int(profile.get('cash', 0)), + 'heat': int(profile.get('heat', _mock_player_heat.get(user_id, 0))), + 'level': int(profile.get('level', 1)), + 'xp': int(profile.get('xp', 0)), + 'username': profile.get('username', ''), + } + + def deduct_cash(self, user_id: str, amount: int) -> Optional[Dict]: + """Deduct cash if funds allow. Returns updated player state or None.""" + if amount < 0: + raise ValueError('amount must be non-negative') + profile = self.get_or_create_profile(user_id) + cash = int(profile.get('cash', 0)) + if cash < amount: + return None + return self.apply_economy_delta(user_id, cash_delta=-amount, heat_delta=0) + + def apply_economy_delta( + self, + user_id: str, + cash_delta: int = 0, + heat_delta: int = 0, + ) -> Dict: + """Apply cash/heat deltas and return the updated player state.""" + profile = self.get_or_create_profile(user_id) + new_cash = max(0, int(profile.get('cash', 0)) + cash_delta) + new_heat = max(0, min(100, int(profile.get('heat', 0)) + heat_delta)) + + if self._dev_mode: + profile['cash'] = new_cash + profile['heat'] = new_heat + _mock_player_heat[user_id] = new_heat + _mock_profiles[user_id] = profile + return self.get_player_state(user_id) + + try: + self._sb.table('profiles').update({ + 'cash': new_cash, + 'heat': new_heat, + }).eq('id', user_id).execute() + profile['cash'] = new_cash + profile['heat'] = new_heat + return self.get_player_state(user_id) + except Exception as e: + logger.error(f"apply_economy_delta failed: {e}") + raise + + def find_block_by_hash(self, block_hash: str) -> Optional[Dict]: + if self._dev_mode: + for block in _mock_blocks.values(): + if block.get('block_hash') == block_hash: + return block + return None + try: + result = ( + self._sb.table('blocks') + .select('*') + .eq('block_hash', block_hash) + .limit(1) + .execute() + ) + return result.data[0] if result.data else None + except Exception as e: + logger.error(f"find_block_by_hash failed: {e}") + return None + + def save_placements(self, block_id: str, placements: List[Dict]) -> List[Dict]: + """Replace placements for a block and bump live revision.""" + normalized = list(placements) + if self._dev_mode: + _mock_placements[block_id] = normalized + if block_id in _mock_blocks: + block = _mock_blocks[block_id] + block['placements'] = normalized + block['live_revision'] = int(block.get('live_revision', 0)) + 1 + block['pending_income'] = int(block.get('pending_income', 0)) + income = sum(int(p.get('incomePerTick') or p.get('income_per_tick') or 0) for p in normalized) + block['income_per_tick'] = income + return normalized + + try: + self._sb.table('block_placements').delete().eq('block_id', block_id).execute() + rows = [] + for p in normalized: + rows.append({ + 'block_id': block_id, + 'member_id': p.get('memberId') or p.get('member_id'), + 'role': p.get('role'), + 'x': p.get('gridX', p.get('x')), + 'y': p.get('gridY', p.get('y')), + 'anchor_id': p.get('anchorId') or p.get('anchor_id'), + 'health': p.get('health', 100), + 'income_per_tick': p.get('incomePerTick') or p.get('income_per_tick') or 0, + 'payload': p, + }) + if rows: + self._sb.table('block_placements').insert(rows).execute() + block = self.get_block(block_id) + if block: + rev = int(block.get('live_revision', 0)) + 1 + self._sb.table('blocks').update({ + 'live_revision': rev, + 'income_per_tick': sum(int(r.get('income_per_tick') or 0) for r in rows), + }).eq('id', block_id).execute() + return normalized + except Exception as e: + logger.error(f"save_placements failed: {e}") + raise + + def get_placements(self, block_id: str) -> List[Dict]: + if self._dev_mode: + if block_id in _mock_placements: + return list(_mock_placements[block_id]) + block = _mock_blocks.get(block_id) or {} + return list(block.get('placements') or []) + try: + result = ( + self._sb.table('block_placements') + .select('*') + .eq('block_id', block_id) + .execute() + ) + rows = result.data or [] + out = [] + for row in rows: + payload = row.get('payload') or {} + out.append({ + **payload, + 'memberId': row.get('member_id') or payload.get('memberId'), + 'role': row.get('role') or payload.get('role'), + 'gridX': row.get('x'), + 'gridY': row.get('y'), + 'anchorId': row.get('anchor_id') or payload.get('anchorId'), + 'health': row.get('health', 100), + 'incomePerTick': row.get('income_per_tick', 0), + }) + return out + except Exception as e: + logger.error(f"get_placements failed: {e}") + return [] + + def collect_block_income(self, user_id: str, block_id: str) -> Optional[Dict]: + """Move pending_income to player cash. Returns {collected, player, block}.""" + block = self.get_block(block_id) + if not block or block.get('owner_id') != user_id: + return None + pending = int(block.get('pending_income') or 0) + if pending <= 0: + player = self.get_player_state(user_id) + return {'collected': 0, 'player': player, 'block': block} + + if self._dev_mode: + block['pending_income'] = 0 + _mock_blocks[block_id] = block + player = self.apply_economy_delta(user_id, cash_delta=pending, heat_delta=0) + return {'collected': pending, 'player': player, 'block': block} + + try: + self._sb.table('blocks').update({'pending_income': 0}).eq('id', block_id).execute() + player = self.apply_economy_delta(user_id, cash_delta=pending, heat_delta=0) + block['pending_income'] = 0 + return {'collected': pending, 'player': player, 'block': block} + except Exception as e: + logger.error(f"collect_block_income failed: {e}") + raise + + def tick_block_income(self, block_id: str) -> Optional[Dict]: + """Add income_per_tick into pending_income (world/earn step).""" + block = self.get_block(block_id) + if not block: + return None + income = int(block.get('income_per_tick') or block.get('income_per_hour') or 0) + pending = int(block.get('pending_income') or 0) + income + if self._dev_mode: + block['pending_income'] = pending + _mock_blocks[block_id] = block + return block + try: + self._sb.table('blocks').update({'pending_income': pending}).eq('id', block_id).execute() + block['pending_income'] = pending + return block + except Exception as e: + logger.error(f"tick_block_income failed: {e}") + raise + # ─── Paid access entitlements ───────────────────────────────────────── def get_active_entitlements(self, user_id: str) -> List[Dict]: @@ -127,9 +322,13 @@ def claim_block( gang_name: str = '', grid_data: Optional[Dict] = None, traffic_score: float = 0.5, + block_hash: str = '', + scene_manifest: Optional[Dict] = None, + heat_level: int = 0, ) -> Dict: """Claim a block for a user. Returns the created block record.""" block_id = _make_id() + scene_version = f"scene-{block_id[:8]}-v1" block = { 'id': block_id, 'owner_id': user_id, @@ -145,12 +344,20 @@ def claim_block( 'grid_data': grid_data or {}, 'traffic_score': traffic_score, 'income_per_hour': traffic_score * 10, - 'heat_level': 0, + 'income_per_tick': 0, + 'pending_income': 0, + 'heat_level': heat_level, + 'block_hash': block_hash, + 'scene_version': scene_version, + 'scene_manifest': scene_manifest or {}, + 'live_revision': 1, + 'placements': [], 'claimed_at': _now(), } if self._dev_mode: _mock_blocks[block_id] = block + _mock_placements[block_id] = [] return block try: diff --git a/backend/python/tests/test_gate_0b_vertical_slice.py b/backend/python/tests/test_gate_0b_vertical_slice.py new file mode 100644 index 0000000..deab4de --- /dev/null +++ b/backend/python/tests/test_gate_0b_vertical_slice.py @@ -0,0 +1,110 @@ +"""Integration tests for Gate 0B claim → place → earn → collect → reload.""" + +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from app import create_app +from services.db import _mock_blocks, _mock_placements, _mock_profiles, _mock_player_heat + + +@pytest.fixture +def app(): + application = create_app() + application.config['TESTING'] = True + application.config['SUPABASE_URL'] = None + application.config['SUPABASE_SERVICE_ROLE_KEY'] = None + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def auth_headers(): + return {'Authorization': 'Bearer dev-token'} + + +def _clear_mocks(): + _mock_blocks.clear() + _mock_placements.clear() + _mock_profiles.clear() + _mock_player_heat.clear() + + +def test_claim_place_earn_collect_reload(client, auth_headers): + _clear_mocks() + + claim = client.post('/api/blocks/claim', json={ + 'address': '100 NE 1st Ave, Miami, FL', + 'coordinates': {'lat': 25.7617, 'lng': -80.1918}, + 'city': 'miami', + 'gangName': 'Test Crew', + }, headers=auth_headers) + assert claim.status_code == 201, claim.get_json() + body = claim.get_json() + block = body['block'] + block_id = block['id'] + assert body['player']['cash'] == 5000 # 10000 - 5000 + assert body['player']['heat'] == 5 + assert block['sceneVersion'] + assert block.get('sceneManifest') or True + + place = client.post(f'/api/blocks/{block_id}/members/place', json={ + 'placements': [{ + 'memberId': 'dealer-1', + 'memberName': 'Dez', + 'role': 'dealer', + 'gridX': 2, + 'gridY': 3, + 'zoneType': 'storefront', + 'incomePerTick': 40, + 'health': 100, + }], + }, headers=auth_headers) + assert place.status_code == 200, place.get_json() + assert place.get_json()['incomePerTick'] == 40 + + tick = client.post(f'/api/blocks/{block_id}/tick-income', headers=auth_headers) + assert tick.status_code == 200 + assert tick.get_json()['block']['pendingIncome'] == 40 + + collect = client.post(f'/api/blocks/{block_id}/collect', headers=auth_headers) + assert collect.status_code == 200 + collected = collect.get_json() + assert collected['collected'] == 40 + assert collected['player']['cash'] == 5040 + + # Refresh / reload path + mine = client.get('/api/blocks/my-blocks', headers=auth_headers) + assert mine.status_code == 200 + blocks = mine.get_json()['blocks'] + assert len(blocks) == 1 + assert blocks[0]['id'] == block_id + assert len(blocks[0]['placements']) == 1 + assert blocks[0]['placements'][0]['memberId'] == 'dealer-1' + + state = client.get('/api/player/state', headers=auth_headers) + assert state.status_code == 200 + assert state.get_json()['player']['cash'] == 5040 + assert state.get_json()['player']['heat'] == 5 + + +def test_claim_rejects_insufficient_funds(client, auth_headers): + _clear_mocks() + from services.db import get_db + db = get_db() + db.apply_economy_delta('dev-user-001', cash_delta=-6000) # leave 4000 + + claim = client.post('/api/blocks/claim', json={ + 'address': '200 NE 1st Ave, Miami, FL', + 'lat': 25.7620, + 'lng': -80.1920, + 'city': 'miami', + }, headers=auth_headers) + assert claim.status_code == 400 + assert claim.get_json()['reason'] == 'insufficient_funds' From 49aecc1147dd08211ac634ab4b550d659b4e6888 Mon Sep 17 00:00:00 2001 From: Steven Wessel Date: Fri, 17 Jul 2026 13:47:55 -0400 Subject: [PATCH 4/4] feat: wire MAP claim and empire hydrate to Flask Gate 0B APIs TerritoryMap claims through blocksApi, placements/collect sync to the server, and useEmpireHydration reloads cash and blocks after refresh. Co-authored-by: Cursor --- .../plans/slide-phase-0a-reconciliation.md | 266 ++++++++++++++++++ docs/GATE_0B_REPORT.md | 83 ++++++ frontend/src/App.tsx | 2 + frontend/src/components/map/BlockModeView.tsx | 43 ++- .../src/components/map/ClaimBlockModal.tsx | 20 +- frontend/src/components/map/TerritoryMap.tsx | 155 +++++----- frontend/src/hooks/useEmpireHydration.ts | 47 ++++ frontend/src/services/api.service.ts | 78 ++++- frontend/src/stores/blockStore.ts | 34 ++- frontend/src/utils/blockMappers.ts | 73 +++++ 10 files changed, 687 insertions(+), 114 deletions(-) create mode 100644 .cursor/plans/slide-phase-0a-reconciliation.md create mode 100644 docs/GATE_0B_REPORT.md create mode 100644 frontend/src/hooks/useEmpireHydration.ts create mode 100644 frontend/src/utils/blockMappers.ts diff --git a/.cursor/plans/slide-phase-0a-reconciliation.md b/.cursor/plans/slide-phase-0a-reconciliation.md new file mode 100644 index 0000000..86f2b6b --- /dev/null +++ b/.cursor/plans/slide-phase-0a-reconciliation.md @@ -0,0 +1,266 @@ +# SLIDE Phase 0A — Repository and Contract Reconciliation + +**Prepared:** July 17, 2026 +**Mode:** Local Agent only — planning gate; **no feature code until this plan is approved** +**Repo truth:** `/Users/bdmacbook/Documents/slide` → `https://github.com/BrandDead/slide.git` +**Not truth alone:** `/Users/bdmacbook/Documents/slide-main-tL2525` (git-less snapshot; missing Las Olas / readiness commits) + +--- + +## 1. Recommended integration base and merge strategy + +### Decision + +| Choice | Value | +|--------|--------| +| **Integration base** | `origin/agent/mvp-readiness-2026-07-16` | +| **Feature branch (after approval)** | `feat/mvp-vertical-slice` created from that tip | +| **Default branch policy** | Never commit directly to `main-tL2525`; land via PR | +| **Deferred branch** | `origin/agent/las-olas-graphics-destruction` — **do not merge separately** | + +### Why mvp-readiness (not main, not las-olas alone) + +Verified after `git fetch origin --prune` (2026-07-17): + +| Fact | Evidence | +|------|----------| +| Both agent branches fork from current `origin/main-tL2525` tip | Merge-base = `7b7df1d` (Bip N Dip / #74) | +| Las Olas **does** exist in-repo | On both agent branches; **absent** from `main-tL2525` | +| Graphics trees are **byte-identical** between agent branches | SHA256 match for V3 renderer, `lasOlas1208Scene.ts`, Impact/Ragdoll engines, thin `CanvasStreetRenderer` shim | +| mvp-readiness is a **strict functional superset** of las-olas | Same 6 graphics commits (reapplied SHAs) **plus** `3557d2a` paid-MVP foundation | + +**Preserve from mvp-readiness (do not recreate):** + +- `frontend/src/components/slide/CanvasStreetRendererV3.tsx` +- `frontend/src/config/lasOlas1208Scene.ts` +- `frontend/src/components/dev/ShooterGraphicsLab.tsx` +- `frontend/src/utils/ImpactEngine.ts`, `RagdollEngine.ts` +- `docs/SHOOTER_GRAPHICS_QA.md`, `docs/GRAPHICS_UPGRADE_1208_LAS_OLAS.md` +- `docs/MVP_STATUS_AND_DEV_PLAN_2026-07-16.md`, `docs/MVP_2026_SCOPE.md` +- Age gate (`AgeGate.tsx` + tests), identity hydration (`authPlayer.ts` + tests) +- Entitlement foundation (`004_paid_entitlements.sql`, `api/entitlements.py`, read-only `/api/entitlements/me`) + +**Intentionally defer from readiness branch into later gates:** + +- Checkout / webhook / paid lock UI (Gate 4 / post-alpha-loop) +- Broad payment product config +- Any production secret usage + +### Exact merge/cherry-pick strategy (after approval) + +```text +1. cd /Users/bdmacbook/Documents/slide # NOT slide-main-tL2525 +2. git fetch origin --prune +3. git checkout -B feat/mvp-vertical-slice origin/agent/mvp-readiness-2026-07-16 +4. # Optional safety check: confirm local main is not needed + git log --oneline origin/main-tL2525..HEAD # expect readiness commits +5. Fast-forward or merge origin/main-tL2525 ONLY if main advances with unique commits + after this plan’s freeze point; resolve conflicts preserving V3 + age gate + entitlements. +6. Do not cherry-pick las-olas commits (content already present). +7. Tag baseline: git tag -a phase-0a-baseline -m "Pre Gate 0B baseline" +``` + +**Local machine note:** Current checkout `main-tL2525` was **25 commits behind** `origin/main-tL2525` before this audit. Always sync from remotes; do not treat an old local tip as base. + +**Workspace note:** Cursor agent root should remain `/Users/bdmacbook/Documents/slide`. The `slide-main-tL2525` folder is an incomplete export and caused the false “Las Olas not in repo” conclusion. + +--- + +## 2. Baseline health commands + +Run from a **clean** tree on the integration tip (after checkout in §1). Document pass/fail in the gate report before Gate 0B. + +### Frontend (`frontend/`) + +| Check | Command | Notes | +|-------|---------|--------| +| Install | `npm ci` | Prefer lockfile-clean install | +| Unit tests | `npx vitest run` | `package.json` `"test"` is still a stub echo; vitest is a dep and tests exist on readiness | +| Typecheck | `npm run typecheck` | `tsc --noEmit` | +| Lint | `npm run lint` | Expect pre-existing warnings; treat *errors* as blockers | +| Production build | `npm run build` | Prefer build+preview for Mapbox (see AGENTS gotcha) | +| Visual smoke | `npm run preview -- --port 3000` then open ShooterGraphicsLab per docs | Query/flag path in `docs/SHOOTER_GRAPHICS_QA.md` | + +### Backend (`backend/python/`) + +| Check | Command | Notes | +|-------|---------|--------| +| Venv | `python3 -m venv venv && ./venv/bin/pip install -r requirements.txt` | If missing | +| Tests | `./venv/bin/python -m pytest` | **Blank** `SUPABASE_URL` / `DATABASE_URL` in `.env` for mock mode | +| Smoke API | `./venv/bin/python app.py` then `curl localhost:5000/api/health` (or documented health route) | Dev `DEV_USER` auth bypass | + +### Local full-stack smoke + +1. Backend mock mode (blank Supabase server env). +2. Frontend with local Supabase **or** documented staging anon keys (never commit). +3. Path: AgeGate → Auth → onboarding shell → open MAP or ShooterGraphicsLab. +4. Confirm no blank white screen from mapbox-gl default-export interop (`AGENTS.md` GOTCHA 1). + +### Acceptance for Phase 0A baseline + +- Frontend: vitest green on readiness suite; typecheck clean; production build succeeds. +- Backend: pytest green in blank-Supabase mock mode (readiness claimed 37; re-verify and record count). +- No secrets in Git (`git secrets` / manual review of staged files). + +--- + +## 3. Runtime authority map (one page) + +**Working rule:** Geometry decides collision / placement / combat facts. AI pixels decide atmosphere only. (Schemas introduced in Gate 0B; Overture ingest in Gate 3.) + +```mermaid +flowchart TB + subgraph client [React_OSShell] + AgeGate[AgeGate] + Auth[SupabaseAuth] + Zustand[Zustand_localUX] + BlockSync[useBlockSync] + CombatUI[CombatUI_V3_or_Phaser] + end + subgraph api [Flask_MVP_authority] + DBAdapter[DBAdapter] + BlocksAPI["/api/blocks"] + CombatAPI["/api/combat_driveby"] + EntAPI["/api/entitlements/me"] + WorldAPI["/api/world"] + end + subgraph data [Persistence] + Mock[(InMemory_when_no_Supabase)] + SB[(Supabase_Postgres_RLS)] + end + AgeGate --> Auth + Auth --> Zustand + Auth --> BlockSync + BlockSync --> SB + CombatUI --> CombatAPI + BlocksAPI --> DBAdapter + CombatAPI --> DBAdapter + EntAPI --> DBAdapter + DBAdapter --> Mock + DBAdapter --> SB +``` + +| Domain | Authoritative path for MVP | Explicit non-authority (do not grow) | +|--------|----------------------------|--------------------------------------| +| **Identity** | Supabase Auth session → `authPlayer` hydration into player profile | Orphan local profiles that ignore account switch | +| **Claim** | Flask `POST /api/blocks/claim` via `DBAdapter` (+ Edge only if documented as temporary twin) | TerritoryMap-only `$2k` local claim; dual costs | +| **Placement** | Server-backed placements (API or documented Supabase table with RLS) keyed toward future **anchor IDs** | Pixel-only client positions without revision | +| **Economy** | Server-validated money/heat/inventory for the vertical slice; Zustand as cache | Market catalog that never hits API | +| **Combat result** | Flask combat/driveby session + persisted consequences; V3/Las Olas as **presentation** until Phaser parity | Client-only win that mutates Zustand without API | +| **Entitlement** | DB entitlements + read-only `/api/entitlements/me`; grants only via future server webhooks | Browser-trusted “I paid” flags | +| **Scene geometry (later)** | Immutable `BlockSceneManifest` version | AI plate pixels, Street View cache as gameplay | + +--- + +## 4. Environment variable classification + +Update `.env.example` files to match this table during Gate 0B setup (no real values in Git). + +### Browser-safe (`frontend/.env` — `VITE_*` only) + +| Variable | Required for | Notes | +|----------|--------------|--------| +| `VITE_SUPABASE_URL` | Auth UI | App throws if missing (`supabase.ts`) | +| `VITE_SUPABASE_ANON_KEY` | Auth UI | Anon only | +| `VITE_API_URL` | Flask calls | e.g. `http://localhost:5000` | +| `VITE_MAPBOX_ACCESS_TOKEN` | Map / geocode | **Canonical name**; code also reads `VITE_MAPBOX_TOKEN` in places — Gate 0B must unify | +| `VITE_ENV` | Feature flags | `development` / `staging` | +| `VITE_SOCKET_URL` | Optional | Unused until Realtime/Socket decision; keep optional | + +### Server-only (`backend/python/.env` — never `VITE_`) + +| Variable | Required for | Notes | +|----------|--------------|--------| +| `SUPABASE_URL` | Live DB | **Leave blank** for pytest/mock | +| `SUPABASE_SERVICE_ROLE_KEY` | Server writes | Staging/prod only; never frontend | +| `SUPABASE_ANON_KEY` | Optional server | Prefer service role server-side | +| `DATABASE_URL` | Alternate PG | Blank in mock mode | +| `MAPBOX_ACCESS_TOKEN` | Server geocode / static | Server token | +| `SECRET_KEY` | Flask sessions/JWT | Local random OK | +| `CORS_ORIGINS` | Dev CORS | localhost ports | +| `HOST` / `PORT` | Bind | Default `5000` | + +### Staging-only (Cursor Cloud / CI secrets — not committed) + +| Variable | Purpose | +|----------|---------| +| Staging Supabase URL + anon + service role | E2E against non-prod project | +| Staging Mapbox token | Geocode/static with usage caps | +| Image-provider staging key | Gate 3 worker only; rate-limited | +| Payment processor **test** keys | Gate 4 only | + +### Optional / feature flags + +| Variable | Default | Notes | +|----------|---------|--------| +| `GOOGLE_MAPS_API_KEY` | empty | Do not enable Street View caching for combat art | +| `ENABLE_STREET_VIEW` | `false` | Keep false for MVP combat plates | +| `ENABLE_3D_TILES` | `false` | Deferred | +| `ENABLE_WORLD_TICK` | `true` | NPC scheduler later | + +**Policy:** No production payment keys, no production image keys, no service-role in frontend, no unrestricted Street View prefetch into object storage for gameplay. + +--- + +## 5. Risks, rollback, acceptance criteria + +### Risks + +| Risk | Mitigation | +|------|------------| +| Working in git-less `slide-main-tL2525` and “losing” Las Olas again | Agent root = `/Users/bdmacbook/Documents/slide`; document in AGENTS | +| Divergent local main (behind remote) | Always branch from fetched remote tips | +| Recreating V3 / age gate / entitlements | Diff against mvp-readiness before any rewrite | +| Dual claim/economy paths grow during Gate 0B | Runtime map above is binding; delete or wrap orphans | +| Mapbox blank screen in `npm run dev` | Prefer build+preview until `optimizeDeps.include` fix lands | +| `npm test` stub hides real failures | Use `npx vitest run`; fix script in Gate 0B | +| Cloud Agent before env.json/secrets | **Local-first**; cloud only after `.cursor/environment.json` + staging secrets | + +### Rollback + +1. Tag `phase-0a-baseline` on approved integration tip before Gate 0B commits. +2. Any bad Gate 0B commit: `git revert` or reset feature branch to tag (unpushed only); if pushed, revert PR. +3. Never force-push `main-tL2525`. +4. Keep `CanvasStreetRendererV3` until Phaser fixed-scenario parity (Gate 1 protocol). + +### Phase 0A acceptance (stop here — wait for human approval) + +- [ ] Written decision: base = `agent/mvp-readiness-2026-07-16`; las-olas deferred as duplicate content. +- [ ] Baseline commands listed and (after approval) executed with recorded results. +- [ ] Runtime authority table agreed (Flask + DBAdapter for MVP slice). +- [ ] Env classification documented; examples contain placeholders only. +- [ ] Feature branch name reserved: `feat/mvp-vertical-slice`. +- [ ] Product defaults locked for later gates (below). +- [ ] **No feature implementation commits yet.** + +### Product defaults (human-confirmed via review doc) + +| Decision | Default | +|----------|---------| +| First playable location | Pre-approved commercial/sample South Florida; Las Olas fixture as visual inspiration | +| Address policy | Commercial/sample first; residential → fictionalized district alias + stylized geometry; never publish private street as attack target | +| Combat feel | Arcade drive-by 45–75s; obvious civilians; cover; reload/escape | +| Cameras | Side-scroll drive-by + tactical top-down | +| Art bar | Stylized 2.5D neon South Florida; not photoreal | +| Audience | Closed adult-only alpha; solo + NPC | +| Monetization | Access + cosmetics/founder recognition only after alpha loop; Cursor owns payment engineering | + +--- + +## 6. What happens after approval (not started yet) + +| Gate | Objective | +|------|-----------| +| **0B** | One claim→deploy→earn→refresh path; typed `BlockSceneManifest` / `LiveBlockState` / `AttackSnapshot` contracts + tests | +| **1** | Phaser pilot + fixed NPC fixture; keep V3 until parity; first Manus combat kit via signed asset contract | +| **2** | NPC ownership / retaliation world loop | +| **3** | 80 m geometry pilot + constrained AI plates; privacy-safe location | +| **4** | AttackSnapshot fairness, telemetry, fallbacks, staging entitlements | + +Cloud Agents: only after install/test commands + staging secrets + committed `.cursor/environment.json`. + +--- + +## Stop + +**Awaiting approval of this Phase 0A plan.** No Gate 0B code, no merges, no secret commits until you say proceed. diff --git a/docs/GATE_0B_REPORT.md b/docs/GATE_0B_REPORT.md new file mode 100644 index 0000000..a5f11f5 --- /dev/null +++ b/docs/GATE_0B_REPORT.md @@ -0,0 +1,83 @@ +# SLIDE Gate Report — Phase 0A Baseline + Gate 0B + +**Branch:** `feat/mvp-vertical-slice` +**Base:** `origin/agent/mvp-readiness-2026-07-16` @ `3557d2a` +**Tag:** `phase-0a-baseline` +**Date:** 2026-07-17 +**Mode:** Local Agent only + +--- + +## Phase 0A — Baseline health + +| Check | Command | Result | +|-------|---------|--------| +| Checkout | `git checkout -B feat/mvp-vertical-slice origin/agent/mvp-readiness-2026-07-16` | Pass — tip `3557d2a` | +| Frontend install | `npm ci` | Pass | +| Frontend tests | `npx vitest run` / `npm test` | **25 passed** (was 22; +3 contract tests) | +| Typecheck | `npm run typecheck` | Pass | +| Production build | `npm run build` | Pass | +| Backend deps | `pip install -r requirements.txt` | Pass after Pillow pin (`>=11,<12` for Python 3.9) | +| Backend tests | `./venv/bin/python -m pytest` | **42 passed** (was 37 after auth fix; +3 contracts +2 vertical-slice) | + +### Baseline blockers fixed before Gate 0B + +1. **Python 3.9 `dict | None`** in `middleware/auth.py` prevented all blueprints from registering (404s). Fixed with `from __future__ import annotations` + `Optional[dict]`. +2. **Pillow==12.1.1** unavailable on Python 3.9 — relaxed to `Pillow>=11.0.0,<12`. + +--- + +## Gate 0B — Claim-to-save vertical slice + +### Definition of done + +> Sign in → claim → deploy → earn → refresh → same state + +**Proven by:** `backend/python/tests/test_gate_0b_vertical_slice.py` +Flow: claim ($5000) → place dealer → tick income → collect → `GET /my-blocks` + `GET /player/state` retain placements and cash. + +### What changed + +| Area | Change | +|------|--------| +| **One claim cost** | `CLAIM_BLOCK_COST = 5000` in `frontend/src/config/gameEconomy.ts` + `backend/python/config/game_constants.py` | +| **Flask authority** | Rewrote `api/blocks.py` claim/my-blocks/get/nearby/city onto `DBAdapter` (no SQLAlchemy) | +| **Economy** | `DBAdapter.get_player_state / apply_economy_delta / deduct_cash`; `GET /api/player/state` | +| **Placement** | `POST /api/blocks//members/place` | +| **Earn** | `POST .../tick-income`, `POST .../collect` | +| **Contracts** | `BlockSceneManifest`, `LiveBlockState`, `AttackSnapshot` (TS + Python) + unit tests | +| **Frontend** | TerritoryMap claims via `blocksApi`; blockStore syncs placements; BlockModeView collect hits API; `useEmpireHydration` reloads player+blocks | + +### Evidence commands + +```bash +cd backend/python && ./venv/bin/python -m pytest -q +# 42 passed + +cd frontend && npm test && npm run typecheck && npm run build +# 25 passed, typecheck clean, build OK +``` + +### Manual smoke (local) + +1. Start backend mock: `cd backend/python && ./venv/bin/python app.py` (blank `SUPABASE_URL`) +2. Frontend: `npm run build && npm run preview -- --port 3000` (Mapbox gotcha) +3. Age gate → auth → MAP hood → claim eligible Miami sample → deploy dealer → collect after tick → refresh → cash/placements persist via hydration + +### Risks / rollback + +- Mock DBAdapter placements are in-memory — Flask restart clears them (expected in blank-Supabase mode). +- Supabase live path for `block_placements` columns may need a follow-up migration if table schema differs from adapter payload. +- Rollback: `git reset --hard phase-0a-baseline` on this feature branch (unpushed) or revert commits. + +### Explicitly not started + +- Gate 1 Phaser / NPC fixture +- Overture ingest / AI plates +- Production payments / webhooks + +--- + +## Next gate + +**Gate 1** — Unified Phaser combat pilot + fixed NPC defender fixture; keep `CanvasStreetRendererV3` until parity. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e96b64..4925a0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { useGameLoop } from './utils/gameLoopEngine'; import { useHeatDecay } from './hooks/useHeatDecay'; import { useRaidCheck } from './hooks/useRaidCheck'; import { useBlockSync } from './hooks/useBlockSync'; +import { useEmpireHydration } from './hooks/useEmpireHydration'; import { useSoundManager } from './hooks/useSoundManager'; import { useSalarySystem } from './hooks/useSalarySystem'; import PayrollModal from './components/economy/PayrollModal'; @@ -111,6 +112,7 @@ const App: React.FC = () => { useHeatDecay(); const { raidBlockId, clearRaid } = useRaidCheck(); useBlockSync(); + useEmpireHydration(Boolean(authUser) && authChecked); useSoundManager(); const salarySystem = useSalarySystem(); useNPCRetaliation(); diff --git a/frontend/src/components/map/BlockModeView.tsx b/frontend/src/components/map/BlockModeView.tsx index bf44463..6086a17 100644 --- a/frontend/src/components/map/BlockModeView.tsx +++ b/frontend/src/components/map/BlockModeView.tsx @@ -158,21 +158,38 @@ const BlockModeView: React.FC = ({ const block = selectedBlockId ? (blocks[selectedBlockId] as BlockData | undefined) : undefined; const activeEvent = selectedBlockId ? activeDriveBys[selectedBlockId] : undefined; - const handleCollect = useCallback(() => { + const handleCollect = useCallback(async () => { if (!selectedBlockId || !block) return; - const amount = collectIncome(selectedBlockId); - if (amount > 0) { - // Route collected income to Shoebox (bankBalance) - updatePlayer({ bankBalance: (player.bankBalance ?? 0) + amount }); - showToast(`💰 Collected $${amount}!`); - soundManager.play('cash_register'); - // Tutorial: first income collected - const reward = completeStep('first_income_collected'); - if (reward.cashReward > 0) updateMoney(reward.cashReward); - } else { - showToast('No income to collect yet.'); + try { + const { blocksApi } = await import('../../services/api.service'); + const result = await blocksApi.collect(selectedBlockId); + if (result.collected > 0) { + updatePlayer({ + money: result.player.cash, + heat: result.player.heat, + bankBalance: (player.bankBalance ?? 0) + result.collected, + }); + // Mirror pendingIncome locally + collectIncome(selectedBlockId); + showToast(`💰 Collected $${result.collected}!`); + soundManager.play('cash_register'); + const reward = completeStep('first_income_collected'); + if (reward.cashReward > 0) updateMoney(reward.cashReward); + } else { + showToast('No income to collect yet.'); + } + } catch { + // Fallback to local collect if backend offline + const amount = collectIncome(selectedBlockId); + if (amount > 0) { + updatePlayer({ bankBalance: (player.bankBalance ?? 0) + amount }); + showToast(`💰 Collected $${amount}! (local)`); + soundManager.play('cash_register'); + } else { + showToast('No income to collect yet.'); + } } - }, [selectedBlockId, block, collectIncome, updateMoney, showToast, completeStep]); + }, [selectedBlockId, block, collectIncome, updateMoney, updatePlayer, player.bankBalance, showToast, completeStep]); const handleDeploy = useCallback( (memberId: string, memberName: string, role: string, level: number) => { diff --git a/frontend/src/components/map/ClaimBlockModal.tsx b/frontend/src/components/map/ClaimBlockModal.tsx index e343ae7..3dbebf0 100644 --- a/frontend/src/components/map/ClaimBlockModal.tsx +++ b/frontend/src/components/map/ClaimBlockModal.tsx @@ -6,27 +6,25 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useBlockClaim } from '../../hooks/useBlockClaim'; import { usePlayerStore } from '../../stores/gameStore'; +import { CLAIM_BLOCK_COST } from '../../config/gameEconomy'; import './ClaimBlockModal.css'; interface ClaimBlockModalProps { onClose: () => void; } -const CLAIM_COST = 5000; - const ClaimBlockModal: React.FC = ({ onClose }) => { - const { player, updateMoney } = usePlayerStore(); + const { player } = usePlayerStore(); const [{ searchQuery, suggestions, isSearching, preview, isClaiming, claimError, claimedBlock, selectedLocation }, { setSearchQuery, selectSuggestion, claimBlock, reset }] = useBlockClaim(player.id ?? ''); const [showSuccess, setShowSuccess] = useState(false); - const canAfford = player.money >= CLAIM_COST; + const canAfford = player.money >= CLAIM_BLOCK_COST; const handleClaim = async () => { if (!canAfford) return; - await claimBlock(player.gangName ?? 'My Gang'); - if (!claimError) { - updateMoney(-CLAIM_COST); + const result = await claimBlock(player.gangName ?? 'My Gang'); + if (result.success) { setShowSuccess(true); } }; @@ -63,7 +61,7 @@ const ClaimBlockModal: React.FC = ({ onClose }) => {

Block Claimed!

{(claimedBlock as any).address}

-

-${CLAIM_COST.toLocaleString()} deducted

+

-${CLAIM_BLOCK_COST.toLocaleString()} deducted

) : ( @@ -122,7 +120,7 @@ const ClaimBlockModal: React.FC = ({ onClose }) => {
Claim Cost - ${CLAIM_COST.toLocaleString()} + ${CLAIM_BLOCK_COST.toLocaleString()}
@@ -130,7 +128,7 @@ const ClaimBlockModal: React.FC = ({ onClose }) => { {!canAfford && (
- Insufficient funds. Need ${CLAIM_COST.toLocaleString()}, have ${player.money.toLocaleString()} + Insufficient funds. Need ${CLAIM_BLOCK_COST.toLocaleString()}, have ${player.money.toLocaleString()}
)} @@ -139,7 +137,7 @@ const ClaimBlockModal: React.FC = ({ onClose }) => { onClick={handleClaim} disabled={!canAfford || isClaiming} > - {isClaiming ? 'CLAIMING...' : `CLAIM THIS BLOCK ($${CLAIM_COST.toLocaleString()})`} + {isClaiming ? 'CLAIMING...' : `CLAIM THIS BLOCK ($${CLAIM_BLOCK_COST.toLocaleString()})`} )} diff --git a/frontend/src/components/map/TerritoryMap.tsx b/frontend/src/components/map/TerritoryMap.tsx index b6f8119..812eaae 100644 --- a/frontend/src/components/map/TerritoryMap.tsx +++ b/frontend/src/components/map/TerritoryMap.tsx @@ -52,7 +52,7 @@ const ROLE_COLORS: Record = { // ─── Component ─────────────────────────────────────────────── const TerritoryMap: React.FC = () => { const { goBack } = useNavigationStore(); - const { player, updateMoney, updateHeat } = usePlayerStore(); + const { player, updatePlayer } = usePlayerStore(); const { members } = useGangStore(); const { blocks, selectedBlockId, selectBlock, upsertBlock } = useBlockStore(); @@ -79,85 +79,88 @@ const TerritoryMap: React.FC = () => { setTimeout(() => setNotification(null), ms); }, []); - // ── Shared claim logic ────────────────────────────────────── - const claimBlock = useCallback(( - id: string, + // ── Shared claim logic (Gate 0B Flask authority + local DNA/satellite) ── + const claimBlock = useCallback(async ( + _id: string, address: string, lat: number, lng: number, ) => { - if ((player?.money || 0) < 2000) { - notify('Not enough cash! Need $2,000 to claim.'); + const { CLAIM_BLOCK_COST } = await import('../../config/gameEconomy'); + if ((player?.money || 0) < CLAIM_BLOCK_COST) { + notify(`Not enough cash! Need $${CLAIM_BLOCK_COST.toLocaleString()} to claim.`); return; } - updateMoney(-2000); - updateHeat(5); - - // Resolve the block DNA archetype from the real address - const resolved = resolveBlockDNA(lat, lng, address); - - // Build the Mapbox satellite image URL for the block background - const satelliteUrl = buildStaticImageUrl({ - coordinates: { lat, lng }, - zoom: 18, - width: 512, - height: 512, - style: 'satellite-streets-v12', - highRes: true, - }); - // Update the hood overlay - setClaimedBlocks(prev => { - const exists = prev.some(b => b.id === id); - if (exists) { - return prev.map(b => b.id === id - ? { ...b, owner: 'player' as const, income: Math.round(100 * resolved.incomeMultiplier) } - : b - ); - } - return [...prev, { - id, + try { + const { blocksApi } = await import('../../services/api.service'); + const { apiBlockToBlockData } = await import('../../utils/blockMappers'); + const result = await blocksApi.claim({ address, - lat, - lng, - owner: 'player' as const, - income: Math.round(100 * resolved.incomeMultiplier), - heat: resolved.startingHeat, - members: 0, - }]; - }); - - // Seed a block store entry with DNA-resolved values - const { generateDefaultGrid } = useBlockStore.getState(); - upsertBlock({ - id, - address, - lat, - lng, - owner: 'player', - grid: generateDefaultGrid(), - placements: [], - incomePerTick: 0, - heat: resolved.startingHeat, - morale: resolved.startingMorale, - members: 0, - viewMode: 'topdown', - pendingIncome: 0, - topdownBgUrl: satelliteUrl, - }); - - selectBlock(id); - setSelectedMapBlock(null); - setPendingClaim(null); - setShowBlockSearch(false); - setView('block'); - - notify( - `🏴 Claimed ${address}! (${resolved.dna.name} · -$2,000)`, - 3500, - ); - completeTutorialStep('first_block_claimed'); - }, [player, updateMoney, updateHeat, upsertBlock, selectBlock, notify, completeTutorialStep]); + coordinates: { lat, lng }, + city: 'miami', + gangName: player?.gangName || 'Crew', + }); + + updatePlayer({ + money: result.player.cash, + heat: result.player.heat, + }); + + const live = apiBlockToBlockData(result.block as Record); + const resolved = resolveBlockDNA(lat, lng, address); + const satelliteUrl = buildStaticImageUrl({ + coordinates: { lat, lng }, + zoom: 18, + width: 512, + height: 512, + style: 'satellite-streets-v12', + highRes: true, + }); + + upsertBlock({ + ...live, + heat: live.heat || resolved.startingHeat, + morale: resolved.startingMorale, + topdownBgUrl: satelliteUrl, + }); + selectBlock(live.id); + + setClaimedBlocks((prev) => { + const without = prev.filter( + (b) => b.id !== _id && b.id !== live.id && b.address !== address, + ); + return [ + ...without, + { + id: live.id, + address: live.address, + lat: live.lat, + lng: live.lng, + owner: 'player' as const, + income: Math.round(100 * resolved.incomeMultiplier), + heat: live.heat || resolved.startingHeat, + members: live.members, + }, + ]; + }); + + setSelectedMapBlock(null); + setPendingClaim(null); + setShowBlockSearch(false); + setView('block'); + notify( + `🏴 Claimed ${live.address}! (${resolved.dna.name} · -$${result.claimCost.toLocaleString()})`, + 3500, + ); + completeTutorialStep('first_block_claimed'); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + 'Claim failed — is the backend running?'; + notify(message, 3500); + } + }, [player, updatePlayer, upsertBlock, selectBlock, notify, completeTutorialStep]); // ── Hood view handlers ────────────────────────────────────── const handleMapLoad = useCallback((map: any) => setMapInstance(map), []); @@ -186,17 +189,15 @@ const TerritoryMap: React.FC = () => { }, []); const handleClaimBlock = useCallback((blockData: MapBlockData) => { - claimBlock(blockData.id, blockData.address, blockData.lat, blockData.lng); + void claimBlock(blockData.id, blockData.address, blockData.lat, blockData.lng); }, [claimBlock]); // ── Block-view address search ─────────────────────────────── const handleBlockSearchResult = useCallback((result: AddressResult) => { const id = `claimed-${result.placeId || Date.now()}`; setPendingClaim(result); - // Show confirm prompt inline setShowBlockSearch(false); - // Auto-claim after confirmation (we show a confirm banner) - claimBlock(id, result.address, result.lat, result.lng); + void claimBlock(id, result.address, result.lat, result.lng); }, [claimBlock]); // ── Derive active block ID for BlockModeView ── @@ -347,7 +348,7 @@ const TerritoryMap: React.FC = () => { block={selectedMapBlock} onClose={() => setSelectedMapBlock(null)} onCollectIncome={(b) => { - updateMoney(b.income || 0); + updatePlayer({ money: (player?.money || 0) + (b.income || 0) }); notify(`💰 Collected $${b.income} from ${b.address}`); setSelectedMapBlock(null); }} diff --git a/frontend/src/hooks/useEmpireHydration.ts b/frontend/src/hooks/useEmpireHydration.ts new file mode 100644 index 0000000..62a5fc4 --- /dev/null +++ b/frontend/src/hooks/useEmpireHydration.ts @@ -0,0 +1,47 @@ +/** + * Hydrate player cash/heat + owned blocks from Flask (Gate 0B authority). + */ + +import { useEffect, useRef } from 'react'; +import { blocksApi, playerApi } from '../services/api.service'; +import { usePlayerStore } from '../stores/gameStore'; +import { useBlockStore } from '../stores/blockStore'; +import { apiBlockToBlockData } from '../utils/blockMappers'; + +export function useEmpireHydration(enabled: boolean) { + const ran = useRef(false); + const updatePlayer = usePlayerStore((s) => s.updatePlayer); + const upsertBlock = useBlockStore((s) => s.upsertBlock); + + useEffect(() => { + if (!enabled || ran.current) return; + ran.current = true; + + let cancelled = false; + (async () => { + try { + const [player, owned] = await Promise.all([ + playerApi.getState(), + blocksApi.getOwned(), + ]); + if (cancelled) return; + updatePlayer({ + money: player.cash, + heat: player.heat, + level: player.level ?? 1, + xp: player.xp ?? 0, + }); + for (const raw of owned.blocks || []) { + upsertBlock(apiBlockToBlockData(raw as Record)); + } + } catch (err) { + // Backend may be offline in pure-UI sessions — keep local cache. + console.warn('[useEmpireHydration] skipped:', err); + } + })(); + + return () => { + cancelled = true; + }; + }, [enabled, updatePlayer, upsertBlock]); +} diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index a937983..b49be5c 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -152,17 +152,34 @@ export const blocksApi = { }, /** POST /api/blocks/claim */ - claim: async (data: { block_hash: string; gang_name: string }) => { - const response = await apiClient.post<{ success: boolean; block: Block }>( - '/blocks/claim', - data, + claim: async (data: { + address: string; + coordinates?: { lat: number; lng: number }; + lat?: number; + lng?: number; + city?: string; + gangName?: string; + }) => { + const response = await apiClient.post<{ + success: boolean; + block: Record; + player: { user_id: string; cash: number; heat: number; level: number; xp: number }; + claimCost: number; + }>('/blocks/claim', data); + return response.data; + }, + + /** GET /api/blocks/my-blocks */ + getOwned: async () => { + const response = await apiClient.get<{ blocks: Record[]; count: number }>( + '/blocks/my-blocks', ); return response.data; }, /** GET /api/blocks/availability/ */ checkAvailability: async (blockHash: string) => { - const response = await apiClient.get<{ available: boolean; owner?: string }>( + const response = await apiClient.get<{ available: boolean; isAvailable?: boolean; owner?: string }>( `/blocks/availability/${blockHash}`, ); return response.data; @@ -170,14 +187,38 @@ export const blocksApi = { /** GET /api/blocks/ */ getById: async (blockId: string) => { - const response = await apiClient.get<{ block: Block }>(`/blocks/${blockId}`); - return response.data.block; + const response = await apiClient.get>(`/blocks/${blockId}`); + return response.data; }, - /** GET /api/blocks/my-blocks */ - getOwned: async () => { - const response = await apiClient.get<{ blocks: Block[] }>('/blocks/my-blocks'); - return response.data.blocks; + /** POST /api/blocks/:id/members/place */ + placeMembers: async (blockId: string, placements: Record[]) => { + const response = await apiClient.post<{ + success: boolean; + placements: Record[]; + liveRevision: number; + incomePerTick: number; + }>(`/blocks/${blockId}/members/place`, { placements }); + return response.data; + }, + + /** POST /api/blocks/:id/tick-income */ + tickIncome: async (blockId: string) => { + const response = await apiClient.post<{ success: boolean; block: Record }>( + `/blocks/${blockId}/tick-income`, + ); + return response.data; + }, + + /** POST /api/blocks/:id/collect */ + collect: async (blockId: string) => { + const response = await apiClient.post<{ + success: boolean; + collected: number; + player: { cash: number; heat: number }; + block: Record; + }>(`/blocks/${blockId}/collect`); + return response.data; }, /** GET /api/blocks/nearby?lat=&lng=&radius= */ @@ -505,6 +546,20 @@ export const worldApi = { }, }; +// ───────────────────────────────────────────────────────────────────────────── +// PLAYER STATE (Gate 0B) +// ───────────────────────────────────────────────────────────────────────────── + +export const playerApi = { + /** GET /api/player/state */ + getState: async () => { + const response = await apiClient.get<{ + player: { user_id: string; cash: number; heat: number; level: number; xp: number; username: string }; + }>('/player/state'); + return response.data.player; + }, +}; + // ───────────────────────────────────────────────────────────────────────────── // EXPORT ALL // ───────────────────────────────────────────────────────────────────────────── @@ -518,6 +573,7 @@ export const api = { gang: gangApi, alchemy: alchemyApi, world: worldApi, + player: playerApi, }; export default api; diff --git a/frontend/src/stores/blockStore.ts b/frontend/src/stores/blockStore.ts index 37d867e..611912f 100644 --- a/frontend/src/stores/blockStore.ts +++ b/frontend/src/stores/blockStore.ts @@ -97,7 +97,7 @@ export const useBlockStore = create()( getBlock: (blockId) => get().blocks[blockId], - placeMember: (blockId, placement) => + placeMember: (blockId, placement) => { set((state) => { const block = state.blocks[blockId]; if (!block) return state; @@ -140,7 +140,37 @@ export const useBlockStore = create()( }, }, }; - }), + }); + + // Gate 0B — persist placements to Flask (best-effort) + void (async () => { + try { + const { blocksApi } = await import('../services/api.service'); + const { placementsToApiPayload } = await import('../utils/blockMappers'); + const latest = get().blocks[blockId]; + if (!latest) return; + const result = await blocksApi.placeMembers( + blockId, + placementsToApiPayload(latest.placements), + ); + set((state) => { + const b = state.blocks[blockId]; + if (!b) return state; + return { + blocks: { + ...state.blocks, + [blockId]: { + ...b, + incomePerTick: result.incomePerTick ?? b.incomePerTick, + }, + }, + }; + }); + } catch (err) { + console.warn('[blockStore] place sync skipped:', err); + } + })(); + }, removeMemberFromBlock: (blockId, memberId) => set((state) => { diff --git a/frontend/src/utils/blockMappers.ts b/frontend/src/utils/blockMappers.ts new file mode 100644 index 0000000..b773544 --- /dev/null +++ b/frontend/src/utils/blockMappers.ts @@ -0,0 +1,73 @@ +/** + * Gate 0B — map Flask claim/my-blocks payloads into local BlockData. + */ + +import type { BlockData, BlockPlacement, BlockZone } from '../types/block.types'; +import { useBlockStore } from '../stores/blockStore'; +import { gridCellToAnchorId } from '../types/contracts/blockScene.types'; + +export function apiBlockToBlockData(raw: Record): BlockData { + const generateDefaultGrid = useBlockStore.getState().generateDefaultGrid; + const coords = (raw.coordinates as { lat?: number; lng?: number } | undefined) || {}; + const placementsRaw = (raw.placements as Record[] | undefined) || []; + + const placements: BlockPlacement[] = placementsRaw.map((p) => { + const x = Number(p.gridX ?? p.x ?? 0); + const y = Number(p.gridY ?? p.y ?? 0); + return { + memberId: String(p.memberId ?? p.member_id ?? ''), + memberName: String(p.memberName ?? p.member_name ?? 'Member'), + role: (p.role as BlockPlacement['role']) || 'dealer', + x, + y, + zoneType: (p.zoneType as BlockPlacement['zoneType']) || 'sidewalk', + incomePerTick: Number(p.incomePerTick ?? p.income_per_tick ?? 0), + exposureRisk: Number(p.exposureRisk ?? 50), + level: Number(p.level ?? 1), + health: Number(p.health ?? 100), + portraitUrl: (p.portraitUrl as string | undefined), + topdownUrl: (p.topdownUrl as string | undefined), + }; + }); + + const grid: BlockZone[][] = generateDefaultGrid(); + for (const p of placements) { + if (grid[p.y]?.[p.x]) { + grid[p.y][p.x].occupantId = p.memberId; + } + } + + return { + id: String(raw.id), + address: String(raw.address ?? 'Unknown'), + lat: Number(coords.lat ?? raw.lat ?? 0), + lng: Number(coords.lng ?? raw.lng ?? 0), + owner: 'player', + grid, + placements, + incomePerTick: Number(raw.incomePerTick ?? 0), + heat: Number(raw.heatLevel ?? raw.heat ?? 0), + morale: 80, + members: placements.length, + viewMode: 'topdown', + pendingIncome: Number(raw.pendingIncome ?? 0), + }; +} + +export function placementsToApiPayload(placements: BlockPlacement[]) { + return placements.map((p) => ({ + memberId: p.memberId, + memberName: p.memberName, + role: p.role, + gridX: p.x, + gridY: p.y, + x: p.x, + y: p.y, + zoneType: p.zoneType, + incomePerTick: p.incomePerTick, + exposureRisk: p.exposureRisk, + level: p.level, + health: p.health, + anchorId: gridCellToAnchorId(p.x, p.y), + })); +}