recruitopenenv / demo /index.html
bathientran's picture
Upload folder using huggingface_hub
be37527 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Driver Recruit Environment</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#09090b;--s1:#111113;--s2:#18181b;--s3:#27272a;
--b1:#27272a;--b2:#3f3f46;
--t1:#fafafa;--t2:#a1a1aa;--t3:#71717a;
--green:#22c55e;--red:#ef4444;--amber:#f59e0b;--blue:#3b82f6;--violet:#8b5cf6;--rose:#f43f5e;--cyan:#06b6d4;--orange:#f97316;
}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--t1);-webkit-font-smoothing:antialiased}
.mono{font-family:'IBM Plex Mono',monospace}
/* ─── HERO ─── */
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 24px;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:800px;height:800px;background:radial-gradient(circle,rgba(139,92,246,0.06) 0%,transparent 70%);pointer-events:none}
.hero-eyebrow{font-size:13px;font-weight:500;color:var(--violet);letter-spacing:0.08em;text-transform:uppercase;margin-bottom:20px}
.hero h1{font-size:clamp(2rem,5vw,3.5rem);font-weight:700;letter-spacing:-0.03em;line-height:1.1;text-align:center;max-width:700px;margin-bottom:16px}
.hero-sub{color:var(--t2);font-size:17px;line-height:1.6;text-align:center;max-width:560px;margin-bottom:48px}
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--b1);border:1px solid var(--b1);border-radius:12px;overflow:hidden;max-width:900px;width:100%;margin-bottom:48px}
.card{background:var(--s1);padding:24px 20px}
.card-num{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--t3);margin-bottom:10px}
.card h3{font-size:14px;font-weight:600;margin-bottom:6px}
.card p{font-size:13px;color:var(--t2);line-height:1.55}
@media(max-width:800px){.cards{grid-template-columns:1fr 1fr}}
@media(max-width:500px){.cards{grid-template-columns:1fr}}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;transition:all .15s}
.btn-white{background:var(--t1);color:var(--bg)}
.btn-white:hover{opacity:.9}
.env-input{background:var(--s2);border:1px solid var(--b1);color:var(--t2);padding:8px 12px;border-radius:6px;font-size:13px;font-family:'IBM Plex Mono',monospace;width:240px;margin-top:16px;text-align:center}
.env-input:focus{outline:none;border-color:var(--b2)}
/* ─── GAME ─── */
.game{display:none;max-width:1280px;margin:0 auto;padding:16px 20px 40px}
.game.on{display:block}
/* Top bar */
.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--b1);margin-bottom:16px;flex-wrap:wrap;gap:12px}
.topbar-left{display:flex;align-items:center;gap:16px}
.avatar{width:36px;height:36px;border-radius:50%;background:var(--s3);display:flex;align-items:center;justify-content:center;font-weight:600;font-size:14px;flex-shrink:0}
.driver-meta h2{font-size:15px;font-weight:600;line-height:1}
.pill{display:inline-block;font-size:11px;font-weight:500;padding:2px 8px;border-radius:4px;margin-top:4px}
.pill-chatty{background:rgba(34,197,94,.12);color:var(--green)}
.pill-professional{background:rgba(59,130,246,.12);color:var(--blue)}
.pill-impatient{background:rgba(239,68,68,.12);color:var(--red)}
.pill-suspicious{background:rgba(244,63,94,.12);color:var(--rose)}
.topbar-stats{display:flex;gap:24px;align-items:center}
.ts{text-align:right}
.ts-label{font-size:11px;color:var(--t3);text-transform:uppercase;letter-spacing:.05em}
.ts-val{font-family:'IBM Plex Mono',monospace;font-size:16px;font-weight:600}
.trust-wrap{width:140px}
.trust-track{width:100%;height:4px;background:var(--s3);border-radius:2px;margin-top:4px;overflow:hidden}
.trust-fill{height:100%;border-radius:2px;transition:width .4s ease}
.trust-num{display:flex;justify-content:space-between;align-items:center;margin-top:3px}
.trust-num span{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--t3)}
.trust-num .delta{font-weight:600}
.delta-up{color:var(--green)}
.delta-down{color:var(--red)}
/* Layout */
.layout{display:grid;grid-template-columns:260px 1fr 260px;gap:16px;min-height:calc(100vh - 120px)}
@media(max-width:1000px){.layout{grid-template-columns:1fr}}
.right-col{display:flex;flex-direction:column;gap:12px}
/* Sidebar */
.sidebar{display:flex;flex-direction:column;gap:12px}
.pane{background:var(--s1);border:1px solid var(--b1);border-radius:10px;overflow:hidden}
.pane-head{font-size:11px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;padding:12px 14px 8px;display:flex;align-items:center;justify-content:space-between}
.job{padding:10px 14px;border-bottom:1px solid var(--b1);font-size:13px;cursor:default;transition:background .1s}
.job:last-child{border-bottom:none}
.job:hover{background:var(--s2)}
.job-id{font-family:'IBM Plex Mono',monospace;color:var(--t3);font-size:11px;font-weight:500}
.job-co{font-weight:500;margin-left:6px}
.job-det{color:var(--t2);font-size:12px;margin-top:3px;line-height:1.5}
.job-warn{color:var(--amber);font-size:11px;margin-top:2px}
.info-item{padding:8px 14px;border-bottom:1px solid var(--b1);font-size:13px;line-height:1.5}
.info-item:last-child{border-bottom:none}
.info-cat{font-family:'IBM Plex Mono',monospace;font-size:10px;font-weight:600;color:var(--violet);text-transform:uppercase;letter-spacing:.04em}
.info-empty{padding:14px;color:var(--t3);font-size:13px;font-style:italic}
.crm-field{padding:5px 14px;border-bottom:1px solid var(--b1);font-size:12px;display:flex;justify-content:space-between}
.crm-field:last-child{border-bottom:none}
.crm-key{color:var(--t3);font-family:'IBM Plex Mono',monospace;font-size:11px}
.crm-val{color:var(--t1)}
.crm-empty{padding:14px;color:var(--t3);font-size:13px;font-style:italic}
/* Main area */
.main{display:flex;flex-direction:column;gap:12px}
/* Timeline */
.timeline{flex:1;background:var(--s1);border:1px solid var(--b1);border-radius:10px;padding:16px;overflow-y:auto;max-height:calc(100vh - 320px);min-height:320px}
.tl-entry{display:flex;gap:12px;margin-bottom:16px;animation:slideIn .25s ease}
@keyframes slideIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.tl-dot-col{display:flex;flex-direction:column;align-items:center;padding-top:4px}
.tl-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.tl-line{width:1px;flex:1;background:var(--b1);margin-top:4px}
.tl-content{flex:1;min-width:0}
.tl-head{display:flex;align-items:center;gap:8px;margin-bottom:4px;flex-wrap:wrap}
.tl-action{font-size:13px;font-weight:600}
.tl-reward{font-family:'IBM Plex Mono',monospace;font-size:12px;font-weight:500}
.tl-reward.pos{color:var(--green)}.tl-reward.neg{color:var(--red)}.tl-reward.zero{color:var(--t3)}
.tl-tool-badge{font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.04em}
.badge-crm{background:rgba(6,182,212,.12);color:var(--cyan)}
.badge-messaging{background:rgba(139,92,246,.12);color:var(--violet)}
.badge-approval{background:rgba(249,115,22,.12);color:var(--orange)}
.badge-workflow{background:rgba(113,113,122,.15);color:var(--t3)}
.tl-body{font-size:13px;color:var(--t2);line-height:1.55;padding:8px 12px;background:var(--s2);border-radius:6px;border-left:2px solid var(--b1)}
.tl-body.good{border-left-color:var(--green)}
.tl-body.bad{border-left-color:var(--red)}
.tl-step{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--t3)}
/* Tool sections */
.tool-section{background:var(--s1);border:1px solid var(--b1);border-radius:10px;padding:10px 14px}
.tool-section-head{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;display:flex;align-items:center;gap:6px}
.tool-section-head .dot{width:6px;height:6px;border-radius:50%;display:inline-block}
.act-grid{display:flex;flex-wrap:wrap;gap:6px}
.act{padding:6px 12px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--b1);background:var(--s2);color:var(--t2);transition:all .12s;white-space:nowrap}
.act:hover{background:var(--s3);color:var(--t1);border-color:var(--b2)}
.act:disabled{opacity:.3;cursor:not-allowed}
.act-go{border-color:rgba(34,197,94,.3);color:var(--green)}
.act-go:hover{background:rgba(34,197,94,.1);border-color:var(--green)}
.act-no{border-color:rgba(239,68,68,.2);color:var(--red)}
.act-no:hover{background:rgba(239,68,68,.08);border-color:var(--red)}
.act-warn{border-color:rgba(245,158,11,.2);color:var(--amber)}
.act-warn:hover{background:rgba(245,158,11,.08);border-color:var(--amber)}
/* Pipeline */
.pipeline{background:var(--s1);border:1px solid var(--b1);border-radius:10px;padding:10px 14px}
.pipe-stages{display:flex;gap:2px;align-items:center}
.pipe-stage{font-size:10px;font-weight:600;padding:4px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.04em;background:var(--s2);color:var(--t3);border:1px solid var(--b1)}
.pipe-stage.active{background:rgba(139,92,246,.15);color:var(--violet);border-color:var(--violet)}
.pipe-stage.done{background:rgba(34,197,94,.1);color:var(--green);border-color:rgba(34,197,94,.3)}
.pipe-stage.fail{background:rgba(239,68,68,.1);color:var(--red);border-color:rgba(239,68,68,.3)}
.pipe-arrow{color:var(--t3);font-size:10px}
/* Pending reply indicator */
.pending-badge{font-size:11px;font-weight:500;padding:3px 10px;border-radius:4px;background:rgba(139,92,246,.12);color:var(--violet);animation:pulse 1.5s ease infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-bg.on{display:flex}
.modal{background:var(--s1);border:1px solid var(--b1);border-radius:12px;padding:20px;width:420px;max-width:90vw}
.modal h3{font-size:14px;font-weight:600;margin-bottom:14px}
.modal-job{display:block;width:100%;text-align:left;background:var(--s2);border:1px solid var(--b1);color:var(--t1);padding:10px 12px;border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px;transition:border-color .12s}
.modal-job:hover{border-color:var(--violet)}
.modal-cancel{display:block;width:100%;text-align:center;background:transparent;border:1px solid var(--b1);color:var(--t3);padding:8px;border-radius:6px;cursor:pointer;font-size:13px;margin-top:8px}
.modal-cancel:hover{color:var(--t2)}
/* Input modal */
.modal input[type="text"]{width:100%;background:var(--s2);border:1px solid var(--b1);color:var(--t1);padding:8px 12px;border-radius:6px;font-size:13px;font-family:'IBM Plex Mono',monospace;margin-bottom:8px}
.modal input[type="text"]:focus{outline:none;border-color:var(--violet)}
.modal-row{display:flex;gap:8px;margin-top:8px}
.modal-row .btn{flex:1;justify-content:center;padding:8px;font-size:13px}
.modal-btn-go{background:var(--violet);color:white;border:none;padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer}
.modal-btn-go:hover{opacity:.9}
/* End screen */
.endscreen{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:center;justify-content:center;backdrop-filter:blur(8px)}
.endscreen.on{display:flex}
.end-card{background:var(--s1);border:1px solid var(--b1);border-radius:14px;padding:40px 36px;text-align:center;width:440px;max-width:90vw}
.end-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-bottom:8px}
.end-title{font-size:28px;font-weight:700;letter-spacing:-0.02em;margin-bottom:6px}
.end-sub{font-size:13px;color:var(--t2);margin-bottom:28px;line-height:1.5}
.end-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:1px;background:var(--b1);border:1px solid var(--b1);border-radius:8px;overflow:hidden;margin-bottom:24px}
.end-stat{background:var(--s1);padding:14px 8px}
.end-stat-val{font-family:'IBM Plex Mono',monospace;font-size:18px;font-weight:600}
.end-stat-lbl{font-size:11px;color:var(--t3);margin-top:2px}
/* Hidden info toggle */
.hidden-info.off{display:none}
.toggle-hidden{background:var(--s2);border:1px solid var(--b1);color:var(--t3);padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;margin-left:12px}
.toggle-hidden:hover{color:var(--t2);border-color:var(--b2)}
/* Stage select */
.stage-select{background:var(--s2);border:1px solid var(--b1);color:var(--t1);padding:4px 8px;border-radius:4px;font-size:12px;font-family:'IBM Plex Mono',monospace}
.stage-select:focus{outline:none;border-color:var(--violet)}
</style>
</head>
<body>
<div class="hero" id="hero">
<div class="hero-eyebrow">OpenEnv Hackathon &mdash; Long-Horizon RL</div>
<h1>Train an AI to recruit truck drivers through tool calls</h1>
<p class="hero-sub">A multi-turn RL environment where agents use CRM, messaging, approval, and workflow tools across 40-70 step episodes to screen candidates, avoid trap jobs, and close hires.</p>
<div class="cards">
<div class="card">
<div class="card-num mono">01</div>
<h3>Tool calling</h3>
<p>4 tools &mdash; CRM, messaging, approval, workflow. The agent must call the right tool with the right action at each step.</p>
</div>
<div class="card">
<div class="card-num mono">02</div>
<h3>Long horizon</h3>
<p>Episodes span 40-70 steps through a full recruiting pipeline: lead &rarr; contacted &rarr; interested &rarr; approval &rarr; offer &rarr; hired.</p>
</div>
<div class="card">
<div class="card-num mono">03</div>
<h3>Hidden information</h3>
<p>Driver preferences, deal breakers, and personality are hidden. Must be discovered through screening messages.</p>
</div>
<div class="card">
<div class="card-num mono">04</div>
<h3>Trap jobs</h3>
<p>Jobs that look perfect but violate deal breakers. Skip screening and you'll hire for the wrong one &mdash; big negative reward.</p>
</div>
</div>
<button class="btn btn-white" onclick="startGame()">Play the environment</button>
<input class="env-input" id="envUrl" value="http://localhost:8000" spellcheck="false">
</div>
<div class="game" id="game">
<div class="topbar">
<div class="topbar-left">
<div class="avatar" id="av">?</div>
<div class="driver-meta">
<h2 id="dName">---</h2>
<span class="pill" id="dPers"></span>
</div>
</div>
<div class="topbar-stats">
<div class="ts">
<div class="ts-label">Stage</div>
<div class="ts-val" id="uiStage">lead</div>
</div>
<div class="ts">
<div class="ts-label">Step</div>
<div class="ts-val"><span id="uiStep">0</span><span style="color:var(--t3)"> / 100</span></div>
</div>
<div class="ts">
<div class="ts-label">Reward</div>
<div class="ts-val" id="uiRew">0.0</div>
</div>
<div id="pendingBadge" style="display:none" class="pending-badge">Unread reply</div>
<button class="toggle-hidden" onclick="toggleHidden()">Show hidden</button>
</div>
</div>
<!-- Pipeline -->
<div class="pipeline">
<div class="pipe-stages" id="pipeStages"></div>
</div>
<div class="layout" style="margin-top:12px">
<div class="sidebar">
<div class="pane" id="jobsPane">
<div class="pane-head">Jobs</div>
<div id="jobsList"></div>
</div>
<div class="pane">
<div class="pane-head">CRM Record</div>
<div id="crmList"><div class="crm-empty">Not loaded &mdash; use crm.read_candidate</div></div>
</div>
</div>
<div class="main">
<div class="timeline" id="tl"></div>
<!-- Tool: CRM -->
<div class="tool-section">
<div class="tool-section-head"><span class="dot" style="background:var(--cyan)"></span><span style="color:var(--cyan)">CRM</span></div>
<div class="act-grid">
<button class="act" onclick="doTool('crm','read_candidate')">read_candidate</button>
<button class="act" onclick="showStageModal()">update_stage</button>
<button class="act" onclick="showFieldModal()">update_field</button>
<button class="act" onclick="showNoteModal()">add_note</button>
</div>
</div>
<!-- Tool: Messaging -->
<div class="tool-section">
<div class="tool-section-head"><span class="dot" style="background:var(--violet)"></span><span style="color:var(--violet)">Messaging</span></div>
<div class="act-grid" id="msgGrid">
<button class="act" onclick="doMsg('greeting')">greeting</button>
<button class="act" onclick="doMsg('call')">call</button>
<span style="width:1px;height:24px;background:var(--b1)"></span>
<button class="act" onclick="doMsg('experience')">experience</button>
<button class="act" onclick="doMsg('home_time')">home time</button>
<button class="act" onclick="doMsg('pay')">pay</button>
<button class="act" onclick="doMsg('equipment')">equipment</button>
<button class="act" onclick="doMsg('route')">route</button>
<button class="act" onclick="doMsg('deal_breakers')">deal breakers</button>
<button class="act" onclick="doMsg('availability')">availability</button>
<button class="act" onclick="doMsg('violations')">violations</button>
<button class="act" onclick="doMsg('medical_card')">medical card</button>
<button class="act" onclick="doMsg('references')">references</button>
<span style="width:1px;height:24px;background:var(--b1)"></span>
<button class="act act-warn" onclick="showJobModal('pitch')">pitch job</button>
<button class="act act-warn" onclick="showJobModal('offer')">send offer</button>
<span style="width:1px;height:24px;background:var(--b1)"></span>
<button class="act" onclick="doMsg('negotiate_pay')">negotiate pay</button>
<button class="act" onclick="doMsg('negotiate_home_time')">negotiate home</button>
<button class="act" onclick="doMsg('signing_bonus')">signing bonus</button>
<button class="act" onclick="doMsg('address_concern')">address concern</button>
<span style="width:1px;height:24px;background:var(--b1)"></span>
<button class="act act-go" onclick="doTool('messaging','read_reply')">read_reply</button>
</div>
</div>
<!-- Tool: Approval + Workflow -->
<div style="display:flex;gap:12px">
<div class="tool-section" style="flex:1">
<div class="tool-section-head"><span class="dot" style="background:var(--orange)"></span><span style="color:var(--orange)">Approval</span></div>
<div class="act-grid">
<button class="act" onclick="showJobModal('request_approval')">request_approval</button>
<button class="act" onclick="doTool('approval','check_approval')">check_approval</button>
</div>
</div>
<div class="tool-section" style="flex:1">
<div class="tool-section-head"><span class="dot" style="background:var(--t3)"></span><span style="color:var(--t3)">Workflow</span></div>
<div class="act-grid">
<button class="act" onclick="doTool('workflow','wait')">wait</button>
<button class="act act-go" onclick="showStageModal('hired')">hire (finish)</button>
<button class="act act-no" onclick="doStage('lost')">reject (lost)</button>
</div>
</div>
</div>
</div>
<div class="right-col">
<div class="pane">
<div class="pane-head">Discovered Info</div>
<div id="infoList"><div class="info-empty">No info yet &mdash; send messages and read replies</div></div>
</div>
</div>
</div>
</div>
<!-- Job picker modal -->
<div class="modal-bg" id="modalBg">
<div class="modal">
<h3 id="modalTitle">Select job</h3>
<div id="modalJobs"></div>
<button class="modal-cancel" onclick="closeModal()">Cancel</button>
</div>
</div>
<!-- Stage modal -->
<div class="modal-bg" id="stageModalBg">
<div class="modal">
<h3>Update Pipeline Stage</h3>
<div id="stageModalBtns"></div>
<button class="modal-cancel" onclick="closeStageModal()">Cancel</button>
</div>
</div>
<!-- Field modal -->
<div class="modal-bg" id="fieldModalBg">
<div class="modal">
<h3>Update CRM Field</h3>
<select id="fieldSelect" class="stage-select" style="width:100%;margin-bottom:8px;padding:8px">
<option value="cdl_class">cdl_class</option>
<option value="years_experience">years_experience</option>
<option value="endorsements">endorsements</option>
<option value="location">location</option>
<option value="home_time_pref">home_time_pref</option>
<option value="pay_expectation">pay_expectation</option>
<option value="equipment_pref">equipment_pref</option>
<option value="route_pref">route_pref</option>
<option value="deal_breakers">deal_breakers</option>
<option value="availability">availability</option>
<option value="violations">violations</option>
<option value="medical_card">medical_card</option>
<option value="references">references</option>
<option value="matched_job">matched_job</option>
</select>
<input type="text" id="fieldValue" placeholder="Value..." />
<div class="modal-row">
<button class="modal-btn-go" onclick="submitField()">Save</button>
<button class="modal-cancel" onclick="closeFieldModal()" style="margin-top:0">Cancel</button>
</div>
</div>
</div>
<!-- Note modal -->
<div class="modal-bg" id="noteModalBg">
<div class="modal">
<h3>Add CRM Note</h3>
<input type="text" id="noteValue" placeholder="Note text..." />
<div class="modal-row">
<button class="modal-btn-go" onclick="submitNote()">Add</button>
<button class="modal-cancel" onclick="closeNoteModal()" style="margin-top:0">Cancel</button>
</div>
</div>
</div>
<!-- End screen -->
<div class="endscreen" id="endscreen">
<div class="end-card">
<div class="end-label" id="endLabel"></div>
<div class="end-title" id="endTitle"></div>
<div class="end-sub" id="endSub"></div>
<div class="end-grid">
<div class="end-stat"><div class="end-stat-val" id="erRew"></div><div class="end-stat-lbl">Reward</div></div>
<div class="end-stat"><div class="end-stat-val" id="erStep"></div><div class="end-stat-lbl">Steps</div></div>
<div class="end-stat"><div class="end-stat-val" id="erStage"></div><div class="end-stat-lbl">Final Stage</div></div>
</div>
<button class="btn btn-white" onclick="startGame()">Play again</button>
</div>
</div>
<script>
let ENV='',WS=null;
let S={obs:null,rew:0,done:false,jobs:[],stepCount:0};
let showHidden=false;
const STAGES=['lead','contacted','interested','approval_pending','offer_sent','hired'];
const FAIL_STAGES=['lost','ghosted'];
function toggleHidden(){
showHidden=!showHidden;
document.querySelectorAll('.hidden-info').forEach(el=>el.classList.toggle('off',!showHidden));
document.querySelector('.toggle-hidden').textContent=showHidden?'Hide hidden':'Show hidden';
}
function wsUrl(){
const base=document.getElementById('envUrl').value.replace(/\/$/,'');
return base.replace(/^http/,'ws')+'/ws';
}
function connectWS(){
return new Promise((resolve,reject)=>{
if(WS&&WS.readyState===WebSocket.OPEN){resolve();return}
if(WS)WS.close();
WS=new WebSocket(wsUrl());
WS.onopen=()=>resolve();
WS.onerror=()=>reject(new Error('WebSocket connection failed'));
WS.onmessage=(ev)=>{
const msg=JSON.parse(ev.data);
if(msg.type==='error'){
console.error('WS error:',msg.data);
if(pendingResolve){pendingResolve=null;}
return;
}
if(msg.type==='observation'&&pendingResolve){
const cb=pendingResolve;pendingResolve=null;
cb(msg.data);
}
};
WS.onclose=()=>{WS=null};
});
}
let pendingResolve=null;
function wsSend(msg){
return new Promise(resolve=>{
pendingResolve=resolve;
WS.send(JSON.stringify(msg));
});
}
async function startGame(){
document.getElementById('hero').style.display='none';
document.getElementById('game').classList.add('on');
document.getElementById('endscreen').classList.remove('on');
document.getElementById('tl').innerHTML='';
S={obs:null,rew:0,done:false,jobs:[],stepCount:0};
try{
await connectWS();
const d=await wsSend({type:'reset'});
handle(d,null);
}catch(e){
alert('Cannot reach server: '+e.message);
document.getElementById('hero').style.display='';
document.getElementById('game').classList.remove('on');
}
}
// --- Tool actions ---
async function doTool(tool,action,extra){
if(S.done||!WS)return;
const data={tool,action,...(extra||{})};
const d=await wsSend({type:'step',data});
handle(d,tool+'.'+action,data);
}
async function doMsg(topic,jobId){
const extra={topic};
if(jobId!==undefined)extra.job_id=jobId;
await doTool('messaging','send_message',extra);
}
async function doStage(stage){
await doTool('crm','update_stage',{stage});
}
// --- Handle response ---
function handle(d,label,actionData){
const o=d.observation,rw=d.reward||0;
S.obs=o; S.rew+=rw; S.done=d.done;
if(o.steps_taken!==undefined)S.stepCount=o.steps_taken;
else if(label)S.stepCount++;
render(o,rw,label,actionData);
if(d.done)setTimeout(()=>showEnd(o),500);
}
function render(o,rw,label,actionData){
// Driver info
document.getElementById('dName').textContent=o.driver_name;
document.getElementById('av').textContent=o.driver_name?o.driver_name[0]:'?';
// Stage
document.getElementById('uiStage').textContent=o.stage;
document.getElementById('uiStep').textContent=S.stepCount;
// Reward
const re=document.getElementById('uiRew');
re.textContent=(S.rew>=0?'+':'')+S.rew.toFixed(1);
re.style.color=S.rew>=0?'var(--green)':'var(--red)';
// Pending reply
document.getElementById('pendingBadge').style.display=o.pending_reply?'':'none';
// Pipeline
renderPipeline(o.stage);
// Jobs
if(o.jobs_summary){
const lines=o.jobs_summary.split('\n');
document.getElementById('jobsList').innerHTML=lines.map(l=>{
const fm=l.match(/\[(.+?)\]/);
const warn=fm?'<div class="job-warn">'+fm[1]+'</div>':'';
const parts=l.split(' \u2014 ');
const hd=parts[0]||'';
const det=parts[1]||'';
const im=hd.match(/^Job (\d+): (.+)/);
return '<div class="job"><span class="job-id">#'+((im&&im[1])||'?')+'</span><span class="job-co">'+((im&&im[2])||hd)+'</span><div class="job-det">'+det+'</div>'+warn+'</div>';
}).join('');
S.jobs=lines.map(l=>{const m=l.match(/^Job (\d+): (.+?) \u2014/);return m?{id:+m[1],label:'#'+m[1]+' '+m[2]}:null}).filter(Boolean);
}
// CRM
if(o.crm_summary){
const lines=o.crm_summary.split('\n');
let html='';
lines.forEach(l=>{
const fieldMatch=l.match(/^\s{2}(\w+):\s*(.+)/);
if(fieldMatch){
html+='<div class="crm-field"><span class="crm-key">'+fieldMatch[1]+'</span><span class="crm-val">'+fieldMatch[2]+'</span></div>';
} else if(l.startsWith('Name:')||l.startsWith('Stage:')){
html+='<div class="crm-field"><span class="crm-key">'+l.split(':')[0]+'</span><span class="crm-val">'+l.split(':').slice(1).join(':').trim()+'</span></div>';
} else if(l.trim()==='Fields: (none recorded)'){
html+='<div class="crm-field"><span class="crm-key" style="color:var(--t3)">no fields recorded</span></div>';
} else if(l.match(/^\s{2}-\s(.+)/)){
html+='<div class="crm-field"><span class="crm-key">note</span><span class="crm-val" style="font-style:italic">'+l.match(/^\s{2}-\s(.+)/)[1]+'</span></div>';
}
});
document.getElementById('crmList').innerHTML=html||'<div class="crm-empty">Empty CRM</div>';
}
// Discovered info
if(o.discovered_info){
const items=o.discovered_info.split('\n').filter(l=>l.trim());
document.getElementById('infoList').innerHTML=items.map(l=>{
const m=l.match(/^\[(.+?)\]\s*(.*)/);
if(m)return '<div class="info-item"><span class="info-cat">'+m[1]+'</span><br>'+m[2]+'</div>';
return '<div class="info-item">'+l+'</div>';
}).join('');
}
// Timeline
if(label){
const tl=document.getElementById('tl');
const rwClass=rw>0?'pos':rw<0?'neg':'zero';
const rwStr=rw>=0?'+'+rw.toFixed(1):rw.toFixed(1);
const dotColor=rw>0?'var(--green)':rw<0?'var(--red)':'var(--b2)';
const bodyClass=rw>0?'good':rw<0?'bad':'';
// Tool badge
let toolName='';
if(actionData&&actionData.tool)toolName=actionData.tool;
else if(label.includes('.'))toolName=label.split('.')[0];
const badgeClass={'crm':'badge-crm','messaging':'badge-messaging','approval':'badge-approval','workflow':'badge-workflow'}[toolName]||'badge-workflow';
const badge=toolName?'<span class="tl-tool-badge '+badgeClass+'">'+toolName+'</span>':'';
// Parse feedback for display
let feedbackText='';
if(o.feedback){
try{
const fb=JSON.parse(o.feedback);
if(fb.reply)feedbackText=fb.reply;
else if(fb.message)feedbackText=fb.message;
else if(fb.error)feedbackText='Error: '+fb.error;
else if(fb.result)feedbackText='Result: '+fb.result+(fb.reason?' ('+fb.reason+')':'');
else if(fb.approval_status)feedbackText='Approval: '+fb.approval_status;
else if(fb.stage)feedbackText='Stage updated: '+fb.stage;
else if(fb.field)feedbackText=fb.field+' = '+fb.value;
else if(fb.elapsed)feedbackText='Time elapsed: '+fb.elapsed;
else feedbackText=o.feedback;
}catch(e){feedbackText=o.feedback}
}
let html='<div class="tl-entry"><div class="tl-dot-col"><div class="tl-dot" style="background:'+dotColor+'"></div><div class="tl-line"></div></div><div class="tl-content"><div class="tl-head"><span class="tl-step mono">'+S.stepCount+'</span>'+badge+'<span class="tl-action">'+label+'</span><span class="tl-reward '+rwClass+'">'+rwStr+'</span></div>';
if(feedbackText)html+='<div class="tl-body '+bodyClass+'">'+feedbackText+'</div>';
html+='</div></div>';
tl.innerHTML+=html;
tl.scrollTop=tl.scrollHeight;
} else if(o.feedback){
const tl=document.getElementById('tl');
let feedbackText='';
try{
const fb=JSON.parse(o.feedback);
feedbackText='New episode: '+o.driver_name+' &mdash; '+fb.jobs+' jobs available';
}catch(e){feedbackText=o.feedback}
tl.innerHTML+='<div class="tl-entry"><div class="tl-dot-col"><div class="tl-dot" style="background:var(--violet)"></div><div class="tl-line"></div></div><div class="tl-content"><div class="tl-head"><span class="tl-step mono">0</span><span class="tl-action">Episode start</span></div><div class="tl-body">'+feedbackText+'</div></div></div>';
}
}
function renderPipeline(currentStage){
const el=document.getElementById('pipeStages');
const failStage=FAIL_STAGES.includes(currentStage)?currentStage:null;
const curIdx=STAGES.indexOf(currentStage);
let html='';
STAGES.forEach((s,i)=>{
let cls='pipe-stage';
if(failStage){cls+=' fail'}
else if(i<curIdx)cls+=' done';
else if(i===curIdx)cls+=' active';
html+='<span class="'+cls+'">'+s.replace('_',' ')+'</span>';
if(i<STAGES.length-1)html+='<span class="pipe-arrow">&rarr;</span>';
});
if(failStage){
html+='<span class="pipe-arrow">&rarr;</span><span class="pipe-stage fail">'+failStage+'</span>';
}
el.innerHTML=html;
}
// --- Modals ---
let pendingModalAction='';
function showJobModal(action){
pendingModalAction=action;
const titles={'pitch':'Pitch which job?','offer':'Send offer for which job?','request_approval':'Request approval for which job?'};
document.getElementById('modalTitle').textContent=titles[action]||'Select job';
document.getElementById('modalJobs').innerHTML=S.jobs.map(j=>'<button class="modal-job" onclick="selJob('+j.id+')">'+j.label+'</button>').join('');
document.getElementById('modalBg').classList.add('on');
}
function selJob(id){
closeModal();
if(pendingModalAction==='pitch'||pendingModalAction==='offer'){
doMsg(pendingModalAction,id);
} else if(pendingModalAction==='request_approval'){
doTool('approval','request_approval',{job_id:id});
}
}
function closeModal(){document.getElementById('modalBg').classList.remove('on')}
function showStageModal(preselect){
const stages=['contacted','interested','approval_pending','offer_sent','hired','lost'];
document.getElementById('stageModalBtns').innerHTML=stages.map(s=>{
const cls=s==='hired'?'modal-job" style="border-color:var(--green);color:var(--green)':s==='lost'?'modal-job" style="border-color:var(--red);color:var(--red)':'modal-job';
return '<button class="'+cls+'" onclick="doStage(\''+s+'\');closeStageModal()">'+s.replace('_',' ')+'</button>';
}).join('');
document.getElementById('stageModalBg').classList.add('on');
}
function closeStageModal(){document.getElementById('stageModalBg').classList.remove('on')}
function showFieldModal(){document.getElementById('fieldModalBg').classList.add('on');document.getElementById('fieldValue').value='';document.getElementById('fieldValue').focus()}
function closeFieldModal(){document.getElementById('fieldModalBg').classList.remove('on')}
function submitField(){
const f=document.getElementById('fieldSelect').value;
const v=document.getElementById('fieldValue').value;
if(!v)return;
closeFieldModal();
doTool('crm','update_field',{field:f,value:v});
}
function showNoteModal(){document.getElementById('noteModalBg').classList.add('on');document.getElementById('noteValue').value='';document.getElementById('noteValue').focus()}
function closeNoteModal(){document.getElementById('noteModalBg').classList.remove('on')}
function submitNote(){
const v=document.getElementById('noteValue').value;
if(!v)return;
closeNoteModal();
doTool('crm','add_note',{value:v});
}
// --- End screen ---
function showEnd(o){
const e=document.getElementById('endscreen');e.classList.add('on');
const win=o.stage==='hired';
document.getElementById('endLabel').textContent=win?'DRIVER HIRED':'EPISODE ENDED';
document.getElementById('endLabel').style.color=win?'var(--green)':'var(--red)';
document.getElementById('endTitle').textContent=win?'Placement complete':o.stage==='ghosted'?'Driver ghosted':'Failed';
document.getElementById('endTitle').style.color=win?'var(--green)':'var(--t1)';
let subText='';
if(o.feedback){
try{
const fb=JSON.parse(o.feedback);
if(fb.reason)subText=fb.reason.replace(/_/g,' ');
if(fb.result)subText=fb.result.replace(/_/g,' ')+(subText?' &mdash; '+subText:'');
if(fb.score)subText+=' (fit score: '+fb.score+')';
if(fb.crm_bonus)subText+=' CRM bonus: +'+fb.crm_bonus;
}catch(e){subText=o.feedback}
}
document.getElementById('endSub').innerHTML=subText;
const rv=document.getElementById('erRew');
rv.textContent=(S.rew>=0?'+':'')+S.rew.toFixed(1);
rv.style.color=S.rew>=0?'var(--green)':'var(--red)';
document.getElementById('erStep').textContent=S.stepCount;
document.getElementById('erStage').textContent=o.stage;
}
// Enter to submit in modals
document.getElementById('fieldValue').addEventListener('keydown',e=>{if(e.key==='Enter')submitField()});
document.getElementById('noteValue').addEventListener('keydown',e=>{if(e.key==='Enter')submitNote()});
</script>
</body>
</html>