monop-state/site/index.html
Jarvis 7dadd1f37f Organize directory structure
- cardinal-plugin/ → plugins/ (matches Cardinal's plugin dir convention)
- index.html + game-state.json → site/
- OUTPUT_CATALOG.md → docs/
- Core Python files and tests stay flat (no import changes needed)
2026-02-21 10:16:49 +00:00

468 lines
21 KiB
HTML

<!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; }
@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>
<div class="container">
<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>
<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
// Bottom row (right to left): squares 0-10
// Left col (bottom to top): squares 10-20
// Top row (left to right): squares 20-30
// Right col (top to bottom): squares 30-39 + 0
const POSITIONS = [];
// Bottom row: 0=Go (bottom-right corner) to 10 (bottom-left corner)
for (let i = 0; i <= 10; i++) POSITIONS[i] = [10, 10 - i];
// Left column: 11-19 (going up, excluding corners)
for (let i = 1; i <= 9; i++) POSITIONS[10 + i] = [10 - i, 0];
// Top row: 20=Free Parking (top-left) to 30 (top-right)
for (let i = 0; i <= 10; i++) POSITIONS[20 + i] = [0, i];
// Right column: 31-39 (going down, excluding corners)
for (let i = 1; i <= 9; i++) POSITIONS[30 + i] = [i, 10];
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' : '');
// Position and size
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) {
// Top or bottom row
w = sideSize; h = cornerSize;
x = cornerSize + (col - 1) * sideSize;
y = row === 0 ? 0 : bw - cornerSize;
} else {
// Left or right column
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';
// Color bar for property groups
if (sq.group && GROUP_COLORS[sq.group]) {
const bar = document.createElement('div');
bar.className = 'color-bar';
bar.style.backgroundColor = GROUP_COLORS[sq.group];
// Bar on the inside edge
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);
}
// Name
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);
// Cost
if (sq.cost > 0) {
const costEl = document.createElement('div');
costEl.className = 'cost';
costEl.textContent = '$' + sq.cost;
el.appendChild(costEl);
}
// Owner indicator
if (sq.owner >= 0 && sq.owner < players.length) {
const ownerEl = document.createElement('div');
ownerEl.className = 'owner-indicator';
ownerEl.textContent = '⬤';
ownerEl.style.color = PLAYER_COLORS[sq.owner % PLAYER_COLORS.length];
el.appendChild(ownerEl);
}
// Houses/hotel
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);
}
// Players on this square
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 || [];
players.forEach((p, idx) => {
const panel = document.createElement('div');
panel.className = 'player-panel' + (idx === state.currentPlayer ? ' current-turn' : '');
const color = PLAYER_COLORS[idx % PLAYER_COLORS.length];
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 (idx === state.currentPlayer) html += ' 🎲';
html += '</h3>';
html += `<div class="money ${p.money < 0 ? 'negative' : ''}">$${p.money.toLocaleString()}</div>`;
if (p.goJailFreeCards > 0) {
html += `<div style="font-size:0.8em;margin-top:4px">🃏 ${p.goJailFreeCards} GOJF card${p.goJailFreeCards > 1 ? 's' : ''}</div>`;
}
if (p.inJail) {
html += `<div style="font-size:0.8em;color:#e74c3c;margin-top:4px">🔒 In Jail</div>`;
}
// Location
const loc = squares[p.location];
if (loc) {
html += `<div style="font-size:0.8em;margin-top:4px;color:#aaa">📍 ${loc.name}</div>`;
}
// Properties
if (p.properties && p.properties.length > 0) {
html += '<div class="props">';
p.properties.forEach(sid => {
const sq = squares[sid];
if (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;
container.appendChild(panel);
});
}
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('');
}
let lastUpdate = '';
async function fetchState() {
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();
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
renderBoard(state);
renderPlayers(state);
renderLog(state);
}
document.getElementById('status').textContent =
`Last updated: ${state.lastUpdated ? new Date(state.lastUpdated).toLocaleString() : 'never'} · ${state.players?.length || 0} players`;
} catch (e) {
document.getElementById('status').textContent = 'Waiting for game-state.json... (' + e.message + ')';
}
}
// Generate demo state if no real game is running
function demoState() {
const squares = [
{id:0,name:"Go",type:"safe",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:1,name:"Mediterranean Ave.",type:"property",owner:0,cost:60,mortgaged:false,houses:2,monopoly:true,group:"purple",rent:[2,10,30,90,160,250]},
{id:2,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:3,name:"Baltic Ave.",type:"property",owner:0,cost:60,mortgaged:false,houses:1,monopoly:true,group:"purple",rent:[4,20,60,180,320,450]},
{id:4,name:"Income Tax",type:"tax",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:5,name:"Reading Railroad",type:"railroad",owner:1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:6,name:"Oriental Ave.",type:"property",owner:-1,cost:100,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[6]},
{id:7,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:8,name:"Vermont Ave.",type:"property",owner:-1,cost:100,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[6]},
{id:9,name:"Connecticut Ave.",type:"property",owner:2,cost:120,mortgaged:false,houses:0,monopoly:false,group:"lightblue",rent:[8]},
{id:10,name:"Just Visiting",type:"jail",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:11,name:"St. Charles Place",type:"property",owner:1,cost:140,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[10]},
{id:12,name:"Electric Company",type:"utility",owner:-1,cost:150,mortgaged:false,houses:0,monopoly:false,group:"utility",rent:[0]},
{id:13,name:"States Ave.",type:"property",owner:-1,cost:140,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[10]},
{id:14,name:"Virginia Ave.",type:"property",owner:-1,cost:160,mortgaged:false,houses:0,monopoly:false,group:"pink",rent:[12]},
{id:15,name:"Pennsylvania Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:16,name:"St. James Place",type:"property",owner:2,cost:180,mortgaged:false,houses:3,monopoly:true,group:"orange",rent:[14,70,200,550,750,950]},
{id:17,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:18,name:"Tennessee Ave.",type:"property",owner:2,cost:180,mortgaged:false,houses:2,monopoly:true,group:"orange",rent:[14,70,200,550,750,950]},
{id:19,name:"New York Ave.",type:"property",owner:2,cost:200,mortgaged:false,houses:0,monopoly:true,group:"orange",rent:[16]},
{id:20,name:"Free Parking",type:"safe",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:21,name:"Kentucky Ave.",type:"property",owner:-1,cost:220,mortgaged:false,houses:0,monopoly:false,group:"red",rent:[18]},
{id:22,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:23,name:"Indiana Ave.",type:"property",owner:-1,cost:220,mortgaged:false,houses:0,monopoly:false,group:"red",rent:[18]},
{id:24,name:"Illinois Ave.",type:"property",owner:0,cost:240,mortgaged:true,houses:0,monopoly:false,group:"red",rent:[20]},
{id:25,name:"B&O Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:26,name:"Atlantic Ave.",type:"property",owner:-1,cost:260,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[22]},
{id:27,name:"Ventnor Ave.",type:"property",owner:-1,cost:260,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[22]},
{id:28,name:"Water Works",type:"utility",owner:1,cost:150,mortgaged:false,houses:0,monopoly:false,group:"utility",rent:[0]},
{id:29,name:"Marvin Gardens",type:"property",owner:-1,cost:280,mortgaged:false,houses:0,monopoly:false,group:"yellow",rent:[24]},
{id:30,name:"Go to Jail",type:"gotojail",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:31,name:"Pacific Ave.",type:"property",owner:-1,cost:300,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[26]},
{id:32,name:"North Carolina Ave.",type:"property",owner:-1,cost:300,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[26]},
{id:33,name:"Community Chest",type:"cc",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:34,name:"Pennsylvania Ave.",type:"property",owner:-1,cost:320,mortgaged:false,houses:0,monopoly:false,group:"green",rent:[28]},
{id:35,name:"Short Line Railroad",type:"railroad",owner:-1,cost:200,mortgaged:false,houses:0,monopoly:false,group:"railroad",rent:[0]},
{id:36,name:"Chance",type:"chance",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:37,name:"Park Place",type:"property",owner:-1,cost:350,mortgaged:false,houses:0,monopoly:false,group:"darkblue",rent:[35]},
{id:38,name:"Luxury Tax",type:"tax",owner:-1,cost:0,mortgaged:false,houses:0,monopoly:false,group:null,rent:[0]},
{id:39,name:"Boardwalk",type:"property",owner:1,cost:400,mortgaged:false,houses:5,monopoly:false,group:"darkblue",rent:[50,200,600,1400,1700,2000]},
];
return {
lastUpdated: new Date().toISOString(),
currentPlayer: 1,
players: [
{name:"Alice",number:1,money:820,location:24,inJail:false,jailTurns:0,goJailFreeCards:1,properties:[1,3,24],numRailroads:0,numUtilities:0},
{name:"Bob",number:2,money:1350,location:39,inJail:false,jailTurns:0,goJailFreeCards:0,properties:[5,11,28,39],numRailroads:1,numUtilities:1},
{name:"Charlie",number:3,money:640,location:10,inJail:true,jailTurns:2,goJailFreeCards:0,properties:[9,16,18,19],numRailroads:0,numUtilities:0},
],
squares: squares,
log: [
{timestamp:new Date(Date.now()-60000).toISOString(),text:"roll is 4, 3",player:"Alice"},
{timestamp:new Date(Date.now()-55000).toISOString(),text:"Landed on Illinois Ave.",player:"Alice"},
{timestamp:new Date(Date.now()-50000).toISOString(),text:"roll is 6, 6",player:"Bob"},
{timestamp:new Date(Date.now()-45000).toISOString(),text:"Passed Go, collected $200",player:"Bob"},
{timestamp:new Date(Date.now()-40000).toISOString(),text:"Landed on Boardwalk",player:"Bob"},
{timestamp:new Date(Date.now()-35000).toISOString(),text:"Bob rolled doubles",player:"Bob"},
{timestamp:new Date(Date.now()-30000).toISOString(),text:"roll is 2, 5",player:"Bob"},
{timestamp:new Date(Date.now()-25000).toISOString(),text:"Paid $550 rent (3 houses)",player:"Bob"},
{timestamp:new Date(Date.now()-20000).toISOString(),text:"Charlie's turn",player:"Charlie"},
{timestamp:new Date(Date.now()-10000).toISOString(),text:"Still in jail (turn 2)",player:"Charlie"},
]
};
}
// Try to fetch real state, fall back to demo
async function update() {
try {
const resp = await fetch('game-state.json?t=' + Date.now());
if (!resp.ok) throw new Error('no file');
const state = await resp.json();
if (!state.players || state.players.length === 0) throw new Error('empty');
const updateStr = JSON.stringify(state);
if (updateStr !== lastUpdate) {
lastUpdate = updateStr;
renderBoard(state);
renderPlayers(state);
renderLog(state);
document.getElementById('status').textContent =
`Live · ${state.players.length} players · Updated: ${new Date(state.lastUpdated).toLocaleTimeString()}`;
}
} catch (e) {
// Use demo state
const demo = demoState();
const demoStr = 'demo';
if (lastUpdate !== demoStr) {
lastUpdate = demoStr;
renderBoard(demo);
renderPlayers(demo);
renderLog(demo);
document.getElementById('status').textContent = 'Demo mode (no live game detected)';
}
}
}
update();
setInterval(update, 2000);
</script>
</body>
</html>