Game
Create a game (you become admin) or join with a 6‑digit code. Open this page on other devices to sync live.
No game connected.
# | Name | Token | Balance |
---|
Create a game (you become admin) or join with a 6‑digit code. Open this page on other devices to sync live.
No game connected.
# | Name | Token | Balance |
---|
.");
}
// ---- Init ----
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
// Elements
const $ = (id) => document.getElementById(id);
const rulesDlg = $('rulesDlg'), createDlg = $('createDlg'), joinDlg = $('joinDlg'), setupDlg = $('setupDlg');
const statusEl = $('status'), gameInfoEl = $('gameInfo'), dashboard = $('dashboard'), logEl = $('log');
const playersTableWrap = $('playerTableWrap'), playersTbody = document.querySelector('#playersTable tbody');
const fromSel = $('fromPlayer'), toSel = $('toPlayer'), amtInput = $('amount'), roleLabel = $('roleLabel');
// Wire basics
$('rulesBtn').addEventListener('click', () => rulesDlg.showModal());
$('rulesClose').addEventListener('click', () => rulesDlg.close());
$('rulesOk').addEventListener('click', () => rulesDlg.close());
$('createBtn').addEventListener('click', () => createDlg.showModal());
$('createClose').addEventListener('click', () => createDlg.close());
$('createCancel').addEventListener('click', () => createDlg.close());
$('joinBtn').addEventListener('click', () => joinDlg.showModal());
$('joinClose').addEventListener('click', () => joinDlg.close());
$('joinCancel').addEventListener('click', () => joinDlg.close());
$('setupBtn').addEventListener('click', () => {
if (!window.role?.admin) { alert('Only admin can manage players.'); return; }
setupDlg.showModal();
});
$('setupClose').addEventListener('click', () => setupDlg.close());
$('setupDone').addEventListener('click', () => setupDlg.close());
// App state
let uid = null;
let gameId = null;
let unsub = null;
// Auth
signInAnonymously(auth).catch(console.error);
onAuthStateChanged(auth, (user) => {
if (!user) { statusEl.textContent = "Status: offline"; return; }
uid = user.uid;
statusEl.textContent = "Status: online";
});
// Create game (creator becomes admin). Also register a 6-digit code in /codes and in /games/{id}/codes/{code}
$('createGo').addEventListener('click', async () => {
const name = $('gameName').value.trim() || "MyCityopoly";
const code = String(Math.floor(100000 + Math.random()*900000)); // 6-digit
const gamesCol = collection(db, "games");
const ref = doc(gamesCol); // auto-id
await setDoc(ref, { name, code, createdAt: serverTimestamp(), tokens: {}, playersIndex: {}, log: [] });
await setDoc(doc(ref, "members", uid), { admin: true, joinedAt: serverTimestamp() });
// codes mapping
await setDoc(doc(db, "codes", code), { gameId: ref.id, createdAt: serverTimestamp() });
await setDoc(doc(ref, "codes", code), { active: true, createdAt: serverTimestamp() });
connect(ref.id);
createDlg.close();
alert(`Game created. Share code: ${code}`);
});
// Join game by 6-digit code (no gameId needed)
$('joinGo').addEventListener('click', async () => {
const code = $('joinCode').value.trim();
if (!code || code.length !== 6) return alert('Enter a 6-digit code.');
const codeRef = doc(db, "codes", code);
const codeSnap = await getDoc(codeRef);
if (!codeSnap.exists()) return alert("Code not found.");
const id = codeSnap.data().gameId;
const ref = doc(db, "games", id);
const gameSnap = await getDoc(ref);
if (!gameSnap.exists()) return alert("Game not found.");
// write membership with code for rule validation
await setDoc(doc(ref, "members", uid), { admin: false, joinedAt: serverTimestamp(), codeEntered: code }, { merge: true });
connect(id);
joinDlg.close();
});
// Connect to a game
async function connect(id) {
if (unsub) { unsub(); unsub = null; }
gameId = id;
const ref = doc(db, "games", id);
const roleSnap = await getDoc(doc(ref, "members", uid));
window.role = roleSnap.exists() ? roleSnap.data() : { admin:false };
roleLabel.textContent = window.role.admin ? "(Admin)" : "(Player)";
const url = new URL(location.href);
url.hash = id;
history.replaceState(null, "", url.toString());
gameInfoEl.innerHTML = `Game: ${id}
— Share the 6‑digit code to invite players.`;
unsub = onSnapshot(ref, (snap) => {
if (!snap.exists()) return;
const data = snap.data();
renderPlayersSection(data.playersIndex || {});
renderLogSection(data.log || []);
});
}
function renderPlayersSection(index) {
const entries = Object.values(index);
entries.sort((a,b)=> a.name.localeCompare(b.name));
playersTbody.innerHTML = entries.map((p, i) =>
`
` ).join(''); playersTableWrap.style.display = entries.length ? 'block' : 'none'; dashboard.style.display = entries.length ? 'block' : 'none'; fromSel.innerHTML = `` + entries.map(p => ``).join(''); toSel.innerHTML = `` + entries.map(p => ``).join(''); document.querySelectorAll('#dashboard .btn[data-action]').forEach(b => b.disabled = entries.length === 0); } function renderLogSection(log) { logEl.innerHTML = (log||[]).slice(0,200).map(e => { const time = new Date(e.t || Date.now()).toLocaleTimeString(); return `
`; }).join(''); } // Player setup (admin only) $('setupAdd').addEventListener('click', async () => { if (!window.role?.admin) return alert('Admin only.'); if (!gameId) return alert('No game.'); const name = $('pName').value.trim(); const token = $('pToken').value; if (!name) return alert('Enter a name.'); const ref = doc(db, "games", gameId); await runTransaction(db, async (tx) => { const snap = await tx.get(ref); const data = snap.data() || {}; const idx = data.playersIndex || {}; const tokens = data.tokens || {}; if (Object.values(idx).some(p => p.name.toLowerCase() === name.toLowerCase())) { throw new Error('Name already taken.'); } if (tokens[token]) { throw new Error('Token already taken.'); } const id = crypto.randomUUID(); tokens[token] = id; idx[id] = { id, name, token, balance: 1500, mortgaged: 0 }; const log = [{ t: Date.now(), msg: `+ ${name} (${token})` }, ...(data.log||[])].slice(0,500); tx.set(ref, { tokens, playersIndex: idx, log }, { merge: true }); }).then(()=>{ $('pName').value=""; }).catch(e=>alert(e.message)); }); $('setupClear').addEventListener('click', async () => { if (!window.role?.admin) return alert('Admin only.'); if (!confirm('Remove all players?')) return; if (!gameId) return; const ref = doc(db, "games", gameId); await runTransaction(db, async (tx) => { const snap = await tx.get(ref); const data = snap.data() || {}; tx.set(ref, { tokens:{}, playersIndex:{}, log:[{t:Date.now(), msg:'Players cleared'}] }, { merge:true }); }); }); // Bank actions (admin only) document.querySelectorAll('#dashboard .btn[data-action]').forEach(btn => { btn.addEventListener('click', () => handleAction(btn.dataset.action)); }); async function handleAction(action) { if (!window.role?.admin) return alert('Admin only.'); if (!gameId) return alert('No game.'); const fromId = fromSel.value || null; const toId = toSel.value || null; const amt = Math.round(Number(amtInput.value || 0)); if (!amt || amt <= 0) return alert('Enter a positive amount.'); const ref = doc(db, "games", gameId); await runTransaction(db, async (tx) => { const snap = await tx.get(ref); const data = snap.data() || {}; const idx = data.playersIndex || {}; const get = (id) => idx[id]; const log = data.log || []; if (action === 'bankToPlayer') { if (!toId) throw new Error('Select recipient.'); get(toId).balance += amt; log.unshift({ t: Date.now(), msg: `Bank → ${get(toId).name}: $${amt}` }); } else if (action === 'playerToBank') { if (!fromId) throw new Error('Select payer.'); if ((get(fromId).balance||0) - amt < 0) throw new Error('Insufficient funds.'); get(fromId).balance -= amt; log.unshift({ t: Date.now(), msg: `${get(fromId).name} → Bank: $${amt}` }); } else if (action === 'playerToPlayer') { if (!fromId || !toId || fromId === toId) throw new Error('Pick two different players.'); if ((get(fromId).balance||0) - amt < 0) throw new Error('Insufficient funds.'); get(fromId).balance -= amt; get(toId).balance += amt; log.unshift({ t: Date.now(), msg: `${get(fromId).name} → ${get(toId).name}: $${amt}` }); } else if (action === 'mortgage') { if (!toId) throw new Error('Select player (use → select).'); get(toId).balance += amt; get(toId).mortgaged = (get(toId).mortgaged||0) + amt; log.unshift({ t: Date.now(), msg: `${get(toId).name} mortgaged, +$${amt}` }); } else if (action === 'unmortgage') { if (!fromId) throw new Error('Select player (use from ←).'); if ((get(fromId).mortgaged||0) - amt < 0) throw new Error('Owes less than that.'); if ((get(fromId).balance||0) - amt < 0) throw new Error('Insufficient funds.'); get(fromId).balance -= amt; get(fromId).mortgaged = (get(fromId).mortgaged||0) - amt; log.unshift({ t: Date.now(), msg: `${get(fromId).name} unmortgaged, -$${amt}` }); } else if (action === 'rent') { if (!fromId || !toId || fromId === toId) throw new Error('Pick payer and owner.'); if ((get(fromId).balance||0) - amt < 0) throw new Error('Insufficient funds.'); get(fromId).balance -= amt; get(toId).balance += amt; log.unshift({ t: Date.now(), msg: `${get(fromId).name} paid rent $${amt} → ${get(toId).name}` }); } tx.set(ref, { playersIndex: idx, log: log.slice(0,500) }, { merge: true }); }).then(()=>{ amtInput.value=""; }).catch(e=>alert(e.message)); } // Utils function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',\"'\":'''}[c])); } const firebaseConfig = { apiKey: "AIzaSyCniBqooNo6pGNMIxobgjqbFjVFiaLyuuI", authDomain: "mycityopoly-305c8.firebaseapp.com", projectId: "mycityopoly-305c8", storageBucket: "mycityopoly-305c8.firebasestorage.app", messagingSenderId: "184003631365", appId: "1:184003631365:web:1dc349340b950f19f9bf6c" };