monop-state/site/index.html

867 lines
32 KiB
HTML
Raw Normal View History

2026-02-21 01:35:37 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monopoly Board Viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
.container { display: flex; flex-direction: column; align-items: center; padding: 20px; gap: 20px; }
h1 { color: #fff; font-size: 1.5em; text-align: center; }
.status { font-size: 0.8em; color: #888; }
/* Board layout */
.board-wrapper { position: relative; width: min(90vw, 700px); height: min(90vw, 700px); }
.board { position: absolute; inset: 0; }
/* Squares */
.square {
position: absolute;
border: 1px solid #333;
background: #16213e;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.55em;
overflow: hidden;
cursor: default;
transition: background 0.2s;
}
.square:hover { background: #1a2a50; z-index: 10; }
.square .name { text-align: center; line-height: 1.1; padding: 1px 2px; font-weight: 500; }
.square .cost { font-size: 0.85em; color: #aaa; }
.square .color-bar {
position: absolute; top: 0; left: 0; right: 0; height: 6px;
}
.square .players-here {
display: flex; flex-wrap: wrap; gap: 1px; margin-top: 2px;
}
.player-token {
width: 12px; height: 12px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 7px;
font-weight: bold; color: #fff; border: 1px solid rgba(255,255,255,0.3);
}
.square .houses-display { display: flex; gap: 1px; margin-top: 1px; }
.house { width: 6px; height: 6px; background: #2ecc71; }
.hotel { width: 8px; height: 8px; background: #e74c3c; }
.mortgaged { opacity: 0.4; }
.owner-indicator { font-size: 0.7em; color: #ffd700; }
/* Corner squares are bigger */
.corner { width: calc(100% / 8.5); height: calc(100% / 8.5); font-size: 0.65em; }
/* Group colors */
.group-purple { background-color: #6a0dad; }
.group-lightblue { background-color: #87ceeb; }
.group-pink { background-color: #d63384; }
.group-orange { background-color: #e67e22; }
.group-red { background-color: #e74c3c; }
.group-yellow { background-color: #f1c40f; }
.group-green { background-color: #27ae60; }
.group-darkblue { background-color: #2c3e8a; }
.group-railroad { background-color: #555; }
.group-utility { background-color: #777; }
/* Player info panel */
.info-panels { display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; max-width: 900px; width: 100%; }
.player-panel {
background: #16213e; border: 2px solid #333; border-radius: 8px;
padding: 12px; min-width: 200px; flex: 1; max-width: 280px;
}
.player-panel h3 { font-size: 1em; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.player-panel .money { font-size: 1.2em; color: #2ecc71; font-weight: bold; }
.player-panel .money.negative { color: #e74c3c; }
.player-panel .props { font-size: 0.8em; margin-top: 6px; }
.player-panel .prop-item { padding: 2px 0; display: flex; align-items: center; gap: 4px; }
.prop-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.current-turn { border-color: #ffd700 !important; box-shadow: 0 0 10px rgba(255, 215, 0, 0.3); }
/* Game log */
.game-log {
background: #16213e; border: 1px solid #333; border-radius: 8px;
padding: 12px; max-width: 900px; width: 100%; max-height: 200px;
overflow-y: auto; font-size: 0.85em;
}
.game-log h3 { margin-bottom: 8px; }
.log-entry { padding: 2px 0; border-bottom: 1px solid #1a1a2e; color: #ccc; }
.log-entry .log-player { color: #ffd700; font-weight: 500; }
.log-entry .log-time { color: #666; font-size: 0.8em; }
/* Center area of board */
.board-center {
position: absolute;
top: calc(100% / 8.5);
left: calc(100% / 8.5);
right: calc(100% / 8.5);
bottom: calc(100% / 8.5);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: #0f3460;
border: 2px solid #333;
font-size: 1.8em;
font-weight: bold;
color: #e74c3c;
text-align: center;
letter-spacing: 3px;
}
.board-center .subtitle { font-size: 0.35em; color: #888; letter-spacing: 0; margin-top: 8px; font-weight: normal; }
/* ===== ZERO STATE ===== */
.zero-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 80vh; text-align: center; gap: 20px; padding: 40px;
}
.zero-state .dice { font-size: 5em; animation: dice-bounce 2s ease-in-out infinite; }
@keyframes dice-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(15deg); }
}
.zero-state h2 { color: #fff; font-size: 2em; }
.zero-state p { color: #888; font-size: 1.1em; max-width: 400px; line-height: 1.5; }
/* ===== DISCONNECTED BANNER ===== */
.disconnected-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
background: rgba(40, 20, 20, 0.92); border-bottom: 1px solid #e74c3c;
padding: 8px 20px; text-align: center; font-size: 0.85em; color: #ccc;
display: none; backdrop-filter: blur(2px);
animation: stale-fade-in 0.3s ease-out;
}
.disconnected-banner .icon { margin-right: 6px; }
.disconnected-banner .retry-text { color: #e74c3c; font-weight: 500; }
body.greyed-out .container { filter: grayscale(60%) brightness(0.6); }
/* ===== STALE STATE ===== */
.stale-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 400;
background: rgba(30, 30, 50, 0.92); border-bottom: 1px solid #444;
padding: 8px 20px; text-align: center; font-size: 0.85em; color: #aaa;
display: none; backdrop-filter: blur(2px);
animation: stale-fade-in 0.3s ease-out;
}
@keyframes stale-fade-in { from { opacity: 0; transform: translateY(-100%); } to { opacity: 1; transform: translateY(0); } }
.stale-banner .icon { margin-right: 6px; }
.stale-banner .age { color: #f39c12; font-weight: 500; }
body.stale .container { filter: brightness(0.8) saturate(0.7); }
/* ===== GAME OVER ===== */
.game-over-banner {
position: fixed; top: 0; left: 0; right: 0;
background: linear-gradient(135deg, #ffd700, #ff8c00);
color: #1a1a2e; text-align: center; padding: 20px;
font-size: 1.5em; font-weight: bold; z-index: 500;
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5);
animation: banner-slide 0.5s ease-out;
}
@keyframes banner-slide { from { transform: translateY(-100%); } to { transform: translateY(0); } }
.game-over-banner .winner { font-size: 1.4em; }
.game-over-banner .sub { font-size: 0.5em; font-weight: normal; color: #333; }
body.game-over .container { padding-top: 100px; }
/* Confetti canvas */
#confetti-canvas {
position: fixed; inset: 0; z-index: 499; pointer-events: none;
}
/* ===== DEBUG PANEL ===== */
.debug-panel {
position: fixed; bottom: 10px; right: 10px; background: #0d1117;
border: 1px solid #30363d; border-radius: 8px; padding: 12px;
font-size: 0.75em; z-index: 2000; display: none;
}
.debug-panel.visible { display: block; }
.debug-panel h4 { color: #58a6ff; margin-bottom: 8px; }
.debug-panel button {
display: block; width: 100%; margin: 4px 0; padding: 6px 10px;
background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
border-radius: 4px; cursor: pointer; text-align: left; font-size: 1em;
}
.debug-panel button:hover { background: #30363d; }
2026-02-21 01:35:37 +00:00
@media (max-width: 600px) {
.board-wrapper { width: 95vw; height: 95vw; }
.square { font-size: 0.4em; }
.player-token { width: 8px; height: 8px; font-size: 5px; }
.info-panels { flex-direction: column; }
.player-panel { max-width: 100%; }
}
</style>
</head>
<body>
<!-- Zero state (shown when no game exists) -->
<div class="zero-state" id="zero-state" style="display:none">
<div class="dice">🎲</div>
<h2>No Game in Progress</h2>
<p>Start a Monopoly game on IRC and the board will appear here automatically.</p>
<div style="color:#555; font-size:0.85em">Checking for game data every 2 seconds...</div>
</div>
<!-- Main game UI -->
<div class="container" id="game-container" style="display:none">
2026-02-21 01:35:37 +00:00
<div>
<h1>🎲 Monopoly Board</h1>
<div class="status" id="status">Loading game state...</div>
</div>
<div class="board-wrapper">
<div class="board" id="board"></div>
<div class="board-center">
MONOPOLY
<div class="subtitle">IRC Edition</div>
</div>
</div>
<div class="info-panels" id="players"></div>
<div class="game-log">
<h3>📜 Game Log</h3>
<div id="log"></div>
</div>
</div>
<!-- Disconnected banner -->
<div class="disconnected-banner" id="disconnect-banner">
<span class="icon">📡</span>
Connection lost — <span class="retry-text">retrying every 2s...</span>
</div>
<!-- Stale data banner -->
<div class="stale-banner" id="stale-banner">
<span class="icon">⏸️</span>
Game data hasn't updated in <span class="age" id="stale-age">--</span> — the bridge may have stopped
</div>
<!-- Confetti canvas -->
<canvas id="confetti-canvas"></canvas>
<!-- Game over banner -->
<div class="game-over-banner" id="game-over-banner" style="display:none">
<div class="winner" id="winner-name">🏆 WINS!</div>
<div class="sub">Game Over</div>
</div>
<!-- Debug panel (hidden, activate with Ctrl+Shift+D) -->
<div class="debug-panel" id="debug-panel">
<h4>🔧 Debug States</h4>
<button onclick="debugState('zero')">Zero State (no game)</button>
<button onclick="debugState('playing')">Playing (demo)</button>
<button onclick="debugState('gameover')">Game Over (confetti)</button>
<button onclick="debugState('stale')">Stale (no updates)</button>
<button onclick="debugState('disconnected')">Disconnected</button>
<button onclick="debugState('reset')">Reset (live mode)</button>
</div>
2026-02-21 01:35:37 +00:00
<script>
const PLAYER_COLORS = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e91e63','#00bcd4','#ff5722'];
const GROUP_COLORS = {
purple: '#6a0dad', lightblue: '#87ceeb', pink: '#d63384',
orange: '#e67e22', red: '#e74c3c', yellow: '#f1c40f',
green: '#27ae60', darkblue: '#2c3e8a', railroad: '#555', utility: '#777'
};
// Board square positions: [row, col] in an 11x11 grid
const POSITIONS = [];
for (let i = 0; i <= 10; i++) POSITIONS[i] = [10, 10 - i];
for (let i = 1; i <= 9; i++) POSITIONS[10 + i] = [10 - i, 0];
for (let i = 0; i <= 10; i++) POSITIONS[20 + i] = [0, i];
for (let i = 1; i <= 9; i++) POSITIONS[30 + i] = [i, 10];
// ===== STATE MANAGEMENT =====
let lastUpdate = '';
let currentView = 'loading'; // loading, zero, playing, gameover, disconnected
let debugMode = null; // null = live, or a forced state name
let consecutiveFailures = 0;
let gameOverShown = false; // only trigger confetti once per game
let confettiRunning = false;
let lastStateTimestamp = null; // ISO string from state.lastUpdated
const STALE_THRESHOLD_MS = 20000;
function showView(view) {
currentView = view;
document.getElementById('zero-state').style.display = view === 'zero' ? 'flex' : 'none';
document.getElementById('game-container').style.display = (view === 'playing' || view === 'gameover') ? 'flex' : 'none';
document.getElementById('disconnect-banner').style.display = view === 'disconnected' ? 'block' : 'none';
document.getElementById('game-over-banner').style.display = view === 'gameover' ? 'block' : 'none';
document.body.classList.toggle('greyed-out', view === 'disconnected');
document.body.classList.toggle('game-over', view === 'gameover');
if (view !== 'gameover') {
stopConfetti();
}
}
// ===== RENDERING =====
2026-02-21 01:35:37 +00:00
function renderBoard(state) {
const board = document.getElementById('board');
board.innerHTML = '';
const squares = state.squares || [];
const players = state.players || [];
const bw = board.offsetWidth || 700;
const cornerSize = bw / 8.5;
const sideSize = (bw - 2 * cornerSize) / 9;
squares.forEach((sq, idx) => {
const [row, col] = POSITIONS[idx];
const el = document.createElement('div');
el.className = 'square' + (sq.mortgaged ? ' mortgaged' : '');
let x, y, w, h;
const isCorner = [0, 10, 20, 30].includes(idx);
if (isCorner) {
w = cornerSize; h = cornerSize;
x = col === 0 ? 0 : col === 10 ? bw - cornerSize : col * sideSize;
y = row === 0 ? 0 : row === 10 ? bw - cornerSize : row * sideSize;
} else if (row === 0 || row === 10) {
w = sideSize; h = cornerSize;
x = cornerSize + (col - 1) * sideSize;
y = row === 0 ? 0 : bw - cornerSize;
} else {
w = cornerSize; h = sideSize;
x = col === 0 ? 0 : bw - cornerSize;
y = cornerSize + (row - 1) * sideSize;
}
el.style.left = x + 'px';
el.style.top = y + 'px';
el.style.width = w + 'px';
el.style.height = h + 'px';
if (sq.group && GROUP_COLORS[sq.group]) {
const bar = document.createElement('div');
bar.className = 'color-bar';
bar.style.backgroundColor = GROUP_COLORS[sq.group];
if (row === 10) { bar.style.top = '0'; bar.style.bottom = ''; }
else if (row === 0) { bar.style.top = ''; bar.style.bottom = '0'; bar.style.top = 'auto'; }
else if (col === 0) { bar.style.top = '0'; bar.style.left = ''; bar.style.right = '0'; bar.style.width = '6px'; bar.style.height = '100%'; }
else if (col === 10) { bar.style.top = '0'; bar.style.left = '0'; bar.style.right = ''; bar.style.width = '6px'; bar.style.height = '100%'; }
el.appendChild(bar);
}
const nameEl = document.createElement('div');
nameEl.className = 'name';
nameEl.textContent = sq.name.replace(' Ave.', '').replace(' Place', ' Pl').replace(' Railroad', ' RR').replace('Community Chest', 'Comm. Chest').replace('Pennsylvania', 'Penn.');
el.appendChild(nameEl);
if (sq.cost > 0) {
const costEl = document.createElement('div');
costEl.className = 'cost';
costEl.textContent = '$' + sq.cost;
el.appendChild(costEl);
}
if (sq.owner != null && sq.owner > 0) {
// Find owner's index in the players array (which is turn-ordered)
const ownerIdx = players.findIndex(p => p.number === sq.owner);
if (ownerIdx >= 0) {
const ownerEl = document.createElement('div');
ownerEl.className = 'owner-indicator';
ownerEl.textContent = '⬤';
ownerEl.style.color = PLAYER_COLORS[ownerIdx % PLAYER_COLORS.length];
el.appendChild(ownerEl);
}
2026-02-21 01:35:37 +00:00
}
if (sq.houses > 0 && sq.houses < 5) {
const hd = document.createElement('div');
hd.className = 'houses-display';
for (let h = 0; h < sq.houses; h++) {
const house = document.createElement('div');
house.className = 'house';
hd.appendChild(house);
}
el.appendChild(hd);
} else if (sq.houses >= 5) {
const hd = document.createElement('div');
hd.className = 'houses-display';
const hotel = document.createElement('div');
hotel.className = 'hotel';
hd.appendChild(hotel);
el.appendChild(hd);
}
const playersHere = players.filter(p => p.location === idx);
if (playersHere.length > 0) {
const ph = document.createElement('div');
ph.className = 'players-here';
playersHere.forEach(p => {
const pidx = players.indexOf(p);
const tok = document.createElement('div');
tok.className = 'player-token';
tok.style.backgroundColor = PLAYER_COLORS[pidx % PLAYER_COLORS.length];
tok.textContent = p.name.charAt(0).toUpperCase();
tok.title = p.name;
ph.appendChild(tok);
});
el.appendChild(ph);
}
board.appendChild(el);
});
}
function renderPlayers(state) {
const container = document.getElementById('players');
container.innerHTML = '';
const players = state.players || [];
const squares = state.squares || [];
const isSetup = state.phase === 'setup';
const expected = state.numPlayersExpected || 0;
2026-02-21 01:35:37 +00:00
// During setup, show slots for all expected players
const slotCount = isSetup ? Math.max(players.length, expected) : players.length;
2026-02-21 01:35:37 +00:00
for (let idx = 0; idx < slotCount; idx++) {
const p = players[idx]; // may be undefined for empty slots
const panel = document.createElement('div');
2026-02-21 01:35:37 +00:00
const color = PLAYER_COLORS[idx % PLAYER_COLORS.length];
if (!p) {
// Empty slot — waiting for registration
panel.className = 'player-panel';
panel.style.opacity = '0.4';
panel.style.borderStyle = 'dashed';
panel.innerHTML = `<h3><span class="player-token" style="background:#555;width:20px;height:20px;font-size:11px">?</span> Player ${idx + 1}</h3>
<div style="color:#555;font-size:0.9em">Waiting to join...</div>`;
} else if (isSetup && p.name.startsWith('Player ')) {
// Placeholder — registered slot but no name yet
panel.className = 'player-panel';
panel.style.opacity = '0.6';
panel.style.borderStyle = 'dashed';
panel.innerHTML = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px;opacity:0.5">?</span> ${p.name}</h3>
<div style="color:#888;font-size:0.9em">Registering...</div>`;
} else if (isSetup) {
// Registered player during setup
panel.className = 'player-panel';
panel.innerHTML = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px">${p.name.charAt(0).toUpperCase()}</span> ${p.name} ✓</h3>
<div class="money">$1,500</div>
<div style="font-size:0.8em;margin-top:4px;color:#888">Ready to play</div>`;
} else if (p.bankrupt) {
// Bankrupt player
panel.className = 'player-panel';
panel.style.opacity = '0.45';
panel.style.borderColor = '#e74c3c';
panel.style.borderStyle = 'dashed';
panel.innerHTML = `<h3><span class="player-token" style="background:#555;width:20px;height:20px;font-size:11px;text-decoration:line-through">${p.name.charAt(0).toUpperCase()}</span> <span style="text-decoration:line-through;color:#888">${p.name}</span> 💀</h3>
<div style="color:#e74c3c;font-size:0.9em;font-weight:bold">BANKRUPT</div>`;
} else {
// Normal playing state
panel.className = 'player-panel' + (p.number === state.currentPlayer ? ' current-turn' : '');
let html = `<h3><span class="player-token" style="background:${color};width:20px;height:20px;font-size:11px">${p.name.charAt(0).toUpperCase()}</span> ${p.name}`;
if (p.number === state.currentPlayer) html += ' 🎲';
html += '</h3>';
2026-02-21 01:35:37 +00:00
html += `<div class="money ${p.money < 0 ? 'negative' : ''}">$${p.money.toLocaleString()}</div>`;
2026-02-21 01:35:37 +00:00
if (p.getOutOfJailFreeCards > 0) {
html += `<div style="font-size:0.8em;margin-top:4px">🃏 ${p.getOutOfJailFreeCards} GOJF card${p.getOutOfJailFreeCards > 1 ? 's' : ''}</div>`;
}
if (p.inJail) {
html += `<div style="font-size:0.8em;color:#e74c3c;margin-top:4px">🔒 In Jail (turn ${p.jailTurns})</div>`;
}
const loc = p.location === 40 ? { name: 'JAIL' } : squares[p.location];
if (loc) {
html += `<div style="font-size:0.8em;margin-top:4px;color:#aaa">📍 ${loc.name}</div>`;
}
2026-02-21 01:35:37 +00:00
const owned = squares.filter(sq => sq.owner === p.number);
if (owned.length > 0) {
html += '<div class="props">';
owned.forEach(sq => {
const gc = sq.group ? GROUP_COLORS[sq.group] || '#666' : '#666';
html += `<div class="prop-item"><span class="prop-dot" style="background:${gc}"></span>${sq.name}`;
if (sq.houses > 0 && sq.houses < 5) html += ` 🏠×${sq.houses}`;
else if (sq.houses >= 5) html += ' 🏨';
if (sq.mortgaged) html += ' (M)';
html += '</div>';
});
html += '</div>';
}
panel.innerHTML = html;
2026-02-21 01:35:37 +00:00
}
container.appendChild(panel);
}
2026-02-21 01:35:37 +00:00
}
function renderLog(state) {
const container = document.getElementById('log');
const entries = (state.log || []).slice(-30).reverse();
container.innerHTML = entries.map(e => {
const time = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '';
const player = e.player ? `<span class="log-player">${e.player}</span>: ` : '';
return `<div class="log-entry"><span class="log-time">${time}</span> ${player}${e.text}</div>`;
}).join('');
}
function renderGame(state) {
renderBoard(state);
renderPlayers(state);
renderLog(state);
}
// ===== CONFETTI =====
const confettiCanvas = document.getElementById('confetti-canvas');
const confettiCtx = confettiCanvas.getContext('2d');
let confettiPieces = [];
let confettiAnimFrame = null;
function resizeConfetti() {
confettiCanvas.width = window.innerWidth;
confettiCanvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeConfetti);
resizeConfetti();
function startConfetti() {
if (confettiRunning) return;
confettiRunning = true;
confettiPieces = [];
const colors = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#ffd700','#e91e63','#00bcd4'];
for (let i = 0; i < 200; i++) {
confettiPieces.push({
x: Math.random() * confettiCanvas.width,
y: Math.random() * confettiCanvas.height - confettiCanvas.height,
w: Math.random() * 10 + 5,
h: Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
vx: (Math.random() - 0.5) * 3,
vy: Math.random() * 3 + 2,
spin: Math.random() * 0.2 - 0.1,
angle: Math.random() * Math.PI * 2,
opacity: 1,
});
}
animateConfetti();
}
function animateConfetti() {
confettiCtx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
let alive = 0;
confettiPieces.forEach(p => {
if (p.opacity <= 0) return;
alive++;
p.x += p.vx;
p.y += p.vy;
p.angle += p.spin;
p.vy += 0.05; // gravity
// Fade out when past bottom
if (p.y > confettiCanvas.height) {
p.opacity -= 0.02;
}
confettiCtx.save();
confettiCtx.translate(p.x, p.y);
confettiCtx.rotate(p.angle);
confettiCtx.globalAlpha = Math.max(0, p.opacity);
confettiCtx.fillStyle = p.color;
confettiCtx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
confettiCtx.restore();
});
if (alive > 0) {
confettiAnimFrame = requestAnimationFrame(animateConfetti);
} else {
confettiRunning = false;
}
}
function stopConfetti() {
if (confettiAnimFrame) {
cancelAnimationFrame(confettiAnimFrame);
confettiAnimFrame = null;
}
confettiRunning = false;
confettiCtx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
}
// ===== GAME OVER DETECTION =====
function findWinner(state) {
const log = state.log || [];
for (let i = log.length - 1; i >= 0; i--) {
const m = log[i].text.match(/^(.+?) WINS!$/);
if (m) return m[1];
}
// If only one player left, they won
if (state.players && state.players.length === 1) {
return state.players[0].name;
}
return null;
}
function isGameOver(state) {
// Check log for WINS
const winner = findWinner(state);
if (winner) return true;
// Check if state explicitly says over (future-proofing)
if (state.phase === 'over') return true;
return false;
}
// ===== STALE DETECTION =====
function checkStale() {
if (!lastStateTimestamp || currentView === 'zero' || currentView === 'gameover') {
hideStale();
return;
}
const age = Date.now() - new Date(lastStateTimestamp).getTime();
if (age > STALE_THRESHOLD_MS) {
showStale(age);
} else {
hideStale();
}
}
function showStale(ageMs) {
const secs = Math.floor(ageMs / 1000);
let ageText;
if (secs < 60) ageText = `${secs}s`;
else if (secs < 3600) ageText = `${Math.floor(secs / 60)}m ${secs % 60}s`;
else ageText = `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
document.getElementById('stale-age').textContent = ageText;
document.getElementById('stale-banner').style.display = 'block';
document.body.classList.add('stale');
}
function hideStale() {
document.getElementById('stale-banner').style.display = 'none';
document.body.classList.remove('stale');
}
// ===== CONNECTION MONITORING =====
function showDisconnected() {
document.getElementById('disconnect-banner').style.display = 'block';
document.body.classList.add('greyed-out');
}
function hideDisconnected() {
document.getElementById('disconnect-banner').style.display = 'none';
document.body.classList.remove('greyed-out');
consecutiveFailures = 0;
}
// ===== MAIN UPDATE LOOP =====
async function update() {
if (debugMode) return; // paused during debug
2026-02-21 01:35:37 +00:00
try {
const resp = await fetch('game-state.json?t=' + Date.now());
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const state = await resp.json();
// Success — clear failure state
if (consecutiveFailures >= 3) hideDisconnected();
consecutiveFailures = 0;
// Track last update time for stale detection
if (state.lastUpdated) lastStateTimestamp = state.lastUpdated;
checkStale();
// Determine what to show
if (!state.players || (state.players.length === 0 && !state.phase)) {
// No players and no phase = no game
showView('zero');
lastUpdate = '';
gameOverShown = false;
return;
}
// Setup phase — show registration
if (state.phase === 'setup') {
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
showView('playing');
renderGame(state);
}
const registered = (state.players || []).filter(p => !p.name.startsWith('Player ')).length;
const expected = state.numPlayersExpected || '?';
document.getElementById('status').textContent =
`Setting up · ${registered}/${expected} players registered`;
return;
}
// Check for game over
if (isGameOver(state)) {
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
renderGame(state);
}
if (!gameOverShown) {
gameOverShown = true;
const winner = findWinner(state);
document.getElementById('winner-name').textContent = `🏆 ${winner || 'Someone'} WINS!`;
showView('gameover');
startConfetti();
}
document.getElementById('status').textContent =
`Game Over · ${state.players.length} players · ${new Date(state.lastUpdated).toLocaleTimeString()}`;
return;
}
// Active game
gameOverShown = false;
2026-02-21 01:35:37 +00:00
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
showView('playing');
renderGame(state);
2026-02-21 01:35:37 +00:00
}
document.getElementById('status').textContent =
`Live · ${state.players.length} players · Updated: ${new Date(state.lastUpdated).toLocaleTimeString()}`;
2026-02-21 01:35:37 +00:00
} catch (e) {
consecutiveFailures++;
checkStale(); // still update stale timer on failures
if (consecutiveFailures >= 3) {
showDisconnected();
}
2026-02-21 01:35:37 +00:00
}
}
// ===== DEMO / TEST STATES =====
function demoSquares() {
const names = [
"=== GO ===","Mediterranean ave. (P)","Community Chest i","Baltic ave. (P)","Income Tax",
"Reading RR","Oriental ave. (L)","Chance i","Vermont ave. (L)","Connecticut ave. (L)",
"Just Visiting","St. Charles pl. (V)","Electric Co.","States ave. (V)","Virginia ave. (V)",
"Pennsylvania RR","St. James pl. (O)","Community Chest ii","Tennessee ave. (O)","New York ave. (O)",
"Free Parking","Kentucky ave. (R)","Chance ii","Indiana ave. (R)","Illinois ave. (R)",
"B&O RR","Atlantic ave. (Y)","Ventnor ave. (Y)","Water Works","Marvin Gardens (Y)",
"GO TO JAIL","Pacific ave. (G)","N. Carolina ave. (G)","Community Chest iii","Pennsylvania ave. (G)",
"Short Line RR","Chance iii","Park place (D)","Luxury Tax","Boardwalk (D)"
2026-02-21 01:35:37 +00:00
];
const groups = [
null,"purple",null,"purple",null,"railroad","lightblue",null,"lightblue","lightblue",
null,"violet","utility","violet","violet","railroad","orange",null,"orange","orange",
null,"red",null,"red","red","railroad","yellow","yellow","utility","yellow",
null,"green","green",null,"green","railroad",null,"darkblue",null,"darkblue"
];
const costs = [
0,60,0,60,0,200,100,0,100,120,0,140,150,140,160,200,180,0,180,200,
0,220,0,220,240,200,260,260,150,280,0,300,300,0,320,200,0,350,0,400
];
const types = [
"safe","property","cc","property","tax","railroad","property","chance","property","property",
"safe","property","utility","property","property","railroad","property","cc","property","property",
"safe","property","chance","property","property","railroad","property","property","utility","property",
"gotojail","property","property","cc","property","railroad","chance","property","tax","property"
];
return names.map((name, i) => ({
id: i, name, type: types[i], group: groups[i], cost: costs[i],
owner: null, mortgaged: false, houses: 0
}));
}
function demoPlayingState() {
const sq = demoSquares();
sq[1].owner = 1; sq[3].owner = 1; sq[1].houses = 2; sq[3].houses = 1;
sq[5].owner = 2; sq[39].owner = 2; sq[39].houses = 5;
sq[11].owner = 2; sq[28].owner = 2;
sq[9].owner = 3; sq[16].owner = 3; sq[18].owner = 3; sq[19].owner = 3;
sq[16].houses = 3; sq[18].houses = 2;
sq[24].owner = 1; sq[24].mortgaged = true;
2026-02-21 01:35:37 +00:00
return {
lastUpdated: new Date().toISOString(),
currentPlayer: 2,
2026-02-21 01:35:37 +00:00
players: [
{name:"Alice",number:1,money:820,location:24,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:1},
{name:"Bob",number:2,money:1350,location:39,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:0},
{name:"Charlie",number:3,money:640,location:40,inJail:true,jailTurns:2,doublesCount:0,getOutOfJailFreeCards:0},
2026-02-21 01:35:37 +00:00
],
squares: sq,
2026-02-21 01:35:37 +00:00
log: [
{timestamp:new Date(Date.now()-60000).toISOString(),text:"roll is 4, 3",player:"Alice"},
{timestamp:new Date(Date.now()-50000).toISOString(),text:"Landed on Illinois ave. (R)",player:"Alice"},
{timestamp:new Date(Date.now()-40000).toISOString(),text:"roll is 6, 6",player:"Bob"},
{timestamp:new Date(Date.now()-30000).toISOString(),text:"Passed GO — collected $200",player:"Bob"},
{timestamp:new Date(Date.now()-20000).toISOString(),text:"Landed on Boardwalk (D)",player:"Bob"},
2026-02-21 01:35:37 +00:00
{timestamp:new Date(Date.now()-10000).toISOString(),text:"Still in jail (turn 2)",player:"Charlie"},
]
};
}
function demoGameOverState() {
const st = demoPlayingState();
st.players = [
{name:"Bob",number:1,money:4250,location:39,inJail:false,jailTurns:0,doublesCount:0,getOutOfJailFreeCards:2},
];
st.currentPlayer = 1;
st.log.push({timestamp:new Date().toISOString(), text:"Bob WINS!", player:"Bob"});
return st;
}
// ===== DEBUG MODE =====
function debugState(mode) {
stopConfetti();
gameOverShown = false;
if (mode === 'reset') {
debugMode = null;
lastUpdate = '';
lastStateTimestamp = null;
hideDisconnected();
hideStale();
update();
return;
}
debugMode = mode;
if (mode === 'zero') {
showView('zero');
} else if (mode === 'playing') {
const st = demoPlayingState();
showView('playing');
renderGame(st);
document.getElementById('status').textContent = 'Debug: Playing (demo data)';
} else if (mode === 'gameover') {
const st = demoGameOverState();
showView('gameover');
renderGame(st);
const winner = findWinner(st);
document.getElementById('winner-name').textContent = `🏆 ${winner} WINS!`;
startConfetti();
document.getElementById('status').textContent = 'Debug: Game Over';
} else if (mode === 'stale') {
const st = demoPlayingState();
st.lastUpdated = new Date(Date.now() - 120000).toISOString(); // 2 min ago
lastStateTimestamp = st.lastUpdated;
showView('playing');
renderGame(st);
checkStale();
document.getElementById('status').textContent = 'Debug: Stale data';
} else if (mode === 'disconnected') {
// Show game underneath the overlay
const st = demoPlayingState();
showView('playing');
renderGame(st);
showDisconnected();
2026-02-21 01:35:37 +00:00
}
}
// Ctrl+Shift+D to toggle debug panel
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
e.preventDefault();
document.getElementById('debug-panel').classList.toggle('visible');
}
});
// ===== INIT =====
2026-02-21 01:35:37 +00:00
update();
setInterval(update, 2000);
</script>
</body>
</html>