let _editBidDebounceTimer = null; let _editTaxDebounceTimer = null; function openManagePlayersModal() { loadSavedPlayers(); document.getElementById('managePlayersModal').classList.add('active'); } function renderGamePlayersModal() { const container = document.getElementById('gamePlayersContent'); const activePlayers = gameState.players.filter(player => player.active !== false); const inactivePlayers = gameState.players.filter(player => player.active === false); const availableSavedPlayers = getSavedPlayers().filter(name => !gameState.players.some(player => player.name === name)); container.innerHTML = `
${activePlayers.map(player => `
${escapeHtml(player.name)}
Score: ${player.score}
`).join('')}
${inactivePlayers.length > 0 ? `
${inactivePlayers.map(player => `
${escapeHtml(player.name)} • ${player.score}
`).join('')}
` : ''}

Missed-round credit uses Math.round(((players - 2) * 20) / players) for each scored round they missed.

${availableSavedPlayers.map(player => `
${escapeHtml(player)}
` ).join('')}
`; } function toggleMidGameDropdown() { toggleDropdownPanel('player-select-panel', document.getElementById('midgame-select-panel')); } function selectMidGamePlayer(name) { document.querySelectorAll('.player-select-panel.open').forEach(p => p.classList.remove('open')); document.getElementById('midGamePlayerSelect').value = name; document.getElementById('midgame-select-label').textContent = name; document.querySelectorAll('#midgame-select-panel .player-select-option').forEach(opt => { opt.classList.toggle('selected', opt.textContent === name); }); } function openGamePlayersModal() { renderGamePlayersModal(); document.getElementById('gamePlayersModal').classList.add('active'); } function addPlayerMidGame() { const select = document.getElementById('midGamePlayerSelect'); const input = document.getElementById('midGamePlayerName'); const name = (input.value.trim() || select.value.trim()); if (!name) { alert('Choose a saved player or type a new player name.'); return; } if (gameState.players.some(player => player.name.toLowerCase() === name.toLowerCase())) { alert('That player is already part of this game.'); return; } if (getActivePlayerIndexes().length >= 11) { alert('You already have 11 active players. Disable someone first.'); return; } const player = { name, score: 0, rounds: [], active: true, previousPosition: gameState.players.length + 1 }; const playerIndex = gameState.players.push(player) - 1; gameState.rounds.forEach((round, roundIndex) => { if (round.scored) { const credit = calculateMissedRoundCredit(getRoundParticipatingCount(round)); round.playerData[playerIndex] = createRoundPlayerData(false, { score: credit, absentReason: 'joined-late' }); player.score += credit; player.rounds.push({ round: roundIndex + 1, bid: null, gotSet: false, score: credit, joinedLate: true }); } else { round.playerData[playerIndex] = createRoundPlayerData(true); } }); const savedPlayers = getSavedPlayers(); if (!savedPlayers.includes(name)) { savePlayers([...savedPlayers, name]); loadSavedPlayers(); } renderGame(); updatePlayerPositions(); autoSave(); renderGamePlayersModal(); alert(`${name} added with ${player.score} catch-up points.`); } function disablePlayerMidGame(playerName) { const playerIndex = gameState.players.findIndex(player => player.name === playerName); if (playerIndex < 0) return; const activeIndexes = getActivePlayerIndexes(); if (activeIndexes.length <= 3) { alert('You need at least 3 active players.'); return; } if (!confirm(`Disable ${playerName} for the rest of the game?`)) { return; } const currentRound = gameState.rounds[gameState.currentRound]; const currentRoundData = currentRound?.playerData?.[playerIndex]; gameState.players[playerIndex].active = false; if (currentRound && !currentRound.scored && currentRoundData?.participating) { currentRound.playerData[playerIndex] = createRoundPlayerData(false, { absentReason: 'disabled' }); if (currentRound.dealerIndex === playerIndex) { const remainingActive = gameState.players .map((player, idx) => (currentRound.playerData[idx]?.participating ? idx : null)) .filter(idx => idx !== null); currentRound.dealerIndex = remainingActive[0] ?? currentRound.dealerIndex; } } renderGame(); updatePlayerPositions(); autoSave(); renderGamePlayersModal(); } function openRulesModal() { const content = document.getElementById('rulesContent'); content.innerHTML = `

🎯 Game Overview

Oh Hell is a trick-taking card game where players must bid exactly how many tricks they'll win. The total bids cannot equal the hand size (marked as ❌ EXACT).

📊 Scoring Formula

Your score for a round depends on whether you made your bid:

✅ Made Your Bid (Positive Bid > 0)

Score = 10 + (Bid²) + Confidence - Deferred - Tax

Example: Bid 3, Confidence MAX (10), No deferred, No tax

= 10 + (3²) + 10 - 0 - 0 = 29 points

✅ Made Zero Bid

Score = ZeroBonus + HandSize + Confidence - Deferred - Tax

Zero Bonus: 10 points (≤6 players) or 5 points (>6 players)

Example: 4 players, Hand size 8, Confidence MAX (5)

= 10 + 8 + 5 - 0 - 0 = 23 points

❌ Got Set (Failed Bid)

Score = -Confidence - Deferred - Tax

Example: Confidence MAX (10), Deferred, Tax 2

= -10 - 2 - 2 = -14 points

⚙️ Modifiers

Confidence

How confident are you in making your bid?

Tax

Points paid by the auction winner to buy trump suit choice

Deferred

If you bid last and deferred your decision:

📐 Complete Examples

Example 1: Successful High Bid

• Bid: 5
• Confidence: MAX (10)
• Tax: 3
• Deferred: No
• Result: Made bid ✅

10 + (5²) + 10 - 0 - 3 = 10 + 25 + 10 - 3 = 42 points

Example 2: Failed Bid with Penalties

• Bid: 4
• Confidence: 5
• Tax: 2
• Deferred: Yes
• Result: Got set ❌

-5 - 2 - 2 = -9 points

Example 3: Successful Zero Bid

• Bid: 0
• Hand Size: 10
• Players: 6
• Confidence: MAX (5 for zero bids)
• Tax: 0
• Result: Made bid ✅

10 + 10 + 5 - 0 - 0 = 25 points

🎲 Bid Rules

`; document.getElementById('rulesModal').classList.add('active'); } function openSettingsModal() { const syncUrl = normalizeSyncUrl(localStorage.getItem('ohHellPlayerSyncUrl') || DEFAULT_SYNC_URL); const historySourceUrl = localStorage.getItem(HISTORY_SOURCE_URL_STORAGE_KEY) || DEFAULT_HISTORY_SOURCE_URL; const autoSync = localStorage.getItem('ohHellAutoSync') !== 'false'; const modal = document.getElementById('settingsModal'); const content = modal.querySelector('.modal-content'); content.innerHTML = `

Enter a URL to a JSON file with your player list. The app will automatically fetch updates when loaded.

Example: GitHub raw file URL or any CORS-enabled endpoint with a JSON players array

Paste a GitHub history folder URL and the app will auto-discover every JSON file using the GitHub Contents API.

Example: https://github.com/rickytann14/Oh-Hell/tree/main/history

`; modal.classList.add('active'); } function showSaveLoadModal() { const gameList = document.getElementById('gameList'); gameList.innerHTML = `

Select a saved game file (.json) to load

`; document.getElementById('saveLoadModal').classList.add('active'); } function loadGameFromFile(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const game = JSON.parse(e.target.result); // Validate it's a valid game file if (!game.players || !game.rounds || !Array.isArray(game.players)) { throw new Error('Invalid game file format'); } // Handle backward compatibility if (!game.startingHandSize) { game.startingHandSize = game.handProgression?.[0] || game.rounds?.[0]?.handSize || game.currentHandSize || 10; } // Initialize previousPosition if not present gameState = normalizeGameState(game); setActiveView('game'); renderGame(); closeModal('saveLoadModal'); alert('✅ Game loaded successfully!'); } catch (error) { alert('❌ Error loading game file: ' + error.message); } }; reader.readAsText(file); // Reset file input event.target.value = ''; } function deleteGame(gameId) { // This function is no longer needed but kept for backward compatibility alert('Delete function is no longer available. Simply delete the JSON file from your computer.'); } function closeModal(modalId) { document.getElementById(modalId).classList.remove('active'); } function editRound(roundIndex) { editingRoundIndex = roundIndex; const round = gameState.rounds[roundIndex]; document.getElementById('editRoundNumber').textContent = roundIndex + 1; const totalBids = round.playerData.reduce((sum, p) => sum + p.bid, 0); let bidStatus = ''; let bidClass = ''; if (totalBids < round.handSize) { bidStatus = '🦆 Under'; bidClass = 'under'; } else if (totalBids > round.handSize) { bidStatus = '🦢 Over'; bidClass = 'over'; } else { bidStatus = '❌ Exact'; bidClass = 'exact'; } const content = document.getElementById('editRoundContent'); content.innerHTML = `

Edit Round Settings

${totalBids}/${round.handSize} ${bidStatus}
${round.handSize}
${gameState.players.map((player, idx) => { const pdata = round.playerData[idx]; if (!pdata || !pdata.participating) { const label = pdata?.absentReason === 'joined-late' ? `Missed round credit: +${pdata.score}` : 'Did not play this round'; return `
${escapeHtml(player.name)}
${label}
`; } const madePrediction = calculatePlayerRoundScore(round, round.playerData[idx], false); const setPrediction = calculatePlayerRoundScore(round, round.playerData[idx], true); const existingRoundScore = round.playerData[idx].score || 0; const madeTotal = calculateProjectedTotal(player.score, madePrediction, existingRoundScore); const setTotal = calculateProjectedTotal(player.score, setPrediction, existingRoundScore); return `
${escapeHtml(player.name)} ${idx === round.dealerIndex ? '🎴 Dealer' : ''}
${round.playerData[idx].bid}
${[5, 10].map(value => ` `).join('')}
${round.playerData[idx].tax}
${[5, 10, 15, 25].map(value => ` `).join('')}
MAX
${(round.playerData[idx].bid > 0 ? [0, 5, 10] : [0, 5]).map(n => `
${n}
` ).join('')}
If made: ${madeTotal} (${formatSignedValue(madePrediction)}) If set: ${setTotal} (${formatSignedValue(setPrediction)})
`; }).join('')}
`; document.getElementById('editRoundModal').classList.add('active'); } // Edit-round mutation functions (mirror current-round but target editingRoundIndex) function updateEditBid(playerIdx, value) { gameState.rounds[editingRoundIndex].playerData[playerIdx].bid = parseInt(value) || 0; clearTimeout(_editBidDebounceTimer); _editBidDebounceTimer = setTimeout(() => editRound(editingRoundIndex), 150); } function setEditBid(playerIdx, value) { gameState.rounds[editingRoundIndex].playerData[playerIdx].bid = _clampedBid(editingRoundIndex, playerIdx, value); editRound(editingRoundIndex); } function adjustEditBid(playerIdx, delta) { const round = gameState.rounds[editingRoundIndex]; round.playerData[playerIdx].bid = Math.max(0, Math.min(round.handSize, round.playerData[playerIdx].bid + delta)); editRound(editingRoundIndex); } function updateEditHandSize(value) { _applyHandSize(editingRoundIndex, value); editRound(editingRoundIndex); } function adjustEditHandSize(delta) { updateEditHandSize(Math.max(1, Math.min(20, gameState.rounds[editingRoundIndex].handSize + delta))); } function updateEditTax(playerIdx, value) { gameState.rounds[editingRoundIndex].playerData[playerIdx].tax = parseInt(value) || 0; clearTimeout(_editTaxDebounceTimer); _editTaxDebounceTimer = setTimeout(() => editRound(editingRoundIndex), 150); } function adjustEditTax(playerIdx, delta) { const round = gameState.rounds[editingRoundIndex]; round.playerData[playerIdx].tax = _clampedTax(round.playerData[playerIdx].tax + delta); editRound(editingRoundIndex); } function setEditTax(playerIdx, value) { gameState.rounds[editingRoundIndex].playerData[playerIdx].tax = _clampedTax(value); editRound(editingRoundIndex); } function updateEditConfidence(playerIdx, value) { gameState.rounds[editingRoundIndex].playerData[playerIdx].confidence = value; editRound(editingRoundIndex); } function updateEditDeferred(playerIdx, checked) { gameState.rounds[editingRoundIndex].playerData[playerIdx].deferred = checked; editRound(editingRoundIndex); } function updateEditGotSet(playerIdx, checked) { gameState.rounds[editingRoundIndex].playerData[playerIdx].gotSet = checked; editRound(editingRoundIndex); } function saveEditedRound() { if (!confirm('Save changes to this round?')) return; const round = gameState.rounds[editingRoundIndex]; // Keep exact bids invalid in edit mode too. const totalBids = round.playerData.reduce((sum, pdata) => { if (!pdata || pdata.participating === false) return sum; return sum + (Number(pdata.bid) || 0); }, 0); if (totalBids === round.handSize) { round.exactBidBlocks = (Number(round.exactBidBlocks) || 0) + 1; autoSave(); alert('❌ Cannot save an EXACT-bid round. Change at least one bid first.'); return; } // Check if at least one player got set const anyoneSet = round.playerData.some(p => p && p.participating && p.gotSet); if (!anyoneSet) { alert('Please mark at least one player as "Got Set"!'); return; } // First, reverse the old scores for this round round.playerData.forEach((pdata, idx) => { if (!pdata) { return; } gameState.players[idx].score -= pdata.score; // Find and remove the old round data from player history const roundHistoryIdx = gameState.players[idx].rounds.findIndex( r => r.round === editingRoundIndex + 1 ); if (roundHistoryIdx >= 0) { gameState.players[idx].rounds.splice(roundHistoryIdx, 1); } }); // Now recalculate scores with new data round.playerData.forEach((pdata, idx) => { if (!pdata) { return; } if (!pdata.participating) { gameState.players[idx].score += pdata.score; if (pdata.absentReason === 'joined-late' && pdata.score) { gameState.players[idx].rounds.push({ round: editingRoundIndex + 1, bid: null, gotSet: false, score: pdata.score, joinedLate: true }); gameState.players[idx].rounds.sort((a, b) => a.round - b.round); } return; } const bid = pdata.bid; const roundScore = calculatePlayerRoundScore(round, pdata, pdata.gotSet); pdata.score = roundScore; gameState.players[idx].score += roundScore; // Re-add to player history gameState.players[idx].rounds.push({ round: editingRoundIndex + 1, bid: bid, gotSet: pdata.gotSet, score: roundScore }); // Sort rounds by round number gameState.players[idx].rounds.sort((a, b) => a.round - b.round); }); closeModal('editRoundModal'); // Re-render everything if (editingRoundIndex === gameState.currentRound) { renderRoundSetup(); } renderScoreboard(); renderHistory(); updatePlayerPositions(); alert('Round updated successfully!'); }