// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time. // // Two flavors of call, one feature: // · Play NOW — pick a game, max players, and how long to give people. // Others respond "Ready now" or "+N minutes". Resolves early if the // roster fills, or when the clock runs out — the CALLER starts it. // · SCHEDULED — pick a game and a clock time ("Among Us at 20:00"). // People RSVP with "I'm in". 15 minutes before start the call enters // its CHECK-IN window: it gets highlighted everywhere and everyone // who's in is nudged to answer with the same ready / +N-minute states. // // Every call also carries a small group chat (see ctp-chat.jsx). // // Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx. const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React; const GAMES_ = window.GAMES; const PEERS_ = window.PEERS; const installedPeersFor_ = window.installedPeersFor; const parseMaxPlayers_ = window.parseMaxPlayers; const Icon_ = window.Icon; const GameCover_ = window.GameCover; const CtpChat_ = window.CtpChat; const avatarColor = window.ctpAvatarColor; const fmtClock = window.ctpFmtClock; // Check-in opens this long before a scheduled call's start time — and any // call (scheduled or not) counts as "starting soon" inside this window. const CHECKIN_LEAD_MS = 15 * 60000; function phaseOf(n, now) { if (!n.scheduledFor) return 'now'; return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin'; } // Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED. function statusOf(n, now) { if (n.state === 'started') return 'started'; if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready'; if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon'; return n.scheduledFor ? 'scheduled' : 'call'; } function fmtCountdown(ms) { if (ms <= 0) return '0:00'; const total = Math.ceil(ms / 1000); const m = Math.floor(total / 60), s = total % 60; return `${m}:${String(s).padStart(2, '0')}`; } function fmtCountdownShort(ms) { if (ms <= 0) return 'now'; const total = Math.ceil(ms / 1000); if (total < 60) return `${total}s`; return `${Math.ceil(total / 60)}m`; } function fmtUntil(ms) { if (ms <= 60000) return 'in <1 min'; const min = Math.round(ms / 60000); if (min < 60) return `in ${min} min`; const h = Math.floor(min / 60), m = min % 60; return m ? `in ${h}h ${m}m` : `in ${h}h`; } function isReady(p, now) { if (!p) return false; return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt); } function readyCountOf(n, now) { return Object.values(n.participants).filter(p => isReady(p, now)).length; } function inCountOf(n, now) { return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length; } // ──────────────────────────────────────────────────────────────────── // Simulation — bots trickle into open calls (RSVP on scheduled ones, // ready-up during check-in / now-calls) and occasionally post a chat // line. Bots use second-scale buffers purely so the demo resolves // visibly; real people would use minute-scale ones. // ──────────────────────────────────────────────────────────────────── const CTP_BOT_LINES = [ 'anyone need the game files? I can seed', 'gimme 5, grabbing food', 'who hosts?', 'voice is up — channel 2', 'ok, back at my desk', 'poke your neighbor, we need one more', 'default settings, no mods — right?', 'glhf', ]; function maybeBotChat(n, now) { const msgs = n.messages || []; const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p)); if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs; const from = speakers[Math.floor(Math.random() * speakers.length)]; const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)]; return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }]; } function tickNomination(n, now) { if (n.state !== 'open') return n; const phase = phaseOf(n, now); let participants = n.participants; const joined = Object.keys(participants); const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p)); if (phase === 'scheduled') { // RSVP trickle — far from start, people just say "I'm in". if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) { const bot = available[Math.floor(Math.random() * available.length)]; participants = { ...participants, [bot]: { status: 'in', joinedAt: now } }; } return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) }; } // Check-in window: RSVP'd bots convert to ready / "+N minutes". const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in'); if (phase === 'checkin' && inBots.length && Math.random() < 0.22) { const bot = inBots[Math.floor(Math.random() * inBots.length)]; participants = { ...participants, [bot]: Math.random() < 0.6 ? { ...participants[bot], status: 'ready' } : { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 }, }; } else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) { // Walk-ins can still join late. const bot = available[Math.floor(Math.random() * available.length)]; const readyNow = Math.random() < 0.55; participants = { ...participants, [bot]: readyNow ? { status: 'ready', joinedAt: now } : { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 }, }; } const messages = maybeBotChat({ ...n, participants }, now); const readyCount = readyCountOf({ participants }, now); const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open'; return { ...n, participants, messages, state }; } function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) { const now = Date.now() - ageSec * 1000; const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null; return { id: `seed-${gameId}-${creator}`, gameId, creator, maxPlayers, createdAt: now, scheduledFor, deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000, participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants }, messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })), state: 'open', }; } function useNominations({ username, seed = [] }) { const [noms, setNoms] = useState(() => seed.map(makeSeedNomination)); useEffect(() => { const id = setInterval(() => { setNoms(prev => { const now = Date.now(); return prev .map(n => tickNomination(n, now)) .filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600)); }); }, 1000); return () => clearInterval(id); }, []); const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => { const now = Date.now(); const nom = { id: `n${now}${Math.random().toString(36).slice(2, 7)}`, gameId, creator: username, maxPlayers, createdAt: now, scheduledFor, deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000, participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } }, messages: [], state: 'open', }; setNoms(prev => [nom, ...prev]); }; const respond = (id, choice) => { setNoms(prev => prev.map(n => { if (n.id !== id) return n; const now = Date.now(); const participants = { ...n.participants }; participants[username] = choice === 'ready' ? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now } : { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 }; return { ...n, participants }; })); }; // RSVP for a scheduled call: "I'm in". const rsvp = (id) => { setNoms(prev => prev.map(n => n.id === id ? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } } : n)); }; const sendMessage = (id, text) => { const t = String(text || '').trim(); if (!t) return; setNoms(prev => prev.map(n => n.id === id ? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] } : n)); }; const leave = (id) => { setNoms(prev => prev.map(n => { if (n.id !== id) return n; const participants = { ...n.participants }; delete participants[username]; return { ...n, participants }; })); }; const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id)); const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n)); const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n)); return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime }; } // ──────────────────────────────────────────────────────────────────── // Top-bar entry point // ──────────────────────────────────────────────────────────────────── function CallToPlayButton({ nominations, onClick }) { const active = nominations.filter(n => n.state !== 'started'); return ( ); } // ──────────────────────────────────────────────────────────────────── // Mini ready-bubbles — the per-player state, compressed for the // quick bars: green ring + check = ready, "+Nm" tag = needs minutes, // dimmed = RSVP'd but not checked in yet. // ──────────────────────────────────────────────────────────────────── function MiniBubbles({ nomination: n, now, max = 6 }) { const entries = Object.entries(n.participants); const shown = entries.slice(0, max); return ( {shown.map(([name, p]) => { const ready = isReady(p, now); const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending'; const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase(); const title = ready ? `${name} — ready` : st === 'in' ? `${name} — in, not checked in yet` : `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`; return ( {initials} {ready && } {st === 'pending' && {fmtCountdownShort(p.readyAt - now)}} ); })} {entries.length > max && +{entries.length - max}} ); } // ──────────────────────────────────────────────────────────────────── // Persistent quick bars — one row per active call, soonest first. // Scheduled calls show their clock time; once check-in opens the row // lights up to nudge everyone who said "I'm in". // ──────────────────────────────────────────────────────────────────── const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' }; // Sort rank: ready first, starting-soon next, everything else after — // ties broken by whichever resolves/starts soonest. const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 }; function CallToPlayTickerRow({ nomination: n, accent, onOpen }) { const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []); const game = GAMES_.find(g => g.id === n.gameId); if (!game) return null; const now = Date.now(); const status = statusOf(n, now); const ready = readyCountOf(n, now); const total = Object.keys(n.participants).length; const remaining = Math.max(0, n.deadline - now); const msgs = n.messages || []; const last = msgs[msgs.length - 1]; const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`; const time = status === 'ready' ? 'waiting to start' : status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}` : n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}` : fmtCountdown(remaining); return ( ); } function CallToPlayTicker({ nominations, accent, onOpen }) { const now = Date.now(); const active = nominations.filter(n => n.state !== 'started') .sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline); if (!active.length) return null; return (
{active.map(n => )}
); } // ──────────────────────────────────────────────────────────────────── // Avatar chip — ready / counting-down / RSVP'd ("in") // ──────────────────────────────────────────────────────────────────── function AvatarChip({ name, participant, now }) { const ready = isReady(participant, now); const isIn = !ready && participant.status === 'in'; const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0; const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase(); const title = ready ? `${name} — ready` : isIn ? `${name} — in, not checked in yet` : `${name} — ready in ${fmtCountdown(remaining)}`; return (
{initials} {ready ? : isIn ? in : {fmtCountdownShort(remaining)}}
); } // ──────────────────────────────────────────────────────────────────── // Nomination card // ──────────────────────────────────────────────────────────────────── function NominationCard({ nomination: n, username, accent, actions, focused }) { const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []); const [confirmCancel, setConfirmCancel] = useState(false); const game = GAMES_.find(g => g.id === n.gameId); if (!game) return null; const now = Date.now(); const entries = Object.entries(n.participants); const readyCount = readyCountOf(n, now); const inCount = inCountOf(n, now); const isMe = username in n.participants; const myStatus = n.participants[username]; const isCreator = n.creator === username; const isDone = n.state === 'done'; const isStarted = n.state === 'started'; const phase = phaseOf(n, now); const isSched = phase === 'scheduled' && !isDone && !isStarted; const isCheckin = phase === 'checkin' && !isDone && !isStarted; const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt; const totalMs = Math.max(1, n.deadline - windowStart); const remaining = Math.max(0, n.deadline - now); const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100)); const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low'; const peerCount = installedPeersFor_(game).length; let actionsBody; if (isStarted) { actionsBody =
Launching {game.title} for {n.maxPlayers} players…
; } else if (isSched) { if (isCreator) { actionsBody =
Scheduled for {fmtClock(n.scheduledFor)} — check-in opens 15 min before start.
; } else if (isMe) { actionsBody = (
You’re in — we’ll nudge you to check in 15 min before start
); } else { actionsBody = (
Check-in opens 15 min before start
); } } else if (isCreator) { if (isDone) { actionsBody = ( ); } else if (myStatus && myStatus.status === 'in') { actionsBody = (
{[5, 10, 15].map(min => ( ))}
); } else { actionsBody =
Waiting for players to ready up…
; } } else if (isDone) { actionsBody =
{readyCount >= n.maxPlayers ? 'Everyone’s ready.' : `It’s time — waiting for ${n.creator} to start.`}
; } else if (isMe) { if (myStatus.status === 'in') { actionsBody = (
You said you’re in — check in:
{[5, 10, 15].map(min => ( ))}
); } else { const ready = isReady(myStatus, now); actionsBody = (
{ready ? 'You’re in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}
{!ready && }
); } } else { actionsBody = (
{[5, 10, 15, 30].map(min => ( ))}
); } const timerEl = isStarted ?
Launching…
: isDone ?
Ready
: isSched ? (
{fmtClock(n.scheduledFor)} {fmtUntil(n.scheduledFor - now)}
) : isCheckin ? (
{fmtCountdown(remaining)} starts {fmtClock(n.scheduledFor)}
) :
{fmtCountdown(remaining)}
; const subEl = n.scheduledFor ? Scheduled by {n.creator} · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed : Called by {n.creator} · {peerCount}/{PEERS_.length} peers have it installed; const rosterLabel = isSched ? `${entries.length} in · up to ${n.maxPlayers} players` : isCheckin && inCount > 0 ? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet` : `${readyCount}/${n.maxPlayers} ready`; return (
{game.title}
{subEl}
{timerEl}
{isCheckin && (
{isMe && myStatus && myStatus.status === 'in' ? 'Starting soon — you said you’re in. Check in below.' : 'Starting soon — check-in is open.'}
)} {!isSched && (
)}
{rosterLabel}
{entries.map(([name, p]) => )} {Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
))}
{actionsBody}
actions.sendMessage(n.id, text)}/> {isCreator && !isStarted && ( confirmCancel ? (
Cancel this call for everyone?
) : ( ) )}
); } // ──────────────────────────────────────────────────────────────────── // Create-nomination form — "Now" or a scheduled clock time // ──────────────────────────────────────────────────────────────────── function CreateNominationForm({ accent, onCancel, onCreate }) { const [query, setQuery] = useState(''); const [gameId, setGameId] = useState(null); const [maxPlayers, setMaxPlayers] = useState(8); const [duration, setDuration] = useState(10); const [when, setWhen] = useState('now'); const [dayOffset, setDayOffset] = useState(0); const nextSlot = () => { const d = new Date(Date.now() + 40 * 60000); d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h) const [editing, setEditing] = useState(false); const [timeDraft, setTimeDraft] = useState(timeStr); const timeInputRef = useRef(null); // Select-all when the type-a-time field appears, so the user can overtype // immediately. Done in a layout effect (not onFocus+autoFocus) so it fires // after the browser places the autofocus caret, and re-asserts if the // per-second nominations tick re-renders the overlay. useLayoutEffect(() => { if (editing && timeInputRef.current) { timeInputRef.current.focus(); timeInputRef.current.select(); } }, [editing]); const [hStr, mStr] = timeStr.split(':'); // Nudge the time by whole hours or minute-steps, wrapping within 0–23:59. const bump = (field, delta) => { let [h, m] = timeStr.split(':').map(Number); if (field === 'h') h = (h + delta + 24) % 24; else { let total = h * 60 + m + delta; total = ((total % 1440) + 1440) % 1440; h = Math.floor(total / 60); m = total % 60; } setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); }; // Constrain free-text entry: digits + at most one colon, ≤2 digits per side // (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected // (returns the previous draft) — so a colon can't be typed after 3 digits. const sanitizeTime = (raw, prev) => { const v = String(raw).replace(/[^\d:]/g, ''); if ((v.match(/:/g) || []).length > 1) return prev; const ci = v.indexOf(':'); if (ci !== -1) { if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev; return v; } return v.length > 4 ? prev : v; }; // Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts. const commitDraft = () => { const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/); if (mm) { const h = Number(mm[1]), min = Number(mm[2] || 0); if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`); } setEditing(false); }; const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; const dayChips = [0, 1, 2].map(off => { const d = new Date(); d.setDate(d.getDate() + off); const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()]; return { off, label }; }); const matches = useMemo(() => { if (!query.trim()) return []; const q = query.toLowerCase(); return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6); }, [query]); const selected = GAMES_.find(g => g.id === gameId); const pickGame = (g) => { setGameId(g.id); setMaxPlayers(parseMaxPlayers_(g.players)); setQuery(g.title); }; const scheduledTs = () => { const [h, m] = timeStr.split(':').map(Number); const d = new Date(); d.setDate(d.getDate() + dayOffset); d.setHours(h || 0, m || 0, 0, 0); if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1); return d.getTime(); }; return (
{ setQuery(e.target.value); setGameId(null); }}/>
{!selected && query.trim() && (
{matches.map(g => ( ))} {matches.length === 0 &&
No games match "{query}"
}
)}
{selected && (
setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/> Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}
{when === 'later' && (
{editing ? ( setTimeDraft(prev => sanitizeTime(e.target.value, prev))} onBlur={commitDraft} onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/> ) : (
{hStr}
:
{mStr}
)}
Day {dayChips.map(c => ( ))}
24-hour clock. People RSVP with “I’m in” — check-in to ready up opens 15 min before start.
)}
{when === 'now' && (
{[5, 10, 15, 30, 60].map(min => ( ))}
)}
)} {!selected && (
)}
); } // ──────────────────────────────────────────────────────────────────── // Full overlay // ──────────────────────────────────────────────────────────────────── function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) { const [showCreate, setShowCreate] = useState(false); const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline); return (
e.stopPropagation()}>

Call to Play

Rally the LAN around a game and a time — right now, or scheduled for later with an “I’m in” RSVP. The caller decides when it actually starts.

{!showCreate && ( )}
{showCreate && ( setShowCreate(false)} onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/> )} {sorted.length === 0 && !showCreate && (
No active calls right now — be the one to start something.
)} {sorted.map(n => ( ))}
); } Object.assign(window, { useNominations, makeSeedNomination, CallToPlayButton, CallToPlayTicker, CallToPlayOverlay, NominationCard, CreateNominationForm, });