From d2e4009b8921c499d321ed208248b02162a0d693 Mon Sep 17 00:00:00 2001 From: jdgrgjfhjw <50918275-jdgrgjfhjw@users.noreply.replit.com> Date: Mon, 6 Apr 2026 07:07:17 +0000 Subject: [PATCH 1/2] Add a strategy tab to the state page with a fully functional game hub Integrates the new game module into the application, including backend API endpoints, database models, and frontend components for a strategy game experience. Replit-Commit-Author: Agent Replit-Commit-Session-Id: fc5ab706-ba5a-47b0-93fe-bb5a9b81860a Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 63ecc6b1-a47c-42c5-b102-b4aab894938c Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/84da491d-3c20-4e7e-a6b3-7e801fb4d509/fc5ab706-ba5a-47b0-93fe-bb5a9b81860a/uxPVUxd Replit-Helium-Checkpoint-Created: true --- .replit | 55 ++ backend/db/models.py | 92 ++- backend/main.py | 26 +- backend/model/game_config.py | 767 ++++++++++++++++++ backend/model/map_gen.py | 251 ++++++ backend/routers/game.py | 733 +++++++++++++++++ frontend/package-lock.json | 118 ++- frontend/package.json | 4 +- frontend/src/app/state/page.js | 8 +- frontend/src/components/game/CityBuilder.js | 342 ++++++++ frontend/src/components/game/Diplomacy.js | 183 +++++ frontend/src/components/game/GameHub.js | 180 ++++ .../src/components/game/MilitaryCommand.js | 289 +++++++ frontend/src/components/game/TechTree.js | 224 +++++ frontend/src/components/game/WorldMap.js | 372 +++++++++ frontend/src/lib/game-api.js | 36 + replit.md | 57 ++ 17 files changed, 3712 insertions(+), 25 deletions(-) create mode 100644 .replit create mode 100644 backend/model/game_config.py create mode 100644 backend/model/map_gen.py create mode 100644 backend/routers/game.py create mode 100644 frontend/src/components/game/CityBuilder.js create mode 100644 frontend/src/components/game/Diplomacy.js create mode 100644 frontend/src/components/game/GameHub.js create mode 100644 frontend/src/components/game/MilitaryCommand.js create mode 100644 frontend/src/components/game/TechTree.js create mode 100644 frontend/src/components/game/WorldMap.js create mode 100644 frontend/src/lib/game-api.js create mode 100644 replit.md diff --git a/.replit b/.replit new file mode 100644 index 0000000..82168c4 --- /dev/null +++ b/.replit @@ -0,0 +1,55 @@ +modules = ["nodejs-20", "python-3.12", "web"] + +[nix] +channel = "stable-25_05" + +[agent] +expertMode = true + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Backend API" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Backend API" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "cd backend && python main.py" +waitForPort = 8000 + +[workflows.workflow.metadata] +outputType = "console" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "cd frontend && npm run dev" +waitForPort = 5000 + +[workflows.workflow.metadata] +outputType = "webview" + +[[ports]] +localPort = 5000 +externalPort = 5000 + +[[ports]] +localPort = 8000 +externalPort = 80 diff --git a/backend/db/models.py b/backend/db/models.py index 9c74452..8c04a39 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Boolean +from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Boolean, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -28,22 +28,22 @@ class State(TimestampMixin, Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, nullable=False) - date = Column(String, nullable=False) # Format: YYYY-MM + date = Column(String, nullable=False) flag_svg = Column(String, nullable=False) description = Column(String, nullable=False) turn_in_progress = Column(Boolean, nullable=False, default=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - # Relationships user = relationship("User", back_populates="states") snapshots = relationship("StateSnapshot", back_populates="state") + game_state = relationship("GameState", back_populates="state", uselist=False) class StateSnapshot(TimestampMixin, Base): __tablename__ = "state_snapshots" id = Column(Integer, primary_key=True, index=True) - date = Column(String, nullable=False) # Format: YYYY-MM + date = Column(String, nullable=False) state_id = Column(Integer, ForeignKey("states.id"), nullable=False) markdown_state = Column(String, nullable=False) @@ -52,5 +52,87 @@ class StateSnapshot(TimestampMixin, Base): markdown_delta = Column(String, nullable=True) markdown_delta_report = Column(String, nullable=True) - # Relationship to State state = relationship("State", back_populates="snapshots") + + +class GameState(TimestampMixin, Base): + """Extended game state โ€” world map, resources, turn counter.""" + __tablename__ = "game_states" + + id = Column(Integer, primary_key=True, index=True) + state_id = Column(Integer, ForeignKey("states.id"), unique=True, nullable=False) + map_seed = Column(Integer, nullable=False, default=0) + map_data = Column(JSON, nullable=False, default=dict) + game_turn = Column(Integer, nullable=False, default=0) + gold = Column(Integer, nullable=False, default=5000) + production = Column(Integer, nullable=False, default=100) + + state = relationship("State", back_populates="game_state") + military_units = relationship("MilitaryUnit", back_populates="game_state", cascade="all, delete-orphan") + cities = relationship("GameCity", back_populates="game_state", cascade="all, delete-orphan") + researched_techs = relationship("ResearchedTech", back_populates="game_state", cascade="all, delete-orphan") + diplomacy = relationship("DiplomacyRelation", back_populates="game_state", cascade="all, delete-orphan") + + +class MilitaryUnit(TimestampMixin, Base): + """A military unit on the world map.""" + __tablename__ = "military_units" + + id = Column(Integer, primary_key=True, index=True) + game_state_id = Column(Integer, ForeignKey("game_states.id"), nullable=False) + unit_type = Column(String, nullable=False) + name = Column(String, nullable=True) + tile_col = Column(Integer, nullable=False, default=0) + tile_row = Column(Integer, nullable=False, default=0) + health = Column(Integer, nullable=False, default=100) + moves_remaining = Column(Integer, nullable=False, default=1) + experience = Column(Integer, nullable=False, default=0) + status = Column(String, nullable=False, default="active") + + game_state = relationship("GameState", back_populates="military_units") + + +class GameCity(TimestampMixin, Base): + """A city managed by the player (city builder).""" + __tablename__ = "game_cities" + + id = Column(Integer, primary_key=True, index=True) + game_state_id = Column(Integer, ForeignKey("game_states.id"), nullable=False) + name = Column(String, nullable=False) + tile_col = Column(Integer, nullable=False) + tile_row = Column(Integer, nullable=False) + population = Column(Integer, nullable=False, default=10000) + is_capital = Column(Boolean, nullable=False, default=False) + city_grid = Column(JSON, nullable=False, default=list) + budget = Column(Integer, nullable=False, default=500000) + happiness = Column(Integer, nullable=False, default=50) + + game_state = relationship("GameState", back_populates="cities") + + +class ResearchedTech(TimestampMixin, Base): + """Technology researched by a game state.""" + __tablename__ = "researched_techs" + + id = Column(Integer, primary_key=True, index=True) + game_state_id = Column(Integer, ForeignKey("game_states.id"), nullable=False) + tech_id = Column(String, nullable=False) + progress = Column(Integer, nullable=False, default=0) + researched = Column(Boolean, nullable=False, default=False) + + game_state = relationship("GameState", back_populates="researched_techs") + + +class DiplomacyRelation(TimestampMixin, Base): + """Diplomatic relations between player and AI countries.""" + __tablename__ = "diplomacy_relations" + + id = Column(Integer, primary_key=True, index=True) + game_state_id = Column(Integer, ForeignKey("game_states.id"), nullable=False) + ai_country_id = Column(String, nullable=False) + ai_country_name = Column(String, nullable=False) + relation_score = Column(Integer, nullable=False, default=50) + status = Column(String, nullable=False, default="neutral") + trade_active = Column(Boolean, nullable=False, default=False) + + game_state = relationship("GameState", back_populates="diplomacy") diff --git a/backend/main.py b/backend/main.py index 7da92c2..b014c01 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -7,7 +8,7 @@ from db.database import init_db from tasks.tasks import reset_stuck_states -from routers import auth, states, stripe +from routers import auth, states, stripe, game @asynccontextmanager @@ -31,15 +32,23 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) # Configure CORS +_frontend_url = os.getenv("FRONTEND_URL", "") +_allowed_origins = [ + "http://localhost:3000", + "http://localhost:5000", + "http://localhost:8000", + "https://*.up.railway.app", + "https://*.replit.dev", + "https://*.replit.app", + "https://*.repl.co", +] +if _frontend_url and _frontend_url not in _allowed_origins: + _allowed_origins.append(_frontend_url) + app.add_middleware( CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:8000", - "http://localhost:8080", - "https://*.up.railway.app", - ], + allow_origins=_allowed_origins, + allow_origin_regex=r"https://.*\.(replit\.dev|replit\.app|repl\.co)$", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -48,6 +57,7 @@ async def lifespan(app: FastAPI): app.include_router(auth.router) app.include_router(states.router) app.include_router(stripe.router) +app.include_router(game.router) if __name__ == "__main__": import uvicorn diff --git a/backend/model/game_config.py b/backend/model/game_config.py new file mode 100644 index 0000000..e3c3424 --- /dev/null +++ b/backend/model/game_config.py @@ -0,0 +1,767 @@ +UNIT_TYPES = { + "warrior": { + "name": "Warriors", + "icon": "โš”๏ธ", + "category": "land", + "attack": 2, + "defense": 1, + "moves": 1, + "health": 100, + "cost": 10, + "era": "ancient", + "requires_tech": None, + "requires_building": "barracks", + "description": "Basic melee unit. Cheap and quickly trained.", + }, + "archer": { + "name": "Archers", + "icon": "๐Ÿน", + "category": "land", + "attack": 3, + "defense": 2, + "moves": 1, + "health": 100, + "cost": 15, + "era": "ancient", + "requires_tech": None, + "requires_building": "barracks", + "description": "Ranged unit. Can attack from a distance.", + }, + "knight": { + "name": "Knights", + "icon": "๐Ÿด", + "category": "land", + "attack": 5, + "defense": 3, + "moves": 2, + "health": 100, + "cost": 30, + "era": "medieval", + "requires_tech": "chivalry", + "requires_building": "barracks", + "description": "Fast mounted warrior. High mobility.", + }, + "musketeer": { + "name": "Musketeers", + "icon": "๐Ÿ”ซ", + "category": "land", + "attack": 6, + "defense": 4, + "moves": 1, + "health": 100, + "cost": 35, + "era": "gunpowder", + "requires_tech": "gunpowder", + "requires_building": "barracks", + "description": "Early gunpowder infantry unit.", + }, + "infantry": { + "name": "Infantry", + "icon": "๐Ÿช–", + "category": "land", + "attack": 8, + "defense": 6, + "moves": 1, + "health": 100, + "cost": 40, + "era": "modern", + "requires_tech": "military_science", + "requires_building": "barracks", + "description": "Modern foot soldier. Versatile and reliable.", + }, + "tank": { + "name": "Tank", + "icon": "๐Ÿšœ", + "category": "land", + "attack": 16, + "defense": 8, + "moves": 3, + "health": 100, + "cost": 80, + "era": "modern", + "requires_tech": "mechanized_warfare", + "requires_building": "barracks", + "description": "Armored vehicle with heavy firepower.", + }, + "artillery": { + "name": "Artillery", + "icon": "๐Ÿ’ฅ", + "category": "land", + "attack": 20, + "defense": 4, + "moves": 2, + "health": 100, + "cost": 70, + "era": "modern", + "requires_tech": "artillery_tech", + "requires_building": "barracks", + "description": "Long-range bombardment. Devastating but vulnerable.", + }, + "fighter": { + "name": "Fighter Jet", + "icon": "โœˆ๏ธ", + "category": "air", + "attack": 15, + "defense": 10, + "moves": 9, + "health": 100, + "cost": 120, + "era": "modern", + "requires_tech": "flight", + "requires_building": "airbase", + "description": "Aerial combat jet. High speed and range.", + }, + "bomber": { + "name": "Bomber", + "icon": "๐Ÿ’ฃ", + "category": "air", + "attack": 25, + "defense": 4, + "moves": 8, + "health": 100, + "cost": 150, + "era": "modern", + "requires_tech": "advanced_flight", + "requires_building": "airbase", + "description": "Strategic bomber. Destroys infrastructure and cities.", + }, + "destroyer": { + "name": "Destroyer", + "icon": "โš“", + "category": "naval", + "attack": 12, + "defense": 10, + "moves": 6, + "health": 100, + "cost": 100, + "era": "modern", + "requires_tech": "navigation", + "requires_building": "harbor", + "description": "Fast naval warship. Patrols sea lanes.", + }, + "submarine": { + "name": "Submarine", + "icon": "๐Ÿ”ฑ", + "category": "naval", + "attack": 18, + "defense": 8, + "moves": 5, + "health": 100, + "cost": 130, + "era": "digital", + "requires_tech": "nuclear", + "requires_building": "harbor", + "description": "Stealth underwater weapon. Nuclear-capable.", + }, + "drone": { + "name": "Combat Drone", + "icon": "๐Ÿ›ธ", + "category": "air", + "attack": 14, + "defense": 6, + "moves": 10, + "health": 100, + "cost": 90, + "era": "digital", + "requires_tech": "ai_warfare", + "requires_building": "airbase", + "description": "Autonomous AI-controlled strike drone.", + }, +} + +TECHNOLOGIES = { + "agriculture": { + "name": "Agriculture", + "era": "ancient", + "cost": 5, + "x": 0, + "y": 0, + "description": "Enables organized crop farming and food surplus.", + "requires": [], + "unlocks_units": [], + "unlocks_buildings": ["farm"], + "bonus": "+10% food production", + }, + "writing": { + "name": "Writing", + "era": "ancient", + "cost": 5, + "x": 0, + "y": 2, + "description": "Enables literacy, record keeping, and libraries.", + "requires": [], + "unlocks_units": [], + "unlocks_buildings": ["library"], + "bonus": "+5% research speed", + }, + "bronze_working": { + "name": "Bronze Working", + "era": "ancient", + "cost": 8, + "x": 0, + "y": 4, + "description": "Enables bronze weapons, armor, and basic military.", + "requires": [], + "unlocks_units": ["warrior", "archer"], + "unlocks_buildings": [], + "bonus": "Unlocks Warriors and Archers", + }, + "mathematics": { + "name": "Mathematics", + "era": "ancient", + "cost": 10, + "x": 1, + "y": 2, + "description": "Advanced calculation enables engineering and science.", + "requires": ["writing"], + "unlocks_units": [], + "unlocks_buildings": ["monument"], + "bonus": "+10% construction speed", + }, + "feudalism": { + "name": "Feudalism", + "era": "medieval", + "cost": 15, + "x": 2, + "y": 4, + "description": "Hierarchical land system enables military vassals.", + "requires": ["bronze_working"], + "unlocks_units": [], + "unlocks_buildings": ["castle"], + "bonus": "+3 city defense", + }, + "chivalry": { + "name": "Chivalry", + "era": "medieval", + "cost": 20, + "x": 3, + "y": 4, + "description": "Mounted warfare doctrine enables Knights.", + "requires": ["feudalism"], + "unlocks_units": ["knight"], + "unlocks_buildings": [], + "bonus": "Unlocks Knights", + }, + "currency": { + "name": "Currency", + "era": "medieval", + "cost": 15, + "x": 2, + "y": 0, + "description": "Standardized money enables trade and taxation.", + "requires": ["agriculture", "mathematics"], + "unlocks_units": [], + "unlocks_buildings": ["market"], + "bonus": "+15% tax income", + }, + "philosophy": { + "name": "Philosophy", + "era": "medieval", + "cost": 15, + "x": 2, + "y": 2, + "description": "Rational inquiry enables universities and governance.", + "requires": ["writing", "mathematics"], + "unlocks_units": [], + "unlocks_buildings": ["university"], + "bonus": "+20% research speed", + }, + "gunpowder": { + "name": "Gunpowder", + "era": "gunpowder", + "cost": 30, + "x": 4, + "y": 4, + "description": "Explosive chemistry revolutionizes warfare.", + "requires": ["chivalry", "currency"], + "unlocks_units": ["musketeer"], + "unlocks_buildings": ["barracks"], + "bonus": "Unlocks Musketeers and Barracks", + }, + "navigation": { + "name": "Navigation", + "era": "gunpowder", + "cost": 30, + "x": 4, + "y": 0, + "description": "Ocean navigation enables naval power.", + "requires": ["currency", "philosophy"], + "unlocks_units": ["destroyer"], + "unlocks_buildings": ["harbor"], + "bonus": "Unlocks Destroyers and Harbor", + }, + "printing_press": { + "name": "Printing Press", + "era": "gunpowder", + "cost": 25, + "x": 4, + "y": 2, + "description": "Mass information dissemination transforms society.", + "requires": ["philosophy"], + "unlocks_units": [], + "unlocks_buildings": ["newspaper"], + "bonus": "+10% public approval", + }, + "steam_power": { + "name": "Steam Power", + "era": "industrial", + "cost": 40, + "x": 5, + "y": 3, + "description": "Steam engines enable railways and factories.", + "requires": ["gunpowder", "navigation"], + "unlocks_units": [], + "unlocks_buildings": ["factory", "railway_station"], + "bonus": "+25% production speed", + }, + "banking": { + "name": "Banking", + "era": "industrial", + "cost": 35, + "x": 5, + "y": 1, + "description": "Credit and investment accelerate economic growth.", + "requires": ["currency", "printing_press"], + "unlocks_units": [], + "unlocks_buildings": ["bank"], + "bonus": "+20% economic income", + }, + "industrialization": { + "name": "Industrialization", + "era": "industrial", + "cost": 45, + "x": 6, + "y": 2, + "description": "Mass production transforms the economy.", + "requires": ["steam_power", "banking"], + "unlocks_units": [], + "unlocks_buildings": ["power_plant"], + "bonus": "+30% city production", + }, + "military_science": { + "name": "Military Science", + "era": "modern", + "cost": 50, + "x": 7, + "y": 4, + "description": "Systematic warfare doctrine enables modern armies.", + "requires": ["industrialization"], + "unlocks_units": ["infantry"], + "unlocks_buildings": [], + "bonus": "Unlocks Infantry", + }, + "artillery_tech": { + "name": "Artillery", + "era": "modern", + "cost": 55, + "x": 8, + "y": 4, + "description": "Heavy long-range bombardment weapons.", + "requires": ["military_science"], + "unlocks_units": ["artillery"], + "unlocks_buildings": [], + "bonus": "Unlocks Artillery", + }, + "mechanized_warfare": { + "name": "Mechanized Warfare", + "era": "modern", + "cost": 65, + "x": 9, + "y": 4, + "description": "Armored vehicles and tanks dominate the battlefield.", + "requires": ["military_science", "industrialization"], + "unlocks_units": ["tank"], + "unlocks_buildings": [], + "bonus": "Unlocks Tanks", + }, + "flight": { + "name": "Flight", + "era": "modern", + "cost": 60, + "x": 8, + "y": 2, + "description": "Powered aircraft enable air superiority.", + "requires": ["industrialization", "military_science"], + "unlocks_units": ["fighter"], + "unlocks_buildings": ["airbase"], + "bonus": "Unlocks Fighters and Airbase", + }, + "advanced_flight": { + "name": "Advanced Flight", + "era": "modern", + "cost": 70, + "x": 9, + "y": 2, + "description": "Strategic bombing capability from long-range aircraft.", + "requires": ["flight"], + "unlocks_units": ["bomber"], + "unlocks_buildings": [], + "bonus": "Unlocks Bombers", + }, + "nuclear": { + "name": "Nuclear Power", + "era": "digital", + "cost": 80, + "x": 10, + "y": 3, + "description": "Nuclear fission unlocks immense energy and weapons.", + "requires": ["advanced_flight", "mechanized_warfare"], + "unlocks_units": ["submarine"], + "unlocks_buildings": ["nuclear_plant"], + "bonus": "Unlocks Submarines and Nuclear Plant", + }, + "electronics": { + "name": "Electronics", + "era": "digital", + "cost": 70, + "x": 10, + "y": 1, + "description": "Silicon circuits enable computers and communication.", + "requires": ["flight", "banking"], + "unlocks_units": [], + "unlocks_buildings": ["internet_hub"], + "bonus": "+15% all research speed", + }, + "ai_warfare": { + "name": "AI Warfare", + "era": "digital", + "cost": 100, + "x": 11, + "y": 2, + "description": "Autonomous AI weapons and battlefield management.", + "requires": ["nuclear", "electronics"], + "unlocks_units": ["drone"], + "unlocks_buildings": [], + "bonus": "Unlocks Combat Drones", + }, + "space_program": { + "name": "Space Program", + "era": "digital", + "cost": 120, + "x": 11, + "y": 4, + "description": "Satellite network and space exploration capability.", + "requires": ["nuclear", "electronics"], + "unlocks_units": [], + "unlocks_buildings": ["space_center"], + "bonus": "Unlocks Space Center, +20% all stats", + }, +} + +BUILDING_TYPES = { + "residential_low": { + "name": "Low-Density Housing", + "category": "zone", + "icon": "๐Ÿ ", + "color": "#86efac", + "cost": 50, + "population_capacity": 100, + "happiness": 5, + "income": 10, + "power_demand": 5, + "water_demand": 5, + }, + "residential_mid": { + "name": "Mid-Density Housing", + "category": "zone", + "icon": "๐Ÿ˜๏ธ", + "color": "#4ade80", + "cost": 100, + "population_capacity": 500, + "happiness": 3, + "income": 30, + "power_demand": 15, + "water_demand": 15, + }, + "residential_high": { + "name": "High-Rise Apartments", + "category": "zone", + "icon": "๐Ÿข", + "color": "#16a34a", + "cost": 200, + "population_capacity": 2000, + "happiness": -2, + "income": 80, + "power_demand": 50, + "water_demand": 50, + }, + "commercial": { + "name": "Commercial Zone", + "category": "zone", + "icon": "๐Ÿช", + "color": "#fde68a", + "cost": 100, + "population_capacity": 0, + "happiness": 2, + "income": 100, + "power_demand": 30, + "water_demand": 10, + }, + "industrial": { + "name": "Industrial Zone", + "category": "zone", + "icon": "๐Ÿญ", + "color": "#d1d5db", + "cost": 150, + "population_capacity": 0, + "happiness": -5, + "income": 200, + "power_demand": 100, + "water_demand": 30, + }, + "road": { + "name": "Road", + "category": "infrastructure", + "icon": "๐Ÿ›ฃ๏ธ", + "color": "#6b7280", + "cost": 20, + "population_capacity": 0, + "happiness": 1, + "income": 0, + "power_demand": 0, + "water_demand": 0, + }, + "power_plant_coal": { + "name": "Coal Power Plant", + "category": "power", + "icon": "โšก", + "color": "#374151", + "cost": 300, + "population_capacity": 0, + "happiness": -3, + "income": -50, + "power_supply": 200, + "water_demand": 20, + }, + "power_plant_solar": { + "name": "Solar Farm", + "category": "power", + "icon": "โ˜€๏ธ", + "color": "#fbbf24", + "cost": 500, + "population_capacity": 0, + "happiness": 5, + "income": -20, + "power_supply": 100, + "water_demand": 0, + }, + "power_plant_nuclear": { + "name": "Nuclear Power Plant", + "category": "power", + "icon": "โ˜ข๏ธ", + "color": "#7c3aed", + "cost": 1000, + "population_capacity": 0, + "happiness": -10, + "income": -100, + "power_supply": 1000, + "water_demand": 50, + "requires_tech": "nuclear", + }, + "water_tower": { + "name": "Water Tower", + "category": "infrastructure", + "icon": "๐Ÿ’ง", + "color": "#60a5fa", + "cost": 100, + "population_capacity": 0, + "happiness": 3, + "income": -10, + "water_supply": 500, + "power_demand": 5, + }, + "police_station": { + "name": "Police Station", + "category": "service", + "icon": "๐Ÿ‘ฎ", + "color": "#3b82f6", + "cost": 200, + "population_capacity": 0, + "happiness": 5, + "income": -30, + "crime_reduction": 20, + "power_demand": 10, + }, + "fire_station": { + "name": "Fire Station", + "category": "service", + "icon": "๐Ÿš’", + "color": "#ef4444", + "cost": 150, + "population_capacity": 0, + "happiness": 3, + "income": -20, + "power_demand": 8, + }, + "hospital": { + "name": "Hospital", + "category": "service", + "icon": "๐Ÿฅ", + "color": "#f87171", + "cost": 400, + "population_capacity": 0, + "happiness": 8, + "income": -50, + "health_bonus": 15, + "power_demand": 30, + }, + "school": { + "name": "School", + "category": "service", + "icon": "๐Ÿซ", + "color": "#a78bfa", + "cost": 200, + "population_capacity": 0, + "happiness": 5, + "income": -25, + "education_bonus": 10, + "power_demand": 15, + }, + "park": { + "name": "Park", + "category": "service", + "icon": "๐ŸŒณ", + "color": "#bbf7d0", + "cost": 80, + "population_capacity": 0, + "happiness": 10, + "income": -5, + "power_demand": 2, + }, + "barracks": { + "name": "Military Barracks", + "category": "military", + "icon": "๐Ÿช–", + "color": "#78350f", + "cost": 300, + "population_capacity": 0, + "happiness": -2, + "income": -40, + "enables_units": ["warrior", "archer", "musketeer", "infantry", "knight", "tank", "artillery"], + "power_demand": 20, + }, + "airbase": { + "name": "Air Force Base", + "category": "military", + "icon": "โœˆ๏ธ", + "color": "#1e3a5f", + "cost": 600, + "population_capacity": 0, + "happiness": -3, + "income": -80, + "enables_units": ["fighter", "bomber", "drone"], + "power_demand": 80, + "requires_tech": "flight", + }, + "harbor": { + "name": "Naval Harbor", + "category": "military", + "icon": "โš“", + "color": "#0c4a6e", + "cost": 500, + "population_capacity": 0, + "happiness": 0, + "income": -60, + "enables_units": ["destroyer", "submarine"], + "power_demand": 40, + "requires_tech": "navigation", + }, + "market": { + "name": "Market", + "category": "economy", + "icon": "๐Ÿ›’", + "color": "#f59e0b", + "cost": 150, + "population_capacity": 0, + "happiness": 4, + "income": 80, + "power_demand": 10, + "requires_tech": "currency", + }, + "bank": { + "name": "Bank", + "category": "economy", + "icon": "๐Ÿฆ", + "color": "#d97706", + "cost": 250, + "population_capacity": 0, + "happiness": 2, + "income": 150, + "power_demand": 15, + "requires_tech": "banking", + }, + "university": { + "name": "University", + "category": "education", + "icon": "๐ŸŽ“", + "color": "#7c3aed", + "cost": 350, + "population_capacity": 0, + "happiness": 6, + "income": -40, + "education_bonus": 25, + "power_demand": 25, + "requires_tech": "philosophy", + }, + "factory": { + "name": "Factory", + "category": "production", + "icon": "โš™๏ธ", + "color": "#6b7280", + "cost": 400, + "population_capacity": 0, + "happiness": -4, + "income": 250, + "power_demand": 120, + "requires_tech": "steam_power", + }, + "nuclear_plant": { + "name": "Nuclear Power Plant", + "category": "power", + "icon": "โš›๏ธ", + "color": "#6d28d9", + "cost": 800, + "population_capacity": 0, + "happiness": -8, + "income": -80, + "power_supply": 800, + "requires_tech": "nuclear", + }, + "space_center": { + "name": "Space Center", + "category": "science", + "icon": "๐Ÿš€", + "color": "#1e1b4b", + "cost": 1500, + "population_capacity": 0, + "happiness": 15, + "income": -200, + "power_demand": 200, + "requires_tech": "space_program", + }, +} + +TERRAIN_TYPES = { + "ocean": {"color": "#1e40af", "movement_cost": 99, "defense_bonus": 0, "passable_land": False, "passable_naval": True}, + "coast": {"color": "#3b82f6", "movement_cost": 99, "defense_bonus": 0, "passable_land": False, "passable_naval": True}, + "plains": {"color": "#86efac", "movement_cost": 1, "defense_bonus": 0, "passable_land": True, "passable_naval": False}, + "grassland": {"color": "#4ade80", "movement_cost": 1, "defense_bonus": 0, "passable_land": True, "passable_naval": False}, + "desert": {"color": "#fbbf24", "movement_cost": 2, "defense_bonus": 0, "passable_land": True, "passable_naval": False}, + "forest": {"color": "#166534", "movement_cost": 2, "defense_bonus": 2, "passable_land": True, "passable_naval": False}, + "jungle": {"color": "#14532d", "movement_cost": 3, "defense_bonus": 3, "passable_land": True, "passable_naval": False}, + "mountain": {"color": "#78716c", "movement_cost": 99, "defense_bonus": 5, "passable_land": False, "passable_naval": False}, + "hills": {"color": "#a8a29e", "movement_cost": 2, "defense_bonus": 3, "passable_land": True, "passable_naval": False}, + "tundra": {"color": "#e2e8f0", "movement_cost": 2, "defense_bonus": 1, "passable_land": True, "passable_naval": False}, + "snow": {"color": "#f8fafc", "movement_cost": 3, "defense_bonus": 0, "passable_land": True, "passable_naval": False}, +} + +AI_COUNTRIES = [ + {"name": "Valdoria", "flag_color": "#dc2626", "personality": "aggressive"}, + {"name": "Elyndra", "flag_color": "#2563eb", "personality": "diplomatic"}, + {"name": "Thornheim", "flag_color": "#16a34a", "personality": "expansionist"}, + {"name": "Azureth", "flag_color": "#7c3aed", "personality": "peaceful"}, + {"name": "Drakmoor", "flag_color": "#d97706", "personality": "militaristic"}, + {"name": "Celestara", "flag_color": "#db2777", "personality": "scientific"}, + {"name": "Ironveil", "flag_color": "#475569", "personality": "industrial"}, + {"name": "Sunhaven", "flag_color": "#f59e0b", "personality": "cultural"}, +] diff --git a/backend/model/map_gen.py b/backend/model/map_gen.py new file mode 100644 index 0000000..912841b --- /dev/null +++ b/backend/model/map_gen.py @@ -0,0 +1,251 @@ +import random +import math +from typing import List, Dict, Any, Tuple + + +MAP_COLS = 38 +MAP_ROWS = 24 + +TERRITORY_SIZE = 15 +AI_TERRITORY_SIZE = 10 + + +def _noise(x: float, y: float, seed: int) -> float: + """Simple pseudo-noise function for terrain generation.""" + n = int(x * 1000 + y * 100 + seed) + n = (n >> 13) ^ n + n = (n * (n * n * 60493 + 19990303) + 1376312589) & 0x7FFFFFFF + return n / 0x7FFFFFFF + + +def _smooth_noise(x: float, y: float, seed: int) -> float: + """Smoothed noise by averaging neighbors.""" + corners = ( + _noise(x - 1, y - 1, seed) + _noise(x + 1, y - 1, seed) + + _noise(x - 1, y + 1, seed) + _noise(x + 1, y + 1, seed) + ) / 16.0 + sides = ( + _noise(x - 1, y, seed) + _noise(x + 1, y, seed) + + _noise(x, y - 1, seed) + _noise(x, y + 1, seed) + ) / 8.0 + center = _noise(x, y, seed) / 4.0 + return corners + sides + center + + +def _interpolated_noise(x: float, y: float, seed: int) -> float: + """Interpolated noise for smoother terrain.""" + ix, iy = int(x), int(y) + fx, fy = x - ix, y - iy + v1 = _smooth_noise(ix, iy, seed) + v2 = _smooth_noise(ix + 1, iy, seed) + v3 = _smooth_noise(ix, iy + 1, seed) + v4 = _smooth_noise(ix + 1, iy + 1, seed) + i1 = v1 + fx * (v2 - v1) + i2 = v3 + fx * (v4 - v3) + return i1 + fy * (i2 - i1) + + +def _get_elevation(col: int, row: int, seed: int) -> float: + """Get terrain elevation at a given hex position.""" + x, y = col / MAP_COLS, row / MAP_ROWS + e = 0.0 + e += 1.0 * _interpolated_noise(x * 2, y * 2, seed) + e += 0.5 * _interpolated_noise(x * 4, y * 4, seed + 1) + e += 0.25 * _interpolated_noise(x * 8, y * 8, seed + 2) + e /= 1.75 + # Bias toward land in the center, water at edges + dx = abs(col - MAP_COLS / 2) / (MAP_COLS / 2) + dy = abs(row - MAP_ROWS / 2) / (MAP_ROWS / 2) + edge_bias = max(dx, dy) * 0.4 + e -= edge_bias + return e + + +def _get_moisture(col: int, row: int, seed: int) -> float: + """Get moisture level at a given hex position.""" + x, y = col / MAP_COLS, row / MAP_ROWS + m = _interpolated_noise(x * 3 + 0.5, y * 3 + 0.5, seed + 100) + return m + + +def _elevation_to_terrain(elevation: float, moisture: float) -> str: + """Convert elevation and moisture to a terrain type.""" + if elevation < 0.10: + return "ocean" + elif elevation < 0.18: + return "coast" + elif elevation > 0.75: + return "mountain" + elif elevation > 0.60: + return "hills" + elif elevation < 0.20 and moisture < 0.3: + return "coast" + elif moisture < 0.25: + if elevation > 0.35: + return "hills" + return "desert" + elif moisture < 0.45: + return "plains" + elif moisture < 0.65: + return "grassland" + elif elevation > 0.55: + return "hills" + elif moisture > 0.80: + return "jungle" + else: + return "forest" + + +def _hex_distance(q1: int, r1: int, q2: int, r2: int) -> int: + """Calculate hex grid distance using axial coordinates (offset grid).""" + # Convert offset to axial + ax = q1 - (r1 - (r1 & 1)) // 2 + ar = r1 + bx = q2 - (r2 - (r2 & 1)) // 2 + br = r2 + return max(abs(ax - bx), abs(ar - br), abs((ax + ar) - (bx + br))) + + +def _flood_fill_territory( + start_col: int, + start_row: int, + tiles: List[List[Dict]], + owner: str, + size: int, + rng: random.Random, +) -> List[Tuple[int, int]]: + """Flood-fill territory from a starting position.""" + claimed = [] + queue = [(start_col, start_row)] + visited = set() + visited.add((start_col, start_row)) + + while queue and len(claimed) < size: + rng.shuffle(queue) + col, row = queue.pop(0) + + tile = tiles[row][col] + if tile["terrain"] in ("ocean", "mountain"): + continue + if tile["owner"] is not None: + continue + + tile["owner"] = owner + claimed.append((col, row)) + + neighbors = _get_neighbors(col, row) + for nc, nr in neighbors: + if 0 <= nc < MAP_COLS and 0 <= nr < MAP_ROWS: + if (nc, nr) not in visited: + visited.add((nc, nr)) + queue.append((nc, nr)) + + return claimed + + +def _get_neighbors(col: int, row: int) -> List[Tuple[int, int]]: + """Get hex neighbors for offset grid.""" + if row % 2 == 0: + directions = [(1, 0), (-1, 0), (0, 1), (0, -1), (-1, 1), (-1, -1)] + else: + directions = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1)] + return [(col + dc, row + dr) for dc, dr in directions] + + +def _find_land_start(tiles: List[List[Dict]], preferred_col: int, preferred_row: int) -> Tuple[int, int]: + """Find the nearest land tile to a preferred starting position.""" + best = None + best_dist = float("inf") + for r in range(MAP_ROWS): + for c in range(MAP_COLS): + if tiles[r][c]["terrain"] not in ("ocean", "mountain", "coast") and tiles[r][c]["owner"] is None: + dist = math.sqrt((c - preferred_col) ** 2 + (r - preferred_row) ** 2) + if dist < best_dist: + best_dist = dist + best = (c, r) + return best + + +def generate_map(seed: int, state_name: str, state_id: str) -> Dict[str, Any]: + """ + Generate a world map with terrain, player territory, and AI countries. + Returns a dict with tiles (2D array), countries, and metadata. + """ + rng = random.Random(seed) + + # Generate terrain + tiles: List[List[Dict]] = [] + for row in range(MAP_ROWS): + row_tiles = [] + for col in range(MAP_COLS): + elevation = _get_elevation(col, row, seed) + moisture = _get_moisture(col, row, seed) + terrain = _elevation_to_terrain(elevation, moisture) + row_tiles.append({ + "terrain": terrain, + "owner": None, + "city": None, + "units": [], + "elevation": round(elevation, 3), + }) + tiles.append(row_tiles) + + # Place player territory in a semi-central area + player_start_col = rng.randint(MAP_COLS // 4, 3 * MAP_COLS // 4) + player_start_row = rng.randint(MAP_ROWS // 4, 3 * MAP_ROWS // 4) + start = _find_land_start(tiles, player_start_col, player_start_row) + if start: + player_tiles = _flood_fill_territory(start[0], start[1], tiles, state_id, TERRITORY_SIZE, rng) + capital_tile = player_tiles[0] if player_tiles else (start[0], start[1]) + else: + capital_tile = (player_start_col, player_start_row) + player_tiles = [] + + # Place capital city + tiles[capital_tile[1]][capital_tile[0]]["city"] = { + "name": state_name, + "is_capital": True, + "population": 500000, + } + + # Place AI countries + from model.game_config import AI_COUNTRIES + ai_countries = [] + for i, ai_data in enumerate(AI_COUNTRIES[:6]): + angle = (i / 6) * 2 * math.pi + rng.uniform(-0.3, 0.3) + radius = min(MAP_COLS, MAP_ROWS) * 0.35 + ai_col = int(MAP_COLS / 2 + radius * math.cos(angle)) + ai_row = int(MAP_ROWS / 2 + radius * math.sin(angle)) + ai_col = max(2, min(MAP_COLS - 3, ai_col)) + ai_row = max(2, min(MAP_ROWS - 3, ai_row)) + + ai_start = _find_land_start(tiles, ai_col, ai_row) + if not ai_start: + continue + + ai_id = f"ai_{i}" + ai_tiles = _flood_fill_territory(ai_start[0], ai_start[1], tiles, ai_id, AI_TERRITORY_SIZE, rng) + if ai_tiles: + ai_capital = ai_tiles[0] + tiles[ai_capital[1]][ai_capital[0]]["city"] = { + "name": ai_data["name"], + "is_capital": True, + "population": rng.randint(100000, 800000), + } + ai_countries.append({ + "id": ai_id, + "name": ai_data["name"], + "flag_color": ai_data["flag_color"], + "personality": ai_data["personality"], + "capital": {"col": ai_capital[0], "row": ai_capital[1]}, + "relations": {"player": rng.randint(20, 80)}, + }) + + return { + "tiles": tiles, + "cols": MAP_COLS, + "rows": MAP_ROWS, + "player_capital": {"col": capital_tile[0], "row": capital_tile[1]}, + "ai_countries": ai_countries, + "seed": seed, + } diff --git a/backend/routers/game.py b/backend/routers/game.py new file mode 100644 index 0000000..90ced36 --- /dev/null +++ b/backend/routers/game.py @@ -0,0 +1,733 @@ +import random +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from pydantic import BaseModel + +from db.database import get_db +from db.models import State, GameState, MilitaryUnit, GameCity, ResearchedTech, DiplomacyRelation +from routers.auth import get_current_user_from_token +from db.models import User +from model.game_config import UNIT_TYPES, TECHNOLOGIES, BUILDING_TYPES, AI_COUNTRIES +from model.map_gen import generate_map + +router = APIRouter(prefix="/api/game", tags=["game"]) + +CITY_GRID_SIZE = 20 + + +def _get_or_create_game_state(state_id: int, state_name: str, db: Session) -> GameState: + """Get or create a GameState for a given state_id.""" + gs = db.query(GameState).filter(GameState.state_id == state_id).first() + if gs: + return gs + + seed = random.randint(1, 999999) + map_data = generate_map(seed, state_name, str(state_id)) + + gs = GameState( + state_id=state_id, + map_seed=seed, + map_data=map_data, + gold=5000, + production=100, + game_turn=0, + ) + db.add(gs) + db.flush() + + # Create capital city on the player's capital tile + capital = map_data.get("player_capital", {"col": 5, "row": 5}) + grid = [[None for _ in range(CITY_GRID_SIZE)] for _ in range(CITY_GRID_SIZE)] + grid[CITY_GRID_SIZE // 2][CITY_GRID_SIZE // 2] = {"type": "residential_mid", "built_at": 0} + grid[CITY_GRID_SIZE // 2 - 1][CITY_GRID_SIZE // 2] = {"type": "commercial", "built_at": 0} + grid[CITY_GRID_SIZE // 2][CITY_GRID_SIZE // 2 + 1] = {"type": "road", "built_at": 0} + grid[CITY_GRID_SIZE // 2 + 1][CITY_GRID_SIZE // 2] = {"type": "power_plant_coal", "built_at": 0} + + capital_city = GameCity( + game_state_id=gs.id, + name=state_name, + tile_col=capital["col"], + tile_row=capital["row"], + population=500000, + is_capital=True, + city_grid=grid, + budget=1000000, + happiness=55, + ) + db.add(capital_city) + + # Create starting military units near capital + for unit_type in ["warrior", "archer"]: + unit_cfg = UNIT_TYPES[unit_type] + unit = MilitaryUnit( + game_state_id=gs.id, + unit_type=unit_type, + name=f"{unit_cfg['name']} I", + tile_col=capital["col"] + random.randint(-1, 1), + tile_row=capital["row"] + random.randint(-1, 1), + health=100, + moves_remaining=unit_cfg["moves"], + experience=0, + status="active", + ) + db.add(unit) + + # Seed starting technologies + for tech_id in ["agriculture", "writing", "bronze_working"]: + tech = ResearchedTech( + game_state_id=gs.id, + tech_id=tech_id, + progress=100, + researched=True, + ) + db.add(tech) + + # Create diplomacy relations with AI countries + for ai_data in map_data.get("ai_countries", []): + rel = DiplomacyRelation( + game_state_id=gs.id, + ai_country_id=ai_data["id"], + ai_country_name=ai_data["name"], + relation_score=ai_data.get("relations", {}).get("player", 50), + status="neutral", + trade_active=False, + ) + db.add(rel) + + db.commit() + db.refresh(gs) + return gs + + +def _compute_city_stats(city: GameCity) -> dict: + """Compute city stats from the grid.""" + grid = city.city_grid or [] + total_population = city.population + happiness = city.happiness + income = 0 + power_supply = 0 + power_demand = 0 + water_supply = 0 + water_demand = 0 + buildings_count = {} + + for row in grid: + for cell in row: + if not cell: + continue + btype = cell.get("type") + if not btype or btype not in BUILDING_TYPES: + continue + cfg = BUILDING_TYPES[btype] + buildings_count[btype] = buildings_count.get(btype, 0) + 1 + income += cfg.get("income", 0) + happiness += cfg.get("happiness", 0) + power_supply += cfg.get("power_supply", 0) + power_demand += cfg.get("power_demand", 0) + water_supply += cfg.get("water_supply", 0) + water_demand += cfg.get("water_demand", 0) + total_population += cfg.get("population_capacity", 0) + + return { + "population": total_population, + "happiness": min(100, max(0, happiness)), + "income": income, + "power_supply": power_supply, + "power_demand": power_demand, + "power_ok": power_supply >= power_demand, + "water_supply": water_supply, + "water_demand": water_demand, + "water_ok": water_supply >= water_demand, + "buildings": buildings_count, + } + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # +# SCHEMAS # +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # + +class MoveUnitRequest(BaseModel): + unit_id: int + to_col: int + to_row: int + + +class TrainUnitRequest(BaseModel): + unit_type: str + city_id: int + + +class BuildRequest(BaseModel): + city_id: int + row: int + col: int + building_type: str + + +class DemolishRequest(BaseModel): + city_id: int + row: int + col: int + + +class ResearchRequest(BaseModel): + tech_id: str + + +class DiplomacyRequest(BaseModel): + ai_country_id: str + action: str + + +class AttackRequest(BaseModel): + unit_id: int + target_col: int + target_row: int + + +class EndTurnRequest(BaseModel): + pass + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # +# ENDPOINTS # +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # + +@router.get("/{state_id}") +async def get_game_state( + state_id: int, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + + units = [ + { + "id": u.id, + "unit_type": u.unit_type, + "name": u.name, + "tile_col": u.tile_col, + "tile_row": u.tile_row, + "health": u.health, + "moves_remaining": u.moves_remaining, + "experience": u.experience, + "status": u.status, + **UNIT_TYPES.get(u.unit_type, {}), + } + for u in gs.military_units + ] + + cities = [] + for c in gs.cities: + stats = _compute_city_stats(c) + cities.append({ + "id": c.id, + "name": c.name, + "tile_col": c.tile_col, + "tile_row": c.tile_row, + "population": stats["population"], + "is_capital": c.is_capital, + "happiness": stats["happiness"], + "income": stats["income"], + "budget": c.budget, + "city_grid": c.city_grid, + "power_ok": stats["power_ok"], + "water_ok": stats["water_ok"], + }) + + researched = {t.tech_id: {"progress": t.progress, "researched": t.researched} for t in gs.researched_techs} + diplomacy = [ + { + "id": d.id, + "ai_country_id": d.ai_country_id, + "ai_country_name": d.ai_country_name, + "relation_score": d.relation_score, + "status": d.status, + "trade_active": d.trade_active, + } + for d in gs.diplomacy + ] + + return { + "game_state_id": gs.id, + "state_id": state_id, + "state_name": state.name, + "gold": gs.gold, + "production": gs.production, + "game_turn": gs.game_turn, + "map_data": gs.map_data, + "military_units": units, + "cities": cities, + "researched_techs": researched, + "diplomacy": diplomacy, + "config": { + "unit_types": UNIT_TYPES, + "technologies": TECHNOLOGIES, + "building_types": BUILDING_TYPES, + }, + } + + +@router.post("/{state_id}/unit/train") +async def train_unit( + state_id: int, + request: TrainUnitRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + + if request.unit_type not in UNIT_TYPES: + raise HTTPException(status_code=400, detail="Unknown unit type") + + unit_cfg = UNIT_TYPES[request.unit_type] + cost = unit_cfg["cost"] + + if gs.gold < cost: + raise HTTPException(status_code=400, detail=f"Not enough gold. Need {cost}, have {gs.gold}.") + + required_tech = unit_cfg.get("requires_tech") + if required_tech: + tech_record = next((t for t in gs.researched_techs if t.tech_id == required_tech and t.researched), None) + if not tech_record: + raise HTTPException(status_code=400, detail=f"Requires technology: {TECHNOLOGIES.get(required_tech, {}).get('name', required_tech)}") + + city = db.query(GameCity).filter(GameCity.id == request.city_id, GameCity.game_state_id == gs.id).first() + if not city: + raise HTTPException(status_code=404, detail="City not found") + + required_building = unit_cfg.get("requires_building") + if required_building: + has_building = False + for row_cells in (city.city_grid or []): + for cell in row_cells: + if cell and cell.get("type") == required_building: + has_building = True + break + if not has_building: + bname = BUILDING_TYPES.get(required_building, {}).get("name", required_building) + raise HTTPException(status_code=400, detail=f"Requires building: {bname}") + + gs.gold -= cost + unit_number = len(gs.military_units) + 1 + unit = MilitaryUnit( + game_state_id=gs.id, + unit_type=request.unit_type, + name=f"{unit_cfg['name']} {unit_number}", + tile_col=city.tile_col, + tile_row=city.tile_row, + health=100, + moves_remaining=unit_cfg["moves"], + experience=0, + status="active", + ) + db.add(unit) + db.commit() + db.refresh(unit) + + return { + "unit": { + "id": unit.id, + "unit_type": unit.unit_type, + "name": unit.name, + "tile_col": unit.tile_col, + "tile_row": unit.tile_row, + "health": unit.health, + "moves_remaining": unit.moves_remaining, + **unit_cfg, + }, + "gold": gs.gold, + } + + +@router.post("/{state_id}/unit/move") +async def move_unit( + state_id: int, + request: MoveUnitRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + unit = db.query(MilitaryUnit).filter(MilitaryUnit.id == request.unit_id, MilitaryUnit.game_state_id == gs.id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + if unit.moves_remaining <= 0: + raise HTTPException(status_code=400, detail="Unit has no moves remaining this turn") + + map_tiles = gs.map_data.get("tiles", []) + map_rows = len(map_tiles) + map_cols = len(map_tiles[0]) if map_rows > 0 else 0 + + if not (0 <= request.to_row < map_rows and 0 <= request.to_col < map_cols): + raise HTTPException(status_code=400, detail="Target position out of map bounds") + + target_tile = map_tiles[request.to_row][request.to_col] + terrain = target_tile.get("terrain", "plains") + unit_cfg = UNIT_TYPES.get(unit.unit_type, {}) + category = unit_cfg.get("category", "land") + + from model.game_config import TERRAIN_TYPES + terrain_cfg = TERRAIN_TYPES.get(terrain, {}) + if category == "land" and not terrain_cfg.get("passable_land", True): + raise HTTPException(status_code=400, detail=f"Land units cannot enter {terrain} terrain") + if category == "naval" and not terrain_cfg.get("passable_naval", False): + raise HTTPException(status_code=400, detail=f"Naval units can only move in water") + + unit.tile_col = request.to_col + unit.tile_row = request.to_row + unit.moves_remaining = max(0, unit.moves_remaining - 1) + db.commit() + + return {"unit_id": unit.id, "tile_col": unit.tile_col, "tile_row": unit.tile_row, "moves_remaining": unit.moves_remaining} + + +@router.post("/{state_id}/unit/attack") +async def attack_unit( + state_id: int, + request: AttackRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + unit = db.query(MilitaryUnit).filter(MilitaryUnit.id == request.unit_id, MilitaryUnit.game_state_id == gs.id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + if unit.moves_remaining <= 0: + raise HTTPException(status_code=400, detail="Unit cannot attack โ€” no moves remaining") + + attacker_cfg = UNIT_TYPES.get(unit.unit_type, {}) + attack_power = attacker_cfg.get("attack", 5) + + rng = random.Random() + damage_dealt = int(attack_power * rng.uniform(0.7, 1.3)) + damage_taken = int(attacker_cfg.get("defense", 3) * rng.uniform(0.3, 0.7)) + + unit.health = max(0, unit.health - damage_taken) + unit.moves_remaining = 0 + unit.experience = min(100, unit.experience + 5) + + if unit.health <= 0: + unit.status = "destroyed" + + db.commit() + + return { + "attacker_id": unit.id, + "attacker_health": unit.health, + "attacker_status": unit.status, + "damage_dealt": damage_dealt, + "damage_taken": damage_taken, + "message": f"Your {unit.name} attacked at ({request.target_col}, {request.target_row}), dealing {damage_dealt} damage and taking {damage_taken}.", + } + + +@router.delete("/{state_id}/unit/{unit_id}") +async def disband_unit( + state_id: int, + unit_id: int, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + unit = db.query(MilitaryUnit).filter(MilitaryUnit.id == unit_id, MilitaryUnit.game_state_id == gs.id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + db.delete(unit) + db.commit() + return {"success": True} + + +@router.post("/{state_id}/city/build") +async def build_in_city( + state_id: int, + request: BuildRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + city = db.query(GameCity).filter(GameCity.id == request.city_id, GameCity.game_state_id == gs.id).first() + if not city: + raise HTTPException(status_code=404, detail="City not found") + + if request.building_type not in BUILDING_TYPES: + raise HTTPException(status_code=400, detail="Unknown building type") + + btype_cfg = BUILDING_TYPES[request.building_type] + cost = btype_cfg["cost"] + + required_tech = btype_cfg.get("requires_tech") + if required_tech: + tech_record = next((t for t in gs.researched_techs if t.tech_id == required_tech and t.researched), None) + if not tech_record: + raise HTTPException(status_code=400, detail=f"Requires technology: {TECHNOLOGIES.get(required_tech, {}).get('name', required_tech)}") + + if gs.gold < cost: + raise HTTPException(status_code=400, detail=f"Not enough gold. Need {cost}, have {gs.gold}.") + + if not (0 <= request.row < CITY_GRID_SIZE and 0 <= request.col < CITY_GRID_SIZE): + raise HTTPException(status_code=400, detail="Invalid grid position") + + grid = [list(row) for row in (city.city_grid or [[None] * CITY_GRID_SIZE for _ in range(CITY_GRID_SIZE)])] + + if len(grid) < CITY_GRID_SIZE: + while len(grid) < CITY_GRID_SIZE: + grid.append([None] * CITY_GRID_SIZE) + + for r in range(len(grid)): + if len(grid[r]) < CITY_GRID_SIZE: + grid[r] = list(grid[r]) + [None] * (CITY_GRID_SIZE - len(grid[r])) + + if grid[request.row][request.col] is not None: + raise HTTPException(status_code=400, detail="Cell is already occupied") + + gs.gold -= cost + grid[request.row][request.col] = {"type": request.building_type, "built_at": gs.game_turn} + city.city_grid = grid + + stats = _compute_city_stats(city) + city.population = stats["population"] + city.happiness = stats["happiness"] + + db.commit() + + return { + "success": True, + "gold": gs.gold, + "city_stats": stats, + "cell": {"row": request.row, "col": request.col, "type": request.building_type}, + } + + +@router.post("/{state_id}/city/demolish") +async def demolish_in_city( + state_id: int, + request: DemolishRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + city = db.query(GameCity).filter(GameCity.id == request.city_id, GameCity.game_state_id == gs.id).first() + if not city: + raise HTTPException(status_code=404, detail="City not found") + + grid = [list(row) for row in (city.city_grid or [])] + if not (0 <= request.row < len(grid) and 0 <= request.col < len(grid[0])): + raise HTTPException(status_code=400, detail="Invalid grid position") + + removed = grid[request.row][request.col] + grid[request.row][request.col] = None + city.city_grid = grid + + if removed: + btype_cfg = BUILDING_TYPES.get(removed.get("type", ""), {}) + gs.gold += btype_cfg.get("cost", 0) // 4 + + stats = _compute_city_stats(city) + city.population = stats["population"] + city.happiness = stats["happiness"] + db.commit() + + return {"success": True, "gold": gs.gold, "city_stats": stats} + + +@router.post("/{state_id}/research") +async def research_tech( + state_id: int, + request: ResearchRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + + if request.tech_id not in TECHNOLOGIES: + raise HTTPException(status_code=400, detail="Unknown technology") + + tech_cfg = TECHNOLOGIES[request.tech_id] + cost = tech_cfg["cost"] + + already = next((t for t in gs.researched_techs if t.tech_id == request.tech_id), None) + if already and already.researched: + raise HTTPException(status_code=400, detail="Technology already researched") + + for req_id in tech_cfg.get("requires", []): + prereq = next((t for t in gs.researched_techs if t.tech_id == req_id and t.researched), None) + if not prereq: + raise HTTPException(status_code=400, detail=f"Requires: {TECHNOLOGIES[req_id]['name']}") + + if gs.gold < cost: + raise HTTPException(status_code=400, detail=f"Not enough gold. Need {cost}, have {gs.gold}.") + + gs.gold -= cost + if already: + already.researched = True + already.progress = 100 + else: + new_tech = ResearchedTech( + game_state_id=gs.id, + tech_id=request.tech_id, + progress=100, + researched=True, + ) + db.add(new_tech) + + db.commit() + return { + "success": True, + "tech_id": request.tech_id, + "tech_name": tech_cfg["name"], + "gold": gs.gold, + "unlocks_units": tech_cfg.get("unlocks_units", []), + "unlocks_buildings": tech_cfg.get("unlocks_buildings", []), + } + + +@router.post("/{state_id}/diplomacy") +async def diplomacy_action( + state_id: int, + request: DiplomacyRequest, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + rel = db.query(DiplomacyRelation).filter( + DiplomacyRelation.game_state_id == gs.id, + DiplomacyRelation.ai_country_id == request.ai_country_id, + ).first() + if not rel: + raise HTTPException(status_code=404, detail="Country not found") + + message = "" + if request.action == "gift": + cost = 200 + if gs.gold < cost: + raise HTTPException(status_code=400, detail="Not enough gold for gift") + gs.gold -= cost + rel.relation_score = min(100, rel.relation_score + 15) + message = f"Gift of gold sent to {rel.ai_country_name}. Relations improved." + elif request.action == "trade": + if rel.trade_active: + rel.trade_active = False + message = f"Trade agreement with {rel.ai_country_name} cancelled." + else: + if rel.relation_score < 40: + raise HTTPException(status_code=400, detail="Relations too low for trade agreement") + rel.trade_active = True + message = f"Trade agreement established with {rel.ai_country_name}!" + elif request.action == "denounce": + rel.relation_score = max(0, rel.relation_score - 20) + rel.status = "hostile" if rel.relation_score < 30 else "cold" + rel.trade_active = False + message = f"You denounced {rel.ai_country_name}. Relations deteriorated." + elif request.action == "alliance": + if rel.relation_score < 70: + raise HTTPException(status_code=400, detail="Relations too low for alliance") + rel.status = "allied" + message = f"Alliance formed with {rel.ai_country_name}!" + else: + raise HTTPException(status_code=400, detail="Unknown diplomacy action") + + if rel.relation_score >= 70: + rel.status = "friendly" if rel.status not in ("allied",) else rel.status + elif rel.relation_score >= 40: + rel.status = "neutral" if rel.status not in ("allied",) else rel.status + elif rel.relation_score >= 20: + rel.status = "cold" if rel.status not in ("hostile",) else rel.status + else: + rel.status = "hostile" + + db.commit() + return { + "success": True, + "ai_country_id": rel.ai_country_id, + "ai_country_name": rel.ai_country_name, + "relation_score": rel.relation_score, + "status": rel.status, + "trade_active": rel.trade_active, + "message": message, + "gold": gs.gold, + } + + +@router.post("/{state_id}/end-turn") +async def end_turn( + state_id: int, + current_user: User = Depends(get_current_user_from_token), + db: Session = Depends(get_db), +): + state = db.query(State).filter(State.id == state_id, State.user_id == current_user.id).first() + if not state: + raise HTTPException(status_code=404, detail="State not found") + + gs = _get_or_create_game_state(state_id, state.name, db) + gs.game_turn += 1 + + city_income = 0 + for city in gs.cities: + stats = _compute_city_stats(city) + city_income += stats["income"] + city.population = max(1000, int(city.population * (1 + (stats["happiness"] - 50) / 2000))) + city.budget += stats["income"] + + trade_income = sum(50 for d in gs.diplomacy if d.trade_active) + gs.gold += city_income + gs.production + trade_income + + for unit in gs.military_units: + unit_cfg = UNIT_TYPES.get(unit.unit_type, {}) + unit.moves_remaining = unit_cfg.get("moves", 1) + if unit.health < 100: + unit.health = min(100, unit.health + 10) + + for rel in gs.diplomacy: + drift = random.randint(-2, 2) + rel.relation_score = max(0, min(100, rel.relation_score + drift)) + + db.commit() + + return { + "game_turn": gs.game_turn, + "gold": gs.gold, + "income_this_turn": city_income + gs.production + trade_income, + "city_income": city_income, + "trade_income": trade_income, + "production_income": gs.production, + } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff08af1..04fc410 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -493,6 +494,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -510,6 +512,7 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -524,6 +527,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -533,6 +537,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -542,12 +547,14 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -692,6 +699,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -705,6 +713,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -714,6 +723,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -727,6 +737,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -1725,15 +1736,6 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz", - "integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1748,6 +1750,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1760,6 +1763,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1772,12 +1776,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1791,6 +1797,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/aria-hidden": { @@ -1818,12 +1825,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1836,6 +1845,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1845,6 +1855,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1868,6 +1879,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -1942,6 +1954,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -1966,6 +1979,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -2019,6 +2033,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2031,6 +2046,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, "license": "MIT" }, "node_modules/color-string": { @@ -2057,6 +2073,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -2075,6 +2092,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2089,6 +2107,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -2301,12 +2320,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/dom-helpers": { @@ -2322,12 +2343,14 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/estree-util-is-identifier-name": { @@ -2361,6 +2384,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -2377,6 +2401,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -2389,6 +2414,7 @@ "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -2398,6 +2424,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -2410,6 +2437,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -2426,6 +2454,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2440,6 +2469,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2458,6 +2488,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -2478,6 +2509,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -2490,6 +2522,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2591,6 +2624,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -2603,6 +2637,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -2627,6 +2662,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2636,6 +2672,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2645,6 +2682,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -2666,6 +2704,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -2686,12 +2725,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -2707,6 +2748,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -2721,6 +2763,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -2733,6 +2776,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -2782,6 +2826,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -2942,6 +2987,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -3372,6 +3418,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -3385,6 +3432,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -3400,6 +3448,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -3414,6 +3463,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -3525,6 +3575,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3543,6 +3594,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3552,6 +3604,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parse-entities": { @@ -3581,6 +3634,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3590,12 +3644,14 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -3618,6 +3674,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -3630,6 +3687,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3639,6 +3697,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3648,6 +3707,7 @@ "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3676,6 +3736,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -3693,6 +3754,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -3712,6 +3774,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3747,6 +3810,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3772,6 +3836,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -3785,6 +3850,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prop-types": { @@ -3815,6 +3881,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -4037,6 +4104,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -4046,6 +4114,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -4124,6 +4193,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -4144,6 +4214,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -4154,6 +4225,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -4236,6 +4308,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4248,6 +4321,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4257,6 +4331,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -4305,6 +4380,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -4323,6 +4399,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4337,6 +4414,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4346,12 +4424,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4377,6 +4457,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4393,6 +4474,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4405,6 +4487,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4445,6 +4528,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -4467,6 +4551,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4489,6 +4574,7 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -4535,6 +4621,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -4544,6 +4631,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -4561,6 +4649,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -4591,6 +4680,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -4727,6 +4817,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/vfile": { @@ -4780,6 +4871,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4795,6 +4887,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -4813,6 +4906,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4830,6 +4924,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4839,6 +4934,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4854,12 +4950,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4874,6 +4972,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4886,6 +4985,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index 7621f2f..242380b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 5000 -H 0.0.0.0", "build": "next build", - "start": "next start", + "start": "next start -p 5000 -H 0.0.0.0", "lint": "next lint" }, "dependencies": { diff --git a/frontend/src/app/state/page.js b/frontend/src/app/state/page.js index cde527e..1fb5263 100644 --- a/frontend/src/app/state/page.js +++ b/frontend/src/app/state/page.js @@ -17,6 +17,7 @@ import { PlayDialog } from '@/components/dashboard/play-dialog'; import { ReportDialog } from '@/components/dashboard/report-dialog'; import { withErrorBoundary } from '@/components/error-boundary'; import { FlagSVG } from '@/components/flag-svg'; +import GameHub from '@/components/game/GameHub'; import { InfoDialog } from '@/components/info-dialog'; import { DashboardNav } from '@/components/nav'; import { Badge } from '@/components/ui/badge'; @@ -203,9 +204,10 @@ function StatePageContent({ stateId }) { report={latestReport} /> - +
+ ๐ŸŒ Strategy Overview People Education @@ -224,6 +226,10 @@ function StatePageContent({ stateId }) {
+ + {stateId && } + + diff --git a/frontend/src/components/game/CityBuilder.js b/frontend/src/components/game/CityBuilder.js new file mode 100644 index 0000000..5caff2b --- /dev/null +++ b/frontend/src/components/game/CityBuilder.js @@ -0,0 +1,342 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; + +const GRID_SIZE = 20; +const CELL_PX = 24; + +const CATEGORY_GROUPS = { + 'Zones': ['residential_low', 'residential_mid', 'residential_high', 'commercial', 'industrial'], + 'Roads & Infrastructure': ['road', 'water_tower'], + 'Power': ['power_plant_coal', 'power_plant_solar', 'power_plant_nuclear'], + 'Services': ['police_station', 'fire_station', 'hospital', 'school', 'park'], + 'Economy': ['market', 'bank', 'factory'], + 'Military': ['barracks', 'airbase', 'harbor'], + 'Science': ['university', 'space_center'], +}; + +function CityGrid({ grid, buildingTypes, onCellClick, hoveredCell, onHover }) { + return ( +
+ + + + {Array.from({ length: GRID_SIZE }).map((_, r) => + Array.from({ length: GRID_SIZE }).map((_, c) => { + const cell = grid[r]?.[c]; + const btype = cell?.type; + const cfg = btype ? buildingTypes[btype] : null; + const isHovered = hoveredCell?.r === r && hoveredCell?.c === c; + const x = c * CELL_PX; + const y = r * CELL_PX; + + return ( + onCellClick(r, c, !!cell)} + onMouseEnter={() => onHover({ r, c, cell, cfg })} + onMouseLeave={() => onHover(null)} + style={{ cursor: cell ? 'context-menu' : 'crosshair' }} + > + + {cfg && ( + + {cfg.icon} + + )} + {!cfg && isHovered && ( + + + )} + + ); + }) + )} + + + + +
+ ); +} + +function CityStats({ stats, budget }) { + const powerStatus = stats.power_ok ? 'โœ…' : 'โš ๏ธ'; + const waterStatus = stats.water_ok ? 'โœ…' : 'โš ๏ธ'; + const happinessColor = stats.happiness > 70 ? 'text-green-500' : stats.happiness > 40 ? 'text-yellow-500' : 'text-red-500'; + + return ( +
+
+
Population
+
{stats.population?.toLocaleString()}
+
+
+
Happiness
+
{stats.happiness}%
+
+
+
Income/Turn
+
๐Ÿ’ฐ {stats.income?.toLocaleString()}
+
+
+
Budget
+
{budget?.toLocaleString()}
+
+
+
Power
+
{powerStatus} {stats.power_supply}/{stats.power_demand} MW
+
+
+
Water
+
{waterStatus} {stats.water_supply}/{stats.water_demand}
+
+
+ ); +} + +export default function CityBuilder({ stateId, gameState, onRefresh }) { + const { toast } = useToast(); + const [selectedBuilding, setSelectedBuilding] = useState(null); + const [selectedCity, setSelectedCity] = useState(null); + const [hoveredCell, setHoveredCell] = useState(null); + const [mode, setMode] = useState('build'); + const [loading, setLoading] = useState(false); + + const config = gameState?.config || {}; + const buildingTypes = config.building_types || {}; + const researched = gameState?.researched_techs || {}; + const cities = gameState?.cities || []; + const gold = gameState?.gold || 0; + + const currentCity = selectedCity + ? cities.find((c) => c.id === selectedCity) + : cities.find((c) => c.is_capital) || cities[0]; + + const canBuild = (btype) => { + const cfg = buildingTypes[btype]; + if (!cfg) return false; + if (cfg.requires_tech && !researched[cfg.requires_tech]?.researched) return false; + return true; + }; + + const computeStats = () => { + if (!currentCity) return { population: 0, happiness: 50, income: 0, power_supply: 0, power_demand: 0, power_ok: false, water_supply: 0, water_demand: 0, water_ok: false }; + const grid = currentCity.city_grid || []; + let population = currentCity.population || 10000; + let happiness = 50; + let income = 0; + let power_supply = 0; + let power_demand = 0; + let water_supply = 0; + let water_demand = 0; + + for (const row of grid) { + for (const cell of row) { + if (!cell?.type || !buildingTypes[cell.type]) continue; + const cfg = buildingTypes[cell.type]; + population += cfg.population_capacity || 0; + happiness += cfg.happiness || 0; + income += cfg.income || 0; + power_supply += cfg.power_supply || 0; + power_demand += cfg.power_demand || 0; + water_supply += cfg.water_supply || 0; + water_demand += cfg.water_demand || 0; + } + } + return { + population, happiness: Math.min(100, Math.max(0, happiness)), income, + power_supply, power_demand, power_ok: power_supply >= power_demand, + water_supply, water_demand, water_ok: water_supply >= water_demand, + }; + }; + + const handleCellClick = useCallback(async (r, c, occupied) => { + if (!currentCity) return; + if (mode === 'build' && selectedBuilding && !occupied) { + const cfg = buildingTypes[selectedBuilding]; + if (!cfg) return; + if (gold < cfg.cost) { + toast({ title: 'Not enough gold', description: `Need ${cfg.cost} gold`, variant: 'destructive', duration: 3000 }); + return; + } + setLoading(true); + try { + await gameApi.buildInCity(stateId, currentCity.id, r, c, selectedBuilding); + toast({ title: `${cfg.name} built!`, description: `Cost: ๐Ÿ’ฐ ${cfg.cost}`, duration: 2000 }); + onRefresh?.(); + } catch (err) { + toast({ title: 'Build failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setLoading(false); + } + } else if (mode === 'demolish' && occupied) { + if (!confirm('Demolish this building?')) return; + setLoading(true); + try { + await gameApi.demolishInCity(stateId, currentCity.id, r, c); + toast({ title: 'Building demolished', duration: 2000 }); + onRefresh?.(); + } catch (err) { + toast({ title: 'Demolish failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setLoading(false); + } + } + }, [mode, selectedBuilding, currentCity, stateId, buildingTypes, gold, onRefresh, toast]); + + const stats = computeStats(); + + if (!currentCity) { + return
No city found.
; + } + + return ( +
+
+
+
+ {cities.map((city) => ( + + ))} +
+
+ + +
+
+ + {mode === 'build' && ( +
+ {selectedBuilding ? `Selected: ${buildingTypes[selectedBuilding]?.icon} ${buildingTypes[selectedBuilding]?.name} (๐Ÿ’ฐ ${buildingTypes[selectedBuilding]?.cost}) โ€” click empty cell to build` : 'Select a building from the panel, then click an empty cell to build'} +
+ )} + {mode === 'demolish' && ( +
Click a building on the grid to demolish it (you'll get 25% refund)
+ )} + + + + {hoveredCell?.cfg && ( +
+ {hoveredCell.cfg.icon} +
+ {hoveredCell.cfg.name} + + {hoveredCell.cfg.income !== 0 && ๐Ÿ’ฐ {hoveredCell.cfg.income > 0 ? '+' : ''}{hoveredCell.cfg.income}/turn} + {hoveredCell.cfg.happiness !== 0 && {hoveredCell.cfg.happiness > 0 ? '๐Ÿ˜Š' : '๐Ÿ˜ž'} {hoveredCell.cfg.happiness > 0 ? '+' : ''}{hoveredCell.cfg.happiness}} + {hoveredCell.cfg.power_supply > 0 && โšก +{hoveredCell.cfg.power_supply}} + {hoveredCell.cfg.power_demand > 0 && โšก -{hoveredCell.cfg.power_demand}} + +
+
+ )} +
+ +
+ + +
+
+ Buildings + ๐Ÿ’ฐ {gold.toLocaleString()} +
+
+ {Object.entries(CATEGORY_GROUPS).map(([groupName, btypes]) => { + const available = btypes.filter((id) => buildingTypes[id] && canBuild(id)); + if (available.length === 0) return null; + return ( +
+
{groupName}
+ {available.map((btype) => { + const cfg = buildingTypes[btype]; + const isSelected = selectedBuilding === btype; + const canAfford = gold >= cfg.cost; + return ( + + ); + })} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/frontend/src/components/game/Diplomacy.js b/frontend/src/components/game/Diplomacy.js new file mode 100644 index 0000000..26a8e46 --- /dev/null +++ b/frontend/src/components/game/Diplomacy.js @@ -0,0 +1,183 @@ +'use client'; + +import { useState } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; + +const STATUS_CONFIG = { + allied: { color: 'text-violet-500', bg: 'bg-violet-500/10 border-violet-500/30', label: 'Allied', icon: '๐Ÿค' }, + friendly: { color: 'text-green-500', bg: 'bg-green-500/10 border-green-500/30', label: 'Friendly', icon: '๐Ÿ˜Š' }, + neutral: { color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/30', label: 'Neutral', icon: '๐Ÿ˜' }, + cold: { color: 'text-yellow-500', bg: 'bg-yellow-500/10 border-yellow-500/30', label: 'Cold', icon: '๐Ÿฅถ' }, + hostile: { color: 'text-red-500', bg: 'bg-red-500/10 border-red-500/30', label: 'Hostile', icon: '๐Ÿ˜ ' }, +}; + +const PERSONALITY_ICONS = { + aggressive: 'โš”๏ธ', + diplomatic: '๐Ÿ•Š๏ธ', + expansionist: '๐Ÿ—บ๏ธ', + peaceful: '๐ŸŒฟ', + militaristic: '๐Ÿช–', + scientific: '๐Ÿ”ฌ', + industrial: 'โš™๏ธ', + cultural: '๐ŸŽญ', +}; + +function RelationBar({ score }) { + const color = score >= 70 ? 'bg-green-500' : score >= 40 ? 'bg-yellow-500' : score >= 20 ? 'bg-orange-500' : 'bg-red-500'; + return ( +
+
+
+
+ {score} +
+ ); +} + +function CountryCard({ relation, aiData, stateId, gold, onRefresh }) { + const { toast } = useToast(); + const [loading, setLoading] = useState(null); + + const status = relation.status || 'neutral'; + const statusCfg = STATUS_CONFIG[status] || STATUS_CONFIG.neutral; + const personality = aiData?.personality; + + const doAction = async (action) => { + setLoading(action); + try { + const result = await gameApi.diplomacyAction(stateId, relation.ai_country_id, action); + toast({ title: result.message, duration: 3000 }); + onRefresh?.(); + } catch (err) { + toast({ title: 'Action failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setLoading(null); + } + }; + + return ( +
+
+
+
+ {relation.ai_country_name.slice(0, 1)} +
+
+
{relation.ai_country_name}
+ {personality && ( +
+ {PERSONALITY_ICONS[personality] || '๐ŸŒ'} {personality} +
+ )} +
+
+
+ {statusCfg.icon} + {statusCfg.label} +
+
+ +
+
Relations
+ +
+ + {relation.trade_active && ( +
+ ๐Ÿ“ฆ Active trade agreement (+50 gold/turn) +
+ )} + +
+ + + + +
+
+ ); +} + +export default function Diplomacy({ stateId, gameState, onRefresh }) { + const diplomacy = gameState?.diplomacy || []; + const aiCountries = gameState?.map_data?.ai_countries || []; + const gold = gameState?.gold || 0; + + const alliedCount = diplomacy.filter((d) => d.status === 'allied').length; + const tradeCount = diplomacy.filter((d) => d.trade_active).length; + const hostileCount = diplomacy.filter((d) => d.status === 'hostile').length; + const tradeIncome = tradeCount * 50; + + const getAiData = (aiCountryId) => aiCountries.find((c) => c.id === aiCountryId); + + return ( +
+
+
+
{alliedCount}
+
Allies
+
+
+
+{tradeIncome}
+
Trade Income/Turn
+
+
+
{hostileCount}
+
Hostile Nations
+
+
+ +
+ Diplomacy Tips: Send gifts to improve relations, establish trade for passive income (+50/turn), form alliances for military support, and avoid denouncing without reason. + Relation score โ‰ฅ 40 for trade ยท โ‰ฅ 70 for alliance. +
+ +
+ {diplomacy.map((rel) => ( + + ))} + {diplomacy.length === 0 && ( +
+ No neighboring countries discovered yet. Explore the world map to encounter other nations. +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/game/GameHub.js b/frontend/src/components/game/GameHub.js new file mode 100644 index 0000000..19762f0 --- /dev/null +++ b/frontend/src/components/game/GameHub.js @@ -0,0 +1,180 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; +import WorldMap from './WorldMap'; +import MilitaryCommand from './MilitaryCommand'; +import CityBuilder from './CityBuilder'; +import TechTree from './TechTree'; +import Diplomacy from './Diplomacy'; + +const TABS = [ + { id: 'map', label: '๐Ÿ—บ๏ธ World Map' }, + { id: 'military', label: 'โš”๏ธ Military' }, + { id: 'city', label: '๐Ÿ™๏ธ City Builder' }, + { id: 'tech', label: '๐Ÿ”ฌ Tech Lab' }, + { id: 'diplomacy', label: '๐Ÿค Diplomacy' }, +]; + +function TurnSummaryToast({ result }) { + return ( +
+
Turn {result.game_turn} complete
+
๐Ÿ’ฐ +{result.income_this_turn?.toLocaleString()} gold
+ {result.city_income > 0 &&
๐Ÿ™๏ธ Cities: +{result.city_income}
} + {result.trade_income > 0 &&
๐Ÿ“ฆ Trade: +{result.trade_income}
} +
+ ); +} + +export default function GameHub({ stateId }) { + const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('map'); + const [gameState, setGameState] = useState(null); + const [loading, setLoading] = useState(true); + const [endingTurn, setEndingTurn] = useState(false); + + const fetchGameState = useCallback(async () => { + try { + const data = await gameApi.getGameState(stateId); + setGameState(data); + } catch (err) { + toast({ title: 'Failed to load game state', description: err.message, variant: 'destructive', duration: 4000 }); + } finally { + setLoading(false); + } + }, [stateId, toast]); + + useEffect(() => { + fetchGameState(); + }, [fetchGameState]); + + const handleEndTurn = async () => { + setEndingTurn(true); + try { + const result = await gameApi.endTurn(stateId); + toast({ + title: `โฉ Turn ${result.game_turn} Complete`, + description: `๐Ÿ’ฐ +${result.income_this_turn?.toLocaleString()} gold earned this turn`, + duration: 4000, + }); + await fetchGameState(); + } catch (err) { + toast({ title: 'End turn failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setEndingTurn(false); + } + }; + + if (loading) { + return ( +
+
+
๐ŸŒ
+
Generating world mapโ€ฆ
+
+
+ ); + } + + if (!gameState) { + return ( +
+ Could not load game state. Make sure the backend is running. +
+ ); + } + + const activeUnits = (gameState.military_units || []).filter((u) => u.status !== 'destroyed'); + const cities = gameState.cities || []; + const researchedCount = Object.values(gameState.researched_techs || {}).filter((t) => t.researched).length; + const totalTechs = Object.keys(gameState.config?.technologies || {}).length; + const alliedCount = (gameState.diplomacy || []).filter((d) => d.status === 'allied').length; + + return ( +
+
+
+
+ ๐ŸŒ +
+
{gameState.state_name}
+
Turn {gameState.game_turn}
+
+
+
+
+ ๐Ÿ’ฐ + {gameState.gold?.toLocaleString()} + gold +
+
+ โš™๏ธ + {gameState.production} + prod/turn +
+
+ ๐Ÿช– + {activeUnits.length} + units +
+
+ ๐Ÿ”ฌ + {researchedCount}/{totalTechs} + techs +
+
+ ๐Ÿค + {alliedCount} + allies +
+
+
+ +
+ +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {activeTab === 'map' && ( + + )} + {activeTab === 'military' && ( + + )} + {activeTab === 'city' && ( + + )} + {activeTab === 'tech' && ( + + )} + {activeTab === 'diplomacy' && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/game/MilitaryCommand.js b/frontend/src/components/game/MilitaryCommand.js new file mode 100644 index 0000000..8e2959f --- /dev/null +++ b/frontend/src/components/game/MilitaryCommand.js @@ -0,0 +1,289 @@ +'use client'; + +import { useState } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; + +const CATEGORY_ICONS = { land: '๐Ÿช–', air: 'โœˆ๏ธ', naval: 'โš“' }; +const ERA_ORDER = ['ancient', 'medieval', 'gunpowder', 'modern', 'digital']; +const ERA_COLORS = { + ancient: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300', + medieval: 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300', + gunpowder: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300', + modern: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300', + digital: 'bg-violet-100 dark:bg-violet-900/30 text-violet-800 dark:text-violet-300', +}; + +function UnitCard({ unit, onDisband, isOwn }) { + const healthColor = unit.health > 66 ? 'bg-green-500' : unit.health > 33 ? 'bg-yellow-500' : 'bg-red-500'; + const expStars = Math.floor(unit.experience / 34); + + return ( +
+
+
+ {unit.icon} +
+
{unit.name}
+
{unit.unit_type} ยท {CATEGORY_ICONS[unit.category]} {unit.category}
+
+
+ {unit.era} +
+ +
+
+ HP{unit.health}/100 +
+
+
+
+
+ +
+
+
{unit.attack}
+
ATK
+
+
+
{unit.defense}
+
DEF
+
+
+
{unit.moves_remaining}/{unit.moves}
+
MOV
+
+
+
{'โญ'.repeat(expStars + 1).slice(0, 3)}
+
EXP
+
+
+ +
+ ๐Ÿ“ Tile ({unit.tile_col}, {unit.tile_row}) +
+ + {isOwn && unit.status !== 'destroyed' && ( + + )} +
+ ); +} + +function TrainUnitPanel({ stateId, gameState, onRefresh }) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [selectedType, setSelectedType] = useState(null); + + const config = gameState?.config || {}; + const unitTypes = config.unit_types || {}; + const researched = gameState?.researched_techs || {}; + const cities = gameState?.cities || []; + const capitalCity = cities.find((c) => c.is_capital) || cities[0]; + + const canTrain = (unitCfg) => { + if (!unitCfg.requires_tech) return true; + return researched[unitCfg.requires_tech]?.researched; + }; + + const hasBuilding = (unitCfg) => { + if (!unitCfg.requires_building || !capitalCity) return true; + const grid = capitalCity.city_grid || []; + for (const row of grid) { + for (const cell of row) { + if (cell && cell.type === unitCfg.requires_building) return true; + } + } + return false; + }; + + const handleTrain = async () => { + if (!selectedType || !capitalCity) return; + setLoading(true); + try { + const result = await gameApi.trainUnit(stateId, selectedType, capitalCity.id); + toast({ title: `${result.unit.name} trained!`, description: `Gold remaining: ${result.gold}`, duration: 3000 }); + setSelectedType(null); + onRefresh?.(); + } catch (err) { + toast({ title: 'Training failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setLoading(false); + } + }; + + const grouped = ERA_ORDER.reduce((acc, era) => { + acc[era] = Object.entries(unitTypes).filter(([, cfg]) => cfg.era === era); + return acc; + }, {}); + + return ( +
+
+

Train New Unit

+
๐Ÿ’ฐ {gameState?.gold?.toLocaleString()} gold
+
+ + {!capitalCity && ( +
No city found to train units from.
+ )} + + {ERA_ORDER.map((era) => { + const eraUnits = grouped[era] || []; + if (eraUnits.length === 0) return null; + return ( +
+
{era} era
+
+ {eraUnits.map(([typeId, cfg]) => { + const trained = canTrain(cfg); + const built = hasBuilding(cfg); + const available = trained && built; + const isSelected = selectedType === typeId; + return ( + + ); + })} +
+
+ ); + })} + + {selectedType && ( +
+
+ {unitTypes[selectedType]?.icon} +
+
{unitTypes[selectedType]?.name}
+
Cost: ๐Ÿ’ฐ {unitTypes[selectedType]?.cost}
+
+
+ +
+ )} +
+ ); +} + +export default function MilitaryCommand({ stateId, gameState, onRefresh }) { + const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('roster'); + + const units = gameState?.military_units || []; + const activeUnits = units.filter((u) => u.status !== 'destroyed'); + const destroyedUnits = units.filter((u) => u.status === 'destroyed'); + + const totalAttack = activeUnits.reduce((s, u) => s + u.attack, 0); + const totalDefense = activeUnits.reduce((s, u) => s + u.defense, 0); + const avgHealth = activeUnits.length > 0 ? Math.round(activeUnits.reduce((s, u) => s + u.health, 0) / activeUnits.length) : 0; + + const handleDisband = async (unitId, name) => { + if (!confirm(`Disband ${name}? This cannot be undone.`)) return; + try { + await gameApi.disbandUnit(stateId, unitId); + toast({ title: `${name} disbanded`, duration: 2000 }); + onRefresh?.(); + } catch (err) { + toast({ title: 'Failed to disband', description: err.message, variant: 'destructive', duration: 3000 }); + } + }; + + return ( +
+
+
+
{totalAttack}
+
Total Attack Power
+
+
+
{totalDefense}
+
Total Defense Power
+
+
+
{avgHealth}%
+
Average Unit Health
+
+
+ +
+ {[['roster', `๐Ÿช– Active (${activeUnits.length})`], ['train', 'โž• Train Units'], ['destroyed', `๐Ÿ’€ Lost (${destroyedUnits.length})`]].map(([id, label]) => ( + + ))} +
+ + {activeTab === 'roster' && ( +
+ {activeUnits.length === 0 ? ( +
+ No active units. Train some units to defend your nation! +
+ ) : ( +
+ {activeUnits.map((unit) => ( + + ))} +
+ )} +
+ )} + + {activeTab === 'train' && ( + + )} + + {activeTab === 'destroyed' && ( +
+ {destroyedUnits.length === 0 ? ( +
No units lost in battle.
+ ) : ( +
+ {destroyedUnits.map((unit) => ( + {}} isOwn={false} /> + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/game/TechTree.js b/frontend/src/components/game/TechTree.js new file mode 100644 index 0000000..f9b1543 --- /dev/null +++ b/frontend/src/components/game/TechTree.js @@ -0,0 +1,224 @@ +'use client'; + +import { useState } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; + +const ERA_ORDER = ['ancient', 'medieval', 'gunpowder', 'industrial', 'modern', 'digital']; +const ERA_COLORS_BG = { + ancient: '#78350f', + medieval: '#7c2d12', + gunpowder: '#7f1d1d', + industrial: '#1e3a5f', + modern: '#1e3a8a', + digital: '#4c1d95', +}; +const ERA_COLORS_LIGHT = { + ancient: '#fef3c7', + medieval: '#ffedd5', + gunpowder: '#fee2e2', + industrial: '#dbeafe', + modern: '#e0e7ff', + digital: '#ede9fe', +}; + +function TechCard({ techId, tech, status, onResearch, loading, gold }) { + const canResearch = status === 'available' && gold >= tech.cost; + const isResearched = status === 'researched'; + const isLocked = status === 'locked'; + const isResearching = loading === techId; + + return ( +
canResearch && onResearch(techId)} + > +
+
{tech.name}
+
+ {isResearched && โœ“} + {isLocked && ๐Ÿ”’} + {(status === 'available') && ๐Ÿ”ฌ} +
+
+ +
{tech.description}
+ + {tech.bonus && ( +
+ โœจ {tech.bonus} +
+ )} + + {tech.unlocks_units?.length > 0 && ( +
+ ๐Ÿช– Unlocks: {tech.unlocks_units.join(', ')} +
+ )} + + {tech.requires?.length > 0 && !isResearched && ( +
+ ๐Ÿ“‹ Requires: {tech.requires.join(', ')} +
+ )} + +
+ + ๐Ÿ’ฐ {tech.cost} gold + + {canResearch && !isResearching && ( + Click to research โ†’ + )} + {isResearching && ( + Researchingโ€ฆ + )} + {!canResearch && !isResearched && status === 'available' && ( + Not enough gold + )} +
+
+ ); +} + +function EraProgress({ era, techs, researched }) { + const total = techs.length; + const done = techs.filter((id) => researched[id]?.researched).length; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + return ( +
+ {era} +
+
+
+ {done}/{total} +
+ ); +} + +export default function TechTree({ stateId, gameState, onRefresh }) { + const { toast } = useToast(); + const [loading, setLoading] = useState(null); + const [filter, setFilter] = useState('all'); + + const config = gameState?.config || {}; + const technologies = config.technologies || {}; + const researched = gameState?.researched_techs || {}; + const gold = gameState?.gold || 0; + + const getTechStatus = (techId, tech) => { + if (researched[techId]?.researched) return 'researched'; + const prereqsMet = (tech.requires || []).every((r) => researched[r]?.researched); + if (!prereqsMet) return 'locked'; + return 'available'; + }; + + const handleResearch = async (techId) => { + setLoading(techId); + try { + const result = await gameApi.researchTech(stateId, techId); + toast({ + title: `โœ… ${result.tech_name} researched!`, + description: result.unlocks_units?.length + ? `Unlocked: ${result.unlocks_units.join(', ')}` + : `Gold remaining: ${result.gold}`, + duration: 4000, + }); + onRefresh?.(); + } catch (err) { + toast({ title: 'Research failed', description: err.message, variant: 'destructive', duration: 3000 }); + } finally { + setLoading(null); + } + }; + + const byEra = ERA_ORDER.reduce((acc, era) => { + acc[era] = Object.entries(technologies).filter(([, t]) => t.era === era); + return acc; + }, {}); + + const counts = { + all: Object.keys(technologies).length, + researched: Object.values(technologies).filter((_, i) => { + const id = Object.keys(technologies)[i]; + return researched[id]?.researched; + }).length, + available: Object.entries(technologies).filter(([id, t]) => getTechStatus(id, t) === 'available').length, + }; + + return ( +
+
+
+ {[['all', `All (${counts.all})`], ['available', `Available (${counts.available})`], ['researched', `Researched (${counts.researched})`]].map(([id, label]) => ( + + ))} +
+
๐Ÿ’ฐ {gold.toLocaleString()} gold
+
+ +
+
Era Progress
+ {ERA_ORDER.map((era) => ( + id)} + researched={researched} + /> + ))} +
+ + {ERA_ORDER.map((era) => { + const eraTechs = byEra[era] || []; + const visible = eraTechs.filter(([id, tech]) => { + const status = getTechStatus(id, tech); + if (filter === 'researched') return status === 'researched'; + if (filter === 'available') return status === 'available'; + return true; + }); + if (visible.length === 0) return null; + + return ( +
+
+
+ {era} era +
+
+
+
+ {visible.map(([id, tech]) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/game/WorldMap.js b/frontend/src/components/game/WorldMap.js new file mode 100644 index 0000000..059b8d1 --- /dev/null +++ b/frontend/src/components/game/WorldMap.js @@ -0,0 +1,372 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { gameApi } from '@/lib/game-api'; +import { useToast } from '@/hooks/use-toast'; + +const HEX_SIZE = 20; +const SQRT3 = Math.sqrt(3); + +const TERRAIN_COLORS = { + ocean: '#1e40af', + coast: '#3b82f6', + plains: '#86efac', + grassland: '#4ade80', + desert: '#fbbf24', + forest: '#166534', + jungle: '#14532d', + mountain: '#78716c', + hills: '#a8a29e', + tundra: '#e2e8f0', + snow: '#f8fafc', +}; + +const STATUS_COLORS = { + neutral: '#94a3b8', + friendly: '#4ade80', + cold: '#fbbf24', + hostile: '#ef4444', + allied: '#818cf8', +}; + +function hexCenter(col, row) { + const x = HEX_SIZE * SQRT3 * (col + 0.5 * (row & 1)); + const y = HEX_SIZE * 1.5 * row; + return { x, y }; +} + +function hexPoints(cx, cy) { + const pts = []; + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 180) * (60 * i - 30); + pts.push(`${(cx + HEX_SIZE * Math.cos(angle)).toFixed(1)},${(cy + HEX_SIZE * Math.sin(angle)).toFixed(1)}`); + } + return pts.join(' '); +} + +function hexContains(col, row, px, py) { + const { x, y } = hexCenter(col, row); + const dx = px - x; + const dy = py - y; + return Math.sqrt(dx * dx + dy * dy) < HEX_SIZE * 0.9; +} + +function getOwnerColor(owner, stateId, aiCountries) { + if (!owner) return null; + if (owner === String(stateId)) return '#f97316'; + const ai = aiCountries.find((c) => c.id === owner); + return ai ? ai.flag_color : '#6b7280'; +} + +export default function WorldMap({ stateId, gameState, onRefresh }) { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 520 }); + const [dragging, setDragging] = useState(false); + const [dragStart, setDragStart] = useState(null); + const [selectedUnit, setSelectedUnit] = useState(null); + const [hoveredTile, setHoveredTile] = useState(null); + const [showGrid, setShowGrid] = useState(true); + const [mode, setMode] = useState('select'); + const { toast } = useToast(); + + const mapData = gameState?.map_data || {}; + const tiles = mapData.tiles || []; + const aiCountries = mapData.ai_countries || []; + const units = gameState?.military_units || []; + const cities = gameState?.cities || []; + const playerCapital = mapData.player_capital || {}; + + useEffect(() => { + if (playerCapital.col !== undefined) { + const { x, y } = hexCenter(playerCapital.col, playerCapital.row); + setViewBox({ x: x - 400, y: y - 260, w: 800, h: 520 }); + } + }, [playerCapital.col, playerCapital.row]); + + const handleSvgMouseDown = useCallback((e) => { + if (e.button === 1 || e.altKey) { + setDragging(true); + setDragStart({ x: e.clientX, y: e.clientY, vb: { ...viewBox } }); + e.preventDefault(); + } + }, [viewBox]); + + const handleSvgMouseMove = useCallback((e) => { + if (!dragging || !dragStart) return; + const dx = (e.clientX - dragStart.x) * (dragStart.vb.w / (containerRef.current?.clientWidth || 800)); + const dy = (e.clientY - dragStart.y) * (dragStart.vb.h / (containerRef.current?.clientHeight || 520)); + setViewBox({ ...dragStart.vb, x: dragStart.vb.x - dx, y: dragStart.vb.y - dy }); + }, [dragging, dragStart]); + + const handleSvgMouseUp = useCallback(() => setDragging(false), []); + + const handleWheel = useCallback((e) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 1.15 : 0.87; + setViewBox((prev) => { + const cx = prev.x + prev.w / 2; + const cy = prev.y + prev.h / 2; + const nw = Math.min(2000, Math.max(300, prev.w * factor)); + const nh = Math.min(1300, Math.max(200, prev.h * factor)); + return { x: cx - nw / 2, y: cy - nh / 2, w: nw, h: nh }; + }); + }, []); + + const svgCoords = useCallback((e) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const sx = viewBox.x + ((e.clientX - rect.left) / rect.width) * viewBox.w; + const sy = viewBox.y + ((e.clientY - rect.top) / rect.height) * viewBox.h; + return { x: sx, y: sy }; + }, [viewBox]); + + const findTileAt = useCallback((svgX, svgY) => { + const rows = tiles.length; + const cols = rows > 0 ? tiles[0].length : 0; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (hexContains(c, r, svgX, svgY)) return { col: c, row: r, tile: tiles[r][c] }; + } + } + return null; + }, [tiles]); + + const handleTileClick = useCallback(async (e) => { + if (dragging) return; + const { x, y } = svgCoords(e); + const found = findTileAt(x, y); + if (!found) return; + + const { col, row, tile } = found; + + if (mode === 'move' && selectedUnit) { + try { + await gameApi.moveUnit(stateId, selectedUnit.id, col, row); + toast({ title: `${selectedUnit.name} moved`, duration: 2000 }); + setSelectedUnit(null); + setMode('select'); + onRefresh?.(); + } catch (err) { + toast({ title: 'Move failed', description: err.message, variant: 'destructive', duration: 3000 }); + } + return; + } + + if (mode === 'attack' && selectedUnit) { + try { + const result = await gameApi.attackUnit(stateId, selectedUnit.id, col, row); + toast({ title: 'Attack!', description: result.message, duration: 3000 }); + setSelectedUnit(null); + setMode('select'); + onRefresh?.(); + } catch (err) { + toast({ title: 'Attack failed', description: err.message, variant: 'destructive', duration: 3000 }); + } + return; + } + + const clickedUnit = units.find((u) => u.tile_col === col && u.tile_row === row); + if (clickedUnit) { + setSelectedUnit(clickedUnit); + setMode('select'); + } else { + setSelectedUnit(null); + } + }, [dragging, mode, selectedUnit, stateId, units, findTileAt, svgCoords, onRefresh, toast]); + + const handleMouseMoveOnSvg = useCallback((e) => { + handleSvgMouseMove(e); + if (!dragging) { + const { x, y } = svgCoords(e); + const found = findTileAt(x, y); + setHoveredTile(found || null); + } + }, [handleSvgMouseMove, dragging, svgCoords, findTileAt]); + + const rows = tiles.length; + const cols = rows > 0 ? tiles[0].length : 0; + const mapW = HEX_SIZE * SQRT3 * (cols + 0.5); + const mapH = HEX_SIZE * 1.5 * rows + HEX_SIZE * 0.5; + + const diplomacy = gameState?.diplomacy || []; + const statusByAiId = Object.fromEntries(diplomacy.map((d) => [d.ai_country_id, d.status])); + + return ( +
+
+ Alt+Drag to pan ยท Scroll to zoom +
+ + {selectedUnit && ( + <> + + + + )} + +
+
+ +
+ + {tiles.map((rowArr, r) => + rowArr.map((tile, c) => { + const { x, y } = hexCenter(c, r); + const terrain = tile.terrain || 'plains'; + const baseColor = TERRAIN_COLORS[terrain] || '#86efac'; + const ownerColor = getOwnerColor(tile.owner, stateId, aiCountries); + const isHovered = hoveredTile?.col === c && hoveredTile?.row === r; + const pts = hexPoints(x, y); + + return ( + + + {ownerColor && ( + + )} + {tile.city && ( + + {tile.city.is_capital ? '๐Ÿ›๏ธ' : '๐Ÿ™๏ธ'} + + )} + + ); + }) + )} + + {units.map((unit) => { + const { x, y } = hexCenter(unit.tile_col, unit.tile_row); + const isSelected = selectedUnit?.id === unit.id; + return ( + + {isSelected && ( + + + + )} + + {unit.icon} + {unit.health < 100 && ( + <> + + + + )} + + ); + })} + + {mode === 'move' && selectedUnit && hoveredTile && ( + + )} + {mode === 'attack' && selectedUnit && hoveredTile && ( + + )} + + + {hoveredTile && ( +
+ {hoveredTile.tile?.terrain} + {hoveredTile.tile?.city && ๐Ÿ™๏ธ {hoveredTile.tile.city.name}} + {hoveredTile.tile?.owner && + {hoveredTile.tile.owner === String(stateId) ? '๐ŸŸ  Your territory' : '๐Ÿ”ต Foreign territory'} + } +
+ )} +
+ +
+ {Object.entries(TERRAIN_COLORS).slice(0, 7).map(([t, c]) => ( + + + {t} + + ))} + + + Your territory + +
+ + {selectedUnit && ( +
+
+ {selectedUnit.icon} +
+
{selectedUnit.name}
+
{selectedUnit.unit_type} ยท HP {selectedUnit.health}/100 ยท Moves: {selectedUnit.moves_remaining}/{selectedUnit.moves}
+
+
+
+ โš”๏ธ {selectedUnit.attack} + ๐Ÿ›ก๏ธ {selectedUnit.defense} + โญ {selectedUnit.experience} +
+ +
+ )} + +
+ {aiCountries.map((ai) => { + const rel = diplomacy.find((d) => d.ai_country_id === ai.id); + const status = rel?.status || 'neutral'; + return ( +
+ + {ai.name} + ยท{status} + {rel && ({rel.relation_score})} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/lib/game-api.js b/frontend/src/lib/game-api.js new file mode 100644 index 0000000..a7a3b04 --- /dev/null +++ b/frontend/src/lib/game-api.js @@ -0,0 +1,36 @@ +import { API_URL } from './api'; + +const TOKEN_KEY = 'state-sandbox-token'; + +function authHeaders() { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, + }; +} + +async function request(method, path, body) { + const res = await fetch(`${API_URL}${path}`, { + method, + headers: authHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || `API error: ${res.statusText}`); + } + return res.json(); +} + +export const gameApi = { + getGameState: (stateId) => request('GET', `/api/game/${stateId}`), + trainUnit: (stateId, unitType, cityId) => request('POST', `/api/game/${stateId}/unit/train`, { unit_type: unitType, city_id: cityId }), + moveUnit: (stateId, unitId, toCol, toRow) => request('POST', `/api/game/${stateId}/unit/move`, { unit_id: unitId, to_col: toCol, to_row: toRow }), + attackUnit: (stateId, unitId, targetCol, targetRow) => request('POST', `/api/game/${stateId}/unit/attack`, { unit_id: unitId, target_col: targetCol, target_row: targetRow }), + disbandUnit: (stateId, unitId) => request('DELETE', `/api/game/${stateId}/unit/${unitId}`), + buildInCity: (stateId, cityId, row, col, buildingType) => request('POST', `/api/game/${stateId}/city/build`, { city_id: cityId, row, col, building_type: buildingType }), + demolishInCity: (stateId, cityId, row, col) => request('POST', `/api/game/${stateId}/city/demolish`, { city_id: cityId, row, col }), + researchTech: (stateId, techId) => request('POST', `/api/game/${stateId}/research`, { tech_id: techId }), + diplomacyAction: (stateId, aiCountryId, action) => request('POST', `/api/game/${stateId}/diplomacy`, { ai_country_id: aiCountryId, action }), + endTurn: (stateId) => request('POST', `/api/game/${stateId}/end-turn`, {}), +}; diff --git a/replit.md b/replit.md new file mode 100644 index 0000000..61df224 --- /dev/null +++ b/replit.md @@ -0,0 +1,57 @@ +# State Sandbox AI โ€” Project Overview + +## Architecture + +- **Frontend**: Next.js 15 (App Router), running on port 5000 with `-H 0.0.0.0` + - `frontend/src/app/` โ€” Next.js app routes + - `frontend/src/components/` โ€” React components + - `frontend/src/components/game/` โ€” All game components (GameHub, WorldMap, CityBuilder, TechTree, Diplomacy, MilitaryCommand) +- **Backend**: FastAPI (Python), running on port 8000 + - `backend/main.py` โ€” FastAPI entrypoint + - `backend/routers/` โ€” API routers (game, ai, auth, state, etc.) + - `backend/model/` โ€” Game config, map generation, DB models + - `backend/db/` โ€” Database session and models + +## Workflows + +- `Backend API`: `cd backend && python main.py` +- `Start application`: `cd frontend && npm run dev` + +## Game Features (State Sandbox AI) + +A political + strategy simulation game combining: +- **Freeciv-style hex world map** (38ร—24 tiles, procedural generation with terrain) +- **12 military unit types** (Warrior, Archer, Spearman, Catapult, Knight, Musketeer, Cannon, Rifleman, Tank, Artillery, Fighter, Bomber) +- **20-tech research tree** across 5 eras (Ancient โ†’ Information) +- **20ร—20 grid city builder** (Micropolis-style) with zones, services, and military buildings +- **Diplomacy system** (gifts, trade, alliances, denounce) +- **AI countries** with personalities (aggressive, diplomatic, scientific, economic, militaristic) +- **Turn-based engine** with end-turn advancing AI + +## Key Files + +- `frontend/src/app/state/page.js` โ€” State page (hosts all tabs including ๐ŸŒ Strategy tab) +- `frontend/src/components/game/GameHub.js` โ€” Master game UI with tab navigation +- `frontend/src/components/game/WorldMap.js` โ€” SVG hex grid world map +- `frontend/src/components/game/CityBuilder.js` โ€” City grid builder +- `frontend/src/components/game/TechTree.js` โ€” Research tech tree +- `frontend/src/components/game/Diplomacy.js` โ€” Diplomacy relations panel +- `frontend/src/components/game/MilitaryCommand.js` โ€” Unit roster and training +- `backend/routers/game.py` โ€” All game API endpoints +- `backend/model/game_config.py` โ€” Unit, tech, building, terrain definitions +- `backend/model/map_gen.py` โ€” Procedural hex map generator +- `backend/db/models.py` โ€” All database models (including GameState, MilitaryUnit, GameCity, etc.) + +## Environment Variables Required + +- `DATABASE_URL` โ€” PostgreSQL connection string +- `OPENAI_API_KEY` โ€” For AI turns and reports +- `JWT_SECRET_KEY` โ€” Authentication +- `STRIPE_SECRET_KEY` โ€” Payments +- `STRIPE_WEBHOOK_SECRET` โ€” Stripe webhooks +- `POSTMARK_API_KEY` โ€” Email delivery +- `FRONTEND_URL` โ€” Frontend origin URL + +## npm Install Note + +Always use `--legacy-peer-deps` (react-svg@16 is incompatible with React 19). From 4de41128a1a1ed81c22ff9f8614cf09f44daa554 Mon Sep 17 00:00:00 2001 From: jdgrgjfhjw <50918275-jdgrgjfhjw@users.noreply.replit.com> Date: Mon, 6 Apr 2026 07:07:17 +0000 Subject: [PATCH 2/2] Add a strategy tab to the state page with a fully functional game hub Integrates the new game module into the application, including backend API endpoints, database models, and frontend components for a strategy game experience. Replit-Commit-Author: Agent Replit-Commit-Session-Id: fc5ab706-ba5a-47b0-93fe-bb5a9b81860a Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 63ecc6b1-a47c-42c5-b102-b4aab894938c Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/84da491d-3c20-4e7e-a6b3-7e801fb4d509/fc5ab706-ba5a-47b0-93fe-bb5a9b81860a/uxPVUxd Replit-Helium-Checkpoint-Created: true