fix: fixed UI bugs, keyless testing initiated
Browse files- Dockerfile +20 -0
- Makefile +6 -0
- README.md +41 -0
- agent/gemini_client.py +110 -18
- dashboard/api.py +142 -0
- dashboard/index.html +93 -92
- dashboard/static/app.js +537 -320
- dashboard/static/character.js +512 -291
- dashboard/static/style.css +884 -1352
- docker-compose.yml +23 -18
- main.py +43 -5
- parlay_env/server.py +34 -2
- scripts/Find-Python311.ps1 +64 -0
- scripts/init_db.py +14 -0
- scripts/setup.ps1 +38 -16
- scripts/setup_train.ps1 +38 -2
- scripts/start.sh +14 -0
- tests/test_keyless.py +291 -0
- training/notebooks/parlay_training.ipynb +49 -49
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).
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
| 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
|
| 30 |
"""Convert legacy {'role','parts'} messages to google-genai Content list."""
|
| 31 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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()
|
| 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 |
-
|
|
|
|
|
|
|
| 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"
|
| 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 β
|
| 7 |
-
<title>Parlay β
|
| 8 |
|
| 9 |
-
<!--
|
| 10 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
|
| 11 |
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
| 12 |
|
| 13 |
-
<!--
|
| 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 |
-
<
|
| 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">
|
| 29 |
</div>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
-
|
| 34 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 35 |
-
<div id="
|
| 36 |
-
<div class="
|
| 37 |
-
<div class="
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
</div>
|
| 55 |
|
| 56 |
-
|
| 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 |
-
|
| 65 |
-
<
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
</div>
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
|
| 76 |
-
<div class="
|
| 77 |
-
|
| 78 |
-
|
| 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 |
-
|
| 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">
|
| 132 |
<span id="cp-value" class="cp-value mono">100 / 100</span>
|
| 133 |
</div>
|
| 134 |
-
<div class="cp-track" role="progressbar" aria-label="
|
| 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 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 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 →
|
| 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">← Back</button>
|
| 85 |
+
<button id="step2-continue" class="btn btn-primary" type="button">Continue →</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">← Back</button>
|
| 107 |
+
<button id="step3-start" class="btn btn-primary" type="button">Enter the Room →</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 |
-
//
|
| 4 |
-
// Zero dependencies beyond native fetch / DOM APIs
|
| 5 |
// ============================================================
|
| 6 |
|
| 7 |
-
const
|
| 8 |
const API_BASE = ""; // same-origin
|
| 9 |
|
| 10 |
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -21,26 +21,35 @@ let gameState = {
|
|
| 21 |
maxCp: 100,
|
| 22 |
};
|
| 23 |
|
| 24 |
-
let character
|
| 25 |
-
let charts
|
| 26 |
-
let _driftTimer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
// ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
document.addEventListener("DOMContentLoaded", () => {
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
_initDarkMode();
|
| 33 |
-
_initDarkModeToggle();
|
| 34 |
loadScenarios();
|
| 35 |
loadPersonas();
|
|
|
|
| 36 |
|
| 37 |
-
//
|
| 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
|
| 54 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 65 |
loadLeaderboard();
|
| 66 |
});
|
| 67 |
|
| 68 |
-
// ββ
|
| 69 |
-
function
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
|
| 76 |
-
function
|
| 77 |
-
const
|
| 78 |
-
if (
|
| 79 |
-
|
| 80 |
-
|
| 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 |
-
// ββ
|
| 89 |
-
function
|
| 90 |
-
const
|
| 91 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
if (
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
}
|
| 101 |
-
if (
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
-
function
|
| 113 |
-
const el = document.getElementById(
|
| 114 |
if (!el) return;
|
| 115 |
el.textContent = msg;
|
| 116 |
-
|
| 117 |
-
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
-
function
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 161 |
-
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
-
if (
|
| 165 |
} catch (e) {
|
| 166 |
-
|
| 167 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 178 |
-
const moveSelect
|
| 179 |
-
const
|
| 180 |
|
| 181 |
-
const offerRaw
|
| 182 |
-
const move
|
| 183 |
-
const cardId
|
| 184 |
-
const offer
|
| 185 |
|
| 186 |
if (offer === null && move === "counter") {
|
| 187 |
offerInput && offerInput.focus();
|
| 188 |
return;
|
| 189 |
}
|
| 190 |
-
if (
|
| 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
|
| 201 |
-
const res = await fetch(`${API_BASE}/api/game/step`, {
|
| 202 |
method: "POST",
|
| 203 |
headers: { "Content-Type": "application/json" },
|
| 204 |
-
body: JSON.stringify(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
});
|
| 206 |
|
| 207 |
-
|
| 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 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
}
|
| 225 |
|
| 226 |
-
if (gameState.done)
|
| 227 |
-
_handleGameOver(data);
|
| 228 |
-
}
|
| 229 |
|
| 230 |
-
// Clear input
|
| 231 |
if (offerInput) offerInput.value = "";
|
| 232 |
-
if (
|
| 233 |
|
| 234 |
-
if (
|
| 235 |
} catch (e) {
|
| 236 |
_removeThinkingBubble(thinkId);
|
| 237 |
_showError("Move failed: " + e.message);
|
| 238 |
-
if (
|
| 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
|
| 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/
|
| 255 |
method: "POST",
|
| 256 |
headers: { "Content-Type": "application/json" },
|
| 257 |
-
body: JSON.stringify({ session_id: gameState.sessionId
|
| 258 |
});
|
| 259 |
|
| 260 |
-
const data = await res.json();
|
| 261 |
_removeThinkingBubble(thinkId);
|
| 262 |
gameState.done = true;
|
| 263 |
updateUI(data);
|
| 264 |
-
|
| 265 |
-
_handleGameOver(data);
|
| 266 |
-
if (
|
| 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/
|
| 283 |
method: "POST",
|
| 284 |
headers: { "Content-Type": "application/json" },
|
| 285 |
-
body: JSON.stringify({ session_id: gameState.sessionId
|
| 286 |
});
|
| 287 |
|
| 288 |
-
const data = await res.json();
|
| 289 |
_removeThinkingBubble(thinkId);
|
| 290 |
gameState.done = true;
|
| 291 |
updateUI(data);
|
| 292 |
-
|
| 293 |
-
|
| 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)
|
| 307 |
const data = await res.json();
|
| 308 |
const scenarios = data.scenarios || data || [];
|
| 309 |
-
|
| 310 |
-
if (
|
| 311 |
} catch (e) {
|
| 312 |
-
|
|
|
|
|
|
|
| 313 |
}
|
| 314 |
}
|
| 315 |
|
| 316 |
async function loadPersonas() {
|
| 317 |
try {
|
| 318 |
const res = await fetch(`${API_BASE}/api/personas`);
|
| 319 |
-
if (!res.ok)
|
| 320 |
const data = await res.json();
|
| 321 |
const personas = data.personas || data || [];
|
| 322 |
-
|
| 323 |
-
if (
|
| 324 |
} catch (e) {
|
| 325 |
-
|
|
|
|
| 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 |
-
|
| 335 |
-
|
| 336 |
-
if (DEBUG) console.log("[app] leaderboard loaded", entries);
|
| 337 |
} catch (e) {
|
| 338 |
-
if (
|
| 339 |
}
|
| 340 |
}
|
| 341 |
|
|
@@ -364,42 +542,53 @@ function updateUI(response) {
|
|
| 364 |
}
|
| 365 |
|
| 366 |
// Sparkline update
|
| 367 |
-
if (charts &&
|
| 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
|
| 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 |
-
|
|
|
|
| 548 |
fill.style.width = `${pct}%`;
|
| 549 |
|
| 550 |
let level = "low";
|
| 551 |
-
if (pct >=
|
| 552 |
-
else if (pct >=
|
| 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
|
| 577 |
-
const pctEl
|
| 578 |
-
const confEl
|
| 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 |
-
|
| 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 >
|
| 622 |
-
else if (tension >
|
| 623 |
-
else if (tension <
|
| 624 |
|
| 625 |
character.setState(state);
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 637 |
-
|
| 638 |
-
|
| 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 β
|
|
|
|
|
|
|
| 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 ?? "
|
| 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
|
| 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(--
|
| 799 |
-
"border-radius:
|
| 800 |
-
"font-
|
| 801 |
-
"
|
|
|
|
| 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
|
| 812 |
-
const amount
|
| 813 |
-
const score
|
| 814 |
|
| 815 |
-
// Show result in chat
|
| 816 |
const resultMsg = deal
|
| 817 |
-
? `Deal
|
| 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
|
| 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 (
|
| 846 |
}
|
| 847 |
|
| 848 |
-
// ββ Scenario
|
| 849 |
-
function
|
| 850 |
-
const grid = document.getElementById("scenario-grid");
|
| 851 |
if (!grid) return;
|
| 852 |
-
|
| 853 |
grid.innerHTML = "";
|
| 854 |
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
];
|
| 863 |
-
}
|
| 864 |
|
| 865 |
-
scenarios.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
const card = document.createElement("div");
|
| 867 |
-
card.className = "
|
| 868 |
card.dataset.scenarioId = s.id || s.scenario_id;
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
|
| 881 |
card.addEventListener("click", () => {
|
| 882 |
-
grid.querySelectorAll(".
|
| 883 |
card.classList.add("selected");
|
| 884 |
});
|
|
|
|
|
|
|
|
|
|
| 885 |
|
| 886 |
grid.appendChild(card);
|
| 887 |
});
|
| 888 |
}
|
| 889 |
|
| 890 |
-
|
| 891 |
-
|
|
|
|
| 892 |
if (!grid) return;
|
| 893 |
-
|
| 894 |
grid.innerHTML = "";
|
| 895 |
|
| 896 |
const defaults = [
|
| 897 |
-
{ id: "shark", name: "Shark",
|
| 898 |
-
{ id: "diplomat", name: "Diplomat",
|
| 899 |
-
{ id: "analyst", name: "Analyst",
|
| 900 |
-
{ id: "wildcard", name: "Wildcard",
|
| 901 |
-
{ id: "veteran", name: "Veteran",
|
| 902 |
];
|
| 903 |
|
| 904 |
const list = (personas && personas.length) ? personas : defaults;
|
| 905 |
|
| 906 |
list.forEach((p) => {
|
| 907 |
-
const
|
| 908 |
-
opt.className = "persona-option";
|
| 909 |
-
opt.dataset.persona = p.id || p.persona_id;
|
| 910 |
|
| 911 |
-
const
|
| 912 |
-
|
| 913 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 918 |
|
| 919 |
-
|
| 920 |
-
opt.appendChild(name);
|
| 921 |
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 940 |
}
|
| 941 |
|
| 942 |
// ββ Sparkline init ββββββββββββββββββββββββββββββββββββββββββββ
|
| 943 |
function _initSparkline(observation) {
|
| 944 |
if (!charts || !observation) return;
|
| 945 |
-
|
| 946 |
-
const
|
| 947 |
-
const
|
| 948 |
-
|
| 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 |
-
|
| 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)
|
| 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
|
| 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: "
|
| 1015 |
-
diplomat: "
|
| 1016 |
-
analyst: "
|
| 1017 |
-
wildcard: "
|
| 1018 |
-
veteran: "
|
| 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
|
| 3 |
-
//
|
| 4 |
-
//
|
|
|
|
| 5 |
// ============================================================
|
| 6 |
|
|
|
|
| 7 |
const CHARACTER_STATES = {
|
| 8 |
-
idle: { headTilt: 0, eyeScale: 1.0, animSpeed: 0.
|
| 9 |
-
thinking: { headTilt: 0.
|
| 10 |
-
aggressive: { headTilt: -0.
|
| 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
};
|
| 30 |
|
|
|
|
| 31 |
class NegotiatorCharacter {
|
| 32 |
constructor(canvasId, persona = "shark") {
|
| 33 |
-
this.canvasId
|
| 34 |
-
this.persona
|
| 35 |
-
this.state
|
| 36 |
this.targetState = CHARACTER_STATES.idle;
|
| 37 |
-
this.
|
| 38 |
-
this.currentEyeScale = 1.0;
|
| 39 |
-
this.clock = 0;
|
| 40 |
this.animFrameId = null;
|
| 41 |
-
this.scene
|
| 42 |
-
this.camera
|
| 43 |
-
this.renderer
|
| 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 |
-
|
|
|
|
| 59 |
|
| 60 |
this.camera = new THREE.PerspectiveCamera(45, 280 / 380, 0.1, 100);
|
| 61 |
-
this.camera.position.set(0, 1.
|
| 62 |
-
this.camera.lookAt(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 |
-
|
| 76 |
-
|
| 77 |
-
this.scene.add(ambient);
|
| 78 |
|
| 79 |
-
//
|
| 80 |
-
const key = new THREE.DirectionalLight(
|
| 81 |
-
key.position.set(3,
|
| 82 |
key.castShadow = true;
|
| 83 |
this.scene.add(key);
|
| 84 |
|
| 85 |
-
//
|
| 86 |
-
const
|
| 87 |
-
|
| 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 |
-
|
| 97 |
return new THREE.MeshStandardMaterial({ color, roughness, metalness });
|
| 98 |
}
|
| 99 |
|
| 100 |
-
|
| 101 |
const size = 128;
|
| 102 |
-
const cvs
|
| 103 |
-
cvs.width = size;
|
| 104 |
-
|
| 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
|
| 114 |
ctx.textAlign = "center";
|
| 115 |
ctx.textBaseline = "middle";
|
| 116 |
-
ctx.fillText(symbol, size / 2, size / 2);
|
| 117 |
|
| 118 |
-
|
| 119 |
-
return tex;
|
| 120 |
}
|
| 121 |
|
| 122 |
-
|
| 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.
|
|
|
|
| 136 |
|
| 137 |
-
const
|
| 138 |
const group = new THREE.Group();
|
| 139 |
this.characterGroup = group;
|
| 140 |
this.scene.add(group);
|
| 141 |
|
| 142 |
-
|
| 143 |
-
const
|
| 144 |
-
const
|
| 145 |
-
const
|
| 146 |
-
const
|
| 147 |
-
const
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
const
|
| 151 |
-
|
| 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
|
| 161 |
-
const
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
group.add(shirt);
|
| 165 |
|
| 166 |
// Tie
|
| 167 |
-
const
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
|
| 172 |
-
//
|
| 173 |
const lapelGeo = new THREE.BoxGeometry(0.22, 0.7, 0.08);
|
| 174 |
-
const lapelL = new THREE.Mesh(lapelGeo,
|
| 175 |
-
lapelL.position.set(-0.22, 0.25, 0.3);
|
| 176 |
-
lapelL.rotation.z = 0.2;
|
| 177 |
group.add(lapelL);
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
lapelR.position.set(0.22, 0.25, 0.3);
|
| 181 |
-
lapelR.rotation.z = -0.2;
|
| 182 |
group.add(lapelR);
|
| 183 |
|
| 184 |
-
//
|
| 185 |
-
const badgeTex = this.
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
);
|
| 190 |
-
|
| 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 |
-
//
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
neck.position.set(0, 0.8, 0);
|
| 200 |
group.add(neck);
|
| 201 |
-
this.meshes.neck = neck;
|
| 202 |
|
| 203 |
-
//
|
| 204 |
-
const
|
| 205 |
-
|
| 206 |
head.position.set(0, 1.45, 0);
|
|
|
|
| 207 |
group.add(head);
|
| 208 |
this.meshes.head = head;
|
| 209 |
|
| 210 |
-
// Hair
|
| 211 |
-
const
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
group.add(hair);
|
| 215 |
|
| 216 |
// Hair sides
|
| 217 |
-
const
|
| 218 |
-
const
|
| 219 |
-
|
| 220 |
-
group.add(
|
| 221 |
-
const
|
| 222 |
-
|
| 223 |
-
group.add(
|
| 224 |
-
|
| 225 |
-
//
|
| 226 |
-
const eyeW = new THREE.SphereGeometry(0.
|
| 227 |
-
const eyeP = new THREE.SphereGeometry(0.
|
|
|
|
|
|
|
| 228 |
|
| 229 |
const eyeLW = new THREE.Mesh(eyeW, eyeWhite);
|
| 230 |
-
eyeLW.position.set(-0.19, 1.
|
| 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.
|
| 236 |
group.add(eyeRW);
|
| 237 |
this.meshes.eyeRight = eyeRW;
|
| 238 |
|
| 239 |
-
const eyeLP = new THREE.Mesh(eyeP,
|
| 240 |
-
eyeLP.position.set(-0.19, 1.
|
| 241 |
group.add(eyeLP);
|
| 242 |
this.meshes.pupilLeft = eyeLP;
|
| 243 |
|
| 244 |
-
const eyeRP = new THREE.Mesh(eyeP,
|
| 245 |
-
eyeRP.position.set(0.19, 1.
|
| 246 |
group.add(eyeRP);
|
| 247 |
this.meshes.pupilRight = eyeRP;
|
| 248 |
|
| 249 |
-
// Eyebrows
|
| 250 |
-
const
|
| 251 |
-
const
|
|
|
|
| 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 |
-
//
|
| 263 |
-
|
| 264 |
-
const
|
| 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
|
| 272 |
-
const
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
| 275 |
group.add(nose);
|
| 276 |
|
| 277 |
-
//
|
| 278 |
-
const
|
| 279 |
-
const shlL = new THREE.Mesh(
|
| 280 |
-
|
| 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,
|
| 289 |
-
uArmL.position.set(-0.72, 0.05, 0);
|
| 290 |
-
group.add(uArmL);
|
| 291 |
this.meshes.upperArmL = uArmL;
|
| 292 |
-
|
| 293 |
-
|
| 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,
|
| 301 |
-
|
| 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
|
| 309 |
const handGeo = new THREE.SphereGeometry(0.14, 8, 6);
|
| 310 |
-
const handL = new THREE.Mesh(handGeo,
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
const
|
| 315 |
-
|
| 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,
|
| 335 |
-
|
| 336 |
-
group.add(legL);
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
| 341 |
|
| 342 |
-
//
|
| 343 |
-
|
| 344 |
|
| 345 |
-
//
|
| 346 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
}
|
| 348 |
|
|
|
|
| 349 |
setState(newState) {
|
| 350 |
if (!(newState in CHARACTER_STATES)) return;
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
this.targetState = CHARACTER_STATES[newState];
|
| 353 |
-
this.
|
| 354 |
}
|
| 355 |
|
| 356 |
setPersona(persona) {
|
| 357 |
-
if (!(persona in
|
| 358 |
this.persona = persona;
|
| 359 |
this._buildCharacter();
|
| 360 |
}
|
| 361 |
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
| 365 |
}
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
this.clock += dt * speed;
|
| 381 |
|
| 382 |
-
|
| 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 |
-
|
| 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 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 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 |
-
//
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
);
|
| 435 |
}
|
|
|
|
| 436 |
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 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 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
}
|
| 456 |
|
| 457 |
this.renderer.render(this.scene, this.camera);
|
| 458 |
}
|
| 459 |
|
| 460 |
destroy() {
|
| 461 |
-
if (this.
|
| 462 |
-
this.
|
| 463 |
if (this.renderer) this.renderer.dispose();
|
| 464 |
}
|
| 465 |
}
|
| 466 |
|
| 467 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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 β
|
| 3 |
-
|
| 4 |
-
|
| 5 |
============================================================ */
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
:root {
|
| 9 |
-
/*
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
| 18 |
-
|
| 19 |
-
/*
|
| 20 |
-
--
|
| 21 |
-
--
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
--
|
| 25 |
-
|
| 26 |
-
-
|
| 27 |
-
--parlay-red
|
| 28 |
-
--parlay-amber:
|
| 29 |
-
--parlay-
|
| 30 |
-
--parlay-blue:
|
| 31 |
-
--parlay-
|
| 32 |
-
--parlay-
|
| 33 |
-
--parlay-
|
| 34 |
-
|
| 35 |
-
/* Layout */
|
| 36 |
-
--col-left: 260px;
|
| 37 |
-
--col-right: 300px;
|
| 38 |
-
--header-h: 56px;
|
| 39 |
-
--gap: 16px;
|
| 40 |
|
| 41 |
/* Typography */
|
| 42 |
-
--font-
|
| 43 |
-
--font-
|
| 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 |
-
/*
|
| 67 |
-
|
| 68 |
-
--
|
| 69 |
-
--
|
| 70 |
-
--
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
--
|
| 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
|
| 92 |
-
*, *::before, *::after {
|
| 93 |
-
box-sizing: border-box;
|
| 94 |
-
margin: 0;
|
| 95 |
-
padding: 0;
|
| 96 |
-
}
|
| 97 |
|
| 98 |
html {
|
| 99 |
-
|
| 100 |
-
-
|
| 101 |
-
|
| 102 |
}
|
| 103 |
|
| 104 |
body {
|
|
|
|
|
|
|
|
|
|
| 105 |
font-family: var(--font-body);
|
| 106 |
-
font-size:
|
| 107 |
-
line-height: 1.
|
| 108 |
-
color: var(--parlay-ink);
|
| 109 |
-
background: var(--parlay-surface-2);
|
| 110 |
-
min-height: 100vh;
|
| 111 |
overflow-x: hidden;
|
| 112 |
}
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
a { color: var(--parlay-blue); text-decoration: none; }
|
| 117 |
-
a:hover { text-decoration: underline; }
|
| 118 |
-
ul { list-style: none; }
|
| 119 |
|
| 120 |
-
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
/*
|
| 134 |
-
.
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
| 138 |
font-family: var(--font-mono);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
|
|
|
| 140 |
|
| 141 |
-
/*
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
.header-brand {
|
| 157 |
display: flex;
|
| 158 |
align-items: center;
|
| 159 |
-
gap: var(--
|
| 160 |
-
font-
|
|
|
|
| 161 |
font-weight: 700;
|
| 162 |
-
|
| 163 |
-
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
-
.
|
| 167 |
-
width:
|
| 168 |
-
height: 8px;
|
| 169 |
border-radius: 50%;
|
| 170 |
-
background: var(--
|
| 171 |
-
box-shadow: 0 0
|
| 172 |
-
|
| 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-
|
| 181 |
display: flex;
|
| 182 |
-
|
| 183 |
-
gap: var(--sp-3);
|
| 184 |
}
|
| 185 |
|
| 186 |
.header-nav a {
|
| 187 |
-
font-
|
| 188 |
-
font-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
-
|
| 195 |
-
.header-nav a
|
| 196 |
-
color: var(--
|
| 197 |
-
|
| 198 |
-
text-decoration: none;
|
| 199 |
}
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
left: 2px;
|
| 219 |
-
width: 16px;
|
| 220 |
-
height: 16px;
|
| 221 |
border-radius: 50%;
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
border-color
|
| 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:
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
}
|
| 246 |
|
| 247 |
-
.col-left
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
gap: var(--sp-3);
|
| 251 |
-
min-width: 0;
|
| 252 |
-
}
|
| 253 |
|
| 254 |
-
/* ββ Panels
|
| 255 |
.panel {
|
| 256 |
-
background: var(--
|
| 257 |
-
border: 1px solid
|
| 258 |
-
border-radius:
|
| 259 |
-
padding: var(--
|
| 260 |
-
|
| 261 |
}
|
| 262 |
|
| 263 |
.panel-header {
|
| 264 |
display: flex;
|
| 265 |
align-items: center;
|
| 266 |
justify-content: space-between;
|
| 267 |
-
margin-bottom: var(--
|
|
|
|
|
|
|
| 268 |
}
|
| 269 |
|
| 270 |
.panel-title {
|
| 271 |
-
font-
|
| 272 |
-
font-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 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(--
|
| 290 |
-
margin-bottom: var(--
|
| 291 |
}
|
| 292 |
|
| 293 |
.player-avatar {
|
| 294 |
-
width:
|
| 295 |
-
height: 40px;
|
| 296 |
border-radius: 50%;
|
| 297 |
-
background: var(--
|
| 298 |
-
border: 2px solid var(--
|
| 299 |
display: flex;
|
| 300 |
align-items: center;
|
| 301 |
justify-content: center;
|
| 302 |
-
font-
|
| 303 |
-
font-
|
| 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(--
|
| 314 |
-
|
| 315 |
-
overflow: hidden;
|
| 316 |
-
text-overflow: ellipsis;
|
| 317 |
}
|
| 318 |
|
| 319 |
-
.player-
|
| 320 |
-
font-size: 0.
|
| 321 |
-
color: var(--parlay-ink-3);
|
| 322 |
-
font-family: var(--font-mono);
|
| 323 |
-
}
|
| 324 |
|
| 325 |
/* CP Bar */
|
| 326 |
-
.cp-section {
|
| 327 |
-
|
| 328 |
-
.cp-label
|
| 329 |
-
|
| 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(--
|
| 360 |
border-radius: 3px;
|
| 361 |
-
transition: width
|
| 362 |
-
position: relative;
|
| 363 |
}
|
| 364 |
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
}
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
| 376 |
}
|
| 377 |
|
| 378 |
-
|
| 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
|
| 398 |
}
|
| 399 |
|
| 400 |
-
.tactical-card:hover .card-inner
|
| 401 |
-
.tactical-card.
|
| 402 |
-
|
| 403 |
-
}
|
| 404 |
|
| 405 |
.card-face, .card-back {
|
| 406 |
position: absolute;
|
| 407 |
inset: 0;
|
| 408 |
-
border-radius:
|
| 409 |
-
|
|
|
|
|
|
|
| 410 |
backface-visibility: hidden;
|
| 411 |
-
-
|
| 412 |
display: flex;
|
| 413 |
flex-direction: column;
|
| 414 |
-
|
| 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 |
-
.
|
| 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-
|
|
|
|
| 444 |
font-weight: 600;
|
| 445 |
-
color: var(--
|
|
|
|
| 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 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 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-
|
| 473 |
-
font-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
margin-bottom: var(--sp-1);
|
| 478 |
}
|
| 479 |
-
|
| 480 |
.card-game-theory {
|
| 481 |
-
font-size: 0.
|
|
|
|
| 482 |
line-height: 1.4;
|
| 483 |
-
|
| 484 |
}
|
| 485 |
|
| 486 |
-
/* ββ
|
| 487 |
.achievements-strip {
|
| 488 |
-
display:
|
| 489 |
-
|
| 490 |
-
gap: var(--
|
| 491 |
}
|
| 492 |
|
| 493 |
.badge {
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
padding: var(--
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 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 |
-
|
| 510 |
-
border-color: var(--
|
| 511 |
-
color: var(--
|
| 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 |
-
|
| 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 |
-
|
| 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:
|
| 564 |
justify-content: space-between;
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
}
|
| 567 |
|
| 568 |
.scenario-title {
|
| 569 |
-
font-
|
| 570 |
-
font-
|
| 571 |
-
|
|
|
|
| 572 |
}
|
| 573 |
|
| 574 |
.scenario-meta {
|
| 575 |
-
font-
|
| 576 |
-
|
| 577 |
margin-top: 2px;
|
| 578 |
}
|
| 579 |
|
| 580 |
-
|
| 581 |
-
.drift-alert {
|
| 582 |
display: flex;
|
| 583 |
-
|
| 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 |
-
.
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
}
|
|
|
|
|
|
|
| 611 |
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
transition: background var(--t-fast);
|
| 623 |
}
|
| 624 |
|
| 625 |
-
.drift-
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
-
/* ββ Chat Thread ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 628 |
.chat-thread {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
display: flex;
|
| 630 |
flex-direction: column;
|
| 631 |
-
gap: var(--
|
| 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.
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
}
|
| 659 |
|
| 660 |
.bubble-body {
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 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 |
-
.
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
}
|
| 679 |
|
| 680 |
-
/* Offer chip
|
| 681 |
.offer-chip {
|
| 682 |
display: inline-flex;
|
| 683 |
align-items: center;
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
font-family: var(--font-mono);
|
| 688 |
-
font-size: 0.
|
| 689 |
-
font-weight:
|
| 690 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
}
|
| 692 |
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
|
|
|
|
|
|
|
|
|
| 696 |
}
|
| 697 |
|
| 698 |
-
.
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
|
|
|
| 702 |
}
|
|
|
|
|
|
|
| 703 |
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
padding:
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
}
|
| 715 |
|
| 716 |
-
.
|
| 717 |
-
.
|
| 718 |
-
.
|
| 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 |
-
/*
|
| 723 |
-
.
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 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(--
|
| 748 |
-
|
| 749 |
}
|
| 750 |
|
| 751 |
.offer-input {
|
| 752 |
flex: 1;
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
border:
|
| 756 |
-
|
| 757 |
-
background: var(--parlay-surface-2);
|
| 758 |
-
color: var(--parlay-ink);
|
| 759 |
font-family: var(--font-mono);
|
| 760 |
-
font-size: 0.
|
| 761 |
-
|
| 762 |
-
transition: border-color var(--t-fast), background var(--t-fast);
|
| 763 |
-
}
|
| 764 |
-
|
| 765 |
-
.offer-input:focus {
|
| 766 |
outline: none;
|
| 767 |
-
border-color
|
| 768 |
-
background: var(--parlay-surface);
|
| 769 |
-
box-shadow: 0 0 0 2px var(--parlay-blue);
|
| 770 |
}
|
|
|
|
|
|
|
| 771 |
|
| 772 |
.move-select {
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 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 |
-
.
|
| 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(--
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
border
|
|
|
|
| 804 |
font-size: 0.875rem;
|
| 805 |
font-weight: 600;
|
| 806 |
-
|
| 807 |
cursor: pointer;
|
| 808 |
-
transition: all var(--
|
| 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(--
|
| 820 |
-
color:
|
| 821 |
-
border-color: var(--
|
| 822 |
}
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
}
|
| 827 |
|
| 828 |
.btn-success {
|
| 829 |
-
background:
|
| 830 |
-
color:
|
| 831 |
-
border-color: var(--
|
| 832 |
-
}
|
| 833 |
-
|
| 834 |
-
.btn-success:not(:disabled):hover {
|
| 835 |
-
filter: brightness(1.1);
|
| 836 |
}
|
|
|
|
| 837 |
|
| 838 |
.btn-danger {
|
| 839 |
-
background:
|
| 840 |
-
color: var(--
|
| 841 |
-
border-color: var(--
|
| 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(--
|
| 852 |
-
border-color:
|
| 853 |
}
|
|
|
|
| 854 |
|
| 855 |
-
.btn-
|
| 856 |
-
background: var(--parlay-surface-2);
|
| 857 |
-
border-color: var(--parlay-border-2);
|
| 858 |
-
}
|
| 859 |
|
| 860 |
-
|
| 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(--
|
| 874 |
-
border: 1px solid
|
| 875 |
-
border-radius:
|
| 876 |
-
padding: var(--
|
| 877 |
}
|
| 878 |
|
| 879 |
.zopa-track-outer {
|
| 880 |
position: relative;
|
| 881 |
height: 32px;
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
border: 1px solid
|
| 885 |
-
margin: var(--
|
| 886 |
overflow: visible;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
}
|
| 888 |
|
| 889 |
.zopa-zone {
|
| 890 |
position: absolute;
|
| 891 |
-
top: 0;
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
border: 1px solid var(--
|
| 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:
|
| 903 |
-
transform: translate(-50%, -50%);
|
| 904 |
display: flex;
|
| 905 |
flex-direction: column;
|
| 906 |
align-items: center;
|
| 907 |
-
|
| 908 |
pointer-events: none;
|
| 909 |
}
|
| 910 |
|
| 911 |
.zopa-marker-line {
|
| 912 |
width: 2px;
|
| 913 |
-
|
| 914 |
-
|
| 915 |
}
|
| 916 |
|
| 917 |
-
.marker-player
|
| 918 |
-
.marker-opponent .zopa-marker-line { background: var(--
|
| 919 |
-
.marker-current
|
| 920 |
|
| 921 |
.zopa-label {
|
| 922 |
-
font-
|
| 923 |
-
font-
|
| 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 |
-
|
| 940 |
-
|
| 941 |
-
|
| 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 |
-
|
|
|
|
| 952 |
font-family: var(--font-mono);
|
| 953 |
-
|
| 954 |
-
|
| 955 |
}
|
| 956 |
|
| 957 |
-
/* ββ Tension Meter ββββββββββββββββββββββββββββββββββββββββββ */
|
| 958 |
.tension-section {
|
| 959 |
display: flex;
|
| 960 |
align-items: center;
|
| 961 |
-
gap: var(--
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
border:
|
| 965 |
-
|
| 966 |
}
|
| 967 |
|
| 968 |
.tension-label {
|
| 969 |
-
font-
|
| 970 |
-
font-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
color: var(--parlay-ink-3);
|
| 974 |
white-space: nowrap;
|
|
|
|
| 975 |
}
|
| 976 |
|
| 977 |
.tension-track {
|
| 978 |
flex: 1;
|
| 979 |
-
height:
|
| 980 |
-
|
| 981 |
-
|
|
|
|
| 982 |
overflow: hidden;
|
| 983 |
-
position: relative;
|
| 984 |
}
|
| 985 |
|
| 986 |
.tension-fill {
|
| 987 |
height: 100%;
|
| 988 |
-
border-radius:
|
| 989 |
-
|
|
|
|
| 990 |
}
|
|
|
|
|
|
|
| 991 |
|
| 992 |
.tension-value {
|
| 993 |
-
font-size: 0.75rem;
|
| 994 |
font-family: var(--font-mono);
|
| 995 |
-
font-
|
| 996 |
-
|
|
|
|
| 997 |
text-align: right;
|
| 998 |
-
color: var(--parlay-ink-2);
|
| 999 |
-
transition: color 500ms ease;
|
| 1000 |
}
|
| 1001 |
|
| 1002 |
-
/*
|
| 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 |
-
|
| 1016 |
-
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
#character-canvas {
|
| 1020 |
display: block;
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
}
|
| 1024 |
|
| 1025 |
.character-state-badge {
|
| 1026 |
position: absolute;
|
| 1027 |
-
bottom: var(--
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
border-radius:
|
| 1032 |
-
font-
|
| 1033 |
-
font-
|
| 1034 |
-
|
|
|
|
| 1035 |
text-transform: uppercase;
|
| 1036 |
-
|
| 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(--
|
| 1047 |
-
|
| 1048 |
-
border
|
| 1049 |
-
border:
|
| 1050 |
-
|
| 1051 |
}
|
| 1052 |
|
| 1053 |
.persona-avatar {
|
| 1054 |
-
width:
|
| 1055 |
-
|
| 1056 |
-
border
|
| 1057 |
-
display: flex;
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
font-size: 1rem;
|
| 1061 |
flex-shrink: 0;
|
| 1062 |
}
|
| 1063 |
|
| 1064 |
-
.persona-
|
| 1065 |
-
.persona-
|
| 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 |
-
|
| 1071 |
-
|
| 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:
|
|
|
|
| 1090 |
align-items: center;
|
| 1091 |
-
gap: var(--
|
| 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 |
-
|
| 1103 |
-
|
| 1104 |
-
background: var(--
|
| 1105 |
-
border
|
| 1106 |
overflow: hidden;
|
| 1107 |
}
|
| 1108 |
|
| 1109 |
.belief-fill {
|
| 1110 |
height: 100%;
|
| 1111 |
-
border-radius:
|
| 1112 |
-
transition: width
|
| 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 |
-
|
| 1130 |
-
.belief-confidence {
|
| 1131 |
-
width: 8px;
|
| 1132 |
-
height: 8px;
|
| 1133 |
-
border-radius: 50%;
|
| 1134 |
-
flex-shrink: 0;
|
| 1135 |
-
}
|
| 1136 |
|
| 1137 |
-
.
|
| 1138 |
-
.confidence-
|
| 1139 |
-
.confidence-
|
|
|
|
| 1140 |
|
| 1141 |
-
/* ββ
|
| 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 |
-
|
| 1155 |
-
margin-top:
|
| 1156 |
-
font-size: 0.625rem;
|
| 1157 |
-
font-family: var(--font-mono);
|
| 1158 |
-
color: var(--parlay-ink-3);
|
| 1159 |
}
|
|
|
|
| 1160 |
|
| 1161 |
-
/* ββ Leaderboard
|
| 1162 |
.leaderboard-table {
|
| 1163 |
width: 100%;
|
| 1164 |
border-collapse: collapse;
|
| 1165 |
-
font-size: 0.
|
| 1166 |
}
|
| 1167 |
-
|
| 1168 |
.leaderboard-table th {
|
| 1169 |
-
font-
|
| 1170 |
-
font-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
color: var(--parlay-ink-3);
|
| 1174 |
-
padding: 0 var(--sp-2) var(--sp-2);
|
| 1175 |
text-align: left;
|
| 1176 |
-
|
|
|
|
|
|
|
| 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(--
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
}
|
|
|
|
|
|
|
|
|
|
| 1189 |
|
| 1190 |
-
.
|
| 1191 |
-
|
| 1192 |
-
.
|
|
|
|
| 1193 |
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
color: var(--parlay-blue);
|
| 1197 |
-
font-weight: 600;
|
| 1198 |
-
}
|
| 1199 |
-
|
| 1200 |
-
.lb-rank {
|
| 1201 |
font-family: var(--font-mono);
|
| 1202 |
-
font-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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(--
|
| 1289 |
-
border: 1px solid
|
| 1290 |
-
border-radius:
|
| 1291 |
-
padding: var(--
|
| 1292 |
-
|
| 1293 |
-
flex-direction: column;
|
| 1294 |
-
|
| 1295 |
-
gap: var(--sp-4);
|
| 1296 |
-
min-width: 200px;
|
| 1297 |
}
|
| 1298 |
|
| 1299 |
.spinner {
|
| 1300 |
-
width: 36px;
|
| 1301 |
-
|
| 1302 |
-
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 |
-
|
| 1336 |
-
animation: bounce 1.2s ease-in-out infinite;
|
| 1337 |
}
|
| 1338 |
|
| 1339 |
-
.
|
| 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 |
-
/* ββ
|
| 1348 |
-
.
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
|
|
|
|
|
|
| 1353 |
display: flex;
|
| 1354 |
align-items: center;
|
| 1355 |
justify-content: center;
|
| 1356 |
-
|
| 1357 |
-
|
|
|
|
|
|
|
| 1358 |
}
|
| 1359 |
|
| 1360 |
-
.
|
| 1361 |
-
|
| 1362 |
-
.
|
| 1363 |
-
|
| 1364 |
-
|
| 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 |
-
|
| 1373 |
-
|
| 1374 |
-
to { opacity: 1; transform: scale(1) translateY(0); }
|
| 1375 |
}
|
| 1376 |
|
| 1377 |
-
.
|
| 1378 |
-
|
| 1379 |
-
border-bottom: 1px solid var(--parlay-border);
|
| 1380 |
}
|
| 1381 |
|
| 1382 |
-
.
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
color: var(--parlay-ink);
|
| 1386 |
}
|
| 1387 |
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
|
|
|
|
|
|
| 1392 |
}
|
| 1393 |
|
| 1394 |
-
.
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1399 |
}
|
| 1400 |
|
| 1401 |
-
.
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
display: flex;
|
| 1405 |
-
justify-content: flex-end;
|
| 1406 |
-
gap: var(--sp-3);
|
| 1407 |
}
|
| 1408 |
|
| 1409 |
-
.
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
|
|
|
|
|
|
|
|
|
| 1413 |
}
|
| 1414 |
|
| 1415 |
-
.
|
| 1416 |
-
font-
|
|
|
|
| 1417 |
font-weight: 600;
|
| 1418 |
-
color: var(--
|
|
|
|
|
|
|
| 1419 |
}
|
| 1420 |
|
| 1421 |
-
.
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1433 |
outline: none;
|
| 1434 |
-
|
| 1435 |
-
|
| 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 |
-
.
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 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 |
-
.
|
| 1472 |
-
|
| 1473 |
-
.model-tag.grpo { background: var(--parlay-green-bg); color: var(--parlay-green); }
|
| 1474 |
-
|
| 1475 |
-
.metric-row {
|
| 1476 |
display: flex;
|
| 1477 |
-
justify-content:
|
| 1478 |
-
|
| 1479 |
-
padding: var(--sp-2) 0;
|
| 1480 |
-
border-bottom: 1px solid var(--parlay-border);
|
| 1481 |
-
font-size: 0.8125rem;
|
| 1482 |
}
|
| 1483 |
|
| 1484 |
-
|
| 1485 |
-
.
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
}
|
| 1491 |
|
| 1492 |
-
|
| 1493 |
-
.
|
| 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 |
-
.
|
| 1504 |
-
|
| 1505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1506 |
|
| 1507 |
-
|
| 1508 |
-
|
| 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 |
-
.
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
gap: var(--sp-3);
|
| 1532 |
}
|
| 1533 |
|
| 1534 |
-
.
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
}
|
| 1539 |
|
| 1540 |
-
.
|
| 1541 |
-
font-
|
| 1542 |
-
font-
|
| 1543 |
-
|
| 1544 |
text-transform: uppercase;
|
| 1545 |
-
|
|
|
|
| 1546 |
}
|
| 1547 |
|
| 1548 |
-
.
|
| 1549 |
-
font-family: var(--font-
|
| 1550 |
-
font-size: 0.
|
| 1551 |
font-weight: 600;
|
| 1552 |
-
color: var(--
|
|
|
|
|
|
|
| 1553 |
}
|
| 1554 |
|
| 1555 |
-
.
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
}
|
| 1561 |
|
| 1562 |
-
.
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
|
|
|
|
|
|
|
|
|
| 1567 |
}
|
| 1568 |
|
| 1569 |
-
.
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 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 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
|
|
|
| 1590 |
}
|
| 1591 |
|
| 1592 |
-
|
| 1593 |
-
|
| 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 |
-
|
| 1604 |
-
|
| 1605 |
-
|
| 1606 |
-
border:
|
| 1607 |
-
|
| 1608 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1609 |
}
|
| 1610 |
|
| 1611 |
-
.
|
| 1612 |
-
|
| 1613 |
-
|
|
|
|
| 1614 |
border-radius: 4px;
|
| 1615 |
overflow: hidden;
|
| 1616 |
-
|
|
|
|
| 1617 |
}
|
| 1618 |
|
| 1619 |
-
.
|
|
|
|
| 1620 |
height: 100%;
|
| 1621 |
-
|
| 1622 |
-
border-radius: 4px;
|
| 1623 |
-
transition: width 300ms ease;
|
| 1624 |
-
width: 0%;
|
| 1625 |
}
|
| 1626 |
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 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 |
-
|
|
|
|
| 1637 |
}
|
| 1638 |
|
| 1639 |
-
.
|
| 1640 |
-
|
| 1641 |
-
|
| 1642 |
-
|
| 1643 |
-
|
| 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 |
-
.
|
| 1661 |
-
|
| 1662 |
-
|
| 1663 |
-
|
|
|
|
| 1664 |
}
|
| 1665 |
|
| 1666 |
-
|
| 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:
|
| 1675 |
}
|
| 1676 |
|
| 1677 |
-
.
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
}
|
| 1685 |
|
| 1686 |
-
.
|
| 1687 |
-
|
| 1688 |
-
|
| 1689 |
-
|
| 1690 |
-
|
| 1691 |
-
|
| 1692 |
-
font-weight: 700;
|
| 1693 |
}
|
| 1694 |
|
| 1695 |
-
.
|
| 1696 |
-
|
| 1697 |
-
|
|
|
|
|
|
|
| 1698 |
}
|
| 1699 |
|
| 1700 |
-
/* ββ
|
| 1701 |
-
|
| 1702 |
-
|
| 1703 |
-
|
| 1704 |
-
|
| 1705 |
-
|
| 1706 |
-
|
| 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
|
| 8 |
ports:
|
| 9 |
-
- "
|
| 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 |
-
|
|
|
|
| 19 |
build:
|
| 20 |
context: .
|
| 21 |
-
dockerfile: Dockerfile
|
|
|
|
| 22 |
ports:
|
| 23 |
-
- "
|
| 24 |
environment:
|
| 25 |
-
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
| 26 |
depends_on:
|
| 27 |
-
-
|
| 28 |
restart: unless-stopped
|
| 29 |
|
| 30 |
-
|
|
|
|
| 31 |
build:
|
| 32 |
context: .
|
| 33 |
-
dockerfile: Dockerfile.
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
- "8002:8002"
|
| 37 |
environment:
|
| 38 |
-
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
| 39 |
-
|
| 40 |
-
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
@app.get("/train", include_in_schema=False)
|
| 75 |
async def serve_train() -> FileResponse:
|
| 76 |
"""Serve the training dashboard."""
|
| 77 |
-
return FileResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
@app.get("/health")
|
| 81 |
async def health() -> dict:
|
| 82 |
-
"""
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 4 |
|
| 5 |
$ErrorActionPreference = "Stop"
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
Write-Host "Setting up Parlay..." -ForegroundColor Cyan
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
-
|
| 18 |
-
Write-Host "
|
| 19 |
exit 1
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
Write-Host "Creating game venv..." -ForegroundColor Yellow
|
| 24 |
-
|
|
|
|
|
|
|
| 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 "
|
| 40 |
-
Write-Host "
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|