Add player login, agent creation, and NPC chat
Browse filesBackend:
- database.py: users table with sha256+salt auth, session tokens
- routes.py: /auth/register, /auth/login, /auth/me, /auth/logout
+ /player/create, /player/update, /player/move, /player/plan, /player/talk
- Registration auto-creates player agent at town_square
- /player/talk feeds player message to NPC, returns LLM reply
Frontend:
- Login/register modal on page load (skip button for observers)
- Player panel in sidebar: location, move dropdown, Edit Profile, My Plans
- Agent profile editor modal (name, age, occupation, background, traits)
- Plans modal: view + add items to daily plan
- Talk button on NPC detail panel opens inline chat window
- Multi-turn chat: send message, NPC replies via LLM
- Gold ring drawn around player-controlled agent on map
- Session persisted in localStorage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/soci/api/routes.py +307 -1
- src/soci/persistence/database.py +80 -0
- web/index.html +402 -0
|
@@ -2,8 +2,9 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from fastapi import APIRouter, HTTPException
|
| 6 |
from pydantic import BaseModel
|
|
|
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
|
|
@@ -19,6 +20,66 @@ class PlayerJoinRequest(BaseModel):
|
|
| 19 |
background: str = "A newcomer to Soci City."
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
@router.get("/city")
|
| 23 |
async def get_city():
|
| 24 |
"""Get the full city state — locations, agents, time, weather."""
|
|
@@ -201,6 +262,251 @@ async def get_stats():
|
|
| 201 |
}
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@router.post("/player/join")
|
| 205 |
async def player_join(request: PlayerJoinRequest):
|
| 206 |
"""Register a human player as a new agent in the simulation."""
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Header
|
| 6 |
from pydantic import BaseModel
|
| 7 |
+
from typing import Optional
|
| 8 |
|
| 9 |
router = APIRouter()
|
| 10 |
|
|
|
|
| 20 |
background: str = "A newcomer to Soci City."
|
| 21 |
|
| 22 |
|
| 23 |
+
# ── Auth models ───────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
class AuthRequest(BaseModel):
|
| 26 |
+
username: str
|
| 27 |
+
password: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PlayerCreateRequest(BaseModel):
|
| 31 |
+
token: str
|
| 32 |
+
name: str
|
| 33 |
+
age: int = 30
|
| 34 |
+
occupation: str = "Newcomer"
|
| 35 |
+
background: str = "A newcomer to Soci City."
|
| 36 |
+
extraversion: int = 5
|
| 37 |
+
agreeableness: int = 7
|
| 38 |
+
openness: int = 6
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PlayerUpdateRequest(BaseModel):
|
| 42 |
+
token: str
|
| 43 |
+
name: Optional[str] = None
|
| 44 |
+
age: Optional[int] = None
|
| 45 |
+
occupation: Optional[str] = None
|
| 46 |
+
background: Optional[str] = None
|
| 47 |
+
extraversion: Optional[int] = None
|
| 48 |
+
agreeableness: Optional[int] = None
|
| 49 |
+
openness: Optional[int] = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class PlayerTalkRequest(BaseModel):
|
| 53 |
+
token: str
|
| 54 |
+
target_id: str
|
| 55 |
+
message: str
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class PlayerMoveRequest(BaseModel):
|
| 59 |
+
token: str
|
| 60 |
+
location: str
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class PlayerPlanRequest(BaseModel):
|
| 64 |
+
token: str
|
| 65 |
+
plan_item: str
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── Helper ────────────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
async def _get_player_from_token(token: str):
|
| 71 |
+
"""Validate token and return (user, agent). Raises 401/404 on failure."""
|
| 72 |
+
from soci.api.server import get_simulation, get_database
|
| 73 |
+
db = get_database()
|
| 74 |
+
sim = get_simulation()
|
| 75 |
+
user = await db.get_user_by_token(token)
|
| 76 |
+
if not user:
|
| 77 |
+
raise HTTPException(status_code=401, detail="Invalid or expired session token")
|
| 78 |
+
agent_id = user.get("agent_id")
|
| 79 |
+
agent = sim.agents.get(agent_id) if agent_id else None
|
| 80 |
+
return user, agent
|
| 81 |
+
|
| 82 |
+
|
| 83 |
@router.get("/city")
|
| 84 |
async def get_city():
|
| 85 |
"""Get the full city state — locations, agents, time, weather."""
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
|
| 265 |
+
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
| 266 |
+
|
| 267 |
+
@router.post("/auth/register")
|
| 268 |
+
async def auth_register(request: AuthRequest):
|
| 269 |
+
"""Register a new user and auto-create their player agent."""
|
| 270 |
+
from soci.api.server import get_simulation, get_database
|
| 271 |
+
from soci.agents.agent import Agent
|
| 272 |
+
from soci.agents.persona import Persona
|
| 273 |
+
db = get_database()
|
| 274 |
+
sim = get_simulation()
|
| 275 |
+
|
| 276 |
+
if not request.username.strip():
|
| 277 |
+
raise HTTPException(status_code=400, detail="Username cannot be empty")
|
| 278 |
+
if len(request.password) < 3:
|
| 279 |
+
raise HTTPException(status_code=400, detail="Password must be at least 3 characters")
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
user = await db.create_user(request.username.strip(), request.password)
|
| 283 |
+
except ValueError as e:
|
| 284 |
+
raise HTTPException(status_code=409, detail=str(e))
|
| 285 |
+
|
| 286 |
+
# Auto-create default player agent at town_square
|
| 287 |
+
safe_name = request.username.strip()
|
| 288 |
+
player_id = f"player_{safe_name.lower().replace(' ', '_')}"
|
| 289 |
+
# Ensure unique ID
|
| 290 |
+
suffix = 1
|
| 291 |
+
base_id = player_id
|
| 292 |
+
while player_id in sim.agents:
|
| 293 |
+
player_id = f"{base_id}_{suffix}"
|
| 294 |
+
suffix += 1
|
| 295 |
+
|
| 296 |
+
persona = Persona(
|
| 297 |
+
id=player_id,
|
| 298 |
+
name=safe_name,
|
| 299 |
+
age=30,
|
| 300 |
+
occupation="Newcomer",
|
| 301 |
+
gender="unknown",
|
| 302 |
+
background="A newcomer to Soci City.",
|
| 303 |
+
home_location="town_square",
|
| 304 |
+
work_location="",
|
| 305 |
+
extraversion=5,
|
| 306 |
+
agreeableness=7,
|
| 307 |
+
openness=6,
|
| 308 |
+
)
|
| 309 |
+
agent = Agent(persona)
|
| 310 |
+
agent.is_player = True
|
| 311 |
+
sim.add_agent(agent)
|
| 312 |
+
await db.set_user_agent(safe_name, player_id)
|
| 313 |
+
user["agent_id"] = player_id
|
| 314 |
+
|
| 315 |
+
return user
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@router.post("/auth/login")
|
| 319 |
+
async def auth_login(request: AuthRequest):
|
| 320 |
+
"""Login and return session token."""
|
| 321 |
+
from soci.api.server import get_database
|
| 322 |
+
db = get_database()
|
| 323 |
+
user = await db.authenticate_user(request.username.strip(), request.password)
|
| 324 |
+
if not user:
|
| 325 |
+
raise HTTPException(status_code=401, detail="Invalid username or password")
|
| 326 |
+
return user
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@router.get("/auth/me")
|
| 330 |
+
async def auth_me(authorization: str = Header(default="")):
|
| 331 |
+
"""Verify session token and return current user info."""
|
| 332 |
+
token = authorization.removeprefix("Bearer ").strip()
|
| 333 |
+
if not token:
|
| 334 |
+
raise HTTPException(status_code=401, detail="No token provided")
|
| 335 |
+
from soci.api.server import get_database
|
| 336 |
+
db = get_database()
|
| 337 |
+
user = await db.get_user_by_token(token)
|
| 338 |
+
if not user:
|
| 339 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 340 |
+
return user
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@router.post("/auth/logout")
|
| 344 |
+
async def auth_logout(authorization: str = Header(default="")):
|
| 345 |
+
"""Invalidate session token."""
|
| 346 |
+
token = authorization.removeprefix("Bearer ").strip()
|
| 347 |
+
from soci.api.server import get_database
|
| 348 |
+
db = get_database()
|
| 349 |
+
await db.logout_user(token)
|
| 350 |
+
return {"ok": True}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ── Player management endpoints ───────────────────────────────────────────────
|
| 354 |
+
|
| 355 |
+
@router.post("/player/create")
|
| 356 |
+
async def player_create(request: PlayerCreateRequest):
|
| 357 |
+
"""Create or replace the player's agent."""
|
| 358 |
+
from soci.api.server import get_simulation, get_database
|
| 359 |
+
from soci.agents.agent import Agent
|
| 360 |
+
from soci.agents.persona import Persona
|
| 361 |
+
db = get_database()
|
| 362 |
+
sim = get_simulation()
|
| 363 |
+
user = await db.get_user_by_token(request.token)
|
| 364 |
+
if not user:
|
| 365 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 366 |
+
|
| 367 |
+
player_id = f"player_{request.name.lower().replace(' ', '_')}"
|
| 368 |
+
suffix = 1
|
| 369 |
+
base_id = player_id
|
| 370 |
+
while player_id in sim.agents and sim.agents[player_id].persona.name != request.name:
|
| 371 |
+
player_id = f"{base_id}_{suffix}"
|
| 372 |
+
suffix += 1
|
| 373 |
+
|
| 374 |
+
# Remove old agent if exists
|
| 375 |
+
old_id = user.get("agent_id")
|
| 376 |
+
if old_id and old_id in sim.agents:
|
| 377 |
+
loc = sim.city.get_location(sim.agents[old_id].location)
|
| 378 |
+
if loc:
|
| 379 |
+
loc.remove_agent(old_id)
|
| 380 |
+
del sim.agents[old_id]
|
| 381 |
+
|
| 382 |
+
persona = Persona(
|
| 383 |
+
id=player_id,
|
| 384 |
+
name=request.name,
|
| 385 |
+
age=request.age,
|
| 386 |
+
occupation=request.occupation,
|
| 387 |
+
background=request.background,
|
| 388 |
+
home_location="town_square",
|
| 389 |
+
work_location="",
|
| 390 |
+
extraversion=request.extraversion,
|
| 391 |
+
agreeableness=request.agreeableness,
|
| 392 |
+
openness=request.openness,
|
| 393 |
+
)
|
| 394 |
+
agent = Agent(persona)
|
| 395 |
+
agent.is_player = True
|
| 396 |
+
sim.add_agent(agent)
|
| 397 |
+
await db.set_user_agent(user["username"], player_id)
|
| 398 |
+
return {"agent_id": player_id}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@router.put("/player/update")
|
| 402 |
+
async def player_update(request: PlayerUpdateRequest):
|
| 403 |
+
"""Update the player's persona fields in-place."""
|
| 404 |
+
user, agent = await _get_player_from_token(request.token)
|
| 405 |
+
if not agent:
|
| 406 |
+
raise HTTPException(status_code=404, detail="No player agent found")
|
| 407 |
+
p = agent.persona
|
| 408 |
+
if request.name is not None:
|
| 409 |
+
p.name = request.name
|
| 410 |
+
agent.name = request.name
|
| 411 |
+
if request.age is not None:
|
| 412 |
+
p.age = request.age
|
| 413 |
+
if request.occupation is not None:
|
| 414 |
+
p.occupation = request.occupation
|
| 415 |
+
if request.background is not None:
|
| 416 |
+
p.background = request.background
|
| 417 |
+
if request.extraversion is not None:
|
| 418 |
+
p.extraversion = max(1, min(10, request.extraversion))
|
| 419 |
+
if request.agreeableness is not None:
|
| 420 |
+
p.agreeableness = max(1, min(10, request.agreeableness))
|
| 421 |
+
if request.openness is not None:
|
| 422 |
+
p.openness = max(1, min(10, request.openness))
|
| 423 |
+
return {"ok": True}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@router.post("/player/move")
|
| 427 |
+
async def player_move(request: PlayerMoveRequest):
|
| 428 |
+
"""Move the player to a city location."""
|
| 429 |
+
from soci.api.server import get_simulation
|
| 430 |
+
from soci.agents.agent import AgentAction, AgentState
|
| 431 |
+
sim = get_simulation()
|
| 432 |
+
user, agent = await _get_player_from_token(request.token)
|
| 433 |
+
if not agent:
|
| 434 |
+
raise HTTPException(status_code=404, detail="No player agent found")
|
| 435 |
+
loc = sim.city.get_location(request.location)
|
| 436 |
+
if not loc:
|
| 437 |
+
raise HTTPException(status_code=400, detail=f"Unknown location: {request.location}")
|
| 438 |
+
action = AgentAction(type="move", target=request.location, detail=f"Walking to {loc.name}", duration_ticks=1)
|
| 439 |
+
await sim._execute_action(agent, action)
|
| 440 |
+
return {"ok": True, "location": agent.location}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@router.post("/player/talk")
|
| 444 |
+
async def player_talk(request: PlayerTalkRequest):
|
| 445 |
+
"""Send a message to an NPC and get their LLM-generated reply."""
|
| 446 |
+
from soci.api.server import get_simulation
|
| 447 |
+
from soci.actions.conversation import Conversation, ConversationTurn, continue_conversation
|
| 448 |
+
import uuid
|
| 449 |
+
sim = get_simulation()
|
| 450 |
+
user, player = await _get_player_from_token(request.token)
|
| 451 |
+
if not player:
|
| 452 |
+
raise HTTPException(status_code=404, detail="No player agent found")
|
| 453 |
+
target = sim.agents.get(request.target_id)
|
| 454 |
+
if not target:
|
| 455 |
+
raise HTTPException(status_code=404, detail="Target agent not found")
|
| 456 |
+
|
| 457 |
+
# Build a conversation with the player's message as the opening turn
|
| 458 |
+
conv_id = f"player_conv_{uuid.uuid4().hex[:8]}"
|
| 459 |
+
conv = Conversation(
|
| 460 |
+
id=conv_id,
|
| 461 |
+
location=player.location,
|
| 462 |
+
participants=[player.id, target.id],
|
| 463 |
+
topic="player interaction",
|
| 464 |
+
max_turns=20,
|
| 465 |
+
)
|
| 466 |
+
player_turn = ConversationTurn(
|
| 467 |
+
speaker_id=player.id,
|
| 468 |
+
speaker_name=player.name,
|
| 469 |
+
message=request.message,
|
| 470 |
+
tick=sim.clock.total_ticks,
|
| 471 |
+
)
|
| 472 |
+
conv.add_turn(player_turn)
|
| 473 |
+
|
| 474 |
+
# Let the NPC respond via LLM
|
| 475 |
+
reply_turn = await continue_conversation(conv, target, player, sim.llm, sim.clock)
|
| 476 |
+
|
| 477 |
+
# Update relationship
|
| 478 |
+
target.relationships.get_or_create(player.id, player.name)
|
| 479 |
+
player.relationships.get_or_create(target.id, target.name)
|
| 480 |
+
|
| 481 |
+
# Add memory to both
|
| 482 |
+
player.add_observation(
|
| 483 |
+
tick=sim.clock.total_ticks, day=sim.clock.day, time_str=sim.clock.time_str,
|
| 484 |
+
content=f"I said to {target.name}: \"{request.message}\"",
|
| 485 |
+
importance=4, involved_agents=[target.id],
|
| 486 |
+
)
|
| 487 |
+
if reply_turn and reply_turn.speaker_id == target.id:
|
| 488 |
+
target.add_observation(
|
| 489 |
+
tick=sim.clock.total_ticks, day=sim.clock.day, time_str=sim.clock.time_str,
|
| 490 |
+
content=f"{player.name} talked to me: \"{request.message}\"",
|
| 491 |
+
importance=4, involved_agents=[player.id],
|
| 492 |
+
)
|
| 493 |
+
return {"reply": reply_turn.message, "inner_thought": reply_turn.inner_thought}
|
| 494 |
+
|
| 495 |
+
return {"reply": "(no response)", "inner_thought": ""}
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
@router.post("/player/plan")
|
| 499 |
+
async def player_add_plan(request: PlayerPlanRequest):
|
| 500 |
+
"""Append an item to the player's daily plan."""
|
| 501 |
+
user, agent = await _get_player_from_token(request.token)
|
| 502 |
+
if not agent:
|
| 503 |
+
raise HTTPException(status_code=404, detail="No player agent found")
|
| 504 |
+
agent.daily_plan.append(request.plan_item)
|
| 505 |
+
return {"ok": True, "daily_plan": agent.daily_plan}
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
# ── Legacy join/action (kept for compatibility) ───────────────────────────────
|
| 509 |
+
|
| 510 |
@router.post("/player/join")
|
| 511 |
async def player_join(request: PlayerJoinRequest):
|
| 512 |
"""Register a human player as a new agent in the simulation."""
|
|
@@ -2,9 +2,11 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import os
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Optional
|
| 10 |
|
|
@@ -12,6 +14,10 @@ import aiosqlite
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# SOCI_DATA_DIR env var lets you point at a persistent disk (e.g. /var/data on Render).
|
| 16 |
DB_DIR = Path(os.environ.get("SOCI_DATA_DIR", "data"))
|
| 17 |
DEFAULT_DB = DB_DIR / "soci.db"
|
|
@@ -52,6 +58,16 @@ CREATE TABLE IF NOT EXISTS conversations (
|
|
| 52 |
CREATE INDEX IF NOT EXISTS idx_event_tick ON event_log(tick);
|
| 53 |
CREATE INDEX IF NOT EXISTS idx_event_agent ON event_log(agent_id);
|
| 54 |
CREATE INDEX IF NOT EXISTS idx_conv_tick ON conversations(tick);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""
|
| 56 |
|
| 57 |
|
|
@@ -172,3 +188,67 @@ class Database:
|
|
| 172 |
}
|
| 173 |
for r in rows
|
| 174 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import hashlib
|
| 6 |
import json
|
| 7 |
import logging
|
| 8 |
import os
|
| 9 |
+
import secrets
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Optional
|
| 12 |
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
|
| 18 |
+
def _hash_password(password: str, salt: str) -> str:
|
| 19 |
+
return hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
|
| 20 |
+
|
| 21 |
# SOCI_DATA_DIR env var lets you point at a persistent disk (e.g. /var/data on Render).
|
| 22 |
DB_DIR = Path(os.environ.get("SOCI_DATA_DIR", "data"))
|
| 23 |
DEFAULT_DB = DB_DIR / "soci.db"
|
|
|
|
| 58 |
CREATE INDEX IF NOT EXISTS idx_event_tick ON event_log(tick);
|
| 59 |
CREATE INDEX IF NOT EXISTS idx_event_agent ON event_log(agent_id);
|
| 60 |
CREATE INDEX IF NOT EXISTS idx_conv_tick ON conversations(tick);
|
| 61 |
+
|
| 62 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 63 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 64 |
+
username TEXT NOT NULL UNIQUE,
|
| 65 |
+
password_hash TEXT NOT NULL,
|
| 66 |
+
salt TEXT NOT NULL,
|
| 67 |
+
token TEXT UNIQUE,
|
| 68 |
+
agent_id TEXT,
|
| 69 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 70 |
+
);
|
| 71 |
"""
|
| 72 |
|
| 73 |
|
|
|
|
| 188 |
}
|
| 189 |
for r in rows
|
| 190 |
]
|
| 191 |
+
|
| 192 |
+
# ── Auth / user methods ──────────────────────────────────────────────────
|
| 193 |
+
|
| 194 |
+
async def create_user(self, username: str, password: str) -> dict:
|
| 195 |
+
"""Create a new user. Raises ValueError if username taken."""
|
| 196 |
+
assert self._db is not None
|
| 197 |
+
salt = secrets.token_hex(16)
|
| 198 |
+
pw_hash = _hash_password(password, salt)
|
| 199 |
+
token = secrets.token_hex(32)
|
| 200 |
+
try:
|
| 201 |
+
await self._db.execute(
|
| 202 |
+
"INSERT INTO users (username, password_hash, salt, token) VALUES (?, ?, ?, ?)",
|
| 203 |
+
(username, pw_hash, salt, token),
|
| 204 |
+
)
|
| 205 |
+
await self._db.commit()
|
| 206 |
+
except aiosqlite.IntegrityError:
|
| 207 |
+
raise ValueError(f"Username '{username}' is already taken")
|
| 208 |
+
return {"username": username, "token": token, "agent_id": None}
|
| 209 |
+
|
| 210 |
+
async def authenticate_user(self, username: str, password: str) -> Optional[dict]:
|
| 211 |
+
"""Verify credentials and return user dict with fresh token, or None."""
|
| 212 |
+
assert self._db is not None
|
| 213 |
+
cursor = await self._db.execute(
|
| 214 |
+
"SELECT username, password_hash, salt, agent_id FROM users WHERE username = ?",
|
| 215 |
+
(username,),
|
| 216 |
+
)
|
| 217 |
+
row = await cursor.fetchone()
|
| 218 |
+
if not row:
|
| 219 |
+
return None
|
| 220 |
+
stored_hash = row[1]
|
| 221 |
+
salt = row[2]
|
| 222 |
+
if _hash_password(password, salt) != stored_hash:
|
| 223 |
+
return None
|
| 224 |
+
token = secrets.token_hex(32)
|
| 225 |
+
await self._db.execute(
|
| 226 |
+
"UPDATE users SET token = ? WHERE username = ?", (token, username)
|
| 227 |
+
)
|
| 228 |
+
await self._db.commit()
|
| 229 |
+
return {"username": row[0], "token": token, "agent_id": row[3]}
|
| 230 |
+
|
| 231 |
+
async def get_user_by_token(self, token: str) -> Optional[dict]:
|
| 232 |
+
"""Look up a user by their session token."""
|
| 233 |
+
assert self._db is not None
|
| 234 |
+
cursor = await self._db.execute(
|
| 235 |
+
"SELECT username, agent_id FROM users WHERE token = ?", (token,)
|
| 236 |
+
)
|
| 237 |
+
row = await cursor.fetchone()
|
| 238 |
+
if not row:
|
| 239 |
+
return None
|
| 240 |
+
return {"username": row[0], "agent_id": row[1]}
|
| 241 |
+
|
| 242 |
+
async def set_user_agent(self, username: str, agent_id: str) -> None:
|
| 243 |
+
"""Link a player agent to a user account."""
|
| 244 |
+
assert self._db is not None
|
| 245 |
+
await self._db.execute(
|
| 246 |
+
"UPDATE users SET agent_id = ? WHERE username = ?", (agent_id, username)
|
| 247 |
+
)
|
| 248 |
+
await self._db.commit()
|
| 249 |
+
|
| 250 |
+
async def logout_user(self, token: str) -> None:
|
| 251 |
+
"""Invalidate a session token."""
|
| 252 |
+
assert self._db is not None
|
| 253 |
+
await self._db.execute("UPDATE users SET token = NULL WHERE token = ?", (token,))
|
| 254 |
+
await self._db.commit()
|
|
@@ -158,6 +158,77 @@
|
|
| 158 |
::-webkit-scrollbar { width: 6px; }
|
| 159 |
::-webkit-scrollbar-track { background: #1a1a2e; }
|
| 160 |
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
</style>
|
| 162 |
</head>
|
| 163 |
<body>
|
|
@@ -209,6 +280,23 @@
|
|
| 209 |
<div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div>
|
| 210 |
</div>
|
| 211 |
<div id="tab-agents" class="tab-content active">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
<div id="agent-detail"></div>
|
| 213 |
</div>
|
| 214 |
<div id="tab-conversations" class="tab-content">
|
|
@@ -219,6 +307,64 @@
|
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
<script>
|
| 223 |
// ============================================================
|
| 224 |
// CONFIG
|
|
@@ -1816,6 +1962,17 @@ function drawPerson(id, agent, globalIdx, W, H) {
|
|
| 1816 |
ctx.scale(scale, scale);
|
| 1817 |
if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; }
|
| 1818 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1819 |
// ══════════════════════════════════════════════════════════════
|
| 1820 |
// SLEEPING VIEW — agent lying on a bed
|
| 1821 |
// ══════════════════════════════════════════════════════════════
|
|
@@ -2582,6 +2739,11 @@ function renderAgentDetail(data) {
|
|
| 2582 |
|
| 2583 |
<div class="section-header">Recent Memories</div>
|
| 2584 |
${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2585 |
`;
|
| 2586 |
}
|
| 2587 |
|
|
@@ -2687,6 +2849,7 @@ function processStateData(data) {
|
|
| 2687 |
document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
|
| 2688 |
|
| 2689 |
agents = data.agents || {};
|
|
|
|
| 2690 |
|
| 2691 |
const tick = clock.total_ticks || 0;
|
| 2692 |
if (tick !== lastTick) fetchSecondaryData();
|
|
@@ -2878,6 +3041,244 @@ function checkForNotableEvents() {
|
|
| 2878 |
}
|
| 2879 |
}
|
| 2880 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2881 |
// ============================================================
|
| 2882 |
// INIT
|
| 2883 |
// ============================================================
|
|
@@ -2887,6 +3288,7 @@ fetchState();
|
|
| 2887 |
fetchControls();
|
| 2888 |
connectWebSocket();
|
| 2889 |
setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
|
|
|
|
| 2890 |
</script>
|
| 2891 |
</body>
|
| 2892 |
</html>
|
|
|
|
| 158 |
::-webkit-scrollbar { width: 6px; }
|
| 159 |
::-webkit-scrollbar-track { background: #1a1a2e; }
|
| 160 |
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
| 161 |
+
|
| 162 |
+
/* ── Player / Auth UI ────────────────────────────────────────────── */
|
| 163 |
+
.modal-overlay {
|
| 164 |
+
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
|
| 165 |
+
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
| 166 |
+
}
|
| 167 |
+
.modal-box {
|
| 168 |
+
background: #0f1b35; border: 1px solid #1a3a6e; border-radius: 8px;
|
| 169 |
+
padding: 24px 28px; min-width: 300px; max-width: 420px; width: 90%;
|
| 170 |
+
color: #e0e0e0;
|
| 171 |
+
}
|
| 172 |
+
.modal-box h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; }
|
| 173 |
+
.modal-box label { display: block; font-size: 11px; color: #888; margin: 10px 0 3px; }
|
| 174 |
+
.modal-box input, .modal-box textarea, .modal-box select {
|
| 175 |
+
width: 100%; box-sizing: border-box; background: #0a1628; border: 1px solid #1a3a6e;
|
| 176 |
+
color: #e0e0e0; padding: 7px 10px; border-radius: 4px; font-size: 13px;
|
| 177 |
+
}
|
| 178 |
+
.modal-box textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
| 179 |
+
.modal-box input[type=range] { padding: 4px 0; border: none; background: none; }
|
| 180 |
+
.modal-row { display: flex; gap: 10px; }
|
| 181 |
+
.modal-row > * { flex: 1; }
|
| 182 |
+
.modal-actions { display: flex; gap: 10px; margin-top: 18px; justify-content: flex-end; }
|
| 183 |
+
.btn-primary {
|
| 184 |
+
background: #4ecca3; color: #0a1628; border: none; padding: 8px 18px;
|
| 185 |
+
border-radius: 4px; cursor: pointer; font-weight: 700; font-size: 13px;
|
| 186 |
+
}
|
| 187 |
+
.btn-primary:hover { background: #3ab88e; }
|
| 188 |
+
.btn-secondary {
|
| 189 |
+
background: transparent; color: #a0a0c0; border: 1px solid #1a3a6e;
|
| 190 |
+
padding: 8px 18px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
| 191 |
+
}
|
| 192 |
+
.btn-secondary:hover { color: #e0e0e0; border-color: #4ecca3; }
|
| 193 |
+
.btn-danger {
|
| 194 |
+
background: transparent; color: #e94560; border: 1px solid #e94560;
|
| 195 |
+
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
|
| 196 |
+
}
|
| 197 |
+
.auth-switch { text-align: center; margin-top: 12px; font-size: 12px; color: #666; }
|
| 198 |
+
.auth-switch a { color: #4ecca3; cursor: pointer; text-decoration: underline; }
|
| 199 |
+
.auth-error { color: #e94560; font-size: 12px; margin-top: 8px; }
|
| 200 |
+
#player-panel {
|
| 201 |
+
background: rgba(78,204,163,0.06); border: 1px solid rgba(78,204,163,0.2);
|
| 202 |
+
border-radius: 6px; margin: 8px; padding: 10px 12px; display: none;
|
| 203 |
+
}
|
| 204 |
+
#player-panel .pp-header {
|
| 205 |
+
display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;
|
| 206 |
+
}
|
| 207 |
+
#player-panel .pp-title { color: #4ecca3; font-size: 11px; font-weight: 700; letter-spacing: 1px; }
|
| 208 |
+
#player-panel .pp-name { color: #e0e0e0; font-size: 13px; font-weight: 600; }
|
| 209 |
+
#player-panel .pp-loc { color: #888; font-size: 10px; }
|
| 210 |
+
.pp-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
| 211 |
+
.pp-btn {
|
| 212 |
+
background: #0f3460; color: #a0a0c0; border: 1px solid #1a3a6e;
|
| 213 |
+
padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px;
|
| 214 |
+
}
|
| 215 |
+
.pp-btn:hover { background: #1a4a80; color: #e0e0e0; }
|
| 216 |
+
.pp-move { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
|
| 217 |
+
.pp-move select { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 4px 6px; border-radius: 3px; font-size: 11px; }
|
| 218 |
+
.pp-move button { background: #4ecca3; color: #0a1628; border: none; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: 700; }
|
| 219 |
+
/* Chat panel */
|
| 220 |
+
#chat-panel {
|
| 221 |
+
border-top: 1px solid #1a3a6e; margin-top: 10px; padding-top: 10px; display: none;
|
| 222 |
+
}
|
| 223 |
+
#chat-panel .chat-header { color: #4ecca3; font-size: 11px; font-weight: 700; margin-bottom: 6px; display: flex; justify-content: space-between; }
|
| 224 |
+
#chat-messages { max-height: 180px; overflow-y: auto; font-size: 12px; margin-bottom: 8px; }
|
| 225 |
+
.chat-msg-you { color: #4ecca3; margin: 3px 0; }
|
| 226 |
+
.chat-msg-npc { color: #e0e0e0; margin: 3px 0; }
|
| 227 |
+
.chat-msg-thinking { color: #666; font-style: italic; margin: 3px 0; }
|
| 228 |
+
#chat-input-row { display: flex; gap: 6px; }
|
| 229 |
+
#chat-input { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: inherit; }
|
| 230 |
+
#chat-send { background: #4ecca3; color: #0a1628; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-weight: 700; font-size: 12px; }
|
| 231 |
+
.slider-val { font-size: 12px; color: #4ecca3; min-width: 20px; text-align: right; }
|
| 232 |
</style>
|
| 233 |
</head>
|
| 234 |
<body>
|
|
|
|
| 280 |
<div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div>
|
| 281 |
</div>
|
| 282 |
<div id="tab-agents" class="tab-content active">
|
| 283 |
+
<!-- Player panel (shown when logged in) -->
|
| 284 |
+
<div id="player-panel">
|
| 285 |
+
<div class="pp-header">
|
| 286 |
+
<span class="pp-title">YOU</span>
|
| 287 |
+
<button class="btn-danger" onclick="authLogout()" style="padding:2px 8px;font-size:10px">Logout</button>
|
| 288 |
+
</div>
|
| 289 |
+
<div id="pp-name" class="pp-name">Loading...</div>
|
| 290 |
+
<div id="pp-loc" class="pp-loc"></div>
|
| 291 |
+
<div class="pp-move">
|
| 292 |
+
<select id="pp-move-select"></select>
|
| 293 |
+
<button onclick="playerMove()">Go</button>
|
| 294 |
+
</div>
|
| 295 |
+
<div class="pp-actions">
|
| 296 |
+
<button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button>
|
| 297 |
+
<button class="pp-btn" onclick="openPlansModal()">My Plans</button>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
<div id="agent-detail"></div>
|
| 301 |
</div>
|
| 302 |
<div id="tab-conversations" class="tab-content">
|
|
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
+
<!-- Login / Register modal -->
|
| 311 |
+
<div id="login-modal" class="modal-overlay" style="display:none">
|
| 312 |
+
<div class="modal-box">
|
| 313 |
+
<h2 id="auth-title">Welcome to Soci City</h2>
|
| 314 |
+
<div id="auth-error" class="auth-error" style="display:none"></div>
|
| 315 |
+
<label>Username</label>
|
| 316 |
+
<input type="text" id="auth-username" placeholder="Your name" autocomplete="username">
|
| 317 |
+
<label>Password</label>
|
| 318 |
+
<input type="password" id="auth-password" placeholder="Password" autocomplete="current-password">
|
| 319 |
+
<div class="modal-actions">
|
| 320 |
+
<button class="btn-secondary" onclick="closeLoginModal()">Skip</button>
|
| 321 |
+
<button class="btn-primary" id="auth-btn" onclick="authSubmit()">Login</button>
|
| 322 |
+
</div>
|
| 323 |
+
<div class="auth-switch" id="auth-switch-text">
|
| 324 |
+
No account? <a onclick="toggleAuthMode()">Register</a>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<!-- Agent Profile Editor modal -->
|
| 330 |
+
<div id="profile-modal" class="modal-overlay" style="display:none">
|
| 331 |
+
<div class="modal-box">
|
| 332 |
+
<h2>Your Agent Profile</h2>
|
| 333 |
+
<div class="modal-row">
|
| 334 |
+
<div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div>
|
| 335 |
+
<div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div>
|
| 336 |
+
</div>
|
| 337 |
+
<label>Occupation</label>
|
| 338 |
+
<input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef">
|
| 339 |
+
<label>Background (how you describe yourself)</label>
|
| 340 |
+
<textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea>
|
| 341 |
+
<label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label>
|
| 342 |
+
<input type="range" id="pe-extraversion" min="1" max="10" value="5" oninput="document.getElementById('pe-extra-val').textContent=this.value">
|
| 343 |
+
<label>Agreeableness: <span id="pe-agree-val" class="slider-val">7</span></label>
|
| 344 |
+
<input type="range" id="pe-agreeableness" min="1" max="10" value="7" oninput="document.getElementById('pe-agree-val').textContent=this.value">
|
| 345 |
+
<label>Openness: <span id="pe-open-val" class="slider-val">6</span></label>
|
| 346 |
+
<input type="range" id="pe-openness" min="1" max="10" value="6" oninput="document.getElementById('pe-open-val').textContent=this.value">
|
| 347 |
+
<div class="modal-actions">
|
| 348 |
+
<button class="btn-secondary" onclick="document.getElementById('profile-modal').style.display='none'">Cancel</button>
|
| 349 |
+
<button class="btn-primary" onclick="saveProfile()">Save</button>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<!-- My Plans modal -->
|
| 355 |
+
<div id="plans-modal" class="modal-overlay" style="display:none">
|
| 356 |
+
<div class="modal-box">
|
| 357 |
+
<h2>My Plans</h2>
|
| 358 |
+
<div id="plans-list" style="font-size:12px;color:#a0a0c0;margin-bottom:12px;min-height:40px"></div>
|
| 359 |
+
<label>Add a plan</label>
|
| 360 |
+
<input type="text" id="plans-input" placeholder="e.g. Go to the cafe and meet someone" onkeydown="if(event.key==='Enter')addPlan()">
|
| 361 |
+
<div class="modal-actions">
|
| 362 |
+
<button class="btn-secondary" onclick="document.getElementById('plans-modal').style.display='none'">Close</button>
|
| 363 |
+
<button class="btn-primary" onclick="addPlan()">Add</button>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
<script>
|
| 369 |
// ============================================================
|
| 370 |
// CONFIG
|
|
|
|
| 1962 |
ctx.scale(scale, scale);
|
| 1963 |
if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; }
|
| 1964 |
|
| 1965 |
+
// Gold ring for player-controlled agent
|
| 1966 |
+
if (agent.is_player) {
|
| 1967 |
+
ctx.beginPath();
|
| 1968 |
+
ctx.arc(0, 0, 22, 0, Math.PI * 2);
|
| 1969 |
+
ctx.strokeStyle = '#f0c040';
|
| 1970 |
+
ctx.lineWidth = 2.5;
|
| 1971 |
+
ctx.globalAlpha = 0.8;
|
| 1972 |
+
ctx.stroke();
|
| 1973 |
+
ctx.globalAlpha = 1.0;
|
| 1974 |
+
}
|
| 1975 |
+
|
| 1976 |
// ══════════════════════════════════════════════════════════════
|
| 1977 |
// SLEEPING VIEW — agent lying on a bed
|
| 1978 |
// ══════════════════════════════════════════════════════════════
|
|
|
|
| 2739 |
|
| 2740 |
<div class="section-header">Recent Memories</div>
|
| 2741 |
${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
|
| 2742 |
+
|
| 2743 |
+
${playerToken && playerAgentId && data.id !== playerAgentId ? `
|
| 2744 |
+
<div style="margin-top:10px">
|
| 2745 |
+
<button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button>
|
| 2746 |
+
</div>` : ''}
|
| 2747 |
`;
|
| 2748 |
}
|
| 2749 |
|
|
|
|
| 2849 |
document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00';
|
| 2850 |
|
| 2851 |
agents = data.agents || {};
|
| 2852 |
+
renderPlayerPanel();
|
| 2853 |
|
| 2854 |
const tick = clock.total_ticks || 0;
|
| 2855 |
if (tick !== lastTick) fetchSecondaryData();
|
|
|
|
| 3041 |
}
|
| 3042 |
}
|
| 3043 |
|
| 3044 |
+
// ============================================================
|
| 3045 |
+
// PLAYER / AUTH
|
| 3046 |
+
// ============================================================
|
| 3047 |
+
let playerToken = localStorage.getItem('soci_token') || null;
|
| 3048 |
+
let playerUsername = localStorage.getItem('soci_username') || null;
|
| 3049 |
+
let playerAgentId = localStorage.getItem('soci_agent_id') || null;
|
| 3050 |
+
let chatTargetId = null;
|
| 3051 |
+
let chatHistory = [];
|
| 3052 |
+
let authMode = 'login'; // 'login' or 'register'
|
| 3053 |
+
|
| 3054 |
+
function closeLoginModal() {
|
| 3055 |
+
document.getElementById('login-modal').style.display = 'none';
|
| 3056 |
+
}
|
| 3057 |
+
|
| 3058 |
+
function toggleAuthMode() {
|
| 3059 |
+
authMode = authMode === 'login' ? 'register' : 'login';
|
| 3060 |
+
document.getElementById('auth-title').textContent = authMode === 'login' ? 'Welcome to Soci City' : 'Create Account';
|
| 3061 |
+
document.getElementById('auth-btn').textContent = authMode === 'login' ? 'Login' : 'Register';
|
| 3062 |
+
document.getElementById('auth-switch-text').innerHTML = authMode === 'login'
|
| 3063 |
+
? 'No account? <a onclick="toggleAuthMode()">Register</a>'
|
| 3064 |
+
: 'Have an account? <a onclick="toggleAuthMode()">Login</a>';
|
| 3065 |
+
document.getElementById('auth-error').style.display = 'none';
|
| 3066 |
+
}
|
| 3067 |
+
|
| 3068 |
+
async function authSubmit() {
|
| 3069 |
+
const username = document.getElementById('auth-username').value.trim();
|
| 3070 |
+
const password = document.getElementById('auth-password').value;
|
| 3071 |
+
const errEl = document.getElementById('auth-error');
|
| 3072 |
+
if (!username || !password) { errEl.textContent = 'Please fill in both fields'; errEl.style.display = 'block'; return; }
|
| 3073 |
+
const endpoint = authMode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
| 3074 |
+
try {
|
| 3075 |
+
const res = await fetch(window.location.origin + endpoint, {
|
| 3076 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 3077 |
+
body: JSON.stringify({username, password})
|
| 3078 |
+
});
|
| 3079 |
+
const data = await res.json();
|
| 3080 |
+
if (!res.ok) { errEl.textContent = data.detail || 'Error'; errEl.style.display = 'block'; return; }
|
| 3081 |
+
playerToken = data.token;
|
| 3082 |
+
playerUsername = data.username;
|
| 3083 |
+
playerAgentId = data.agent_id || null;
|
| 3084 |
+
localStorage.setItem('soci_token', playerToken);
|
| 3085 |
+
localStorage.setItem('soci_username', playerUsername);
|
| 3086 |
+
if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId);
|
| 3087 |
+
closeLoginModal();
|
| 3088 |
+
renderPlayerPanel();
|
| 3089 |
+
} catch(e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; }
|
| 3090 |
+
}
|
| 3091 |
+
|
| 3092 |
+
async function authLogout() {
|
| 3093 |
+
if (playerToken) {
|
| 3094 |
+
try { await fetch(window.location.origin + '/api/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+playerToken}}); } catch(e){}
|
| 3095 |
+
}
|
| 3096 |
+
playerToken = playerUsername = playerAgentId = null;
|
| 3097 |
+
localStorage.removeItem('soci_token'); localStorage.removeItem('soci_username'); localStorage.removeItem('soci_agent_id');
|
| 3098 |
+
document.getElementById('player-panel').style.display = 'none';
|
| 3099 |
+
document.getElementById('chat-panel') && (document.getElementById('chat-panel').style.display = 'none');
|
| 3100 |
+
}
|
| 3101 |
+
|
| 3102 |
+
function renderPlayerPanel() {
|
| 3103 |
+
const panel = document.getElementById('player-panel');
|
| 3104 |
+
if (!playerToken) { panel.style.display = 'none'; return; }
|
| 3105 |
+
panel.style.display = 'block';
|
| 3106 |
+
const agent = playerAgentId ? agents[playerAgentId] : null;
|
| 3107 |
+
document.getElementById('pp-name').textContent = agent ? agent.name : (playerUsername || 'You');
|
| 3108 |
+
const locName = agent ? (locations[agent.location]?.name || agent.location || '') : '';
|
| 3109 |
+
document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : '';
|
| 3110 |
+
|
| 3111 |
+
// Populate move dropdown from known locations
|
| 3112 |
+
const sel = document.getElementById('pp-move-select');
|
| 3113 |
+
const locKeys = Object.keys(locations);
|
| 3114 |
+
if (locKeys.length > 0) {
|
| 3115 |
+
sel.innerHTML = locKeys.map(lid => {
|
| 3116 |
+
const ln = locations[lid]?.name || lid;
|
| 3117 |
+
const selected = agent && agent.location === lid ? ' selected' : '';
|
| 3118 |
+
return `<option value="${lid}"${selected}>${ln}</option>`;
|
| 3119 |
+
}).join('');
|
| 3120 |
+
}
|
| 3121 |
+
}
|
| 3122 |
+
|
| 3123 |
+
async function playerMove() {
|
| 3124 |
+
if (!playerToken || !playerAgentId) return;
|
| 3125 |
+
const location = document.getElementById('pp-move-select').value;
|
| 3126 |
+
try {
|
| 3127 |
+
const res = await fetch(window.location.origin + '/api/player/move', {
|
| 3128 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 3129 |
+
body: JSON.stringify({token: playerToken, location})
|
| 3130 |
+
});
|
| 3131 |
+
if (res.ok) renderPlayerPanel();
|
| 3132 |
+
} catch(e){}
|
| 3133 |
+
}
|
| 3134 |
+
|
| 3135 |
+
function openProfileEditor() {
|
| 3136 |
+
if (!playerAgentId) return;
|
| 3137 |
+
const agent = agents[playerAgentId];
|
| 3138 |
+
if (agent) {
|
| 3139 |
+
document.getElementById('pe-name').value = agent.name || '';
|
| 3140 |
+
document.getElementById('pe-age').value = agent.age || 30;
|
| 3141 |
+
document.getElementById('pe-occupation').value = agent.occupation || '';
|
| 3142 |
+
}
|
| 3143 |
+
document.getElementById('profile-modal').style.display = 'flex';
|
| 3144 |
+
}
|
| 3145 |
+
|
| 3146 |
+
async function saveProfile() {
|
| 3147 |
+
if (!playerToken) return;
|
| 3148 |
+
const body = {
|
| 3149 |
+
token: playerToken,
|
| 3150 |
+
name: document.getElementById('pe-name').value.trim(),
|
| 3151 |
+
age: parseInt(document.getElementById('pe-age').value)||30,
|
| 3152 |
+
occupation: document.getElementById('pe-occupation').value.trim(),
|
| 3153 |
+
background: document.getElementById('pe-background').value.trim(),
|
| 3154 |
+
extraversion: parseInt(document.getElementById('pe-extraversion').value),
|
| 3155 |
+
agreeableness: parseInt(document.getElementById('pe-agreeableness').value),
|
| 3156 |
+
openness: parseInt(document.getElementById('pe-openness').value),
|
| 3157 |
+
};
|
| 3158 |
+
try {
|
| 3159 |
+
const res = await fetch(window.location.origin + '/api/player/update', {
|
| 3160 |
+
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
| 3161 |
+
});
|
| 3162 |
+
if (res.ok) { document.getElementById('profile-modal').style.display='none'; renderPlayerPanel(); }
|
| 3163 |
+
} catch(e){}
|
| 3164 |
+
}
|
| 3165 |
+
|
| 3166 |
+
function openPlansModal() {
|
| 3167 |
+
if (!playerAgentId) return;
|
| 3168 |
+
const agent = agents[playerAgentId];
|
| 3169 |
+
const plans = agent?.daily_plan || [];
|
| 3170 |
+
const listEl = document.getElementById('plans-list');
|
| 3171 |
+
listEl.innerHTML = plans.length > 0
|
| 3172 |
+
? plans.map((p,i) => `<div style="padding:3px 0;border-bottom:1px solid #1a3a6e">${i+1}. ${esc(p)}</div>`).join('')
|
| 3173 |
+
: '<div style="color:#555">No plans yet.</div>';
|
| 3174 |
+
document.getElementById('plans-modal').style.display = 'flex';
|
| 3175 |
+
}
|
| 3176 |
+
|
| 3177 |
+
async function addPlan() {
|
| 3178 |
+
if (!playerToken) return;
|
| 3179 |
+
const input = document.getElementById('plans-input');
|
| 3180 |
+
const item = input.value.trim();
|
| 3181 |
+
if (!item) return;
|
| 3182 |
+
try {
|
| 3183 |
+
const res = await fetch(window.location.origin + '/api/player/plan', {
|
| 3184 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 3185 |
+
body: JSON.stringify({token: playerToken, plan_item: item})
|
| 3186 |
+
});
|
| 3187 |
+
if (res.ok) { input.value = ''; openPlansModal(); }
|
| 3188 |
+
} catch(e){}
|
| 3189 |
+
}
|
| 3190 |
+
|
| 3191 |
+
// Chat with an NPC
|
| 3192 |
+
function openChat(targetId) {
|
| 3193 |
+
chatTargetId = targetId;
|
| 3194 |
+
chatHistory = [];
|
| 3195 |
+
const target = agents[targetId];
|
| 3196 |
+
let chatPanel = document.getElementById('chat-panel');
|
| 3197 |
+
if (!chatPanel) {
|
| 3198 |
+
chatPanel = document.createElement('div');
|
| 3199 |
+
chatPanel.id = 'chat-panel';
|
| 3200 |
+
document.getElementById('agent-detail').after(chatPanel);
|
| 3201 |
+
}
|
| 3202 |
+
chatPanel.style.display = 'block';
|
| 3203 |
+
chatPanel.innerHTML = `
|
| 3204 |
+
<div class="chat-header">
|
| 3205 |
+
<span>Talking to ${esc(target?.name || targetId)}</span>
|
| 3206 |
+
<span style="cursor:pointer;color:#888" onclick="closeChat()">✕ Close</span>
|
| 3207 |
+
</div>
|
| 3208 |
+
<div id="chat-messages"></div>
|
| 3209 |
+
<div id="chat-input-row">
|
| 3210 |
+
<input id="chat-input" type="text" placeholder="Say something..." onkeydown="if(event.key==='Enter')sendChat()">
|
| 3211 |
+
<button id="chat-send" onclick="sendChat()">Send</button>
|
| 3212 |
+
</div>`;
|
| 3213 |
+
}
|
| 3214 |
+
|
| 3215 |
+
function closeChat() {
|
| 3216 |
+
chatTargetId = null;
|
| 3217 |
+
const p = document.getElementById('chat-panel');
|
| 3218 |
+
if (p) p.style.display = 'none';
|
| 3219 |
+
}
|
| 3220 |
+
|
| 3221 |
+
function appendChatMsg(cls, speaker, text) {
|
| 3222 |
+
const el = document.getElementById('chat-messages');
|
| 3223 |
+
if (!el) return;
|
| 3224 |
+
const div = document.createElement('div');
|
| 3225 |
+
div.className = cls;
|
| 3226 |
+
div.innerHTML = `<b>${esc(speaker)}:</b> ${esc(text)}`;
|
| 3227 |
+
el.appendChild(div);
|
| 3228 |
+
el.scrollTop = el.scrollHeight;
|
| 3229 |
+
}
|
| 3230 |
+
|
| 3231 |
+
async function sendChat() {
|
| 3232 |
+
if (!playerToken || !playerAgentId || !chatTargetId) return;
|
| 3233 |
+
const input = document.getElementById('chat-input');
|
| 3234 |
+
const message = input.value.trim();
|
| 3235 |
+
if (!message) return;
|
| 3236 |
+
input.value = '';
|
| 3237 |
+
input.disabled = true;
|
| 3238 |
+
document.getElementById('chat-send').disabled = true;
|
| 3239 |
+
appendChatMsg('chat-msg-you', playerUsername || 'You', message);
|
| 3240 |
+
appendChatMsg('chat-msg-thinking', agents[chatTargetId]?.name || '...', '...');
|
| 3241 |
+
try {
|
| 3242 |
+
const res = await fetch(window.location.origin + '/api/player/talk', {
|
| 3243 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 3244 |
+
body: JSON.stringify({token: playerToken, target_id: chatTargetId, message})
|
| 3245 |
+
});
|
| 3246 |
+
const el = document.getElementById('chat-messages');
|
| 3247 |
+
if (el && el.lastChild) el.lastChild.remove(); // remove thinking...
|
| 3248 |
+
if (res.ok) {
|
| 3249 |
+
const data = await res.json();
|
| 3250 |
+
appendChatMsg('chat-msg-npc', agents[chatTargetId]?.name || chatTargetId, data.reply || '(no reply)');
|
| 3251 |
+
} else {
|
| 3252 |
+
appendChatMsg('chat-msg-thinking', 'System', 'Could not get a response right now.');
|
| 3253 |
+
}
|
| 3254 |
+
} catch(e) {
|
| 3255 |
+
const el = document.getElementById('chat-messages');
|
| 3256 |
+
if (el && el.lastChild) el.lastChild.remove();
|
| 3257 |
+
appendChatMsg('chat-msg-thinking', 'System', 'Connection error.');
|
| 3258 |
+
}
|
| 3259 |
+
input.disabled = false;
|
| 3260 |
+
document.getElementById('chat-send').disabled = false;
|
| 3261 |
+
input.focus();
|
| 3262 |
+
}
|
| 3263 |
+
|
| 3264 |
+
async function checkSession() {
|
| 3265 |
+
if (!playerToken) { document.getElementById('login-modal').style.display = 'flex'; return; }
|
| 3266 |
+
try {
|
| 3267 |
+
const res = await fetch(window.location.origin + '/api/auth/me', {headers:{'Authorization':'Bearer '+playerToken}});
|
| 3268 |
+
if (res.ok) {
|
| 3269 |
+
const data = await res.json();
|
| 3270 |
+
playerUsername = data.username;
|
| 3271 |
+
playerAgentId = data.agent_id;
|
| 3272 |
+
if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId);
|
| 3273 |
+
renderPlayerPanel();
|
| 3274 |
+
} else {
|
| 3275 |
+
// Token expired
|
| 3276 |
+
playerToken = null; localStorage.removeItem('soci_token');
|
| 3277 |
+
document.getElementById('login-modal').style.display = 'flex';
|
| 3278 |
+
}
|
| 3279 |
+
} catch(e) { renderPlayerPanel(); }
|
| 3280 |
+
}
|
| 3281 |
+
|
| 3282 |
// ============================================================
|
| 3283 |
// INIT
|
| 3284 |
// ============================================================
|
|
|
|
| 3288 |
fetchControls();
|
| 3289 |
connectWebSocket();
|
| 3290 |
setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
|
| 3291 |
+
checkSession();
|
| 3292 |
</script>
|
| 3293 |
</body>
|
| 3294 |
</html>
|