Compare commits
1 Commits
war-and-pe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67dc007283 |
@ -121,6 +121,12 @@ class StatisticsSchema(BaseModel):
|
||||
total_agents_died: int
|
||||
total_money_in_circulation: int
|
||||
professions: dict[str, int]
|
||||
# Wealth inequality metrics
|
||||
avg_money: float = 0.0
|
||||
median_money: int = 0
|
||||
richest_agent: int = 0
|
||||
poorest_agent: int = 0
|
||||
gini_coefficient: float = 0.0
|
||||
|
||||
|
||||
class ActionLogSchema(BaseModel):
|
||||
@ -137,6 +143,18 @@ class TurnLogSchema(BaseModel):
|
||||
agent_actions: list[ActionLogSchema]
|
||||
deaths: list[str]
|
||||
trades: list[dict]
|
||||
resources_produced: dict[str, int] = {}
|
||||
resources_consumed: dict[str, int] = {}
|
||||
resources_spoiled: dict[str, int] = {}
|
||||
|
||||
|
||||
class ResourceStatsSchema(BaseModel):
|
||||
"""Schema for resource statistics."""
|
||||
produced: dict[str, int] = {}
|
||||
consumed: dict[str, int] = {}
|
||||
spoiled: dict[str, int] = {}
|
||||
in_inventory: dict[str, int] = {}
|
||||
in_market: dict[str, int] = {}
|
||||
|
||||
|
||||
class WorldStateResponse(BaseModel):
|
||||
@ -152,6 +170,7 @@ class WorldStateResponse(BaseModel):
|
||||
mode: str
|
||||
is_running: bool
|
||||
recent_logs: list[TurnLogSchema]
|
||||
resource_stats: ResourceStatsSchema = ResourceStatsSchema()
|
||||
|
||||
|
||||
# ============== Control Schemas ==============
|
||||
|
||||
@ -31,6 +31,10 @@ class TurnLog:
|
||||
agent_actions: list[dict] = field(default_factory=list)
|
||||
deaths: list[str] = field(default_factory=list)
|
||||
trades: list[dict] = field(default_factory=list)
|
||||
# Resource tracking for this turn
|
||||
resources_produced: dict = field(default_factory=dict)
|
||||
resources_consumed: dict = field(default_factory=dict)
|
||||
resources_spoiled: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@ -38,6 +42,9 @@ class TurnLog:
|
||||
"agent_actions": self.agent_actions,
|
||||
"deaths": self.deaths,
|
||||
"trades": self.trades,
|
||||
"resources_produced": self.resources_produced,
|
||||
"resources_consumed": self.resources_consumed,
|
||||
"resources_spoiled": self.resources_spoiled,
|
||||
}
|
||||
|
||||
|
||||
@ -66,6 +73,17 @@ class GameEngine:
|
||||
self._stop_event = threading.Event()
|
||||
self.turn_logs: list[TurnLog] = []
|
||||
self.logger = get_simulation_logger()
|
||||
|
||||
# Resource statistics tracking (cumulative)
|
||||
self.resource_stats = {
|
||||
"produced": {}, # Total resources produced
|
||||
"consumed": {}, # Total resources consumed
|
||||
"spoiled": {}, # Total resources spoiled
|
||||
"traded": {}, # Total resources traded (bought/sold)
|
||||
"in_market": {}, # Currently in market
|
||||
"in_inventory": {}, # Currently in all inventories
|
||||
}
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
||||
@ -80,6 +98,16 @@ class GameEngine:
|
||||
self.market = OrderBook()
|
||||
self.turn_logs = []
|
||||
|
||||
# Reset resource statistics
|
||||
self.resource_stats = {
|
||||
"produced": {},
|
||||
"consumed": {},
|
||||
"spoiled": {},
|
||||
"traded": {},
|
||||
"in_market": {},
|
||||
"in_inventory": {},
|
||||
}
|
||||
|
||||
# Reset and start new logging session
|
||||
self.logger = reset_simulation_logger()
|
||||
sim_config = get_config()
|
||||
@ -183,6 +211,25 @@ class GameEngine:
|
||||
# Complete agent action with result - this updates the indicator to show what was done
|
||||
if result:
|
||||
agent.complete_action(result.success, result.message)
|
||||
# Log to agent's personal history
|
||||
agent.log_action(
|
||||
turn=current_turn,
|
||||
action_type=decision.action.value,
|
||||
result=result.message,
|
||||
success=result.success,
|
||||
)
|
||||
|
||||
# Track resources produced
|
||||
for res in result.resources_gained:
|
||||
res_type = res.type.value
|
||||
turn_log.resources_produced[res_type] = turn_log.resources_produced.get(res_type, 0) + res.quantity
|
||||
self.resource_stats["produced"][res_type] = self.resource_stats["produced"].get(res_type, 0) + res.quantity
|
||||
|
||||
# Track resources consumed
|
||||
for res in result.resources_consumed:
|
||||
res_type = res.type.value
|
||||
turn_log.resources_consumed[res_type] = turn_log.resources_consumed.get(res_type, 0) + res.quantity
|
||||
self.resource_stats["consumed"][res_type] = self.resource_stats["consumed"].get(res_type, 0) + res.quantity
|
||||
|
||||
turn_log.agent_actions.append({
|
||||
"agent_id": agent.id,
|
||||
@ -215,6 +262,11 @@ class GameEngine:
|
||||
# 6. Decay resources in inventories
|
||||
for agent in self.world.get_living_agents():
|
||||
expired = agent.decay_inventory(current_turn)
|
||||
# Track spoiled resources
|
||||
for res in expired:
|
||||
res_type = res.type.value
|
||||
turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + res.quantity
|
||||
self.resource_stats["spoiled"][res_type] = self.resource_stats["spoiled"].get(res_type, 0) + res.quantity
|
||||
|
||||
# 7. Mark newly dead agents as corpses (don't remove yet for visualization)
|
||||
newly_dead = self._mark_dead_agents(current_turn)
|
||||
@ -297,9 +349,17 @@ class GameEngine:
|
||||
elif action == ActionType.CONSUME:
|
||||
if decision.target_resource:
|
||||
success = agent.consume(decision.target_resource)
|
||||
consumed_list = []
|
||||
if success:
|
||||
consumed_list.append(Resource(
|
||||
type=decision.target_resource,
|
||||
quantity=1,
|
||||
created_turn=self.world.current_turn,
|
||||
))
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=success,
|
||||
resources_consumed=consumed_list,
|
||||
message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume",
|
||||
)
|
||||
return ActionResult(action_type=action, success=False, message="No resource specified")
|
||||
@ -318,6 +378,7 @@ class GameEngine:
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
heat_gained=fire_heat,
|
||||
resources_consumed=[Resource(type=ResourceType.WOOD, quantity=1, created_turn=self.world.current_turn)],
|
||||
message="Built a warm fire",
|
||||
)
|
||||
return ActionResult(action_type=action, success=False, message="No wood for fire")
|
||||
@ -350,6 +411,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
# Check required materials
|
||||
resources_consumed = []
|
||||
if config.requires_resource:
|
||||
if not agent.has_resource(config.requires_resource, config.requires_quantity):
|
||||
agent.restore_energy(energy_cost) # Refund energy
|
||||
@ -359,6 +421,11 @@ class GameEngine:
|
||||
message=f"Missing required {config.requires_resource.value}",
|
||||
)
|
||||
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
|
||||
resources_consumed.append(Resource(
|
||||
type=config.requires_resource,
|
||||
quantity=config.requires_quantity,
|
||||
created_turn=self.world.current_turn,
|
||||
))
|
||||
|
||||
# Get relevant skill for this action
|
||||
skill_name = self._get_skill_for_action(action)
|
||||
@ -434,6 +501,7 @@ class GameEngine:
|
||||
success=True,
|
||||
energy_spent=energy_cost,
|
||||
resources_gained=resources_gained,
|
||||
resources_consumed=resources_consumed,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@ -483,6 +551,10 @@ class GameEngine:
|
||||
self.world.current_turn,
|
||||
)
|
||||
|
||||
# Track traded resources
|
||||
res_type = result.resource_type.value
|
||||
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
|
||||
|
||||
# Deduct money from buyer
|
||||
agent.money -= result.total_paid
|
||||
|
||||
@ -612,6 +684,10 @@ class GameEngine:
|
||||
self.world.current_turn
|
||||
)
|
||||
|
||||
# Track traded resources
|
||||
res_type = result.resource_type.value
|
||||
self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity
|
||||
|
||||
resource = Resource(
|
||||
type=result.resource_type,
|
||||
quantity=result.quantity,
|
||||
@ -684,6 +760,31 @@ class GameEngine:
|
||||
"recent_logs": [
|
||||
log.to_dict() for log in self.turn_logs[-5:]
|
||||
],
|
||||
"resource_stats": self._get_resource_stats(),
|
||||
}
|
||||
|
||||
def _get_resource_stats(self) -> dict:
|
||||
"""Get comprehensive resource statistics."""
|
||||
# Calculate current inventory totals
|
||||
in_inventory = {}
|
||||
for agent in self.world.get_living_agents():
|
||||
for res in agent.inventory:
|
||||
res_type = res.type.value
|
||||
in_inventory[res_type] = in_inventory.get(res_type, 0) + res.quantity
|
||||
|
||||
# Calculate current market totals
|
||||
in_market = {}
|
||||
for order in self.market.get_active_orders():
|
||||
res_type = order.resource_type.value
|
||||
in_market[res_type] = in_market.get(res_type, 0) + order.quantity
|
||||
|
||||
return {
|
||||
"produced": self.resource_stats["produced"].copy(),
|
||||
"consumed": self.resource_stats["consumed"].copy(),
|
||||
"spoiled": self.resource_stats["spoiled"].copy(),
|
||||
"traded": self.resource_stats["traded"].copy(),
|
||||
"in_inventory": in_inventory,
|
||||
"in_market": in_market,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -246,6 +246,10 @@ class Agent:
|
||||
total_trades_completed: int = 0
|
||||
total_money_earned: int = 0
|
||||
|
||||
# Personal action log (recent actions with results)
|
||||
action_history: list = field(default_factory=list)
|
||||
MAX_HISTORY_SIZE: int = 20
|
||||
|
||||
# Configuration - loaded from config
|
||||
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
|
||||
MOVE_SPEED: float = 0.8 # Grid cells per turn
|
||||
@ -283,6 +287,19 @@ class Agent:
|
||||
if action_type in self.actions_performed:
|
||||
self.actions_performed[action_type] += 1
|
||||
|
||||
def log_action(self, turn: int, action_type: str, result: str, success: bool = True) -> None:
|
||||
"""Add an action to the agent's personal history log."""
|
||||
entry = {
|
||||
"turn": turn,
|
||||
"action": action_type,
|
||||
"result": result,
|
||||
"success": success,
|
||||
}
|
||||
self.action_history.append(entry)
|
||||
# Keep only recent history
|
||||
if len(self.action_history) > self.MAX_HISTORY_SIZE:
|
||||
self.action_history = self.action_history[-self.MAX_HISTORY_SIZE:]
|
||||
|
||||
def record_trade(self, money_earned: int) -> None:
|
||||
"""Record a completed trade for statistics."""
|
||||
self.total_trades_completed += 1
|
||||
@ -511,4 +528,6 @@ class Agent:
|
||||
"actions_performed": self.actions_performed.copy(),
|
||||
"total_trades": self.total_trades_completed,
|
||||
"total_money_earned": self.total_money_earned,
|
||||
# Personal action history
|
||||
"action_history": self.action_history.copy(),
|
||||
}
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
"""FastAPI entry point for the Village Simulation backend."""
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from backend.api.routes import router
|
||||
from backend.core.engine import get_engine
|
||||
|
||||
# Path to web frontend
|
||||
WEB_FRONTEND_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web_frontend")
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Village Simulation API",
|
||||
@ -48,6 +53,7 @@ def root():
|
||||
"name": "Village Simulation API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"web_frontend": "/web/",
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
@ -63,6 +69,14 @@ def health_check():
|
||||
}
|
||||
|
||||
|
||||
# ============== Web Frontend Static Files ==============
|
||||
|
||||
# Mount static files for web frontend
|
||||
# Access at http://localhost:8000/web/
|
||||
if os.path.exists(WEB_FRONTEND_PATH):
|
||||
app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the server."""
|
||||
uvicorn.run(
|
||||
|
||||
279
web_frontend/index.html
Normal file
279
web_frontend/index.html
Normal file
@ -0,0 +1,279 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VillSim - Village Economy Simulation</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">VillSim</h1>
|
||||
<span class="subtitle">Village Economy Simulation</span>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<div class="time-display">
|
||||
<span id="day-display">Day 1</span>
|
||||
<span class="separator">·</span>
|
||||
<span id="time-display">☀️ Day</span>
|
||||
<span class="separator">·</span>
|
||||
<span id="turn-display">Turn 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="connection-status" id="connection-status">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-text">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<aside id="left-panel" class="panel">
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Population</h3>
|
||||
<div class="stat-grid" id="population-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="stat-alive">0</span>
|
||||
<span class="stat-label">Alive</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="stat-dead">0</span>
|
||||
<span class="stat-label">Dead</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Professions</h3>
|
||||
<div class="profession-list" id="profession-list">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Economy</h3>
|
||||
<div class="economy-stats" id="economy-stats">
|
||||
<div class="economy-item">
|
||||
<span class="economy-label">Money in Circulation</span>
|
||||
<span class="economy-value" id="stat-money">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="game-container">
|
||||
<!-- Phaser canvas will be inserted here -->
|
||||
</div>
|
||||
|
||||
<aside id="right-panel" class="panel">
|
||||
<div class="panel-section agent-section scrollable-section">
|
||||
<h3 class="section-title">Selected Agent</h3>
|
||||
<div id="agent-details" class="agent-details">
|
||||
<p class="no-selection">Click an agent to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Market Prices</h3>
|
||||
<div class="market-prices" id="market-prices">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Activity Log</h3>
|
||||
<div class="activity-log" id="activity-log">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer id="footer">
|
||||
<div class="controls">
|
||||
<button id="btn-initialize" class="btn btn-secondary" title="Reset Simulation">
|
||||
<span class="btn-icon">⟳</span> Reset
|
||||
</button>
|
||||
<button id="btn-step" class="btn btn-primary" title="Advance one turn">
|
||||
<span class="btn-icon">▶</span> Step
|
||||
</button>
|
||||
<button id="btn-auto" class="btn btn-toggle" title="Toggle auto mode">
|
||||
<span class="btn-icon">⏯</span> Auto
|
||||
</button>
|
||||
<button id="btn-stats" class="btn btn-secondary" title="View Statistics">
|
||||
<span class="btn-icon">📊</span> Stats
|
||||
</button>
|
||||
</div>
|
||||
<div class="speed-control">
|
||||
<label for="speed-slider">Speed</label>
|
||||
<input type="range" id="speed-slider" min="50" max="1000" value="150" step="50">
|
||||
<span id="speed-display">150ms</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Stats Screen (Full View) -->
|
||||
<div id="stats-screen" class="stats-screen hidden">
|
||||
<div class="stats-header">
|
||||
<div class="stats-header-left">
|
||||
<h2>📊 Simulation Statistics</h2>
|
||||
<span class="stats-subtitle">Real-time metrics and charts</span>
|
||||
</div>
|
||||
<div class="stats-header-center">
|
||||
<div class="stats-tabs">
|
||||
<button class="tab-btn active" data-tab="prices">Prices</button>
|
||||
<button class="tab-btn" data-tab="wealth">Wealth</button>
|
||||
<button class="tab-btn" data-tab="population">Population</button>
|
||||
<button class="tab-btn" data-tab="professions">Professions</button>
|
||||
<button class="tab-btn" data-tab="resources">Resources</button>
|
||||
<button class="tab-btn" data-tab="market">Market</button>
|
||||
<button class="tab-btn" data-tab="agents">Agents</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-header-right">
|
||||
<button id="btn-close-stats" class="btn btn-secondary">
|
||||
<span class="btn-icon">◀</span> Back to Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-body">
|
||||
<!-- Prices Tab -->
|
||||
<div id="tab-prices" class="tab-panel active">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prices"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wealth Tab -->
|
||||
<div id="tab-wealth" class="tab-panel">
|
||||
<div class="chart-grid three-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-dist"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-prof"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-time"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Population Tab -->
|
||||
<div id="tab-population" class="tab-panel">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-population"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Professions Tab -->
|
||||
<div id="tab-professions" class="tab-panel">
|
||||
<div class="chart-grid two-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prof-pie"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prof-time"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resources Tab -->
|
||||
<div id="tab-resources" class="tab-panel">
|
||||
<div class="chart-grid four-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-produced"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-consumed"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-spoiled"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-stock"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-grid four-col" style="margin-top: 16px;">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-produced"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-consumed"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-spoiled"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-traded"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Market Tab -->
|
||||
<div id="tab-market" class="tab-panel">
|
||||
<div class="chart-grid two-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-market-supply"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-market-activity"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Agents Tab -->
|
||||
<div id="tab-agents" class="tab-panel">
|
||||
<div class="chart-grid four-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-energy"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-hunger"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-thirst"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-heat"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-footer">
|
||||
<div class="stats-summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Turn</span>
|
||||
<span class="summary-value" id="stats-turn">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Living</span>
|
||||
<span class="summary-value highlight" id="stats-living">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Deaths</span>
|
||||
<span class="summary-value danger" id="stats-deaths">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Total Gold</span>
|
||||
<span class="summary-value gold" id="stats-gold">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Avg Wealth</span>
|
||||
<span class="summary-value" id="stats-avg-wealth">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Gini Index</span>
|
||||
<span class="summary-value" id="stats-gini">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load game modules -->
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
132
web_frontend/src/api.js
Normal file
132
web_frontend/src/api.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* VillSim API Client
|
||||
* Handles all communication with the backend simulation server.
|
||||
*/
|
||||
|
||||
// Auto-detect API base from current page location (same origin)
|
||||
function getApiBase() {
|
||||
// When served by the backend, use same origin
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Fallback for development
|
||||
return 'http://localhost:8000';
|
||||
}
|
||||
|
||||
class SimulationAPI {
|
||||
constructor() {
|
||||
this.baseUrl = getApiBase();
|
||||
this.connected = false;
|
||||
this.lastState = null;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
this.connected = true;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error.message);
|
||||
this.connected = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
async checkHealth() {
|
||||
try {
|
||||
const data = await this.request('/health');
|
||||
this.connected = data.status === 'healthy';
|
||||
return this.connected;
|
||||
} catch {
|
||||
this.connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get full simulation state
|
||||
async getState() {
|
||||
const data = await this.request('/api/state');
|
||||
this.lastState = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get all agents
|
||||
async getAgents() {
|
||||
return await this.request('/api/agents');
|
||||
}
|
||||
|
||||
// Get specific agent
|
||||
async getAgent(agentId) {
|
||||
return await this.request(`/api/agents/${agentId}`);
|
||||
}
|
||||
|
||||
// Get market orders
|
||||
async getMarketOrders() {
|
||||
return await this.request('/api/market/orders');
|
||||
}
|
||||
|
||||
// Get market prices
|
||||
async getMarketPrices() {
|
||||
return await this.request('/api/market/prices');
|
||||
}
|
||||
|
||||
// Control: Initialize simulation
|
||||
async initialize(numAgents = 8, worldWidth = 20, worldHeight = 20) {
|
||||
return await this.request('/api/control/initialize', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
num_agents: numAgents,
|
||||
world_width: worldWidth,
|
||||
world_height: worldHeight,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Control: Advance one step
|
||||
async nextStep() {
|
||||
return await this.request('/api/control/next_step', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// Control: Set mode (manual/auto)
|
||||
async setMode(mode) {
|
||||
return await this.request('/api/control/mode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
}
|
||||
|
||||
// Control: Get status
|
||||
async getStatus() {
|
||||
return await this.request('/api/control/status');
|
||||
}
|
||||
|
||||
// Config: Get configuration
|
||||
async getConfig() {
|
||||
return await this.request('/api/config');
|
||||
}
|
||||
|
||||
// Logs: Get recent logs
|
||||
async getLogs(limit = 10) {
|
||||
return await this.request(`/api/logs?limit=${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const api = new SimulationAPI();
|
||||
export default api;
|
||||
|
||||
71
web_frontend/src/constants.js
Normal file
71
web_frontend/src/constants.js
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* VillSim Constants
|
||||
* Shared constants for the Phaser game.
|
||||
*/
|
||||
|
||||
// Profession icons and colors
|
||||
export const PROFESSIONS = {
|
||||
hunter: { icon: '🏹', color: 0xc45c5c, name: 'Hunter' },
|
||||
gatherer: { icon: '🌿', color: 0x6bab5e, name: 'Gatherer' },
|
||||
woodcutter: { icon: '🪓', color: 0xa67c52, name: 'Woodcutter' },
|
||||
trader: { icon: '💰', color: 0xd4a84b, name: 'Trader' },
|
||||
crafter: { icon: '🧵', color: 0x8b6fc0, name: 'Crafter' },
|
||||
villager: { icon: '👤', color: 0x7a8899, name: 'Villager' },
|
||||
};
|
||||
|
||||
// Resource icons and colors
|
||||
export const RESOURCES = {
|
||||
meat: { icon: '🥩', color: 0xc45c5c, name: 'Meat' },
|
||||
berries: { icon: '🫐', color: 0xa855a8, name: 'Berries' },
|
||||
water: { icon: '💧', color: 0x5a8cc8, name: 'Water' },
|
||||
wood: { icon: '🪵', color: 0xa67c52, name: 'Wood' },
|
||||
hide: { icon: '🦴', color: 0x8b7355, name: 'Hide' },
|
||||
clothes: { icon: '👕', color: 0x6b6560, name: 'Clothes' },
|
||||
};
|
||||
|
||||
// Action icons
|
||||
export const ACTIONS = {
|
||||
hunt: { icon: '🏹', verb: 'hunting' },
|
||||
gather: { icon: '🌿', verb: 'gathering' },
|
||||
chop_wood: { icon: '🪓', verb: 'chopping wood' },
|
||||
get_water: { icon: '💧', verb: 'getting water' },
|
||||
weave: { icon: '🧵', verb: 'weaving' },
|
||||
build_fire: { icon: '🔥', verb: 'building fire' },
|
||||
trade: { icon: '💰', verb: 'trading' },
|
||||
rest: { icon: '💤', verb: 'resting' },
|
||||
sleep: { icon: '😴', verb: 'sleeping' },
|
||||
consume: { icon: '🍽️', verb: 'consuming' },
|
||||
idle: { icon: '⏳', verb: 'idle' },
|
||||
};
|
||||
|
||||
// Time of day
|
||||
export const TIME_OF_DAY = {
|
||||
day: { icon: '☀️', name: 'Day' },
|
||||
night: { icon: '🌙', name: 'Night' },
|
||||
};
|
||||
|
||||
// World zones (approximate x-positions as percentages)
|
||||
export const WORLD_ZONES = {
|
||||
river: { start: 0.0, end: 0.15, color: 0x3a6ea5, name: 'River' },
|
||||
bushes: { start: 0.15, end: 0.35, color: 0x4a7c59, name: 'Berry Bushes' },
|
||||
village: { start: 0.35, end: 0.65, color: 0x8b7355, name: 'Village' },
|
||||
forest: { start: 0.65, end: 1.0, color: 0x2d5016, name: 'Forest' },
|
||||
};
|
||||
|
||||
// Colors for stats
|
||||
export const STAT_COLORS = {
|
||||
energy: 0xd4a84b,
|
||||
hunger: 0xc87f5a,
|
||||
thirst: 0x5a8cc8,
|
||||
heat: 0xc45c5c,
|
||||
};
|
||||
|
||||
// Game display settings
|
||||
export const DISPLAY = {
|
||||
TILE_SIZE: 32,
|
||||
AGENT_SIZE: 24,
|
||||
MIN_ZOOM: 0.5,
|
||||
MAX_ZOOM: 2.0,
|
||||
DEFAULT_ZOOM: 1.0,
|
||||
};
|
||||
|
||||
73
web_frontend/src/main.js
Normal file
73
web_frontend/src/main.js
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* VillSim - Phaser Web Frontend
|
||||
* Main entry point
|
||||
*/
|
||||
|
||||
import BootScene from './scenes/BootScene.js';
|
||||
import GameScene from './scenes/GameScene.js';
|
||||
|
||||
// Calculate game dimensions based on container
|
||||
function getGameDimensions() {
|
||||
const container = document.getElementById('game-container');
|
||||
if (!container) {
|
||||
return { width: 800, height: 600 };
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.floor(rect.width),
|
||||
height: Math.floor(rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
// Phaser game configuration
|
||||
const { width, height } = getGameDimensions();
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'game-container',
|
||||
width: width,
|
||||
height: height,
|
||||
backgroundColor: '#151921',
|
||||
scene: [BootScene, GameScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.RESIZE,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
render: {
|
||||
antialias: true,
|
||||
pixelArt: false,
|
||||
roundPixels: true,
|
||||
},
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { y: 0 },
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
dom: {
|
||||
createContainer: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize game when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('VillSim Web Frontend starting...');
|
||||
|
||||
// Create Phaser game
|
||||
const game = new Phaser.Game(config);
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
const { width, height } = getGameDimensions();
|
||||
game.scale.resize(width, height);
|
||||
});
|
||||
|
||||
// Store game reference globally for debugging
|
||||
window.villsimGame = game;
|
||||
});
|
||||
|
||||
// Export for debugging
|
||||
export { config };
|
||||
|
||||
141
web_frontend/src/scenes/BootScene.js
Normal file
141
web_frontend/src/scenes/BootScene.js
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* BootScene - Initial loading and setup
|
||||
*/
|
||||
|
||||
import { api } from '../api.js';
|
||||
|
||||
export default class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// Create loading graphics
|
||||
const { width, height } = this.cameras.main;
|
||||
|
||||
// Background
|
||||
this.add.rectangle(width / 2, height / 2, width, height, 0x151921);
|
||||
|
||||
// Loading text
|
||||
this.loadingText = this.add.text(width / 2, height / 2 - 40, 'VillSim', {
|
||||
fontSize: '48px',
|
||||
fontFamily: 'Crimson Pro, Georgia, serif',
|
||||
color: '#d4a84b',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.statusText = this.add.text(width / 2, height / 2 + 20, 'Connecting to server...', {
|
||||
fontSize: '18px',
|
||||
fontFamily: 'Crimson Pro, Georgia, serif',
|
||||
color: '#a8a095',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Progress bar background
|
||||
const barWidth = 300;
|
||||
const barHeight = 8;
|
||||
this.progressBg = this.add.rectangle(
|
||||
width / 2, height / 2 + 60,
|
||||
barWidth, barHeight,
|
||||
0x242b3d
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.progressBar = this.add.rectangle(
|
||||
width / 2 - barWidth / 2, height / 2 + 60,
|
||||
0, barHeight,
|
||||
0xd4a84b
|
||||
).setOrigin(0, 0.5);
|
||||
}
|
||||
|
||||
async create() {
|
||||
// Animate progress bar
|
||||
this.tweens.add({
|
||||
targets: this.progressBar,
|
||||
width: 100,
|
||||
duration: 500,
|
||||
ease: 'Power2',
|
||||
});
|
||||
|
||||
// Attempt to connect to server
|
||||
this.statusText.setText('Connecting to server...');
|
||||
|
||||
let connected = false;
|
||||
let retries = 0;
|
||||
const maxRetries = 10;
|
||||
|
||||
while (!connected && retries < maxRetries) {
|
||||
try {
|
||||
connected = await api.checkHealth();
|
||||
if (connected) {
|
||||
this.statusText.setText('Connected! Loading simulation...');
|
||||
this.tweens.add({
|
||||
targets: this.progressBar,
|
||||
width: 200,
|
||||
duration: 300,
|
||||
ease: 'Power2',
|
||||
});
|
||||
} else {
|
||||
retries++;
|
||||
this.statusText.setText(`Connecting... (attempt ${retries}/${maxRetries})`);
|
||||
await this.delay(1000);
|
||||
}
|
||||
} catch (error) {
|
||||
retries++;
|
||||
this.statusText.setText(`Connection failed. Retrying... (${retries}/${maxRetries})`);
|
||||
await this.delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
this.statusText.setText('Could not connect to server. Is the backend running?');
|
||||
this.statusText.setColor('#c45c5c');
|
||||
|
||||
// Add retry button
|
||||
const retryBtn = this.add.text(
|
||||
this.cameras.main.width / 2,
|
||||
this.cameras.main.height / 2 + 100,
|
||||
'[ Click to Retry ]',
|
||||
{
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Crimson Pro, Georgia, serif',
|
||||
color: '#d4a84b',
|
||||
}
|
||||
).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||
|
||||
retryBtn.on('pointerup', () => {
|
||||
this.scene.restart();
|
||||
});
|
||||
|
||||
retryBtn.on('pointerover', () => retryBtn.setColor('#e8e4dc'));
|
||||
retryBtn.on('pointerout', () => retryBtn.setColor('#d4a84b'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load initial state
|
||||
try {
|
||||
const state = await api.getState();
|
||||
this.registry.set('simulationState', state);
|
||||
|
||||
this.tweens.add({
|
||||
targets: this.progressBar,
|
||||
width: 300,
|
||||
duration: 300,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
this.statusText.setText('Starting simulation...');
|
||||
this.time.delayedCall(500, () => {
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.statusText.setText('Error loading simulation state');
|
||||
this.statusText.setColor('#c45c5c');
|
||||
console.error('Failed to load state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
1633
web_frontend/src/scenes/GameScene.js
Normal file
1633
web_frontend/src/scenes/GameScene.js
Normal file
File diff suppressed because it is too large
Load Diff
7
web_frontend/src/scenes/index.js
Normal file
7
web_frontend/src/scenes/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Scene exports
|
||||
*/
|
||||
|
||||
export { default as BootScene } from './BootScene.js';
|
||||
export { default as GameScene } from './GameScene.js';
|
||||
|
||||
1053
web_frontend/styles.css
Normal file
1053
web_frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user