sh4shv4t commited on
Commit
14577ec
Β·
1 Parent(s): dd46a0d

fix: fixed UI bugs, keyless testing initiated

Browse files
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install deps first (layer-cached)
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY . .
10
+
11
+ # Initialise the database at build time
12
+ RUN python -m scripts.init_db
13
+
14
+ # startup script
15
+ RUN chmod +x scripts/start.sh
16
+
17
+ # HF Spaces exposes port 7860
18
+ EXPOSE 7860
19
+
20
+ CMD ["bash", "scripts/start.sh"]
Makefile CHANGED
@@ -45,6 +45,12 @@ train-grpo:
45
  evaluate:
46
  venv-train\Scripts\python -m training.evaluate --base Qwen/Qwen2.5-7B-Instruct --sft models/parlay-sft --grpo models/parlay-grpo --data data/episodes.jsonl --output results/eval_results.json
47
 
 
 
 
 
 
 
48
  clean:
49
  if exist venv rd /s /q venv
50
  if exist venv-train rd /s /q venv-train
 
45
  evaluate:
46
  venv-train\Scripts\python -m training.evaluate --base Qwen/Qwen2.5-7B-Instruct --sft models/parlay-sft --grpo models/parlay-grpo --data data/episodes.jsonl --output results/eval_results.json
47
 
48
+ test-keyless:
49
+ venv\Scripts\pytest tests\test_keyless.py -v
50
+
51
+ docker-test:
52
+ docker build -t parlay-test . && docker run -p 7860:7860 --env-file .env parlay-test
53
+
54
  clean:
55
  if exist venv rd /s /q venv
56
  if exist venv-train rd /s /q venv-train
README.md CHANGED
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  # Parlay β€” The RL Negotiation Arena
2
 
3
  > **The arena where AIs learn to close.**
@@ -736,6 +745,38 @@ GRPO (Group Relative Policy Optimization) eliminates the need for a separate cri
736
 
737
  ---
738
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  ## Contributing
740
 
741
  1. Fork the repo and create a feature branch
 
1
+ ---
2
+ title: Parlay
3
+ emoji: β—ˆ
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
  # Parlay β€” The RL Negotiation Arena
11
 
12
  > **The arena where AIs learn to close.**
 
745
 
746
  ---
747
 
748
+ ## Testing Without API Keys
749
+
750
+ Everything in Parlay runs in **mock mode** when `GOOGLE_API_KEY` is not set.
751
+ Mock mode returns canned persona-consistent responses so you can play and test
752
+ the full game loop without any external account.
753
+
754
+ ```bash
755
+ # 1. Set up the game environment
756
+ make setup
757
+
758
+ # 2. Run the keyless test suite (zero API calls)
759
+ make test-keyless
760
+
761
+ # 3. Start the server in mock mode
762
+ make run
763
+
764
+ # 4. Open the game in your browser
765
+ # β†’ http://localhost:8000
766
+ # A "Demo mode" banner confirms mock mode is active.
767
+ ```
768
+
769
+ To switch to real AI: add `GOOGLE_API_KEY=your_key` to `.env` and restart.
770
+
771
+ To test the exact HF Spaces container locally before pushing:
772
+
773
+ ```bash
774
+ make docker-test
775
+ # β†’ http://localhost:7860
776
+ ```
777
+
778
+ ---
779
+
780
  ## Contributing
781
 
782
  1. Fork the repo and create a feature branch
agent/gemini_client.py CHANGED
@@ -1,6 +1,9 @@
1
  """
2
  Google Gemini 2.0 Flash client for Parlay.
3
- Uses the google-genai SDK. All calls are async (via run_in_executor). All errors return SYNTHETIC_RESPONSE.
 
 
 
4
  """
5
  import asyncio
6
  import json
@@ -8,33 +11,112 @@ import logging
8
  import os
9
  from typing import Optional
10
 
11
- from google import genai
12
- from google.genai import types
13
-
14
  logger = logging.getLogger(__name__)
15
 
16
  MODEL_ID = "gemini-2.0-flash"
17
 
18
- _client: Optional[genai.Client] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
- def _get_client() -> genai.Client:
22
- """Lazily construct API client (empty key is allowed; calls then fail and return synthetic output)."""
 
 
23
  global _client
24
  if _client is None:
 
25
  _client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY") or "")
26
  return _client
27
 
28
 
29
- def _legacy_messages_to_history(messages: list[dict]) -> list[types.Content]:
30
  """Convert legacy {'role','parts'} messages to google-genai Content list."""
31
- contents: list[types.Content] = []
 
 
32
  for m in messages:
33
  role = m.get("role", "user")
34
  if role not in ("user", "model"):
35
  role = "user"
36
  raw_parts = m.get("parts") or []
37
- parts: list[types.Part] = []
38
  for p in raw_parts:
39
  text = p if isinstance(p, str) else str(p)
40
  parts.append(types.Part(text=text))
@@ -44,31 +126,35 @@ def _legacy_messages_to_history(messages: list[dict]) -> list[types.Content]:
44
  return contents
45
 
46
 
47
- SYNTHETIC_RESPONSE: dict = {
48
- "utterance": "I need a moment to consider your proposal.",
49
- "offer_amount": None,
50
- "tactical_move": None,
51
- }
52
-
53
 
54
  async def call_gemini(
55
  system_prompt: str,
56
  messages: list[dict],
57
  max_tokens: int = 500,
 
58
  ) -> dict:
59
  """
60
  Call Gemini 2.0 Flash with a system prompt and message history.
 
61
 
62
  Args:
63
  system_prompt: Persona + scenario context string.
64
  messages: List of {"role": "user"|"model", "parts": ["..."]} dicts.
65
  max_tokens: Maximum output tokens for the response.
 
66
 
67
  Returns:
68
  Parsed dict with keys: utterance (str), offer_amount (float|None),
69
  tactical_move (str|None). Returns SYNTHETIC_RESPONSE on any error.
70
  """
 
 
 
 
71
  try:
 
 
72
  history = messages[:-1] if len(messages) > 1 else []
73
  last_msg = messages[-1]["parts"][0] if messages else "Begin the negotiation."
74
 
@@ -80,7 +166,7 @@ async def call_gemini(
80
  )
81
  user_message = f"{full_prompt}\n\nUser: {last_msg}"
82
 
83
- def _call() -> types.GenerateContentResponse:
84
  chat = _get_client().chats.create(
85
  model=MODEL_ID,
86
  history=_legacy_messages_to_history(history),
@@ -127,6 +213,7 @@ async def call_gemini_tom(
127
  ) -> dict:
128
  """
129
  Call Gemini to infer Theory of Mind beliefs about the opponent.
 
130
 
131
  Args:
132
  system_prompt: Base persona context.
@@ -137,6 +224,9 @@ async def call_gemini_tom(
137
  Updated belief dict: {est_budget, est_walk_away, est_urgency,
138
  est_has_alternative, confidence}.
139
  """
 
 
 
140
  tom_prompt = (
141
  f"{system_prompt}\n\n"
142
  f"THEORY OF MIND TASK:\n"
@@ -149,7 +239,9 @@ async def call_gemini_tom(
149
  )
150
 
151
  try:
152
- def _call() -> types.GenerateContentResponse:
 
 
153
  chat = _get_client().chats.create(
154
  model=MODEL_ID,
155
  history=_legacy_messages_to_history(conversation_history),
 
1
  """
2
  Google Gemini 2.0 Flash client for Parlay.
3
+ Uses the google-genai SDK. All calls are async (via run_in_executor).
4
+ All errors return SYNTHETIC_RESPONSE.
5
+ When GOOGLE_API_KEY is absent, MOCK_RESPONSES are returned so the full game
6
+ loop works without any API key.
7
  """
8
  import asyncio
9
  import json
 
11
  import os
12
  from typing import Optional
13
 
 
 
 
14
  logger = logging.getLogger(__name__)
15
 
16
  MODEL_ID = "gemini-2.0-flash"
17
 
18
+ _client = None
19
+ _mock_warned: bool = False
20
+
21
+ # ── Mock responses (keyless dev / CI) ────────────────────────────────────────
22
+
23
+ MOCK_RESPONSES: dict[str, list[dict]] = {
24
+ "shark": [
25
+ {"utterance": "Let's not waste each other's time. Here's where I stand β€” this number isn't moving much.", "offer_amount": None, "tactical_move": "anchor_high"},
26
+ {"utterance": "I have three other parties at the table. You'd better sharpen your pencil.", "offer_amount": None, "tactical_move": "batna_reveal"},
27
+ {"utterance": "That's a creative offer. Not good enough, but creative.", "offer_amount": None, "tactical_move": None},
28
+ {"utterance": "I've been in rooms like this before. Come back with something real.", "offer_amount": None, "tactical_move": None},
29
+ {"utterance": "Fine. One last move from me. This is the ceiling.", "offer_amount": None, "tactical_move": "anchor_high"},
30
+ ],
31
+ "diplomat": [
32
+ {"utterance": "I believe we can find something that works for both of us. Let's explore the range.", "offer_amount": None, "tactical_move": None},
33
+ {"utterance": "I appreciate you sharing that. Could we consider packaging some non-price elements?", "offer_amount": None, "tactical_move": "sweetener"},
34
+ {"utterance": "We're actually closer than it seems. I'm willing to move if you can meet me halfway.", "offer_amount": None, "tactical_move": None},
35
+ {"utterance": "Here's a revised proposal that I think reflects your concerns.", "offer_amount": None, "tactical_move": "reframe"},
36
+ {"utterance": "I think we've built enough trust here. Let me share something that might help us close.", "offer_amount": None, "tactical_move": None},
37
+ ],
38
+ "analyst": [
39
+ {"utterance": "I've modeled this extensively. The numbers you're presenting don't align with market benchmarks.", "offer_amount": None, "tactical_move": None},
40
+ {"utterance": "Can you provide the data backing that position? I need to validate before proceeding.", "offer_amount": None, "tactical_move": None},
41
+ {"utterance": "Based on comparable transactions, the fair value range is well-established.", "offer_amount": None, "tactical_move": "reframe"},
42
+ {"utterance": "The variance in your offer exceeds two standard deviations from the median.", "offer_amount": None, "tactical_move": None},
43
+ {"utterance": "I've run the numbers three ways. Here is the only figure that makes sense.", "offer_amount": None, "tactical_move": "anchor_high"},
44
+ ],
45
+ "wildcard": [
46
+ {"utterance": "You know what? Let's just see where this goes. I feel good about today.", "offer_amount": None, "tactical_move": None},
47
+ {"utterance": "Honestly I wasn't expecting that. Actually, you know what β€” here's a thought.", "offer_amount": None, "tactical_move": None},
48
+ {"utterance": "Something you said changed my thinking entirely. I'm going a different direction.", "offer_amount": None, "tactical_move": "reframe"},
49
+ {"utterance": "This is either a great deal or a terrible one. I genuinely can't tell. Let's find out.", "offer_amount": None, "tactical_move": None},
50
+ {"utterance": "Sure, why not. But I want something extra thrown in β€” something creative.", "offer_amount": None, "tactical_move": "sweetener"},
51
+ ],
52
+ "veteran": [
53
+ {"utterance": "I've been in this room many times. I know what fair looks like and that isn't it.", "offer_amount": None, "tactical_move": None},
54
+ {"utterance": "…I'll give you a moment to reconsider that position.", "offer_amount": None, "tactical_move": "silence"},
55
+ {"utterance": "I've seen every tactic in the book. Let's cut to what we're both actually thinking.", "offer_amount": None, "tactical_move": None},
56
+ {"utterance": "Experience tells me we're about three moves from a deal.", "offer_amount": None, "tactical_move": "reframe"},
57
+ {"utterance": "You're good. But I've been doing this longer. Here's my considered response.", "offer_amount": None, "tactical_move": None},
58
+ ],
59
+ }
60
+
61
+ SYNTHETIC_RESPONSE: dict = {
62
+ "utterance": "I need a moment to consider your proposal.",
63
+ "offer_amount": None,
64
+ "tactical_move": None,
65
+ }
66
+
67
+
68
+ # ── Helper: mock mode detection ───────────────────────────────────────────────
69
+
70
+ def _is_mock_mode() -> bool:
71
+ """Return True when GOOGLE_API_KEY is absent or empty."""
72
+ return not os.environ.get("GOOGLE_API_KEY", "").strip()
73
+
74
+
75
+ def _get_mock_response(persona: str, turn: int) -> dict:
76
+ """
77
+ Return a canned response for keyless dev.
78
+
79
+ Args:
80
+ persona: Persona key (shark / diplomat / analyst / wildcard / veteran).
81
+ turn: Current turn count β€” used to cycle through the 5 canned lines.
82
+
83
+ Returns:
84
+ Copy of the canned response dict.
85
+ """
86
+ global _mock_warned
87
+ if not _mock_warned:
88
+ logger.warning(
89
+ "GOOGLE_API_KEY not set β€” using mock responses. "
90
+ "Set key in .env for real AI."
91
+ )
92
+ _mock_warned = True
93
+
94
+ responses = MOCK_RESPONSES.get(persona, MOCK_RESPONSES["shark"])
95
+ return dict(responses[turn % len(responses)])
96
 
97
 
98
+ # ── Lazy client ──────────────────────────────────────────────────────────────
99
+
100
+ def _get_client():
101
+ """Lazily construct the Gemini client (import deferred so tests don't need SDK)."""
102
  global _client
103
  if _client is None:
104
+ from google import genai # noqa: PLC0415
105
  _client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY") or "")
106
  return _client
107
 
108
 
109
+ def _legacy_messages_to_history(messages: list[dict]) -> list:
110
  """Convert legacy {'role','parts'} messages to google-genai Content list."""
111
+ from google.genai import types # noqa: PLC0415
112
+
113
+ contents: list = []
114
  for m in messages:
115
  role = m.get("role", "user")
116
  if role not in ("user", "model"):
117
  role = "user"
118
  raw_parts = m.get("parts") or []
119
+ parts: list = []
120
  for p in raw_parts:
121
  text = p if isinstance(p, str) else str(p)
122
  parts.append(types.Part(text=text))
 
126
  return contents
127
 
128
 
129
+ # ── Public API ───────────────────────────────────────────────────────────────
 
 
 
 
 
130
 
131
  async def call_gemini(
132
  system_prompt: str,
133
  messages: list[dict],
134
  max_tokens: int = 500,
135
+ persona: str = "shark",
136
  ) -> dict:
137
  """
138
  Call Gemini 2.0 Flash with a system prompt and message history.
139
+ Returns mock responses when GOOGLE_API_KEY is not set.
140
 
141
  Args:
142
  system_prompt: Persona + scenario context string.
143
  messages: List of {"role": "user"|"model", "parts": ["..."]} dicts.
144
  max_tokens: Maximum output tokens for the response.
145
+ persona: Persona name used for mock-mode selection.
146
 
147
  Returns:
148
  Parsed dict with keys: utterance (str), offer_amount (float|None),
149
  tactical_move (str|None). Returns SYNTHETIC_RESPONSE on any error.
150
  """
151
+ if _is_mock_mode():
152
+ return _get_mock_response(persona, len(messages))
153
+
154
+ text = ""
155
  try:
156
+ from google.genai import types # noqa: PLC0415
157
+
158
  history = messages[:-1] if len(messages) > 1 else []
159
  last_msg = messages[-1]["parts"][0] if messages else "Begin the negotiation."
160
 
 
166
  )
167
  user_message = f"{full_prompt}\n\nUser: {last_msg}"
168
 
169
+ def _call():
170
  chat = _get_client().chats.create(
171
  model=MODEL_ID,
172
  history=_legacy_messages_to_history(history),
 
213
  ) -> dict:
214
  """
215
  Call Gemini to infer Theory of Mind beliefs about the opponent.
216
+ Returns current_state unchanged in mock mode.
217
 
218
  Args:
219
  system_prompt: Base persona context.
 
224
  Updated belief dict: {est_budget, est_walk_away, est_urgency,
225
  est_has_alternative, confidence}.
226
  """
227
+ if _is_mock_mode():
228
+ return current_state
229
+
230
  tom_prompt = (
231
  f"{system_prompt}\n\n"
232
  f"THEORY OF MIND TASK:\n"
 
239
  )
240
 
241
  try:
242
+ from google.genai import types # noqa: PLC0415
243
+
244
+ def _call():
245
  chat = _get_client().chats.create(
246
  model=MODEL_ID,
247
  history=_legacy_messages_to_history(conversation_history),
dashboard/api.py CHANGED
@@ -509,3 +509,145 @@ async def get_leaderboard(scenario_id: Optional[str] = None, limit: int = 10) ->
509
  async def health() -> dict:
510
  """Health check endpoint."""
511
  return {"status": "ok", "service": "parlay-dashboard"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  async def health() -> dict:
510
  """Health check endpoint."""
511
  return {"status": "ok", "service": "parlay-dashboard"}
512
+
513
+
514
+ # ── Unified game step (used by frontend JS) ──────────────────────────────────
515
+
516
+ class GameStepRequest(BaseModel):
517
+ session_id: str
518
+ move: str = "counter"
519
+ offer_amount: Optional[float] = None
520
+ card_id: Optional[str] = None
521
+
522
+
523
+ @router.post("/game/step")
524
+ async def game_step(req: GameStepRequest) -> dict:
525
+ """
526
+ Unified step endpoint β€” dispatches to move / accept / walkaway.
527
+
528
+ Args:
529
+ session_id: Active session UUID.
530
+ move: "counter" | "anchor" | "concede" | "package" | "accept" | "walk_away".
531
+ offer_amount: Required for counter / anchor / concede / package moves.
532
+ card_id: Optional tactical card identifier.
533
+
534
+ Returns:
535
+ Opponent response, updated observation, and done flag.
536
+ """
537
+ match req.move:
538
+ case "accept":
539
+ return await accept_deal(AcceptRequest(session_id=req.session_id))
540
+ case "walk_away":
541
+ return await walk_away(WalkAwayRequest(session_id=req.session_id))
542
+ case _:
543
+ if req.offer_amount is None:
544
+ raise HTTPException(status_code=400, detail="offer_amount required for counter moves")
545
+ tactic: Optional[str] = None
546
+ if req.move == "anchor":
547
+ tactic = "anchor_high"
548
+ return await make_move(MoveRequest(
549
+ session_id=req.session_id,
550
+ amount=req.offer_amount,
551
+ message=req.move,
552
+ tactical_move=tactic,
553
+ ))
554
+
555
+
556
+ # ── Session API (simplified, used by tests and external integrations) ─────────
557
+
558
+ class SessionStartRequest(BaseModel):
559
+ scenario_id: str = "saas_enterprise"
560
+ persona: str = "shark"
561
+ player_name: str = "Player"
562
+
563
+
564
+ class SessionStepRequest(BaseModel):
565
+ amount: float = 145_000.0
566
+ message: str = "I propose this amount."
567
+ tactical_move: Optional[str] = None
568
+
569
+
570
+ @router.post("/session/start")
571
+ async def session_start(req: SessionStartRequest) -> dict:
572
+ """
573
+ Start a new session (simplified API).
574
+ Works in both mock and live Gemini mode.
575
+
576
+ Args:
577
+ scenario_id: One of the five scenario IDs.
578
+ persona: One of the five persona IDs.
579
+ player_name: Display name for the player.
580
+
581
+ Returns:
582
+ session_id and status.
583
+ """
584
+ try:
585
+ session_id, sess = _build_session(req.scenario_id, req.persona, req.player_name)
586
+ except (InvalidScenarioError, InvalidPersonaError) as exc:
587
+ raise HTTPException(status_code=400, detail=str(exc))
588
+ except Exception as exc:
589
+ logger.error(f"Unexpected error in session/start: {exc}")
590
+ raise HTTPException(status_code=500, detail="Internal server error")
591
+
592
+ _sessions[session_id] = sess
593
+ logger.info(f"Session started (simplified): {session_id}, {req.scenario_id}/{req.persona}")
594
+ return {"session_id": session_id, "status": "ok"}
595
+
596
+
597
+ @router.post("/session/{session_id}/step")
598
+ async def session_step(session_id: str, req: SessionStepRequest) -> dict:
599
+ """
600
+ Execute one negotiation step in a session.
601
+ Returns a mock AI response when GOOGLE_API_KEY is absent.
602
+
603
+ Args:
604
+ session_id: Active session UUID from /session/start.
605
+ amount: Player's offer amount.
606
+ message: Player's utterance.
607
+ tactical_move: Optional tactical move string.
608
+
609
+ Returns:
610
+ observation dict and opponent response.
611
+ """
612
+ if session_id not in _sessions:
613
+ raise HTTPException(status_code=404, detail="Session not found")
614
+
615
+ sess = _sessions[session_id]
616
+ if sess["done"]:
617
+ raise HTTPException(status_code=400, detail="Episode already concluded")
618
+
619
+ turn = sess["step_count"]
620
+ gemini_messages = [
621
+ {"role": "user", "parts": [f"Offer: {req.amount:,.0f}. {req.message}"]}
622
+ ]
623
+ opponent_resp = await call_gemini(
624
+ sess["system_prompt"],
625
+ gemini_messages,
626
+ persona=sess["persona"],
627
+ )
628
+
629
+ sess["offer_history"].append(req.amount)
630
+ sess["step_count"] += 1
631
+ sess["done"] = sess["step_count"] >= MAX_TURNS
632
+ _sessions[session_id] = sess
633
+
634
+ hidden = sess["hidden"]
635
+ zopa = compute_zopa(hidden.budget_ceiling, hidden.walk_away_price)
636
+ nash = compute_nash_bargaining_solution(hidden.budget_ceiling, hidden.walk_away_price)
637
+ tension = min(100.0, 20.0 + (sess["step_count"] / MAX_TURNS) * 80.0)
638
+
639
+ return {
640
+ "observation": {
641
+ "step_count": sess["step_count"],
642
+ "zopa_lower": zopa[0] if zopa else 0.0,
643
+ "zopa_upper": zopa[1] if zopa else 0.0,
644
+ "nash_point": nash,
645
+ "tension_score": tension,
646
+ "belief_state": sess["tom"].current_belief.model_dump(),
647
+ "credibility_points": sess["credibility_points"],
648
+ "act": sess["act"],
649
+ },
650
+ "opponent": opponent_resp,
651
+ "done": sess["done"],
652
+ "turns_remaining": MAX_TURNS - sess["step_count"],
653
+ }
dashboard/index.html CHANGED
@@ -1,82 +1,110 @@
1
  <!DOCTYPE html>
2
- <html lang="en" data-theme="light">
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="description" content="Parlay β€” RL Negotiation Game. Bloomberg terminal meets poker app." />
7
- <title>Parlay β€” Negotiation Arena</title>
8
 
9
- <!-- External: Three.js r128 (loaded first β€” character.js depends on it) -->
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
11
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
12
 
13
- <!-- External: Chart.js 4.4.1 -->
14
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"
15
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
16
 
17
- <!-- Parlay styles -->
18
- <link rel="stylesheet" href="/static/style.css" />
19
  </head>
20
  <body>
21
 
 
 
 
 
 
 
 
 
22
  <!-- ═══════════════════════════════════════════════════════════
23
  LOADING OVERLAY
24
  ════════════════════════════════════════════════════════════ -->
25
  <div id="loading-overlay" class="loading-overlay hidden" role="status" aria-live="polite">
26
  <div class="loading-card">
27
  <div class="spinner" aria-hidden="true"></div>
28
- <p class="loading-text">Gemini is thinking…</p>
29
  </div>
30
  </div>
31
 
32
  <!-- ═══════════════════════════════════════════════════════════
33
- SETUP MODAL
34
  ════════════════════════════════════════════════════════════ -->
35
- <div id="setup-modal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-title">
36
- <div class="modal">
37
- <div class="modal-header">
38
- <h1 class="modal-title" id="modal-title">Welcome to Parlay</h1>
39
- <p class="modal-subtitle">An RL-powered negotiation arena. Choose your deal, choose your style.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  </div>
 
 
41
 
42
- <div class="modal-body">
43
- <!-- Player name -->
44
- <div class="form-group">
45
- <label class="form-label" for="player-name-input">Your Name</label>
46
- <input
47
- type="text"
48
- id="player-name-input"
49
- class="form-input"
50
- placeholder="e.g. Jordan Weiss"
51
- maxlength="40"
52
- autocomplete="off"
53
- />
54
- </div>
55
 
56
- <!-- Scenario selection -->
57
- <div class="form-group">
58
- <div class="form-label">Select a Scenario</div>
59
- <div id="scenario-grid" class="selector-grid">
60
- <!-- Populated by loadScenarios() β€” fallbacks also included -->
61
- </div>
62
- </div>
63
 
64
- <!-- Persona selection -->
65
- <div class="form-group">
66
- <div class="form-label">Choose Your Negotiation Style</div>
67
- <div id="persona-grid" class="persona-grid">
68
- <!-- Populated by loadPersonas() -->
69
- </div>
70
- </div>
71
 
72
- <!-- Error placeholder -->
73
- <p id="modal-error" class="hidden text-red text-sm" role="alert"></p>
 
 
 
 
 
 
 
 
 
74
  </div>
75
 
76
- <div class="modal-footer">
77
- <button id="btn-start-game" class="btn btn-primary" type="button">
78
- Enter the Room &rarr;
79
- </button>
 
80
  </div>
81
  </div>
82
  </div>
@@ -97,13 +125,8 @@
97
 
98
  <div class="header-actions">
99
  <p id="global-error" class="hidden text-red text-sm" role="alert"></p>
100
- <button
101
- id="dark-toggle"
102
- class="dark-toggle"
103
- type="button"
104
- aria-label="Toggle dark mode"
105
- title="Toggle dark mode"
106
- ></button>
107
  </div>
108
  </header>
109
 
@@ -125,13 +148,12 @@
125
  </div>
126
  </div>
127
 
128
- <!-- CP Bar -->
129
  <div class="cp-section">
130
  <div class="cp-label-row">
131
- <span class="cp-label">Cognitive Points</span>
132
  <span id="cp-value" class="cp-value mono">100 / 100</span>
133
  </div>
134
- <div class="cp-track" role="progressbar" aria-label="Cognitive Points" aria-valuemin="0" aria-valuemax="100">
135
  <div id="cp-fill" class="cp-fill" style="width: 100%;"></div>
136
  </div>
137
  </div>
@@ -158,28 +180,22 @@
158
  </div>
159
  <div class="achievements-strip">
160
  <div class="badge" data-achievement="first_deal" title="Close your first deal">
161
- <span class="badge-icon" aria-hidden="true">🀝</span>
162
- First Deal
163
  </div>
164
  <div class="badge" data-achievement="anchor_master" title="Win with an aggressive anchor">
165
- <span class="badge-icon" aria-hidden="true">βš“</span>
166
- Anchor
167
  </div>
168
  <div class="badge" data-achievement="drift_adapter" title="Adapt to a drift event">
169
- <span class="badge-icon" aria-hidden="true">πŸŒͺ️</span>
170
- Drift
171
  </div>
172
  <div class="badge" data-achievement="zopa_optimal" title="Close within 5% of Nash point">
173
- <span class="badge-icon" aria-hidden="true">πŸ’Ž</span>
174
- Optimal
175
  </div>
176
  <div class="badge" data-achievement="clean_sweep" title="Complete all 3 acts">
177
- <span class="badge-icon" aria-hidden="true">πŸ†</span>
178
- Sweep
179
  </div>
180
  <div class="badge" data-achievement="bluffer" title="Successfully bluff the opponent">
181
- <span class="badge-icon" aria-hidden="true">πŸƒ</span>
182
- Bluff
183
  </div>
184
  </div>
185
  </section>
@@ -212,7 +228,7 @@
212
  <!-- Chat Thread -->
213
  <div id="chat-thread" class="chat-thread" role="log" aria-live="polite" aria-label="Negotiation conversation">
214
  <div class="message-system">
215
- Set up your game above to start negotiating.
216
  </div>
217
  </div>
218
 
@@ -268,28 +284,23 @@
268
  </div>
269
 
270
  <div id="zopa-track" class="zopa-track-outer" role="img" aria-label="ZOPA visual range">
271
- <!-- Green zone -->
272
  <div id="zopa-zone" class="zopa-zone"></div>
273
 
274
- <!-- Player BATNA marker -->
275
  <div id="marker-player" class="zopa-marker marker-player" style="left: 20%;">
276
  <div class="zopa-marker-line"></div>
277
  <span class="zopa-label">Your BATNA</span>
278
  </div>
279
 
280
- <!-- Opponent BATNA marker -->
281
  <div id="marker-opponent" class="zopa-marker marker-opponent" style="left: 80%;">
282
  <div class="zopa-marker-line"></div>
283
  <span class="zopa-label">Their BATNA</span>
284
  </div>
285
 
286
- <!-- Current offer marker -->
287
  <div id="marker-current" class="zopa-marker marker-current" style="left: 50%; display: none;">
288
  <div class="zopa-marker-line"></div>
289
  <span class="zopa-label">Offer</span>
290
  </div>
291
 
292
- <!-- Nash diamond -->
293
  <div id="nash-diamond" class="nash-diamond" style="left: 50%; display: none;" title="Nash Equilibrium Point"></div>
294
  </div>
295
 
@@ -328,7 +339,7 @@
328
 
329
  <!-- Persona Info -->
330
  <div id="persona-info" class="persona-info">
331
- <div id="persona-avatar" class="persona-avatar shark" aria-hidden="true">🦈</div>
332
  <div>
333
  <div id="persona-name" class="persona-name">β€”</div>
334
  <div id="persona-desc" class="persona-desc text-muted text-xs">Choose a persona to begin</div>
@@ -342,7 +353,6 @@
342
  </div>
343
 
344
  <div class="tom-beliefs">
345
- <!-- Cooperative -->
346
  <div class="belief-row">
347
  <span class="belief-label text-xs">Cooperative</span>
348
  <div class="belief-track" role="progressbar" aria-label="Cooperative belief">
@@ -352,7 +362,6 @@
352
  <div id="belief-cooperative-conf" class="belief-confidence confidence-medium"></div>
353
  </div>
354
 
355
- <!-- Competitive -->
356
  <div class="belief-row">
357
  <span class="belief-label text-xs">Competitive</span>
358
  <div class="belief-track" role="progressbar" aria-label="Competitive belief">
@@ -362,17 +371,15 @@
362
  <div id="belief-competitive-conf" class="belief-confidence confidence-low"></div>
363
  </div>
364
 
365
- <!-- Reservation -->
366
  <div class="belief-row">
367
  <span class="belief-label text-xs">Reservation</span>
368
- <div class="belief-track" role="progressbar" aria-label="Reservation sensitivity belief">
369
  <div id="belief-reservation-fill" class="belief-fill reservation" style="width:40%;"></div>
370
  </div>
371
  <span id="belief-reservation-pct" class="belief-pct">40%</span>
372
  <div id="belief-reservation-conf" class="belief-confidence confidence-low"></div>
373
  </div>
374
 
375
- <!-- Flexibility -->
376
  <div class="belief-row">
377
  <span class="belief-label text-xs">Flexibility</span>
378
  <div class="belief-track" role="progressbar" aria-label="Flexibility belief">
@@ -383,7 +390,6 @@
383
  </div>
384
  </div>
385
 
386
- <!-- Belief confidence over time chart -->
387
  <div class="mt-4 sparkline-wrap" style="height: 80px;">
388
  <canvas id="belief-chart" class="sparkline-canvas" aria-label="Belief confidence over time"></canvas>
389
  </div>
@@ -405,13 +411,8 @@
405
  <section class="panel" aria-label="Leaderboard">
406
  <div class="panel-header">
407
  <span class="panel-title">Top 5</span>
408
- <button
409
- class="btn btn-ghost btn-sm"
410
- type="button"
411
- onclick="loadLeaderboard()"
412
- aria-label="Refresh leaderboard"
413
- title="Refresh"
414
- >↻</button>
415
  </div>
416
 
417
  <table class="leaderboard-table" role="table" aria-label="Top players leaderboard">
@@ -436,9 +437,9 @@
436
  </main>
437
 
438
  <!-- Scripts (order matters) -->
439
- <script src="/static/character.js"></script>
440
- <script src="/static/chart.js"></script>
441
- <script src="/static/app.js"></script>
442
 
443
  </body>
444
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Parlay β€” The Deal Room. An RL-powered negotiation arena." />
7
+ <title>Parlay β€” The Deal Room</title>
8
 
9
+ <!-- Three.js r128 β€” must load before character.js -->
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
11
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
12
 
13
+ <!-- Chart.js 4.4.1 -->
14
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"
15
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
16
 
17
+ <link rel="stylesheet" href="/static/style.css?v=4" />
 
18
  </head>
19
  <body>
20
 
21
+ <!-- ═══════════════════════════════════════════════════════════
22
+ DEMO BANNER
23
+ ════════════════════════════════════════════════════════════ -->
24
+ <div id="demo-banner" class="demo-banner hidden" role="status">
25
+ Demo mode β€” AI responses are simulated Β· Add GOOGLE_API_KEY to .env for real gameplay
26
+ <button class="demo-banner-dismiss" type="button" id="btn-dismiss-demo" aria-label="Dismiss demo banner">βœ•</button>
27
+ </div>
28
+
29
  <!-- ═══════════════════════════════════════════════════════════
30
  LOADING OVERLAY
31
  ════════════════════════════════════════════════════════════ -->
32
  <div id="loading-overlay" class="loading-overlay hidden" role="status" aria-live="polite">
33
  <div class="loading-card">
34
  <div class="spinner" aria-hidden="true"></div>
35
+ <p class="loading-text">The room is thinking…</p>
36
  </div>
37
  </div>
38
 
39
  <!-- ═══════════════════════════════════════════════════════════
40
+ ONBOARDING β€” STEP 1: NAME
41
  ════════════════════════════════════════════════════════════ -->
42
+ <div id="onboarding-step-1" class="onboarding-overlay start-active" role="dialog" aria-modal="true" aria-label="Enter your name">
43
+ <div class="onboarding-card">
44
+ <div class="onboarding-step-num">Step 1 of 3</div>
45
+ <h1 class="onboarding-headline">Who's at<br>the table?</h1>
46
+ <p class="onboarding-sub">Every deal starts with a name on the door.</p>
47
+
48
+ <input
49
+ type="text"
50
+ id="step1-name"
51
+ class="onboarding-name-input"
52
+ placeholder="Your name…"
53
+ maxlength="40"
54
+ autocomplete="off"
55
+ autofocus
56
+ />
57
+
58
+ <div id="step1-error" class="onboarding-error"></div>
59
+
60
+ <div class="onboarding-footer">
61
+ <button id="step1-continue" class="btn btn-primary" type="button">
62
+ Continue &rarr;
63
+ </button>
64
  </div>
65
+ </div>
66
+ </div>
67
 
68
+ <!-- ═══════════════════════════════════════════════════════════
69
+ ONBOARDING β€” STEP 2: SCENARIO
70
+ ════════════════════════════════════════════════════════════ -->
71
+ <div id="onboarding-step-2" class="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Choose a scenario" inert>
72
+ <div class="onboarding-card wide">
73
+ <div class="onboarding-step-num">Step 2 of 3</div>
74
+ <h1 class="onboarding-headline">Choose your deal</h1>
75
+ <p class="onboarding-sub">Select a case from the dossier.</p>
76
+
77
+ <div id="scenario-dossier-grid" class="scenario-dossier-grid" role="radiogroup" aria-label="Negotiation scenarios">
78
+ <!-- Populated by loadScenarios() -->
79
+ </div>
 
80
 
81
+ <div id="step2-error" class="onboarding-error"></div>
 
 
 
 
 
 
82
 
83
+ <div class="onboarding-footer">
84
+ <button id="step2-back" class="btn btn-ghost" type="button">&larr; Back</button>
85
+ <button id="step2-continue" class="btn btn-primary" type="button">Continue &rarr;</button>
86
+ </div>
87
+ </div>
88
+ </div>
 
89
 
90
+ <!-- ═══════════════════════════════════════════════════════════
91
+ ONBOARDING β€” STEP 3: PERSONA
92
+ ════════════════════════════════════════════════════════════ -->
93
+ <div id="onboarding-step-3" class="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Choose your opponent" inert>
94
+ <div class="onboarding-card wide">
95
+ <div class="onboarding-step-num">Step 3 of 3</div>
96
+ <h1 class="onboarding-headline">Choose your opponent</h1>
97
+ <p class="onboarding-sub">Study the faces across the table.</p>
98
+
99
+ <div id="persona-cards-grid" class="persona-cards-grid" role="radiogroup" aria-label="Negotiator personas">
100
+ <!-- Populated by loadPersonas() -->
101
  </div>
102
 
103
+ <div id="step3-error" class="onboarding-error"></div>
104
+
105
+ <div class="onboarding-footer">
106
+ <button id="step3-back" class="btn btn-ghost" type="button">&larr; Back</button>
107
+ <button id="step3-start" class="btn btn-primary" type="button">Enter the Room &rarr;</button>
108
  </div>
109
  </div>
110
  </div>
 
125
 
126
  <div class="header-actions">
127
  <p id="global-error" class="hidden text-red text-sm" role="alert"></p>
128
+ <button id="dark-toggle" class="dark-toggle" type="button"
129
+ aria-label="Toggle display mode" title="Toggle display mode"></button>
 
 
 
 
 
130
  </div>
131
  </header>
132
 
 
148
  </div>
149
  </div>
150
 
 
151
  <div class="cp-section">
152
  <div class="cp-label-row">
153
+ <span class="cp-label">Credibility Points</span>
154
  <span id="cp-value" class="cp-value mono">100 / 100</span>
155
  </div>
156
+ <div class="cp-track" role="progressbar" aria-label="Credibility Points" aria-valuemin="0" aria-valuemax="100">
157
  <div id="cp-fill" class="cp-fill" style="width: 100%;"></div>
158
  </div>
159
  </div>
 
180
  </div>
181
  <div class="achievements-strip">
182
  <div class="badge" data-achievement="first_deal" title="Close your first deal">
183
+ <span class="badge-icon" aria-hidden="true">🀝</span>First Deal
 
184
  </div>
185
  <div class="badge" data-achievement="anchor_master" title="Win with an aggressive anchor">
186
+ <span class="badge-icon" aria-hidden="true">βš“</span>Anchor
 
187
  </div>
188
  <div class="badge" data-achievement="drift_adapter" title="Adapt to a drift event">
189
+ <span class="badge-icon" aria-hidden="true">πŸŒͺ️</span>Drift
 
190
  </div>
191
  <div class="badge" data-achievement="zopa_optimal" title="Close within 5% of Nash point">
192
+ <span class="badge-icon" aria-hidden="true">πŸ’Ž</span>Optimal
 
193
  </div>
194
  <div class="badge" data-achievement="clean_sweep" title="Complete all 3 acts">
195
+ <span class="badge-icon" aria-hidden="true">πŸ†</span>Sweep
 
196
  </div>
197
  <div class="badge" data-achievement="bluffer" title="Successfully bluff the opponent">
198
+ <span class="badge-icon" aria-hidden="true">πŸƒ</span>Bluff
 
199
  </div>
200
  </div>
201
  </section>
 
228
  <!-- Chat Thread -->
229
  <div id="chat-thread" class="chat-thread" role="log" aria-live="polite" aria-label="Negotiation conversation">
230
  <div class="message-system">
231
+ Step through the door to begin negotiating.
232
  </div>
233
  </div>
234
 
 
284
  </div>
285
 
286
  <div id="zopa-track" class="zopa-track-outer" role="img" aria-label="ZOPA visual range">
 
287
  <div id="zopa-zone" class="zopa-zone"></div>
288
 
 
289
  <div id="marker-player" class="zopa-marker marker-player" style="left: 20%;">
290
  <div class="zopa-marker-line"></div>
291
  <span class="zopa-label">Your BATNA</span>
292
  </div>
293
 
 
294
  <div id="marker-opponent" class="zopa-marker marker-opponent" style="left: 80%;">
295
  <div class="zopa-marker-line"></div>
296
  <span class="zopa-label">Their BATNA</span>
297
  </div>
298
 
 
299
  <div id="marker-current" class="zopa-marker marker-current" style="left: 50%; display: none;">
300
  <div class="zopa-marker-line"></div>
301
  <span class="zopa-label">Offer</span>
302
  </div>
303
 
 
304
  <div id="nash-diamond" class="nash-diamond" style="left: 50%; display: none;" title="Nash Equilibrium Point"></div>
305
  </div>
306
 
 
339
 
340
  <!-- Persona Info -->
341
  <div id="persona-info" class="persona-info">
342
+ <div id="persona-avatar" class="persona-avatar" aria-hidden="true">β—ˆ</div>
343
  <div>
344
  <div id="persona-name" class="persona-name">β€”</div>
345
  <div id="persona-desc" class="persona-desc text-muted text-xs">Choose a persona to begin</div>
 
353
  </div>
354
 
355
  <div class="tom-beliefs">
 
356
  <div class="belief-row">
357
  <span class="belief-label text-xs">Cooperative</span>
358
  <div class="belief-track" role="progressbar" aria-label="Cooperative belief">
 
362
  <div id="belief-cooperative-conf" class="belief-confidence confidence-medium"></div>
363
  </div>
364
 
 
365
  <div class="belief-row">
366
  <span class="belief-label text-xs">Competitive</span>
367
  <div class="belief-track" role="progressbar" aria-label="Competitive belief">
 
371
  <div id="belief-competitive-conf" class="belief-confidence confidence-low"></div>
372
  </div>
373
 
 
374
  <div class="belief-row">
375
  <span class="belief-label text-xs">Reservation</span>
376
+ <div class="belief-track" role="progressbar" aria-label="Reservation sensitivity">
377
  <div id="belief-reservation-fill" class="belief-fill reservation" style="width:40%;"></div>
378
  </div>
379
  <span id="belief-reservation-pct" class="belief-pct">40%</span>
380
  <div id="belief-reservation-conf" class="belief-confidence confidence-low"></div>
381
  </div>
382
 
 
383
  <div class="belief-row">
384
  <span class="belief-label text-xs">Flexibility</span>
385
  <div class="belief-track" role="progressbar" aria-label="Flexibility belief">
 
390
  </div>
391
  </div>
392
 
 
393
  <div class="mt-4 sparkline-wrap" style="height: 80px;">
394
  <canvas id="belief-chart" class="sparkline-canvas" aria-label="Belief confidence over time"></canvas>
395
  </div>
 
411
  <section class="panel" aria-label="Leaderboard">
412
  <div class="panel-header">
413
  <span class="panel-title">Top 5</span>
414
+ <button class="btn btn-ghost btn-sm" type="button"
415
+ onclick="loadLeaderboard()" aria-label="Refresh leaderboard" title="Refresh">↻</button>
 
 
 
 
 
416
  </div>
417
 
418
  <table class="leaderboard-table" role="table" aria-label="Top players leaderboard">
 
437
  </main>
438
 
439
  <!-- Scripts (order matters) -->
440
+ <script src="/static/character.js?v=4"></script>
441
+ <script src="/static/chart.js?v=4"></script>
442
+ <script src="/static/app.js?v=4"></script>
443
 
444
  </body>
445
  </html>
dashboard/static/app.js CHANGED
@@ -1,10 +1,10 @@
1
  // ============================================================
2
  // Parlay Game Logic β€” app.js
3
- // Connects frontend to FastAPI at /api/*
4
- // Zero dependencies beyond native fetch / DOM APIs
5
  // ============================================================
6
 
7
- const DEBUG = false;
8
  const API_BASE = ""; // same-origin
9
 
10
  // ── State ─────────────────────────────────────────────────────
@@ -21,26 +21,35 @@ let gameState = {
21
  maxCp: 100,
22
  };
23
 
24
- let character = null; // NegotiatorCharacter instance
25
- let charts = null; // ParlayCharts instance
26
- let _driftTimer = null;
 
 
 
 
27
 
28
  // ── Init ──────────────────────────────────────────────────────
29
  document.addEventListener("DOMContentLoaded", () => {
30
- charts = new ParlayCharts();
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- _initDarkMode();
33
- _initDarkModeToggle();
34
  loadScenarios();
35
  loadPersonas();
 
36
 
37
- // Landing modal
38
- const startBtn = document.getElementById("btn-start-game");
39
- if (startBtn) {
40
- startBtn.addEventListener("click", _handleModalStart);
41
- }
42
-
43
- // Input area
44
  const submitBtn = document.getElementById("btn-submit");
45
  if (submitBtn) submitBtn.addEventListener("click", submitMove);
46
 
@@ -50,76 +59,183 @@ document.addEventListener("DOMContentLoaded", () => {
50
  const walkBtn = document.getElementById("btn-walk");
51
  if (walkBtn) walkBtn.addEventListener("click", walkAway);
52
 
53
- const dismissBtn = document.getElementById("btn-dismiss-drift");
54
- if (dismissBtn) dismissBtn.addEventListener("click", dismissDriftAlert);
 
 
 
 
 
 
 
 
 
55
 
56
- // Offer input β€” Enter key submits
57
  const offerInput = document.getElementById("offer-input");
58
  if (offerInput) {
59
  offerInput.addEventListener("keydown", (e) => {
60
- if (e.key === "Enter") submitMove();
61
  });
62
  }
63
 
64
- // Leaderboard refresh
 
 
65
  loadLeaderboard();
66
  });
67
 
68
- // ── Dark Mode ──────────────────────────────────────────────────
69
- function _initDarkMode() {
70
- const saved = localStorage.getItem("parlay-theme");
71
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
72
- const theme = saved || (prefersDark ? "dark" : "light");
73
- document.documentElement.setAttribute("data-theme", theme);
 
 
 
 
 
74
  }
75
 
76
- function _initDarkModeToggle() {
77
- const toggle = document.getElementById("dark-toggle");
78
- if (!toggle) return;
79
- toggle.addEventListener("click", () => {
80
- const current = document.documentElement.getAttribute("data-theme");
81
- const next = current === "dark" ? "light" : "dark";
82
- document.documentElement.setAttribute("data-theme", next);
83
- localStorage.setItem("parlay-theme", next);
84
- if (DEBUG) console.log("[app] theme set to", next);
85
- });
86
  }
87
 
88
- // ── Modal ──────────────────────────────────────────────────────
89
- function _handleModalStart() {
90
- const nameInput = document.getElementById("player-name-input");
91
- const name = nameInput ? nameInput.value.trim() : "Player";
 
 
 
 
92
 
93
- // Gather selection
94
- const selectedScenario = document.querySelector(".selector-card.selected");
95
- const selectedPersona = document.querySelector(".persona-option.selected");
 
 
96
 
97
- if (!selectedScenario) {
98
- _showInlineError("modal-error", "Please select a scenario.");
99
- return;
 
100
  }
101
- if (!selectedPersona) {
102
- _showInlineError("modal-error", "Please choose a negotiation style.");
103
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
- const scenarioId = selectedScenario.dataset.scenarioId;
107
- const persona = selectedPersona.dataset.persona;
 
 
 
 
 
 
108
 
109
- startGame(scenarioId, persona, name || "Player");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
 
112
- function _showInlineError(containerId, msg) {
113
- const el = document.getElementById(containerId);
114
  if (!el) return;
115
  el.textContent = msg;
116
- el.classList.remove("hidden");
117
- setTimeout(() => el.classList.add("hidden"), 3000);
 
118
  }
119
 
120
- function _closeModal() {
121
- const backdrop = document.getElementById("setup-modal");
122
- if (backdrop) backdrop.classList.add("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
  // ── API Calls ─────────────────────────────────────────────────
@@ -138,132 +254,193 @@ async function startGame(scenarioId, persona, playerName) {
138
  body: JSON.stringify({ scenario_id: scenarioId, persona, player_name: playerName }),
139
  });
140
 
 
141
  if (!res.ok) {
142
- const err = await res.json().catch(() => ({}));
143
- throw new Error(err.detail || `HTTP ${res.status}`);
 
 
 
 
144
  }
145
 
146
- const data = await res.json();
147
- gameState.sessionId = data.session_id;
148
  gameState.observation = data.observation;
149
  gameState.hand = data.hand || [];
150
- gameState.cp = data.cp ?? 100;
151
  gameState.maxCp = data.max_cp ?? 100;
152
 
153
- _closeModal();
 
154
  updateUI(data);
155
  _initCharacter(persona);
156
  _initSparkline(data.observation);
 
 
157
 
158
- // First system message
159
  addMessage("system", `Game started. You are negotiating as the ${_personaLabel(persona)}.`);
160
- if (data.opening_message) {
161
- addMessage("opponent", data.opening_message, data.observation?.opponent_offer, null);
 
162
  }
163
 
164
- if (DEBUG) console.log("[app] game started", data);
165
  } catch (e) {
166
- _showError("Failed to start game: " + e.message);
167
- if (DEBUG) console.log("[app] startGame error", e);
 
 
 
 
 
 
 
 
 
 
 
 
168
  } finally {
169
  setLoading(false);
170
  }
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  async function submitMove() {
174
  if (gameState.done) return;
175
  if (!gameState.sessionId) return;
176
 
177
- const offerInput = document.getElementById("offer-input");
178
- const moveSelect = document.getElementById("move-select");
179
- const cardInput = document.querySelector(".tactical-card.selected");
180
 
181
- const offerRaw = offerInput ? offerInput.value.trim() : "";
182
- const move = moveSelect ? moveSelect.value : "counter";
183
- const cardId = cardInput ? cardInput.dataset.cardId : null;
184
- const offer = offerRaw ? parseFloat(offerRaw.replace(/[$,]/g, "")) : null;
185
 
186
  if (offer === null && move === "counter") {
187
  offerInput && offerInput.focus();
188
  return;
189
  }
190
- if (isNaN(offer) && offer !== null) return;
191
 
192
- // Render player message immediately
193
  addMessage("player", _moveSummary(move, offer), offer, move);
194
-
195
- // Show thinking indicator
196
  const thinkId = _showThinkingBubble();
197
-
198
  setLoading(true);
 
199
  try {
200
- const body = { session_id: gameState.sessionId, move, offer_amount: offer, card_id: cardId };
201
- const res = await fetch(`${API_BASE}/api/game/step`, {
202
  method: "POST",
203
  headers: { "Content-Type": "application/json" },
204
- body: JSON.stringify(body),
 
 
 
 
 
205
  });
206
 
207
- if (!res.ok) {
208
- const err = await res.json().catch(() => ({}));
209
- throw new Error(err.detail || `HTTP ${res.status}`);
210
- }
211
 
212
- const data = await res.json();
213
  gameState.observation = data.observation;
214
  gameState.done = data.done ?? false;
215
  gameState.hand = data.hand || gameState.hand;
216
- gameState.cp = data.cp ?? gameState.cp;
217
  gameState.turnCount += 1;
218
 
219
  _removeThinkingBubble(thinkId);
220
  updateUI(data);
221
 
222
- if (data.opponent_message) {
223
- addMessage("opponent", data.opponent_message, data.observation?.opponent_offer, data.opponent_move);
 
 
 
 
 
 
 
224
  }
225
 
226
- if (gameState.done) {
227
- _handleGameOver(data);
228
- }
229
 
230
- // Clear input
231
  if (offerInput) offerInput.value = "";
232
- if (cardInput) cardInput.classList.remove("selected");
233
 
234
- if (DEBUG) console.log("[app] step result", data);
235
  } catch (e) {
236
  _removeThinkingBubble(thinkId);
237
  _showError("Move failed: " + e.message);
238
- if (DEBUG) console.log("[app] submitMove error", e);
239
  } finally {
240
  setLoading(false);
241
  }
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  async function acceptDeal() {
245
  if (gameState.done || !gameState.sessionId) return;
246
  const offerInput = document.getElementById("offer-input");
247
- const offer = offerInput ? parseFloat(offerInput.value.replace(/[$,]/g, "")) : null;
248
 
249
  addMessage("player", "I accept the deal.", offer, "accept");
250
  const thinkId = _showThinkingBubble();
251
  setLoading(true);
252
 
253
  try {
254
- const res = await fetch(`${API_BASE}/api/game/step`, {
255
  method: "POST",
256
  headers: { "Content-Type": "application/json" },
257
- body: JSON.stringify({ session_id: gameState.sessionId, move: "accept", offer_amount: offer }),
258
  });
259
 
260
- const data = await res.json();
261
  _removeThinkingBubble(thinkId);
262
  gameState.done = true;
263
  updateUI(data);
264
- if (data.opponent_message) addMessage("opponent", data.opponent_message, null, "accept");
265
- _handleGameOver(data);
266
- if (DEBUG) console.log("[app] acceptDeal", data);
267
  } catch (e) {
268
  _removeThinkingBubble(thinkId);
269
  _showError(e.message);
@@ -279,19 +456,18 @@ async function walkAway() {
279
  setLoading(true);
280
 
281
  try {
282
- const res = await fetch(`${API_BASE}/api/game/step`, {
283
  method: "POST",
284
  headers: { "Content-Type": "application/json" },
285
- body: JSON.stringify({ session_id: gameState.sessionId, move: "walk_away" }),
286
  });
287
 
288
- const data = await res.json();
289
  _removeThinkingBubble(thinkId);
290
  gameState.done = true;
291
  updateUI(data);
292
- if (data.opponent_message) addMessage("opponent", data.opponent_message, null, "walk");
293
- _handleGameOver(data);
294
- if (DEBUG) console.log("[app] walkAway", data);
295
  } catch (e) {
296
  _removeThinkingBubble(thinkId);
297
  _showError(e.message);
@@ -303,26 +479,29 @@ async function walkAway() {
303
  async function loadScenarios() {
304
  try {
305
  const res = await fetch(`${API_BASE}/api/scenarios`);
306
- if (!res.ok) return;
307
  const data = await res.json();
308
  const scenarios = data.scenarios || data || [];
309
- _renderScenarioOptions(scenarios);
310
- if (DEBUG) console.log("[app] scenarios loaded", scenarios);
311
  } catch (e) {
312
- if (DEBUG) console.log("[app] loadScenarios error", e);
 
 
313
  }
314
  }
315
 
316
  async function loadPersonas() {
317
  try {
318
  const res = await fetch(`${API_BASE}/api/personas`);
319
- if (!res.ok) return;
320
  const data = await res.json();
321
  const personas = data.personas || data || [];
322
- _renderPersonaOptions(personas);
323
- if (DEBUG) console.log("[app] personas loaded", personas);
324
  } catch (e) {
325
- if (DEBUG) console.log("[app] loadPersonas error", e);
 
326
  }
327
  }
328
 
@@ -331,11 +510,10 @@ async function loadLeaderboard() {
331
  const res = await fetch(`${API_BASE}/api/leaderboard?limit=5`);
332
  if (!res.ok) return;
333
  const data = await res.json();
334
- const entries = data.entries || data || [];
335
- renderLeaderboard(entries);
336
- if (DEBUG) console.log("[app] leaderboard loaded", entries);
337
  } catch (e) {
338
- if (DEBUG) console.log("[app] loadLeaderboard error", e);
339
  }
340
  }
341
 
@@ -364,42 +542,53 @@ function updateUI(response) {
364
  }
365
 
366
  // Sparkline update
367
- if (charts && charts.offerSparkline && obs) {
368
  const playerOffer = obs.player_offer ?? obs.your_offer ?? null;
369
  const opponentOffer = obs.opponent_offer ?? null;
370
  if (playerOffer !== null || opponentOffer !== null) {
371
- charts.updateOfferSparkline(playerOffer, opponentOffer, gameState.turnCount);
 
 
 
372
  }
373
  }
374
 
375
- // ToM belief chart update
376
- if (charts && charts.beliefChart && obs?.belief_state) {
377
- charts.updateBeliefChart(obs.belief_state);
378
- }
379
-
380
- // Achievements
381
  const achievements = response.achievements || obs?.achievements;
382
  if (achievements) showAchievements(achievements);
383
 
384
- // Update player avatar initial
385
  const avatarEl = document.getElementById("player-avatar");
386
  if (avatarEl && gameState.playerName) {
387
  avatarEl.textContent = gameState.playerName.charAt(0).toUpperCase();
388
  }
389
-
390
  const nameEl = document.getElementById("player-name-display");
391
  if (nameEl) nameEl.textContent = gameState.playerName;
392
 
393
- // Disable inputs when done
394
  _setInputsDisabled(gameState.done);
395
  }
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  // ── Message Bubbles ────────────────────────────────────────────
398
  function addMessage(role, text, offer, move) {
399
  const thread = document.getElementById("chat-thread");
400
- if (!thread) return;
401
 
402
- // Remove thinking bubble if any
403
  const existing = thread.querySelector(".thinking-bubble");
404
  if (existing) existing.remove();
405
 
@@ -409,13 +598,15 @@ function addMessage(role, text, offer, move) {
409
  sys.textContent = text;
410
  thread.appendChild(sys);
411
  _scrollThread(thread);
412
- return;
413
  }
414
 
415
  const bubble = document.createElement("div");
416
  bubble.className = `message-bubble ${role}`;
 
 
 
417
 
418
- // Meta row
419
  const meta = document.createElement("div");
420
  meta.className = "bubble-meta";
421
 
@@ -426,16 +617,14 @@ function addMessage(role, text, offer, move) {
426
  if (move) {
427
  const pill = document.createElement("span");
428
  pill.className = `move-pill ${move}`;
429
- pill.textContent = move.replace("_", " ");
430
  meta.appendChild(pill);
431
  }
432
 
433
- // Body
434
  const body = document.createElement("div");
435
  body.className = "bubble-body";
436
  body.textContent = text;
437
 
438
- // Offer chip
439
  if (offer != null && !isNaN(offer)) {
440
  const chip = document.createElement("div");
441
  chip.className = "offer-chip";
@@ -447,13 +636,14 @@ function addMessage(role, text, offer, move) {
447
  bubble.appendChild(body);
448
  thread.appendChild(bubble);
449
  _scrollThread(thread);
 
450
  }
451
 
452
  function _showThinkingBubble() {
453
  const thread = document.getElementById("chat-thread");
454
  if (!thread) return null;
455
 
456
- const id = "thinking-" + Date.now();
457
  const wrap = document.createElement("div");
458
  wrap.className = "thinking-bubble";
459
  wrap.id = id;
@@ -476,9 +666,7 @@ function _removeThinkingBubble(id) {
476
  }
477
 
478
  function _scrollThread(thread) {
479
- requestAnimationFrame(() => {
480
- thread.scrollTop = thread.scrollHeight;
481
- });
482
  }
483
 
484
  // ── ZOPA Bar ──────────────────────────────────────────────────
@@ -486,19 +674,16 @@ function updateZOPABar(observation) {
486
  const track = document.getElementById("zopa-track");
487
  if (!track) return;
488
 
489
- const batnaPlayer = observation.player_batna ?? observation.your_batna ?? 0;
490
- const batnaOpponent = observation.opponent_batna ?? observation.opp_batna ?? 100;
491
  const currentOffer = observation.opponent_offer ?? observation.player_offer ?? null;
492
  const nash = observation.nash_point ?? null;
493
 
494
- // Determine scale
495
  const minVal = Math.min(batnaPlayer, batnaOpponent) * 0.9;
496
  const maxVal = Math.max(batnaPlayer, batnaOpponent) * 1.1;
497
  const range = maxVal - minVal || 1;
 
498
 
499
- const pct = (v) => `${Math.max(0, Math.min(100, ((v - minVal) / range) * 100)).toFixed(1)}%`;
500
-
501
- // ZOPA zone
502
  const zopaZone = document.getElementById("zopa-zone");
503
  if (zopaZone) {
504
  const lo = Math.min(batnaPlayer, batnaOpponent);
@@ -507,35 +692,28 @@ function updateZOPABar(observation) {
507
  zopaZone.style.width = `${(((hi - lo) / range) * 100).toFixed(1)}%`;
508
  }
509
 
510
- // Player BATNA marker
511
  const mPlayer = document.getElementById("marker-player");
512
  if (mPlayer) mPlayer.style.left = pct(batnaPlayer);
513
 
514
- // Opponent BATNA marker
515
  const mOpponent = document.getElementById("marker-opponent");
516
  if (mOpponent) mOpponent.style.left = pct(batnaOpponent);
517
 
518
- // Current offer marker
519
  const mCurrent = document.getElementById("marker-current");
520
  if (mCurrent && currentOffer != null) {
521
  mCurrent.style.left = pct(currentOffer);
522
  mCurrent.style.display = "flex";
523
  }
524
 
525
- // Nash diamond
526
  const nashEl = document.getElementById("nash-diamond");
527
  if (nashEl && nash != null) {
528
  nashEl.style.left = pct(nash);
529
  nashEl.style.display = "block";
530
  }
531
 
532
- // Labels
533
  const lblLow = document.getElementById("zopa-label-low");
534
  const lblHigh = document.getElementById("zopa-label-high");
535
  if (lblLow) lblLow.textContent = formatCurrency(minVal, "USD");
536
  if (lblHigh) lblHigh.textContent = formatCurrency(maxVal, "USD");
537
-
538
- if (DEBUG) console.log("[app] updateZOPABar", { batnaPlayer, batnaOpponent, currentOffer, nash });
539
  }
540
 
541
  // ── Tension Meter ─────────────────────────────────────────────
@@ -544,12 +722,13 @@ function updateTensionMeter(tensionScore) {
544
  const value = document.getElementById("tension-value");
545
  if (!fill) return;
546
 
547
- const pct = Math.max(0, Math.min(100, (tensionScore || 0) * 100));
 
548
  fill.style.width = `${pct}%`;
549
 
550
  let level = "low";
551
- if (pct >= 70) level = "high";
552
- else if (pct >= 40) level = "medium";
553
 
554
  fill.setAttribute("data-level", level);
555
 
@@ -573,28 +752,20 @@ function updateBeliefBars(beliefState) {
573
  };
574
 
575
  Object.entries(mapping).forEach(([id, val]) => {
576
- const fill = document.getElementById(id + "-fill");
577
- const pctEl = document.getElementById(id + "-pct");
578
- const confEl = document.getElementById(id + "-conf");
579
-
580
  if (!fill) return;
581
  const pct = Math.max(0, Math.min(100, val * 100));
582
  fill.style.width = `${pct.toFixed(1)}%`;
583
  if (pctEl) pctEl.textContent = `${Math.round(pct)}%`;
584
-
585
- // Confidence dot
586
  if (confEl) {
587
  const conf = pct > 60 ? "high" : pct > 30 ? "medium" : "low";
588
  confEl.className = `belief-confidence confidence-${conf}`;
589
  }
590
  });
591
 
592
- // Update chart
593
- if (charts && charts.beliefChart) {
594
- charts.updateBeliefChart(beliefState);
595
- }
596
-
597
- if (DEBUG) console.log("[app] updateBeliefBars", beliefState);
598
  }
599
 
600
  // ── CP Bar ────────────────────────────────────────────────────
@@ -602,7 +773,6 @@ function updateCPBar(cp) {
602
  const fill = document.getElementById("cp-fill");
603
  const value = document.getElementById("cp-value");
604
  if (!fill) return;
605
-
606
  const maxCp = gameState.maxCp || 100;
607
  const pct = Math.max(0, Math.min(100, (cp / maxCp) * 100));
608
  fill.style.width = `${pct}%`;
@@ -618,12 +788,23 @@ function updateCharacterState(observation) {
618
 
619
  let state = "idle";
620
  if (drift) state = "shocked";
621
- else if (tension > 0.7) state = "aggressive";
622
- else if (tension > 0.4) state = "thinking";
623
- else if (tension < 0.2 && gameState.turnCount > 0) state = "pleased";
624
 
625
  character.setState(state);
626
- if (DEBUG) console.log("[app] character state:", state);
 
 
 
 
 
 
 
 
 
 
 
627
  }
628
 
629
  // ── Drift Alert ───────────────────────────────────────────────
@@ -633,16 +814,13 @@ function showDriftAlert(driftEvent) {
633
 
634
  const text = document.getElementById("drift-alert-text");
635
  if (text) {
636
- if (typeof driftEvent === "string") {
637
- text.textContent = driftEvent;
638
- } else {
639
- text.textContent = driftEvent.description || driftEvent.event || "Market conditions have shifted.";
640
- }
641
  }
642
 
643
  bar.classList.remove("hidden");
644
 
645
- // Auto-dismiss after 8 seconds
646
  if (_driftTimer) clearTimeout(_driftTimer);
647
  _driftTimer = setTimeout(dismissDriftAlert, 8000);
648
  }
@@ -658,56 +836,53 @@ function renderHand(hand) {
658
  const container = document.getElementById("hand-container");
659
  if (!container) return;
660
 
661
- container.innerHTML = ""; // safe β€” no user data in card definitions
 
 
662
 
663
  hand.forEach((card) => {
664
  const wrapper = document.createElement("div");
665
  wrapper.className = "tactical-card";
666
- wrapper.dataset.cardId = card.id || card.card_id || "";
667
 
668
  const inner = document.createElement("div");
669
  inner.className = "card-inner";
670
 
671
- // Front
672
  const front = document.createElement("div");
673
  front.className = "card-face";
674
 
675
  const name = document.createElement("div");
676
  name.className = "card-name";
677
- name.textContent = card.name || "Tactic";
 
678
 
679
  const type = document.createElement("div");
680
  type.className = "card-type";
681
- type.textContent = card.type || "";
 
682
 
683
  const cost = document.createElement("div");
684
  cost.className = "card-cost";
685
- cost.textContent = card.cp_cost ?? card.cost ?? "1";
686
-
687
- front.appendChild(name);
688
- front.appendChild(type);
689
  front.appendChild(cost);
690
 
691
- // Back
692
  const back = document.createElement("div");
693
  back.className = "card-back";
694
 
695
  const backLabel = document.createElement("div");
696
  backLabel.className = "card-back-label";
697
  backLabel.textContent = "Game Theory";
 
698
 
699
  const gt = document.createElement("div");
700
  gt.className = "card-game-theory";
701
  gt.textContent = card.game_theory_basis || card.description || "";
702
-
703
- back.appendChild(backLabel);
704
  back.appendChild(gt);
705
 
706
  inner.appendChild(front);
707
  inner.appendChild(back);
708
  wrapper.appendChild(inner);
709
 
710
- // Selection
711
  wrapper.addEventListener("click", () => {
712
  const already = wrapper.classList.contains("selected");
713
  container.querySelectorAll(".tactical-card").forEach(c => c.classList.remove("selected"));
@@ -716,18 +891,15 @@ function renderHand(hand) {
716
 
717
  container.appendChild(wrapper);
718
  });
719
-
720
- if (DEBUG) console.log("[app] renderHand", hand.length, "cards");
721
  }
722
 
723
  // ── Leaderboard ───────────────────────────────────────────────
724
  function renderLeaderboard(entries) {
725
  const tbody = document.getElementById("leaderboard-body");
726
  if (!tbody) return;
727
-
728
  tbody.innerHTML = "";
729
 
730
- if (!entries || entries.length === 0) {
731
  const tr = document.createElement("tr");
732
  const td = document.createElement("td");
733
  td.colSpan = 4;
@@ -740,48 +912,36 @@ function renderLeaderboard(entries) {
740
 
741
  entries.forEach((entry, idx) => {
742
  const tr = document.createElement("tr");
 
743
 
744
- // Highlight current player
745
- if (entry.player_name === gameState.playerName) {
746
- tr.classList.add("highlight-player");
747
- }
748
-
749
- // Rank
750
  const rankTd = document.createElement("td");
751
  const rankSpan = document.createElement("span");
752
- rankSpan.className = "lb-rank" +
753
- (idx === 0 ? " gold" : idx === 1 ? " silver" : idx === 2 ? " bronze" : "");
754
  rankSpan.textContent = `#${idx + 1}`;
755
  rankTd.appendChild(rankSpan);
756
  tr.appendChild(rankTd);
757
 
758
- // Name
759
  const nameTd = document.createElement("td");
760
  nameTd.textContent = entry.player_name || "β€”";
761
  tr.appendChild(nameTd);
762
 
763
- // Score
764
  const scoreTd = document.createElement("td");
765
  scoreTd.className = "num";
766
- scoreTd.textContent = (entry.score ?? entry.reward ?? 0).toFixed(2);
767
  tr.appendChild(scoreTd);
768
 
769
- // Deals
770
  const dealsTd = document.createElement("td");
771
  dealsTd.className = "num";
772
- dealsTd.textContent = entry.deals ?? "β€”";
773
  tr.appendChild(dealsTd);
774
 
775
  tbody.appendChild(tr);
776
  });
777
-
778
- if (DEBUG) console.log("[app] renderLeaderboard", entries.length, "entries");
779
  }
780
 
781
  // ── Achievements ──────────────────────────────────────────────
782
  function showAchievements(achievements) {
783
  if (!achievements || !Array.isArray(achievements)) return;
784
-
785
  achievements.forEach((ach) => {
786
  const el = document.querySelector(`.badge[data-achievement="${ach.id}"]`);
787
  if (el && !el.classList.contains("earned")) {
@@ -795,10 +955,11 @@ function _showToast(msg) {
795
  const toast = document.createElement("div");
796
  toast.style.cssText = [
797
  "position:fixed", "bottom:24px", "right:24px", "z-index:9999",
798
- "background:var(--parlay-surface)", "border:1px solid var(--parlay-border)",
799
- "border-radius:8px", "padding:12px 16px",
800
- "font-size:0.875rem", "font-weight:500",
801
- "color:var(--parlay-ink)", "box-shadow:0 4px 16px rgba(0,0,0,0.12)",
 
802
  "animation:slide-down 200ms ease",
803
  ].join(";");
804
  toast.textContent = msg;
@@ -808,18 +969,15 @@ function _showToast(msg) {
808
 
809
  // ── Game Over ─────────────────────────────────────────────────
810
  function _handleGameOver(data) {
811
- const deal = data.deal_reached ?? data.deal ?? false;
812
- const amount = data.deal_amount ?? data.final_offer ?? null;
813
- const score = data.reward ?? data.score ?? 0;
814
 
815
- // Show result in chat
816
  const resultMsg = deal
817
- ? `Deal reached at ${formatCurrency(amount, "USD")}! Score: ${score.toFixed(2)}`
818
- : "No deal. You walked away.";
819
-
820
  addMessage("system", resultMsg);
821
 
822
- // Show result banner
823
  const banner = document.getElementById("result-banner");
824
  if (banner) {
825
  banner.className = `result-banner ${deal ? "deal" : "walk"}`;
@@ -829,127 +987,192 @@ function _handleGameOver(data) {
829
  if (title) title.textContent = deal ? "Deal Closed" : "Walked Away";
830
 
831
  const amountEl = banner.querySelector(".result-amount");
832
- if (amountEl && amount != null) amountEl.textContent = formatCurrency(amount, "USD");
833
- else if (amountEl) amountEl.textContent = "β€”";
834
 
835
  const scoreEl = banner.querySelector(".result-score");
836
  if (scoreEl) scoreEl.textContent = `Score: ${score.toFixed(2)}`;
837
  }
838
 
839
- // Character state
840
  if (character) character.setState(deal ? "pleased" : "shocked");
841
-
842
- // Refresh leaderboard after short delay
843
  setTimeout(loadLeaderboard, 1200);
844
 
845
- if (DEBUG) console.log("[app] game over", { deal, amount, score });
846
  }
847
 
848
- // ── Scenario / Persona Selectors ──────────────────────────────
849
- function _renderScenarioOptions(scenarios) {
850
- const grid = document.getElementById("scenario-grid");
851
  if (!grid) return;
852
-
853
  grid.innerHTML = "";
854
 
855
- if (!scenarios.length) {
856
- // Fallback defaults so UI isn't empty
857
- scenarios = [
858
- { id: "merger", name: "M&A Deal", description: "Acquire a fintech startup", difficulty: "Medium" },
859
- { id: "salary", name: "Salary Negotiation",description: "Land the offer you deserve", difficulty: "Easy" },
860
- { id: "licensing", name: "IP Licensing", description: "Patent licensing deal", difficulty: "Hard" },
861
- { id: "supply", name: "Supply Contract", description: "Commodity procurement", difficulty: "Medium" },
862
- ];
863
- }
864
 
865
- scenarios.forEach((s) => {
 
 
 
 
866
  const card = document.createElement("div");
867
- card.className = "selector-card";
868
  card.dataset.scenarioId = s.id || s.scenario_id;
869
-
870
- const name = document.createElement("div");
871
- name.className = "selector-card-name";
872
- name.textContent = s.name || s.title;
873
-
874
- const meta = document.createElement("div");
875
- meta.className = "selector-card-meta";
876
- meta.textContent = (s.description || "") + (s.difficulty ? ` Β· ${s.difficulty}` : "");
877
-
878
- card.appendChild(name);
879
- card.appendChild(meta);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
 
881
  card.addEventListener("click", () => {
882
- grid.querySelectorAll(".selector-card").forEach(c => c.classList.remove("selected"));
883
  card.classList.add("selected");
884
  });
 
 
 
885
 
886
  grid.appendChild(card);
887
  });
888
  }
889
 
890
- function _renderPersonaOptions(personas) {
891
- const grid = document.getElementById("persona-grid");
 
892
  if (!grid) return;
893
-
894
  grid.innerHTML = "";
895
 
896
  const defaults = [
897
- { id: "shark", name: "Shark", icon: "🦈" },
898
- { id: "diplomat", name: "Diplomat", icon: "πŸ•ŠοΈ" },
899
- { id: "analyst", name: "Analyst", icon: "πŸ“Š" },
900
- { id: "wildcard", name: "Wildcard", icon: "πŸƒ" },
901
- { id: "veteran", name: "Veteran", icon: "βš”οΈ" },
902
  ];
903
 
904
  const list = (personas && personas.length) ? personas : defaults;
905
 
906
  list.forEach((p) => {
907
- const opt = document.createElement("div");
908
- opt.className = "persona-option";
909
- opt.dataset.persona = p.id || p.persona_id;
910
 
911
- const icon = document.createElement("div");
912
- icon.className = "persona-option-icon";
913
- icon.textContent = p.icon || "πŸƒ";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
 
915
- const name = document.createElement("div");
916
- name.className = "persona-option-name";
917
- name.textContent = p.name || p.id;
 
 
 
 
 
918
 
919
- opt.appendChild(icon);
920
- opt.appendChild(name);
921
 
922
- opt.addEventListener("click", () => {
923
- grid.querySelectorAll(".persona-option").forEach(c => c.classList.remove("selected"));
924
- opt.classList.add("selected");
 
 
 
925
  });
926
-
927
- grid.appendChild(opt);
928
  });
929
  }
930
 
 
 
 
 
 
931
  // ── Character init ────────────────────────────────────────────
932
  function _initCharacter(persona) {
933
- if (typeof NegotiatorCharacter === "undefined") {
934
- if (DEBUG) console.log("[app] NegotiatorCharacter not available");
935
- return;
936
- }
937
  if (character) character.destroy();
938
  character = new NegotiatorCharacter("character-canvas", persona || "shark");
939
- if (DEBUG) console.log("[app] character created for persona:", persona);
940
  }
941
 
942
  // ── Sparkline init ────────────────────────────────────────────
943
  function _initSparkline(observation) {
944
  if (!charts || !observation) return;
945
-
946
- const lo = observation.player_batna ?? observation.your_batna ?? 0;
947
- const hi = observation.opponent_batna ?? observation.opp_batna ?? 0;
948
- const nash = observation.nash_point ?? ((lo + hi) / 2);
949
-
950
- charts.initOfferSparkline("offer-sparkline", lo, hi, nash);
951
- charts.initBeliefChart("belief-chart");
952
- if (DEBUG) console.log("[app] sparkline init", { lo, hi, nash });
953
  }
954
 
955
  // ── Helpers ───────────────────────────────────────────────────
@@ -957,8 +1180,7 @@ function formatCurrency(amount, currency) {
957
  if (amount == null || isNaN(amount)) return "β€”";
958
  try {
959
  return new Intl.NumberFormat("en-US", {
960
- style: "currency",
961
- currency: currency || "USD",
962
  maximumFractionDigits: 0,
963
  }).format(amount);
964
  } catch {
@@ -968,11 +1190,7 @@ function formatCurrency(amount, currency) {
968
 
969
  function setLoading(isLoading) {
970
  const overlay = document.getElementById("loading-overlay");
971
- if (overlay) {
972
- overlay.classList.toggle("hidden", !isLoading);
973
- }
974
-
975
- // Disable action buttons during load
976
  ["btn-submit", "btn-accept", "btn-walk"].forEach(id => {
977
  const btn = document.getElementById(id);
978
  if (btn) btn.disabled = isLoading;
@@ -980,8 +1198,7 @@ function setLoading(isLoading) {
980
  }
981
 
982
  function _setInputsDisabled(disabled) {
983
- const ids = ["offer-input", "move-select", "btn-submit", "btn-accept", "btn-walk"];
984
- ids.forEach(id => {
985
  const el = document.getElementById(id);
986
  if (el) el.disabled = disabled;
987
  });
@@ -992,7 +1209,7 @@ function _updateActPills(act) {
992
  const pill = document.getElementById(`act-pill-${n}`);
993
  if (!pill) return;
994
  pill.classList.remove("active", "completed");
995
- if (n < act) pill.classList.add("completed");
996
  else if (n === act) pill.classList.add("active");
997
  });
998
  }
@@ -1002,20 +1219,20 @@ function _moveSummary(move, offer) {
1002
  case "anchor": return `Anchoring at ${formatCurrency(offer, "USD")}.`;
1003
  case "counter": return `Counter offer: ${formatCurrency(offer, "USD")}.`;
1004
  case "concede": return `Concession: ${formatCurrency(offer, "USD")}.`;
1005
- case "package": return `Package deal offer: ${formatCurrency(offer, "USD")}.`;
1006
  case "accept": return "Accepting the deal.";
1007
- case "walk_away": return "Walking away.";
1008
  default: return offer != null ? formatCurrency(offer, "USD") : move;
1009
  }
1010
  }
1011
 
1012
  function _personaLabel(persona) {
1013
  const labels = {
1014
- shark: "Shark (aggressive closer)",
1015
- diplomat: "Diplomat (relationship-first)",
1016
- analyst: "Analyst (data-driven)",
1017
- wildcard: "Wildcard (unpredictable)",
1018
- veteran: "Veteran (experience-led)",
1019
  };
1020
  return labels[persona] || persona;
1021
  }
 
1
  // ============================================================
2
  // Parlay Game Logic β€” app.js
3
+ // "The Deal Room" β€” 3-step onboarding, mock mode, Mad Men theme.
4
+ // Zero dependencies beyond native fetch / DOM APIs.
5
  // ============================================================
6
 
7
+ const APP_DEBUG = false; // not "DEBUG" β€” chart.js is also a global script
8
  const API_BASE = ""; // same-origin
9
 
10
  // ── State ─────────────────────────────────────────────────────
 
21
  maxCp: 100,
22
  };
23
 
24
+ let character = null; // NegotiatorCharacter instance
25
+ let charts = null; // ParlayCharts instance
26
+ let _driftTimer = null;
27
+ let _previewChars = {}; // PersonaPreviewCharacter instances keyed by persona id
28
+
29
+ // ── Onboarding step tracking ──────────────────────────────────
30
+ let _currentStep = 1;
31
 
32
  // ── Init ──────────────────────────────────────────────────────
33
  document.addEventListener("DOMContentLoaded", () => {
34
+ // Wire onboarding first so a Chart / fetch failure cannot block the wizard.
35
+ _initOnboarding();
36
+ _syncOnboardingInert();
37
+
38
+ try {
39
+ if (typeof ParlayCharts !== "undefined") {
40
+ charts = new ParlayCharts();
41
+ }
42
+ } catch (e) {
43
+ if (typeof console !== "undefined" && console.error) {
44
+ console.error("[Parlay] ParlayCharts init failed:", e);
45
+ }
46
+ }
47
 
 
 
48
  loadScenarios();
49
  loadPersonas();
50
+ _checkDemoMode();
51
 
52
+ // Game action buttons
 
 
 
 
 
 
53
  const submitBtn = document.getElementById("btn-submit");
54
  if (submitBtn) submitBtn.addEventListener("click", submitMove);
55
 
 
59
  const walkBtn = document.getElementById("btn-walk");
60
  if (walkBtn) walkBtn.addEventListener("click", walkAway);
61
 
62
+ const dismissDrift = document.getElementById("btn-dismiss-drift");
63
+ if (dismissDrift) dismissDrift.addEventListener("click", dismissDriftAlert);
64
+
65
+ const dismissDemo = document.getElementById("btn-dismiss-demo");
66
+ if (dismissDemo) {
67
+ dismissDemo.addEventListener("click", () => {
68
+ const banner = document.getElementById("demo-banner");
69
+ if (banner) banner.classList.add("hidden");
70
+ document.body.classList.remove("demo-mode");
71
+ });
72
+ }
73
 
 
74
  const offerInput = document.getElementById("offer-input");
75
  if (offerInput) {
76
  offerInput.addEventListener("keydown", (e) => {
77
+ if (e.key === "Enter" && !e.shiftKey) submitMove();
78
  });
79
  }
80
 
81
+ const darkToggle = document.getElementById("dark-toggle");
82
+ if (darkToggle) darkToggle.addEventListener("click", _toggleDisplayMode);
83
+
84
  loadLeaderboard();
85
  });
86
 
87
+ // ── Demo mode detection ────────────────────────────────────────
88
+ async function _checkDemoMode() {
89
+ try {
90
+ const res = await fetch(`${API_BASE}/health`);
91
+ if (!res.ok) throw new Error("health check failed");
92
+ const data = await res.json();
93
+ if (data.gemini === "mock") _showDemoBanner();
94
+ } catch {
95
+ // No backend reachable β€” still show demo banner
96
+ _showDemoBanner();
97
+ }
98
  }
99
 
100
+ function _showDemoBanner() {
101
+ const banner = document.getElementById("demo-banner");
102
+ if (banner) banner.classList.remove("hidden");
103
+ document.body.classList.add("demo-mode");
104
+ if (APP_DEBUG) console.log("[app] demo mode active");
 
 
 
 
 
105
  }
106
 
107
+ // ── Display mode toggle (replacing dark-mode concept) ─────────
108
+ function _toggleDisplayMode() {
109
+ const html = document.documentElement;
110
+ const cur = html.getAttribute("data-theme") || "felt";
111
+ const next = cur === "felt" ? "light" : "felt";
112
+ html.setAttribute("data-theme", next);
113
+ localStorage.setItem("parlay-theme", next);
114
+ }
115
 
116
+ // ── Onboarding (3-step wizard) ────────────────────────────────
117
+ function _initOnboarding() {
118
+ // Step 1 β€” name
119
+ const step1Input = document.getElementById("step1-name");
120
+ const step1Continue = document.getElementById("step1-continue");
121
 
122
+ if (step1Input) {
123
+ step1Input.addEventListener("keydown", (e) => {
124
+ if (e.key === "Enter") _goToStep(2);
125
+ });
126
  }
127
+ if (step1Continue) step1Continue.addEventListener("click", () => _goToStep(2));
128
+
129
+ // Step 2 β€” scenario
130
+ const step2Back = document.getElementById("step2-back");
131
+ const step2Continue = document.getElementById("step2-continue");
132
+ if (step2Back) step2Back.addEventListener("click", () => _goToStep(1));
133
+ if (step2Continue) step2Continue.addEventListener("click", () => _goToStep(3));
134
+
135
+ // Step 3 β€” persona
136
+ const step3Back = document.getElementById("step3-back");
137
+ const step3Start = document.getElementById("step3-start");
138
+ if (step3Back) step3Back.addEventListener("click", () => _goToStep(2));
139
+ if (step3Start) step3Start.addEventListener("click", _handleStep3Start);
140
+ }
141
+
142
+ /**
143
+ * Inert hides non-active steps from the accessibility tree and blocks interaction
144
+ * in modern browsers, complementing z-index and pointer-events.
145
+ */
146
+ function _syncOnboardingInert() {
147
+ for (let n = 1; n <= 3; n += 1) {
148
+ const el = document.getElementById(`onboarding-step-${n}`);
149
+ if (!el) continue;
150
+ if (n === _currentStep) {
151
+ el.removeAttribute("inert");
152
+ } else {
153
+ el.setAttribute("inert", "");
154
+ }
155
+ }
156
+ }
157
+
158
+ function _goToStep(step) {
159
+ if (APP_DEBUG) console.log("[app] going to step", step);
160
+
161
+ // Validate before advancing
162
+ if (step === 2) {
163
+ const name = (document.getElementById("step1-name")?.value ?? "").trim();
164
+ if (!name) {
165
+ _showStepError(1, "Please enter your name.");
166
+ return;
167
+ }
168
+ _showStepError(1, "");
169
  }
170
 
171
+ if (step === 3) {
172
+ const sel = document.querySelector(".scenario-dossier.selected");
173
+ if (!sel) {
174
+ _showStepError(2, "Please select a scenario.");
175
+ return;
176
+ }
177
+ _showStepError(2, "");
178
+ }
179
 
180
+ // Exit current step
181
+ const currentEl = document.getElementById(`onboarding-step-${_currentStep}`);
182
+ if (currentEl) {
183
+ currentEl.classList.remove("active", "start-active");
184
+ currentEl.classList.add("exiting");
185
+ setTimeout(() => {
186
+ currentEl.classList.remove("exiting");
187
+ }, 300);
188
+ }
189
+
190
+ _currentStep = step;
191
+ _syncOnboardingInert();
192
+
193
+ const nextEl = document.getElementById(`onboarding-step-${step}`);
194
+ if (nextEl) {
195
+ nextEl.classList.remove("exiting");
196
+ requestAnimationFrame(() => {
197
+ requestAnimationFrame(() => {
198
+ nextEl.classList.add("active");
199
+ });
200
+ });
201
+ }
202
  }
203
 
204
+ function _showStepError(step, msg) {
205
+ const el = document.getElementById(`step${step}-error`);
206
  if (!el) return;
207
  el.textContent = msg;
208
+ if (msg) {
209
+ setTimeout(() => { el.textContent = ""; }, 3000);
210
+ }
211
  }
212
 
213
+ function _closeOnboarding() {
214
+ [1, 2, 3].forEach(n => {
215
+ const el = document.getElementById(`onboarding-step-${n}`);
216
+ if (el) {
217
+ el.classList.remove("active", "start-active", "exiting");
218
+ el.removeAttribute("inert");
219
+ el.style.display = "none";
220
+ }
221
+ });
222
+ }
223
+
224
+ function _handleStep3Start() {
225
+ const nameInput = document.getElementById("step1-name");
226
+ const selScenario = document.querySelector(".scenario-dossier.selected");
227
+ const selPersona = document.querySelector(".persona-card-option.selected");
228
+
229
+ if (!selPersona) {
230
+ _showStepError(3, "Please choose an opponent.");
231
+ return;
232
+ }
233
+
234
+ const name = nameInput?.value.trim() || "Player";
235
+ const scenarioId = selScenario?.dataset.scenarioId || "saas_enterprise";
236
+ const persona = selPersona.dataset.persona;
237
+
238
+ startGame(scenarioId, persona, name);
239
  }
240
 
241
  // ── API Calls ─────────────────────────────────────────────────
 
254
  body: JSON.stringify({ scenario_id: scenarioId, persona, player_name: playerName }),
255
  });
256
 
257
+ let data;
258
  if (!res.ok) {
259
+ // If backend is down, load with mock data
260
+ if (APP_DEBUG) console.log("[app] backend down β€” using mock data");
261
+ data = _mockStartData(scenarioId, persona, playerName);
262
+ _showDemoBanner();
263
+ } else {
264
+ data = await res.json();
265
  }
266
 
267
+ gameState.sessionId = data.session_id;
 
268
  gameState.observation = data.observation;
269
  gameState.hand = data.hand || [];
270
+ gameState.cp = data.cp ?? data.observation?.credibility_points ?? 100;
271
  gameState.maxCp = data.max_cp ?? 100;
272
 
273
+ _closeOnboarding();
274
+ _destroyPreviewChars();
275
  updateUI(data);
276
  _initCharacter(persona);
277
  _initSparkline(data.observation);
278
+ _updateScenarioHeader(data);
279
+ _updatePersonaPanel(data);
280
 
 
281
  addMessage("system", `Game started. You are negotiating as the ${_personaLabel(persona)}.`);
282
+ const opener = data.opening_message || data.persona?.opening_line;
283
+ if (opener) {
284
+ addMessage("opponent", opener, data.observation?.opponent_offer ?? null, null);
285
  }
286
 
287
+ if (APP_DEBUG) console.log("[app] game started", data);
288
  } catch (e) {
289
+ // Backend completely unreachable β€” use mock data
290
+ if (APP_DEBUG) console.log("[app] startGame error, falling back to mock:", e);
291
+ const data = _mockStartData(scenarioId, persona, playerName);
292
+ gameState.sessionId = data.session_id;
293
+ gameState.observation = data.observation;
294
+ gameState.hand = data.hand;
295
+ gameState.cp = 100;
296
+
297
+ _closeOnboarding();
298
+ _destroyPreviewChars();
299
+ updateUI(data);
300
+ _initCharacter(persona);
301
+ _showDemoBanner();
302
+ addMessage("system", `Demo mode: running mock game for ${_personaLabel(persona)}.`);
303
  } finally {
304
  setLoading(false);
305
  }
306
  }
307
 
308
+ function _mockStartData(scenarioId, persona, playerName) {
309
+ const mockScenarios = {
310
+ saas_enterprise: { title: "Enterprise SaaS Contract", lower: 125000, upper: 165000 },
311
+ consulting_retainer: { title: "Consulting Retainer", lower: 25000, upper: 40000 },
312
+ hiring_package: { title: "Senior Engineer Offer", lower: 195000, upper: 230000 },
313
+ vendor_hardware: { title: "Hardware Vendor Contract", lower: 1750000,upper: 2200000},
314
+ acquisition_term_sheet: { title: "Startup Acquisition", lower: 10500000,upper:16000000},
315
+ };
316
+ const s = mockScenarios[scenarioId] || mockScenarios.saas_enterprise;
317
+ const nash = (s.lower + s.upper) / 2;
318
+ const sid = "mock-" + Math.random().toString(36).slice(2);
319
+
320
+ return {
321
+ session_id: sid,
322
+ scenario: { id: scenarioId, title: s.title },
323
+ observation: {
324
+ step_count: 0, zopa_lower: s.lower, zopa_upper: s.upper,
325
+ nash_point: nash, tension_score: 10, credibility_points: 100, act: 1,
326
+ belief_state: { cooperative: 0.5, competitive: 0.3, reservation: 0.4, flexibility: 0.6 },
327
+ },
328
+ persona: { id: persona, name: _personaLabel(persona), symbol: "β—ˆ", emoji: "🎯",
329
+ opening_line: "Let's see what you've got." },
330
+ hand: [],
331
+ opening_message: "Welcome to the room. Let's negotiate.",
332
+ cp: 100,
333
+ max_cp: 100,
334
+ };
335
+ }
336
+
337
  async function submitMove() {
338
  if (gameState.done) return;
339
  if (!gameState.sessionId) return;
340
 
341
+ const offerInput = document.getElementById("offer-input");
342
+ const moveSelect = document.getElementById("move-select");
343
+ const cardEl = document.querySelector(".tactical-card.selected");
344
 
345
+ const offerRaw = offerInput?.value.trim() ?? "";
346
+ const move = moveSelect?.value ?? "counter";
347
+ const cardId = cardEl?.dataset.cardId ?? null;
348
+ const offer = offerRaw ? parseFloat(offerRaw.replace(/[$,]/g, "")) : null;
349
 
350
  if (offer === null && move === "counter") {
351
  offerInput && offerInput.focus();
352
  return;
353
  }
354
+ if (offer !== null && isNaN(offer)) return;
355
 
 
356
  addMessage("player", _moveSummary(move, offer), offer, move);
 
 
357
  const thinkId = _showThinkingBubble();
 
358
  setLoading(true);
359
+
360
  try {
361
+ const res = await fetch(`${API_BASE}/api/game/step`, {
 
362
  method: "POST",
363
  headers: { "Content-Type": "application/json" },
364
+ body: JSON.stringify({
365
+ session_id: gameState.sessionId,
366
+ move,
367
+ offer_amount: offer,
368
+ card_id: cardId,
369
+ }),
370
  });
371
 
372
+ const data = res.ok ? await res.json() : _mockStepData(offer, move);
 
 
 
373
 
 
374
  gameState.observation = data.observation;
375
  gameState.done = data.done ?? false;
376
  gameState.hand = data.hand || gameState.hand;
377
+ gameState.cp = data.cp ?? data.observation?.credibility_points ?? gameState.cp;
378
  gameState.turnCount += 1;
379
 
380
  _removeThinkingBubble(thinkId);
381
  updateUI(data);
382
 
383
+ // Opponent message from /api/game/step unified endpoint response format
384
+ const oppMsg = data.opponent_message ?? data.opponent?.utterance;
385
+ const oppOffer = data.observation?.opponent_offer ?? data.opponent?.offer;
386
+ const oppMove = data.opponent_move ?? data.opponent?.tactical_move;
387
+ if (oppMsg) {
388
+ const bubble = addMessage("opponent", oppMsg, oppOffer, oppMove);
389
+ if (bubble && gameState.persona) {
390
+ bubble.setAttribute("data-persona", gameState.persona);
391
+ }
392
  }
393
 
394
+ if (gameState.done) _handleGameOver(data);
 
 
395
 
 
396
  if (offerInput) offerInput.value = "";
397
+ if (cardEl) cardEl.classList.remove("selected");
398
 
399
+ if (APP_DEBUG) console.log("[app] step result", data);
400
  } catch (e) {
401
  _removeThinkingBubble(thinkId);
402
  _showError("Move failed: " + e.message);
403
+ if (APP_DEBUG) console.log("[app] submitMove error", e);
404
  } finally {
405
  setLoading(false);
406
  }
407
  }
408
 
409
+ function _mockStepData(offer, move) {
410
+ gameState.turnCount += 1;
411
+ const obs = gameState.observation || {};
412
+ const tension = Math.min(100, (obs.tension_score || 10) + 5);
413
+ return {
414
+ observation: { ...obs, tension_score: tension, step_count: (obs.step_count || 0) + 1 },
415
+ opponent_message: "That's an interesting position. Let me consider it.",
416
+ opponent: { utterance: "That's an interesting position. Let me consider it.", offer: null },
417
+ done: false,
418
+ };
419
+ }
420
+
421
  async function acceptDeal() {
422
  if (gameState.done || !gameState.sessionId) return;
423
  const offerInput = document.getElementById("offer-input");
424
+ const offer = offerInput ? parseFloat(offerInput.value.replace(/[$,]/g, "")) : null;
425
 
426
  addMessage("player", "I accept the deal.", offer, "accept");
427
  const thinkId = _showThinkingBubble();
428
  setLoading(true);
429
 
430
  try {
431
+ const res = await fetch(`${API_BASE}/api/game/accept`, {
432
  method: "POST",
433
  headers: { "Content-Type": "application/json" },
434
+ body: JSON.stringify({ session_id: gameState.sessionId }),
435
  });
436
 
437
+ const data = res.ok ? await res.json() : { deal_reached: true, deal_amount: offer, reward: 0 };
438
  _removeThinkingBubble(thinkId);
439
  gameState.done = true;
440
  updateUI(data);
441
+ addMessage("system", "Deal accepted.");
442
+ _handleGameOver({ ...data, deal_reached: true, deal_amount: offer ?? data.final_price });
443
+ if (APP_DEBUG) console.log("[app] acceptDeal", data);
444
  } catch (e) {
445
  _removeThinkingBubble(thinkId);
446
  _showError(e.message);
 
456
  setLoading(true);
457
 
458
  try {
459
+ const res = await fetch(`${API_BASE}/api/game/walkaway`, {
460
  method: "POST",
461
  headers: { "Content-Type": "application/json" },
462
+ body: JSON.stringify({ session_id: gameState.sessionId }),
463
  });
464
 
465
+ const data = res.ok ? await res.json() : { result: "walk_away" };
466
  _removeThinkingBubble(thinkId);
467
  gameState.done = true;
468
  updateUI(data);
469
+ _handleGameOver({ deal_reached: false, reward: 0 });
470
+ if (APP_DEBUG) console.log("[app] walkAway", data);
 
471
  } catch (e) {
472
  _removeThinkingBubble(thinkId);
473
  _showError(e.message);
 
479
  async function loadScenarios() {
480
  try {
481
  const res = await fetch(`${API_BASE}/api/scenarios`);
482
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
483
  const data = await res.json();
484
  const scenarios = data.scenarios || data || [];
485
+ _renderScenarioDossiers(scenarios);
486
+ if (APP_DEBUG) console.log("[app] scenarios loaded", scenarios.length);
487
  } catch (e) {
488
+ // Render defaults so onboarding UI isn't empty
489
+ _renderScenarioDossiers([]);
490
+ if (APP_DEBUG) console.log("[app] loadScenarios error", e);
491
  }
492
  }
493
 
494
  async function loadPersonas() {
495
  try {
496
  const res = await fetch(`${API_BASE}/api/personas`);
497
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
498
  const data = await res.json();
499
  const personas = data.personas || data || [];
500
+ _renderPersonaCards(personas);
501
+ if (APP_DEBUG) console.log("[app] personas loaded", personas.length);
502
  } catch (e) {
503
+ _renderPersonaCards([]);
504
+ if (APP_DEBUG) console.log("[app] loadPersonas error", e);
505
  }
506
  }
507
 
 
510
  const res = await fetch(`${API_BASE}/api/leaderboard?limit=5`);
511
  if (!res.ok) return;
512
  const data = await res.json();
513
+ renderLeaderboard(data.entries || data || []);
514
+ if (APP_DEBUG) console.log("[app] leaderboard loaded");
 
515
  } catch (e) {
516
+ if (APP_DEBUG) console.log("[app] loadLeaderboard error", e);
517
  }
518
  }
519
 
 
542
  }
543
 
544
  // Sparkline update
545
+ if (charts && obs) {
546
  const playerOffer = obs.player_offer ?? obs.your_offer ?? null;
547
  const opponentOffer = obs.opponent_offer ?? null;
548
  if (playerOffer !== null || opponentOffer !== null) {
549
+ charts.updateOfferSparkline && charts.updateOfferSparkline(playerOffer, opponentOffer, gameState.turnCount);
550
+ }
551
+ if (obs.belief_state) {
552
+ charts.updateBeliefChart && charts.updateBeliefChart(obs.belief_state);
553
  }
554
  }
555
 
 
 
 
 
 
 
556
  const achievements = response.achievements || obs?.achievements;
557
  if (achievements) showAchievements(achievements);
558
 
 
559
  const avatarEl = document.getElementById("player-avatar");
560
  if (avatarEl && gameState.playerName) {
561
  avatarEl.textContent = gameState.playerName.charAt(0).toUpperCase();
562
  }
 
563
  const nameEl = document.getElementById("player-name-display");
564
  if (nameEl) nameEl.textContent = gameState.playerName;
565
 
 
566
  _setInputsDisabled(gameState.done);
567
  }
568
 
569
+ function _updateScenarioHeader(data) {
570
+ const titleEl = document.getElementById("scenario-title");
571
+ const metaEl = document.getElementById("scenario-meta");
572
+ const sc = data.scenario || {};
573
+ if (titleEl) titleEl.textContent = sc.title || data.scenario_id || "Negotiation";
574
+ if (metaEl) metaEl.textContent = sc.description || "";
575
+ }
576
+
577
+ function _updatePersonaPanel(data) {
578
+ const p = data.persona || {};
579
+ const nameEl = document.getElementById("persona-name");
580
+ const descEl = document.getElementById("persona-desc");
581
+ const avatEl = document.getElementById("persona-avatar");
582
+ if (nameEl) nameEl.textContent = p.name || _personaLabel(gameState.persona);
583
+ if (descEl) descEl.textContent = p.style || "";
584
+ if (avatEl) avatEl.textContent = p.symbol || p.emoji || "β—ˆ";
585
+ }
586
+
587
  // ── Message Bubbles ────────────────────────────────────────────
588
  function addMessage(role, text, offer, move) {
589
  const thread = document.getElementById("chat-thread");
590
+ if (!thread) return null;
591
 
 
592
  const existing = thread.querySelector(".thinking-bubble");
593
  if (existing) existing.remove();
594
 
 
598
  sys.textContent = text;
599
  thread.appendChild(sys);
600
  _scrollThread(thread);
601
+ return null;
602
  }
603
 
604
  const bubble = document.createElement("div");
605
  bubble.className = `message-bubble ${role}`;
606
+ if (role === "opponent" && gameState.persona) {
607
+ bubble.setAttribute("data-persona", gameState.persona);
608
+ }
609
 
 
610
  const meta = document.createElement("div");
611
  meta.className = "bubble-meta";
612
 
 
617
  if (move) {
618
  const pill = document.createElement("span");
619
  pill.className = `move-pill ${move}`;
620
+ pill.textContent = move.replace(/_/g, " ");
621
  meta.appendChild(pill);
622
  }
623
 
 
624
  const body = document.createElement("div");
625
  body.className = "bubble-body";
626
  body.textContent = text;
627
 
 
628
  if (offer != null && !isNaN(offer)) {
629
  const chip = document.createElement("div");
630
  chip.className = "offer-chip";
 
636
  bubble.appendChild(body);
637
  thread.appendChild(bubble);
638
  _scrollThread(thread);
639
+ return bubble;
640
  }
641
 
642
  function _showThinkingBubble() {
643
  const thread = document.getElementById("chat-thread");
644
  if (!thread) return null;
645
 
646
+ const id = "thinking-" + Date.now();
647
  const wrap = document.createElement("div");
648
  wrap.className = "thinking-bubble";
649
  wrap.id = id;
 
666
  }
667
 
668
  function _scrollThread(thread) {
669
+ requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
 
 
670
  }
671
 
672
  // ── ZOPA Bar ──────────────────────────────────────────────────
 
674
  const track = document.getElementById("zopa-track");
675
  if (!track) return;
676
 
677
+ const batnaPlayer = observation.player_batna ?? observation.your_batna ?? observation.zopa_lower ?? 0;
678
+ const batnaOpponent = observation.opponent_batna ?? observation.opp_batna ?? observation.zopa_upper ?? 100;
679
  const currentOffer = observation.opponent_offer ?? observation.player_offer ?? null;
680
  const nash = observation.nash_point ?? null;
681
 
 
682
  const minVal = Math.min(batnaPlayer, batnaOpponent) * 0.9;
683
  const maxVal = Math.max(batnaPlayer, batnaOpponent) * 1.1;
684
  const range = maxVal - minVal || 1;
685
+ const pct = (v) => `${Math.max(0, Math.min(100, ((v - minVal) / range) * 100)).toFixed(1)}%`;
686
 
 
 
 
687
  const zopaZone = document.getElementById("zopa-zone");
688
  if (zopaZone) {
689
  const lo = Math.min(batnaPlayer, batnaOpponent);
 
692
  zopaZone.style.width = `${(((hi - lo) / range) * 100).toFixed(1)}%`;
693
  }
694
 
 
695
  const mPlayer = document.getElementById("marker-player");
696
  if (mPlayer) mPlayer.style.left = pct(batnaPlayer);
697
 
 
698
  const mOpponent = document.getElementById("marker-opponent");
699
  if (mOpponent) mOpponent.style.left = pct(batnaOpponent);
700
 
 
701
  const mCurrent = document.getElementById("marker-current");
702
  if (mCurrent && currentOffer != null) {
703
  mCurrent.style.left = pct(currentOffer);
704
  mCurrent.style.display = "flex";
705
  }
706
 
 
707
  const nashEl = document.getElementById("nash-diamond");
708
  if (nashEl && nash != null) {
709
  nashEl.style.left = pct(nash);
710
  nashEl.style.display = "block";
711
  }
712
 
 
713
  const lblLow = document.getElementById("zopa-label-low");
714
  const lblHigh = document.getElementById("zopa-label-high");
715
  if (lblLow) lblLow.textContent = formatCurrency(minVal, "USD");
716
  if (lblHigh) lblHigh.textContent = formatCurrency(maxVal, "USD");
 
 
717
  }
718
 
719
  // ── Tension Meter ─────────────────────────────────────────────
 
722
  const value = document.getElementById("tension-value");
723
  if (!fill) return;
724
 
725
+ // tensionScore arrives as 0–100 from the server
726
+ const pct = Math.max(0, Math.min(100, tensionScore || 0));
727
  fill.style.width = `${pct}%`;
728
 
729
  let level = "low";
730
+ if (pct >= 75) level = "high";
731
+ else if (pct >= 50) level = "medium";
732
 
733
  fill.setAttribute("data-level", level);
734
 
 
752
  };
753
 
754
  Object.entries(mapping).forEach(([id, val]) => {
755
+ const fill = document.getElementById(id + "-fill");
756
+ const pctEl = document.getElementById(id + "-pct");
757
+ const confEl= document.getElementById(id + "-conf");
 
758
  if (!fill) return;
759
  const pct = Math.max(0, Math.min(100, val * 100));
760
  fill.style.width = `${pct.toFixed(1)}%`;
761
  if (pctEl) pctEl.textContent = `${Math.round(pct)}%`;
 
 
762
  if (confEl) {
763
  const conf = pct > 60 ? "high" : pct > 30 ? "medium" : "low";
764
  confEl.className = `belief-confidence confidence-${conf}`;
765
  }
766
  });
767
 
768
+ if (charts && charts.updateBeliefChart) charts.updateBeliefChart(beliefState);
 
 
 
 
 
769
  }
770
 
771
  // ── CP Bar ────────────────────────────────────────────────────
 
773
  const fill = document.getElementById("cp-fill");
774
  const value = document.getElementById("cp-value");
775
  if (!fill) return;
 
776
  const maxCp = gameState.maxCp || 100;
777
  const pct = Math.max(0, Math.min(100, (cp / maxCp) * 100));
778
  fill.style.width = `${pct}%`;
 
788
 
789
  let state = "idle";
790
  if (drift) state = "shocked";
791
+ else if (tension > 75) state = "aggressive";
792
+ else if (tension > 50) state = "thinking";
793
+ else if (tension < 25 && gameState.turnCount > 0) state = "pleased";
794
 
795
  character.setState(state);
796
+
797
+ // After drift: revert to aggressive after 2s
798
+ if (drift && character) {
799
+ setTimeout(() => {
800
+ if (character && gameState.sessionId) character.setState("aggressive");
801
+ }, 2000);
802
+ }
803
+
804
+ const label = document.getElementById("character-state-label");
805
+ if (label) label.textContent = state;
806
+
807
+ if (APP_DEBUG) console.log("[app] character state:", state, "tension:", tension);
808
  }
809
 
810
  // ── Drift Alert ───────────────────────────────────────────────
 
814
 
815
  const text = document.getElementById("drift-alert-text");
816
  if (text) {
817
+ text.textContent = typeof driftEvent === "string"
818
+ ? driftEvent
819
+ : (driftEvent.description || driftEvent.event || "Market conditions have shifted.");
 
 
820
  }
821
 
822
  bar.classList.remove("hidden");
823
 
 
824
  if (_driftTimer) clearTimeout(_driftTimer);
825
  _driftTimer = setTimeout(dismissDriftAlert, 8000);
826
  }
 
836
  const container = document.getElementById("hand-container");
837
  if (!container) return;
838
 
839
+ container.innerHTML = ""; // safe β€” card definitions contain no user data
840
+
841
+ if (!hand || !hand.length) return;
842
 
843
  hand.forEach((card) => {
844
  const wrapper = document.createElement("div");
845
  wrapper.className = "tactical-card";
846
+ wrapper.dataset.cardId = card.id || card.card_id || card.move || "";
847
 
848
  const inner = document.createElement("div");
849
  inner.className = "card-inner";
850
 
 
851
  const front = document.createElement("div");
852
  front.className = "card-face";
853
 
854
  const name = document.createElement("div");
855
  name.className = "card-name";
856
+ name.textContent = card.name || card.move || "Tactic";
857
+ front.appendChild(name);
858
 
859
  const type = document.createElement("div");
860
  type.className = "card-type";
861
+ type.textContent = card.type || card.move || "";
862
+ front.appendChild(type);
863
 
864
  const cost = document.createElement("div");
865
  cost.className = "card-cost";
866
+ cost.textContent = `${card.cp_cost ?? card.cost ?? "β€”"} CP`;
 
 
 
867
  front.appendChild(cost);
868
 
 
869
  const back = document.createElement("div");
870
  back.className = "card-back";
871
 
872
  const backLabel = document.createElement("div");
873
  backLabel.className = "card-back-label";
874
  backLabel.textContent = "Game Theory";
875
+ back.appendChild(backLabel);
876
 
877
  const gt = document.createElement("div");
878
  gt.className = "card-game-theory";
879
  gt.textContent = card.game_theory_basis || card.description || "";
 
 
880
  back.appendChild(gt);
881
 
882
  inner.appendChild(front);
883
  inner.appendChild(back);
884
  wrapper.appendChild(inner);
885
 
 
886
  wrapper.addEventListener("click", () => {
887
  const already = wrapper.classList.contains("selected");
888
  container.querySelectorAll(".tactical-card").forEach(c => c.classList.remove("selected"));
 
891
 
892
  container.appendChild(wrapper);
893
  });
 
 
894
  }
895
 
896
  // ── Leaderboard ───────────────────────────────────────────────
897
  function renderLeaderboard(entries) {
898
  const tbody = document.getElementById("leaderboard-body");
899
  if (!tbody) return;
 
900
  tbody.innerHTML = "";
901
 
902
+ if (!entries || !entries.length) {
903
  const tr = document.createElement("tr");
904
  const td = document.createElement("td");
905
  td.colSpan = 4;
 
912
 
913
  entries.forEach((entry, idx) => {
914
  const tr = document.createElement("tr");
915
+ if (entry.player_name === gameState.playerName) tr.classList.add("highlight-player");
916
 
 
 
 
 
 
 
917
  const rankTd = document.createElement("td");
918
  const rankSpan = document.createElement("span");
919
+ rankSpan.className = "lb-rank" + (idx === 0 ? " gold" : idx === 1 ? " silver" : idx === 2 ? " bronze" : "");
 
920
  rankSpan.textContent = `#${idx + 1}`;
921
  rankTd.appendChild(rankSpan);
922
  tr.appendChild(rankTd);
923
 
 
924
  const nameTd = document.createElement("td");
925
  nameTd.textContent = entry.player_name || "β€”";
926
  tr.appendChild(nameTd);
927
 
 
928
  const scoreTd = document.createElement("td");
929
  scoreTd.className = "num";
930
+ scoreTd.textContent = (entry.score ?? entry.total_reward ?? entry.reward ?? 0).toFixed(2);
931
  tr.appendChild(scoreTd);
932
 
 
933
  const dealsTd = document.createElement("td");
934
  dealsTd.className = "num";
935
+ dealsTd.textContent = entry.deals ?? (entry.deal_closed ? "βœ“" : "β€”");
936
  tr.appendChild(dealsTd);
937
 
938
  tbody.appendChild(tr);
939
  });
 
 
940
  }
941
 
942
  // ── Achievements ──────────────────────────────────────────────
943
  function showAchievements(achievements) {
944
  if (!achievements || !Array.isArray(achievements)) return;
 
945
  achievements.forEach((ach) => {
946
  const el = document.querySelector(`.badge[data-achievement="${ach.id}"]`);
947
  if (el && !el.classList.contains("earned")) {
 
955
  const toast = document.createElement("div");
956
  toast.style.cssText = [
957
  "position:fixed", "bottom:24px", "right:24px", "z-index:9999",
958
+ "background:var(--mahogany)", "border:1px solid var(--gold)",
959
+ "border-radius:6px", "padding:12px 20px",
960
+ "font-family:var(--font-display)", "font-style:italic",
961
+ "font-size:0.9rem", "color:var(--cream)",
962
+ "box-shadow:0 4px 16px rgba(0,0,0,0.4)",
963
  "animation:slide-down 200ms ease",
964
  ].join(";");
965
  toast.textContent = msg;
 
969
 
970
  // ── Game Over ─────────────────────────────────────────────────
971
  function _handleGameOver(data) {
972
+ const deal = data.deal_reached ?? data.deal ?? false;
973
+ const amount = data.deal_amount ?? data.final_price ?? null;
974
+ const score = data.reward ?? data.total_reward ?? data.score ?? 0;
975
 
 
976
  const resultMsg = deal
977
+ ? `Deal closed at ${formatCurrency(amount, "USD")}! Score: ${score.toFixed(2)}`
978
+ : "No deal. You walked away from the table.";
 
979
  addMessage("system", resultMsg);
980
 
 
981
  const banner = document.getElementById("result-banner");
982
  if (banner) {
983
  banner.className = `result-banner ${deal ? "deal" : "walk"}`;
 
987
  if (title) title.textContent = deal ? "Deal Closed" : "Walked Away";
988
 
989
  const amountEl = banner.querySelector(".result-amount");
990
+ if (amountEl) amountEl.textContent = amount != null ? formatCurrency(amount, "USD") : "β€”";
 
991
 
992
  const scoreEl = banner.querySelector(".result-score");
993
  if (scoreEl) scoreEl.textContent = `Score: ${score.toFixed(2)}`;
994
  }
995
 
 
996
  if (character) character.setState(deal ? "pleased" : "shocked");
 
 
997
  setTimeout(loadLeaderboard, 1200);
998
 
999
+ if (APP_DEBUG) console.log("[app] game over", { deal, amount, score });
1000
  }
1001
 
1002
+ // ── Scenario Dossier Cards ────────────────────────────────────
1003
+ function _renderScenarioDossiers(scenarios) {
1004
+ const grid = document.getElementById("scenario-dossier-grid");
1005
  if (!grid) return;
 
1006
  grid.innerHTML = "";
1007
 
1008
+ const defaults = [
1009
+ { id: "saas_enterprise", title: "Enterprise SaaS", description: "500-seat analytics platform", zopa_lower: 125000, zopa_upper: 165000, difficulty: 2 },
1010
+ { id: "consulting_retainer", title: "Consulting Retainer", description: "Monthly strategy retainer", zopa_lower: 25000, zopa_upper: 40000, difficulty: 1 },
1011
+ { id: "hiring_package", title: "Senior Eng. Offer", description: "Total comp negotiation", zopa_lower: 195000, zopa_upper: 230000, difficulty: 2 },
1012
+ { id: "vendor_hardware", title: "Hardware Contract", description: "200-unit bulk purchase", zopa_lower: 1750000, zopa_upper: 2200000, difficulty: 3 },
1013
+ { id: "acquisition_term_sheet", title: "Startup Acquisition", description: "Acqui-hire term sheet", zopa_lower: 10500000,zopa_upper:16000000, difficulty: 3 },
1014
+ ];
 
 
1015
 
1016
+ const list = (scenarios && scenarios.length) ? scenarios : defaults;
1017
+ const diffLabels = ["", "Easy", "Medium", "Hard"];
1018
+ let caseNum = 1;
1019
+
1020
+ list.forEach((s) => {
1021
  const card = document.createElement("div");
1022
+ card.className = "scenario-dossier";
1023
  card.dataset.scenarioId = s.id || s.scenario_id;
1024
+ card.setAttribute("role", "radio");
1025
+ card.setAttribute("tabindex", "0");
1026
+
1027
+ const caseEl = document.createElement("div");
1028
+ caseEl.className = "dossier-case";
1029
+ caseEl.textContent = `CASE ${String(caseNum++).padStart(3, "0")}`;
1030
+ card.appendChild(caseEl);
1031
+
1032
+ const diffEl = document.createElement("div");
1033
+ diffEl.className = "dossier-difficulty";
1034
+ diffEl.textContent = diffLabels[s.difficulty ?? 2] || "Medium";
1035
+ card.appendChild(diffEl);
1036
+
1037
+ const titleEl = document.createElement("div");
1038
+ titleEl.className = "dossier-title";
1039
+ titleEl.textContent = s.title || s.name;
1040
+ card.appendChild(titleEl);
1041
+
1042
+ const descEl = document.createElement("div");
1043
+ descEl.className = "dossier-desc";
1044
+ descEl.textContent = s.description || "";
1045
+ card.appendChild(descEl);
1046
+
1047
+ const zopaLo = s.zopa_lower ?? s.zopa?.[0] ?? 0;
1048
+ const zopaHi = s.zopa_upper ?? s.zopa?.[1] ?? 0;
1049
+ const zopaEl = document.createElement("div");
1050
+ zopaEl.className = "dossier-zopa";
1051
+ zopaEl.textContent = `ZOPA ${formatCurrency(zopaLo, "USD")} – ${formatCurrency(zopaHi, "USD")}`;
1052
+ card.appendChild(zopaEl);
1053
 
1054
  card.addEventListener("click", () => {
1055
+ grid.querySelectorAll(".scenario-dossier").forEach(c => c.classList.remove("selected"));
1056
  card.classList.add("selected");
1057
  });
1058
+ card.addEventListener("keydown", (e) => {
1059
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); card.click(); }
1060
+ });
1061
 
1062
  grid.appendChild(card);
1063
  });
1064
  }
1065
 
1066
+ // ── Persona Cards ──────────────────────────────────────────────
1067
+ function _renderPersonaCards(personas) {
1068
+ const grid = document.getElementById("persona-cards-grid");
1069
  if (!grid) return;
 
1070
  grid.innerHTML = "";
1071
 
1072
  const defaults = [
1073
+ { id: "shark", name: "The Shark", symbol: "β—ˆ", aggression: 0.88, patience: 0.18, bluff_rate: 0.72 },
1074
+ { id: "diplomat", name: "The Diplomat", symbol: "β—Ž", aggression: 0.20, patience: 0.85, bluff_rate: 0.15 },
1075
+ { id: "analyst", name: "The Analyst", symbol: "β—»", aggression: 0.35, patience: 0.90, bluff_rate: 0.10 },
1076
+ { id: "wildcard", name: "The Wildcard", symbol: "β—‡", aggression: 0.60, patience: 0.25, bluff_rate: 0.65 },
1077
+ { id: "veteran", name: "The Veteran", symbol: "β—†", aggression: 0.50, patience: 0.95, bluff_rate: 0.45 },
1078
  ];
1079
 
1080
  const list = (personas && personas.length) ? personas : defaults;
1081
 
1082
  list.forEach((p) => {
1083
+ const pid = p.id || p.persona_id;
 
 
1084
 
1085
+ const card = document.createElement("div");
1086
+ card.className = "persona-card-option";
1087
+ card.dataset.persona = pid;
1088
+ card.setAttribute("role", "radio");
1089
+ card.setAttribute("tabindex", "0");
1090
+
1091
+ // Mini Three.js preview canvas
1092
+ const canvasWrap = document.createElement("div");
1093
+ canvasWrap.className = "persona-card-canvas-wrap";
1094
+ const previewCanvas = document.createElement("canvas");
1095
+ previewCanvas.width = 280;
1096
+ previewCanvas.height = 200;
1097
+ canvasWrap.appendChild(previewCanvas);
1098
+ card.appendChild(canvasWrap);
1099
+
1100
+ const nameEl = document.createElement("div");
1101
+ nameEl.className = "persona-card-name";
1102
+ nameEl.textContent = p.name || pid;
1103
+ card.appendChild(nameEl);
1104
+
1105
+ const symEl = document.createElement("div");
1106
+ symEl.className = "persona-card-symbol";
1107
+ symEl.textContent = p.symbol || "β—ˆ";
1108
+ card.appendChild(symEl);
1109
+
1110
+ // Trait bars
1111
+ const traits = document.createElement("div");
1112
+ traits.className = "persona-trait-bars";
1113
+ [
1114
+ { label: "AGG", val: p.aggression ?? 0.5 },
1115
+ { label: "PAT", val: p.patience ?? 0.5 },
1116
+ ].forEach(({ label, val }) => {
1117
+ const row = document.createElement("div");
1118
+ row.className = "persona-trait-row";
1119
+ const lbl = document.createElement("div");
1120
+ lbl.className = "persona-trait-label";
1121
+ lbl.textContent = label;
1122
+ const bar = document.createElement("div");
1123
+ bar.className = "persona-trait-bar";
1124
+ const fill = document.createElement("div");
1125
+ fill.className = "persona-trait-fill";
1126
+ fill.style.width = `${Math.round(val * 100)}%`;
1127
+ bar.appendChild(fill);
1128
+ row.appendChild(lbl);
1129
+ row.appendChild(bar);
1130
+ traits.appendChild(row);
1131
+ });
1132
+ card.appendChild(traits);
1133
 
1134
+ // Click selection
1135
+ card.addEventListener("click", () => {
1136
+ grid.querySelectorAll(".persona-card-option").forEach(c => c.classList.remove("selected"));
1137
+ card.classList.add("selected");
1138
+ });
1139
+ card.addEventListener("keydown", (e) => {
1140
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); card.click(); }
1141
+ });
1142
 
1143
+ grid.appendChild(card);
 
1144
 
1145
+ // Spin up a PersonaPreviewCharacter after DOM insert
1146
+ requestAnimationFrame(() => {
1147
+ if (typeof PersonaPreviewCharacter !== "undefined") {
1148
+ const prev = new PersonaPreviewCharacter(previewCanvas, pid);
1149
+ _previewChars[pid] = prev;
1150
+ }
1151
  });
 
 
1152
  });
1153
  }
1154
 
1155
+ function _destroyPreviewChars() {
1156
+ Object.values(_previewChars).forEach(c => { try { c.destroy(); } catch {} });
1157
+ _previewChars = {};
1158
+ }
1159
+
1160
  // ── Character init ────────────────────────────────────────────
1161
  function _initCharacter(persona) {
1162
+ if (typeof NegotiatorCharacter === "undefined") return;
 
 
 
1163
  if (character) character.destroy();
1164
  character = new NegotiatorCharacter("character-canvas", persona || "shark");
1165
+ if (APP_DEBUG) console.log("[app] character created for persona:", persona);
1166
  }
1167
 
1168
  // ── Sparkline init ────────────────────────────────────────────
1169
  function _initSparkline(observation) {
1170
  if (!charts || !observation) return;
1171
+ const lo = observation.player_batna ?? observation.your_batna ?? observation.zopa_lower ?? 0;
1172
+ const hi = observation.opponent_batna ?? observation.opp_batna ?? observation.zopa_upper ?? 0;
1173
+ const nash = observation.nash_point ?? ((lo + hi) / 2);
1174
+ charts.initOfferSparkline && charts.initOfferSparkline("offer-sparkline", lo, hi, nash);
1175
+ charts.initBeliefChart && charts.initBeliefChart("belief-chart");
 
 
 
1176
  }
1177
 
1178
  // ── Helpers ───────────────────────────────────────────────────
 
1180
  if (amount == null || isNaN(amount)) return "β€”";
1181
  try {
1182
  return new Intl.NumberFormat("en-US", {
1183
+ style: "currency", currency: currency || "USD",
 
1184
  maximumFractionDigits: 0,
1185
  }).format(amount);
1186
  } catch {
 
1190
 
1191
  function setLoading(isLoading) {
1192
  const overlay = document.getElementById("loading-overlay");
1193
+ if (overlay) overlay.classList.toggle("hidden", !isLoading);
 
 
 
 
1194
  ["btn-submit", "btn-accept", "btn-walk"].forEach(id => {
1195
  const btn = document.getElementById(id);
1196
  if (btn) btn.disabled = isLoading;
 
1198
  }
1199
 
1200
  function _setInputsDisabled(disabled) {
1201
+ ["offer-input", "move-select", "btn-submit", "btn-accept", "btn-walk"].forEach(id => {
 
1202
  const el = document.getElementById(id);
1203
  if (el) el.disabled = disabled;
1204
  });
 
1209
  const pill = document.getElementById(`act-pill-${n}`);
1210
  if (!pill) return;
1211
  pill.classList.remove("active", "completed");
1212
+ if (n < act) pill.classList.add("completed");
1213
  else if (n === act) pill.classList.add("active");
1214
  });
1215
  }
 
1219
  case "anchor": return `Anchoring at ${formatCurrency(offer, "USD")}.`;
1220
  case "counter": return `Counter offer: ${formatCurrency(offer, "USD")}.`;
1221
  case "concede": return `Concession: ${formatCurrency(offer, "USD")}.`;
1222
+ case "package": return `Package deal: ${formatCurrency(offer, "USD")}.`;
1223
  case "accept": return "Accepting the deal.";
1224
+ case "walk_away": return "Walking away from the table.";
1225
  default: return offer != null ? formatCurrency(offer, "USD") : move;
1226
  }
1227
  }
1228
 
1229
  function _personaLabel(persona) {
1230
  const labels = {
1231
+ shark: "The Shark",
1232
+ diplomat: "The Diplomat",
1233
+ analyst: "The Analyst",
1234
+ wildcard: "The Wildcard",
1235
+ veteran: "The Veteran",
1236
  };
1237
  return labels[persona] || persona;
1238
  }
dashboard/static/character.js CHANGED
@@ -1,48 +1,155 @@
1
  // ============================================================
2
- // Parlay β€” Three.js r128 Negotiator Character System
3
- // Bloomberg terminal meets poker app.
4
- // MUST be loaded AFTER Three.js r128 from cdnjs.
 
5
  // ============================================================
6
 
 
7
  const CHARACTER_STATES = {
8
- idle: { headTilt: 0, eyeScale: 1.0, animSpeed: 0.5, mouthOpen: 0, mouthCurve: 0 },
9
- thinking: { headTilt: 0.15, eyeScale: 0.8, animSpeed: 0.3, mouthOpen: 0, mouthCurve: 0 },
10
- aggressive: { headTilt: -0.1, eyeScale: 1.3, animSpeed: 1.2, mouthOpen: 0.4, mouthCurve: -0.2 },
11
- pleased: { headTilt: 0.08, eyeScale: 1.1, animSpeed: 0.8, mouthOpen: 0.2, mouthCurve: 0.3 },
12
- shocked: { headTilt: 0.25, eyeScale: 1.5, animSpeed: 2.0, mouthOpen: 0.7, mouthCurve: 0 },
13
  };
14
 
15
- const PERSONA_COLORS = {
16
- shark: { primary: 0xb83030, secondary: 0x8a2020, accent: 0xe05050, suit: 0x2a1010, tie: 0xb83030 },
17
- diplomat: { primary: 0x2d7a4f, secondary: 0x1d5a3a, accent: 0x4daa70, suit: 0x1a2e22, tie: 0x2d7a4f },
18
- analyst: { primary: 0x1a5fa8, secondary: 0x104080, accent: 0x3a8fd8, suit: 0x0f1e30, tie: 0x1a5fa8 },
19
- wildcard: { primary: 0xa05c00, secondary: 0x7a4400, accent: 0xd08020, suit: 0x2a1c00, tie: 0xa05c00 },
20
- veteran: { primary: 0x5c3d9e, secondary: 0x3c2070, accent: 0x8c60d0, suit: 0x1a0f30, tie: 0x5c3d9e },
21
- };
22
-
23
- const PERSONA_SYMBOLS = {
24
- shark: "β–²",
25
- diplomat: "β—†",
26
- analyst: "●",
27
- wildcard: "β˜…",
28
- veteran: "⬟",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  };
30
 
 
31
  class NegotiatorCharacter {
32
  constructor(canvasId, persona = "shark") {
33
- this.canvasId = canvasId;
34
- this.persona = persona;
35
- this.state = "idle";
36
  this.targetState = CHARACTER_STATES.idle;
37
- this.currentTilt = 0;
38
- this.currentEyeScale = 1.0;
39
- this.clock = 0;
40
  this.animFrameId = null;
41
- this.scene = null;
42
- this.camera = null;
43
- this.renderer = null;
44
- this.meshes = {};
 
 
 
 
 
 
 
 
 
 
45
  this._init(canvasId);
 
46
  this._buildCharacter();
47
  this._animate();
48
  }
@@ -55,54 +162,63 @@ class NegotiatorCharacter {
55
  }
56
 
57
  this.scene = new THREE.Scene();
58
- this.scene.background = null;
 
59
 
60
  this.camera = new THREE.PerspectiveCamera(45, 280 / 380, 0.1, 100);
61
- this.camera.position.set(0, 1.0, 5.5);
62
- this.camera.lookAt(0, 0.8, 0);
63
 
64
- this.renderer = new THREE.WebGLRenderer({
65
- canvas,
66
- alpha: true,
67
- antialias: true,
68
- });
69
  this.renderer.setSize(280, 380);
70
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
71
- this.renderer.setClearColor(0x000000, 0);
72
  this.renderer.shadowMap.enabled = true;
73
  this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
 
74
 
75
- // Ambient light
76
- const ambient = new THREE.AmbientLight(0xffffff, 0.55);
77
- this.scene.add(ambient);
78
 
79
- // Key light
80
- const key = new THREE.DirectionalLight(0xffffff, 0.9);
81
- key.position.set(3, 5, 5);
82
  key.castShadow = true;
83
  this.scene.add(key);
84
 
85
- // Fill light
86
- const fill = new THREE.DirectionalLight(0x8899ff, 0.35);
87
- fill.position.set(-3, 2, -2);
88
- this.scene.add(fill);
89
-
90
- // Rim light
91
- const rim = new THREE.DirectionalLight(0xffffff, 0.2);
92
- rim.position.set(0, -2, -4);
93
  this.scene.add(rim);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
 
96
- _makeMat(color, roughness = 0.7, metalness = 0.1) {
97
  return new THREE.MeshStandardMaterial({ color, roughness, metalness });
98
  }
99
 
100
- _makeTextureBadge(symbol, bgColor, fgColor) {
101
  const size = 128;
102
- const cvs = document.createElement("canvas");
103
- cvs.width = size;
104
- cvs.height = size;
105
- const ctx = cvs.getContext("2d");
106
 
107
  ctx.fillStyle = `#${bgColor.toString(16).padStart(6, "0")}`;
108
  ctx.beginPath();
@@ -110,358 +226,463 @@ class NegotiatorCharacter {
110
  ctx.fill();
111
 
112
  ctx.fillStyle = `#${fgColor.toString(16).padStart(6, "0")}`;
113
- ctx.font = "bold 56px sans-serif";
114
  ctx.textAlign = "center";
115
  ctx.textBaseline = "middle";
116
- ctx.fillText(symbol, size / 2, size / 2);
117
 
118
- const tex = new THREE.CanvasTexture(cvs);
119
- return tex;
120
  }
121
 
122
- _clearScene() {
123
- // Remove character meshes
124
- Object.values(this.meshes).forEach(m => {
125
- if (m) this.scene.remove(m);
126
- });
127
- this.meshes = {};
128
  if (this.characterGroup) {
129
- this.scene.remove(this.characterGroup);
130
  }
131
  this.characterGroup = null;
 
132
  }
133
 
134
  _buildCharacter() {
135
- this._clearScene();
 
136
 
137
- const colors = PERSONA_COLORS[this.persona];
138
  const group = new THREE.Group();
139
  this.characterGroup = group;
140
  this.scene.add(group);
141
 
142
- // Skin tone β€” neutral
143
- const skinColor = 0xf5d0a9;
144
- const skinMat = this._makeMat(skinColor, 0.85, 0.0);
145
- const suitMat = this._makeMat(colors.suit, 0.8, 0.05);
146
- const shirtMat = this._makeMat(0xf0f0f0, 0.9, 0.0);
147
- const tieMat = this._makeMat(colors.tie, 0.6, 0.05);
148
- const accentMat= this._makeMat(colors.accent, 0.5, 0.2);
149
- const eyeWhite = this._makeMat(0xffffff, 0.95, 0.0);
150
- const eyePupil = this._makeMat(0x111111, 0.9, 0.0);
151
- const hairMat = this._makeMat(0x2a1a0a, 0.9, 0.0);
152
-
153
- // --- TORSO ---
154
- const torsoGeo = new THREE.BoxGeometry(1.1, 1.3, 0.6);
155
- const torso = new THREE.Mesh(torsoGeo, suitMat);
156
- torso.position.set(0, 0, 0);
157
  group.add(torso);
158
  this.meshes.torso = torso;
159
 
160
- // Shirt (white strip on chest)
161
- const shirtGeo = new THREE.BoxGeometry(0.36, 0.9, 0.32);
162
- const shirt = new THREE.Mesh(shirtGeo, shirtMat);
163
- shirt.position.set(0, 0.1, 0.32);
164
- group.add(shirt);
165
 
166
  // Tie
167
- const tieGeo = new THREE.BoxGeometry(0.12, 0.75, 0.06);
168
- const tie = new THREE.Mesh(tieGeo, tieMat);
169
- tie.position.set(0, 0.1, 0.36);
170
- group.add(tie);
171
 
172
- // Suit lapels (two thin boxes)
173
  const lapelGeo = new THREE.BoxGeometry(0.22, 0.7, 0.08);
174
- const lapelL = new THREE.Mesh(lapelGeo, suitMat);
175
- lapelL.position.set(-0.22, 0.25, 0.3);
176
- lapelL.rotation.z = 0.2;
177
  group.add(lapelL);
178
-
179
- const lapelR = new THREE.Mesh(lapelGeo, suitMat);
180
- lapelR.position.set(0.22, 0.25, 0.3);
181
- lapelR.rotation.z = -0.2;
182
  group.add(lapelR);
183
 
184
- // Persona badge on left breast
185
- const badgeTex = this._makeTextureBadge(
186
- PERSONA_SYMBOLS[this.persona],
187
- colors.primary,
188
- 0xffffff
189
  );
190
- const badgeMat = new THREE.MeshStandardMaterial({ map: badgeTex, roughness: 0.5 });
191
- const badgeGeo = new THREE.BoxGeometry(0.2, 0.2, 0.04);
192
- const badge = new THREE.Mesh(badgeGeo, badgeMat);
193
- badge.position.set(-0.28, 0.4, 0.32);
194
  group.add(badge);
195
 
196
- // --- NECK ---
197
- const neckGeo = new THREE.CylinderGeometry(0.13, 0.15, 0.3, 8);
198
- const neck = new THREE.Mesh(neckGeo, skinMat);
 
 
199
  neck.position.set(0, 0.8, 0);
200
  group.add(neck);
201
- this.meshes.neck = neck;
202
 
203
- // --- HEAD ---
204
- const headGeo = new THREE.BoxGeometry(0.75, 0.85, 0.65);
205
- const head = new THREE.Mesh(headGeo, skinMat);
206
  head.position.set(0, 1.45, 0);
 
207
  group.add(head);
208
  this.meshes.head = head;
209
 
210
- // Hair
211
- const hairGeo = new THREE.BoxGeometry(0.78, 0.28, 0.68);
212
- const hair = new THREE.Mesh(hairGeo, hairMat);
213
- hair.position.set(0, 1.82, -0.02);
214
- group.add(hair);
215
 
216
  // Hair sides
217
- const hairSideGeo = new THREE.BoxGeometry(0.12, 0.45, 0.6);
218
- const hairL = new THREE.Mesh(hairSideGeo, hairMat);
219
- hairL.position.set(-0.37, 1.6, -0.02);
220
- group.add(hairL);
221
- const hairR = new THREE.Mesh(hairSideGeo, hairMat);
222
- hairR.position.set(0.37, 1.6, -0.02);
223
- group.add(hairR);
224
-
225
- // --- EYES ---
226
- const eyeW = new THREE.SphereGeometry(0.1, 12, 8);
227
- const eyeP = new THREE.SphereGeometry(0.055, 8, 6);
 
 
228
 
229
  const eyeLW = new THREE.Mesh(eyeW, eyeWhite);
230
- eyeLW.position.set(-0.19, 1.5, 0.33);
231
  group.add(eyeLW);
232
  this.meshes.eyeLeft = eyeLW;
233
 
234
  const eyeRW = new THREE.Mesh(eyeW, eyeWhite);
235
- eyeRW.position.set(0.19, 1.5, 0.33);
236
  group.add(eyeRW);
237
  this.meshes.eyeRight = eyeRW;
238
 
239
- const eyeLP = new THREE.Mesh(eyeP, eyePupil);
240
- eyeLP.position.set(-0.19, 1.5, 0.38);
241
  group.add(eyeLP);
242
  this.meshes.pupilLeft = eyeLP;
243
 
244
- const eyeRP = new THREE.Mesh(eyeP, eyePupil);
245
- eyeRP.position.set(0.19, 1.5, 0.38);
246
  group.add(eyeRP);
247
  this.meshes.pupilRight = eyeRP;
248
 
249
- // Eyebrows
250
- const browGeo = new THREE.BoxGeometry(0.18, 0.04, 0.04);
251
- const browMat = this._makeMat(0x2a1a0a, 0.9, 0.0);
 
252
  const browL = new THREE.Mesh(browGeo, browMat);
253
  browL.position.set(-0.19, 1.63, 0.33);
254
  group.add(browL);
255
  this.meshes.browL = browL;
256
-
257
  const browR = new THREE.Mesh(browGeo, browMat);
258
  browR.position.set(0.19, 1.63, 0.33);
259
  group.add(browR);
260
  this.meshes.browR = browR;
261
 
262
- // --- MOUTH ---
263
- // We represent the mouth as a thin box that we morph
264
- const mouthGeo = new THREE.BoxGeometry(0.22, 0.04, 0.04);
265
- const mouthMat = this._makeMat(0x8b3a3a, 0.8, 0.0);
266
- const mouth = new THREE.Mesh(mouthGeo, mouthMat);
267
  mouth.position.set(0, 1.33, 0.33);
268
  group.add(mouth);
269
  this.meshes.mouth = mouth;
270
 
271
- // Nose (small box)
272
- const noseGeo = new THREE.BoxGeometry(0.1, 0.1, 0.12);
273
- const nose = new THREE.Mesh(noseGeo, this._makeMat(0xe8b890, 0.85, 0.0));
274
- nose.position.set(0, 1.44, 0.35);
 
 
275
  group.add(nose);
276
 
277
- // --- SHOULDERS / UPPER ARMS ---
278
- const shoulderGeo = new THREE.BoxGeometry(0.32, 0.28, 0.45);
279
- const shlL = new THREE.Mesh(shoulderGeo, suitMat);
280
- shlL.position.set(-0.7, 0.5, 0);
281
- group.add(shlL);
282
- const shlR = new THREE.Mesh(shoulderGeo, suitMat);
283
- shlR.position.set(0.7, 0.5, 0);
284
- group.add(shlR);
285
 
286
- // Upper arms
287
  const uArmGeo = new THREE.BoxGeometry(0.28, 0.6, 0.3);
288
- const uArmL = new THREE.Mesh(uArmGeo, suitMat);
289
- uArmL.position.set(-0.72, 0.05, 0);
290
- group.add(uArmL);
291
  this.meshes.upperArmL = uArmL;
292
-
293
- const uArmR = new THREE.Mesh(uArmGeo, suitMat);
294
- uArmR.position.set(0.72, 0.05, 0);
295
- group.add(uArmR);
296
  this.meshes.upperArmR = uArmR;
297
 
298
- // Lower arms
299
  const lArmGeo = new THREE.BoxGeometry(0.24, 0.55, 0.26);
300
- const lArmL = new THREE.Mesh(lArmGeo, suitMat);
301
- lArmL.position.set(-0.72, -0.5, 0);
302
- group.add(lArmL);
303
-
304
- const lArmR = new THREE.Mesh(lArmGeo, suitMat);
305
- lArmR.position.set(0.72, -0.5, 0);
306
- group.add(lArmR);
307
 
308
- // Hands (spheres)
309
  const handGeo = new THREE.SphereGeometry(0.14, 8, 6);
310
- const handL = new THREE.Mesh(handGeo, skinMat);
311
- handL.position.set(-0.72, -0.85, 0);
312
- group.add(handL);
313
-
314
- const handR = new THREE.Mesh(handGeo, skinMat);
315
- handR.position.set(0.72, -0.85, 0);
316
- group.add(handR);
317
-
318
- // Accent cufflinks
319
- const cuffGeo = new THREE.BoxGeometry(0.26, 0.06, 0.28);
320
- const cuffL = new THREE.Mesh(cuffGeo, accentMat);
321
- cuffL.position.set(-0.72, -0.74, 0);
322
- group.add(cuffL);
323
- const cuffR = new THREE.Mesh(cuffGeo, accentMat);
324
- cuffR.position.set(0.72, -0.74, 0);
325
- group.add(cuffR);
326
-
327
- // --- PELVIS / LEGS (partial, cropped by camera) ---
328
- const pelvisGeo = new THREE.BoxGeometry(1.0, 0.3, 0.55);
329
- const pelvis = new THREE.Mesh(pelvisGeo, suitMat);
330
- pelvis.position.set(0, -0.8, 0);
331
- group.add(pelvis);
332
 
333
  const legGeo = new THREE.BoxGeometry(0.38, 0.5, 0.38);
334
- const legL = new THREE.Mesh(legGeo, suitMat);
335
- legL.position.set(-0.3, -1.15, 0);
336
- group.add(legL);
337
 
338
- const legR = new THREE.Mesh(legGeo, suitMat);
339
- legR.position.set(0.3, -1.15, 0);
340
- group.add(legR);
 
341
 
342
- // Centre group
343
- group.position.set(0, 0, 0);
344
 
345
- // Store per-state base positions for lerp
346
- this._eyeBaseY = 1.5;
 
 
 
 
347
  }
348
 
 
349
  setState(newState) {
350
  if (!(newState in CHARACTER_STATES)) return;
351
- this.state = newState;
 
 
 
 
 
 
 
352
  this.targetState = CHARACTER_STATES[newState];
353
- this._updateStateBadge(newState);
354
  }
355
 
356
  setPersona(persona) {
357
- if (!(persona in PERSONA_COLORS)) return;
358
  this.persona = persona;
359
  this._buildCharacter();
360
  }
361
 
362
- _updateStateBadge(state) {
363
- const badge = document.querySelector(".character-state-badge");
364
- if (badge) badge.textContent = state;
 
365
  }
366
 
367
- _lerp(a, b, t) {
368
- return a + (b - a) * t;
 
 
 
 
369
  }
370
 
 
 
371
  _animate() {
372
  this.animFrameId = requestAnimationFrame(() => this._animate());
373
-
374
  if (!this.scene || !this.camera || !this.renderer) return;
375
 
 
 
376
  const target = this.targetState;
377
  const speed = target.animSpeed;
378
- const dt = 0.016; // ~60fps assumption
 
 
 
 
379
 
380
  this.clock += dt * speed;
381
 
382
- // Breathing: subtle y oscillation on whole group
383
- if (this.characterGroup) {
384
- const breathAmp = this.state === "aggressive" ? 0.025 : 0.012;
385
- const breathFreq = this.state === "aggressive" ? 1.8 : 0.9;
386
- this.characterGroup.position.y = Math.sin(this.clock * breathFreq) * breathAmp;
387
 
388
- // Head tilt lerp
389
- this.currentTilt = this._lerp(this.currentTilt, target.headTilt, 0.05);
390
- if (this.meshes.head) {
391
- this.meshes.head.rotation.z = this.currentTilt;
392
- }
393
 
394
- // Eye scale lerp
395
- this.currentEyeScale = this._lerp(this.currentEyeScale, target.eyeScale, 0.06);
396
- const es = this.currentEyeScale;
397
- if (this.meshes.eyeLeft) this.meshes.eyeLeft.scale.setScalar(es);
398
- if (this.meshes.eyeRight) this.meshes.eyeRight.scale.setScalar(es);
399
-
400
- // Pupils β€” subtle idle wander, large for shocked
401
- if (this.meshes.pupilLeft && this.meshes.pupilRight) {
402
- const wanderX = Math.sin(this.clock * 0.7) * 0.025;
403
- const wanderY = Math.cos(this.clock * 0.5) * 0.015;
404
- this.meshes.pupilLeft.position.x = -0.19 + wanderX;
405
- this.meshes.pupilRight.position.x = 0.19 + wanderX;
406
- this.meshes.pupilLeft.position.y = this._eyeBaseY + wanderY;
407
- this.meshes.pupilRight.position.y = this._eyeBaseY + wanderY;
408
- }
409
 
410
- // Eyebrow expression
411
- if (this.meshes.browL && this.meshes.browR) {
412
- const browTarget = this.state === "aggressive" ? -0.12
413
- : this.state === "shocked" ? 0.12
414
- : this.state === "pleased" ? 0.05
415
- : 0;
416
- this.meshes.browL.position.y = this._lerp(this.meshes.browL.position.y, 1.63 + browTarget, 0.07);
417
- this.meshes.browR.position.y = this._lerp(this.meshes.browR.position.y, 1.63 + browTarget, 0.07);
418
-
419
- // Angle brows for aggressive (inner raised) vs. shocked (both raised)
420
- const browAngle = this.state === "aggressive" ? 0.2 : 0;
421
- this.meshes.browL.rotation.z = this._lerp(this.meshes.browL.rotation.z, browAngle, 0.07);
422
- this.meshes.browR.rotation.z = this._lerp(this.meshes.browR.rotation.z, -browAngle, 0.07);
423
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
- // Mouth
426
- if (this.meshes.mouth) {
427
- const openTarget = target.mouthOpen;
428
- const curveTarget = target.mouthCurve;
429
- this.meshes.mouth.scale.y = this._lerp(this.meshes.mouth.scale.y, 1 + openTarget * 2, 0.07);
430
- this.meshes.mouth.position.y = this._lerp(
431
- this.meshes.mouth.position.y,
432
- 1.33 - openTarget * 0.04 + curveTarget * 0.04,
433
- 0.07
 
 
 
 
 
 
 
 
 
 
434
  );
435
  }
 
436
 
437
- // Arm sway idle
438
- if (this.meshes.upperArmL && this.meshes.upperArmR) {
439
- const swayAmp = this.state === "aggressive" ? 0.08 : 0.02;
440
- const swayFreq = this.state === "aggressive" ? 1.2 : 0.4;
441
- const sway = Math.sin(this.clock * swayFreq) * swayAmp;
442
- this.meshes.upperArmL.rotation.z = sway;
443
- this.meshes.upperArmR.rotation.z = -sway;
444
- }
445
 
446
- // Thinking: periodic head bob
447
- if (this.state === "thinking") {
448
- const bobY = Math.sin(this.clock * 1.5) * 0.015;
449
- if (this.meshes.head) {
450
- this.meshes.head.position.y = 1.45 + bobY;
451
- }
452
- } else if (this.meshes.head) {
453
- this.meshes.head.position.y = this._lerp(this.meshes.head.position.y, 1.45, 0.05);
454
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  }
456
 
457
  this.renderer.render(this.scene, this.camera);
458
  }
459
 
460
  destroy() {
461
- if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
462
- this._clearScene();
463
  if (this.renderer) this.renderer.dispose();
464
  }
465
  }
466
 
467
- window.NegotiatorCharacter = NegotiatorCharacter;
 
 
 
 
1
  // ============================================================
2
+ // Parlay β€” Three.js r128 Character System
3
+ // "The Deal Room" β€” 1960s Madison Avenue boardroom aesthetic.
4
+ // Loaded AFTER Three.js r128 from cdnjs.
5
+ // NO CapsuleGeometry. NO OrbitControls.
6
  // ============================================================
7
 
8
+ // ── State definitions ─────────────────────────────────────────────────────
9
  const CHARACTER_STATES = {
10
+ idle: { headTilt: 0, eyeScale: 1.0, animSpeed: 0.6, bodyLean: 0, mouthOpen: 0, mouthCurve: 0 },
11
+ thinking: { headTilt: 0.17, eyeScale: 0.8, animSpeed: 0.3, bodyLean: 0, mouthOpen: 0, mouthCurve: 0 },
12
+ aggressive: { headTilt: -0.08, eyeScale: 1.3, animSpeed: 1.5, bodyLean: -0.08, mouthOpen: 0.4, mouthCurve: -0.2 },
13
+ pleased: { headTilt: 0.08, eyeScale: 1.1, animSpeed: 0.8, bodyLean: 0.03, mouthOpen: 0.2, mouthCurve: 0.3 },
14
+ shocked: { headTilt: 0.25, eyeScale: 1.5, animSpeed: 2.0, bodyLean: 0, mouthOpen: 0.7, mouthCurve: 0 },
15
  };
16
 
17
+ // ── Persona definitions (1960s boardroom) ─────────────────────────────────
18
+ const PERSONA_DEFS = {
19
+ shark: {
20
+ suitColor: 0x1a1a2e, // deep navy
21
+ hairColor: 0x1a1a1a, // black
22
+ tieColor: 0x8b1a1a, // scarlet
23
+ skinColor: 0xf0c8a0,
24
+ badge: "β—ˆ",
25
+ badgeColor: 0xc9a84c,
26
+ // accessory: slim briefcase left hand
27
+ buildAccessory(group, mats) {
28
+ const geo = new THREE.BoxGeometry(0.2, 0.28, 0.06);
29
+ const mat = new THREE.MeshStandardMaterial({ color: 0x2c1810, roughness: 0.7, metalness: 0.1 });
30
+ const briefcase = new THREE.Mesh(geo, mat);
31
+ briefcase.position.set(-0.82, -0.75, 0.04);
32
+ group.add(briefcase);
33
+ // handle
34
+ const hgeo = new THREE.BoxGeometry(0.08, 0.03, 0.04);
35
+ const handle = new THREE.Mesh(hgeo, new THREE.MeshStandardMaterial({ color: 0xc9a84c, roughness: 0.4, metalness: 0.4 }));
36
+ handle.position.set(-0.82, -0.59, 0.04);
37
+ group.add(handle);
38
+ return { briefcase, handle };
39
+ },
40
+ },
41
+ diplomat: {
42
+ suitColor: 0x2a3d28, // dark forest
43
+ hairColor: 0x5c4020, // brown
44
+ tieColor: 0xc9a84c, // gold
45
+ skinColor: 0xf0c8a0,
46
+ badge: "β—Ž",
47
+ badgeColor: 0xc9a84c,
48
+ // accessory: pocket watch fob (squashed sphere)
49
+ buildAccessory(group, mats) {
50
+ const geo = new THREE.SphereGeometry(0.06, 8, 6);
51
+ const mat = new THREE.MeshStandardMaterial({ color: 0xc9a84c, roughness: 0.3, metalness: 0.6 });
52
+ const watch = new THREE.Mesh(geo, mat);
53
+ watch.scale.set(1, 0.5, 0.5);
54
+ watch.position.set(0.38, 0.35, 0.34);
55
+ group.add(watch);
56
+ // chain
57
+ const cgeo = new THREE.BoxGeometry(0.02, 0.12, 0.02);
58
+ const chain = new THREE.Mesh(cgeo, mat);
59
+ chain.position.set(0.38, 0.44, 0.33);
60
+ group.add(chain);
61
+ return { watch, chain };
62
+ },
63
+ },
64
+ analyst: {
65
+ suitColor: 0x1a2535, // charcoal blue
66
+ hairColor: 0x3a3020, // dark
67
+ tieColor: 0x1a5fa8, // blue
68
+ skinColor: 0xf0c8a0,
69
+ badge: "β—»",
70
+ badgeColor: 0x3a8fd8,
71
+ // accessory: glasses frames
72
+ buildAccessory(group, mats) {
73
+ const frameMat = new THREE.MeshStandardMaterial({ color: 0x2c1810, roughness: 0.8 });
74
+ const lensGeo = new THREE.BoxGeometry(0.16, 0.1, 0.02);
75
+ const lensL = new THREE.Mesh(lensGeo, frameMat);
76
+ lensL.position.set(-0.19, 1.49, 0.4);
77
+ group.add(lensL);
78
+ const lensR = new THREE.Mesh(lensGeo, frameMat);
79
+ lensR.position.set(0.19, 1.49, 0.4);
80
+ group.add(lensR);
81
+ const bridgeGeo = new THREE.BoxGeometry(0.07, 0.02, 0.02);
82
+ const bridge = new THREE.Mesh(bridgeGeo, frameMat);
83
+ bridge.position.set(0, 1.49, 0.4);
84
+ group.add(bridge);
85
+ return { lensL, lensR, bridge };
86
+ },
87
+ },
88
+ wildcard: {
89
+ suitColor: 0x3d2810, // warm brown
90
+ hairColor: 0xc8a050, // golden
91
+ tieColor: 0xd08020, // amber
92
+ skinColor: 0xf0c8a0,
93
+ badge: "β—‡",
94
+ badgeColor: 0xd08020,
95
+ // accessory: wide loose tie (wider geo, slight rotation)
96
+ buildAccessory(group, mats) {
97
+ const tieGeo = new THREE.BoxGeometry(0.22, 0.72, 0.06);
98
+ const tieMat = new THREE.MeshStandardMaterial({ color: 0xd08020, roughness: 0.6 });
99
+ const looseTie = new THREE.Mesh(tieGeo, tieMat);
100
+ looseTie.position.set(0.02, 0.1, 0.37);
101
+ looseTie.rotation.z = 0.05;
102
+ group.add(looseTie);
103
+ return { looseTie };
104
+ },
105
+ },
106
+ veteran: {
107
+ suitColor: 0x1a1a1a, // near-black charcoal
108
+ hairColor: 0xe0e0d8, // silver-white
109
+ tieColor: 0x5c3d9e, // deep purple
110
+ skinColor: 0xd8b090, // slightly older skin tone
111
+ badge: "β—†",
112
+ badgeColor: 0xc9a84c,
113
+ // accessory: gold cufflinks at wrist
114
+ buildAccessory(group, mats) {
115
+ const cuffMat = new THREE.MeshStandardMaterial({ color: 0xc9a84c, roughness: 0.2, metalness: 0.7 });
116
+ const cuffGeo = new THREE.BoxGeometry(0.08, 0.06, 0.28);
117
+ const cuffL = new THREE.Mesh(cuffGeo, cuffMat);
118
+ cuffL.position.set(-0.72, -0.73, 0);
119
+ group.add(cuffL);
120
+ const cuffR = new THREE.Mesh(cuffGeo, cuffMat);
121
+ cuffR.position.set(0.72, -0.73, 0);
122
+ group.add(cuffR);
123
+ return { cuffL, cuffR };
124
+ },
125
+ },
126
  };
127
 
128
+ // ── NegotiatorCharacter class ─────────────────────────────────────────────
129
  class NegotiatorCharacter {
130
  constructor(canvasId, persona = "shark") {
131
+ this.canvasId = canvasId;
132
+ this.persona = persona;
133
+ this.state = "idle";
134
  this.targetState = CHARACTER_STATES.idle;
135
+ this.clock = 0;
 
 
136
  this.animFrameId = null;
137
+ this.scene = null;
138
+ this.camera = null;
139
+ this.renderer = null;
140
+ this.meshes = {};
141
+ this.characterGroup = null;
142
+
143
+ // Lerp accumulators
144
+ this._curTilt = 0;
145
+ this._curEyeScale = 1.0;
146
+ this._curLean = 0;
147
+
148
+ // Shocked-state timer
149
+ this._shockedUntil = 0;
150
+
151
  this._init(canvasId);
152
+ this._buildScene();
153
  this._buildCharacter();
154
  this._animate();
155
  }
 
162
  }
163
 
164
  this.scene = new THREE.Scene();
165
+ // Felt-green boardroom background
166
+ this.scene.background = new THREE.Color(0x1c2b1a);
167
 
168
  this.camera = new THREE.PerspectiveCamera(45, 280 / 380, 0.1, 100);
169
+ this.camera.position.set(0, 1.6, 5.2);
170
+ this.camera.lookAt(0, 1.3, 0);
171
 
172
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
 
 
 
 
173
  this.renderer.setSize(280, 380);
174
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
 
175
  this.renderer.shadowMap.enabled = true;
176
  this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
177
+ }
178
 
179
+ _buildScene() {
180
+ if (!this.scene) return;
 
181
 
182
+ // Warm key light β€” brass pendant lamp
183
+ const key = new THREE.DirectionalLight(0xffe8b0, 1.4);
184
+ key.position.set(3, 6, 4);
185
  key.castShadow = true;
186
  this.scene.add(key);
187
 
188
+ // Cool rim light
189
+ const rim = new THREE.DirectionalLight(0x8090c0, 0.3);
190
+ rim.position.set(-3, 2, -2);
 
 
 
 
 
191
  this.scene.add(rim);
192
+
193
+ // Ambient
194
+ const ambient = new THREE.AmbientLight(0xffffff, 0.4);
195
+ this.scene.add(ambient);
196
+
197
+ // Mahogany table surface
198
+ const tableGeo = new THREE.BoxGeometry(10, 0.1, 3);
199
+ const tableMat = new THREE.MeshStandardMaterial({ color: 0x2c1810, roughness: 0.5, metalness: 0.05 });
200
+ const table = new THREE.Mesh(tableGeo, tableMat);
201
+ table.position.set(0, -0.8, 0);
202
+ table.receiveShadow = true;
203
+ this.scene.add(table);
204
+
205
+ // Table edge highlight (thin gold strip)
206
+ const edgeGeo = new THREE.BoxGeometry(10, 0.02, 0.04);
207
+ const edgeMat = new THREE.MeshStandardMaterial({ color: 0xc9a84c, roughness: 0.3, metalness: 0.5 });
208
+ const edge = new THREE.Mesh(edgeGeo, edgeMat);
209
+ edge.position.set(0, -0.745, 1.5);
210
+ this.scene.add(edge);
211
  }
212
 
213
+ _mat(color, roughness = 0.7, metalness = 0.05) {
214
  return new THREE.MeshStandardMaterial({ color, roughness, metalness });
215
  }
216
 
217
+ _badgeTex(symbol, bgColor, fgColor) {
218
  const size = 128;
219
+ const cvs = document.createElement("canvas");
220
+ cvs.width = size; cvs.height = size;
221
+ const ctx = cvs.getContext("2d");
 
222
 
223
  ctx.fillStyle = `#${bgColor.toString(16).padStart(6, "0")}`;
224
  ctx.beginPath();
 
226
  ctx.fill();
227
 
228
  ctx.fillStyle = `#${fgColor.toString(16).padStart(6, "0")}`;
229
+ ctx.font = "bold 58px serif";
230
  ctx.textAlign = "center";
231
  ctx.textBaseline = "middle";
232
+ ctx.fillText(symbol, size / 2, size / 2 + 3);
233
 
234
+ return new THREE.CanvasTexture(cvs);
 
235
  }
236
 
237
+ _clearCharacter() {
 
 
 
 
 
238
  if (this.characterGroup) {
239
+ this.scene && this.scene.remove(this.characterGroup);
240
  }
241
  this.characterGroup = null;
242
+ this.meshes = {};
243
  }
244
 
245
  _buildCharacter() {
246
+ this._clearCharacter();
247
+ if (!this.scene) return;
248
 
249
+ const def = PERSONA_DEFS[this.persona] || PERSONA_DEFS.shark;
250
  const group = new THREE.Group();
251
  this.characterGroup = group;
252
  this.scene.add(group);
253
 
254
+ const skin = this._mat(def.skinColor, 0.85, 0.0);
255
+ const suit = this._mat(def.suitColor, 0.75, 0.05);
256
+ const tie = this._mat(def.tieColor, 0.55, 0.05);
257
+ const hair = this._mat(def.hairColor, 0.85, 0.0);
258
+ const shirt= this._mat(0xf5efe0, 0.9, 0.0);
259
+ const lapel= this._mat(Math.max(0, def.suitColor - 0x050505), 0.8, 0.0);
260
+
261
+ // ── TORSO ──────────────────────────────────────────────
262
+ const torso = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.3, 0.6), suit);
263
+ torso.castShadow = true;
 
 
 
 
 
264
  group.add(torso);
265
  this.meshes.torso = torso;
266
 
267
+ // Shirt front
268
+ const shirtMesh = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.9, 0.32), shirt);
269
+ shirtMesh.position.set(0, 0.1, 0.32);
270
+ group.add(shirtMesh);
 
271
 
272
  // Tie
273
+ const tieMesh = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.74, 0.06), tie);
274
+ tieMesh.position.set(0, 0.1, 0.36);
275
+ group.add(tieMesh);
276
+ this.meshes.tie = tieMesh;
277
 
278
+ // Lapels
279
  const lapelGeo = new THREE.BoxGeometry(0.22, 0.7, 0.08);
280
+ const lapelL = new THREE.Mesh(lapelGeo, lapel);
281
+ lapelL.position.set(-0.22, 0.25, 0.3); lapelL.rotation.z = 0.2;
 
282
  group.add(lapelL);
283
+ const lapelR = new THREE.Mesh(lapelGeo, lapel);
284
+ lapelR.position.set(0.22, 0.25, 0.3); lapelR.rotation.z = -0.2;
 
 
285
  group.add(lapelR);
286
 
287
+ // Badge on left breast
288
+ const badgeTex = this._badgeTex(def.badge, def.badgeColor, 0xffffff);
289
+ const badge = new THREE.Mesh(
290
+ new THREE.BoxGeometry(0.2, 0.2, 0.04),
291
+ new THREE.MeshStandardMaterial({ map: badgeTex, roughness: 0.4 })
292
  );
293
+ badge.position.set(-0.27, 0.4, 0.32);
 
 
 
294
  group.add(badge);
295
 
296
+ // Analyst: slightly hunched torso
297
+ if (this.persona === "analyst") torso.rotation.x = 0.05;
298
+
299
+ // ── NECK ───────────────────────────────────────────────
300
+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.15, 0.3, 8), skin);
301
  neck.position.set(0, 0.8, 0);
302
  group.add(neck);
 
303
 
304
+ // ── HEAD ───────────────────────────────────────────────
305
+ const head = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.85, 0.65), skin);
306
+ head.scale.set(1, 1.05, 0.95);
307
  head.position.set(0, 1.45, 0);
308
+ head.castShadow = true;
309
  group.add(head);
310
  this.meshes.head = head;
311
 
312
+ // Hair top slab
313
+ const hairTop = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.26, 0.68), hair);
314
+ hairTop.position.set(0, 1.82, -0.02);
315
+ group.add(hairTop);
 
316
 
317
  // Hair sides
318
+ const hSideGeo = new THREE.BoxGeometry(0.12, 0.44, 0.6);
319
+ const hL = new THREE.Mesh(hSideGeo, hair);
320
+ hL.position.set(-0.37, 1.6, -0.02);
321
+ group.add(hL);
322
+ const hR = new THREE.Mesh(hSideGeo, hair);
323
+ hR.position.set(0.37, 1.6, -0.02);
324
+ group.add(hR);
325
+
326
+ // ── EYES ───────────────────────────────────────────────
327
+ const eyeW = new THREE.SphereGeometry(0.095, 12, 8);
328
+ const eyeP = new THREE.SphereGeometry(0.052, 8, 6);
329
+ const eyeWhite = this._mat(0xffffff, 0.95, 0.0);
330
+ const pupil = this._mat(0x111111, 0.9, 0.0);
331
 
332
  const eyeLW = new THREE.Mesh(eyeW, eyeWhite);
333
+ eyeLW.position.set(-0.19, 1.50, 0.33);
334
  group.add(eyeLW);
335
  this.meshes.eyeLeft = eyeLW;
336
 
337
  const eyeRW = new THREE.Mesh(eyeW, eyeWhite);
338
+ eyeRW.position.set(0.19, 1.50, 0.33);
339
  group.add(eyeRW);
340
  this.meshes.eyeRight = eyeRW;
341
 
342
+ const eyeLP = new THREE.Mesh(eyeP, pupil);
343
+ eyeLP.position.set(-0.19, 1.50, 0.38);
344
  group.add(eyeLP);
345
  this.meshes.pupilLeft = eyeLP;
346
 
347
+ const eyeRP = new THREE.Mesh(eyeP, pupil);
348
+ eyeRP.position.set(0.19, 1.50, 0.38);
349
  group.add(eyeRP);
350
  this.meshes.pupilRight = eyeRP;
351
 
352
+ // Eyebrows (Veteran gets thicker)
353
+ const browH = this.persona === "veteran" ? 0.055 : 0.04;
354
+ const browGeo = new THREE.BoxGeometry(0.18, browH, 0.04);
355
+ const browMat = this._mat(def.hairColor === 0xe0e0d8 ? 0x888878 : def.hairColor, 0.9, 0.0);
356
  const browL = new THREE.Mesh(browGeo, browMat);
357
  browL.position.set(-0.19, 1.63, 0.33);
358
  group.add(browL);
359
  this.meshes.browL = browL;
 
360
  const browR = new THREE.Mesh(browGeo, browMat);
361
  browR.position.set(0.19, 1.63, 0.33);
362
  group.add(browR);
363
  this.meshes.browR = browR;
364
 
365
+ // Mouth
366
+ const mouthMat = this._mat(0x8b3a3a, 0.8, 0.0);
367
+ const mouth = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.04, 0.04), mouthMat);
 
 
368
  mouth.position.set(0, 1.33, 0.33);
369
  group.add(mouth);
370
  this.meshes.mouth = mouth;
371
 
372
+ // Nose
373
+ const nose = new THREE.Mesh(
374
+ new THREE.BoxGeometry(0.09, 0.09, 0.12),
375
+ this._mat(def.skinColor * 0.95 | 0, 0.85, 0.0)
376
+ );
377
+ nose.position.set(0, 1.44, 0.36);
378
  group.add(nose);
379
 
380
+ // ── SHOULDERS / ARMS ───────────────────────────────────
381
+ const shlGeo = new THREE.BoxGeometry(0.32, 0.28, 0.45);
382
+ const shlL = new THREE.Mesh(shlGeo, suit); shlL.position.set(-0.7, 0.5, 0); group.add(shlL);
383
+ const shlR = new THREE.Mesh(shlGeo, suit); shlR.position.set(0.7, 0.5, 0); group.add(shlR);
 
 
 
 
384
 
 
385
  const uArmGeo = new THREE.BoxGeometry(0.28, 0.6, 0.3);
386
+ const uArmL = new THREE.Mesh(uArmGeo, suit);
387
+ uArmL.position.set(-0.72, 0.05, 0); group.add(uArmL);
 
388
  this.meshes.upperArmL = uArmL;
389
+ const uArmR = new THREE.Mesh(uArmGeo, suit);
390
+ uArmR.position.set(0.72, 0.05, 0); group.add(uArmR);
 
 
391
  this.meshes.upperArmR = uArmR;
392
 
 
393
  const lArmGeo = new THREE.BoxGeometry(0.24, 0.55, 0.26);
394
+ const lArmL = new THREE.Mesh(lArmGeo, suit); lArmL.position.set(-0.72, -0.5, 0); group.add(lArmL);
395
+ const lArmR = new THREE.Mesh(lArmGeo, suit); lArmR.position.set(0.72, -0.5, 0); group.add(lArmR);
 
 
 
 
 
396
 
397
+ // Hands
398
  const handGeo = new THREE.SphereGeometry(0.14, 8, 6);
399
+ const handL = new THREE.Mesh(handGeo, skin); handL.position.set(-0.72, -0.85, 0); group.add(handL);
400
+ const handR = new THREE.Mesh(handGeo, skin); handR.position.set(0.72, -0.85, 0); group.add(handR);
401
+
402
+ // ── LEGS ───────────────────────────────────────────────
403
+ const pelvis = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.3, 0.55), suit);
404
+ pelvis.position.set(0, -0.8, 0); group.add(pelvis);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
 
406
  const legGeo = new THREE.BoxGeometry(0.38, 0.5, 0.38);
407
+ const legL = new THREE.Mesh(legGeo, suit); legL.position.set(-0.3, -1.15, 0); group.add(legL);
408
+ const legR = new THREE.Mesh(legGeo, suit); legR.position.set(0.3, -1.15, 0); group.add(legR);
 
409
 
410
+ // ── PERSONA ACCESSORY ──────────────────────────────────
411
+ if (typeof def.buildAccessory === "function") {
412
+ this.meshes.accessory = def.buildAccessory(group, { suit, skin, hair, tie });
413
+ }
414
 
415
+ // Veteran: most upright β€” no lean even in aggressive
416
+ this.meshes._veteranPosture = this.persona === "veteran";
417
 
418
+ // Wildcard: random head tilt state
419
+ this._wildcardAngle = 0;
420
+ this._wildcardNext = 0;
421
+
422
+ group.position.set(0, 0, 0);
423
+ this._eyeBaseY = 1.50;
424
  }
425
 
426
+ // ── Public API ──────────────────────────────────────────────────────────
427
  setState(newState) {
428
  if (!(newState in CHARACTER_STATES)) return;
429
+
430
+ if (newState === "shocked") {
431
+ this._shockedUntil = performance.now() + 1500;
432
+ const stateBefore = this.state;
433
+ this._postShockState = stateBefore === "shocked" ? "idle" : stateBefore;
434
+ }
435
+
436
+ this.state = newState;
437
  this.targetState = CHARACTER_STATES[newState];
438
+ this._updateBadge(newState);
439
  }
440
 
441
  setPersona(persona) {
442
+ if (!(persona in PERSONA_DEFS)) return;
443
  this.persona = persona;
444
  this._buildCharacter();
445
  }
446
 
447
+ destroy() {
448
+ if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
449
+ this._clearCharacter();
450
+ if (this.renderer) this.renderer.dispose();
451
  }
452
 
453
+ // ── Private helpers ──────────────────────────────────────────────────────
454
+ _updateBadge(state) {
455
+ const b = document.querySelector(".character-state-badge");
456
+ if (b) b.textContent = state;
457
+ const chip = document.getElementById("character-state-label");
458
+ if (chip) chip.textContent = state;
459
  }
460
 
461
+ _lerp(a, b, t) { return a + (b - a) * t; }
462
+
463
  _animate() {
464
  this.animFrameId = requestAnimationFrame(() => this._animate());
 
465
  if (!this.scene || !this.camera || !this.renderer) return;
466
 
467
+ const now = performance.now();
468
+ const dt = 0.016; // ~60fps
469
  const target = this.targetState;
470
  const speed = target.animSpeed;
471
+
472
+ // Shocked state auto-revert after 1.5s
473
+ if (this.state === "shocked" && now > this._shockedUntil) {
474
+ this.setState(this._postShockState || "idle");
475
+ }
476
 
477
  this.clock += dt * speed;
478
 
479
+ if (!this.characterGroup) { this.renderer.render(this.scene, this.camera); return; }
 
 
 
 
480
 
481
+ const g = this.characterGroup;
 
 
 
 
482
 
483
+ // ── Breathing (y-oscillation 0.6Hz idle, 1.5Hz aggressive) ─────────────
484
+ const breathFreq = this.state === "aggressive" ? 1.5 : 0.6;
485
+ const breathAmp = this.state === "aggressive" ? 0.025 : 0.012;
486
+ g.position.y = Math.sin(this.clock * breathFreq) * breathAmp;
487
+
488
+ // ── Head side-to-side Β±2Β° at 0.25Hz ──────────────────────────────────
489
+ if (this.meshes.head) {
490
+ const headSwing = Math.sin(this.clock * 0.25) * (3.14159 / 90); // Β±2Β°
491
+ this.meshes.head.rotation.y = headSwing;
 
 
 
 
 
 
492
 
493
+ // Thinking: head tilts forward 10Β°
494
+ const forwardTilt = this.state === "thinking" ? -(10 * 3.14159 / 180) : 0;
495
+ this.meshes.head.rotation.x = this._lerp(this.meshes.head.rotation.x, forwardTilt, 0.05);
496
+
497
+ // Head tilt (z) lerp
498
+ this._curTilt = this._lerp(this._curTilt, target.headTilt, 0.05);
499
+ this.meshes.head.rotation.z = this._curTilt;
500
+
501
+ // Shocked: rapid head shake
502
+ if (this.state === "shocked") {
503
+ this.meshes.head.rotation.z = Math.sin(this.clock * 4) * (8 * 3.14159 / 180);
 
 
504
  }
505
+ }
506
+
507
+ // ── Body lean ─────────────────────────────────────────────────────────
508
+ const isVeteran = this.meshes._veteranPosture;
509
+ const targetLean = isVeteran ? 0 : target.bodyLean;
510
+ this._curLean = this._lerp(this._curLean, targetLean, 0.04);
511
+ g.rotation.x = this._curLean;
512
+
513
+ // ── Eye scale lerp ────────────────────────────────────────────────────
514
+ this._curEyeScale = this._lerp(this._curEyeScale, target.eyeScale, 0.06);
515
+ const es = this._curEyeScale;
516
+ if (this.meshes.eyeLeft) this.meshes.eyeLeft.scale.setScalar(es);
517
+ if (this.meshes.eyeRight) this.meshes.eyeRight.scale.setScalar(es);
518
+
519
+ // ── Pupils wander ─────────────────────────────────────────────────────
520
+ if (this.meshes.pupilLeft && this.meshes.pupilRight) {
521
+ const wx = Math.sin(this.clock * 0.7) * 0.025;
522
+ const wy = Math.cos(this.clock * 0.5) * 0.015;
523
+ this.meshes.pupilLeft.position.x = -0.19 + wx;
524
+ this.meshes.pupilRight.position.x = 0.19 + wx;
525
+ this.meshes.pupilLeft.position.y = this._eyeBaseY + wy;
526
+ this.meshes.pupilRight.position.y = this._eyeBaseY + wy;
527
+ }
528
+
529
+ // ── Eyebrows ──────────────────────────────────────────────────────────
530
+ if (this.meshes.browL && this.meshes.browR) {
531
+ const browDelta = this.state === "aggressive" ? -0.1
532
+ : this.state === "shocked" ? 0.12
533
+ : this.state === "pleased" ? 0.04
534
+ : 0;
535
+ const tY = 1.63 + browDelta;
536
+ this.meshes.browL.position.y = this._lerp(this.meshes.browL.position.y, tY, 0.07);
537
+ this.meshes.browR.position.y = this._lerp(this.meshes.browR.position.y, tY, 0.07);
538
+
539
+ const browAngle = this.state === "aggressive" ? 0.2 : 0;
540
+ this.meshes.browL.rotation.z = this._lerp(this.meshes.browL.rotation.z, browAngle, 0.07);
541
+ this.meshes.browR.rotation.z = this._lerp(this.meshes.browR.rotation.z, -browAngle, 0.07);
542
+ }
543
+
544
+ // ── Mouth morph ───────────────────────────────────────────────────────
545
+ if (this.meshes.mouth) {
546
+ this.meshes.mouth.scale.y = this._lerp(
547
+ this.meshes.mouth.scale.y,
548
+ 1 + target.mouthOpen * 2.2,
549
+ 0.07
550
+ );
551
+ this.meshes.mouth.position.y = this._lerp(
552
+ this.meshes.mouth.position.y,
553
+ 1.33 - target.mouthOpen * 0.04 + target.mouthCurve * 0.04,
554
+ 0.07
555
+ );
556
+ }
557
 
558
+ // ── Arm sway ──────────────────────────────────────────────────────────
559
+ if (this.meshes.upperArmL && this.meshes.upperArmR) {
560
+ const swayAmp = this.state === "aggressive" ? 0.07 : 0.015;
561
+ const swayFreq = this.state === "aggressive" ? 1.5 : 0.35;
562
+ const sway = Math.sin(this.clock * swayFreq) * swayAmp;
563
+ this.meshes.upperArmL.rotation.z = sway;
564
+ this.meshes.upperArmR.rotation.z = -sway;
565
+ }
566
+
567
+ // ── Wildcard: random head snap ─────────────────────────────────────────
568
+ if (this.persona === "wildcard" && this.state === "idle") {
569
+ if (Math.random() < 0.005) {
570
+ this._wildcardAngle = (Math.random() - 0.5) * 0.3;
571
+ }
572
+ if (this.meshes.head) {
573
+ this.meshes.head.rotation.z = this._lerp(
574
+ this.meshes.head.rotation.z,
575
+ this._wildcardAngle,
576
+ 0.08
577
  );
578
  }
579
+ }
580
 
581
+ this.renderer.render(this.scene, this.camera);
582
+ }
583
+ }
 
 
 
 
 
584
 
585
+ // ── Mini render for persona-picker cards (180px wide) ─────────────────────
586
+ class PersonaPreviewCharacter {
587
+ constructor(canvasEl, persona = "shark") {
588
+ this.persona = persona;
589
+ this.clock = 0;
590
+ this.animId = null;
591
+ this.scene = null; this.camera = null; this.renderer = null;
592
+ this.group = null;
593
+
594
+ this._setup(canvasEl);
595
+ this._build();
596
+ this._animate();
597
+ }
598
+
599
+ _setup(canvas) {
600
+ const w = canvas.width || 280;
601
+ const h = canvas.height || 200;
602
+
603
+ this.scene = new THREE.Scene();
604
+ this.scene.background = new THREE.Color(0x1c2b1a);
605
+
606
+ this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
607
+ this.camera.position.set(0, 1.4, 4.5);
608
+ this.camera.lookAt(0, 1.1, 0);
609
+
610
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
611
+ this.renderer.setSize(w, h, false);
612
+
613
+ const key = new THREE.DirectionalLight(0xffe8b0, 1.3);
614
+ key.position.set(2, 5, 3);
615
+ this.scene.add(key);
616
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.45));
617
+
618
+ // mini table
619
+ const t = new THREE.Mesh(
620
+ new THREE.BoxGeometry(8, 0.08, 2),
621
+ new THREE.MeshStandardMaterial({ color: 0x2c1810, roughness: 0.5 })
622
+ );
623
+ t.position.y = -0.8;
624
+ this.scene.add(t);
625
+ }
626
+
627
+ _build() {
628
+ if (!this.scene) return;
629
+ if (this.group) this.scene.remove(this.group);
630
+
631
+ const def = PERSONA_DEFS[this.persona] || PERSONA_DEFS.shark;
632
+ const g = new THREE.Group();
633
+ this.group = g;
634
+ this.scene.add(g);
635
+
636
+ const mat = (c, r=0.75) => new THREE.MeshStandardMaterial({ color: c, roughness: r, metalness: 0.04 });
637
+
638
+ const torso = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.3, 0.6), mat(def.suitColor));
639
+ torso.position.y = 0;
640
+ g.add(torso);
641
+
642
+ const head = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.85, 0.65), mat(def.skinColor, 0.85));
643
+ head.position.set(0, 1.45, 0);
644
+ g.add(head);
645
+ this._head = head;
646
+
647
+ // Hair
648
+ const hTop = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.26, 0.68), mat(def.hairColor, 0.85));
649
+ hTop.position.set(0, 1.82, -0.02);
650
+ g.add(hTop);
651
+
652
+ // Eyes
653
+ const ew = new THREE.SphereGeometry(0.09, 10, 7);
654
+ const ep = new THREE.SphereGeometry(0.05, 8, 6);
655
+ const eWM = mat(0xffffff, 0.95);
656
+ const ePM = mat(0x111111, 0.9);
657
+ [-0.19, 0.19].forEach(x => {
658
+ const ew_ = new THREE.Mesh(ew, eWM); ew_.position.set(x, 1.5, 0.33); g.add(ew_);
659
+ const ep_ = new THREE.Mesh(ep, ePM); ep_.position.set(x, 1.5, 0.38); g.add(ep_);
660
+ });
661
+ }
662
+
663
+ _animate() {
664
+ this.animId = requestAnimationFrame(() => this._animate());
665
+ if (!this.scene || !this.renderer || !this.camera) return;
666
+
667
+ this.clock += 0.016 * 0.5;
668
+ if (this.group) {
669
+ this.group.position.y = Math.sin(this.clock) * 0.01;
670
+ }
671
+ if (this._head) {
672
+ this._head.rotation.y = Math.sin(this.clock * 0.25) * 0.03;
673
  }
674
 
675
  this.renderer.render(this.scene, this.camera);
676
  }
677
 
678
  destroy() {
679
+ if (this.animId) cancelAnimationFrame(this.animId);
680
+ if (this.group) this.scene && this.scene.remove(this.group);
681
  if (this.renderer) this.renderer.dispose();
682
  }
683
  }
684
 
685
+ // ── Global exports ─────────────────────────────────────────────────────────
686
+ window.NegotiatorCharacter = NegotiatorCharacter;
687
+ window.PersonaPreviewCharacter = PersonaPreviewCharacter;
688
+ window.PERSONA_DEFS = PERSONA_DEFS;
dashboard/static/style.css CHANGED
@@ -1,1820 +1,1352 @@
1
  /* ============================================================
2
- Parlay β€” Production CSS
3
- Bloomberg terminal meets poker app.
4
- Robinhood Γ— Figma aesthetic.
5
  ============================================================ */
6
 
7
- /* ── Custom Properties ────────────────────────────────────── */
 
 
8
  :root {
9
- /* Ink (text) */
10
- --parlay-ink: #0f1117;
11
- --parlay-ink-2: #3d4151;
12
- --parlay-ink-3: #8a8f9e;
13
-
14
- /* Surfaces */
15
- --parlay-surface: #ffffff;
16
- --parlay-surface-2: #f4f5f7;
17
- --parlay-surface-3: #e8eaed;
18
-
19
- /* Borders */
20
- --parlay-border: #e0e2e7;
21
- --parlay-border-2: #c8cad0;
22
-
23
- /* Semantic colours */
24
- --parlay-green: #00a878;
25
- --parlay-green-bg: #e6f6f2;
26
- --parlay-red: #e03535;
27
- --parlay-red-bg: #fdf0f0;
28
- --parlay-amber: #d97706;
29
- --parlay-amber-bg: #fef9ec;
30
- --parlay-blue: #2563eb;
31
- --parlay-blue-bg: #eff4ff;
32
- --parlay-purple: #7c3aed;
33
- --parlay-purple-bg: #f3eeff;
34
-
35
- /* Layout */
36
- --col-left: 260px;
37
- --col-right: 300px;
38
- --header-h: 56px;
39
- --gap: 16px;
40
 
41
  /* Typography */
42
- --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
43
- --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
44
-
45
- /* Radii */
46
- --r-chip: 4px;
47
- --r-card: 8px;
48
- --r-panel: 12px;
49
-
50
- /* Spacing scale */
51
- --sp-1: 4px;
52
- --sp-2: 8px;
53
- --sp-3: 12px;
54
- --sp-4: 16px;
55
- --sp-6: 24px;
56
- --sp-8: 32px;
57
- --sp-12: 48px;
58
- --sp-16: 64px;
59
-
60
- /* Transitions */
61
- --t-fast: 120ms ease;
62
- --t-normal: 200ms ease;
63
- --t-slow: 350ms ease;
64
- }
65
 
66
- /* Dark theme */
67
- [data-theme="dark"] {
68
- --parlay-ink: #f0f1f5;
69
- --parlay-ink-2: #b0b4c1;
70
- --parlay-ink-3: #6b7080;
 
71
 
72
- --parlay-surface: #0d0f14;
73
- --parlay-surface-2: #161820;
74
- --parlay-surface-3: #1e2130;
75
-
76
- --parlay-border: #2a2d3a;
77
- --parlay-border-2: #3a3d4d;
78
-
79
- --parlay-green: #00d49a;
80
- --parlay-green-bg: #0a2420;
81
- --parlay-red: #f05454;
82
- --parlay-red-bg: #2a1010;
83
- --parlay-amber: #f59e0b;
84
- --parlay-amber-bg: #2a1f08;
85
- --parlay-blue: #3b82f6;
86
- --parlay-blue-bg: #0f1e40;
87
- --parlay-purple: #a855f7;
88
- --parlay-purple-bg: #1c0f30;
89
  }
90
 
91
- /* ── Reset / Base ─────────────────────────────────────────── */
92
- *, *::before, *::after {
93
- box-sizing: border-box;
94
- margin: 0;
95
- padding: 0;
96
- }
97
 
98
  html {
99
- font-size: 14px;
100
- -webkit-text-size-adjust: 100%;
101
- scroll-behavior: smooth;
102
  }
103
 
104
  body {
 
 
 
105
  font-family: var(--font-body);
106
- font-size: 0.9375rem;
107
- line-height: 1.5;
108
- color: var(--parlay-ink);
109
- background: var(--parlay-surface-2);
110
- min-height: 100vh;
111
  overflow-x: hidden;
112
  }
113
 
114
- img, svg { display: block; max-width: 100%; }
115
- button, input, select, textarea { font: inherit; }
116
- a { color: var(--parlay-blue); text-decoration: none; }
117
- a:hover { text-decoration: underline; }
118
- ul { list-style: none; }
119
 
120
- /* Focus ring β€” single rule, nowhere else */
121
- :focus-visible {
122
- outline: none;
123
- box-shadow: 0 0 0 2px var(--parlay-blue);
124
- border-radius: var(--r-chip);
125
- }
126
 
127
- /* Scrollbar */
128
- ::-webkit-scrollbar { width: 6px; height: 6px; }
129
- ::-webkit-scrollbar-track { background: var(--parlay-surface-2); }
130
- ::-webkit-scrollbar-thumb { background: var(--parlay-border-2); border-radius: 3px; }
131
- ::-webkit-scrollbar-thumb:hover { background: var(--parlay-ink-3); }
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- /* Monospace numbers everywhere they appear */
134
- .mono, .offer-amount, .stat-value, .cp-value,
135
- td.num, .leaderboard td:not(:first-child),
136
- .zopa-value, .batna-label, .nash-label,
137
- .sparkline-label {
 
 
 
138
  font-family: var(--font-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
 
140
 
141
- /* ── App Shell ────────────────────────────────────────────── */
 
 
 
142
  .app-header {
143
- position: sticky;
144
- top: 0;
145
- z-index: 100;
146
- height: var(--header-h);
147
  display: flex;
148
  align-items: center;
149
  justify-content: space-between;
150
- padding: 0 var(--sp-6);
151
- background: var(--parlay-surface);
152
- border-bottom: 1px solid var(--parlay-border);
153
- gap: var(--sp-4);
 
 
 
154
  }
155
 
156
  .header-brand {
157
  display: flex;
158
  align-items: center;
159
- gap: var(--sp-2);
160
- font-size: 1.125rem;
 
161
  font-weight: 700;
162
- letter-spacing: -0.02em;
163
- color: var(--parlay-ink);
 
164
  }
165
 
166
- .header-brand .brand-dot {
167
- width: 8px;
168
- height: 8px;
169
  border-radius: 50%;
170
- background: var(--parlay-green);
171
- box-shadow: 0 0 6px var(--parlay-green);
172
- animation: pulse-dot 2s ease-in-out infinite;
173
- }
174
-
175
- @keyframes pulse-dot {
176
- 0%, 100% { opacity: 1; transform: scale(1); }
177
- 50% { opacity: 0.6; transform: scale(0.85); }
178
  }
179
 
180
- .header-actions {
181
  display: flex;
182
- align-items: center;
183
- gap: var(--sp-3);
184
  }
185
 
186
  .header-nav a {
187
- font-size: 0.8125rem;
188
- font-weight: 500;
189
- color: var(--parlay-ink-2);
190
- padding: var(--sp-1) var(--sp-2);
191
- border-radius: var(--r-chip);
192
- transition: color var(--t-fast), background var(--t-fast);
 
 
193
  }
194
-
195
- .header-nav a:hover, .header-nav a.active {
196
- color: var(--parlay-ink);
197
- background: var(--parlay-surface-2);
198
- text-decoration: none;
199
  }
200
 
201
- /* Dark mode toggle */
202
- .dark-toggle {
203
- width: 40px;
204
- height: 22px;
205
- background: var(--parlay-surface-3);
206
- border: 1px solid var(--parlay-border);
207
- border-radius: 11px;
208
- position: relative;
209
- cursor: pointer;
210
- transition: background var(--t-normal);
211
- flex-shrink: 0;
212
  }
213
 
214
- .dark-toggle::after {
215
- content: "";
216
- position: absolute;
217
- top: 2px;
218
- left: 2px;
219
- width: 16px;
220
- height: 16px;
221
  border-radius: 50%;
222
- background: var(--parlay-ink-2);
223
- transition: transform var(--t-normal), background var(--t-normal);
224
- }
225
-
226
- [data-theme="dark"] .dark-toggle {
227
- background: var(--parlay-blue);
228
- border-color: var(--parlay-blue);
229
- }
230
-
231
- [data-theme="dark"] .dark-toggle::after {
232
- transform: translateX(18px);
233
- background: var(--parlay-surface);
234
  }
 
 
235
 
236
- /* ── 3-Column Layout ──────────────────────────────────────── */
237
  .app-body {
238
  display: grid;
239
- grid-template-columns: var(--col-left) 1fr var(--col-right);
240
- grid-template-rows: 1fr;
241
- gap: var(--gap);
242
- padding: var(--gap);
243
- min-height: calc(100vh - var(--header-h));
244
- align-items: start;
245
  }
246
 
247
- .col-left, .col-center, .col-right {
248
- display: flex;
249
- flex-direction: column;
250
- gap: var(--sp-3);
251
- min-width: 0;
252
- }
253
 
254
- /* ── Panels (base card) ───────────────────────────────────── */
255
  .panel {
256
- background: var(--parlay-surface);
257
- border: 1px solid var(--parlay-border);
258
- border-radius: var(--r-panel);
259
- padding: var(--sp-4);
260
- overflow: hidden;
261
  }
262
 
263
  .panel-header {
264
  display: flex;
265
  align-items: center;
266
  justify-content: space-between;
267
- margin-bottom: var(--sp-3);
 
 
268
  }
269
 
270
  .panel-title {
271
- font-size: 0.75rem;
272
- font-weight: 600;
273
- letter-spacing: 0.06em;
274
- text-transform: uppercase;
275
- color: var(--parlay-ink-3);
276
  }
277
 
278
- /* ── Player Card ──────────────────────────────────────────── */
279
- .player-card {
280
- background: var(--parlay-surface);
281
- border: 1px solid var(--parlay-border);
282
- border-radius: var(--r-panel);
283
- padding: var(--sp-4);
284
- }
285
 
286
  .player-card-header {
287
  display: flex;
288
  align-items: center;
289
- gap: var(--sp-3);
290
- margin-bottom: var(--sp-4);
291
  }
292
 
293
  .player-avatar {
294
- width: 40px;
295
- height: 40px;
296
  border-radius: 50%;
297
- background: var(--parlay-blue-bg);
298
- border: 2px solid var(--parlay-blue);
299
  display: flex;
300
  align-items: center;
301
  justify-content: center;
302
- font-size: 1.125rem;
303
- font-weight: 700;
304
- color: var(--parlay-blue);
305
- flex-shrink: 0;
306
- }
307
-
308
- .player-info { min-width: 0; }
309
-
310
- .player-name {
311
- font-size: 0.9375rem;
312
  font-weight: 600;
313
- color: var(--parlay-ink);
314
- white-space: nowrap;
315
- overflow: hidden;
316
- text-overflow: ellipsis;
317
  }
318
 
319
- .player-rank {
320
- font-size: 0.75rem;
321
- color: var(--parlay-ink-3);
322
- font-family: var(--font-mono);
323
- }
324
 
325
  /* CP Bar */
326
- .cp-section { margin-top: var(--sp-2); }
327
-
328
- .cp-label-row {
329
- display: flex;
330
- justify-content: space-between;
331
- align-items: center;
332
- margin-bottom: var(--sp-1);
333
- }
334
-
335
- .cp-label {
336
- font-size: 0.75rem;
337
- font-weight: 600;
338
- letter-spacing: 0.05em;
339
- text-transform: uppercase;
340
- color: var(--parlay-ink-3);
341
- }
342
-
343
- .cp-value {
344
- font-size: 0.8125rem;
345
- font-weight: 700;
346
- color: var(--parlay-blue);
347
- }
348
 
349
  .cp-track {
350
  height: 6px;
351
- background: var(--parlay-surface-3);
352
  border-radius: 3px;
 
 
353
  overflow: hidden;
354
- position: relative;
355
  }
356
-
357
  .cp-fill {
358
  height: 100%;
359
- background: linear-gradient(90deg, var(--parlay-blue), var(--parlay-purple));
360
  border-radius: 3px;
361
- transition: width 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
362
- position: relative;
363
  }
364
 
365
- .cp-fill::after {
366
- content: "";
367
- position: absolute;
368
- inset: 0;
369
- background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.25) 50%, transparent 100%);
370
- animation: cp-shimmer 2s linear infinite;
371
  }
372
 
373
- @keyframes cp-shimmer {
374
- 0% { transform: translateX(-100%); }
375
- 100% { transform: translateX(100%); }
 
 
 
376
  }
377
 
378
- /* ── Tactical Cards (the hand) ────────────────────────────── */
379
- .hand-container {
380
- display: flex;
381
- flex-direction: column;
382
- gap: var(--sp-2);
383
- }
384
 
 
385
  .tactical-card {
386
- position: relative;
387
- height: 64px;
388
- cursor: pointer;
389
  perspective: 600px;
 
 
390
  }
391
 
392
  .card-inner {
393
  position: relative;
394
- width: 100%;
395
- height: 100%;
396
  transform-style: preserve-3d;
397
- transition: transform var(--t-slow);
398
  }
399
 
400
- .tactical-card:hover .card-inner,
401
- .tactical-card.flipped .card-inner {
402
- transform: rotateY(180deg);
403
- }
404
 
405
  .card-face, .card-back {
406
  position: absolute;
407
  inset: 0;
408
- border-radius: var(--r-card);
409
- padding: var(--sp-2) var(--sp-3);
 
 
410
  backface-visibility: hidden;
411
- -webkit-backface-visibility: hidden;
412
  display: flex;
413
  flex-direction: column;
414
- justify-content: center;
415
- }
416
-
417
- .card-face {
418
- background: var(--parlay-surface);
419
- border: 1px solid var(--parlay-border);
420
- transition: border-color var(--t-fast), background var(--t-fast);
421
- }
422
-
423
- .tactical-card:hover .card-face,
424
- .tactical-card.selected .card-face {
425
- border-color: var(--parlay-blue);
426
- background: var(--parlay-blue-bg);
427
  }
428
 
429
- .tactical-card.selected .card-face {
430
- border-width: 2px;
431
- }
432
-
433
- .card-back {
434
- background: var(--parlay-surface-3);
435
- border: 1px solid var(--parlay-border);
436
- transform: rotateY(180deg);
437
- font-size: 0.6875rem;
438
- color: var(--parlay-ink-2);
439
- overflow: hidden;
440
- }
441
 
442
  .card-name {
443
- font-size: 0.8125rem;
 
444
  font-weight: 600;
445
- color: var(--parlay-ink);
 
446
  }
447
-
448
  .card-type {
449
- font-size: 0.6875rem;
450
- color: var(--parlay-ink-3);
451
  font-family: var(--font-mono);
 
 
 
 
 
452
  }
453
-
454
  .card-cost {
455
- position: absolute;
456
- top: var(--sp-2);
457
- right: var(--sp-2);
458
- width: 22px;
459
- height: 22px;
460
- border-radius: 50%;
461
- background: var(--parlay-blue);
462
- color: var(--parlay-surface);
463
- font-size: 0.6875rem;
464
- font-weight: 700;
465
- display: flex;
466
- align-items: center;
467
- justify-content: center;
468
  font-family: var(--font-mono);
 
 
 
 
469
  }
470
 
471
  .card-back-label {
472
- font-size: 0.625rem;
473
- font-weight: 600;
474
- letter-spacing: 0.05em;
475
- text-transform: uppercase;
476
- color: var(--parlay-ink-3);
477
- margin-bottom: var(--sp-1);
478
  }
479
-
480
  .card-game-theory {
481
- font-size: 0.6875rem;
 
482
  line-height: 1.4;
483
- color: var(--parlay-ink-2);
484
  }
485
 
486
- /* ── Achievement Badges ───────────────────────────────────── */
487
  .achievements-strip {
488
- display: flex;
489
- flex-wrap: wrap;
490
- gap: var(--sp-2);
491
  }
492
 
493
  .badge {
494
- display: flex;
495
- align-items: center;
496
- gap: var(--sp-1);
497
- padding: var(--sp-1) var(--sp-2);
498
- border-radius: var(--r-chip);
499
- background: var(--parlay-surface-2);
500
- border: 1px solid var(--parlay-border);
501
- font-size: 0.6875rem;
502
- font-weight: 500;
503
- color: var(--parlay-ink-3);
504
- transition: all var(--t-normal);
505
  cursor: default;
 
 
506
  }
507
 
508
  .badge.earned {
509
- background: var(--parlay-amber-bg);
510
- border-color: var(--parlay-amber);
511
- color: var(--parlay-amber);
512
- }
513
-
514
- .badge.earned:hover {
515
- background: var(--parlay-amber);
516
- color: var(--parlay-surface);
517
- }
518
-
519
- .badge-icon {
520
- font-size: 0.875rem;
521
- line-height: 1;
522
  }
523
 
524
- /* ── Act Pills ────────────────────────────────────────────── */
525
- .act-pills {
526
- display: flex;
527
- align-items: center;
528
- gap: var(--sp-2);
529
- }
530
-
531
- .act-pill {
532
- padding: var(--sp-1) var(--sp-3);
533
- border-radius: 99px;
534
- font-size: 0.6875rem;
535
- font-weight: 700;
536
- letter-spacing: 0.06em;
537
- text-transform: uppercase;
538
- background: var(--parlay-surface-3);
539
- color: var(--parlay-ink-3);
540
- border: 1px solid var(--parlay-border);
541
- transition: all var(--t-normal);
542
- }
543
-
544
- .act-pill.active {
545
- background: var(--parlay-ink);
546
- color: var(--parlay-surface);
547
- border-color: var(--parlay-ink);
548
- }
549
 
550
- .act-pill.completed {
551
- background: var(--parlay-green-bg);
552
- color: var(--parlay-green);
553
- border-color: var(--parlay-green);
554
- }
555
-
556
- /* ── Scenario Header ──────────────────────────────────────── */
557
  .scenario-header {
558
- background: var(--parlay-surface);
559
- border: 1px solid var(--parlay-border);
560
- border-radius: var(--r-panel);
561
- padding: var(--sp-4);
562
  display: flex;
563
- align-items: center;
564
  justify-content: space-between;
565
- gap: var(--sp-4);
 
 
 
 
566
  }
567
 
568
  .scenario-title {
569
- font-size: 1rem;
570
- font-weight: 700;
571
- color: var(--parlay-ink);
 
572
  }
573
 
574
  .scenario-meta {
575
- font-size: 0.75rem;
576
- color: var(--parlay-ink-3);
577
  margin-top: 2px;
578
  }
579
 
580
- /* ── Drift Alert Bar ──────────────────────────────────────── */
581
- .drift-alert {
582
  display: flex;
583
- align-items: center;
584
- justify-content: space-between;
585
- padding: var(--sp-2) var(--sp-4);
586
- background: var(--parlay-amber-bg);
587
- border: 1px solid var(--parlay-amber);
588
- border-radius: var(--r-card);
589
- gap: var(--sp-3);
590
- animation: slide-down 200ms ease;
591
- }
592
-
593
- .drift-alert.hidden { display: none; }
594
-
595
- @keyframes slide-down {
596
- from { opacity: 0; transform: translateY(-8px); }
597
- to { opacity: 1; transform: translateY(0); }
598
- }
599
-
600
- .drift-alert-icon {
601
- font-size: 1rem;
602
- flex-shrink: 0;
603
  }
604
 
605
- .drift-alert-text {
606
- flex: 1;
607
- font-size: 0.8125rem;
608
- font-weight: 500;
609
- color: var(--parlay-amber);
 
 
 
 
610
  }
 
 
611
 
612
- .drift-dismiss {
613
- background: none;
614
- border: none;
615
- cursor: pointer;
616
- color: var(--parlay-amber);
617
- font-size: 1rem;
618
- line-height: 1;
619
- padding: var(--sp-1);
620
- border-radius: var(--r-chip);
621
- flex-shrink: 0;
622
- transition: background var(--t-fast);
623
  }
624
 
625
- .drift-dismiss:hover { background: rgba(0,0,0,0.08); }
 
 
 
626
 
627
- /* ── Chat Thread ──────────────────────────────────────────── */
628
  .chat-thread {
 
 
 
 
 
 
 
629
  display: flex;
630
  flex-direction: column;
631
- gap: var(--sp-4);
632
- min-height: 320px;
633
- max-height: 480px;
634
- overflow-y: auto;
635
- padding: var(--sp-4);
636
- background: var(--parlay-surface);
637
- border: 1px solid var(--parlay-border);
638
- border-radius: var(--r-panel);
639
  scroll-behavior: smooth;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  }
641
 
 
642
  .message-bubble {
643
- display: flex;
644
- flex-direction: column;
645
- gap: var(--sp-1);
646
  max-width: 82%;
 
 
 
 
 
 
 
 
 
 
 
647
  }
648
 
649
- .message-bubble.player { align-self: flex-end; align-items: flex-end; }
650
- .message-bubble.opponent { align-self: flex-start; align-items: flex-start; }
 
 
 
 
 
 
 
 
 
 
 
651
 
652
  .bubble-meta {
653
- font-size: 0.6875rem;
654
- color: var(--parlay-ink-3);
655
  display: flex;
656
  align-items: center;
657
- gap: var(--sp-1);
 
 
 
 
658
  }
659
 
660
  .bubble-body {
661
- padding: var(--sp-2) var(--sp-3);
662
- border-radius: var(--r-card);
663
- font-size: 0.875rem;
664
- line-height: 1.55;
665
- }
666
-
667
- .message-bubble.player .bubble-body {
668
- background: var(--parlay-blue);
669
- color: #fff;
670
- border-bottom-right-radius: var(--r-chip);
671
  }
672
 
673
- .message-bubble.opponent .bubble-body {
674
- background: var(--parlay-surface-2);
675
- color: var(--parlay-ink);
676
- border: 1px solid var(--parlay-border);
677
- border-bottom-left-radius: var(--r-chip);
 
 
 
 
 
678
  }
679
 
680
- /* Offer chip within message */
681
  .offer-chip {
682
  display: inline-flex;
683
  align-items: center;
684
- gap: var(--sp-1);
685
- padding: 2px var(--sp-2);
686
- border-radius: var(--r-chip);
 
 
 
 
687
  font-family: var(--font-mono);
688
- font-size: 0.8125rem;
689
- font-weight: 700;
690
- margin: var(--sp-1) 0 0;
 
 
 
 
 
 
 
691
  }
692
 
693
- .message-bubble.player .offer-chip {
694
- background: rgba(255,255,255,0.25);
695
- color: #fff;
 
 
 
696
  }
697
 
698
- .message-bubble.opponent .offer-chip {
699
- background: var(--parlay-green-bg);
700
- color: var(--parlay-green);
701
- border: 1px solid var(--parlay-green);
 
702
  }
 
 
703
 
704
- .move-pill {
705
- display: inline-flex;
706
- align-items: center;
707
- padding: 1px var(--sp-2);
708
- border-radius: var(--r-chip);
709
- font-size: 0.625rem;
710
- font-weight: 600;
711
- letter-spacing: 0.05em;
712
- text-transform: uppercase;
713
- margin-left: var(--sp-1);
 
 
 
 
 
 
 
714
  }
715
 
716
- .move-pill.anchor { background: var(--parlay-red-bg); color: var(--parlay-red); }
717
- .move-pill.concede { background: var(--parlay-amber-bg); color: var(--parlay-amber); }
718
- .move-pill.package { background: var(--parlay-blue-bg); color: var(--parlay-blue); }
719
- .move-pill.walk { background: var(--parlay-purple-bg); color: var(--parlay-purple); }
720
- .move-pill.accept { background: var(--parlay-green-bg); color: var(--parlay-green); }
721
 
722
- /* System messages */
723
- .message-system {
724
- align-self: center;
725
- font-size: 0.75rem;
726
- color: var(--parlay-ink-3);
727
- background: var(--parlay-surface-2);
728
- border: 1px solid var(--parlay-border);
729
- padding: var(--sp-1) var(--sp-3);
730
- border-radius: 99px;
731
- text-align: center;
732
- }
733
-
734
- /* ── Input Area ───────────────────────────────────────────── */
735
- .input-area {
736
- background: var(--parlay-surface);
737
- border: 1px solid var(--parlay-border);
738
- border-radius: var(--r-panel);
739
- padding: var(--sp-4);
740
- display: flex;
741
- flex-direction: column;
742
- gap: var(--sp-3);
743
  }
744
 
745
  .input-row {
746
  display: flex;
747
- gap: var(--sp-2);
748
- align-items: center;
749
  }
750
 
751
  .offer-input {
752
  flex: 1;
753
- height: 40px;
754
- padding: 0 var(--sp-3);
755
- border: 1px solid var(--parlay-border);
756
- border-radius: var(--r-card);
757
- background: var(--parlay-surface-2);
758
- color: var(--parlay-ink);
759
  font-family: var(--font-mono);
760
- font-size: 0.9375rem;
761
- font-weight: 600;
762
- transition: border-color var(--t-fast), background var(--t-fast);
763
- }
764
-
765
- .offer-input:focus {
766
  outline: none;
767
- border-color: var(--parlay-blue);
768
- background: var(--parlay-surface);
769
- box-shadow: 0 0 0 2px var(--parlay-blue);
770
  }
 
 
771
 
772
  .move-select {
773
- height: 40px;
774
- padding: 0 var(--sp-3);
775
- padding-right: var(--sp-6);
776
- border: 1px solid var(--parlay-border);
777
- border-radius: var(--r-card);
778
- background: var(--parlay-surface-2);
779
- color: var(--parlay-ink);
780
- font-size: 0.875rem;
781
  cursor: pointer;
782
- appearance: none;
783
- -webkit-appearance: none;
784
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238a8f9e'/%3E%3C/svg%3E");
785
- background-repeat: no-repeat;
786
- background-position: right 10px center;
787
- transition: border-color var(--t-fast);
788
  }
 
789
 
790
- .move-select:focus {
791
- outline: none;
792
- border-color: var(--parlay-blue);
793
- box-shadow: 0 0 0 2px var(--parlay-blue);
794
- }
795
 
 
796
  .btn {
797
  display: inline-flex;
798
  align-items: center;
799
  justify-content: center;
800
- gap: var(--sp-2);
801
- height: 40px;
802
- padding: 0 var(--sp-4);
803
- border-radius: var(--r-card);
 
804
  font-size: 0.875rem;
805
  font-weight: 600;
806
- border: 1px solid transparent;
807
  cursor: pointer;
808
- transition: all var(--t-fast);
809
  white-space: nowrap;
810
- flex-shrink: 0;
811
- }
812
-
813
- .btn:disabled {
814
- opacity: 0.45;
815
- cursor: not-allowed;
816
  }
 
817
 
818
  .btn-primary {
819
- background: var(--parlay-blue);
820
- color: #fff;
821
- border-color: var(--parlay-blue);
822
  }
823
-
824
- .btn-primary:not(:disabled):hover {
825
- filter: brightness(1.1);
826
  }
827
 
828
  .btn-success {
829
- background: var(--parlay-green);
830
- color: #fff;
831
- border-color: var(--parlay-green);
832
- }
833
-
834
- .btn-success:not(:disabled):hover {
835
- filter: brightness(1.1);
836
  }
 
837
 
838
  .btn-danger {
839
- background: var(--parlay-red-bg);
840
- color: var(--parlay-red);
841
- border-color: var(--parlay-red);
842
- }
843
-
844
- .btn-danger:not(:disabled):hover {
845
- background: var(--parlay-red);
846
- color: #fff;
847
  }
 
848
 
849
  .btn-ghost {
850
  background: transparent;
851
- color: var(--parlay-ink-2);
852
- border-color: var(--parlay-border);
853
  }
 
854
 
855
- .btn-ghost:not(:disabled):hover {
856
- background: var(--parlay-surface-2);
857
- border-color: var(--parlay-border-2);
858
- }
859
 
860
- .btn-sm {
861
- height: 32px;
862
- padding: 0 var(--sp-3);
863
- font-size: 0.8125rem;
864
- }
865
-
866
- .action-buttons {
867
- display: flex;
868
- gap: var(--sp-2);
869
- }
870
-
871
- /* ── ZOPA Bar ─────────────────────────────────────────────── */
872
  .zopa-section {
873
- background: var(--parlay-surface);
874
- border: 1px solid var(--parlay-border);
875
- border-radius: var(--r-panel);
876
- padding: var(--sp-4);
877
  }
878
 
879
  .zopa-track-outer {
880
  position: relative;
881
  height: 32px;
882
- background: var(--parlay-surface-2);
883
- border-radius: var(--r-chip);
884
- border: 1px solid var(--parlay-border);
885
- margin: var(--sp-3) 0;
886
  overflow: visible;
 
 
 
 
 
 
 
 
 
887
  }
888
 
889
  .zopa-zone {
890
  position: absolute;
891
- top: 0;
892
- height: 100%;
893
- background: var(--parlay-green-bg);
894
- border: 1px solid var(--parlay-green);
895
- border-radius: var(--r-chip);
896
- opacity: 0.6;
897
- transition: left var(--t-normal), width var(--t-normal);
898
  }
899
 
900
  .zopa-marker {
901
  position: absolute;
902
- top: 50%;
903
- transform: translate(-50%, -50%);
904
  display: flex;
905
  flex-direction: column;
906
  align-items: center;
907
- gap: 2px;
908
  pointer-events: none;
909
  }
910
 
911
  .zopa-marker-line {
912
  width: 2px;
913
- height: 28px;
914
- border-radius: 1px;
915
  }
916
 
917
- .marker-player .zopa-marker-line { background: var(--parlay-blue); }
918
- .marker-opponent .zopa-marker-line { background: var(--parlay-red); }
919
- .marker-current .zopa-marker-line { background: var(--parlay-ink); }
920
 
921
  .zopa-label {
922
- font-size: 0.5rem;
923
- font-weight: 700;
924
- letter-spacing: 0.05em;
925
- text-transform: uppercase;
926
  white-space: nowrap;
927
  position: absolute;
928
  bottom: -16px;
 
929
  }
930
 
931
- .marker-player .zopa-label { color: var(--parlay-blue); }
932
- .marker-opponent .zopa-label { color: var(--parlay-red); }
933
- .marker-current .zopa-label { color: var(--parlay-ink); }
934
-
935
- /* Nash diamond */
936
  .nash-diamond {
937
  position: absolute;
938
- top: 50%;
939
- transform: translate(-50%, -50%) rotate(45deg);
940
- width: 10px;
941
- height: 10px;
942
- background: var(--parlay-amber);
943
- border: 1px solid var(--parlay-surface);
944
- transition: left var(--t-normal);
945
- z-index: 2;
946
  }
947
 
948
  .zopa-labels-row {
949
  display: flex;
950
  justify-content: space-between;
951
- font-size: 0.6875rem;
 
952
  font-family: var(--font-mono);
953
- color: var(--parlay-ink-3);
954
- margin-top: var(--sp-3);
955
  }
956
 
957
- /* ── Tension Meter ────────────────────────────────────────── */
958
  .tension-section {
959
  display: flex;
960
  align-items: center;
961
- gap: var(--sp-3);
962
- padding: var(--sp-3) var(--sp-4);
963
- background: var(--parlay-surface);
964
- border: 1px solid var(--parlay-border);
965
- border-radius: var(--r-panel);
966
  }
967
 
968
  .tension-label {
969
- font-size: 0.6875rem;
970
- font-weight: 600;
971
- text-transform: uppercase;
972
- letter-spacing: 0.05em;
973
- color: var(--parlay-ink-3);
974
  white-space: nowrap;
 
975
  }
976
 
977
  .tension-track {
978
  flex: 1;
979
- height: 4px;
980
- background: var(--parlay-surface-3);
981
- border-radius: 2px;
 
982
  overflow: hidden;
983
- position: relative;
984
  }
985
 
986
  .tension-fill {
987
  height: 100%;
988
- border-radius: 2px;
989
- transition: width 500ms ease, background 500ms ease;
 
990
  }
 
 
991
 
992
  .tension-value {
993
- font-size: 0.75rem;
994
  font-family: var(--font-mono);
995
- font-weight: 600;
996
- width: 32px;
 
997
  text-align: right;
998
- color: var(--parlay-ink-2);
999
- transition: color 500ms ease;
1000
  }
1001
 
1002
- /* tension colour shifts */
1003
- .tension-fill[data-level="low"] { background: var(--parlay-green); }
1004
- .tension-fill[data-level="medium"] { background: var(--parlay-amber); }
1005
- .tension-fill[data-level="high"] { background: var(--parlay-red); }
1006
-
1007
- /* ── Three.js Canvas Container ────────────────────────────── */
1008
  .character-canvas-wrap {
1009
- width: 280px;
1010
- height: 380px;
1011
- border-radius: var(--r-panel);
1012
- overflow: hidden;
1013
- background: transparent;
1014
  position: relative;
1015
- align-self: center;
1016
- margin: 0 auto;
1017
  }
1018
 
1019
  #character-canvas {
1020
  display: block;
1021
- width: 280px !important;
1022
- height: 380px !important;
1023
  }
1024
 
1025
  .character-state-badge {
1026
  position: absolute;
1027
- bottom: var(--sp-2);
1028
- left: 50%;
1029
- transform: translateX(-50%);
1030
- padding: 2px var(--sp-2);
1031
- border-radius: var(--r-chip);
1032
- font-size: 0.625rem;
1033
- font-weight: 700;
1034
- letter-spacing: 0.06em;
 
1035
  text-transform: uppercase;
1036
- background: rgba(0,0,0,0.55);
1037
- color: #fff;
1038
- pointer-events: none;
1039
- white-space: nowrap;
1040
  }
1041
 
1042
- /* ── Persona Info ─────────────────────────────────────────── */
1043
  .persona-info {
1044
  display: flex;
1045
  align-items: center;
1046
- gap: var(--sp-3);
1047
- padding: var(--sp-3) var(--sp-4);
1048
- border-radius: var(--r-panel);
1049
- border: 1px solid var(--parlay-border);
1050
- background: var(--parlay-surface);
1051
  }
1052
 
1053
  .persona-avatar {
1054
- width: 32px;
1055
- height: 32px;
1056
- border-radius: var(--r-chip);
1057
- display: flex;
1058
- align-items: center;
1059
- justify-content: center;
1060
- font-size: 1rem;
1061
  flex-shrink: 0;
1062
  }
1063
 
1064
- .persona-avatar.shark { background: var(--parlay-red-bg); }
1065
- .persona-avatar.diplomat { background: var(--parlay-green-bg); }
1066
- .persona-avatar.analyst { background: var(--parlay-blue-bg); }
1067
- .persona-avatar.wildcard { background: var(--parlay-amber-bg); }
1068
- .persona-avatar.veteran { background: var(--parlay-purple-bg); }
1069
 
1070
- .persona-name {
1071
- font-size: 0.875rem;
1072
- font-weight: 600;
1073
- color: var(--parlay-ink);
1074
- }
1075
-
1076
- .persona-desc {
1077
- font-size: 0.75rem;
1078
- color: var(--parlay-ink-3);
1079
- }
1080
-
1081
- /* ── ToM Belief Bars ──────────────────────────────────────── */
1082
- .tom-beliefs {
1083
- display: flex;
1084
- flex-direction: column;
1085
- gap: var(--sp-2);
1086
- }
1087
 
1088
  .belief-row {
1089
- display: flex;
 
1090
  align-items: center;
1091
- gap: var(--sp-2);
1092
  }
1093
 
1094
- .belief-label {
1095
- font-size: 0.75rem;
1096
- color: var(--parlay-ink-2);
1097
- width: 80px;
1098
- flex-shrink: 0;
1099
- }
1100
 
1101
  .belief-track {
1102
- flex: 1;
1103
- height: 6px;
1104
- background: var(--parlay-surface-3);
1105
- border-radius: 3px;
1106
  overflow: hidden;
1107
  }
1108
 
1109
  .belief-fill {
1110
  height: 100%;
1111
- border-radius: 3px;
1112
- transition: width 400ms ease;
1113
- }
1114
-
1115
- .belief-fill.cooperative { background: var(--parlay-green); }
1116
- .belief-fill.competitive { background: var(--parlay-red); }
1117
- .belief-fill.reservation { background: var(--parlay-amber); }
1118
- .belief-fill.flexibility { background: var(--parlay-blue); }
1119
-
1120
- .belief-pct {
1121
- font-size: 0.6875rem;
1122
- font-family: var(--font-mono);
1123
- font-weight: 600;
1124
- width: 28px;
1125
- text-align: right;
1126
- color: var(--parlay-ink-3);
1127
  }
 
 
 
 
1128
 
1129
- /* confidence dot */
1130
- .belief-confidence {
1131
- width: 8px;
1132
- height: 8px;
1133
- border-radius: 50%;
1134
- flex-shrink: 0;
1135
- }
1136
 
1137
- .confidence-high { background: var(--parlay-green); }
1138
- .confidence-medium { background: var(--parlay-amber); }
1139
- .confidence-low { background: var(--parlay-red); }
 
1140
 
1141
- /* ── Sparkline / Offer History ────────────────────────────── */
1142
  .sparkline-wrap {
1143
  position: relative;
 
1144
  }
1145
-
1146
- .sparkline-canvas {
1147
- width: 100%;
1148
- height: 72px;
1149
- display: block;
1150
- }
1151
-
1152
  .sparkline-labels {
1153
- display: flex;
1154
- justify-content: space-between;
1155
- margin-top: var(--sp-1);
1156
- font-size: 0.625rem;
1157
- font-family: var(--font-mono);
1158
- color: var(--parlay-ink-3);
1159
  }
 
1160
 
1161
- /* ── Leaderboard Table ────────────────────────────────────── */
1162
  .leaderboard-table {
1163
  width: 100%;
1164
  border-collapse: collapse;
1165
- font-size: 0.8125rem;
1166
  }
1167
-
1168
  .leaderboard-table th {
1169
- font-size: 0.625rem;
1170
- font-weight: 600;
1171
- letter-spacing: 0.06em;
1172
- text-transform: uppercase;
1173
- color: var(--parlay-ink-3);
1174
- padding: 0 var(--sp-2) var(--sp-2);
1175
  text-align: left;
1176
- border-bottom: 1px solid var(--parlay-border);
 
 
1177
  }
1178
-
1179
- .leaderboard-table th.num,
1180
- .leaderboard-table td.num {
1181
- text-align: right;
1182
- }
1183
-
1184
  .leaderboard-table td {
1185
- padding: var(--sp-2);
1186
- color: var(--parlay-ink);
1187
- border-bottom: 1px solid var(--parlay-border);
1188
  }
 
 
 
1189
 
1190
- .leaderboard-table tr:last-child td { border-bottom: none; }
1191
-
1192
- .leaderboard-table tr:hover td { background: var(--parlay-surface-2); }
 
1193
 
1194
- .leaderboard-table tr.highlight-player td {
1195
- background: var(--parlay-blue-bg);
1196
- color: var(--parlay-blue);
1197
- font-weight: 600;
1198
- }
1199
-
1200
- .lb-rank {
1201
  font-family: var(--font-mono);
1202
- font-weight: 700;
1203
- color: var(--parlay-ink-3);
1204
- }
1205
-
1206
- .lb-rank.gold { color: #c9a227; }
1207
- .lb-rank.silver { color: #8c9aad; }
1208
- .lb-rank.bronze { color: #b87333; }
1209
-
1210
- /* ── Scenario & Persona Selectors ─────────────────────────── */
1211
- .selector-grid {
1212
- display: grid;
1213
- grid-template-columns: 1fr 1fr;
1214
- gap: var(--sp-2);
1215
- }
1216
-
1217
- .selector-card {
1218
- border: 2px solid var(--parlay-border);
1219
- border-radius: var(--r-card);
1220
- padding: var(--sp-3);
1221
- cursor: pointer;
1222
- transition: all var(--t-fast);
1223
- background: var(--parlay-surface);
1224
- }
1225
-
1226
- .selector-card:hover {
1227
- border-color: var(--parlay-blue);
1228
- background: var(--parlay-blue-bg);
1229
- }
1230
-
1231
- .selector-card.selected {
1232
- border-color: var(--parlay-blue);
1233
- background: var(--parlay-blue-bg);
1234
- box-shadow: 0 0 0 2px var(--parlay-blue);
1235
- }
1236
-
1237
- .selector-card-name {
1238
- font-size: 0.875rem;
1239
- font-weight: 600;
1240
- color: var(--parlay-ink);
1241
- margin-bottom: var(--sp-1);
1242
- }
1243
-
1244
- .selector-card-meta {
1245
- font-size: 0.6875rem;
1246
- color: var(--parlay-ink-3);
1247
- }
1248
-
1249
- /* Persona selector full-width */
1250
- .persona-grid {
1251
- display: grid;
1252
- grid-template-columns: repeat(5, 1fr);
1253
- gap: var(--sp-2);
1254
- }
1255
-
1256
- .persona-option {
1257
- border: 2px solid var(--parlay-border);
1258
- border-radius: var(--r-card);
1259
- padding: var(--sp-3) var(--sp-2);
1260
- cursor: pointer;
1261
- text-align: center;
1262
- transition: all var(--t-fast);
1263
- background: var(--parlay-surface);
1264
  }
 
1265
 
1266
- .persona-option:hover { border-color: var(--parlay-blue); }
1267
-
1268
- .persona-option.selected { border-color: var(--parlay-blue); box-shadow: 0 0 0 2px var(--parlay-blue); }
1269
-
1270
- .persona-option-icon { font-size: 1.5rem; margin-bottom: var(--sp-1); }
1271
- .persona-option-name { font-size: 0.6875rem; font-weight: 600; color: var(--parlay-ink); }
1272
-
1273
- /* ── Loading Spinner (Gemini wait) ────────────────────────── */
1274
  .loading-overlay {
1275
- position: fixed;
1276
- inset: 0;
1277
- z-index: 999;
1278
- background: rgba(0,0,0,0.35);
1279
- display: flex;
1280
- align-items: center;
1281
- justify-content: center;
1282
  backdrop-filter: blur(2px);
 
 
1283
  }
1284
 
1285
- .loading-overlay.hidden { display: none; }
1286
-
1287
  .loading-card {
1288
- background: var(--parlay-surface);
1289
- border: 1px solid var(--parlay-border);
1290
- border-radius: var(--r-panel);
1291
- padding: var(--sp-8);
1292
- display: flex;
1293
- flex-direction: column;
1294
- align-items: center;
1295
- gap: var(--sp-4);
1296
- min-width: 200px;
1297
  }
1298
 
1299
  .spinner {
1300
- width: 36px;
1301
- height: 36px;
1302
- border: 3px solid var(--parlay-border);
1303
- border-top-color: var(--parlay-blue);
1304
- border-radius: 50%;
1305
- animation: spin 700ms linear infinite;
1306
- }
1307
-
1308
- @keyframes spin {
1309
- to { transform: rotate(360deg); }
1310
- }
1311
-
1312
- .loading-text {
1313
- font-size: 0.875rem;
1314
- color: var(--parlay-ink-2);
1315
- font-weight: 500;
1316
- }
1317
-
1318
- /* Inline thinking indicator in chat */
1319
- .thinking-bubble {
1320
- align-self: flex-start;
1321
- display: flex;
1322
- align-items: center;
1323
- gap: 4px;
1324
- padding: var(--sp-2) var(--sp-3);
1325
- background: var(--parlay-surface-2);
1326
- border: 1px solid var(--parlay-border);
1327
- border-radius: var(--r-card);
1328
- border-bottom-left-radius: var(--r-chip);
1329
- }
1330
-
1331
- .thinking-dot {
1332
- width: 6px;
1333
- height: 6px;
1334
  border-radius: 50%;
1335
- background: var(--parlay-ink-3);
1336
- animation: bounce 1.2s ease-in-out infinite;
1337
  }
1338
 
1339
- .thinking-dot:nth-child(2) { animation-delay: 0.2s; }
1340
- .thinking-dot:nth-child(3) { animation-delay: 0.4s; }
1341
-
1342
- @keyframes bounce {
1343
- 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
1344
- 40% { transform: translateY(-5px); opacity: 1; }
1345
- }
1346
 
1347
- /* ── Modal (landing / setup) ──────────────────────────────── */
1348
- .modal-backdrop {
1349
- position: fixed;
1350
- inset: 0;
1351
- z-index: 200;
1352
- background: rgba(0,0,0,0.55);
 
 
1353
  display: flex;
1354
  align-items: center;
1355
  justify-content: center;
1356
- padding: var(--sp-4);
1357
- backdrop-filter: blur(4px);
 
 
1358
  }
1359
 
1360
- .modal-backdrop.hidden { display: none; }
1361
-
1362
- .modal {
1363
- background: var(--parlay-surface);
1364
- border: 1px solid var(--parlay-border);
1365
- border-radius: var(--r-panel);
1366
- width: min(640px, 100%);
1367
- max-height: 90vh;
1368
- overflow-y: auto;
1369
- animation: modal-in 250ms cubic-bezier(0.34, 1.56, 0.64, 1);
1370
  }
1371
 
1372
- @keyframes modal-in {
1373
- from { opacity: 0; transform: scale(0.94) translateY(12px); }
1374
- to { opacity: 1; transform: scale(1) translateY(0); }
1375
  }
1376
 
1377
- .modal-header {
1378
- padding: var(--sp-6);
1379
- border-bottom: 1px solid var(--parlay-border);
1380
  }
1381
 
1382
- .modal-title {
1383
- font-size: 1.25rem;
1384
- font-weight: 700;
1385
- color: var(--parlay-ink);
1386
  }
1387
 
1388
- .modal-subtitle {
1389
- font-size: 0.875rem;
1390
- color: var(--parlay-ink-3);
1391
- margin-top: var(--sp-1);
 
 
1392
  }
1393
 
1394
- .modal-body {
1395
- padding: var(--sp-6);
1396
- display: flex;
1397
- flex-direction: column;
1398
- gap: var(--sp-6);
 
 
 
 
1399
  }
1400
 
1401
- .modal-footer {
1402
- padding: var(--sp-4) var(--sp-6);
1403
- border-top: 1px solid var(--parlay-border);
1404
- display: flex;
1405
- justify-content: flex-end;
1406
- gap: var(--sp-3);
1407
  }
1408
 
1409
- .form-group {
1410
- display: flex;
1411
- flex-direction: column;
1412
- gap: var(--sp-2);
 
 
 
1413
  }
1414
 
1415
- .form-label {
1416
- font-size: 0.8125rem;
 
1417
  font-weight: 600;
1418
- color: var(--parlay-ink-2);
 
 
1419
  }
1420
 
1421
- .form-input {
1422
- height: 40px;
1423
- padding: 0 var(--sp-3);
1424
- border: 1px solid var(--parlay-border);
1425
- border-radius: var(--r-card);
1426
- background: var(--parlay-surface-2);
1427
- color: var(--parlay-ink);
1428
- font-size: 0.9375rem;
1429
- transition: border-color var(--t-fast), box-shadow var(--t-fast);
1430
  }
1431
 
1432
- .form-input:focus {
 
 
 
 
 
 
 
 
 
1433
  outline: none;
1434
- border-color: var(--parlay-blue);
1435
- box-shadow: 0 0 0 2px var(--parlay-blue);
1436
- background: var(--parlay-surface);
1437
- }
1438
-
1439
- /* ── Training Dashboard Specifics ─────────────────────────── */
1440
- .train-grid {
1441
- display: grid;
1442
- grid-template-columns: repeat(3, 1fr);
1443
- gap: var(--sp-4);
1444
- margin-bottom: var(--sp-4);
1445
- }
1446
-
1447
- .model-card {
1448
- background: var(--parlay-surface);
1449
- border: 1px solid var(--parlay-border);
1450
- border-radius: var(--r-panel);
1451
- padding: var(--sp-4);
1452
  }
 
 
1453
 
1454
- .model-card.highlight {
1455
- border-color: var(--parlay-blue);
1456
- box-shadow: 0 0 0 2px var(--parlay-blue-bg);
1457
- }
1458
-
1459
- .model-tag {
1460
- display: inline-flex;
1461
- align-items: center;
1462
- padding: 2px var(--sp-2);
1463
- border-radius: var(--r-chip);
1464
- font-size: 0.6875rem;
1465
- font-weight: 700;
1466
- letter-spacing: 0.05em;
1467
- text-transform: uppercase;
1468
- margin-bottom: var(--sp-3);
1469
  }
1470
 
1471
- .model-tag.base { background: var(--parlay-surface-3); color: var(--parlay-ink-2); }
1472
- .model-tag.sft { background: var(--parlay-blue-bg); color: var(--parlay-blue); }
1473
- .model-tag.grpo { background: var(--parlay-green-bg); color: var(--parlay-green); }
1474
-
1475
- .metric-row {
1476
  display: flex;
1477
- justify-content: space-between;
1478
- align-items: baseline;
1479
- padding: var(--sp-2) 0;
1480
- border-bottom: 1px solid var(--parlay-border);
1481
- font-size: 0.8125rem;
1482
  }
1483
 
1484
- .metric-row:last-child { border-bottom: none; }
1485
- .metric-name { color: var(--parlay-ink-2); }
1486
- .metric-val {
1487
- font-family: var(--font-mono);
1488
- font-weight: 700;
1489
- color: var(--parlay-ink);
1490
  }
1491
 
1492
- .metric-val.positive { color: var(--parlay-green); }
1493
- .metric-val.negative { color: var(--parlay-red); }
1494
-
1495
- .chart-panel {
1496
- background: var(--parlay-surface);
1497
- border: 1px solid var(--parlay-border);
1498
- border-radius: var(--r-panel);
1499
- padding: var(--sp-6);
1500
- margin-bottom: var(--sp-4);
1501
  }
1502
 
1503
- .chart-panel canvas {
1504
- max-height: 320px;
1505
- }
 
 
 
 
 
 
1506
 
1507
- .training-log {
1508
- background: var(--parlay-surface-2);
1509
- border: 1px solid var(--parlay-border);
1510
- border-radius: var(--r-panel);
1511
- padding: var(--sp-4);
1512
- font-family: var(--font-mono);
1513
- font-size: 0.75rem;
1514
- color: var(--parlay-ink-2);
1515
- min-height: 160px;
1516
- max-height: 320px;
1517
- overflow-y: auto;
1518
- white-space: pre-wrap;
1519
- line-height: 1.6;
1520
  }
1521
 
1522
- .log-line { display: block; }
1523
- .log-line.info { color: var(--parlay-blue); }
1524
- .log-line.warn { color: var(--parlay-amber); }
1525
- .log-line.error { color: var(--parlay-red); }
1526
- .log-line.success{ color: var(--parlay-green); }
1527
-
1528
- .config-grid {
1529
- display: grid;
1530
- grid-template-columns: repeat(2, 1fr);
1531
- gap: var(--sp-3);
1532
  }
1533
 
1534
- .config-item {
1535
- display: flex;
1536
- flex-direction: column;
1537
- gap: var(--sp-1);
1538
  }
1539
 
1540
- .config-key {
1541
- font-size: 0.6875rem;
1542
- font-weight: 600;
1543
- letter-spacing: 0.05em;
1544
  text-transform: uppercase;
1545
- color: var(--parlay-ink-3);
 
1546
  }
1547
 
1548
- .config-val {
1549
- font-family: var(--font-mono);
1550
- font-size: 0.875rem;
1551
  font-weight: 600;
1552
- color: var(--parlay-ink);
 
 
1553
  }
1554
 
1555
- .steps-list {
1556
- display: flex;
1557
- flex-direction: column;
1558
- gap: var(--sp-3);
1559
- counter-reset: step-counter;
1560
  }
1561
 
1562
- .step-item {
1563
- display: flex;
1564
- gap: var(--sp-3);
1565
- align-items: flex-start;
1566
- counter-increment: step-counter;
 
 
 
1567
  }
1568
 
1569
- .step-num {
1570
- width: 24px;
1571
- height: 24px;
1572
- border-radius: 50%;
1573
- background: var(--parlay-blue-bg);
1574
- border: 1px solid var(--parlay-blue);
1575
- color: var(--parlay-blue);
1576
- font-size: 0.75rem;
1577
- font-weight: 700;
1578
- display: flex;
1579
- align-items: center;
1580
- justify-content: center;
1581
- flex-shrink: 0;
1582
  font-family: var(--font-mono);
 
 
1583
  }
1584
 
1585
- .step-text {
1586
- font-size: 0.875rem;
1587
- color: var(--parlay-ink-2);
1588
- line-height: 1.5;
1589
- padding-top: 2px;
 
1590
  }
1591
 
1592
- .step-code {
1593
- display: inline-block;
1594
- font-family: var(--font-mono);
1595
- font-size: 0.75rem;
1596
- background: var(--parlay-surface-3);
1597
- border: 1px solid var(--parlay-border);
1598
- border-radius: var(--r-chip);
1599
- padding: 1px var(--sp-2);
1600
- color: var(--parlay-ink);
1601
  }
1602
 
1603
- /* Progress bar for training */
1604
- .training-progress-wrap {
1605
- background: var(--parlay-surface-2);
1606
- border: 1px solid var(--parlay-border);
1607
- border-radius: var(--r-panel);
1608
- padding: var(--sp-4);
 
 
 
 
 
 
 
1609
  }
1610
 
1611
- .training-progress-bar-track {
1612
- height: 8px;
1613
- background: var(--parlay-surface-3);
 
1614
  border-radius: 4px;
1615
  overflow: hidden;
1616
- margin: var(--sp-2) 0;
 
1617
  }
1618
 
1619
- .training-progress-bar-fill {
 
1620
  height: 100%;
1621
- background: linear-gradient(90deg, var(--parlay-blue), var(--parlay-purple));
1622
- border-radius: 4px;
1623
- transition: width 300ms ease;
1624
- width: 0%;
1625
  }
1626
 
1627
- /* ── Stat Chips / Inline ──────────────────────────────────── */
1628
- .stat-chip {
1629
- display: inline-flex;
1630
- align-items: center;
1631
- gap: var(--sp-1);
1632
- padding: 2px var(--sp-2);
1633
- border-radius: var(--r-chip);
1634
- font-size: 0.75rem;
1635
  font-weight: 600;
1636
- font-family: var(--font-mono);
 
1637
  }
1638
 
1639
- .stat-chip.green { background: var(--parlay-green-bg); color: var(--parlay-green); }
1640
- .stat-chip.red { background: var(--parlay-red-bg); color: var(--parlay-red); }
1641
- .stat-chip.amber { background: var(--parlay-amber-bg); color: var(--parlay-amber); }
1642
- .stat-chip.blue { background: var(--parlay-blue-bg); color: var(--parlay-blue); }
1643
- .stat-chip.purple { background: var(--parlay-purple-bg); color: var(--parlay-purple); }
1644
-
1645
- /* Divider */
1646
- .divider {
1647
- height: 1px;
1648
- background: var(--parlay-border);
1649
- margin: var(--sp-4) 0;
1650
- }
1651
-
1652
- /* Empty state */
1653
- .empty-state {
1654
- text-align: center;
1655
- padding: var(--sp-8) var(--sp-4);
1656
- color: var(--parlay-ink-3);
1657
- font-size: 0.875rem;
1658
  }
1659
 
1660
- .empty-state-icon {
1661
- font-size: 2rem;
1662
- margin-bottom: var(--sp-3);
1663
- opacity: 0.5;
 
1664
  }
1665
 
1666
- /* ── Game Over / Result ───────────────────────────────────── */
1667
- .result-banner {
1668
- border-radius: var(--r-panel);
1669
- padding: var(--sp-6);
1670
- text-align: center;
1671
  display: flex;
1672
- flex-direction: column;
1673
  align-items: center;
1674
- gap: var(--sp-3);
1675
  }
1676
 
1677
- .result-banner.deal { background: var(--parlay-green-bg); border: 1px solid var(--parlay-green); }
1678
- .result-banner.walk { background: var(--parlay-red-bg); border: 1px solid var(--parlay-red); }
1679
-
1680
- .result-title {
1681
- font-size: 1.375rem;
1682
- font-weight: 800;
1683
- letter-spacing: -0.02em;
1684
  }
1685
 
1686
- .result-banner.deal .result-title { color: var(--parlay-green); }
1687
- .result-banner.walk .result-title { color: var(--parlay-red); }
1688
-
1689
- .result-amount {
1690
- font-size: 2rem;
1691
- font-family: var(--font-mono);
1692
- font-weight: 700;
1693
  }
1694
 
1695
- .result-score {
1696
- font-size: 0.875rem;
1697
- color: var(--parlay-ink-2);
 
 
1698
  }
1699
 
1700
- /* ── Utility ──────────────────────────────────────────────── */
1701
- .hidden { display: none !important; }
1702
- .sr-only {
1703
- position: absolute;
1704
- width: 1px; height: 1px;
1705
- padding: 0; margin: -1px;
1706
- overflow: hidden;
1707
- clip: rect(0,0,0,0);
1708
- white-space: nowrap;
1709
- border: 0;
1710
- }
1711
-
1712
- .text-center { text-align: center; }
1713
- .text-right { text-align: right; }
1714
- .text-mono { font-family: var(--font-mono); }
1715
- .text-muted { color: var(--parlay-ink-3); }
1716
- .text-sm { font-size: 0.8125rem; }
1717
- .text-xs { font-size: 0.75rem; }
1718
- .text-green { color: var(--parlay-green); }
1719
- .text-red { color: var(--parlay-red); }
1720
- .text-amber { color: var(--parlay-amber); }
1721
- .text-blue { color: var(--parlay-blue); }
1722
-
1723
- .fw-600 { font-weight: 600; }
1724
- .fw-700 { font-weight: 700; }
1725
-
1726
- .flex { display: flex; }
1727
- .flex-col { flex-direction: column; }
1728
- .items-center { align-items: center; }
1729
- .gap-2 { gap: var(--sp-2); }
1730
- .gap-3 { gap: var(--sp-3); }
1731
- .gap-4 { gap: var(--sp-4); }
1732
-
1733
- .mt-2 { margin-top: var(--sp-2); }
1734
- .mt-3 { margin-top: var(--sp-3); }
1735
- .mt-4 { margin-top: var(--sp-4); }
1736
- .mb-2 { margin-bottom: var(--sp-2); }
1737
- .mb-3 { margin-bottom: var(--sp-3); }
1738
- .mb-4 { margin-bottom: var(--sp-4); }
1739
-
1740
- /* ── Mobile Breakpoints ───────────────────────────────────── */
1741
- @media (max-width: 1100px) {
1742
- :root {
1743
- --col-left: 220px;
1744
- --col-right: 260px;
1745
- }
1746
- }
1747
-
1748
- @media (max-width: 900px) {
1749
- .app-body {
1750
- grid-template-columns: 1fr var(--col-right);
1751
- }
1752
-
1753
- .col-left {
1754
- display: none;
1755
- }
1756
- }
1757
-
1758
- @media (max-width: 768px) {
1759
- .app-body {
1760
- grid-template-columns: 1fr;
1761
- padding: var(--sp-2);
1762
- gap: var(--sp-2);
1763
- }
1764
-
1765
- .col-left, .col-right {
1766
- display: flex;
1767
- }
1768
-
1769
- .train-grid {
1770
- grid-template-columns: 1fr;
1771
- }
1772
-
1773
- .persona-grid {
1774
- grid-template-columns: repeat(3, 1fr);
1775
- }
1776
-
1777
- .selector-grid {
1778
- grid-template-columns: 1fr;
1779
- }
1780
-
1781
- .config-grid {
1782
- grid-template-columns: 1fr;
1783
- }
1784
-
1785
- .app-header {
1786
- padding: 0 var(--sp-4);
1787
- }
1788
-
1789
- .header-nav { display: none; }
1790
-
1791
- .chat-thread {
1792
- max-height: 360px;
1793
- }
1794
-
1795
- .character-canvas-wrap {
1796
- width: 240px;
1797
- height: 320px;
1798
- }
1799
-
1800
- #character-canvas {
1801
- width: 240px !important;
1802
- height: 320px !important;
1803
- }
1804
- }
1805
-
1806
- @media (max-width: 480px) {
1807
- html { font-size: 13px; }
1808
-
1809
- .modal {
1810
- border-radius: var(--r-card);
1811
- }
1812
-
1813
- .persona-grid {
1814
- grid-template-columns: repeat(2, 1fr);
1815
- }
1816
-
1817
- .action-buttons {
1818
- flex-wrap: wrap;
1819
- }
1820
  }
 
 
 
 
1
  /* ============================================================
2
+ Parlay β€” "The Deal Room"
3
+ 1960s Madison Avenue aesthetic. Smoke-filled boardrooms,
4
+ whisky glasses, leather chairs. Prestige TV drama energy.
5
  ============================================================ */
6
 
7
+ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=DM+Mono:wght@400;500&display=swap');
8
+
9
+ /* ── Custom properties ─────────────────────────────────────────────────────── */
10
  :root {
11
+ --felt: #1c2b1a; /* dark green baize β€” the negotiating table */
12
+ --felt-light: #2a3d28;
13
+ --mahogany: #2c1810; /* dark wood tones */
14
+ --mahogany-light:#3d2518;
15
+ --cream: #f5f0e8; /* aged paper / documents */
16
+ --cream-dark: #e8e0d0;
17
+ --gold: #c9a84c; /* brass fixtures */
18
+ --gold-light: #e8c96a;
19
+ --smoke: #8a8070; /* cigarette smoke grey */
20
+ --smoke-light: #b8b0a0;
21
+ --ink: #1a1208; /* fountain pen ink */
22
+ --scarlet: #8b1a1a; /* deal-breaker red */
23
+ --scarlet-light: #c42020;
24
+ --emerald: #1a5c2a; /* deal-made green */
25
+ --ivory: #faf6ee;
26
+ --shadow: rgba(0,0,0,0.4);
27
+
28
+ /* Backward-compat aliases referenced by app.js */
29
+ --parlay-red: var(--scarlet-light);
30
+ --parlay-amber: var(--gold);
31
+ --parlay-green: var(--emerald);
32
+ --parlay-blue: #1a5fa8;
33
+ --parlay-purple: #5c3d9e;
34
+ --parlay-surface:var(--cream);
35
+ --parlay-border: var(--gold);
36
+ --parlay-ink: var(--ink);
 
 
 
 
 
37
 
38
  /* Typography */
39
+ --font-display: 'Playfair Display', Georgia, serif;
40
+ --font-body: 'EB Garamond', Georgia, serif;
41
+ --font-mono: 'DM Mono', 'Courier New', monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
+ /* Spacing */
44
+ --space-xs: 4px;
45
+ --space-sm: 8px;
46
+ --space-md: 16px;
47
+ --space-lg: 24px;
48
+ --space-xl: 40px;
49
 
50
+ /* Transitions */
51
+ --transition: 180ms ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
 
54
+ /* ── Reset ─────────────────────────────────────────────────────────────────── */
55
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
 
 
 
56
 
57
  html {
58
+ height: 100%;
59
+ font-size: 16px;
60
+ -webkit-font-smoothing: antialiased;
61
  }
62
 
63
  body {
64
+ height: 100%;
65
+ background: var(--felt);
66
+ color: var(--cream);
67
  font-family: var(--font-body);
68
+ font-size: 1rem;
69
+ line-height: 1.6;
 
 
 
70
  overflow-x: hidden;
71
  }
72
 
73
+ a { color: var(--gold); text-decoration: none; }
74
+ a:hover { color: var(--gold-light); }
 
 
 
75
 
76
+ h1, h2, h3 { font-family: var(--font-display); font-weight: 600; }
 
 
 
 
 
77
 
78
+ .mono, .text-mono { font-family: var(--font-mono); }
79
+ .text-muted { color: var(--smoke-light); }
80
+ .text-xs { font-size: 0.75rem; }
81
+ .text-sm { font-size: 0.875rem; }
82
+ .text-red { color: var(--scarlet-light); }
83
+ .text-amber { color: var(--gold); }
84
+ .text-green { color: var(--emerald); }
85
+ .mt-4 { margin-top: var(--space-md); }
86
+ .hidden { display: none !important; }
87
+
88
+ /* Screen-reader only */
89
+ .sr-only {
90
+ position: absolute; width: 1px; height: 1px;
91
+ padding: 0; margin: -1px; overflow: hidden;
92
+ clip: rect(0,0,0,0); white-space: nowrap; border: 0;
93
+ }
94
 
95
+ /* ── Demo Banner ───────────────────────────────────────────────────────────── */
96
+ .demo-banner {
97
+ position: fixed;
98
+ top: 0; left: 0; right: 0;
99
+ z-index: 500;
100
+ background: var(--mahogany);
101
+ border-bottom: 1px solid var(--gold);
102
+ color: var(--gold);
103
  font-family: var(--font-mono);
104
+ font-size: 0.75rem;
105
+ letter-spacing: 0.04em;
106
+ padding: 6px var(--space-lg);
107
+ text-align: center;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ gap: var(--space-md);
112
+ }
113
+
114
+ .demo-banner.hidden { display: none !important; }
115
+
116
+ .demo-banner-dismiss {
117
+ background: none; border: none;
118
+ color: var(--smoke-light);
119
+ cursor: pointer; font-size: 1rem; line-height: 1;
120
+ padding: 2px 6px;
121
+ border-radius: 3px;
122
  }
123
+ .demo-banner-dismiss:hover { color: var(--cream); }
124
 
125
+ /* push content below banner */
126
+ body.demo-mode { padding-top: 30px; }
127
+
128
+ /* ── App Header ────────────────────────────────────────────────────────────── */
129
  .app-header {
 
 
 
 
130
  display: flex;
131
  align-items: center;
132
  justify-content: space-between;
133
+ background: var(--mahogany);
134
+ border-bottom: 2px solid var(--gold);
135
+ padding: 0 var(--space-lg);
136
+ height: 56px;
137
+ position: sticky;
138
+ top: 0;
139
+ z-index: 200;
140
  }
141
 
142
  .header-brand {
143
  display: flex;
144
  align-items: center;
145
+ gap: var(--space-sm);
146
+ font-family: var(--font-display);
147
+ font-size: 1.35rem;
148
  font-weight: 700;
149
+ color: var(--gold);
150
+ letter-spacing: 0.03em;
151
+ font-style: italic;
152
  }
153
 
154
+ .brand-dot {
155
+ width: 10px; height: 10px;
 
156
  border-radius: 50%;
157
+ background: var(--gold);
158
+ box-shadow: 0 0 8px var(--gold);
159
+ display: inline-block;
 
 
 
 
 
160
  }
161
 
162
+ .header-nav {
163
  display: flex;
164
+ gap: var(--space-lg);
 
165
  }
166
 
167
  .header-nav a {
168
+ font-family: var(--font-display);
169
+ font-size: 0.875rem;
170
+ letter-spacing: 0.06em;
171
+ text-transform: uppercase;
172
+ color: var(--smoke-light);
173
+ padding: 4px 0;
174
+ border-bottom: 2px solid transparent;
175
+ transition: color var(--transition), border-color var(--transition);
176
  }
177
+ .header-nav a:hover,
178
+ .header-nav a.active {
179
+ color: var(--gold);
180
+ border-bottom-color: var(--gold);
 
181
  }
182
 
183
+ .header-actions {
184
+ display: flex;
185
+ align-items: center;
186
+ gap: var(--space-md);
 
 
 
 
 
 
 
187
  }
188
 
189
+ .dark-toggle {
190
+ width: 32px; height: 32px;
191
+ background: var(--mahogany-light);
192
+ border: 1px solid var(--smoke);
 
 
 
193
  border-radius: 50%;
194
+ cursor: pointer;
195
+ color: var(--smoke-light);
196
+ font-size: 1rem;
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ transition: border-color var(--transition), color var(--transition);
 
 
 
 
 
201
  }
202
+ .dark-toggle::after { content: "β—‘"; }
203
+ .dark-toggle:hover { border-color: var(--gold); color: var(--gold); }
204
 
205
+ /* ── 3-Column Layout ───────────────────────────────────────────────────────── */
206
  .app-body {
207
  display: grid;
208
+ grid-template-columns: 280px 1fr 300px;
209
+ gap: var(--space-md);
210
+ padding: var(--space-md);
211
+ min-height: calc(100vh - 56px);
212
+ max-width: 1400px;
213
+ margin: 0 auto;
214
  }
215
 
216
+ .col-left { display: flex; flex-direction: column; gap: var(--space-md); }
217
+ .col-center{ display: flex; flex-direction: column; gap: var(--space-sm); }
218
+ .col-right { display: flex; flex-direction: column; gap: var(--space-md); overflow-y: auto; max-height: calc(100vh - 80px); }
 
 
 
219
 
220
+ /* ── Panels ────────────────────────────────────────────────────────────────── */
221
  .panel {
222
+ background: var(--mahogany);
223
+ border: 1px solid rgba(201,168,76,0.25);
224
+ border-radius: 6px;
225
+ padding: var(--space-md);
226
+ box-shadow: 0 4px 16px var(--shadow);
227
  }
228
 
229
  .panel-header {
230
  display: flex;
231
  align-items: center;
232
  justify-content: space-between;
233
+ margin-bottom: var(--space-sm);
234
+ padding-bottom: var(--space-sm);
235
+ border-bottom: 1px solid rgba(201,168,76,0.2);
236
  }
237
 
238
  .panel-title {
239
+ font-family: var(--font-display);
240
+ font-style: italic;
241
+ font-size: 0.95rem;
242
+ color: var(--gold);
243
+ letter-spacing: 0.02em;
244
  }
245
 
246
+ /* ── Player Card ───────────────────────────────────────────────────────────── */
247
+ .player-card { }
 
 
 
 
 
248
 
249
  .player-card-header {
250
  display: flex;
251
  align-items: center;
252
+ gap: var(--space-md);
253
+ margin-bottom: var(--space-md);
254
  }
255
 
256
  .player-avatar {
257
+ width: 48px; height: 48px;
 
258
  border-radius: 50%;
259
+ background: var(--felt-light);
260
+ border: 2px solid var(--gold);
261
  display: flex;
262
  align-items: center;
263
  justify-content: center;
264
+ font-family: var(--font-display);
265
+ font-size: 1.2rem;
 
 
 
 
 
 
 
 
266
  font-weight: 600;
267
+ color: var(--gold);
268
+ flex-shrink: 0;
 
 
269
  }
270
 
271
+ .player-name { font-family: var(--font-display); font-weight: 600; font-size: 1rem; color: var(--cream); }
272
+ .player-rank { font-family: var(--font-mono); font-size: 0.7rem; color: var(--smoke-light); }
 
 
 
273
 
274
  /* CP Bar */
275
+ .cp-section { }
276
+ .cp-label-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
277
+ .cp-label { font-size: 0.75rem; color: var(--smoke-light); letter-spacing: 0.04em; text-transform: uppercase; }
278
+ .cp-value { font-family: var(--font-mono); font-size: 0.75rem; color: var(--gold); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  .cp-track {
281
  height: 6px;
 
282
  border-radius: 3px;
283
+ background: var(--felt);
284
+ border: 1px solid rgba(201,168,76,0.2);
285
  overflow: hidden;
 
286
  }
 
287
  .cp-fill {
288
  height: 100%;
289
+ background: linear-gradient(90deg, var(--gold), var(--gold-light));
290
  border-radius: 3px;
291
+ transition: width 400ms ease;
 
292
  }
293
 
294
+ /* ── Tactical Cards (playing card style) ────────────────────────────────────── */
295
+ .hand-container {
296
+ display: grid;
297
+ grid-template-columns: repeat(3, 1fr);
298
+ gap: var(--space-sm);
299
+ min-height: 80px;
300
  }
301
 
302
+ .empty-state {
303
+ grid-column: 1 / -1;
304
+ text-align: center;
305
+ padding: var(--space-lg);
306
+ color: var(--smoke);
307
+ font-size: 0.875rem;
308
  }
309
 
310
+ .empty-state-icon { font-size: 1.5rem; margin-bottom: var(--space-sm); }
 
 
 
 
 
311
 
312
+ /* Flip-card container */
313
  .tactical-card {
 
 
 
314
  perspective: 600px;
315
+ cursor: pointer;
316
+ height: 110px;
317
  }
318
 
319
  .card-inner {
320
  position: relative;
321
+ width: 100%; height: 100%;
 
322
  transform-style: preserve-3d;
323
+ transition: transform 400ms ease;
324
  }
325
 
326
+ .tactical-card:hover .card-inner { transform: rotateY(180deg); }
327
+ .tactical-card.selected .card-inner { transform: rotateY(180deg); }
328
+ .tactical-card.selected { outline: 2px solid var(--gold); border-radius: 8px; }
 
329
 
330
  .card-face, .card-back {
331
  position: absolute;
332
  inset: 0;
333
+ border-radius: 8px;
334
+ border: 1px solid var(--gold);
335
+ background: var(--ivory);
336
+ color: var(--ink);
337
  backface-visibility: hidden;
338
+ padding: var(--space-sm);
339
  display: flex;
340
  flex-direction: column;
341
+ box-shadow: 2px 3px 8px var(--shadow);
 
 
 
 
 
 
 
 
 
 
 
 
342
  }
343
 
344
+ .card-back { transform: rotateY(180deg); background: var(--cream-dark); }
 
 
 
 
 
 
 
 
 
 
 
345
 
346
  .card-name {
347
+ font-family: var(--font-display);
348
+ font-size: 0.7rem;
349
  font-weight: 600;
350
+ color: var(--ink);
351
+ line-height: 1.2;
352
  }
 
353
  .card-type {
 
 
354
  font-family: var(--font-mono);
355
+ font-size: 0.55rem;
356
+ color: var(--smoke);
357
+ text-transform: uppercase;
358
+ letter-spacing: 0.04em;
359
+ margin-top: 2px;
360
  }
 
361
  .card-cost {
362
+ margin-top: auto;
363
+ align-self: flex-end;
364
+ background: var(--mahogany);
365
+ color: var(--gold);
 
 
 
 
 
 
 
 
 
366
  font-family: var(--font-mono);
367
+ font-size: 0.6rem;
368
+ padding: 1px 5px;
369
+ border-radius: 3px;
370
+ border: 1px solid var(--gold);
371
  }
372
 
373
  .card-back-label {
374
+ font-family: var(--font-display);
375
+ font-style: italic;
376
+ font-size: 0.6rem;
377
+ color: var(--smoke);
378
+ margin-bottom: 4px;
 
379
  }
 
380
  .card-game-theory {
381
+ font-size: 0.65rem;
382
+ color: var(--ink);
383
  line-height: 1.4;
384
+ overflow: hidden;
385
  }
386
 
387
+ /* ── Achievements ───────────────────────────────────────────────────────────── */
388
  .achievements-strip {
389
+ display: grid;
390
+ grid-template-columns: repeat(3, 1fr);
391
+ gap: var(--space-xs);
392
  }
393
 
394
  .badge {
395
+ background: var(--felt);
396
+ border: 1px solid rgba(201,168,76,0.15);
397
+ border-radius: 6px;
398
+ padding: var(--space-sm);
399
+ text-align: center;
400
+ font-size: 0.65rem;
401
+ color: var(--smoke);
 
 
 
 
402
  cursor: default;
403
+ opacity: 0.45;
404
+ transition: opacity var(--transition), border-color var(--transition);
405
  }
406
 
407
  .badge.earned {
408
+ opacity: 1;
409
+ border-color: var(--gold);
410
+ color: var(--gold);
 
 
 
 
 
 
 
 
 
 
411
  }
412
 
413
+ .badge-icon { display: block; font-size: 1rem; margin-bottom: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
+ /* ── Center β€” Scenario Header ──────────────────────────────────────────────── */
 
 
 
 
 
 
416
  .scenario-header {
 
 
 
 
417
  display: flex;
418
+ align-items: flex-start;
419
  justify-content: space-between;
420
+ background: var(--mahogany);
421
+ border: 1px solid rgba(201,168,76,0.2);
422
+ border-radius: 6px;
423
+ padding: var(--space-md);
424
+ box-shadow: 0 2px 8px var(--shadow);
425
  }
426
 
427
  .scenario-title {
428
+ font-family: var(--font-display);
429
+ font-size: 1.1rem;
430
+ font-weight: 600;
431
+ color: var(--cream);
432
  }
433
 
434
  .scenario-meta {
435
+ font-family: var(--font-mono);
436
+ font-size: 0.7rem;
437
  margin-top: 2px;
438
  }
439
 
440
+ .act-pills {
 
441
  display: flex;
442
+ gap: var(--space-xs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  }
444
 
445
+ .act-pill {
446
+ width: 28px; height: 28px;
447
+ border-radius: 50%;
448
+ border: 1px solid rgba(201,168,76,0.3);
449
+ display: flex; align-items: center; justify-content: center;
450
+ font-family: var(--font-display);
451
+ font-size: 0.75rem;
452
+ color: var(--smoke);
453
+ cursor: default;
454
  }
455
+ .act-pill.active { border-color: var(--gold); color: var(--gold); }
456
+ .act-pill.completed{ border-color: var(--emerald); background: var(--emerald); color: var(--ivory); }
457
 
458
+ /* ── Drift Alert ───────────────────────────────────────────────────────────── */
459
+ .drift-alert {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: var(--space-sm);
463
+ background: rgba(139,26,26,0.25);
464
+ border: 1px solid var(--scarlet);
465
+ border-radius: 6px;
466
+ padding: var(--space-sm) var(--space-md);
467
+ animation: slide-down 300ms ease;
 
468
  }
469
 
470
+ .drift-alert-icon { font-size: 1rem; }
471
+ .drift-alert-text { flex: 1; font-size: 0.875rem; color: var(--cream); }
472
+ .drift-dismiss { background: none; border: none; color: var(--smoke-light); cursor: pointer; font-size: 1rem; }
473
+ .drift-dismiss:hover { color: var(--cream); }
474
 
475
+ /* ── Chat Thread ───────────────────────────────────────────────────────────── */
476
  .chat-thread {
477
+ flex: 1;
478
+ min-height: 300px;
479
+ max-height: 420px;
480
+ overflow-y: auto;
481
+ padding: var(--space-md);
482
+ border-radius: 6px;
483
+ border: 1px solid rgba(201,168,76,0.15);
484
  display: flex;
485
  flex-direction: column;
486
+ gap: var(--space-sm);
 
 
 
 
 
 
 
487
  scroll-behavior: smooth;
488
+
489
+ /* Felt texture */
490
+ background-color: var(--felt);
491
+ background-image:
492
+ repeating-linear-gradient(
493
+ 45deg,
494
+ transparent,
495
+ transparent 3px,
496
+ rgba(255,255,255,0.008) 3px,
497
+ rgba(255,255,255,0.008) 6px
498
+ ),
499
+ repeating-linear-gradient(
500
+ -45deg,
501
+ transparent,
502
+ transparent 3px,
503
+ rgba(255,255,255,0.005) 3px,
504
+ rgba(255,255,255,0.005) 6px
505
+ );
506
+ }
507
+
508
+ .chat-thread::-webkit-scrollbar { width: 4px; }
509
+ .chat-thread::-webkit-scrollbar-track { background: var(--felt); }
510
+ .chat-thread::-webkit-scrollbar-thumb { background: var(--smoke); border-radius: 2px; }
511
+
512
+ .message-system {
513
+ text-align: center;
514
+ font-family: var(--font-mono);
515
+ font-size: 0.7rem;
516
+ color: var(--smoke);
517
+ letter-spacing: 0.04em;
518
+ padding: var(--space-xs);
519
  }
520
 
521
+ /* Message bubbles */
522
  .message-bubble {
 
 
 
523
  max-width: 82%;
524
+ border-radius: 6px;
525
+ padding: var(--space-sm) var(--space-md);
526
+ box-shadow: 0 1px 4px var(--shadow);
527
+ animation: slide-up 200ms ease;
528
+ }
529
+
530
+ .message-bubble.player {
531
+ align-self: flex-end;
532
+ background: var(--ivory);
533
+ border-right: 3px solid var(--gold);
534
+ color: var(--ink);
535
  }
536
 
537
+ .message-bubble.opponent {
538
+ align-self: flex-start;
539
+ background: var(--cream);
540
+ border-left: 3px solid var(--gold);
541
+ color: var(--ink);
542
+ }
543
+
544
+ /* Persona-tinted left border for opponent messages */
545
+ .message-bubble.opponent[data-persona="shark"] { border-left-color: var(--scarlet-light); }
546
+ .message-bubble.opponent[data-persona="diplomat"] { border-left-color: var(--emerald); }
547
+ .message-bubble.opponent[data-persona="analyst"] { border-left-color: var(--parlay-blue); }
548
+ .message-bubble.opponent[data-persona="wildcard"] { border-left-color: var(--gold); }
549
+ .message-bubble.opponent[data-persona="veteran"] { border-left-color: var(--parlay-purple); }
550
 
551
  .bubble-meta {
 
 
552
  display: flex;
553
  align-items: center;
554
+ gap: var(--space-sm);
555
+ margin-bottom: 4px;
556
+ font-family: var(--font-mono);
557
+ font-size: 0.65rem;
558
+ color: var(--smoke);
559
  }
560
 
561
  .bubble-body {
562
+ font-family: var(--font-body);
563
+ font-size: 0.9rem;
564
+ line-height: 1.5;
 
 
 
 
 
 
 
565
  }
566
 
567
+ .move-pill {
568
+ padding: 1px 6px;
569
+ border-radius: 3px;
570
+ font-size: 0.6rem;
571
+ font-family: var(--font-mono);
572
+ text-transform: uppercase;
573
+ letter-spacing: 0.04em;
574
+ background: rgba(201,168,76,0.15);
575
+ color: var(--gold);
576
+ border: 1px solid rgba(201,168,76,0.3);
577
  }
578
 
579
+ /* ── Offer Chips (casino chip style) ─────────────────────────────────────────── */
580
  .offer-chip {
581
  display: inline-flex;
582
  align-items: center;
583
+ justify-content: center;
584
+ min-width: 72px;
585
+ height: 72px;
586
+ border-radius: 50%;
587
+ border: 4px solid var(--gold);
588
+ background: var(--mahogany);
589
+ color: var(--gold-light);
590
  font-family: var(--font-mono);
591
+ font-size: 0.65rem;
592
+ font-weight: 500;
593
+ text-align: center;
594
+ padding: 4px;
595
+ margin-top: var(--space-sm);
596
+ box-shadow:
597
+ inset 0 2px 4px rgba(255,255,255,0.1),
598
+ 0 2px 8px rgba(0,0,0,0.4),
599
+ 0 0 0 2px var(--mahogany),
600
+ 0 0 0 5px rgba(201,168,76,0.3);
601
  }
602
 
603
+ /* ── Thinking Bubble ───────────────────────────────────────────────────────── */
604
+ .thinking-bubble {
605
+ display: flex;
606
+ gap: 5px;
607
+ padding: var(--space-sm) var(--space-md);
608
+ align-self: flex-start;
609
  }
610
 
611
+ .thinking-dot {
612
+ width: 8px; height: 8px;
613
+ border-radius: 50%;
614
+ background: var(--smoke);
615
+ animation: dot-bounce 1.4s infinite;
616
  }
617
+ .thinking-dot:nth-child(2) { animation-delay: 0.2s; }
618
+ .thinking-dot:nth-child(3) { animation-delay: 0.4s; }
619
 
620
+ /* ── Result Banner ─────────────────────────────────────────────────────────── */
621
+ .result-banner {
622
+ border-radius: 6px;
623
+ padding: var(--space-md);
624
+ text-align: center;
625
+ border: 1px solid;
626
+ animation: slide-down 300ms ease;
627
+ }
628
+ .result-banner.deal {
629
+ background: rgba(26,92,42,0.3);
630
+ border-color: var(--emerald);
631
+ color: var(--ivory);
632
+ }
633
+ .result-banner.walk {
634
+ background: rgba(139,26,26,0.3);
635
+ border-color: var(--scarlet);
636
+ color: var(--ivory);
637
  }
638
 
639
+ .result-title { font-family: var(--font-display); font-size: 1.1rem; font-weight: 600; }
640
+ .result-amount { font-family: var(--font-mono); font-size: 1.4rem; color: var(--gold); margin: var(--space-xs) 0; }
641
+ .result-score { font-family: var(--font-mono); font-size: 0.75rem; color: var(--smoke-light); }
 
 
642
 
643
+ /* ── Input Area ────────────────────────────────────────────────────────────── */
644
+ .input-area {
645
+ background: var(--mahogany);
646
+ border: 1px solid rgba(201,168,76,0.25);
647
+ border-radius: 6px;
648
+ padding: var(--space-md);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  }
650
 
651
  .input-row {
652
  display: flex;
653
+ gap: var(--space-sm);
654
+ margin-bottom: var(--space-sm);
655
  }
656
 
657
  .offer-input {
658
  flex: 1;
659
+ background: var(--felt);
660
+ border: 1px solid rgba(201,168,76,0.3);
661
+ border-radius: 4px;
662
+ color: var(--cream);
 
 
663
  font-family: var(--font-mono);
664
+ font-size: 0.9rem;
665
+ padding: var(--space-sm) var(--space-md);
 
 
 
 
666
  outline: none;
667
+ transition: border-color var(--transition);
 
 
668
  }
669
+ .offer-input::placeholder { color: var(--smoke); }
670
+ .offer-input:focus { border-color: var(--gold); }
671
 
672
  .move-select {
673
+ background: var(--felt);
674
+ border: 1px solid rgba(201,168,76,0.3);
675
+ border-radius: 4px;
676
+ color: var(--cream);
677
+ font-family: var(--font-mono);
678
+ font-size: 0.8rem;
679
+ padding: var(--space-sm) var(--space-md);
680
+ outline: none;
681
  cursor: pointer;
 
 
 
 
 
 
682
  }
683
+ .move-select:focus { border-color: var(--gold); }
684
 
685
+ .action-buttons { display: flex; gap: var(--space-sm); }
 
 
 
 
686
 
687
+ /* ── Buttons ───────────────────────────────────────────────────────────────── */
688
  .btn {
689
  display: inline-flex;
690
  align-items: center;
691
  justify-content: center;
692
+ gap: var(--space-xs);
693
+ padding: 8px 18px;
694
+ border-radius: 4px;
695
+ border: 1px solid transparent;
696
+ font-family: var(--font-display);
697
  font-size: 0.875rem;
698
  font-weight: 600;
699
+ letter-spacing: 0.04em;
700
  cursor: pointer;
701
+ transition: all var(--transition);
702
  white-space: nowrap;
 
 
 
 
 
 
703
  }
704
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
705
 
706
  .btn-primary {
707
+ background: var(--gold);
708
+ color: var(--ink);
709
+ border-color: var(--gold);
710
  }
711
+ .btn-primary:hover:not(:disabled) {
712
+ background: var(--gold-light);
713
+ border-color: var(--gold-light);
714
  }
715
 
716
  .btn-success {
717
+ background: transparent;
718
+ color: var(--emerald);
719
+ border-color: var(--emerald);
 
 
 
 
720
  }
721
+ .btn-success:hover:not(:disabled) { background: rgba(26,92,42,0.2); }
722
 
723
  .btn-danger {
724
+ background: transparent;
725
+ color: var(--scarlet-light);
726
+ border-color: var(--scarlet);
 
 
 
 
 
727
  }
728
+ .btn-danger:hover:not(:disabled) { background: rgba(139,26,26,0.2); }
729
 
730
  .btn-ghost {
731
  background: transparent;
732
+ color: var(--smoke-light);
733
+ border-color: transparent;
734
  }
735
+ .btn-ghost:hover:not(:disabled) { color: var(--gold); }
736
 
737
+ .btn-sm { padding: 5px 12px; font-size: 0.8rem; }
 
 
 
738
 
739
+ /* ── ZOPA Bar (ruler style) ────────────────────────────��────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
740
  .zopa-section {
741
+ background: var(--mahogany);
742
+ border: 1px solid rgba(201,168,76,0.2);
743
+ border-radius: 6px;
744
+ padding: var(--space-md);
745
  }
746
 
747
  .zopa-track-outer {
748
  position: relative;
749
  height: 32px;
750
+ border-radius: 4px;
751
+ background: var(--felt);
752
+ border: 1px solid rgba(201,168,76,0.3);
753
+ margin: var(--space-sm) 0;
754
  overflow: visible;
755
+
756
+ /* Ruler tick marks */
757
+ background-image: repeating-linear-gradient(
758
+ 90deg,
759
+ transparent,
760
+ transparent 9%,
761
+ rgba(201,168,76,0.15) 9%,
762
+ rgba(201,168,76,0.15) 10%
763
+ );
764
  }
765
 
766
  .zopa-zone {
767
  position: absolute;
768
+ top: 0; bottom: 0;
769
+ background: rgba(26,92,42,0.3);
770
+ border-left: 1px solid var(--emerald);
771
+ border-right: 1px solid var(--emerald);
 
 
 
772
  }
773
 
774
  .zopa-marker {
775
  position: absolute;
776
+ top: -6px; bottom: -6px;
 
777
  display: flex;
778
  flex-direction: column;
779
  align-items: center;
780
+ transform: translateX(-50%);
781
  pointer-events: none;
782
  }
783
 
784
  .zopa-marker-line {
785
  width: 2px;
786
+ flex: 1;
787
+ opacity: 0.9;
788
  }
789
 
790
+ .marker-player .zopa-marker-line { background: var(--parlay-blue); }
791
+ .marker-opponent .zopa-marker-line { background: var(--scarlet-light); }
792
+ .marker-current .zopa-marker-line { background: var(--gold); }
793
 
794
  .zopa-label {
795
+ font-family: var(--font-mono);
796
+ font-size: 0.55rem;
 
 
797
  white-space: nowrap;
798
  position: absolute;
799
  bottom: -16px;
800
+ color: var(--smoke-light);
801
  }
802
 
 
 
 
 
 
803
  .nash-diamond {
804
  position: absolute;
805
+ top: 50%; transform: translate(-50%, -50%);
806
+ width: 10px; height: 10px;
807
+ background: var(--gold);
808
+ clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
 
 
 
 
809
  }
810
 
811
  .zopa-labels-row {
812
  display: flex;
813
  justify-content: space-between;
814
+ align-items: center;
815
+ margin-top: var(--space-xs);
816
  font-family: var(--font-mono);
817
+ font-size: 0.65rem;
818
+ color: var(--smoke-light);
819
  }
820
 
821
+ /* ── Tension Meter ─────────────────────────────────────────────────────────── */
822
  .tension-section {
823
  display: flex;
824
  align-items: center;
825
+ gap: var(--space-sm);
826
+ background: var(--mahogany);
827
+ border: 1px solid rgba(201,168,76,0.15);
828
+ border-radius: 6px;
829
+ padding: var(--space-sm) var(--space-md);
830
  }
831
 
832
  .tension-label {
833
+ font-family: var(--font-display);
834
+ font-style: italic;
835
+ font-size: 0.8rem;
836
+ color: var(--smoke-light);
 
837
  white-space: nowrap;
838
+ min-width: 52px;
839
  }
840
 
841
  .tension-track {
842
  flex: 1;
843
+ height: 6px;
844
+ border-radius: 3px;
845
+ background: var(--felt);
846
+ border: 1px solid rgba(201,168,76,0.2);
847
  overflow: hidden;
 
848
  }
849
 
850
  .tension-fill {
851
  height: 100%;
852
+ border-radius: 3px;
853
+ background: var(--emerald);
854
+ transition: width 400ms ease, background 400ms ease;
855
  }
856
+ .tension-fill[data-level="medium"] { background: var(--gold); }
857
+ .tension-fill[data-level="high"] { background: var(--scarlet-light); }
858
 
859
  .tension-value {
 
860
  font-family: var(--font-mono);
861
+ font-size: 0.75rem;
862
+ color: var(--smoke-light);
863
+ min-width: 36px;
864
  text-align: right;
 
 
865
  }
866
 
867
+ /* ── Character Canvas ──────────────────────────────────────────────────────── */
 
 
 
 
 
868
  .character-canvas-wrap {
 
 
 
 
 
869
  position: relative;
870
+ display: flex;
871
+ justify-content: center;
872
  }
873
 
874
  #character-canvas {
875
  display: block;
876
+ border-radius: 4px;
877
+ border: 1px solid rgba(201,168,76,0.15);
878
  }
879
 
880
  .character-state-badge {
881
  position: absolute;
882
+ bottom: var(--space-sm);
883
+ right: var(--space-sm);
884
+ background: rgba(44,24,16,0.85);
885
+ border: 1px solid var(--gold);
886
+ border-radius: 3px;
887
+ font-family: var(--font-mono);
888
+ font-size: 0.6rem;
889
+ color: var(--gold);
890
+ padding: 2px 6px;
891
  text-transform: uppercase;
892
+ letter-spacing: 0.06em;
 
 
 
893
  }
894
 
895
+ /* ── Persona Info ──────────────────────────────────────────────────────────── */
896
  .persona-info {
897
  display: flex;
898
  align-items: center;
899
+ gap: var(--space-md);
900
+ background: var(--mahogany);
901
+ border: 1px solid rgba(201,168,76,0.2);
902
+ border-radius: 6px;
903
+ padding: var(--space-sm) var(--space-md);
904
  }
905
 
906
  .persona-avatar {
907
+ width: 40px; height: 40px;
908
+ border-radius: 50%;
909
+ border: 2px solid var(--gold);
910
+ display: flex; align-items: center; justify-content: center;
911
+ font-size: 1.2rem;
912
+ background: var(--felt);
 
913
  flex-shrink: 0;
914
  }
915
 
916
+ .persona-name { font-family: var(--font-display); font-weight: 600; font-size: 0.95rem; color: var(--cream); }
917
+ .persona-desc { font-size: 0.75rem; color: var(--smoke-light); margin-top: 2px; }
 
 
 
918
 
919
+ /* ── ToM Belief Bars ───────────────────────────────────────────────────────── */
920
+ .tom-beliefs { display: flex; flex-direction: column; gap: var(--space-sm); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
 
922
  .belief-row {
923
+ display: grid;
924
+ grid-template-columns: 80px 1fr 36px 10px;
925
  align-items: center;
926
+ gap: var(--space-xs);
927
  }
928
 
929
+ .belief-label { font-size: 0.7rem; color: var(--smoke-light); }
 
 
 
 
 
930
 
931
  .belief-track {
932
+ height: 4px;
933
+ border-radius: 2px;
934
+ background: var(--felt);
935
+ border: 1px solid rgba(201,168,76,0.15);
936
  overflow: hidden;
937
  }
938
 
939
  .belief-fill {
940
  height: 100%;
941
+ border-radius: 2px;
942
+ transition: width 600ms ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
943
  }
944
+ .belief-fill.cooperative { background: var(--emerald); }
945
+ .belief-fill.competitive { background: var(--scarlet-light); }
946
+ .belief-fill.reservation { background: var(--gold); }
947
+ .belief-fill.flexibility { background: var(--parlay-blue); }
948
 
949
+ .belief-pct { font-family: var(--font-mono); font-size: 0.65rem; color: var(--smoke-light); }
 
 
 
 
 
 
950
 
951
+ .belief-confidence { width: 8px; height: 8px; border-radius: 50%; }
952
+ .confidence-high { background: var(--emerald); }
953
+ .confidence-medium { background: var(--gold); }
954
+ .confidence-low { background: var(--smoke); }
955
 
956
+ /* ── Sparklines ────────────────────────────────────────────────────────────── */
957
  .sparkline-wrap {
958
  position: relative;
959
+ overflow: hidden;
960
  }
961
+ .sparkline-canvas { width: 100%; height: 100%; }
 
 
 
 
 
 
962
  .sparkline-labels {
963
+ display: flex; justify-content: space-between; align-items: center;
964
+ font-family: var(--font-mono); font-size: 0.65rem; color: var(--smoke-light);
965
+ margin-top: 4px;
 
 
 
966
  }
967
+ .sparkline-label { font-size: 0.65rem; }
968
 
969
+ /* ── Leaderboard ───────────────────────────────────────────────────────────── */
970
  .leaderboard-table {
971
  width: 100%;
972
  border-collapse: collapse;
973
+ font-size: 0.8rem;
974
  }
 
975
  .leaderboard-table th {
976
+ font-family: var(--font-display);
977
+ font-style: italic;
978
+ font-size: 0.7rem;
979
+ color: var(--smoke-light);
 
 
980
  text-align: left;
981
+ padding: 4px var(--space-sm);
982
+ border-bottom: 1px solid rgba(201,168,76,0.15);
983
+ letter-spacing: 0.02em;
984
  }
985
+ .leaderboard-table th.num { text-align: right; }
 
 
 
 
 
986
  .leaderboard-table td {
987
+ padding: 5px var(--space-sm);
988
+ border-bottom: 1px solid rgba(255,255,255,0.03);
989
+ color: var(--cream);
990
  }
991
+ .leaderboard-table td.num { text-align: right; font-family: var(--font-mono); color: var(--smoke-light); }
992
+ .leaderboard-table tr:hover td { background: rgba(201,168,76,0.04); }
993
+ .leaderboard-table tr.highlight-player td { background: rgba(201,168,76,0.08); }
994
 
995
+ .lb-rank { font-family: var(--font-mono); font-size: 0.7rem; color: var(--smoke-light); }
996
+ .lb-rank.gold { color: #FFD700; }
997
+ .lb-rank.silver { color: #C0C0C0; }
998
+ .lb-rank.bronze { color: #CD7F32; }
999
 
1000
+ /* ── Stat chip ─────────────────────────────────────────────────────────────── */
1001
+ .stat-chip {
 
 
 
 
 
1002
  font-family: var(--font-mono);
1003
+ font-size: 0.65rem;
1004
+ padding: 2px 8px;
1005
+ border-radius: 3px;
1006
+ border: 1px solid;
1007
+ text-transform: uppercase;
1008
+ letter-spacing: 0.04em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
  }
1010
+ .stat-chip.blue { color: var(--parlay-blue); border-color: var(--parlay-blue); }
1011
 
1012
+ /* ── Loading Overlay ───────────────────────────────────────────────────────── */
 
 
 
 
 
 
 
1013
  .loading-overlay {
1014
+ position: fixed; inset: 0;
1015
+ background: rgba(28,43,26,0.75);
 
 
 
 
 
1016
  backdrop-filter: blur(2px);
1017
+ display: flex; align-items: center; justify-content: center;
1018
+ z-index: 900;
1019
  }
1020
 
 
 
1021
  .loading-card {
1022
+ background: var(--mahogany);
1023
+ border: 1px solid rgba(201,168,76,0.4);
1024
+ border-radius: 8px;
1025
+ padding: var(--space-xl) var(--space-xl);
1026
+ text-align: center;
1027
+ display: flex; flex-direction: column; align-items: center; gap: var(--space-md);
1028
+ box-shadow: 0 8px 32px var(--shadow);
 
 
1029
  }
1030
 
1031
  .spinner {
1032
+ width: 36px; height: 36px;
1033
+ border: 3px solid rgba(201,168,76,0.2);
1034
+ border-top-color: var(--gold);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1035
  border-radius: 50%;
1036
+ animation: spin 800ms linear infinite;
 
1037
  }
1038
 
1039
+ .loading-text { font-family: var(--font-display); font-style: italic; color: var(--smoke-light); }
 
 
 
 
 
 
1040
 
1041
+ /* ── Onboarding Overlay (3-step wizard) ──────────────────────────────────────
1042
+ Step 2 and 3 are *after* step 1 in the DOM. With the same z-index, they
1043
+ stack on top and can steal pointer events. Keep inactive at 800; lift any
1044
+ step that is start-active, active, or exiting to 820 so the visible step
1045
+ is always the topmost overlay and receives clicks. */
1046
+ .onboarding-overlay {
1047
+ position: fixed; inset: 0;
1048
+ background: var(--felt);
1049
  display: flex;
1050
  align-items: center;
1051
  justify-content: center;
1052
+ z-index: 800;
1053
+ transform: translateX(100%);
1054
+ transition: transform 300ms ease;
1055
+ pointer-events: none;
1056
  }
1057
 
1058
+ .onboarding-overlay.active,
1059
+ .onboarding-overlay.start-active,
1060
+ .onboarding-overlay.exiting {
1061
+ pointer-events: auto;
1062
+ z-index: 820;
 
 
 
 
 
1063
  }
1064
 
1065
+ .onboarding-overlay.active {
1066
+ transform: translateX(0);
 
1067
  }
1068
 
1069
+ .onboarding-overlay.exiting {
1070
+ transform: translateX(-100%);
 
1071
  }
1072
 
1073
+ .onboarding-overlay.start-active {
1074
+ /* First step starts in view */
1075
+ transform: translateX(0);
 
1076
  }
1077
 
1078
+ /* Inactive steps are fully removed from the layout; keeps them from ever
1079
+ intercepting clicks (reliable on all browsers; z-index + pointer-events
1080
+ alone was not enough in some cases). */
1081
+ .onboarding-overlay:not(.active):not(.start-active):not(.exiting) {
1082
+ display: none !important;
1083
+ visibility: hidden;
1084
  }
1085
 
1086
+ .onboarding-card {
1087
+ background: var(--mahogany);
1088
+ border: 1px solid rgba(201,168,76,0.35);
1089
+ border-radius: 8px;
1090
+ padding: var(--space-xl);
1091
+ width: 460px;
1092
+ max-width: 95vw;
1093
+ box-shadow: 0 16px 64px var(--shadow);
1094
+ position: relative;
1095
  }
1096
 
1097
+ .onboarding-card.wide {
1098
+ width: 860px;
1099
+ max-width: 95vw;
 
 
 
1100
  }
1101
 
1102
+ .onboarding-step-num {
1103
+ font-family: var(--font-mono);
1104
+ font-size: 0.65rem;
1105
+ color: var(--smoke);
1106
+ text-transform: uppercase;
1107
+ letter-spacing: 0.1em;
1108
+ margin-bottom: var(--space-sm);
1109
  }
1110
 
1111
+ .onboarding-headline {
1112
+ font-family: var(--font-display);
1113
+ font-size: 2.25rem;
1114
  font-weight: 600;
1115
+ color: var(--cream);
1116
+ margin-bottom: var(--space-sm);
1117
+ line-height: 1.2;
1118
  }
1119
 
1120
+ .onboarding-sub {
1121
+ font-family: var(--font-body);
1122
+ font-size: 1rem;
1123
+ color: var(--smoke-light);
1124
+ margin-bottom: var(--space-xl);
1125
+ line-height: 1.5;
 
 
 
1126
  }
1127
 
1128
+ /* Name input */
1129
+ .onboarding-name-input {
1130
+ width: 100%;
1131
+ background: var(--felt);
1132
+ border: 1px solid rgba(201,168,76,0.4);
1133
+ border-radius: 4px;
1134
+ color: var(--cream);
1135
+ font-family: var(--font-display);
1136
+ font-size: 1.2rem;
1137
+ padding: var(--space-md);
1138
  outline: none;
1139
+ margin-bottom: var(--space-lg);
1140
+ transition: border-color var(--transition);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
  }
1142
+ .onboarding-name-input::placeholder { color: var(--smoke); }
1143
+ .onboarding-name-input:focus { border-color: var(--gold); }
1144
 
1145
+ .onboarding-error {
1146
+ color: var(--scarlet-light);
1147
+ font-family: var(--font-mono);
1148
+ font-size: 0.75rem;
1149
+ margin-bottom: var(--space-sm);
1150
+ min-height: 1.2em;
 
 
 
 
 
 
 
 
 
1151
  }
1152
 
1153
+ .onboarding-footer {
1154
+ margin-top: var(--space-lg);
 
 
 
1155
  display: flex;
1156
+ justify-content: flex-end;
1157
+ gap: var(--space-sm);
 
 
 
1158
  }
1159
 
1160
+ /* Scenario dossier cards */
1161
+ .scenario-dossier-grid {
1162
+ display: grid;
1163
+ grid-template-columns: repeat(5, 1fr);
1164
+ gap: var(--space-md);
1165
+ margin-bottom: var(--space-lg);
1166
  }
1167
 
1168
+ @media (max-width: 860px) {
1169
+ .scenario-dossier-grid { grid-template-columns: repeat(3, 1fr); }
 
 
 
 
 
 
 
1170
  }
1171
 
1172
+ .scenario-dossier {
1173
+ background: var(--cream-dark);
1174
+ border: 2px solid rgba(44,24,16,0.4);
1175
+ border-radius: 4px;
1176
+ padding: var(--space-md);
1177
+ cursor: pointer;
1178
+ position: relative;
1179
+ transition: all var(--transition);
1180
+ color: var(--ink);
1181
 
1182
+ /* Manila envelope fold at top */
1183
+ border-top: none;
 
 
 
 
 
 
 
 
 
 
 
1184
  }
1185
 
1186
+ .scenario-dossier::before {
1187
+ content: "";
1188
+ position: absolute;
1189
+ top: -8px; left: 0; right: 0;
1190
+ height: 8px;
1191
+ background: var(--cream);
1192
+ border: 2px solid rgba(44,24,16,0.3);
1193
+ border-bottom: none;
1194
+ border-radius: 3px 3px 0 0;
 
1195
  }
1196
 
1197
+ .scenario-dossier:hover { border-color: var(--gold); transform: translateY(-2px); }
1198
+ .scenario-dossier.selected {
1199
+ border-color: var(--gold);
1200
+ box-shadow: 0 0 0 2px var(--gold), 0 4px 12px var(--shadow);
1201
  }
1202
 
1203
+ .dossier-case {
1204
+ font-family: var(--font-mono);
1205
+ font-size: 0.55rem;
1206
+ color: var(--smoke);
1207
  text-transform: uppercase;
1208
+ letter-spacing: 0.08em;
1209
+ margin-bottom: var(--space-xs);
1210
  }
1211
 
1212
+ .dossier-title {
1213
+ font-family: var(--font-display);
1214
+ font-size: 0.85rem;
1215
  font-weight: 600;
1216
+ color: var(--ink);
1217
+ margin-bottom: var(--space-xs);
1218
+ line-height: 1.2;
1219
  }
1220
 
1221
+ .dossier-desc {
1222
+ font-size: 0.7rem;
1223
+ color: var(--smoke);
1224
+ line-height: 1.3;
1225
+ margin-bottom: var(--space-sm);
1226
  }
1227
 
1228
+ .dossier-zopa {
1229
+ font-family: var(--font-mono);
1230
+ font-size: 0.6rem;
1231
+ color: var(--emerald);
1232
+ background: rgba(26,92,42,0.1);
1233
+ border-radius: 3px;
1234
+ padding: 2px 5px;
1235
+ border: 1px solid rgba(26,92,42,0.3);
1236
  }
1237
 
1238
+ .dossier-difficulty {
1239
+ position: absolute;
1240
+ top: var(--space-sm);
1241
+ right: var(--space-sm);
 
 
 
 
 
 
 
 
 
1242
  font-family: var(--font-mono);
1243
+ font-size: 0.55rem;
1244
+ color: var(--smoke);
1245
  }
1246
 
1247
+ /* Persona cards (step 3) */
1248
+ .persona-cards-grid {
1249
+ display: grid;
1250
+ grid-template-columns: repeat(5, 1fr);
1251
+ gap: var(--space-md);
1252
+ margin-bottom: var(--space-lg);
1253
  }
1254
 
1255
+ @media (max-width: 860px) {
1256
+ .persona-cards-grid { grid-template-columns: repeat(3, 1fr); }
 
 
 
 
 
 
 
1257
  }
1258
 
1259
+ .persona-card-option {
1260
+ background: var(--felt-light);
1261
+ border: 2px solid rgba(201,168,76,0.2);
1262
+ border-radius: 6px;
1263
+ padding: var(--space-sm);
1264
+ cursor: pointer;
1265
+ transition: all var(--transition);
1266
+ text-align: center;
1267
+ }
1268
+ .persona-card-option:hover { border-color: var(--gold); transform: translateY(-2px); }
1269
+ .persona-card-option.selected {
1270
+ border-color: var(--gold);
1271
+ box-shadow: 0 0 0 2px var(--gold), 0 4px 12px var(--shadow);
1272
  }
1273
 
1274
+ .persona-card-canvas-wrap {
1275
+ width: 100%;
1276
+ aspect-ratio: 280/200;
1277
+ margin-bottom: var(--space-sm);
1278
  border-radius: 4px;
1279
  overflow: hidden;
1280
+ background: var(--felt);
1281
+ border: 1px solid rgba(201,168,76,0.15);
1282
  }
1283
 
1284
+ .persona-card-canvas-wrap canvas {
1285
+ width: 100%;
1286
  height: 100%;
1287
+ display: block;
 
 
 
1288
  }
1289
 
1290
+ .persona-card-name {
1291
+ font-family: var(--font-display);
1292
+ font-size: 0.8rem;
 
 
 
 
 
1293
  font-weight: 600;
1294
+ color: var(--cream);
1295
+ margin-bottom: 4px;
1296
  }
1297
 
1298
+ .persona-card-symbol {
1299
+ font-family: var(--font-mono);
1300
+ font-size: 0.65rem;
1301
+ color: var(--gold);
1302
+ margin-bottom: var(--space-xs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1303
  }
1304
 
1305
+ .persona-trait-bars {
1306
+ display: flex;
1307
+ flex-direction: column;
1308
+ gap: 3px;
1309
+ margin-top: var(--space-xs);
1310
  }
1311
 
1312
+ .persona-trait-row {
 
 
 
 
1313
  display: flex;
 
1314
  align-items: center;
1315
+ gap: 4px;
1316
  }
1317
 
1318
+ .persona-trait-label {
1319
+ font-family: var(--font-mono);
1320
+ font-size: 0.5rem;
1321
+ color: var(--smoke);
1322
+ width: 32px;
1323
+ flex-shrink: 0;
1324
+ text-align: right;
1325
  }
1326
 
1327
+ .persona-trait-bar {
1328
+ flex: 1;
1329
+ height: 3px;
1330
+ background: var(--felt);
1331
+ border-radius: 2px;
1332
+ overflow: hidden;
 
1333
  }
1334
 
1335
+ .persona-trait-fill {
1336
+ height: 100%;
1337
+ background: var(--gold);
1338
+ border-radius: 2px;
1339
+ transition: width 600ms ease;
1340
  }
1341
 
1342
+ /* ── Animations ────────────────────────────────────────────────────────────── */
1343
+ @keyframes spin { to { transform: rotate(360deg); } }
1344
+ @keyframes slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1345
+ @keyframes slide-up { from { transform: translateY(6px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
1346
+ @keyframes dot-bounce {
1347
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
1348
+ 40% { transform: scale(1.0); opacity: 1.0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1349
  }
1350
+
1351
+ /* ── Global Error ──────────────────────────────────────────────────────────── */
1352
+ #global-error { font-family: var(--font-mono); font-size: 0.75rem; }
docker-compose.yml CHANGED
@@ -1,41 +1,46 @@
1
  version: "3.9"
2
 
3
  services:
4
- game:
 
5
  build:
6
  context: .
7
- dockerfile: Dockerfile.game
8
  ports:
9
- - "8000:8000"
10
  environment:
11
- - GOOGLE_API_KEY=${GOOGLE_API_KEY}
12
  - MAX_TURNS_PER_EPISODE=20
13
  - TOP_PLAYER_THRESHOLD=0.60
14
  volumes:
15
  - ./parlay.db:/app/parlay.db
16
  restart: unless-stopped
17
 
18
- env:
 
19
  build:
20
  context: .
21
- dockerfile: Dockerfile.env
 
22
  ports:
23
- - "8001:8001"
24
  environment:
25
- - GOOGLE_API_KEY=${GOOGLE_API_KEY}
26
  depends_on:
27
- - game
28
  restart: unless-stopped
29
 
30
- mcp:
 
31
  build:
32
  context: .
33
- dockerfile: Dockerfile.game
34
- command: python -m mcp_server.server sse
35
- ports:
36
- - "8002:8002"
37
  environment:
38
- - GOOGLE_API_KEY=${GOOGLE_API_KEY}
39
- depends_on:
40
- - game
41
- restart: unless-stopped
 
 
 
1
  version: "3.9"
2
 
3
  services:
4
+ # Combined game + OpenEnv server (matches the single HF Spaces Dockerfile)
5
+ parlay:
6
  build:
7
  context: .
8
+ dockerfile: Dockerfile
9
  ports:
10
+ - "7860:7860"
11
  environment:
12
+ - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
13
  - MAX_TURNS_PER_EPISODE=20
14
  - TOP_PLAYER_THRESHOLD=0.60
15
  volumes:
16
  - ./parlay.db:/app/parlay.db
17
  restart: unless-stopped
18
 
19
+ # MCP server β€” shares the same image but runs the MCP entry-point
20
+ mcp:
21
  build:
22
  context: .
23
+ dockerfile: Dockerfile
24
+ command: python -m mcp_server.server sse
25
  ports:
26
+ - "8002:8002"
27
  environment:
28
+ - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
29
  depends_on:
30
+ - parlay
31
  restart: unless-stopped
32
 
33
+ # Training is intentionally separate and never deployed to HF Spaces
34
+ train:
35
  build:
36
  context: .
37
+ dockerfile: Dockerfile.train
38
+ profiles:
39
+ - training
 
40
  environment:
41
+ - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
42
+ - HF_TOKEN=${HF_TOKEN:-}
43
+ - HF_REPO_ID=${HF_REPO_ID:-}
44
+ volumes:
45
+ - ./data:/app/data
46
+ - ./models:/app/models
main.py CHANGED
@@ -13,7 +13,7 @@ from pathlib import Path
13
  from fastapi import FastAPI
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.staticfiles import StaticFiles
16
- from fastapi.responses import FileResponse
17
 
18
  from parlay_env.server import router as env_router
19
  from dashboard.api import router as dashboard_router
@@ -68,16 +68,54 @@ if static_dir.exists():
68
  @app.get("/", include_in_schema=False)
69
  async def serve_index() -> FileResponse:
70
  """Serve the main game dashboard."""
71
- return FileResponse("dashboard/index.html")
 
 
 
72
 
73
 
74
  @app.get("/train", include_in_schema=False)
75
  async def serve_train() -> FileResponse:
76
  """Serve the training dashboard."""
77
- return FileResponse("dashboard/train.html")
 
 
 
 
 
 
 
 
 
78
 
79
 
80
  @app.get("/health")
81
  async def health() -> dict:
82
- """Global health check."""
83
- return {"status": "ok", "service": "parlay", "version": "1.0.0"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from fastapi import FastAPI
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.staticfiles import StaticFiles
16
+ from fastapi.responses import FileResponse, Response
17
 
18
  from parlay_env.server import router as env_router
19
  from dashboard.api import router as dashboard_router
 
68
  @app.get("/", include_in_schema=False)
69
  async def serve_index() -> FileResponse:
70
  """Serve the main game dashboard."""
71
+ return FileResponse(
72
+ "dashboard/index.html",
73
+ headers={"Cache-Control": "no-cache, must-revalidate"},
74
+ )
75
 
76
 
77
  @app.get("/train", include_in_schema=False)
78
  async def serve_train() -> FileResponse:
79
  """Serve the training dashboard."""
80
+ return FileResponse(
81
+ "dashboard/train.html",
82
+ headers={"Cache-Control": "no-cache, must-revalidate"},
83
+ )
84
+
85
+
86
+ @app.get("/favicon.ico", include_in_schema=False)
87
+ async def favicon() -> Response:
88
+ """Browsers request this by default; avoid noisy 404s in logs."""
89
+ return Response(status_code=204)
90
 
91
 
92
  @app.get("/health")
93
  async def health() -> dict:
94
+ """
95
+ Global health check.
96
+
97
+ Returns:
98
+ status: "ok" when server is running.
99
+ db: "ok" if parlay.db is reachable, "error" otherwise.
100
+ gemini: "configured" when GOOGLE_API_KEY is set, "mock" otherwise.
101
+ version: Application version string.
102
+ """
103
+ import os
104
+ import aiosqlite
105
+
106
+ db_status = "error"
107
+ try:
108
+ async with aiosqlite.connect("parlay.db") as db:
109
+ await db.execute("SELECT 1")
110
+ db_status = "ok"
111
+ except Exception as exc:
112
+ logger.warning(f"Health check DB probe failed: {exc}")
113
+
114
+ gemini_status = "configured" if os.environ.get("GOOGLE_API_KEY", "").strip() else "mock"
115
+
116
+ return {
117
+ "status": "ok",
118
+ "db": db_status,
119
+ "gemini": gemini_status,
120
+ "version": "1.0.0",
121
+ }
parlay_env/server.py CHANGED
@@ -33,6 +33,34 @@ from .models import (
33
  logger = logging.getLogger(__name__)
34
  router = APIRouter(prefix="/env", tags=["OpenEnv"])
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  # In-memory session store (replaced by Redis in prod)
37
  _sessions: dict[str, ParlayState] = {}
38
 
@@ -171,8 +199,12 @@ async def env_websocket(websocket: WebSocket) -> None:
171
  ) as exc:
172
  result = {"error": str(exc)}
173
  except Exception:
174
- logger.exception("Unhandled error in env WebSocket")
175
- result = {"error": "Internal server error"}
 
 
 
 
176
 
177
  await websocket.send_json(result)
178
  except WebSocketDisconnect:
 
33
  logger = logging.getLogger(__name__)
34
  router = APIRouter(prefix="/env", tags=["OpenEnv"])
35
 
36
+ # Fallback observation returned when any server-side error occurs so the
37
+ # WebSocket never crashes on an LLM or state-machine failure.
38
+ _FALLBACK_BELIEF = BeliefState(
39
+ est_budget=0.0,
40
+ est_walk_away=0.0,
41
+ est_urgency=0.5,
42
+ est_has_alternative=False,
43
+ confidence=0.1,
44
+ )
45
+ FALLBACK_OBSERVATION = ParlayObservation(
46
+ step_count=0,
47
+ episode_done=False,
48
+ current_offer=0.0,
49
+ opponent_offer=0.0,
50
+ zopa_lower=0.0,
51
+ zopa_upper=0.0,
52
+ nash_point=0.0,
53
+ tension_score=0.0,
54
+ belief_state=_FALLBACK_BELIEF,
55
+ last_utterance="[Connection issue β€” AI is thinking]",
56
+ available_moves=list(TacticalMove),
57
+ credibility_points=100,
58
+ reward=0.0,
59
+ cumulative_reward=0.0,
60
+ drift_event=None,
61
+ act=1,
62
+ )
63
+
64
  # In-memory session store (replaced by Redis in prod)
65
  _sessions: dict[str, ParlayState] = {}
66
 
 
199
  ) as exc:
200
  result = {"error": str(exc)}
201
  except Exception:
202
+ logger.exception("Unhandled error in env WebSocket β€” returning fallback observation")
203
+ result = {
204
+ "observation": FALLBACK_OBSERVATION.model_dump(),
205
+ "done": False,
206
+ "_fallback": True,
207
+ }
208
 
209
  await websocket.send_json(result)
210
  except WebSocketDisconnect:
scripts/Find-Python311.ps1 ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Find-Python311.ps1 β€” dot-source from setup scripts
2
+ # Returns $null or a hashtable: Exe, PreArgs, Ver, Name
3
+
4
+ function Get-Python311 {
5
+ $candidateDefs = [System.Collections.Generic.List[object]]::new()
6
+ $null = $candidateDefs.Add(@{ Name = "py -3.11 (Windows launcher)"; Exe = "py"; PreArgs = @("-3.11") })
7
+ $null = $candidateDefs.Add(@{ Name = "py -3.11-64 (launcher tag)"; Exe = "py"; PreArgs = @("-3.11-64") })
8
+ $null = $candidateDefs.Add(@{ Name = "python3.11"; Exe = "python3.11"; PreArgs = @() })
9
+ $null = $candidateDefs.Add(@{ Name = "python3"; Exe = "python3"; PreArgs = @() })
10
+ $null = $candidateDefs.Add(@{ Name = "python"; Exe = "python"; PreArgs = @() })
11
+ $local311 = Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"
12
+ $null = $candidateDefs.Add(@{ Name = "per-user: $local311"; Exe = $local311; PreArgs = @() })
13
+ $allUsers = "${env:ProgramFiles}\Python311\python.exe"
14
+ $null = $candidateDefs.Add(@{ Name = "all-users: $allUsers"; Exe = $allUsers; PreArgs = @() })
15
+
16
+ function Test-One {
17
+ param([string] $Exe, [string[]] $PreArgs)
18
+ if ($Exe -match '^(?:[A-Za-z]:|\\\\)') {
19
+ if (-not (Test-Path -LiteralPath $Exe)) { return $null }
20
+ } else {
21
+ if (-not (Get-Command $Exe -ErrorAction SilentlyContinue)) { return $null }
22
+ }
23
+ try {
24
+ $verOut = & $Exe @($PreArgs + @("--version")) 2>&1 | Out-String
25
+ } catch {
26
+ return $null
27
+ }
28
+ if ($verOut -match "3\.11\.\d+") {
29
+ return @{ Exe = $Exe; PreArgs = $PreArgs; Ver = $verOut.Trim() }
30
+ }
31
+ return $null
32
+ }
33
+
34
+ foreach ($c in $candidateDefs) {
35
+ $r = Test-One -Exe $c.Exe -PreArgs $c.PreArgs
36
+ if ($r) {
37
+ $r["Name"] = $c.Name
38
+ return $r
39
+ }
40
+ }
41
+ return $null
42
+ }
43
+
44
+ function Get-Python311Diagnostics {
45
+ $lines = [System.Collections.Generic.List[string]]::new()
46
+ $candidateDefs = @(
47
+ @{ Name = "py -3.11"; Exe = "py"; PreArgs = @("-3.11") }
48
+ @{ Name = "python"; Exe = "python"; PreArgs = @() }
49
+ @{ Name = "python3"; Exe = "python3"; PreArgs = @() }
50
+ )
51
+ $local311 = Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"
52
+ $candidateDefs += @{ Name = "per-user"; Exe = $local311; PreArgs = @() }
53
+
54
+ foreach ($c in $candidateDefs) {
55
+ if ($c.Exe -match '^(?:[A-Za-z]:|\\\\)') {
56
+ if (-not (Test-Path -LiteralPath $c.Exe)) { continue }
57
+ } elseif (-not (Get-Command $c.Exe -ErrorAction SilentlyContinue)) { continue }
58
+ try {
59
+ $v = & $c.Exe @($c.PreArgs + @("--version")) 2>&1 | Out-String
60
+ $null = $lines.Add("$($c.Name) => $($v.Trim())")
61
+ } catch { }
62
+ }
63
+ return $lines
64
+ }
scripts/init_db.py CHANGED
@@ -17,6 +17,20 @@ logger = logging.getLogger(__name__)
17
  DB_PATH = "parlay.db"
18
 
19
  SCHEMA = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  CREATE TABLE IF NOT EXISTS leaderboard (
21
  id INTEGER PRIMARY KEY AUTOINCREMENT,
22
  player_name TEXT NOT NULL,
 
17
  DB_PATH = "parlay.db"
18
 
19
  SCHEMA = """
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ session_id TEXT NOT NULL UNIQUE,
23
+ player_name TEXT NOT NULL,
24
+ scenario_id TEXT NOT NULL,
25
+ persona TEXT NOT NULL,
26
+ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
27
+ completed_at DATETIME,
28
+ status TEXT DEFAULT 'active'
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_sessions_session_id
32
+ ON sessions(session_id);
33
+
34
  CREATE TABLE IF NOT EXISTS leaderboard (
35
  id INTEGER PRIMARY KEY AUTOINCREMENT,
36
  player_name TEXT NOT NULL,
scripts/setup.ps1 CHANGED
@@ -1,40 +1,62 @@
1
  # scripts/setup.ps1
2
  # Run from project root: .\scripts\setup.ps1
3
- # Requires Python 3.11 on PATH and PowerShell 5+
4
 
5
  $ErrorActionPreference = "Stop"
6
 
 
 
 
7
  Write-Host "Setting up Parlay..." -ForegroundColor Cyan
8
 
9
- # Check Python 3.11
10
- try {
11
- $pyver = & python --version 2>&1
12
- if ($pyver -notmatch "3\.11") {
13
- Write-Host "Python 3.11 required. Found: $pyver" -ForegroundColor Red
14
- Write-Host "Download from https://www.python.org/downloads/release/python-3110/"
15
- exit 1
 
 
 
 
16
  }
17
- } catch {
18
- Write-Host "Python not found on PATH." -ForegroundColor Red
19
  exit 1
20
  }
21
 
22
- # Game venv
 
 
 
23
  Write-Host "Creating game venv..." -ForegroundColor Yellow
24
- python -m venv venv
 
 
25
  .\venv\Scripts\pip install --upgrade pip --quiet
26
  .\venv\Scripts\pip install -r requirements.txt --quiet
27
 
28
- # .env
29
  if (-not (Test-Path ".env")) {
30
  Copy-Item ".env.example" ".env"
31
  Write-Host "Created .env from .env.example" -ForegroundColor Green
32
  }
33
 
34
- # DB init
35
  .\venv\Scripts\python scripts\init_db.py
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  Write-Host ""
38
  Write-Host "Game venv ready." -ForegroundColor Green
39
- Write-Host "To start the server: .\scripts\run.ps1"
40
- Write-Host "For training stack: .\scripts\setup_train.ps1"
 
 
1
  # scripts/setup.ps1
2
  # Run from project root: .\scripts\setup.ps1
3
+ # Robust Python 3.11 detection β€” see Find-Python311.ps1
4
 
5
  $ErrorActionPreference = "Stop"
6
 
7
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
8
+ . (Join-Path $scriptDir "Find-Python311.ps1")
9
+
10
  Write-Host "Setting up Parlay..." -ForegroundColor Cyan
11
 
12
+ $chosen = Get-Python311
13
+
14
+ if (-not $chosen) {
15
+ Write-Host ""
16
+ Write-Host "Python 3.11 not found. Parlay needs 3.11 (see .cursorrules)." -ForegroundColor Red
17
+ $diag = Get-Python311Diagnostics
18
+ if ($diag.Count -gt 0) {
19
+ Write-Host "These interpreters were found but are not 3.11:" -ForegroundColor Yellow
20
+ foreach ($line in $diag) { Write-Host " - $line" -ForegroundColor Gray }
21
+ } else {
22
+ Write-Host "No Python was found in PATH. Add Python to PATH or install 3.11." -ForegroundColor Yellow
23
  }
24
+ Write-Host "Install 3.11, then re-run this script:" -ForegroundColor Yellow
25
+ Write-Host " winget install Python.Python.3.11" -ForegroundColor Cyan
26
  exit 1
27
  }
28
 
29
+ Write-Host " Found Python 3.11: $($chosen.Name)" -ForegroundColor Green
30
+ Write-Host " Version line: $($chosen.Ver)" -ForegroundColor Gray
31
+ Write-Host "Using: $($chosen.Exe) $($chosen.PreArgs -join ' ')" -ForegroundColor Cyan
32
+
33
  Write-Host "Creating game venv..." -ForegroundColor Yellow
34
+ & $chosen.Exe @($chosen.PreArgs + @("-m", "venv", "venv"))
35
+ if (-not (Test-Path ".\venv\Scripts\python.exe")) { throw "venv was not created (python -m venv failed)" }
36
+
37
  .\venv\Scripts\pip install --upgrade pip --quiet
38
  .\venv\Scripts\pip install -r requirements.txt --quiet
39
 
 
40
  if (-not (Test-Path ".env")) {
41
  Copy-Item ".env.example" ".env"
42
  Write-Host "Created .env from .env.example" -ForegroundColor Green
43
  }
44
 
 
45
  .\venv\Scripts\python scripts\init_db.py
46
 
47
+ Write-Host "Verifying installed packages..." -ForegroundColor Yellow
48
+ try {
49
+ $checkCmd = "import fastapi, uvicorn, pydantic, aiosqlite; from google import genai; import fastmcp; print('All game deps OK')"
50
+ & .\venv\Scripts\python -c $checkCmd
51
+ Write-Host " All game deps OK" -ForegroundColor Green
52
+ } catch {
53
+ Write-Host " Dependency check failed - re-installing:" -ForegroundColor Red
54
+ Write-Host " .\venv\Scripts\pip install -r requirements.txt --force-reinstall" -ForegroundColor Yellow
55
+ .\venv\Scripts\pip install -r requirements.txt --force-reinstall --quiet
56
+ }
57
+
58
  Write-Host ""
59
  Write-Host "Game venv ready." -ForegroundColor Green
60
+ Write-Host "Run the server: .\scripts\run.ps1"
61
+ Write-Host "Run without an API key: make run (mock mode enabled automatically)"
62
+ Write-Host "Training stack: .\scripts\setup_train.ps1"
scripts/setup_train.ps1 CHANGED
@@ -1,7 +1,43 @@
 
 
 
 
1
  $ErrorActionPreference = "Stop"
 
 
 
 
2
  Write-Host "Setting up training venv (this installs PyTorch ~3GB)..." -ForegroundColor Cyan
3
- python -m venv venv-train
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  .\venv-train\Scripts\pip install --upgrade pip --quiet
5
  .\venv-train\Scripts\pip install -r requirements.txt --quiet
6
  .\venv-train\Scripts\pip install -r requirements-train.txt
7
- Write-Host "Training venv ready. Run: .\scripts\train_data.ps1" -ForegroundColor Green
 
 
 
 
 
 
1
+ # scripts/setup_train.ps1
2
+ # Run from project root: .\scripts\setup_train.ps1
3
+ # Installs the training stack (~3 GB: PyTorch + HF TRL).
4
+
5
  $ErrorActionPreference = "Stop"
6
+
7
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
8
+ . (Join-Path $scriptDir "Find-Python311.ps1")
9
+
10
  Write-Host "Setting up training venv (this installs PyTorch ~3GB)..." -ForegroundColor Cyan
11
+
12
+ $chosen = Get-Python311
13
+
14
+ if (-not $chosen) {
15
+ Write-Host ""
16
+ Write-Host "Python 3.11 not found. Parlay needs 3.11." -ForegroundColor Red
17
+ $diag = Get-Python311Diagnostics
18
+ if ($diag.Count -gt 0) {
19
+ Write-Host "These interpreters were found but are not 3.11:" -ForegroundColor Yellow
20
+ foreach ($line in $diag) { Write-Host " - $line" -ForegroundColor Gray }
21
+ } else {
22
+ Write-Host "No Python was found in PATH." -ForegroundColor Yellow
23
+ }
24
+ Write-Host " winget install Python.Python.3.11" -ForegroundColor Cyan
25
+ exit 1
26
+ }
27
+
28
+ Write-Host " Found Python 3.11: $($chosen.Name)" -ForegroundColor Green
29
+ Write-Host "Using: $($chosen.Exe) $($chosen.PreArgs -join ' ')" -ForegroundColor Cyan
30
+
31
+ Write-Host "Creating training venv..." -ForegroundColor Yellow
32
+ & $chosen.Exe @($chosen.PreArgs + @("-m", "venv", "venv-train"))
33
+ if (-not (Test-Path ".\venv-train\Scripts\python.exe")) { throw "venv-train was not created" }
34
+
35
  .\venv-train\Scripts\pip install --upgrade pip --quiet
36
  .\venv-train\Scripts\pip install -r requirements.txt --quiet
37
  .\venv-train\Scripts\pip install -r requirements-train.txt
38
+
39
+ Write-Host ""
40
+ Write-Host "Training venv ready." -ForegroundColor Green
41
+ Write-Host "Generate data: .\scripts\train_data.ps1"
42
+ Write-Host "SFT warmup: .\scripts\train_sft.ps1"
43
+ Write-Host "GRPO training: .\scripts\train_grpo.ps1"
scripts/start.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Start both the OpenEnv WebSocket server (internal port 8001)
3
+ # and the FastAPI dashboard on port 7860 (what HF Spaces exposes).
4
+ set -e
5
+
6
+ echo "Starting Parlay OpenEnv server on port 8001..."
7
+ python -m parlay_env.server &
8
+ ENV_PID=$!
9
+
10
+ echo "Starting Parlay dashboard on port 7860..."
11
+ uvicorn main:app --host 0.0.0.0 --port 7860
12
+
13
+ # If uvicorn exits, clean up the env process
14
+ kill $ENV_PID 2>/dev/null || true
tests/test_keyless.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Keyless test suite β€” runs with zero API keys.
3
+ Tests the full stack in mock mode: game theory, grader, DB, and HTTP endpoints.
4
+
5
+ Usage:
6
+ pytest tests/test_keyless.py -v
7
+ make test-keyless
8
+ """
9
+ import asyncio
10
+ import os
11
+ import sqlite3
12
+
13
+ import pytest
14
+ import pytest_asyncio
15
+ from httpx import AsyncClient, ASGITransport
16
+
17
+ # Ensure mock mode β€” remove any key that might be set in the environment
18
+ os.environ.pop("GOOGLE_API_KEY", None)
19
+
20
+ # ── App import after env is sanitised ────────────────────────────────────────
21
+ from main import app
22
+ from parlay_env.game_theory import (
23
+ compute_zopa,
24
+ compute_nash_bargaining_solution,
25
+ compute_shapley_value,
26
+ )
27
+ from parlay_env.grader import compute_step_reward, compute_terminal_reward
28
+ from parlay_env.reward import OMEGA
29
+ from parlay_env.models import (
30
+ BeliefState, HiddenState, ParlayAction, ParlayState, PersonaType,
31
+ )
32
+
33
+
34
+ # ── Fixtures ─────────────────────────────────────────────────────────────────
35
+
36
+ @pytest.fixture
37
+ def hidden() -> HiddenState:
38
+ return HiddenState(
39
+ budget_ceiling=165_000.0,
40
+ walk_away_price=125_000.0,
41
+ urgency_score=0.5,
42
+ has_alternative=False,
43
+ persona_drifted=False,
44
+ )
45
+
46
+
47
+ @pytest.fixture
48
+ def belief() -> BeliefState:
49
+ return BeliefState(
50
+ est_budget=140_000.0,
51
+ est_walk_away=130_000.0,
52
+ est_urgency=0.5,
53
+ est_has_alternative=False,
54
+ confidence=0.5,
55
+ )
56
+
57
+
58
+ @pytest.fixture
59
+ def parlay_state(hidden, belief) -> ParlayState:
60
+ return ParlayState(
61
+ session_id="test-session",
62
+ scenario_id="saas_enterprise",
63
+ persona=PersonaType.SHARK,
64
+ act=1,
65
+ step_count=0,
66
+ cumulative_reward=0.0,
67
+ hidden_state=hidden,
68
+ belief_history=[belief],
69
+ offer_history=[],
70
+ drift_events_fired=0,
71
+ episode_done=False,
72
+ credibility_points=100,
73
+ )
74
+
75
+
76
+ @pytest_asyncio.fixture
77
+ async def client():
78
+ transport = ASGITransport(app=app)
79
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
80
+ yield ac
81
+
82
+
83
+ # ── Part 1: HTTP endpoints ────────────────────────────────────────────────────
84
+
85
+ class TestHealthEndpoint:
86
+ def test_health_endpoint(self):
87
+ """GET /health returns 200 with status ok."""
88
+ async def _run():
89
+ transport = ASGITransport(app=app)
90
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
91
+ resp = await ac.get("/health")
92
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
93
+ data = resp.json()
94
+ assert data["status"] == "ok", f"Expected ok, got {data['status']}"
95
+ assert "db" in data, f"Missing 'db' key: {data}"
96
+ assert "gemini" in data, f"Missing 'gemini' key: {data}"
97
+ assert data["gemini"] == "mock", f"Expected mock mode, got {data['gemini']}"
98
+
99
+ asyncio.get_event_loop().run_until_complete(_run())
100
+
101
+
102
+ class TestListScenarios:
103
+ def test_list_scenarios(self):
104
+ """GET /api/scenarios returns exactly 5 scenarios."""
105
+ async def _run():
106
+ transport = ASGITransport(app=app)
107
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
108
+ resp = await ac.get("/api/scenarios")
109
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
110
+ data = resp.json()
111
+ scenarios = data.get("scenarios", [])
112
+ assert len(scenarios) == 5, f"Expected 5 scenarios, got {len(scenarios)}"
113
+
114
+ asyncio.get_event_loop().run_until_complete(_run())
115
+
116
+
117
+ class TestListPersonas:
118
+ def test_list_personas(self):
119
+ """GET /api/personas returns exactly 5 personas."""
120
+ async def _run():
121
+ transport = ASGITransport(app=app)
122
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
123
+ resp = await ac.get("/api/personas")
124
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
125
+ data = resp.json()
126
+ personas = data.get("personas", [])
127
+ assert len(personas) == 5, f"Expected 5 personas, got {len(personas)}"
128
+
129
+ asyncio.get_event_loop().run_until_complete(_run())
130
+
131
+
132
+ # ── Part 2: Game theory ───────────────────────────────────────────────────────
133
+
134
+ class TestGameTheory:
135
+ def test_game_theory_zopa(self):
136
+ """compute_zopa(165000, 125000) == (125000, 165000)."""
137
+ result = compute_zopa(165_000.0, 125_000.0)
138
+ expected = (125_000.0, 165_000.0)
139
+ assert result == expected, f"Expected {expected}, got {result}"
140
+
141
+ def test_zopa_none_when_inverted(self):
142
+ """No ZOPA when seller BATNA exceeds buyer BATNA."""
143
+ result = compute_zopa(100_000.0, 150_000.0)
144
+ assert result is None, f"Expected None (no ZOPA), got {result}"
145
+
146
+ def test_nash(self):
147
+ """compute_nash_bargaining_solution(165000, 125000) == 145000.0."""
148
+ result = compute_nash_bargaining_solution(165_000.0, 125_000.0)
149
+ expected = 145_000.0
150
+ assert result == expected, f"Expected {expected}, got {result}"
151
+
152
+ def test_shapley(self):
153
+ """compute_shapley_value works for a 2-player coalition."""
154
+ coalition_values = {
155
+ frozenset(): 0.0,
156
+ frozenset(["A"]): 40.0,
157
+ frozenset(["B"]): 30.0,
158
+ frozenset(["A", "B"]): 100.0,
159
+ }
160
+ shapley = compute_shapley_value(coalition_values)
161
+ assert "A" in shapley and "B" in shapley, f"Missing players: {shapley}"
162
+ total = shapley["A"] + shapley["B"]
163
+ assert abs(total - 100.0) < 1e-6, f"Shapley values should sum to grand coalition value: {total}"
164
+
165
+
166
+ # ── Part 3: Grader ────────────────────────────────────────────────────────────
167
+
168
+ class TestGrader:
169
+ def test_grader_normal(self, parlay_state):
170
+ """compute_step_reward with valid state returns float in expected range."""
171
+ action = ParlayAction(utterance="I propose 145000.", offer_amount=145_000.0)
172
+ next_state = ParlayState(
173
+ **{**parlay_state.model_dump(), "step_count": 1, "offer_history": [145_000.0]}
174
+ )
175
+ result = compute_step_reward(parlay_state, action, next_state)
176
+ assert isinstance(result, float), f"Expected float, got {type(result)}"
177
+ assert -500.0 <= result <= 500.0, f"Expected reward in range [-500, 500], got {result}"
178
+
179
+ def test_grader_capitulation(self, parlay_state):
180
+ """Deal below BATNA (125000) returns -OMEGA terminal reward."""
181
+ result = compute_terminal_reward(parlay_state, final_price=100_000.0, t_close=10)
182
+ assert result == -OMEGA, f"Expected -{OMEGA}, got {result}"
183
+
184
+
185
+ # ── Part 4: Database ──────────────────────────────────────────────────────────
186
+
187
+ class TestDbInit:
188
+ def test_db_init(self):
189
+ """parlay.db has sessions, episodes, and leaderboard tables."""
190
+ # Run init to ensure tables exist
191
+ async def _run():
192
+ from scripts.init_db import init_db
193
+ await init_db()
194
+
195
+ asyncio.get_event_loop().run_until_complete(_run())
196
+
197
+ conn = sqlite3.connect("parlay.db")
198
+ cursor = conn.execute(
199
+ "SELECT name FROM sqlite_master WHERE type='table'"
200
+ )
201
+ tables = {row[0] for row in cursor.fetchall()}
202
+ conn.close()
203
+
204
+ required = {"sessions", "episodes", "leaderboard"}
205
+ missing = required - tables
206
+ assert not missing, f"Missing tables: {missing}. Found: {tables}"
207
+
208
+ def test_leaderboard_insert(self):
209
+ """POST a score via the accept endpoint, GET leaderboard returns it."""
210
+ async def _run():
211
+ transport = ASGITransport(app=app)
212
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
213
+ # Start a session
214
+ start_resp = await ac.post(
215
+ "/api/game/start",
216
+ json={"scenario_id": "saas_enterprise", "persona": "shark", "player_name": "TestPlayer"},
217
+ )
218
+ assert start_resp.status_code == 200, f"Start failed: {start_resp.text}"
219
+ session_id = start_resp.json()["session_id"]
220
+
221
+ # Make one move
222
+ move_resp = await ac.post(
223
+ "/api/game/move",
224
+ json={"session_id": session_id, "amount": 145_000.0, "message": "Test offer"},
225
+ )
226
+ assert move_resp.status_code == 200, f"Move failed: {move_resp.text}"
227
+
228
+ # Accept
229
+ accept_resp = await ac.post(
230
+ "/api/game/accept",
231
+ json={"session_id": session_id},
232
+ )
233
+ assert accept_resp.status_code == 200, f"Accept failed: {accept_resp.text}"
234
+
235
+ # Check leaderboard
236
+ lb_resp = await ac.get("/api/leaderboard?limit=5")
237
+ assert lb_resp.status_code == 200, f"Leaderboard failed: {lb_resp.text}"
238
+ entries = lb_resp.json().get("entries", [])
239
+ names = [e.get("player_name") for e in entries]
240
+ assert "TestPlayer" in names, f"TestPlayer not in leaderboard: {names}"
241
+
242
+ asyncio.get_event_loop().run_until_complete(_run())
243
+
244
+
245
+ # ── Part 5: Session API (mock mode) ────────────────────���─────────────────────
246
+
247
+ class TestMockSession:
248
+ def test_mock_session(self):
249
+ """POST /api/session/start without API key returns valid session_id."""
250
+ async def _run():
251
+ transport = ASGITransport(app=app)
252
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
253
+ resp = await ac.post(
254
+ "/api/session/start",
255
+ json={"scenario_id": "saas_enterprise", "persona": "shark", "player_name": "MockPlayer"},
256
+ )
257
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
258
+ data = resp.json()
259
+ assert "session_id" in data, f"Missing session_id: {data}"
260
+ assert len(data["session_id"]) == 36, f"session_id looks wrong: {data['session_id']}"
261
+
262
+ asyncio.get_event_loop().run_until_complete(_run())
263
+
264
+ def test_mock_step(self):
265
+ """POST /api/session/{id}/step with mock mode returns valid observation."""
266
+ async def _run():
267
+ transport = ASGITransport(app=app)
268
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
269
+ # Start session
270
+ start = await ac.post(
271
+ "/api/session/start",
272
+ json={"scenario_id": "saas_enterprise", "persona": "diplomat", "player_name": "MockPlayer"},
273
+ )
274
+ assert start.status_code == 200, f"Start failed: {start.text}"
275
+ session_id = start.json()["session_id"]
276
+
277
+ # Step
278
+ step = await ac.post(
279
+ f"/api/session/{session_id}/step",
280
+ json={"amount": 140_000.0, "message": "Test proposal"},
281
+ )
282
+ assert step.status_code == 200, f"Step failed: {step.status_code}: {step.text}"
283
+ data = step.json()
284
+ assert "observation" in data, f"Missing observation: {data}"
285
+ assert "opponent" in data, f"Missing opponent response: {data}"
286
+ obs = data["observation"]
287
+ assert "tension_score" in obs, f"Missing tension_score in obs: {obs}"
288
+ opponent = data["opponent"]
289
+ assert "utterance" in opponent, f"Missing utterance in opponent: {opponent}"
290
+
291
+ asyncio.get_event_loop().run_until_complete(_run())
training/notebooks/parlay_training.ipynb CHANGED
@@ -1,25 +1,8 @@
1
  {
2
- "nbformat": 4,
3
- "nbformat_minor": 5,
4
- "metadata": {
5
- "kernelspec": {
6
- "display_name": "Python 3",
7
- "language": "python",
8
- "name": "python3"
9
- },
10
- "language_info": {
11
- "name": "python",
12
- "version": "3.11.0"
13
- },
14
- "colab": {
15
- "provenance": [],
16
- "gpuType": "T4"
17
- },
18
- "accelerator": "GPU"
19
- },
20
  "cells": [
21
  {
22
  "cell_type": "markdown",
 
23
  "metadata": {},
24
  "source": [
25
  "# Parlay β€” Training Notebook\n",
@@ -33,12 +16,12 @@
33
  "This produces the reward curve shown to hackathon judges.\n",
34
  "\n",
35
  "**Runtime:** ~2.5hr on T4 for 100 episodes + 100 GRPO steps. Full 500-step run: ~8hr on A100."
36
- ],
37
- "id": "cell-markdown-intro"
38
  },
39
  {
40
  "cell_type": "code",
41
  "execution_count": null,
 
42
  "metadata": {},
43
  "outputs": [],
44
  "source": [
@@ -47,12 +30,12 @@
47
  "!pip install -q trl peft transformers accelerate bitsandbytes datasets huggingface-hub\n",
48
  "!pip install -q matplotlib\n",
49
  "print('βœ“ All dependencies installed')"
50
- ],
51
- "id": "cell-install"
52
  },
53
  {
54
  "cell_type": "code",
55
  "execution_count": null,
 
56
  "metadata": {},
57
  "outputs": [],
58
  "source": [
@@ -70,12 +53,12 @@
70
  "assert os.environ.get('GOOGLE_API_KEY'), 'GOOGLE_API_KEY not set in Colab Secrets!'\n",
71
  "print('βœ“ API keys loaded')\n",
72
  "print(f' GOOGLE_API_KEY: {os.environ[\"GOOGLE_API_KEY\"][:8]}...')"
73
- ],
74
- "id": "cell-secrets"
75
  },
76
  {
77
  "cell_type": "code",
78
  "execution_count": null,
 
79
  "metadata": {},
80
  "outputs": [],
81
  "source": [
@@ -99,12 +82,12 @@
99
  "import sys\n",
100
  "sys.path.insert(0, os.getcwd())\n",
101
  "print('βœ“ sys.path updated')"
102
- ],
103
- "id": "cell-setup"
104
  },
105
  {
106
  "cell_type": "code",
107
  "execution_count": null,
 
108
  "metadata": {},
109
  "outputs": [],
110
  "source": [
@@ -124,12 +107,12 @@
124
  "nash = compute_nash_bargaining_solution(165_000, 125_000)\n",
125
  "print(f'\\nSaaS ZOPA: {zopa}')\n",
126
  "print(f'Nash point: {nash:,.0f}')"
127
- ],
128
- "id": "cell-verify"
129
  },
130
  {
131
  "cell_type": "code",
132
  "execution_count": null,
 
133
  "metadata": {},
134
  "outputs": [],
135
  "source": [
@@ -139,12 +122,12 @@
139
  "\n",
140
  "await init_db() # Colab supports top-level await\n",
141
  "print('βœ“ Database initialized')"
142
- ],
143
- "id": "cell-db"
144
  },
145
  {
146
  "cell_type": "code",
147
  "execution_count": null,
 
148
  "metadata": {},
149
  "outputs": [],
150
  "source": [
@@ -158,12 +141,12 @@
158
  "print(result.stdout)\n",
159
  "if result.returncode != 0:\n",
160
  " print('STDERR:', result.stderr)"
161
- ],
162
- "id": "cell-generate"
163
  },
164
  {
165
  "cell_type": "code",
166
  "execution_count": null,
 
167
  "metadata": {},
168
  "outputs": [],
169
  "source": [
@@ -189,12 +172,12 @@
189
  "print(Counter(r['split'] for r in records))\n",
190
  "print('\\nPersona distribution:')\n",
191
  "print(Counter(r['persona'] for r in records))"
192
- ],
193
- "id": "cell-inspect"
194
  },
195
  {
196
  "cell_type": "code",
197
  "execution_count": null,
 
198
  "metadata": {},
199
  "outputs": [],
200
  "source": [
@@ -210,12 +193,12 @@
210
  " --data data/episodes.jsonl \\\n",
211
  " --output models/parlay-sft \\\n",
212
  " --threshold 0.60"
213
- ],
214
- "id": "cell-sft"
215
  },
216
  {
217
  "cell_type": "code",
218
  "execution_count": null,
 
219
  "metadata": {},
220
  "outputs": [],
221
  "source": [
@@ -227,12 +210,12 @@
227
  " --data data/episodes.jsonl \\\n",
228
  " --output models/parlay-grpo \\\n",
229
  " --steps 100"
230
- ],
231
- "id": "cell-grpo"
232
  },
233
  {
234
  "cell_type": "code",
235
  "execution_count": null,
 
236
  "metadata": {},
237
  "outputs": [],
238
  "source": [
@@ -261,12 +244,12 @@
261
  "# Display\n",
262
  "from IPython.display import Image, display\n",
263
  "display(Image('results/reward_curve.png'))"
264
- ],
265
- "id": "cell-eval"
266
  },
267
  {
268
  "cell_type": "code",
269
  "execution_count": null,
 
270
  "metadata": {},
271
  "outputs": [],
272
  "source": [
@@ -293,12 +276,12 @@
293
  "print(f'Base β†’ GRPO reward improvement: {grpo_r - base_r:+.1f}')\n",
294
  "print(f'Base β†’ GRPO efficiency improvement: {(grpo_e - base_e)*100:+.1f}%')\n",
295
  "print('='*60)"
296
- ],
297
- "id": "cell-summary"
298
  },
299
  {
300
  "cell_type": "code",
301
  "execution_count": null,
 
302
  "metadata": {},
303
  "outputs": [],
304
  "source": [
@@ -309,22 +292,22 @@
309
  "!python -m training.push_to_hub \\\n",
310
  " --model models/parlay-grpo \\\n",
311
  " --repo {HF_REPO}"
312
- ],
313
- "id": "cell-push"
314
  },
315
  {
316
  "cell_type": "code",
317
  "execution_count": null,
 
318
  "metadata": {},
319
  "outputs": [],
320
  "source": [
321
  "# Cell 13: Quick demo β€” play one episode from CLI\n",
322
  "!python -m agent.runner --persona shark --scenario saas_enterprise --steps 10"
323
- ],
324
- "id": "cell-demo"
325
  },
326
  {
327
  "cell_type": "markdown",
 
328
  "metadata": {},
329
  "source": [
330
  "## Next Steps\n",
@@ -336,8 +319,25 @@
336
  "\n",
337
  "---\n",
338
  "*Parlay β€” The arena where AIs learn to close.*"
339
- ],
340
- "id": "cell-nextsteps"
341
  }
342
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  }
 
1
  {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  "cells": [
3
  {
4
  "cell_type": "markdown",
5
+ "id": "cell-markdown-intro",
6
  "metadata": {},
7
  "source": [
8
  "# Parlay β€” Training Notebook\n",
 
16
  "This produces the reward curve shown to hackathon judges.\n",
17
  "\n",
18
  "**Runtime:** ~2.5hr on T4 for 100 episodes + 100 GRPO steps. Full 500-step run: ~8hr on A100."
19
+ ]
 
20
  },
21
  {
22
  "cell_type": "code",
23
  "execution_count": null,
24
+ "id": "cell-install",
25
  "metadata": {},
26
  "outputs": [],
27
  "source": [
 
30
  "!pip install -q trl peft transformers accelerate bitsandbytes datasets huggingface-hub\n",
31
  "!pip install -q matplotlib\n",
32
  "print('βœ“ All dependencies installed')"
33
+ ]
 
34
  },
35
  {
36
  "cell_type": "code",
37
  "execution_count": null,
38
+ "id": "cell-secrets",
39
  "metadata": {},
40
  "outputs": [],
41
  "source": [
 
53
  "assert os.environ.get('GOOGLE_API_KEY'), 'GOOGLE_API_KEY not set in Colab Secrets!'\n",
54
  "print('βœ“ API keys loaded')\n",
55
  "print(f' GOOGLE_API_KEY: {os.environ[\"GOOGLE_API_KEY\"][:8]}...')"
56
+ ]
 
57
  },
58
  {
59
  "cell_type": "code",
60
  "execution_count": null,
61
+ "id": "cell-setup",
62
  "metadata": {},
63
  "outputs": [],
64
  "source": [
 
82
  "import sys\n",
83
  "sys.path.insert(0, os.getcwd())\n",
84
  "print('βœ“ sys.path updated')"
85
+ ]
 
86
  },
87
  {
88
  "cell_type": "code",
89
  "execution_count": null,
90
+ "id": "cell-verify",
91
  "metadata": {},
92
  "outputs": [],
93
  "source": [
 
107
  "nash = compute_nash_bargaining_solution(165_000, 125_000)\n",
108
  "print(f'\\nSaaS ZOPA: {zopa}')\n",
109
  "print(f'Nash point: {nash:,.0f}')"
110
+ ]
 
111
  },
112
  {
113
  "cell_type": "code",
114
  "execution_count": null,
115
+ "id": "cell-db",
116
  "metadata": {},
117
  "outputs": [],
118
  "source": [
 
122
  "\n",
123
  "await init_db() # Colab supports top-level await\n",
124
  "print('βœ“ Database initialized')"
125
+ ]
 
126
  },
127
  {
128
  "cell_type": "code",
129
  "execution_count": null,
130
+ "id": "cell-generate",
131
  "metadata": {},
132
  "outputs": [],
133
  "source": [
 
141
  "print(result.stdout)\n",
142
  "if result.returncode != 0:\n",
143
  " print('STDERR:', result.stderr)"
144
+ ]
 
145
  },
146
  {
147
  "cell_type": "code",
148
  "execution_count": null,
149
+ "id": "cell-inspect",
150
  "metadata": {},
151
  "outputs": [],
152
  "source": [
 
172
  "print(Counter(r['split'] for r in records))\n",
173
  "print('\\nPersona distribution:')\n",
174
  "print(Counter(r['persona'] for r in records))"
175
+ ]
 
176
  },
177
  {
178
  "cell_type": "code",
179
  "execution_count": null,
180
+ "id": "cell-sft",
181
  "metadata": {},
182
  "outputs": [],
183
  "source": [
 
193
  " --data data/episodes.jsonl \\\n",
194
  " --output models/parlay-sft \\\n",
195
  " --threshold 0.60"
196
+ ]
 
197
  },
198
  {
199
  "cell_type": "code",
200
  "execution_count": null,
201
+ "id": "cell-grpo",
202
  "metadata": {},
203
  "outputs": [],
204
  "source": [
 
210
  " --data data/episodes.jsonl \\\n",
211
  " --output models/parlay-grpo \\\n",
212
  " --steps 100"
213
+ ]
 
214
  },
215
  {
216
  "cell_type": "code",
217
  "execution_count": null,
218
+ "id": "cell-eval",
219
  "metadata": {},
220
  "outputs": [],
221
  "source": [
 
244
  "# Display\n",
245
  "from IPython.display import Image, display\n",
246
  "display(Image('results/reward_curve.png'))"
247
+ ]
 
248
  },
249
  {
250
  "cell_type": "code",
251
  "execution_count": null,
252
+ "id": "cell-summary",
253
  "metadata": {},
254
  "outputs": [],
255
  "source": [
 
276
  "print(f'Base β†’ GRPO reward improvement: {grpo_r - base_r:+.1f}')\n",
277
  "print(f'Base β†’ GRPO efficiency improvement: {(grpo_e - base_e)*100:+.1f}%')\n",
278
  "print('='*60)"
279
+ ]
 
280
  },
281
  {
282
  "cell_type": "code",
283
  "execution_count": null,
284
+ "id": "cell-push",
285
  "metadata": {},
286
  "outputs": [],
287
  "source": [
 
292
  "!python -m training.push_to_hub \\\n",
293
  " --model models/parlay-grpo \\\n",
294
  " --repo {HF_REPO}"
295
+ ]
 
296
  },
297
  {
298
  "cell_type": "code",
299
  "execution_count": null,
300
+ "id": "cell-demo",
301
  "metadata": {},
302
  "outputs": [],
303
  "source": [
304
  "# Cell 13: Quick demo β€” play one episode from CLI\n",
305
  "!python -m agent.runner --persona shark --scenario saas_enterprise --steps 10"
306
+ ]
 
307
  },
308
  {
309
  "cell_type": "markdown",
310
+ "id": "cell-nextsteps",
311
  "metadata": {},
312
  "source": [
313
  "## Next Steps\n",
 
319
  "\n",
320
  "---\n",
321
  "*Parlay β€” The arena where AIs learn to close.*"
322
+ ]
 
323
  }
324
+ ],
325
+ "metadata": {
326
+ "accelerator": "GPU",
327
+ "colab": {
328
+ "gpuType": "T4",
329
+ "provenance": []
330
+ },
331
+ "kernelspec": {
332
+ "display_name": "Python 3",
333
+ "language": "python",
334
+ "name": "python3"
335
+ },
336
+ "language_info": {
337
+ "name": "python",
338
+ "version": "3.11.0"
339
+ }
340
+ },
341
+ "nbformat": 4,
342
+ "nbformat_minor": 5
343
  }