-->
`;
}
// ==========================================================
// PLAYER NAME HELPERS
// ==========================================================
function pickPlayer() {
if (!State.players.length) return null;
return State.players[Math.floor(Math.random() * State.players.length)];
}
function nextTurnIdx(currentIdx) {
if (!State.players.length) return 0;
return (currentIdx + 1) % State.players.length;
}
function currentPlayerName(idx) {
return State.players[idx % State.players.length] || `Player ${idx+1}`;
}
// Compute eligible players (names minus the Captain). Used for both pool building
// and detecting when we should fall back to anonymous-cue mode.
function eligiblePlayerNames() {
var captainName = (State.captain || '').trim().toLowerCase();
return (State.players || []).filter(function (p) {
return (p || '').trim().toLowerCase() !== captainName;
});
}
// Returns true when there are no real player names available, so games should
// fall back to physical-cue prompts (e.g. "Person to the left of the phone").
function isAnonPickMode() {
return eligiblePlayerNames().length === 0;
}
// Pick a random "player" who hasn't been picked yet for this game's pool.
// When the pool empties, refill it with a fresh shuffled copy of all eligible items.
// If there are no real player names (Captain only or skipped setup), the pool falls
// back to physical-cue prompts from Data.hostSelect so the games still work.
function pickPlayerNoRepeat(gameKey) {
var eligible = eligiblePlayerNames();
// Source: real names if any, otherwise the physical-cue prompts (no-typing fallback).
var source = eligible.length ? eligible : (Data.hostSelect || []);
if (!source.length) return null;
if (!State.playerPools) State.playerPools = {};
var pool = State.playerPools[gameKey];
// Treat the pool as stale if any item in it is no longer in source — handles
// captain changes, player additions/removals, AND switching between names mode
// and anon-cue mode mid-game.
var poolValid = Array.isArray(pool) && pool.length > 0
&& pool.every(function (p) { return source.indexOf(p) !== -1; });
if (!poolValid) {
var copy = source.slice();
// Fisher–Yates shuffle
for (var i = copy.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = copy[i]; copy[i] = copy[j]; copy[j] = tmp;
}
State.playerPools[gameKey] = copy;
}
return State.playerPools[gameKey].shift();
}
// Render the spotlight-player banner. Handles both name mode ("▸ ALICE PITCHES")
// and anon-cue fallback ("▸ NEXT UP — Person to the left of the phone").
// `actionVerb` is the short uppercase verb shown after a name (e.g. PITCHES, ANSWERS).
function turnBannerHTML(player, actionVerb) {
if (!player) return '';
if (isAnonPickMode()) {
// Strip trailing period, render the cue as the prompt itself.
var cue = String(player).replace(/\.$/, '');
return '
';
}
// Reset all per-game pools — call when player list OR Captain changes.
function resetPlayerPools() {
State.playerPools = { roulette: [], backpack: [], olympics: [], scam: [], dating: [], packing: [], jenga: [], embassy: [] };
State.rouletteCurrentPlayer = null;
State.scenarioCurrentPlayer = null;
State.olympicsCurrentPlayer = null;
State.scamCurrentPlayer = null;
State.datingCurrentPlayer = null;
State.packingCurrentPlayer = null;
State.jengaCurrentPlayer = null;
State.embassyCurrentPlayer = null;
}
// CUSTOMS DECLARATION helpers — Quiplash-style answer collection + reveal.
// pickRandomCustomsQuestion uses a "recently shown" list (last ~7 indices) to reduce
// short-term repeats. When the recent set covers most of the pool, it auto-clears so
// the picker doesn't deadlock with a small question pool.
function pickRandomCustomsQuestion(excludeIdx) {
var total = (Data.customsQuestions || []).length;
if (total === 0) return -1;
if (total === 1) return 0;
if (!Array.isArray(State.customsRecentIdxs)) State.customsRecentIdxs = [];
var memorySize = Math.min(7, Math.max(1, total - 1));
// If recent list has grown past memorySize, trim to last N entries
if (State.customsRecentIdxs.length > memorySize) {
State.customsRecentIdxs = State.customsRecentIdxs.slice(-memorySize);
}
var banned = new Set(State.customsRecentIdxs);
if (excludeIdx != null && excludeIdx >= 0) banned.add(excludeIdx);
// Safety: if banned covers nearly all, reset memory so we don't deadlock
if (banned.size >= total - 1) {
State.customsRecentIdxs = [];
banned = new Set();
if (excludeIdx != null && excludeIdx >= 0) banned.add(excludeIdx);
}
var idx;
var safety = 0;
do {
idx = Math.floor(Math.random() * total);
safety++;
if (safety > 100) break;
} while (banned.has(idx));
// Push to recent list
State.customsRecentIdxs.push(idx);
if (State.customsRecentIdxs.length > memorySize) {
State.customsRecentIdxs = State.customsRecentIdxs.slice(-memorySize);
}
return idx;
}
function currentCustomsQuestion() {
var idx = State.customsQuestionIdx;
return (Data.customsQuestions || [])[idx] || null;
}
function ensureCustomsQuestion() {
if (State.customsQuestionIdx < 0 || State.customsQuestionIdx >= (Data.customsQuestions || []).length) {
State.customsQuestionIdx = pickRandomCustomsQuestion(-1);
}
}
// Returns the ordered list of player labels for a given round.
// Real player names if entered (excludes captain); otherwise "Player 1..N" (default 4).
function customsAnswerPlayers() {
var captain = (State.captain || '').trim().toLowerCase();
var real = (State.players || []).filter(function(p){ return (p || '').trim().toLowerCase() !== captain; });
if (real.length >= 2) return real;
return ['Player 1', 'Player 2', 'Player 3', 'Player 4'];
}
// Fisher-Yates shuffle returning a NEW permutation array of indices [0..n-1]
function shuffleIndices(n) {
var arr = [];
for (var i = 0; i < n; i++) arr.push(i);
for (var k = arr.length - 1; k > 0; k--) {
var j = Math.floor(Math.random() * (k + 1));
var t = arr[k]; arr[k] = arr[j]; arr[j] = t;
}
return arr;
}
// Embassy theme helpers — pick / read the current theme, lazy-init if needed.
function currentEmbassyTheme() {
var themes = Data.embassyThemes || [];
if (!themes.length) return null;
var idx = State.embassyThemeIdx;
if (idx == null || idx < 0 || idx >= themes.length) return null;
return themes[idx];
}
function pickRandomEmbassyTheme(excludeIdx) {
var total = (Data.embassyThemes || []).length;
if (total === 0) return -1;
if (total === 1) return 0;
var idx;
do { idx = Math.floor(Math.random() * total); } while (idx === excludeIdx);
return idx;
}
function ensureEmbassyTheme() {
if (State.embassyThemeIdx < 0 || State.embassyThemeIdx >= (Data.embassyThemes || []).length) {
State.embassyThemeIdx = pickRandomEmbassyTheme(-1);
}
}
// Pick 3 random fragments for a given embassy slot WITHIN the current theme.
function rollEmbassyOptions(slotIdx) {
ensureEmbassyTheme();
var theme = currentEmbassyTheme();
var pool = (theme && theme.slots && theme.slots[slotIdx]) || [];
if (pool.length < 3) return pool.slice();
var copy = pool.slice();
for (var i = copy.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = copy[i]; copy[i] = copy[j]; copy[j] = tmp;
}
return copy.slice(0, 3);
}
// Assemble picked fragments into one flowing story with grammatical connectors.
// 4-slot story arc — each slot is a compound sentence, all connectors are sentence breaks:
// 1 SCENE → 2 WHAT WE DID (". ")
// 2 → 3 THE TWIST (". ")
// 3 → 4 ENDING (". ")
function assembleEmbassyStory(picks) {
if (!picks || !picks.length) return '';
var connectors = ['. ', '. ', '. '];
var out = picks[0];
for (var i = 1; i < picks.length; i++) {
out += (connectors[i - 1] || '. ') + picks[i];
}
// End with a period if not already terminated
if (!/[.!?]$/.test(out)) out += '.';
return out;
}
// ==========================================================
// RENDER
// ==========================================================
const appDivEl = document.getElementById('app');
const bgEl = document.getElementById('body-bg');
function topbarHTML() {
let flightCode = 'GATE 12';
if (State.view !== 'lobby' && State.view !== 'host_select' && State.view !== 'players_setup' && State.view !== 'night_setup' && State.view !== 'summary') {
const game = Games.find(g => State.view.startsWith(g.id) || State.currentGame === g.id);
if (game) flightCode = game.code;
}
let roundPip = '';
if (State.nightMode.active) {
roundPip = `RND ${State.nightMode.currentRound}/${State.nightMode.totalRounds}`;
}
const isHome = (State.view === 'lobby' || State.view === 'host_select');
// Captain badge — visible everywhere except host_select itself
const showCaptain = State.captain && State.view !== 'host_select';
const captainBadge = showCaptain
? ``
: (State.view !== 'host_select' && State.view !== 'lobby'
? ``
: '');
// Note: brand-mark spans are kept on one line so textContent stays clean (no trailing whitespace from indentation).
return `
${captainBadge}
${roundPip}FLT NB-001·${flightCode}
`;
}
// Captain Power overlay — bottom sheet
function captainPowerOverlayHTML() {
if (!State.captainPowerOpen) return '';
const cap = State.captain || 'Captain';
return `
👑 ${cap.toUpperCase()}'S POWERS
All powers are honour-system. Captain's word is final.
`;
}
function captainStampHTML() {
if (!State.captainStamp) return '';
return `
${State.captainStamp}
`;
}
function gameHeaderHTML(title, code) {
return `
${code}${title}
${dareRibbonHTML()}`;
}
// Custom dare integration — when user has saved custom dares, splice them
// into every game's penalty rules instead of the default "DARE" word.
// Pick ONCE per game session: same dare for the whole time you're in a game.
// Cleared when returning to lobby; re-picked next time you enter a game.
function isGameView(view) {
return view === 'intro' || view === 'host_play' || view.endsWith('_play');
}
function ensureGameDare() {
// Called from render() — manages State.activeDare lifecycle.
if (!isGameView(State.view)) {
State.activeDare = null;
return null;
}
if (!State.activeDare && State.customDares && State.customDares.length > 0) {
State.activeDare = State.customDares[Math.floor(Math.random() * State.customDares.length)];
}
return State.activeDare;
}
// Persistent top ribbon shown on every game view when a custom dare is active
function dareRibbonHTML() {
if (!State.activeDare) return '';
return `
🎯 ROUND DARE"${State.activeDare}"
`;
}
// ==========================================================
// PROMPT SHARE — generate share card on-the-fly + Web Share API
// ==========================================================
function shareBtnHTML() {
return ``;
}
function setShareablePrompt(text, label, code, opts) {
State.shareablePrompt = (text && text !== 'TAP TO DRAW' && text !== 'TAP FOR A DECREE')
? { text, label, code, opts: opts || {} }
: null;
}
function escapeXml(s) {
return (s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function wrapTextForSVG(text, maxChars) {
const words = text.split(' ');
const lines = [];
let cur = '';
for (const w of words) {
if ((cur + ' ' + w).trim().length > maxChars && cur) {
lines.push(cur.trim());
cur = w;
} else {
cur = (cur + ' ' + w).trim();
}
}
if (cur) lines.push(cur);
return lines;
}
// Build the 1080×1080 passport-blue share card SVG (Variant 2 design)
function buildShareCardSVG(rawText, gameLabel, gameCode) {
const text = escapeXml(rawText);
// Adaptive fit: larger font + narrow wrap for short prompts;
// smaller font + wider wrap for long stories (Embassy can be 5+ sentences).
// Vertical text band ≈ 540px (between header at y≈140 and dashed divider above footer).
const tiers = [
{ fontSize: 80, maxChars: 22 },
{ fontSize: 68, maxChars: 26 },
{ fontSize: 58, maxChars: 30 },
{ fontSize: 50, maxChars: 36 },
{ fontSize: 42, maxChars: 44 },
{ fontSize: 36, maxChars: 52 },
{ fontSize: 32, maxChars: 60 }
];
const maxBand = 540;
let chosen = tiers[tiers.length - 1];
let lines = wrapTextForSVG(text, chosen.maxChars);
for (const t of tiers) {
const wrapped = wrapTextForSVG(text, t.maxChars);
const lh = Math.round(t.fontSize * 1.18);
if (wrapped.length * lh <= maxBand) {
chosen = t; lines = wrapped; break;
}
}
const fontSize = chosen.fontSize;
const lineHeight = Math.round(fontSize * 1.18);
const totalH = lines.length * lineHeight;
const startY = Math.round(560 - totalH / 2);
const lineSvg = lines.map((line, i) => {
const isLast = i === lines.length - 1;
return `${line}`;
}).join('');
// Grid pattern (faint)
const gridLines = [];
for (let x = 40; x < 1080; x += 40) gridLines.push(``);
for (let y = 40; y < 1080; y += 40) gridLines.push(``);
return ``;
}
async function svgToPngBlob(svgStr, size = 1080) {
return new Promise((resolve, reject) => {
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1A2742';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob(b => b ? resolve(b) : reject(new Error('blob failed')), 'image/png', 0.95);
};
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
async function sharePromptCard(text, label, code, opts) {
const SITE_URL = 'https://nightbus.party';
// Most prompts are bare sentences and benefit from outer "..." quotes for share text.
// Structured outputs (e.g., Customs Q+A pairs) already have their own quotes / formatting,
// so callers can pass { noQuote: true } to skip the wrapping and avoid nested quotes.
const noQuote = opts && opts.noQuote;
const shareText = noQuote
? `${text}\n\nFrom ${label} on NIGHT BUS — the free party game for backpackers.`
: `"${text}"\n\nFrom ${label} on NIGHT BUS — the free party game for backpackers.`;
try {
const svg = buildShareCardSVG(text, label, code);
const blob = await svgToPngBlob(svg);
const file = new File([blob], 'nightbus-prompt.png', { type: 'image/png' });
const baseShare = { title: `${label} — NIGHT BUS`, text: shareText, url: SITE_URL };
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ ...baseShare, files: [file] });
flashCaptainStamp('↗ SHARED!');
} else if (navigator.share) {
await navigator.share(baseShare);
flashCaptainStamp('↗ SHARED!');
} else {
// Fallback: download PNG + copy text+URL
const dlUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = dlUrl; a.download = 'nightbus-prompt.png';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(dlUrl), 1000);
try { await navigator.clipboard.writeText(`${shareText}\n${SITE_URL}`); } catch(e) {}
flashCaptainStamp('📋 SAVED + COPIED');
}
} catch(e) {
if (e.name !== 'AbortError') {
flashCaptainStamp('⚠ SHARE FAILED');
console.error('Share failed:', e);
}
}
}
// Verb form: replaces "${penaltyVerb}" — fills "everyone ___" or "they ___" in narrative text.
function dareVerbForm(d) {
return d ? `does the dare: "${d}"` : 'DARES';
}
// Noun form: replaces "${penaltyNoun}" — fills "Wrong = ___" or "+1 ___" in narrative text.
function dareNounForm(d) {
return d ? `the dare: "${d}"` : 'DARE';
}
// SHORT noun for BUTTONS (stable label, never includes the user's custom dare text so buttons
// don't expand or wrap awkwardly when long custom dares are saved).
function dareShortNoun(d) {
return d ? 'CUSTOM DARE' : 'DARE';
}
function render(skipEnter = false) {
bgEl.classList.toggle('bomb-exploded', State.view === 'bomb_play' && State.bombExploded);
let html = `
${topbarHTML()}`;
// Reset shareable prompt; each game view that has a prompt will set it again below
State.shareablePrompt = null;
// Stable dare for the entire game session — same dare from intro through last round
const activeDare = ensureGameDare();
// penaltyVerb fills "everyone ___" or "they ___" in narrative reveal text
const penaltyVerb = dareVerbForm(activeDare);
// penaltyNoun fills "Wrong = ___" or "Fail = ___" in narrative reveal text
const penaltyNoun = dareNounForm(activeDare);
// penaltyShortNoun is the BUTTON-safe label — never includes the user's custom text,
// so buttons stay compact even when long custom dares are saved.
const penaltyShortNoun = dareShortNoun(activeDare);
// ============================================
// HOST SELECT — pick the Captain (Game Master)
// ============================================
if (State.view === 'host_select') {
if (!State.hostPrompt) State.hostPrompt = Data.hostSelect[Math.floor(Math.random() * Data.hostSelect.length)];
const hasRoster = State.players.length > 0;
html += `
↓
FIND THE CAPTAIN
CAPTAIN DUTY
${State.hostPrompt}
👑 CAPTAIN POWERS
↻ Pass the gavel🎯 Summon a dare⚡ +1 dare penalty🗳 Force a re-vote⏭ Skip the round
Tap the 🧢 badge in the header any time you're playing.
${hasRoster ? `
▸ TAP THE CHOSEN ONE
${State.players.map((p,i) => ``).join('')}
` : `
▸ TYPE THE CAPTAIN'S NAME
Or skip — you can pick a Captain later.
`}
`;
}
// ============================================
// PLAYERS SETUP
// ============================================
else if (State.view === 'players_setup') {
if (!State.playersPrompt) State.playersPrompt = Data.playersPrompts[Math.floor(Math.random() * Data.playersPrompts.length)];
html += `
↓
PASSENGER MANIFEST · ${State.players.length}/12
BOARDING NOW
${State.playersPrompt}
▸ ADD A NAME
${State.players.length ? `
${State.players.map((p,i) => `
${p}
`).join('')}
` : ''}
Captain stays out of the random draw — they're the judge, not a player.
${State.players.length ? `` : ''}
`;
}
// ============================================
// CUSTOM DARES (user-defined penalties)
// ============================================
else if (State.view === 'custom_dares') {
html += `
CUSTOM DARES.
Add anything your group will actually do — push-ups, fake-accent challenges, embarrassing texts, inside jokes. Once saved, they replace the default "DARE" penalty in every game. So when Culture Brawl says "everyone DARES," it'll show your custom dare instead. Your party, your call. Saved on your device only.
▸ ADD A DARE · ${State.customDares.length} SAVED
${State.customDares.map((d,i) => `
${d}
`).join('')}
⚠ Whatever you add is on you and your group — keep it safe, legal, and consensual. NIGHT BUS doesn't review or store these on a server.
${State.customDares.length ? `` : ''}
`;
}
// ============================================
// NIGHT MODE SETUP
// ============================================
else if (State.view === 'night_setup') {
html += `
NIGHT MODE.
A full-night session. Random games, Captain's Decree at the end. We track stats and crown the MVP.
▸ HOW MANY ROUNDS
5
QUICK
10
FULL FLIGHT
15
LONG HAUL
`;
}
// ============================================
// LOBBY
// ============================================
else if (State.view === 'lobby') {
html += `
`;
}
// ============================================
// INTRO
// ============================================
else if (State.view === 'intro') {
const game = Games.find(g => g.id === State.currentGame);
const rules = {
'culture': ['Read the statement aloud.', 'Group decides: TRUE or FALSE — vote together.', `PENALTY: Wrong vote = everyone ${penaltyVerb}.`],
'wyr': ['Read both bad choices aloud.', 'Tap START to launch the 15-sec vote timer when everyone has heard the prompt.', `PENALTY: The minority ${penaltyVerb}.`],
'roulette': ['Phone draws a question + picks a player to answer (every player goes once before anyone repeats).', '90-sec timer kicks off — tell your story before time runs out.', 'RULE: Group can ask 3 follow-ups after.'],
'bomb': ['Random timer (15-60s).', 'Pass the phone after each answer.', `PENALTY: Loser hits Captain's Decree.`],
'mostlikely': ['Read the prompt aloud.', '3-second countdown then everyone points.', `PENALTY: Most pointed-at ${penaltyVerb} + holds phone.`],
'visa': ['Question appears. Tap REVEAL ON 3 — everyone raises hand if YES.', `Captain counts the hands and taps the MINORITY side (the few).`, `PENALTY: Minority side ${penaltyVerb}. 50/50 split = everyone takes it.`],
'lit': ['Foreign phrase + 2 options (one is real, one is absurd).', 'Pick A or B in your head. Tap REVEAL ON 3.', `PENALTY: Wrong = ${penaltyNoun}.`],
'backpack': ['Phone picks a random player + a survival scenario (every player goes once before repeats).', 'That player has 30 sec to pull an item from their bag and pitch why it saves them.', `PENALTY: Worst pitch ${penaltyVerb}.`],
'customs': [`Phone shows an absurd customs/travel question (e.g. "What's actually in your suitcase?"). Captain reads aloud, then passes phone around.`, `Each player privately types one anonymous answer (~30 sec). When everyone's done, all answers reveal shuffled. Group taps the funniest. Author is unmasked.`, `PENALTY: Author of the funniest gets bragging rights. Worst answer ${penaltyVerb}. Tap SHARE to send Q + winning answer to your group chat.`],
'jenga': ['One opening sentence, then pass.', 'Each player has 30 sec to add ONE sentence.', 'Sentence 7 = climax. Sentence 10 = end.', `FINALE: Group votes the worst contributor — they ${penaltyNoun}.`],
'olympics': ['A physical mini-challenge appears.', `Random player drawn each round (🎲 every player goes once before anyone repeats).`, `PENALTY: Fail = 2x ${penaltyNoun}.`],
'packing': ['Pick a destination. First player names one item to pack. Each next player repeats the full list in order, then adds one more.', `Captain is the judge — if the player hesitates too long or gets the order wrong, they lose.`, `PENALTY: Captain calls the loss = ${penaltyNoun}. No typing — keep it in your head.`],
'scam': ['Phone picks a random player to answer (every player goes once before repeats).', 'That player decides: SCAM 🚨 or REAL ✅', `PENALTY: Wrong = ${penaltyVerb}. Right = you might save $100 next trip.`],
'dating': ['Phone picks a random player to swipe (every player goes once before repeats).', 'They decide: SWIPE LEFT 👈 or RIGHT 👉', `PENALTY: Bad call = ${penaltyVerb}. Right call = save your future.`]
}[State.currentGame];
html += gameHeaderHTML('PRE-FLIGHT BRIEFING', game.code);
html += `
`;
}
// ============================================
// ROULETTE
// ============================================
else if (State.view === 'roulette_play') {
// Lazy-init the current player on first draw (when story is no longer placeholder).
if (State.currentStory !== 'TAP TO DRAW' && !State.rouletteCurrentPlayer) {
State.rouletteCurrentPlayer = pickPlayerNoRepeat('roulette');
}
setShareablePrompt(State.currentStory, 'Story Roulette', 'NB·03');
html += gameHeaderHTML('STORY ROULETTE', 'NB·03');
const turnPlayer = State.rouletteCurrentPlayer;
const isPlaceholder = State.currentStory === 'TAP TO DRAW';
const tState = State.turnTimerState;
html += `
`;
}
// ============================================
// TIME BOMB
// ============================================
else if (State.view === 'bomb_play') {
html += gameHeaderHTML("DON'T HOLD IT", 'NB·05');
html += `
`;
if (!State.bombRunning && !State.bombExploded) {
// IDLE state — ad slot is safe here (user reading START button, no time pressure, no accidental click risk)
html += `${adSlot('inline-game', 'bomb-idle-top')}
💣
`;
} else if (State.bombExploded) {
if (State.bombTheme) setShareablePrompt(`Name something: ${State.bombTheme}`, 'Time Bomb', 'NB·05');
html += `
NO WAS THE MINORITYEVERYONE WHO SAID NO — ${penaltyVerb}
`;
} else if (State.visaResult === 'tie') {
// Pre-decide which side dares for ties (phone-flip), stored on result for stable display.
resultBanner = `
SHARED CONFESSION — 50/50EVERYONE ${penaltyVerb} · TOGETHER
`;
}
// ============================================
// LOST IN TRANSLATION — 2 options + 3-2-1 reveal
// ============================================
else if (State.view === 'lit_play') {
const q = Data.lit[State.litIndex];
const phase = State.revealPhase;
setShareablePrompt(`${q.lang} idiom: "${q.phrase}" — A) ${q.options[0]} · B) ${q.options[1]}. What does it really mean?`, 'Lost in Translation', 'NB·08');
html += gameHeaderHTML('LOST IN TRANSLATION', 'NB·08');
html += `
▸ ${q.lang}
"${q.phrase}"
${State.litAnswered ? `
${q.exp}
` : ''}
${q.options.map((opt, i) => {
let cls = '';
if (State.litAnswered) {
if (i === q.correct) cls = 'correct';
else cls = 'wrong';
}
return ``;
}).join('')}
`;
}
// ============================================
// BACKPACK ROULETTE (NEW)
// ============================================
else if (State.view === 'backpack_play') {
const scenario = Data.scenarios[State.scenarioIndex];
// Lazy-init: pick a random player on first render if not set
if (!State.scenarioCurrentPlayer) {
State.scenarioCurrentPlayer = pickPlayerNoRepeat('backpack');
}
const turnPlayer = State.scenarioCurrentPlayer;
setShareablePrompt(scenario, 'Backpack Roulette', 'NB·09');
html += gameHeaderHTML('BACKPACK ROULETTE', 'NB·09');
html += `
No player names — Captain points at the worst contributor verbally. They ${penaltyNoun}.
${nightModeNextBtn()}
`}
`;
}
// === PLAYING PHASE ===
else {
// Decide what to call the player in body text — short name in name mode, "they" in anon mode
const callout = anonMode ? 'they' : turnPlayer;
html += `
${anonMode ? 'Add' : turnPlayer + ', add'} one sentence to the story.
`}
${(() => {
const tState = State.turnTimerState;
const isOpener = State.jengaSentence === 1;
const isFinale = State.jengaSentence >= 10;
// Only the opener turn skips the timer (captain reads opener aloud).
// For sentences 2+, the player must first read what came before, then tap START.
if (isOpener) {
return ``;
}
if (isFinale && tState === 'running') {
return turnTimerHTML("TIME'S UP — END IT!");
}
if (isFinale) {
return ``;
}
// Mid-story (sentence 2-9): manual-start timer
if (tState === 'running') {
return turnTimerHTML("TIME'S UP — ADD ANYTHING!") +
``;
}
if (tState === 'expired') {
return ``;
}
// idle: show start-timer + a manual pass option
return `
▸ READ WHAT CAME BEFORE · TAP WHEN READY
`;
})()}
${shareBtnHTML()}
`;
}
}
// ============================================
// HOSTEL OLYMPICS (NEW)
// ============================================
else if (State.view === 'olympics_play') {
const o = Data.olympics[State.olympicsIndex];
// Lazy-init: pick a random player on first render if not set
if (!State.olympicsCurrentPlayer) {
State.olympicsCurrentPlayer = pickPlayerNoRepeat('olympics');
}
const turnPlayer = State.olympicsCurrentPlayer;
setShareablePrompt(o.challenge, 'Hostel Olympics', 'NB·12');
html += gameHeaderHTML('HOSTEL OLYMPICS', 'NB·12');
html += `
`;
}
// ============================================
// I'M PACKING MY BAG (NEW)
// ============================================
else if (State.view === 'packing_play') {
const dest = Data.packingDestinations[State.packingIndex];
// Lazy-init the spotlight player using random no-repeat (Captain auto-excluded;
// falls back to physical-cue prompt if no players have been added).
if (!State.packingCurrentPlayer) {
State.packingCurrentPlayer = pickPlayerNoRepeat('packing');
}
const turnPlayer = State.packingCurrentPlayer || 'NEXT PLAYER';
const captain = State.captain || 'CAPTAIN';
const anonMode = isAnonPickMode();
// Display label depends on mode — anon prompts are full sentences (don't uppercase / shrink font)
const turnDisplay = anonMode
? `
▸ NEXT UP
${String(turnPlayer).replace(/\.$/, '')}
`
: `
▸ NEXT UP
${turnPlayer.toUpperCase()}
`;
// Button labels: short name in name mode, generic "NEXT" in anon mode (since prompt is too long)
const passLabel = anonMode ? 'GOT IT · NEXT PLAYER →' : `${turnPlayer.toUpperCase()} GOT IT · NEXT PLAYER →`;
const loseLabel = anonMode ? `THEY LOST — ${penaltyVerb}` : `${turnPlayer.toUpperCase()} LOST — ${penaltyVerb}`;
html += gameHeaderHTML("I'M PACKING MY BAG", 'NB·13');
html += `