| |
| |
| |
| |
| |
|
|
| const APP_DEBUG = false; |
| const API_BASE = ""; |
|
|
| |
| let gameState = { |
| sessionId: null, |
| persona: null, |
| scenario: null, |
| scenarioData: null, |
| observation: null, |
| hand: [], |
| done: false, |
| playerName: "Player", |
| turnCount: 0, |
| cp: 100, |
| maxCp: 100, |
| currentAct: 1, |
| actTranscripts: { 1: [] }, |
| lastPlayerOffer: null, |
| lastOpponentOffer: null, |
| pendingAiDeal: false, |
| stepperAmount: 145000, |
| selectedTactic: null, |
| }; |
|
|
| let character = null; |
| let charts = null; |
| let _driftTimer = null; |
| let _previewChars = {}; |
|
|
| let _currentStep = 1; |
|
|
| |
| document.addEventListener("DOMContentLoaded", () => { |
| _restoreTheme(); |
| _initOnboarding(); |
| _syncOnboardingInert(); |
|
|
| try { |
| if (typeof ParlayCharts !== "undefined") charts = new ParlayCharts(); |
| } catch (e) { |
| if (APP_DEBUG) console.error("[Parlay] ParlayCharts init failed:", e); |
| } |
|
|
| loadScenarios(); |
| loadPersonas(); |
| _checkDemoMode(); |
|
|
| |
| const submitBtn = document.getElementById("btn-submit"); |
| if (submitBtn) submitBtn.addEventListener("click", submitMove); |
|
|
| |
| const chipAccept = document.getElementById("chip-accept"); |
| if (chipAccept) chipAccept.addEventListener("click", acceptDeal); |
| const chipWalk = document.getElementById("chip-walk"); |
| if (chipWalk) chipWalk.addEventListener("click", walkAway); |
| const chipOffer = document.getElementById("chip-offer"); |
| if (chipOffer) chipOffer.addEventListener("click", _toggleOfferStepper); |
|
|
| document.querySelectorAll(".tactic-btn").forEach((btn) => { |
| btn.addEventListener("click", () => _toggleTacticButton(btn)); |
| }); |
|
|
| |
| document.getElementById("stepper-down")?.addEventListener("click", () => _stepOffer(-5000)); |
| document.getElementById("stepper-up")?.addEventListener("click", () => _stepOffer(+5000)); |
| document.getElementById("stepper-use")?.addEventListener("click", _useStepperOffer); |
| document.getElementById("stepper-cancel")?.addEventListener("click", _hideOfferStepper); |
|
|
| |
| document.getElementById("btn-briefing-begin")?.addEventListener("click", _dismissBriefing); |
|
|
| |
| document.getElementById("btn-dismiss-drift")?.addEventListener("click", dismissDriftAlert); |
|
|
| |
| document.getElementById("btn-dismiss-demo")?.addEventListener("click", () => { |
| document.getElementById("demo-banner")?.classList.add("hidden"); |
| document.body.classList.remove("demo-mode"); |
| }); |
|
|
| |
| const offerInput = document.getElementById("offer-input"); |
| if (offerInput) { |
| offerInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| submitMove(); |
| } |
| }); |
| } |
|
|
| |
| const themeBtn = document.getElementById("theme-toggle"); |
| if (themeBtn) themeBtn.addEventListener("click", _toggleTheme); |
|
|
| loadLeaderboard(); |
| }); |
|
|
| |
| function _restoreTheme() { |
| const saved = localStorage.getItem("parlay-theme") || "dark"; |
| document.documentElement.setAttribute("data-theme", saved); |
| const btn = document.getElementById("theme-toggle"); |
| if (btn) btn.textContent = saved === "light" ? "β" : "β"; |
| } |
|
|
| function _toggleTheme() { |
| const html = document.documentElement; |
| const isDark = html.getAttribute("data-theme") !== "light"; |
| const next = isDark ? "light" : "dark"; |
| html.setAttribute("data-theme", next); |
| const btn = document.getElementById("theme-toggle"); |
| if (btn) btn.textContent = isDark ? "β" : "β"; |
| localStorage.setItem("parlay-theme", next); |
| } |
|
|
| |
| async function _checkDemoMode() { |
| try { |
| const res = await fetch(`${API_BASE}/health`); |
| if (!res.ok) throw new Error("health"); |
| const data = await res.json(); |
| if (data.gemini === "mock") _showDemoBanner(); |
| } catch { _showDemoBanner(); } |
| } |
|
|
| function _showDemoBanner() { |
| document.getElementById("demo-banner")?.classList.remove("hidden"); |
| document.body.classList.add("demo-mode"); |
| } |
|
|
| |
| function _initOnboarding() { |
| document.getElementById("step1-name")?.addEventListener("keydown", e => { |
| if (e.key === "Enter") _goToStep(2); |
| }); |
| document.getElementById("step1-continue")?.addEventListener("click", () => _goToStep(2)); |
| document.getElementById("step2-back")?.addEventListener("click", () => _goToStep(1)); |
| document.getElementById("step2-continue")?.addEventListener("click", () => _goToStep(3)); |
| document.getElementById("step3-back")?.addEventListener("click", () => _goToStep(2)); |
| document.getElementById("step3-start")?.addEventListener("click", _handleStep3Start); |
| } |
|
|
| function _syncOnboardingInert() { |
| for (let n = 1; n <= 3; n++) { |
| const el = document.getElementById(`onboarding-step-${n}`); |
| if (!el) continue; |
| n === _currentStep ? el.removeAttribute("inert") : el.setAttribute("inert", ""); |
| } |
| } |
|
|
| function _goToStep(step) { |
| if (step === 2) { |
| const name = (document.getElementById("step1-name")?.value ?? "").trim(); |
| if (!name) { _showStepError(1, "Please enter your name."); return; } |
| _showStepError(1, ""); |
| } |
| if (step === 3) { |
| const sel = document.querySelector(".scenario-dossier.selected"); |
| if (!sel) { _showStepError(2, "Please select a scenario."); return; } |
| _showStepError(2, ""); |
| } |
|
|
| const currentEl = document.getElementById(`onboarding-step-${_currentStep}`); |
| if (currentEl) { |
| currentEl.classList.remove("active", "start-active"); |
| currentEl.classList.add("exiting"); |
| setTimeout(() => currentEl.classList.remove("exiting"), 300); |
| } |
|
|
| _currentStep = step; |
| _syncOnboardingInert(); |
|
|
| const nextEl = document.getElementById(`onboarding-step-${step}`); |
| if (nextEl) { |
| nextEl.classList.remove("exiting"); |
| requestAnimationFrame(() => requestAnimationFrame(() => nextEl.classList.add("active"))); |
| } |
| } |
|
|
| function _showStepError(step, msg) { |
| const el = document.getElementById(`step${step}-error`); |
| if (!el) return; |
| el.textContent = msg; |
| if (msg) setTimeout(() => { el.textContent = ""; }, 3000); |
| } |
|
|
| function _closeOnboarding() { |
| [1, 2, 3].forEach(n => { |
| const el = document.getElementById(`onboarding-step-${n}`); |
| if (el) { |
| el.classList.remove("active", "start-active", "exiting"); |
| el.removeAttribute("inert"); |
| el.style.display = "none"; |
| } |
| }); |
| } |
|
|
| function _handleStep3Start() { |
| const nameInput = document.getElementById("step1-name"); |
| const selScenario = document.querySelector(".scenario-dossier.selected"); |
| const selPersona = document.querySelector(".persona-card-option.selected"); |
|
|
| if (!selPersona) { _showStepError(3, "Please choose an opponent."); return; } |
|
|
| const name = nameInput?.value.trim() || "Player"; |
| const scenarioId = selScenario?.dataset.scenarioId || "saas_enterprise"; |
| const persona = selPersona.dataset.persona; |
|
|
| startGame(scenarioId, persona, name); |
| } |
|
|
| |
| async function startGame(scenarioId, persona, playerName) { |
| setLoading(true); |
| gameState.persona = persona; |
| gameState.scenario = scenarioId; |
| gameState.playerName = playerName; |
| gameState.done = false; |
| gameState.turnCount = 0; |
| gameState.currentAct = 1; |
| gameState.lastPlayerOffer = null; |
| gameState.lastOpponentOffer = null; |
| gameState.pendingAiDeal = false; |
| gameState.selectedTactic = null; |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/game/start`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ scenario_id: scenarioId, persona, player_name: playerName }), |
| }); |
|
|
| let data; |
| if (!res.ok) { |
| data = _mockStartData(scenarioId, persona, playerName); |
| _showDemoBanner(); |
| } else { |
| data = await res.json(); |
| } |
|
|
| gameState.sessionId = data.session_id; |
| gameState.observation = data.observation; |
| gameState.hand = data.hand || []; |
| gameState.cp = data.cp ?? data.observation?.credibility_points ?? 100; |
| gameState.maxCp = data.max_cp ?? 100; |
| gameState.scenarioData = data.scenario || null; |
|
|
| _closeOnboarding(); |
| _destroyPreviewChars(); |
| updateUI(data); |
| _initCharacter(persona); |
| _initSparkline(data.observation); |
| _updateScenarioHeader(data); |
| _updatePersonaPanel(data); |
| _updateNameplate(data); |
|
|
| |
| _showBriefing(data); |
|
|
| if (APP_DEBUG) console.log("[app] game started", data); |
| } catch (e) { |
| if (APP_DEBUG) console.error("[app] startGame error:", e); |
| const data = _mockStartData(scenarioId, persona, playerName); |
| gameState.sessionId = data.session_id; |
| gameState.observation = data.observation; |
| gameState.hand = data.hand; |
| gameState.cp = 100; |
| gameState.scenarioData = data.scenario; |
|
|
| _closeOnboarding(); |
| _destroyPreviewChars(); |
| updateUI(data); |
| _initCharacter(persona); |
| _showDemoBanner(); |
| _showBriefing(data); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| |
| function _showBriefing(data) { |
| const overlay = document.getElementById("briefing-overlay"); |
| if (!overlay) return; |
|
|
| const sc = data.scenario || {}; |
| const obs = data.observation || {}; |
|
|
| const zopaLo = obs.zopa_lower ?? sc.zopa_lower ?? 125000; |
| const zopaHi = obs.zopa_upper ?? sc.zopa_upper ?? 165000; |
| const scenarioId = sc.id || gameState.scenario || ""; |
| const caseNum = scenarioId.replace(/_/g, "-").toUpperCase(); |
|
|
| const _s = (v) => formatCurrency(v, "USD"); |
|
|
| const caseNumEl = document.getElementById("briefing-case-num"); |
| const titleEl = document.getElementById("briefing-title"); |
| const goalEl = document.getElementById("briefing-your-goal"); |
| const theirEl = document.getElementById("briefing-their-goal"); |
| const rangeEl = document.getElementById("briefing-range"); |
|
|
| if (caseNumEl) caseNumEl.textContent = `CASE FILE #${caseNum || "001"}`; |
| if (titleEl) titleEl.textContent = sc.title || "Negotiation"; |
| if (goalEl) goalEl.textContent = `Close the deal above ${_s(zopaLo)}. Your ideal: ${_s(zopaHi)}.`; |
| if (theirEl) theirEl.textContent = `Pay as little as possible. They'll push hard on price from around ${_s(zopaLo)}.`; |
| if (rangeEl) rangeEl.textContent = `A deal is possible between ${_s(zopaLo)} and ${_s(zopaHi)}.`; |
|
|
| |
| gameState.stepperAmount = Math.round((zopaLo + zopaHi) / 2 / 1000) * 1000; |
| _updateStepperDisplay(); |
|
|
| overlay.style.display = "flex"; |
| } |
|
|
| function _dismissBriefing() { |
| const overlay = document.getElementById("briefing-overlay"); |
| if (overlay) overlay.style.display = "none"; |
|
|
| |
| _setInputsDisabled(false); |
|
|
| |
| const thread = document.getElementById("chat-thread"); |
| if (thread) { |
| |
| const existing = thread.querySelector(".system-msg"); |
| if (existing) existing.remove(); |
| } |
| addMessage("system", `Game started. You are negotiating as ${_personaLabel(gameState.persona)}.`); |
|
|
| |
| const opener = gameState.observation?.opening_message |
| || gameState._pendingOpener; |
| if (opener) { |
| addMessage("opponent", opener, gameState.observation?.opponent_offer ?? null, null); |
| } else { |
| |
| const persona = gameState.persona || "shark"; |
| const mockOpeners = { |
| shark: "Let's not waste each other's time. What's your opening number?", |
| diplomat: "I believe we can find a solution that works for both of us. Shall we begin?", |
| veteran: "I've been in rooms like this before. Let's get to it.", |
| }; |
| addMessage("opponent", mockOpeners[persona] || "Let's begin.", null, null); |
| } |
| } |
|
|
| function _mockStartData(scenarioId, persona, playerName) { |
| const mockScenarios = { |
| saas_enterprise: { id: "saas_enterprise", title: "Enterprise SaaS Contract", zopa_lower: 125000, zopa_upper: 165000 }, |
| hiring_package: { id: "hiring_package", title: "Senior Engineer Offer", zopa_lower: 195000, zopa_upper: 230000 }, |
| acquisition_term_sheet: { id: "acquisition_term_sheet", title: "Startup Acquisition", zopa_lower: 10500000, zopa_upper: 16000000 }, |
| }; |
| const s = mockScenarios[scenarioId] || mockScenarios.saas_enterprise; |
| const nash = (s.zopa_lower + s.zopa_upper) / 2; |
| const sid = "mock-" + Math.random().toString(36).slice(2); |
|
|
| return { |
| session_id: sid, |
| scenario: s, |
| observation: { |
| step_count: 0, zopa_lower: s.zopa_lower, zopa_upper: s.zopa_upper, |
| nash_point: nash, tension_score: 10, credibility_points: 100, zopa_width_pct_remaining: 1.0, |
| belief_state: { cooperative: 0.5, competitive: 0.5, |
| reservation: s.zopa_lower / s.zopa_upper, flexibility: 0.5 }, |
| }, |
| persona: { id: persona, name: _personaLabel(persona), symbol: _personaSymbol(persona), emoji: "β" }, |
| hand: [], |
| opening_message: null, |
| cp: 100, max_cp: 100, |
| }; |
| } |
|
|
| |
| function _parseOptionalOffer(raw) { |
| if (!raw || !String(raw).trim()) return null; |
| const s = String(raw).trim(); |
| const withK = /\$?\s*([\d,]+(?:\.\d+)?)\s*([kK])\b/.exec(s); |
| if (withK) { |
| const n = parseFloat(withK[1].replace(/,/g, ""), 10); |
| if (!isNaN(n)) return n * 1000; |
| } |
| const m = s.match(/\$?\s*([\d]{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\b/); |
| if (!m) return null; |
| const n = parseFloat(m[1].replace(/,/g, ""), 10); |
| return isNaN(n) ? null : n; |
| } |
|
|
| |
| async function submitMove() { |
| if (gameState.done || !gameState.sessionId) return; |
|
|
| const offerInput = document.getElementById("offer-input"); |
|
|
| const raw = offerInput?.value.trim() ?? ""; |
| const cardId = gameState.selectedTactic; |
| if (!raw && !cardId) return; |
|
|
| const offer = _parseOptionalOffer(raw); |
|
|
| const msgText = offer != null |
| ? `Counter offer: ${formatCurrency(offer, "USD")}.` |
| : (raw || (cardId ? `(Tactical: ${cardId})` : "Let me think about that.")); |
|
|
| if (offer != null) gameState.lastPlayerOffer = offer; |
|
|
| addMessage("player", msgText, offer, cardId ? cardId : null); |
| const thinkId = _showThinkingBubble(); |
| setLoading(true); |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/game/step`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| session_id: gameState.sessionId, |
| move: offer != null || cardId ? "counter" : "chat", |
| offer_amount: offer, |
| card_id: cardId, |
| message: raw || (cardId ? `(tactical: ${cardId})` : ""), |
| }), |
| }); |
|
|
| const data = res.ok ? await res.json() : _mockStepData(offer, "counter"); |
|
|
| gameState.observation = data.observation; |
| gameState.done = data.done ?? false; |
| gameState.hand = data.hand || gameState.hand; |
| gameState.cp = data.cp ?? data.observation?.credibility_points ?? gameState.cp; |
| gameState.turnCount += 1; |
|
|
| _removeThinkingBubble(thinkId); |
| updateUI(data); |
|
|
| const oppMsg = data.opponent_message ?? data.opponent?.utterance; |
| const oppOffer = data.observation?.opponent_offer ?? data.opponent?.offer ?? null; |
| const oppMove = data.opponent_move ?? data.opponent?.tactical_move ?? null; |
|
|
| if (oppOffer != null) gameState.lastOpponentOffer = oppOffer; |
|
|
| if (oppMsg) { |
| |
| const aiClose = _checkAiDealClose(oppOffer, oppMsg, data); |
| if (aiClose) { |
| _showAiDealOffer(oppMsg, oppOffer); |
| } else { |
| const bubble = addMessage("opponent", oppMsg, oppOffer, oppMove); |
| if (bubble && gameState.persona) bubble.setAttribute("data-persona", gameState.persona); |
| } |
| } |
|
|
| |
| if (data.observation) updateCharacterFromGameState(data.observation); |
|
|
| if (gameState.done) _handleGameOver(data); |
|
|
| if (offerInput) offerInput.value = ""; |
| gameState.selectedTactic = null; |
| _updateTacticalButtons(); |
|
|
| } catch (e) { |
| _removeThinkingBubble(thinkId); |
| _showError("Move failed: " + e.message); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| function _mockStepData(offer, move) { |
| const obs = gameState.observation || {}; |
| const tens = Math.min(100, (obs.tension_score || 10) + 7); |
| const mockOffer = offer ? Math.round(offer * 0.97) : null; |
| if (mockOffer) gameState.lastOpponentOffer = mockOffer; |
| return { |
| observation: { ...obs, tension_score: tens, step_count: (obs.step_count || 0) + 1, |
| opponent_offer: mockOffer }, |
| opponent_message: "That's an interesting position. Here's my counter.", |
| opponent: { utterance: "That's an interesting position. Here's my counter.", offer: mockOffer }, |
| done: false, |
| }; |
| } |
|
|
| |
| function _checkAiDealClose(oppOffer, oppMsg, data) { |
| if (!oppOffer || !gameState.lastPlayerOffer) return false; |
| const diff = Math.abs(oppOffer - gameState.lastPlayerOffer); |
| const pct = diff / Math.max(gameState.lastPlayerOffer, 1); |
| if (pct < 0.03 && Math.random() < 0.30) return true; |
|
|
| |
| const tension = data.observation?.tension_score ?? 0; |
| const cumRew = data.observation?.cumulative_reward ?? 0; |
| if (tension >= 90 && cumRew < -30 && Math.random() < 0.20) { |
| _showAiWalkAway(); |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| function _showAiDealOffer(msg, offer) { |
| const thread = document.getElementById("chat-thread"); |
| if (!thread) return; |
|
|
| |
| const bubble = document.createElement("div"); |
| bubble.className = `message-bubble opponent deal-offer`; |
| if (gameState.persona) bubble.setAttribute("data-persona", gameState.persona); |
|
|
| const meta = document.createElement("div"); |
| meta.className = "bubble-meta"; |
| const nameSpan = document.createElement("span"); |
| nameSpan.textContent = "Opponent"; |
| meta.appendChild(nameSpan); |
| const pill = document.createElement("span"); |
| pill.className = "move-pill"; |
| pill.textContent = "β Deal offered"; |
| meta.appendChild(pill); |
|
|
| const body = document.createElement("div"); |
| body.className = "bubble-body"; |
| body.textContent = msg || "Deal β let's close this."; |
|
|
| if (offer != null && !isNaN(offer)) { |
| const chip = document.createElement("div"); |
| chip.className = "offer-chip"; |
| chip.textContent = formatCurrency(offer, "USD"); |
| body.appendChild(chip); |
| } |
|
|
| |
| const actions = document.createElement("div"); |
| actions.className = "ai-deal-prompt"; |
| const acceptBtn = document.createElement("button"); |
| acceptBtn.className = "ai-deal-btn accept"; |
| acceptBtn.textContent = "Accept β"; |
| acceptBtn.onclick = () => acceptDeal(); |
| const counterBtn = document.createElement("button"); |
| counterBtn.className = "ai-deal-btn counter"; |
| counterBtn.textContent = "Counter"; |
| counterBtn.onclick = () => { actions.remove(); }; |
|
|
| actions.appendChild(acceptBtn); |
| actions.appendChild(counterBtn); |
|
|
| bubble.appendChild(meta); |
| bubble.appendChild(body); |
| bubble.appendChild(actions); |
| thread.appendChild(bubble); |
| _scrollThread(thread); |
|
|
| gameState.pendingAiDeal = true; |
| } |
|
|
| function _showAiWalkAway() { |
| const walkMsg = "I'm afraid we're too far apart. I'm walking away."; |
| addMessage("opponent", walkMsg, null, null); |
| setTimeout(() => _triggerWalkAwayModal(false), 800); |
| } |
|
|
| |
| async function acceptDeal() { |
| if (gameState.done || !gameState.sessionId) return; |
|
|
| |
| if (!gameState.lastPlayerOffer && !gameState.lastOpponentOffer) { |
| _showInlineWarning("Make an offer first before accepting."); |
| return; |
| } |
|
|
| const dealAmount = gameState.lastOpponentOffer || gameState.lastPlayerOffer; |
|
|
| addMessage("player", "I accept the deal.", dealAmount, "accept"); |
| const thinkId = _showThinkingBubble(); |
| setLoading(true); |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/game/accept`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ session_id: gameState.sessionId }), |
| }); |
| const data = res.ok ? await res.json() : { deal_reached: true, deal_amount: dealAmount, reward: 0 }; |
|
|
| _removeThinkingBubble(thinkId); |
| gameState.done = true; |
| updateUI(data); |
|
|
| const finalPrice = data.final_price ?? data.deal_amount ?? dealAmount; |
| _handleGameOver({ ...data, deal_reached: true, deal_amount: finalPrice }); |
| } catch (e) { |
| _removeThinkingBubble(thinkId); |
| _showError(e.message); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| |
| function _showActTransition(act, price, obs) { |
| const zopaLo = obs.zopa_lower ?? 125000; |
| const zopaHi = obs.zopa_upper ?? 165000; |
| const zopaWidth = Math.max(1, zopaHi - zopaLo); |
| const efficiency = price ? Math.max(0, Math.min(1, (price - zopaLo) / zopaWidth)) : 0; |
| const captured = price ? Math.round(price - zopaLo) : 0; |
| const available = Math.round(zopaWidth); |
| const nash = (zopaLo + zopaHi) / 2; |
| const nashDiff = price ? Math.abs(price - nash) / nash : 1; |
| const nashNote = nashDiff < 0.05 ? "You nailed it." : nashDiff < 0.15 ? "Close to optimal." : "Room to improve next time."; |
|
|
| const overlay = document.createElement("div"); |
| overlay.className = "act-modal-overlay"; |
| overlay.id = "act-modal"; |
|
|
| overlay.innerHTML = ` |
| <div class="act-modal-card" role="dialog" aria-label="Act ${act} complete"> |
| <div class="act-modal-title">ACT ${['I','II','III'][act-1]} COMPLETE</div> |
| <div class="act-modal-price">${formatCurrency(price, "USD")}</div> |
| <div class="act-modal-eff"> |
| <div class="act-modal-eff-label">Efficiency: ${Math.round(efficiency * 100)}%</div> |
| <div class="act-modal-eff-track"> |
| <div class="act-modal-eff-fill" style="width:0%;"></div> |
| </div> |
| </div> |
| <div class="act-modal-caption"> |
| You captured ${formatCurrency(captured, "USD")} of the |
| ${formatCurrency(available, "USD")} available. |
| </div> |
| <div class="act-modal-nash">Nash optimal was ${formatCurrency(nash, "USD")}. ${nashNote}</div> |
| <button class="act-modal-btn" type="button">Continue to Act ${['II','III'][act-1] || 'End'}: ${['Terms β','Coalition β'][act-1] || 'β'}</button> |
| </div> |
| `; |
|
|
| document.body.appendChild(overlay); |
|
|
| |
| requestAnimationFrame(() => { |
| const fill = overlay.querySelector(".act-modal-eff-fill"); |
| if (fill) fill.style.width = `${Math.round(efficiency * 100)}%`; |
| }); |
|
|
| overlay.querySelector(".act-modal-btn").onclick = () => { |
| overlay.remove(); |
| _advanceAct(act + 1, price); |
| }; |
| } |
|
|
| function _advanceAct(nextAct, priceFromPrev) { |
| gameState.currentAct = nextAct; |
| gameState.actsCompleted = nextAct - 1; |
| gameState.done = false; |
| gameState.lastPlayerOffer = null; |
| gameState.lastOpponentOffer = null; |
|
|
| _updateActPills(nextAct); |
|
|
| if (nextAct === 2) { |
| addMessage("system", `Act II β Terms. Price locked at ${formatCurrency(priceFromPrev, "USD")}. Now negotiate the package.`); |
| _showActIIBriefing(priceFromPrev); |
| } else if (nextAct === 3) { |
| addMessage("system", "Act III β Coalition. A third party has entered the room."); |
| _showActIIIIntro(); |
| } else { |
| |
| _handleGameOver({ deal_reached: true, deal_amount: priceFromPrev, reward: 0 }); |
| } |
|
|
| _setInputsDisabled(false); |
| } |
|
|
| |
| function _showActIIBriefing(lockedPrice) { |
| addMessage("opponent", `Price is locked at ${formatCurrency(lockedPrice, "USD")}. Now let's talk about the terms β payment schedule, SLA, and contract length.`, null, null); |
| } |
|
|
| |
| function _showActIIIIntro() { |
| addMessage("system", "The Board has entered. A third party with its own interests will interject every few turns."); |
| addMessage("opponent", "I've been authorized to proceed, but The Board will have the final word on certain terms.", null, null); |
| |
| gameState._boardInterjectionTurns = [ |
| gameState.turnCount + 3, |
| gameState.turnCount + 6, |
| gameState.turnCount + 9, |
| ]; |
| } |
|
|
| |
| async function walkAway() { |
| if (gameState.done || !gameState.sessionId) return; |
| addMessage("player", "I'm walking away from the table.", null, "walk"); |
| const thinkId = _showThinkingBubble(); |
| setLoading(true); |
|
|
| try { |
| const res = await fetch(`${API_BASE}/api/game/walkaway`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ session_id: gameState.sessionId }), |
| }); |
| const data = res.ok ? await res.json() : { result: "walk_away" }; |
| _removeThinkingBubble(thinkId); |
| gameState.done = true; |
| updateUI(data); |
| _triggerWalkAwayModal(true, data); |
| } catch (e) { |
| _removeThinkingBubble(thinkId); |
| _showError(e.message); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| function _triggerWalkAwayModal(playerInitiated, data) { |
| const obs = gameState.observation || {}; |
| const zopaLo = obs.zopa_lower ?? 125000; |
| const zopaHi = obs.zopa_upper ?? 165000; |
| const left = zopaHi - zopaLo; |
| const reasons = _buildWalkAwayReasons(obs, data); |
|
|
| const overlay = document.createElement("div"); |
| overlay.className = "walk-modal-overlay"; |
| overlay.id = "walk-modal"; |
|
|
| const reasonsHtml = reasons.map(r => `<div class="walk-reason-item">${r}</div>`).join(""); |
|
|
| overlay.innerHTML = ` |
| <div class="walk-modal-card" role="dialog" aria-label="No deal"> |
| <div class="walk-modal-title">No Deal</div> |
| <div class="walk-modal-sub">The negotiation collapsed.</div> |
| <div class="walk-modal-left">You left ${formatCurrency(left, "USD")} on the table.</div> |
| <div class="walk-modal-range">The deal was possible between ${formatCurrency(zopaLo,"USD")} and ${formatCurrency(zopaHi,"USD")}.</div> |
| ${reasons.length ? `<div class="walk-modal-reasons"><div class="walk-modal-reasons-label">What went wrong:</div>${reasonsHtml}</div>` : ""} |
| <div class="walk-modal-actions"> |
| <button class="walk-modal-btn primary" id="walk-try-again">Try Again</button> |
| <button class="walk-modal-btn" id="walk-change-opp">Change Opponent</button> |
| </div> |
| </div> |
| `; |
|
|
| document.body.appendChild(overlay); |
|
|
| overlay.querySelector("#walk-try-again").onclick = () => { |
| overlay.remove(); |
| startGame(gameState.scenario, gameState.persona, gameState.playerName); |
| }; |
| overlay.querySelector("#walk-change-opp").onclick = () => { |
| overlay.remove(); |
| location.reload(); |
| }; |
| } |
|
|
| function _buildWalkAwayReasons(obs, data) { |
| const reasons = []; |
| const tension = obs.tension_score ?? 0; |
| const stepCount = obs.step_count ?? 0; |
| const concRate = data?.concession_rate ?? 0; |
| const tomAcc = data?.tom_accuracy ?? obs.tom_accuracy ?? 1; |
| const tacticsUsed = gameState.hand && gameState.hand.length > 0; |
|
|
| if (concRate > 0.15) reasons.push("Too many concessions early"); |
| if (tension > 80) reasons.push(`Tension peaked at turn ${stepCount}`); |
| if (!tacticsUsed && gameState.turnCount > 3) reasons.push("No tactical moves played"); |
| if (tomAcc < 0.4) reasons.push("Misread the opponent's constraints"); |
| return reasons; |
| } |
|
|
| |
| function _checkBoardInterject() { |
| return; |
| const turns = gameState._boardInterjectionTurns || []; |
| const idx = turns.indexOf(gameState.turnCount); |
| if (idx === -1) return; |
|
|
| const msgs = [ |
| "I've been told another vendor can deliver in 4 weeks. That changes our calculus.", |
| "The budget committee approved up to the higher range β but they want delivery certainty.", |
| "The Board is watching this closely. We need to wrap this up.", |
| ]; |
| const msg = msgs[idx % msgs.length]; |
| setTimeout(() => { |
| addMessage("system", `β The Board: "${msg}"`); |
| }, 500); |
| } |
|
|
| |
| function _showInlineWarning(msg) { |
| const thread = document.getElementById("chat-thread"); |
| if (!thread) return; |
| const div = document.createElement("div"); |
| div.className = "system-msg"; |
| div.style.color = "var(--scarlet-light)"; |
| div.textContent = msg; |
| thread.appendChild(div); |
| _scrollThread(thread); |
| setTimeout(() => div.remove(), 3000); |
| } |
|
|
| |
| function _toggleOfferStepper() { |
| const stepper = document.getElementById("offer-stepper"); |
| if (!stepper) return; |
| const visible = stepper.classList.contains("visible"); |
| stepper.classList.toggle("visible", !visible); |
| } |
|
|
| function _hideOfferStepper() { |
| document.getElementById("offer-stepper")?.classList.remove("visible"); |
| } |
|
|
| function _stepOffer(delta) { |
| const obs = gameState.observation || {}; |
| const lo = obs.zopa_lower ?? 0; |
| const hi = obs.zopa_upper ?? 999999999; |
| gameState.stepperAmount = Math.max(lo, Math.min(hi, (gameState.stepperAmount || lo) + delta)); |
| _updateStepperDisplay(); |
| } |
|
|
| function _updateStepperDisplay() { |
| const el = document.getElementById("stepper-value"); |
| if (el) el.textContent = formatCurrency(gameState.stepperAmount, "USD"); |
| } |
|
|
| function _useStepperOffer() { |
| const offerInput = document.getElementById("offer-input"); |
| if (offerInput) offerInput.value = String(gameState.stepperAmount); |
| _hideOfferStepper(); |
| offerInput?.focus(); |
| } |
|
|
| |
| async function loadScenarios() { |
| try { |
| const res = await fetch(`${API_BASE}/api/scenarios`); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
| _renderScenarioDossiers(data.scenarios || data || []); |
| } catch { |
| _renderScenarioDossiers([]); |
| } |
| } |
|
|
| async function loadPersonas() { |
| try { |
| const res = await fetch(`${API_BASE}/api/personas`); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
| _renderPersonaCards(data.personas || data || []); |
| } catch { |
| _renderPersonaCards([]); |
| } |
| } |
|
|
| async function loadLeaderboard() { |
| try { |
| const res = await fetch(`${API_BASE}/api/leaderboard?limit=5`); |
| if (!res.ok) return; |
| const data = await res.json(); |
| renderLeaderboard(data.entries || data || []); |
| } catch {} |
| } |
|
|
| |
| function updateUI(response) { |
| const obs = response.observation || gameState.observation; |
|
|
| if (obs) { |
| updateZOPABar(obs); |
| updateBeliefBars(obs.belief_state || obs.beliefState); |
| updateCharacterState(obs); |
| updateCharacterFromGameState(obs); |
| } |
|
|
| const tension = obs?.tension_score ?? obs?.tension ?? 0; |
| updateTensionMeter(tension); |
| updateCPBar(gameState.cp); |
| _updateTacticalButtons(); |
|
|
| const drift = response.drift_event || obs?.drift_event; |
| if (drift) showDriftAlert(drift); |
|
|
| const act = 1; |
| _updateActPills(act); |
|
|
| if (charts && obs) { |
| const playerOffer = obs.player_offer ?? obs.your_offer ?? null; |
| const opponentOffer = obs.opponent_offer ?? null; |
| if (playerOffer !== null || opponentOffer !== null) { |
| charts.updateOfferSparkline?.call(charts, playerOffer, opponentOffer, gameState.turnCount); |
| } |
| if (obs.belief_state) charts.updateBeliefChart?.call(charts, obs.belief_state); |
| } |
|
|
| const avatarEl = document.getElementById("player-avatar"); |
| if (avatarEl && gameState.playerName) avatarEl.textContent = gameState.playerName.charAt(0).toUpperCase(); |
|
|
| const nameEl = document.getElementById("player-name-display"); |
| if (nameEl) nameEl.textContent = gameState.playerName; |
|
|
| _setInputsDisabled(gameState.done); |
|
|
| |
| _checkBoardInterject(); |
| } |
|
|
| |
| function updateCharacterFromGameState(obs) { |
| if (!character) return; |
| const tension = obs?.tension_score ?? obs?.tension ?? 0; |
| const drift = obs?.drift_event; |
| const cumReward = obs?.cumulative_reward ?? obs?.reward ?? 0; |
|
|
| let state = "idle"; |
| if (drift) state = "shocked"; |
| else if (tension > 80) state = "aggressive"; |
| else if (tension > 55) state = "thinking"; |
| else if (cumReward > 15) state = "pleased"; |
|
|
| character.setState(state); |
|
|
| if (drift) setTimeout(() => { |
| if (character && gameState.sessionId) character.setState("aggressive"); |
| }, 2000); |
|
|
| const label = document.getElementById("character-state-label"); |
| if (label) label.textContent = state; |
| const badge = document.querySelector(".character-state-badge"); |
| if (badge) badge.textContent = state; |
| } |
|
|
| function _updateScenarioHeader(data) { |
| const titleEl = document.getElementById("scenario-title"); |
| const metaEl = document.getElementById("scenario-meta"); |
| const sessionEl = document.getElementById("session-id-label"); |
| const sc = data.scenario || {}; |
| if (titleEl) titleEl.textContent = sc.title || data.scenario_id || "Negotiation"; |
| if (metaEl) metaEl.textContent = sc.description || ""; |
| if (sessionEl) sessionEl.textContent = `Session: ${gameState.sessionId || data.session_id || "β"}`; |
| } |
|
|
| function _updatePersonaPanel(data) { |
| const p = data.persona || {}; |
| const nameEl = document.getElementById("persona-name"); |
| const descEl = document.getElementById("persona-desc"); |
| const avatEl = document.getElementById("persona-avatar"); |
| if (nameEl) nameEl.textContent = p.name || _personaLabel(gameState.persona); |
| if (descEl) descEl.textContent = p.style || ""; |
| if (avatEl) avatEl.textContent = p.symbol || p.emoji || _personaSymbol(gameState.persona); |
| } |
|
|
| function _updateNameplate(data) { |
| const p = data.persona || {}; |
| const sym = document.getElementById("nameplate-symbol"); |
| const name = document.getElementById("nameplate-name"); |
| const tag = document.getElementById("nameplate-tag"); |
|
|
| const personaId = p.id || gameState.persona || "shark"; |
| const pName = p.name || _personaLabel(personaId); |
| const pSym = p.symbol || _personaSymbol(personaId); |
| const pTag = _personaTag(personaId); |
|
|
| if (sym) { |
| sym.textContent = pSym; |
| sym.style.color = _personaColor(personaId); |
| } |
| if (name) name.textContent = pName; |
| if (tag) tag.textContent = pTag; |
| } |
|
|
| |
| function addMessage(role, text, offer, move) { |
| const thread = document.getElementById("chat-thread"); |
| if (!thread) return null; |
|
|
| const existing = thread.querySelector(".thinking-bubble"); |
| if (existing) existing.remove(); |
|
|
| |
| if (role === "system") { |
| const sys = document.createElement("div"); |
| sys.className = "system-msg"; |
| sys.textContent = text; |
| thread.appendChild(sys); |
| _scrollThread(thread); |
| return null; |
| } |
|
|
| const bubble = document.createElement("div"); |
| bubble.className = `message-bubble ${role}`; |
| if (role === "opponent" && gameState.persona) { |
| bubble.setAttribute("data-persona", gameState.persona); |
| } |
|
|
| const meta = document.createElement("div"); |
| meta.className = "bubble-meta"; |
| const nameSpan = document.createElement("span"); |
| nameSpan.textContent = role === "player" ? gameState.playerName : "Opponent"; |
| meta.appendChild(nameSpan); |
|
|
| if (move) { |
| const pill = document.createElement("span"); |
| pill.className = `move-pill ${move}`; |
| pill.textContent = move.replace(/_/g, " "); |
| meta.appendChild(pill); |
| } |
|
|
| const body = document.createElement("div"); |
| body.className = "bubble-body"; |
| body.textContent = text; |
|
|
| if (offer != null && !isNaN(offer)) { |
| const chip = document.createElement("div"); |
| chip.className = "offer-chip"; |
| chip.textContent = formatCurrency(offer, "USD"); |
| body.appendChild(chip); |
| } |
|
|
| bubble.appendChild(meta); |
| bubble.appendChild(body); |
| thread.appendChild(bubble); |
|
|
| |
| const actKey = 1; |
| if (!gameState.actTranscripts[actKey]) gameState.actTranscripts[actKey] = []; |
| gameState.actTranscripts[actKey].push({ role, text, offer, move }); |
|
|
| _scrollThread(thread); |
| return bubble; |
| } |
|
|
| function _showThinkingBubble() { |
| const thread = document.getElementById("chat-thread"); |
| if (!thread) return null; |
| const id = "thinking-" + Date.now(); |
| const wrap = document.createElement("div"); |
| wrap.className = "thinking-bubble"; wrap.id = id; |
| for (let i = 0; i < 3; i++) { |
| const dot = document.createElement("div"); |
| dot.className = "thinking-dot"; wrap.appendChild(dot); |
| } |
| thread.appendChild(wrap); |
| _scrollThread(thread); |
| return id; |
| } |
|
|
| function _removeThinkingBubble(id) { |
| if (!id) return; |
| document.getElementById(id)?.remove(); |
| } |
|
|
| function _scrollThread(thread) { |
| requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; }); |
| } |
|
|
| |
| function _renderTacticChips(hand) { |
| const row = document.getElementById("tactic-chips-row"); |
| if (!row) return; |
| row.innerHTML = ""; |
|
|
| if (!hand || !hand.length) return; |
|
|
| hand.forEach(card => { |
| const chip = document.createElement("button"); |
| chip.className = "tactic-chip"; |
| chip.dataset.cardId = card.id || card.card_id || card.move || ""; |
| chip.type = "button"; |
|
|
| const costDiv = document.createElement("span"); |
| costDiv.className = "tactic-chip-cost"; |
| costDiv.textContent = String(card.cp_cost ?? card.cost ?? "?"); |
| chip.appendChild(costDiv); |
|
|
| const labelSpan = document.createElement("span"); |
| labelSpan.textContent = card.name || card.move || "Tactic"; |
| chip.appendChild(labelSpan); |
|
|
| chip.addEventListener("click", () => { |
| row.querySelectorAll(".tactic-chip").forEach(c => c.classList.remove("selected")); |
| chip.classList.toggle("selected"); |
| }); |
|
|
| row.appendChild(chip); |
| }); |
| } |
|
|
| function _toggleTacticButton(button) { |
| const cardId = button?.dataset.card || null; |
| if (!cardId || button.disabled) return; |
| gameState.selectedTactic = gameState.selectedTactic === cardId ? null : cardId; |
| _updateTacticalButtons(); |
| } |
|
|
| function _updateTacticalButtons() { |
| document.querySelectorAll(".tactic-btn").forEach((btn) => { |
| const cost = Number(btn.dataset.cost || "0"); |
| const cardId = btn.dataset.card || ""; |
| btn.disabled = gameState.done || gameState.cp < cost || !gameState.sessionId; |
| btn.classList.toggle("selected", gameState.selectedTactic === cardId); |
| }); |
| } |
|
|
| function getZopaColor(pctRemaining) { |
| if (pctRemaining > 0.7) return "var(--gold)"; |
| if (pctRemaining > 0.4) return "#c8860a"; |
| return "var(--scarlet)"; |
| } |
|
|
| |
| function updateZOPABar(observation) { |
| const track = document.getElementById("zopa-track"); |
| if (!track) return; |
|
|
| const batnaPlayer = observation.player_batna ?? observation.your_batna ?? observation.zopa_lower ?? 0; |
| const batnaOpponent = observation.opponent_batna ?? observation.opp_batna ?? observation.zopa_upper ?? 100; |
| const playerOffer = observation.player_offer ?? observation.your_offer ?? null; |
| const opponentOffer = observation.opponent_offer ?? null; |
| const nash = observation.nash_point ?? null; |
| const pctRemaining = observation.zopa_width_pct_remaining ?? 1.0; |
| const zopaColor = getZopaColor(pctRemaining); |
|
|
| const minVal = Math.min(batnaPlayer, batnaOpponent) * 0.9; |
| const maxVal = Math.max(batnaPlayer, batnaOpponent) * 1.1; |
| const range = maxVal - minVal || 1; |
| const pct = (v) => `${Math.max(0, Math.min(100, ((v - minVal) / range) * 100)).toFixed(1)}%`; |
|
|
| const zopaZone = document.getElementById("zopa-zone"); |
| if (zopaZone) { |
| const lo = Math.min(batnaPlayer, batnaOpponent); |
| const hi = Math.max(batnaPlayer, batnaOpponent); |
| zopaZone.style.left = pct(lo); |
| zopaZone.style.width = `${(((hi - lo) / range) * 100).toFixed(1)}%`; |
| zopaZone.style.background = zopaColor; |
| zopaZone.style.borderLeftColor = zopaColor; |
| zopaZone.style.borderRightColor = zopaColor; |
| } |
| track.style.borderColor = zopaColor; |
|
|
| const mPlayer = document.getElementById("marker-player"); |
| if (mPlayer) mPlayer.style.left = pct(batnaPlayer); |
|
|
| const mOpponent = document.getElementById("marker-opponent"); |
| if (mOpponent) mOpponent.style.left = pct(batnaOpponent); |
|
|
| |
| const mCurrent = document.getElementById("marker-current"); |
| if (mCurrent) { |
| const currentOffer = playerOffer ?? opponentOffer; |
| if (currentOffer != null) { |
| mCurrent.style.left = pct(currentOffer); |
| mCurrent.style.display = "flex"; |
| } |
| } |
|
|
| |
| const nashMarker = document.getElementById("nash-marker"); |
| if (nashMarker && nash != null) { |
| nashMarker.style.left = pct(nash); |
| nashMarker.style.display = "block"; |
| } |
|
|
| const lblLow = document.getElementById("zopa-label-low"); |
| const lblHigh = document.getElementById("zopa-label-high"); |
| const widthEl = document.getElementById("zopa-width-indicator"); |
| if (lblLow) lblLow.textContent = formatCurrency(minVal, "USD"); |
| if (lblHigh) lblHigh.textContent = formatCurrency(maxVal, "USD"); |
| if (widthEl) widthEl.textContent = `Deal zone: ${Math.round(pctRemaining * 100)}%`; |
| } |
|
|
| |
| function updateTensionMeter(tensionScore) { |
| const fill = document.getElementById("tension-fill"); |
| const value = document.getElementById("tension-value"); |
| const desc = document.getElementById("tension-descriptor"); |
| if (!fill) return; |
|
|
| const pct = Math.max(0, Math.min(100, tensionScore || 0)); |
| fill.style.width = `${pct}%`; |
|
|
| let level = "low"; |
| let word = "Calm"; |
| if (pct >= 85) { level = "high"; word = "Critical"; } |
| else if (pct >= 70) { level = "high"; word = "Intense"; } |
| else if (pct >= 55) { level = "medium"; word = "Heated"; } |
| else if (pct >= 35) { level = "medium"; word = "Warming"; } |
|
|
| fill.setAttribute("data-level", level); |
|
|
| if (value) value.textContent = `${Math.round(pct)}%`; |
| if (desc) desc.textContent = `Β· ${word}`; |
| } |
|
|
| |
| function updateBeliefBars(beliefState) { |
| if (!beliefState) return; |
|
|
| const obs = gameState.observation || {}; |
| const zopaLo = obs.zopa_lower ?? 125000; |
| const zopaHi = obs.zopa_upper ?? 165000; |
| |
| const resPrior = zopaLo / Math.max(zopaHi, 1); |
|
|
| const mapping = { |
| "belief-cooperative": beliefState.cooperative ?? beliefState.cooperative_prob ?? 0.5, |
| "belief-competitive": beliefState.competitive ?? beliefState.competitive_prob ?? 0.5, |
| "belief-reservation": beliefState.reservation ?? beliefState.reservation_sensitivity ?? resPrior, |
| "belief-flexibility": beliefState.flexibility ?? beliefState.concession_rate ?? 0.5, |
| }; |
|
|
| Object.entries(mapping).forEach(([id, val]) => { |
| const fill = document.getElementById(id + "-fill"); |
| const pctEl = document.getElementById(id + "-pct"); |
| const confEl= document.getElementById(id + "-conf"); |
| if (!fill) return; |
| const pct = Math.max(0, Math.min(100, val * 100)); |
| fill.style.width = `${pct.toFixed(1)}%`; |
| if (pctEl) pctEl.textContent = `${Math.round(pct)}%`; |
| if (confEl) { |
| const conf = pct > 60 ? "high" : pct > 30 ? "medium" : "low"; |
| confEl.className = `belief-confidence confidence-${conf}`; |
| } |
| }); |
|
|
| if (charts?.updateBeliefChart) charts.updateBeliefChart(beliefState); |
| } |
|
|
| |
| function updateCPBar(cp) { |
| const fill = document.getElementById("cp-fill"); |
| const value = document.getElementById("cp-value"); |
| if (!fill) return; |
| const maxCp = gameState.maxCp || 100; |
| const pct = Math.max(0, Math.min(100, (cp / maxCp) * 100)); |
| fill.style.width = `${pct}%`; |
| if (value) value.textContent = `${Math.round(cp)} / ${maxCp}`; |
| } |
|
|
| |
| function updateCharacterState(observation) { |
| if (!character) return; |
| const tension = observation?.tension_score ?? observation?.tension ?? 0; |
| const drift = observation?.drift_event; |
|
|
| let state = "idle"; |
| if (drift) state = "shocked"; |
| else if (tension > 75) state = "aggressive"; |
| else if (tension > 50) state = "thinking"; |
| else if (tension < 25 && gameState.turnCount > 0) state = "pleased"; |
|
|
| character.setState(state); |
| if (drift && character) { |
| setTimeout(() => { if (character && gameState.sessionId) character.setState("aggressive"); }, 2000); |
| } |
| } |
|
|
| |
| function showDriftAlert(driftEvent) { |
| const bar = document.getElementById("drift-alert"); |
| const text = document.getElementById("drift-alert-text"); |
| if (!bar) return; |
| if (text) { |
| text.textContent = typeof driftEvent === "string" |
| ? driftEvent |
| : (driftEvent.description || driftEvent.event || "Market conditions have shifted."); |
| } |
| bar.classList.remove("hidden"); |
| if (_driftTimer) clearTimeout(_driftTimer); |
| _driftTimer = setTimeout(dismissDriftAlert, 8000); |
| } |
|
|
| function dismissDriftAlert() { |
| document.getElementById("drift-alert")?.classList.add("hidden"); |
| if (_driftTimer) clearTimeout(_driftTimer); |
| } |
|
|
| |
| function renderHand(hand) { |
| const container = document.getElementById("hand-container"); |
| if (!container) return; |
| container.innerHTML = ""; |
| if (!hand || !hand.length) return; |
|
|
| |
| const CARD_DESCS = { |
| anchor_high: "Set a bold opening number to frame the negotiation.", |
| batna_reveal: "Reveal your walk-away option to signal credibility.", |
| silence: "Say nothing β let the pressure work for you.", |
| }; |
|
|
| hand.forEach((card) => { |
| const wrapper = document.createElement("div"); |
| wrapper.className = "tactical-card"; |
| wrapper.dataset.cardId = card.id || card.card_id || card.move || ""; |
|
|
| const inner = document.createElement("div"); |
| inner.className = "card-inner"; |
|
|
| |
| const front = document.createElement("div"); |
| front.className = "card-face"; |
|
|
| const sym = document.createElement("div"); |
| sym.className = "card-symbol"; |
| sym.textContent = card.symbol || "β"; |
| front.appendChild(sym); |
|
|
| const name = document.createElement("div"); |
| name.className = "card-name"; |
| name.textContent = card.name || _tidyCardName(card.move || card.id || "Tactic"); |
| front.appendChild(name); |
|
|
| |
| const cost = document.createElement("div"); |
| cost.className = "card-cost"; |
| cost.textContent = `${card.cp_cost ?? card.cost ?? "β"}`; |
| front.appendChild(cost); |
|
|
| |
| const back = document.createElement("div"); |
| back.className = "card-back"; |
| const backLabel = document.createElement("div"); |
| backLabel.className = "card-back-label"; |
| backLabel.textContent = "Game Theory"; |
| back.appendChild(backLabel); |
| const gt = document.createElement("div"); |
| gt.className = "card-game-theory"; |
| |
| const moveKey = (card.move || card.id || "").toLowerCase(); |
| gt.textContent = CARD_DESCS[moveKey] || card.game_theory_basis || card.description || ""; |
| back.appendChild(gt); |
|
|
| inner.appendChild(front); |
| inner.appendChild(back); |
| wrapper.appendChild(inner); |
|
|
| wrapper.addEventListener("click", () => { |
| const already = wrapper.classList.contains("selected"); |
| container.querySelectorAll(".tactical-card").forEach(c => c.classList.remove("selected")); |
| if (!already) wrapper.classList.add("selected"); |
| }); |
|
|
| container.appendChild(wrapper); |
| }); |
| } |
|
|
| function _tidyCardName(raw) { |
| return raw.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); |
| } |
|
|
| |
| function renderLeaderboard(entries) { |
| const tbody = document.getElementById("leaderboard-body"); |
| if (!tbody) return; |
| tbody.innerHTML = ""; |
|
|
| if (!entries || !entries.length) { |
| const tr = document.createElement("tr"); |
| const td = document.createElement("td"); |
| td.colSpan = 4; td.className = "empty-state text-muted"; td.textContent = "No games yet"; |
| tr.appendChild(td); tbody.appendChild(tr); |
| return; |
| } |
|
|
| entries.forEach((entry, idx) => { |
| const tr = document.createElement("tr"); |
| if (entry.player_name === gameState.playerName) tr.classList.add("highlight-player"); |
|
|
| const rankTd = document.createElement("td"); |
| const rankSpan = document.createElement("span"); |
| rankSpan.className = "lb-rank" + (idx === 0 ? " gold" : idx === 1 ? " silver" : idx === 2 ? " bronze" : ""); |
| rankSpan.textContent = `#${idx + 1}`; |
| rankTd.appendChild(rankSpan); |
| tr.appendChild(rankTd); |
|
|
| const nameTd = document.createElement("td"); |
| nameTd.textContent = entry.player_name || "β"; |
| tr.appendChild(nameTd); |
|
|
| const scoreTd = document.createElement("td"); |
| scoreTd.className = "num"; |
| scoreTd.textContent = (entry.score ?? entry.total_reward ?? entry.reward ?? 0).toFixed(2); |
| tr.appendChild(scoreTd); |
|
|
| const dealsTd = document.createElement("td"); |
| dealsTd.className = "num"; |
| dealsTd.textContent = entry.deals ?? (entry.deal_closed ? "β" : "β"); |
| tr.appendChild(dealsTd); |
|
|
| tbody.appendChild(tr); |
| }); |
| } |
|
|
| |
| function showAchievements(achievements) { |
| if (!achievements || !Array.isArray(achievements)) return; |
| achievements.forEach((ach) => { |
| const el = document.querySelector(`.badge[data-achievement="${ach.id}"]`); |
| if (el && !el.classList.contains("earned")) { |
| el.classList.add("earned"); |
| _showToast(`Achievement unlocked: ${ach.name || ach.id}`); |
| } |
| }); |
| } |
|
|
| function _showToast(msg) { |
| const toast = document.createElement("div"); |
| toast.style.cssText = [ |
| "position:fixed","bottom:24px","right:24px","z-index:9999", |
| "background:var(--mahogany)","border:1px solid var(--gold)", |
| "border-radius:6px","padding:12px 20px", |
| "font-family:var(--font-display)","font-style:italic", |
| "font-size:0.9rem","color:var(--cream)", |
| "box-shadow:0 4px 16px rgba(0,0,0,0.4)", |
| "animation:slide-down 200ms ease", |
| ].join(";"); |
| toast.textContent = msg; |
| document.body.appendChild(toast); |
| setTimeout(() => toast.remove(), 3500); |
| } |
|
|
| |
| function _handleGameOver(data) { |
| const deal = data.deal_reached ?? data.deal ?? false; |
| const amount = data.deal_amount ?? data.final_price ?? null; |
| const score = data.reward ?? data.total_reward ?? data.score ?? 0; |
|
|
| const resultMsg = deal |
| ? `Deal closed at ${formatCurrency(amount, "USD")}! Score: ${score.toFixed(2)}` |
| : "No deal. You walked away from the table."; |
| addMessage("system", resultMsg); |
|
|
| const banner = document.getElementById("result-banner"); |
| if (banner) { |
| banner.className = `result-banner ${deal ? "deal" : "walk"}`; |
| banner.classList.remove("hidden"); |
| const title = banner.querySelector(".result-title"); |
| const amountEl= banner.querySelector(".result-amount"); |
| const scoreEl = banner.querySelector(".result-score"); |
| if (title) title.textContent = deal ? "Deal Closed" : "Walked Away"; |
| if (amountEl) amountEl.textContent = amount != null ? formatCurrency(amount, "USD") : "β"; |
| if (scoreEl) scoreEl.textContent = `Score: ${score.toFixed(2)}`; |
| } |
|
|
| if (character) character.setState(deal ? "pleased" : "shocked"); |
| setTimeout(loadLeaderboard, 1200); |
| } |
|
|
| |
| function _updateActPills(act) { |
| const labels = { |
| 1: "I Β· Price", 2: "II Β· Terms", 3: "III Β· Coalition", |
| }; |
| [1, 2, 3].forEach(n => { |
| const pill = document.getElementById(`act-pill-${n}`); |
| if (!pill) return; |
| pill.classList.remove("active", "completed", "locked"); |
|
|
| if (n < act) { |
| pill.classList.add("completed"); |
| pill.textContent = `β ${labels[n]}`; |
| pill.style.cursor = "pointer"; |
| pill.onclick = () => _showActTranscript(n); |
| } else if (n === act) { |
| pill.classList.add("active"); |
| pill.textContent = labels[n]; |
| pill.style.cursor = "default"; |
| pill.onclick = null; |
| } else { |
| pill.classList.add("locked"); |
| pill.textContent = labels[n]; |
| pill.style.cursor = "not-allowed"; |
| pill.setAttribute("data-lock-msg", `Complete Act ${['I','II'][n-2]} first`); |
| pill.onclick = null; |
| } |
| }); |
| } |
|
|
| function _showActTranscript(act) { |
| const msgs = gameState.actTranscripts[act] || []; |
| if (!msgs.length) return; |
| _showToast(`Act ${['I','II','III'][act-1]} β ${msgs.length} messages (read-only view)`); |
| } |
|
|
| |
| function _renderScenarioDossiers(scenarios) { |
| const grid = document.getElementById("scenario-dossier-grid"); |
| if (!grid) return; |
| grid.innerHTML = ""; |
|
|
| const defaults = [ |
| { id: "saas_enterprise", title: "Enterprise SaaS", description: "500-seat analytics platform", zopa_lower: 125000, zopa_upper: 165000, difficulty: 2 }, |
| { id: "hiring_package", title: "Senior Eng. Offer", description: "Total comp negotiation", zopa_lower: 195000, zopa_upper: 230000, difficulty: 2 }, |
| { id: "acquisition_term_sheet", title: "Startup Acquisition", description: "Acqui-hire term sheet", zopa_lower: 10500000, zopa_upper: 16000000, difficulty: 3 }, |
| ]; |
|
|
| const list = (scenarios && scenarios.length) ? scenarios : defaults; |
| const diffLabels = ["", "Easy", "Medium", "Hard"]; |
| let caseNum = 1; |
|
|
| list.forEach((s) => { |
| const card = document.createElement("div"); |
| card.className = "scenario-dossier"; |
| card.dataset.scenarioId = s.id || s.scenario_id; |
| card.setAttribute("role", "radio"); card.setAttribute("tabindex", "0"); |
|
|
| const caseEl = document.createElement("div"); |
| caseEl.className = "dossier-case"; |
| caseEl.textContent = `CASE ${String(caseNum++).padStart(3, "0")}`; |
| card.appendChild(caseEl); |
|
|
| const diffEl = document.createElement("div"); |
| diffEl.className = "dossier-difficulty"; |
| diffEl.textContent = diffLabels[s.difficulty ?? 2] || "Medium"; |
| card.appendChild(diffEl); |
|
|
| const titleEl = document.createElement("div"); |
| titleEl.className = "dossier-title"; |
| titleEl.textContent = s.title || s.name; |
| card.appendChild(titleEl); |
|
|
| const descEl = document.createElement("div"); |
| descEl.className = "dossier-desc"; |
| descEl.textContent = s.description || ""; |
| card.appendChild(descEl); |
|
|
| const zopaLo = s.zopa_lower ?? s.zopa?.[0] ?? 0; |
| const zopaHi = s.zopa_upper ?? s.zopa?.[1] ?? 0; |
| const zopaEl = document.createElement("div"); |
| zopaEl.className = "dossier-zopa"; |
| zopaEl.textContent = `ZOPA ${formatCurrency(zopaLo, "USD")} β ${formatCurrency(zopaHi, "USD")}`; |
| card.appendChild(zopaEl); |
|
|
| card.addEventListener("click", () => { |
| grid.querySelectorAll(".scenario-dossier").forEach(c => c.classList.remove("selected")); |
| card.classList.add("selected"); |
| gameState.scenarioData = s; |
| }); |
| card.addEventListener("keydown", e => { |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); card.click(); } |
| }); |
|
|
| grid.appendChild(card); |
| }); |
| } |
|
|
| function _renderPersonaCards(personas) { |
| const grid = document.getElementById("persona-cards-grid"); |
| if (!grid) return; |
| grid.innerHTML = ""; |
|
|
| const defaults = [ |
| { id: "shark", name: "The Shark", symbol: "β", aggression: 0.88, patience: 0.18 }, |
| { id: "diplomat", name: "The Diplomat", symbol: "β", aggression: 0.20, patience: 0.85 }, |
| { id: "veteran", name: "The Veteran", symbol: "β", aggression: 0.50, patience: 0.95 }, |
| ]; |
|
|
| const list = (personas && personas.length) ? personas : defaults; |
|
|
| list.forEach((p) => { |
| const pid = p.id || p.persona_id; |
| const card = document.createElement("div"); |
| card.className = "persona-card-option"; |
| card.dataset.persona = pid; |
| card.setAttribute("role", "radio"); card.setAttribute("tabindex", "0"); |
|
|
| const canvasWrap = document.createElement("div"); |
| canvasWrap.className = "persona-card-canvas-wrap"; |
| const previewCanvas = document.createElement("canvas"); |
| previewCanvas.width = 280; previewCanvas.height = 200; |
| canvasWrap.appendChild(previewCanvas); |
| card.appendChild(canvasWrap); |
|
|
| const nameEl = document.createElement("div"); |
| nameEl.className = "persona-card-name"; |
| nameEl.textContent = p.name || pid; |
| card.appendChild(nameEl); |
|
|
| const symEl = document.createElement("div"); |
| symEl.className = "persona-card-symbol"; |
| symEl.textContent = p.symbol || "β"; |
| card.appendChild(symEl); |
|
|
| const traits = document.createElement("div"); |
| traits.className = "persona-trait-bars"; |
| [ |
| { label: "AGG", val: p.aggression ?? 0.5 }, |
| { label: "PAT", val: p.patience ?? 0.5 }, |
| ].forEach(({ label, val }) => { |
| const row = document.createElement("div"); row.className = "persona-trait-row"; |
| const lbl = document.createElement("div"); lbl.className = "persona-trait-label"; lbl.textContent = label; |
| const bar = document.createElement("div"); bar.className = "persona-trait-bar"; |
| const fill = document.createElement("div"); fill.className = "persona-trait-fill"; |
| fill.style.width = `${Math.round(val * 100)}%`; |
| bar.appendChild(fill); row.appendChild(lbl); row.appendChild(bar); |
| traits.appendChild(row); |
| }); |
| card.appendChild(traits); |
|
|
| card.addEventListener("click", () => { |
| grid.querySelectorAll(".persona-card-option").forEach(c => c.classList.remove("selected")); |
| card.classList.add("selected"); |
| }); |
| card.addEventListener("keydown", e => { |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); card.click(); } |
| }); |
|
|
| grid.appendChild(card); |
|
|
| requestAnimationFrame(() => { |
| if (typeof PersonaPreviewCharacter !== "undefined") { |
| _previewChars[pid] = new PersonaPreviewCharacter(previewCanvas, pid); |
| } |
| }); |
| }); |
| } |
|
|
| function _destroyPreviewChars() { |
| Object.values(_previewChars).forEach(c => { try { c.destroy(); } catch {} }); |
| _previewChars = {}; |
| } |
|
|
| |
| function _initCharacter(persona) { |
| if (typeof NegotiatorCharacter === "undefined") return; |
| if (character) character.destroy(); |
| character = new NegotiatorCharacter("character-canvas", persona || "shark"); |
| } |
|
|
| |
| function _initSparkline(observation) { |
| if (!charts || !observation) return; |
| const lo = observation.player_batna ?? observation.your_batna ?? observation.zopa_lower ?? 0; |
| const hi = observation.opponent_batna ?? observation.opp_batna ?? observation.zopa_upper ?? 0; |
| const nash = observation.nash_point ?? ((lo + hi) / 2); |
| charts.initOfferSparkline?.("offer-sparkline", lo, hi, nash); |
| charts.initBeliefChart?.("belief-chart"); |
| } |
|
|
| |
| function formatCurrency(amount, currency) { |
| if (amount == null || isNaN(amount)) return "β"; |
| try { |
| return new Intl.NumberFormat("en-US", { |
| style: "currency", currency: currency || "USD", maximumFractionDigits: 0, |
| }).format(amount); |
| } catch { |
| return `$${Number(amount).toLocaleString()}`; |
| } |
| } |
|
|
| function setLoading(isLoading) { |
| const overlay = document.getElementById("loading-overlay"); |
| if (overlay) overlay.classList.toggle("hidden", !isLoading); |
| ["btn-submit", "chip-accept", "chip-walk", "chip-offer"].forEach(id => { |
| const btn = document.getElementById(id); |
| if (btn) btn.disabled = isLoading; |
| }); |
| } |
|
|
| function _setInputsDisabled(disabled) { |
| ["offer-input", "btn-submit", "chip-accept", "chip-walk", "chip-offer"].forEach(id => { |
| const el = document.getElementById(id); |
| if (el) el.disabled = disabled; |
| }); |
| } |
|
|
| function _personaLabel(persona) { |
| const labels = { shark: "The Shark", diplomat: "The Diplomat", veteran: "The Veteran" }; |
| return labels[persona] || persona; |
| } |
|
|
| function _personaSymbol(persona) { |
| const syms = { shark: "β", diplomat: "β", veteran: "β" }; |
| return syms[persona] || "β"; |
| } |
|
|
| function _personaColor(persona) { |
| const cols = { shark: "var(--scarlet-light)", diplomat: "var(--emerald)", veteran: "var(--parlay-purple)" }; |
| return cols[persona] || "var(--gold)"; |
| } |
|
|
| function _personaTag(persona) { |
| const tags = { shark: "Aggressive Β· Low A", diplomat: "Cooperative Β· High A", veteran: "Experienced Β· Patient" }; |
| return tags[persona] || "Negotiator"; |
| } |
|
|
| function _showError(msg) { |
| const el = document.getElementById("global-error"); |
| if (!el) { _showToast(msg); return; } |
| el.textContent = msg; |
| el.classList.remove("hidden"); |
| setTimeout(() => el.classList.add("hidden"), 4000); |
| } |
|
|