RayMelius Claude Sonnet 4.6 commited on
Commit
c72bb17
·
1 Parent(s): afde232

Add player login, agent creation, and NPC chat

Browse files

Backend:
- 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 CHANGED
@@ -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."""
src/soci/persistence/database.py CHANGED
@@ -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()
web/index.html CHANGED
@@ -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>