diff --git a/admin.html b/admin.html index 8c80f05..a564155 100644 --- a/admin.html +++ b/admin.html @@ -28,6 +28,7 @@ + diff --git a/index.html b/index.html index ed39d2a..b63f12f 100644 --- a/index.html +++ b/index.html @@ -38,19 +38,32 @@ a1.7 1.7 0 0 0-1.5 1z">
+const transferCodes = new Map();
+
+// Strip secret fields before sending player data to clients
+function sanitizePlayer(player) {
+ const p = Object.assign({}, player);
+ delete p.secretToken;
+ return p;
+}
+
+// Remove expired transfer codes every minute
+setInterval(() => {
+ const now = Date.now();
+ for (const [code, data] of transferCodes) {
+ if (data.expiresAt < now) transferCodes.delete(code);
+ }
+}, 60 * 1000);
const BOT_USER_AGENTS = [
'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
@@ -64,7 +82,7 @@ app.post('/api/checkpoint', (req, res) => {
return res.status(403).json({ error: 'Bot traffic not allowed' });
}
- const { playerId, gameState } = req.body;
+ const { playerId, gameState, secretToken } = req.body;
// Ignore players who haven't consumed any mass
if (gameState.totalMassConsumedEver === 0) {
return res.json({ success: true }); // Accept but don't save
@@ -93,9 +111,19 @@ app.post('/api/checkpoint', (req, res) => {
const now = Date.now();
// Find or create player
+ let code, attempts = 0;
+
let player = leaderboard.find(p => p.id === playerId);
if (player) {
- // Update existing player - store full game state
+ // Token validation with backward-compatible migration
+ if (player.secretToken) {
+ if (secretToken !== player.secretToken) {
+ return res.status(403).json({ error: 'Invalid session token' });
+ }
+ } else if (secretToken) {
+ player.secretToken = secretToken; // migrate: adopt token on first checkpoint
+ }
+
player.gameState = gameState;
player.mass = gameState.blackHoleTotalMass || 0;
player.level = (gameState.asteroidUpgradeLevel || 0) +
@@ -104,12 +132,12 @@ app.post('/api/checkpoint', (req, res) => {
(gameState.giantUpgradeLevel || 0);
player.lastSeen = now;
player.holeAge = now - player.firstSeen;
-
+
} else {
- // New player
- const timestamp = new Date().toLocaleString();
+ const timestamp = new Date().toLocaleString();
const newPlayer = {
id: playerId,
+ secretToken: secretToken || null,
gameState: gameState,
mass: gameState.blackHoleTotalMass || 0,
level: (gameState.asteroidUpgradeLevel || 0) +
@@ -120,9 +148,8 @@ app.post('/api/checkpoint', (req, res) => {
lastSeen: now,
holeAge: 0
};
-
+
console.log(`${timestamp} Created new player: `, newPlayer.id);
-
leaderboard.push(newPlayer);
}
@@ -180,7 +207,7 @@ app.get('/api/leaderboard', (req, res) => {
saveLeaderboard(leaderboard);
// Return only visible players (top 50)
- res.json(visiblePlayers.slice(0, 50));
+ res.json(visiblePlayers.slice(0, 50).map(sanitizePlayer));
});
// Get player info (works even if not visible on leaderboard)
@@ -204,7 +231,7 @@ app.get('/api/player/:playerId', (req, res) => {
const fiveDaysAgo = Date.now() - (LEADERBOARD_VISIBLE_DAYS * 24 * 60 * 60 * 1000);
player.visibleOnLeaderboard = player.lastSeen > fiveDaysAgo;
- res.json(player);
+ res.json(sanitizePlayer(player));
});
// Get player's full game state
@@ -272,6 +299,82 @@ setInterval(cleanupBotAccounts, 24 * 60 * 60 * 1000);
// Run cleanup on startup too
cleanupBotAccounts();
+// Block direct access to leaderboard.json
+app.get('/leaderboard.json', (req, res) => {
+ res.status(403).json({ error: 'Access denied' });
+});
+
+// Create a transfer code for session migration
+app.post('/api/transfer/create', (req, res) => {
+ const { playerId, secretToken } = req.body;
+ if (!playerId || !secretToken) {
+ return res.status(400).json({ error: 'Missing credentials' });
+ }
+
+ const leaderboard = loadLeaderboard();
+ const player = leaderboard.find(p => p.id === playerId);
+ if (!player) {
+ return res.status(404).json({ error: 'Player not found' });
+ }
+
+ if (player.secretToken && player.secretToken !== secretToken) {
+ return res.status(403).json({ error: 'Invalid session token' });
+ }
+
+ // Return existing active code if one exists
+ for (const [existingCode, data] of transferCodes) {
+ if (data.playerId === playerId && data.expiresAt > Date.now()) {
+ return res.json({ code: existingCode, expiresAt: data.expiresAt });
+ }
+ }
+
+ let code, attempts = 0;
+ do {
+ code = Math.floor(Math.random() * 0x100000).toString(16).padStart(5, '0');
+ attempts++;
+ } while (transferCodes.has(code) && attempts < 100);
+
+ const expiresAt = Date.now() + 5 * 60 * 1000;
+ transferCodes.set(code, { playerId, secretToken, expiresAt });
+
+ const timestamp = new Date().toLocaleString();
+ console.log(`${timestamp} Transfer code created for player ...${playerId.slice(-5)}`);
+
+ res.json({ code, expiresAt });
+});
+
+// Claim a transfer code
+app.post('/api/transfer/claim', (req, res) => {
+ const { code } = req.body;
+ if (!code) return res.status(400).json({ error: 'Missing code' });
+
+ const key = code.toLowerCase();
+
+ // Check key
+ if (!/^[0-9a-f]{5}$/.test(key)) {
+ return res.status(400).json({ error: 'Invalid code format' });
+ }
+
+ const data = transferCodes.get(key);
+
+ if (!data) return res.status(404).json({ error: 'Invalid or expired code' });
+
+ if (data.expiresAt < Date.now()) {
+ transferCodes.delete(key);
+ return res.status(410).json({ error: 'Code has expired' });
+ }
+
+ transferCodes.delete(key); // one-time use
+
+ const timestamp = new Date().toLocaleString();
+ console.log(`${timestamp} Transfer code claimed for player ...${data.playerId.slice(-5)}`);
+
+ res.json({
+ playerId: data.playerId,
+ sessionToken: data.secretToken
+ });
+});
+
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
const timestamp = new Date().toLocaleString();
diff --git a/js/storage.js b/js/storage.js
index 4f8b0cd..641e0b6 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -43,5 +43,22 @@ var Storage = {
}, 100);
}
}
+ },
+
+ getOrCreateToken: function() {
+ var token = this.getCookie('sessionToken');
+ if (!token) {
+ var arr = new Uint8Array(16);
+ if (window.crypto && window.crypto.getRandomValues) {
+ window.crypto.getRandomValues(arr);
+ token = Array.from(arr).map(function(b) {
+ return b.toString(16).padStart(2, '0');
+ }).join('');
+ } else {
+ token = Date.now().toString(36) + Math.random().toString(36).substr(2, 16) + Math.random().toString(36).substr(2, 16);
+ }
+ this.setCookie('sessionToken', token, 365);
+ }
+ return token;
}
};
diff --git a/js/ui.js b/js/ui.js
index 73c14f5..263d6fc 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -14,11 +14,13 @@ var UI = {
planetLevel: document.getElementById('planet-level'),
giantLevel: document.getElementById('giant-level'),
mtypeLevel: document.getElementById('mtype-level'),
+ ktypeLevel: document.getElementById('ktype-level'),
asteroidUpgradeBtn: document.getElementById('asteroid-upgrade-btn'),
cometUpgradeBtn: document.getElementById('comet-upgrade-btn'),
planetUpgradeBtn: document.getElementById('planet-upgrade-btn'),
giantUpgradeBtn: document.getElementById('giant-upgrade-btn'),
mtypeUpgradeBtn: document.getElementById('mtype-upgrade-btn'),
+ ktypeUpgradeBtn: document.getElementById('ktype-upgrade-btn'),
gearIcon: document.getElementById('gear-icon'),
settingsMenu: document.getElementById('settings-menu'),
resetBtn: document.getElementById('reset-btn'),
@@ -105,7 +107,70 @@ var UI = {
e.stopPropagation();
});
}
-
+
+ // ---------- Session Transfer ----------
+ var exportBtn = document.getElementById('export-session-btn');
+ var claimBtn = document.getElementById('transfer-claim-btn');
+
+ if (exportBtn) {
+ exportBtn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ self.exportSession();
+ });
+ }
+
+ if (claimBtn) {
+ claimBtn.addEventListener('click', async function(e) {
+ e.stopPropagation();
+ var input = document.getElementById('transfer-code-input');
+ var isVisible = input.style.display !== 'none';
+
+ if (!isVisible) {
+ input.style.display = 'block';
+ claimBtn.classList.add('active');
+ input.focus();
+ return;
+ }
+
+ var code = input.value.trim();
+
+ if (!code) {
+ input.style.display = 'none';
+ claimBtn.classList.remove('active');
+ return;
+ }
+
+ if (!/^[0-9a-fA-F]{5}$/.test(code)) {
+ showErrorNotification('Invalid or expired code');
+ return;
+ }
+
+ try {
+ var result = await Server.claimTransferCode(code.toLowerCase());
+ if (!result || !result.playerId) throw new Error();
+
+ Storage.setCookie('playerId', result.playerId, 365);
+ Storage.setCookie('sessionToken', result.sessionToken, 365);
+
+ showSuccessNotification('Session imported — reloading...');
+ setTimeout(function() { location.reload(); }, 1500);
+
+ } catch (err) {
+ showErrorNotification('Invalid or expired code');
+ }
+ });
+ }
+
+ var transferInput = document.getElementById('transfer-code-input');
+ if (transferInput) {
+ transferInput.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ claimBtn.click();
+ }
+ });
+ }
+
// ---------- Click outside to close ----------
document.addEventListener('click', () => {
// Close settings menu
@@ -139,6 +204,7 @@ var UI = {
this.elements.planetUpgradeBtn.addEventListener('click', handlers.planet);
this.elements.giantUpgradeBtn.addEventListener('click', handlers.giant);
this.elements.mtypeUpgradeBtn.addEventListener('click', handlers.mtype);
+ this.elements.ktypeUpgradeBtn.addEventListener('click', handlers.ktype);
},
update: function(gameState, config) {
@@ -246,12 +312,21 @@ var UI = {
this.elements.mtypeLevel.parentElement.style.display = 'none';
}
+ // Only show ktype upgrade if unlocked
+ if (gameState.ktypeUnlocked) {
+ this.updateKtypeUpgrade(gameState);
+ this.elements.ktypeLevel.parentElement.style.display = '';
+ } else {
+ this.elements.ktypeLevel.parentElement.style.display = 'none';
+ }
+
var canAffordAny =
gameState.totalMassConsumed >= gameState.asteroidUpgradeCost ||
(gameState.cometUnlocked && gameState.totalMassConsumed >= gameState.cometUpgradeCost) ||
(gameState.planetUnlocked && gameState.totalMassConsumed >= gameState.planetUpgradeCost) ||
(gameState.giantUnlocked && gameState.totalMassConsumed >= gameState.giantUpgradeCost) ||
- (gameState.mtypeUnlocked && gameState.totalMassConsumed >= gameState.mtypeUpgradeCost);
+ (gameState.mtypeUnlocked && gameState.totalMassConsumed >= gameState.mtypeUpgradeCost) ||
+ (gameState.ktypeUnlocked && gameState.totalMassConsumed >= gameState.ktypeUpgradeCost);
var holeIcon = document.getElementById('hole-icon');
if (holeIcon) {
@@ -271,7 +346,9 @@ var UI = {
gameState.asteroidUpgradeLevel +
gameState.cometUpgradeLevel +
gameState.planetUpgradeLevel +
- gameState.giantUpgradeLevel;
+ gameState.giantUpgradeLevel +
+ gameState.mtypeUpgradeLevel +
+ gameState.ktypeUpgradeLevel;
el.innerHTML =
'' +
@@ -390,6 +467,19 @@ var UI = {
gameState.totalMassConsumed < gameState.mtypeUpgradeCost;
},
+ updateKtypeUpgrade: function(gameState) {
+ var rate = (259200000 / gameState.currentKtypeSpawnInterval).toFixed(2);
+ var bonusPercent = (gameState.ktypeUpgradeLevel * 3);
+ var tooltipText = 'Spawn Rate: ' + rate + '/3days
Bonus: ' + bonusPercent + '%';
+ this.elements.ktypeLevel.innerHTML = 'K-Type: Level ' +
+ gameState.ktypeUpgradeLevel +
+ '' + tooltipText + '';
+ this.elements.ktypeUpgradeBtn.textContent =
+ 'Upgrade (Cost: ' + this.formatMass(gameState.ktypeUpgradeCost) + ')';
+ this.elements.ktypeUpgradeBtn.disabled =
+ gameState.totalMassConsumed < gameState.ktypeUpgradeCost;
+ },
+
formatMass: function(massKg, options = {}) {
if (massKg == null) return '0'; // fallback
const SOLAR_SWITCH_KG = CONFIG.SOLAR_MASS_KG * 0.01; // ~1% solar mass
@@ -543,5 +633,55 @@ var UI = {
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
+ },
+
+ exportSession: async function() {
+ var btn = document.getElementById('export-session-btn');
+ var display = document.getElementById('export-code-display');
+ var codeEl = document.getElementById('transfer-code-value');
+ var expiryEl = document.getElementById('transfer-code-expiry');
+
+ if (!btn || !display || !codeEl) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Export';
+ display.style.display = 'none';
+
+ try {
+ var result = await Server.createTransferCode();
+ if (!result || !result.code) {
+ showErrorNotification(result && result.error ? result.error : 'Failed to generate transfer code');
+ return;
+ }
+ codeEl.textContent = result.code.toUpperCase();
+ display.style.display = 'block';
+ btn.classList.add('menu-btn-export-active');
+
+ var expiresAt = result.expiresAt;
+ if (this._transferTimer) clearInterval(this._transferTimer);
+ var self = this;
+
+ var tick = function() {
+ var remaining = Math.max(0, expiresAt - Date.now());
+ var mins = Math.floor(remaining / 60000);
+ var secs = Math.floor((remaining % 60000) / 1000);
+ if (expiryEl) expiryEl.textContent = 'Expires in ' + mins + ':' + (secs < 10 ? '0' : '') + secs;
+ if (remaining === 0) {
+ clearInterval(self._transferTimer);
+ if (codeEl) codeEl.textContent = 'EXPIRED';
+ if (expiryEl) expiryEl.textContent = '';
+ btn.classList.remove('menu-btn-export-active');
+ }
+ };
+
+ tick(); // run immediately
+ this._transferTimer = setInterval(tick, 1000);
+
+ } catch (e) {
+ showErrorNotification('Failed to generate transfer code');
+ }
+
+ btn.disabled = false;
+ btn.textContent = 'Export';
}
};
diff --git a/style.css b/style.css
index c803501..849e425 100644
--- a/style.css
+++ b/style.css
@@ -125,9 +125,6 @@ canvas {
}
}
-#hole-icon.pulse:hover {
-}
-
#gear-icon:hover,
.score-icon:hover,
#hole-icon:hover {
@@ -183,7 +180,7 @@ canvas {
}
.menu-btn.danger:hover {
- background: rgba(255, 0, 0, 0.42);
+ background: rgba(255, 0, 0, 0.24);
border-color: rgba(255, 0, 0, 0.69);
}
@@ -419,3 +416,120 @@ canvas {
transform: translate(-50%, -50%) scale(1);
}
}
+
+/* SESSION TRANSFER */
+.menu-divider {
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin: 14px 0;
+}
+
+.menu-subtitle {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+}
+
+.transfer-warning {
+ color: rgba(255, 200, 100, 0.9);
+ font-size: 10px;
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.transfer-code-box {
+ user-select: text;
+ -webkit-user-select: text;
+ cursor: text;
+ text-align: center;
+ margin: 8px 0 4px;
+}
+
+.transfer-code {
+ font-size: 26px;
+ letter-spacing: 7px;
+ color: rgba(0, 255, 255, 0.69);
+}
+
+.transfer-expiry {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.35);
+ text-align: center;
+ margin-bottom: 4px;
+}
+
+.transfer-input-row {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.menu-btn-export {
+ background: rgba(60, 60, 80, 0.5);
+ border: 1px solid rgba(255, 192, 0, 0.42);
+ color: rgba(255, 255, 255, 0.5);
+ padding: 6px 12px;
+ margin-top: 8px;
+ margin-right: 8px;
+ cursor: pointer;
+ font-family: 'Courier New', monospace;
+ font-size: 11px;
+ transition: all 0.2s;
+}
+
+.menu-btn-export:hover {
+ background: rgba(255, 192, 0, 0.24);
+ border-color: rgba(255, 192, 0, 0.69);
+}
+
+.menu-btn-export-active {
+ background: rgba(255, 192, 0, 0.13);
+ border-color: rgba(255, 192, 0, 0.42);
+}
+
+.menu-btn-import {
+ background: rgba(60, 60, 80, 0.5);
+ border: 1px solid rgba(0, 255, 255, 0.42);
+ color: rgba(255, 255, 255, 0.5);
+ padding: 6px 12px;
+ margin-top: 8px;
+ margin-right: 8px;
+ cursor: pointer;
+ font-family: 'Courier New', monospace;
+ font-size: 11px;
+ transition: all 0.2s;
+ margin-bottom: 8px;
+}
+
+.menu-btn-import:hover {
+ background: rgba(0, 255, 255, 0.24);
+ border-color: rgba(0, 255, 255, 0.69);
+}
+
+.menu-btn-import.active {
+ background: rgba(0, 255, 255, 0.13);
+ border-color: rgba(0, 255, 255, 0.42);
+}
+
+.transfer-input {
+ flex: 1;
+ background: rgba(30, 30, 50, 0.8);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: rgba(255, 255, 255, 0.9);
+ padding: 6px 8px;
+ font-size: 15px;
+ letter-spacing: 3px;
+ border-radius: 4px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.transfer-input::placeholder {
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.transfer-input:focus {
+ outline: none;
+ border-color: rgba(0, 255, 255, 0.42);
+}