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">
- - diff --git a/js/config.js b/js/config.js index eb2cf22..f96141d 100644 --- a/js/config.js +++ b/js/config.js @@ -16,11 +16,12 @@ var CONFIG = { STAR_UPDATE_INTERVAL: 1312, // Spawn intervals (in milliseconds) - BASE_ASTEROID_SPAWN_INTERVAL: 1000, + BASE_ASTEROID_SPAWN_INTERVAL: 2000, BASE_COMET_SPAWN_INTERVAL: 120000, // 2 minutes BASE_PLANET_SPAWN_INTERVAL: 3600000, // 1 hour BASE_GIANT_SPAWN_INTERVAL: 21600000, // 6 hours - BASE_MTYPE_SPAWN_INTERVAL: 86400000, // 1 day + BASE_MTYPE_SPAWN_INTERVAL: 86400000, // 1 day + BASE_KTYPE_SPAWN_INTERVAL: 259200000, // 3 days // Rate tracking windows (in milliseconds) RATE_WINDOWS: { @@ -34,7 +35,8 @@ var CONFIG = { COMET_BASE_COST: 1e7, PLANET_BASE_COST: 1e24, GIANT_BASE_COST: 1e27, - MTYPE_BASE_COST: 1e28, + MTYPE_BASE_COST: 1e29, + KTYPE_BASE_COST: 1e30, // Upgrade scaling UPGRADE_COST_MULTIPLIER_ASTER: 1.25, @@ -42,12 +44,14 @@ var CONFIG = { UPGRADE_COST_MULTIPLIER_PLANT: 1.75, UPGRADE_COST_MULTIPLIER_GIANT: 2, UPGRADE_COST_MULTIPLIER_MTYPE: 2.1, + UPGRADE_COST_MULTIPLIER_KTYPE: 2.2, UPGRADE_BONUS_PER_LEVEL_ASTER: 0.1, UPGRADE_BONUS_PER_LEVEL_COMET: 0.1, UPGRADE_BONUS_PER_LEVEL_PLANT: 0.05, UPGRADE_BONUS_PER_LEVEL_GIANT: 0.03, UPGRADE_BONUS_PER_LEVEL_MTYPE: 0.03, + UPGRADE_BONUS_PER_LEVEL_KTYPE: 0.03, // Asteroid spawn patterns ASTEROID_SPAWN_PATTERNS: { @@ -62,7 +66,8 @@ var CONFIG = { comet: [1e7, 1e8], // 10000-100000 t planet: [1e22, 1e25], // Moons & Planets giant: [1e26, 1e28], // Gas & Ice Giants - mtype: [1.589e29, 8.95e29] // M-Type: 0.08-0.45 M☉ + mtype: [1.589e29, 8.95e29],// M-Type: 0.08-0.45 M☉ + ktype: [8.95e29, 1.59e30] // K-Type: 0.45-0.8 M☉ }, // Planet color schemes @@ -108,6 +113,15 @@ var CONFIG = { 'rgba(70, 40, 22, 0.55)', 'rgba(45, 28, 15, 0.5)' ], + + // K-Type star color schemes (warm orange to pale yellow-orange) + KTYPE_COLORS: [ + { light: '#ffe4a0', mid: '#ffb347', dark: '#e07820' }, + { light: '#ffd580', mid: '#f0a030', dark: '#d07010' }, + { light: '#ffc870', mid: '#e89030', dark: '#c86818' }, + { light: '#ffe8b0', mid: '#ffc055', dark: '#e08820' }, + { light: '#ffd070', mid: '#e8952a', dark: '#c87018' } + ], // Star color distribution STAR_COLORS: [ diff --git a/js/entities.js b/js/entities.js index 1ba34f5..d172c82 100644 --- a/js/entities.js +++ b/js/entities.js @@ -54,8 +54,8 @@ function Asteroid(type, blackHole, canvas) { Math.pow(this.x - blackHole.x, 2) + Math.pow(this.y - blackHole.y, 2) ); - this.orbitSpeed = 0.005 + Math.random() * 0.003; - this.decayRate = 0.08 + Math.random() * 0.05; + this.orbitSpeed = 0.004 + Math.random() * 0.004; + this.decayRate = 0.08 + Math.random() * 0.08; this.blackHole = blackHole; @@ -68,7 +68,7 @@ function Asteroid(type, blackHole, canvas) { Asteroid.prototype.initializeTypeSpecificProperties = function() { // Visual size (unchanged) if (this.type === 'comet') { - this.size = 3 + Math.random() * 3; + this.size = 3 + Math.random() * 1; } else if (this.type === 'mtype') { this.size = 15 + Math.random() * 3; this.orbitSpeed *= 0.2; @@ -99,6 +99,64 @@ Asteroid.prototype.initializeTypeSpecificProperties = function() { phase: Math.random() * Math.PI * 2 // random phase for variety }); } + } else if (this.type === 'ktype') { + this.size = 16 + Math.random() * 4; + this.orbitSpeed *= 0.15; + this.decayRate *= 0.22; + this.planetColors = CONFIG.KTYPE_COLORS[Math.floor(Math.random() * CONFIG.KTYPE_COLORS.length)]; + + // Surface granulation cells + this.granules = []; + var granCount = 8 + Math.floor(Math.random() * 10); + for (var i = 0; i < granCount; i++) { + var angle = Math.random() * Math.PI * 2; + var dist = Math.random() * this.size * 0.8; + this.granules.push({ + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + radius: 1.5 + Math.random() * this.size * 0.2, + phase: Math.random() * Math.PI * 2 + }); + } + + // Spikes — each one fully independent + this.spikes = []; + var spikeCount = 11 + Math.floor(Math.random() * 11); // 11–22 + for (var s = 0; s < spikeCount; s++) { + this.spikes.push({ + baseAngle: (s / spikeCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.4, + rotSpeed: (Math.random() - 0.5) * 0.015, // some drift CW, some CCW + lenBase: 0.25 + Math.random() * 0.45, // 0.25–0.70 × size + lenAmp: 0.05 + Math.random() * 0.20, // oscillation range + lenSpeed: 0.3 + Math.random() * 1.0, // oscillation speed + lenPhase: Math.random() * Math.PI * 2, + widthBase: 0.025 + Math.random() * 0.045, // tip width + alphaBase: 0.10 + Math.random() * 0.14, + alphaAmp: 0.03 + Math.random() * 0.07, + alphaPhase: Math.random() * Math.PI * 2, + alphaSpeed: 0.4 + Math.random() * 0.9 + }); + } + + // Sparkles — simulating spikes toward the viewer + this.sparkles = []; + var sparkleCount = 4 + Math.floor(Math.random() * 4); // 4–8 + for (var k = 0; k < sparkleCount; k++) { + var angle = Math.random() * Math.PI * 2; + var dist = Math.random() * this.size * 0.75; + this.sparkles.push({ + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + dist: dist, // ← add + angle: angle, // ← add + wanderSpeed: (Math.random() - 0.5) * 0.008, // ← add, slow angular drift + size: 0.4 + Math.random() * 0.8, + phase: Math.random() * Math.PI * 2, + speed: 0.05 + Math.random() * 1.8, + armLen: 0.4 + Math.random() * 8, + armWidth: 0.2 + Math.random() * 0.4 + }); + } } else if (this.type === 'giant') { this.size = 10 + Math.random() * 5; this.orbitSpeed *= 0.3; @@ -146,6 +204,8 @@ Asteroid.prototype.draw = function(ctx) { this.drawComet(ctx); } else if (this.type === 'mtype') { this.drawMType(ctx, performance.now() * 0.002); + } else if (this.type === 'ktype') { + this.drawKType(ctx, performance.now() * 0.002); } else if (this.type === 'giant') { // Initialize per-giant tilt if not already done if (this._ringTiltBase === undefined) { @@ -348,6 +408,179 @@ Asteroid.prototype.drawMType = function(ctx, time) { ctx.restore(); }; +Asteroid.prototype.drawKType = function(ctx, time) { + var massRange = CONFIG.ASTEROID_MASS_RANGES.ktype; + var massNormalized = (this.massKg - massRange[0]) / (massRange[1] - massRange[0]); + + var pulse = 0.06 * Math.sin(time * 1.4 + this.massKg * 0.001) + 1; + var slowPulse = 0.04 * Math.sin(time * 0.4) + 1; + + ctx.save(); + ctx.translate(this.x, this.y); + + // ── Layer 1: tight ambient halo ────────────────────────────────────── + var haloSize = this.size * (2.2 + massNormalized * 0.5) * slowPulse; + var halo = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, haloSize); + halo.addColorStop(0, 'rgba(255, 160, 40, 0.10)'); + halo.addColorStop(0.5, 'rgba(255, 130, 20, 0.04)'); + halo.addColorStop(1, 'rgba(240, 100, 10, 0)'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, haloSize, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 2: spikes ────────────────────────────────────────────────── + for (var i = 0; i < this.spikes.length; i++) { + var sp = this.spikes[i]; + + var spikeAngle = sp.baseAngle + time * sp.rotSpeed; + var spikeLen = this.size * (sp.lenBase + sp.lenAmp * Math.sin(time * sp.lenSpeed + sp.lenPhase)); + var alpha = sp.alphaBase + sp.alphaAmp * Math.sin(time * sp.alphaSpeed + sp.alphaPhase); + var tipWidth = this.size * sp.widthBase; + + ctx.save(); + ctx.rotate(spikeAngle); + + var spikeGrad = ctx.createLinearGradient( + this.size, 0, + this.size + spikeLen, 0 + ); + spikeGrad.addColorStop(0, 'rgba(255, 210, 110, ' + alpha + ')'); + spikeGrad.addColorStop(0.4, 'rgba(255, 185, 70, ' + (alpha * 0.6) + ')'); + spikeGrad.addColorStop(1, 'rgba(240, 150, 30, 0)'); + + ctx.fillStyle = spikeGrad; + ctx.beginPath(); + ctx.moveTo(this.size * 0.95, -tipWidth); + ctx.quadraticCurveTo( + this.size + spikeLen * 0.5, -tipWidth * 0.3, + this.size + spikeLen, 0 + ); + ctx.quadraticCurveTo( + this.size + spikeLen * 0.5, tipWidth * 0.3, + this.size * 0.95, tipWidth + ); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + // ── Layer 3: inner corona ring ─────────────────────────────────────── + var coronaGrad = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, this.size * 1.3 * pulse); + coronaGrad.addColorStop(0, 'rgba(255, 200, 100, 0.18)'); + coronaGrad.addColorStop(0.5, 'rgba(255, 170, 60, 0.07)'); + coronaGrad.addColorStop(1, 'rgba(255, 140, 30, 0)'); + ctx.fillStyle = coronaGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size * 1.3 * pulse, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 4: main body ─────────────────────────────────────────────── + var bodyGrad = ctx.createRadialGradient( + -this.size * 0.15, -this.size * 0.15, 0, + 0, 0, this.size + ); + bodyGrad.addColorStop(0, this.planetColors.light); + bodyGrad.addColorStop(0.45, this.planetColors.mid); + bodyGrad.addColorStop(1, this.planetColors.dark); + ctx.fillStyle = bodyGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 5: surface granulation ──────────────────────────────────── + for (var g = 0; g < this.granules.length; g++) { + var gran = this.granules[g]; + var gAlpha = 0.05 + 0.03 * Math.sin(time * 0.35 + gran.phase); + var granGrad = ctx.createRadialGradient( + gran.x, gran.y, 0, + gran.x, gran.y, gran.radius + ); + granGrad.addColorStop(0, 'rgba(255, 248, 200, ' + gAlpha + ')'); + granGrad.addColorStop(1, 'rgba(255, 200, 100, 0)'); + ctx.fillStyle = granGrad; + ctx.beginPath(); + ctx.arc(gran.x, gran.y, gran.radius, 0, Math.PI * 2); + ctx.fill(); + } + + // ── Layer 6a: limb darkening ────────────────────────────────────────── + var limbGrad = ctx.createRadialGradient(0, 0, this.size * 0.45, 0, 0, this.size * 1.04); + limbGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); + limbGrad.addColorStop(0.6, 'rgba(0, 0, 0, 0)'); + limbGrad.addColorStop(0.82, 'rgba(0, 0, 0, 0.12)'); + limbGrad.addColorStop(0.93, 'rgba(0, 0, 0, 0.20)'); + limbGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + // ── Layer 6b: sparkles (viewer-facing spikes) ──────────────────────── + for (var k = 0; k < this.sparkles.length; k++) { + var sp = this.sparkles[k]; + + // Drift angle slowly over time + sp.angle += sp.wanderSpeed; + sp.x = Math.cos(sp.angle) * sp.dist; + sp.y = Math.sin(sp.angle) * sp.dist; + + var bright = 0.5 + 0.5 * Math.sin(time * sp.speed + sp.phase); + var sAlpha = bright * bright; // squaring gives snappier flash + + if (sAlpha < 0.05) continue; // skip when nearly invisible + + ctx.save(); + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.clip(); // keep sparkles inside the disc + + ctx.translate(sp.x, sp.y); + + // Four-pointed cross — two perpendicular arms + for (var arm = 0; arm < 2; arm++) { + ctx.save(); + ctx.rotate(arm * Math.PI / 2); + var armGrad = ctx.createLinearGradient(-sp.armLen, 0, sp.armLen, 0); + armGrad.addColorStop(0, 'rgba(255, 255, 240, 0)'); + armGrad.addColorStop(0.35, 'rgba(255, 250, 210, ' + (sAlpha * 0.5) + ')'); + armGrad.addColorStop(0.5, 'rgba(255, 255, 255, ' + sAlpha + ')'); + armGrad.addColorStop(0.65, 'rgba(255, 250, 210, ' + (sAlpha * 0.5) + ')'); + armGrad.addColorStop(1, 'rgba(255, 255, 240, 0)'); + ctx.fillStyle = armGrad; + ctx.beginPath(); + ctx.moveTo(-sp.armLen, 0); + ctx.lineTo(0, -sp.armWidth); + ctx.lineTo( sp.armLen, 0); + ctx.lineTo(0, sp.armWidth); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + // Bright centre dot + var dotGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, sp.size); + dotGrad.addColorStop(0, 'rgba(255, 255, 255, ' + sAlpha + ')'); + dotGrad.addColorStop(0.5, 'rgba(255, 245, 200, ' + (sAlpha * 0.5) + ')'); + dotGrad.addColorStop(1, 'rgba(255, 230, 150, 0)'); + ctx.fillStyle = dotGrad; + ctx.beginPath(); + ctx.arc(0, 0, sp.size, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + // ── Layer 7: bright core ───────────────────────────────────────────── + var coreGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size * 0.45 * pulse); + coreGrad.addColorStop(0, 'rgba(255, 255, 230, 0.55)'); + coreGrad.addColorStop(0.35,'rgba(255, 235, 160, 0.25)'); + coreGrad.addColorStop(0.7, 'rgba(255, 200, 100, 0.08)'); + coreGrad.addColorStop(1, 'rgba(255, 170, 50, 0)'); + ctx.fillStyle = coreGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size * 0.45 * pulse, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); +}; + Asteroid.prototype.drawGiant = function(ctx) { ctx.save(); ctx.translate(this.x, this.y); diff --git a/js/game.js b/js/game.js index b57d865..4b6a705 100644 --- a/js/game.js +++ b/js/game.js @@ -18,17 +18,21 @@ var Game = (function() { giantUpgradeCost: CONFIG.GIANT_BASE_COST, mtypeUpgradeLevel: 0, mtypeUpgradeCost: CONFIG.MTYPE_BASE_COST, + ktypeUpgradeLevel: 0, + ktypeUpgradeCost: CONFIG.KTYPE_BASE_COST, currentAsteroidSpawnInterval: CONFIG.BASE_ASTEROID_SPAWN_INTERVAL, currentCometSpawnInterval: CONFIG.BASE_COMET_SPAWN_INTERVAL, currentPlanetSpawnInterval: CONFIG.BASE_PLANET_SPAWN_INTERVAL, currentGiantSpawnInterval: CONFIG.BASE_GIANT_SPAWN_INTERVAL, currentMtypeSpawnInterval: CONFIG.BASE_MTYPE_SPAWN_INTERVAL, + currentKtypeSpawnInterval: CONFIG.BASE_KTYPE_SPAWN_INTERVAL, asteroidSpawnCount: 0, lastAsteroidSpawn: Date.now(), lastCometSpawn: Date.now(), lastPlanetSpawn: Date.now(), lastGiantSpawn: Date.now(), lastMtypeSpawn: Date.now(), + lastKtypeSpawn: Date.now(), sM: 0, sT: Date.now(), mM: 0, mT: Date.now(), lM: 0, lT: Date.now(), @@ -40,7 +44,8 @@ var Game = (function() { cometUnlocked: false, planetUnlocked: false, giantUnlocked: false, - mtypeUnlocked: false + mtypeUnlocked: false, + ktypeUnlocked: false }; var blackHole; @@ -87,7 +92,8 @@ var Game = (function() { comet: handleCometUpgrade, planet: handlePlanetUpgrade, giant: handleGiantUpgrade, - mtype: handleMtypeUpgrade + mtype: handleMtypeUpgrade, + ktype: handleKtypeUpgrade }); Server.init() @@ -157,7 +163,9 @@ var Game = (function() { state.giantUpgradeLevel = savedState.giantUpgradeLevel; state.giantUpgradeCost = savedState.giantUpgradeCost; state.mtypeUpgradeLevel = savedState.mtypeUpgradeLevel || 0; + state.ktypeUpgradeLevel = savedState.ktypeUpgradeLevel || 0; state.mtypeUpgradeCost = savedState.mtypeUpgradeCost || CONFIG.MTYPE_BASE_COST; + state.ktypeUpgradeCost = savedState.ktypeUpgradeCost || CONFIG.KTYPE_BASE_COST; state.asteroidSpawnCount = savedState.asteroidSpawnCount; // Load unlock states (with backward compatibility) @@ -165,12 +173,14 @@ var Game = (function() { state.planetUnlocked = savedState.planetUnlocked !== undefined ? savedState.planetUnlocked : (savedState.cometUpgradeLevel >= 15); state.giantUnlocked = savedState.giantUnlocked !== undefined ? savedState.giantUnlocked : (savedState.planetUpgradeLevel >= 10); state.mtypeUnlocked = savedState.mtypeUnlocked !== undefined ? savedState.mtypeUnlocked : (savedState.giantUpgradeLevel >= 5); + state.ktypeUnlocked = savedState.ktypeUnlocked !== undefined ? savedState.ktypeUnlocked : (savedState.mtypeUpgradeLevel >= 5); state.lastAsteroidSpawn = savedState.lastAsteroidSpawn || Date.now(); state.lastCometSpawn = savedState.lastCometSpawn || Date.now(); state.lastPlanetSpawn = savedState.lastPlanetSpawn || Date.now(); state.lastGiantSpawn = savedState.lastGiantSpawn || Date.now(); state.lastMtypeSpawn = savedState.lastMtypeSpawn || Date.now(); + state.lastKtypeSpawn = savedState.lastKtypeSpawn || Date.now(); // Load consumption rates state.sM = savedState.sM || 0; @@ -262,6 +272,8 @@ var Game = (function() { (1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT); state.currentMtypeSpawnInterval = CONFIG.BASE_MTYPE_SPAWN_INTERVAL / (1 + state.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE); + state.currentKtypeSpawnInterval = CONFIG.BASE_KTYPE_SPAWN_INTERVAL / + (1 + state.ktypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_KTYPE); } function handleAsteroidUpgrade() { @@ -347,6 +359,26 @@ var Game = (function() { state.mtypeUpgradeCost = Math.floor( CONFIG.MTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_MTYPE, state.mtypeUpgradeLevel) ); + + // Unlock K-Type at m-type level 5 + if (state.mtypeUpgradeLevel >= 5 && !state.ktypeUnlocked) { + state.ktypeUnlocked = true; + state.lastKtypeSpawn = Date.now(); + showUnlockNotification('ktype'); + asteroids.push(new Asteroid('ktype', blackHole, canvas)); + } + updateSpawnIntervals(); + UI.update(state, CONFIG); + } + } + + function handleKtypeUpgrade() { + if (state.totalMassConsumed >= state.ktypeUpgradeCost) { + state.totalMassConsumed -= state.ktypeUpgradeCost; + state.ktypeUpgradeLevel++; + state.ktypeUpgradeCost = Math.floor( + CONFIG.KTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_KTYPE, state.ktypeUpgradeLevel) + ); updateSpawnIntervals(); UI.update(state, CONFIG); } @@ -391,12 +423,20 @@ var Game = (function() { asteroids.push(new Asteroid('giant', blackHole, canvas)); } } + if (state.mtypeUnlocked) { if (currentTime - state.lastMtypeSpawn > state.currentMtypeSpawnInterval) { state.lastMtypeSpawn = currentTime; asteroids.push(new Asteroid('mtype', blackHole, canvas)); } } + + if (state.ktypeUnlocked) { + if (currentTime - state.lastKtypeSpawn > state.currentKtypeSpawnInterval) { + state.lastKtypeSpawn = currentTime; + asteroids.push(new Asteroid('ktype', blackHole, canvas)); + } + } } @@ -492,6 +532,8 @@ var Server = { this.playerId = 'p_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); Storage.setCookie('playerId', this.playerId, 365); } + // Get or create session token + this.sessionToken = Storage.getOrCreateToken(); // Start checkpoints this.startCheckpoints(); @@ -507,6 +549,7 @@ var Server = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerId: this.playerId, + secretToken: this.sessionToken, gameState: gameState }) }); @@ -574,6 +617,40 @@ var Server = { } }, 15000); // Check every 15s, but only send if changed }, + + createTransferCode: async function() { + try { + const resp = await fetch('/api/transfer/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: this.playerId, + secretToken: this.sessionToken + }) + }); + const data = await resp.json(); + if (!resp.ok) return { error: data.error }; + return data; + }catch (err) { + console.error('Failed to create transfer code', err); + return null; + } + }, + + claimTransferCode: async function(code) { + try { + const resp = await fetch('/api/transfer/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }) + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.json(); + } catch (err) { + console.error('Failed to claim transfer code', err); + return null; + } + } }; // Start the game when the page loads diff --git a/js/helpers.js b/js/helpers.js index 2cbf23b..6efee41 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -181,7 +181,8 @@ function showUnlockNotification(type) { comet: 'Comets Unlocked', planet: 'Planets Unlocked', giant: 'Gas & Ice Giants Unlocked', - mtype: 'M-Type Stars Unlocked' + mtype: 'M-Type Stars Unlocked', + ktype: 'K-Type Stars Unlocked' }; showNotification({ diff --git a/js/server.js b/js/server.js index 93dd471..242d5d7 100644 --- a/js/server.js +++ b/js/server.js @@ -8,7 +8,25 @@ app.use(express.json()); const LEADERBOARD_FILE = './leaderboard.json'; const INACTIVE_DAYS = 30; // Completely remove from JSON after 5 days -const LEADERBOARD_VISIBLE_DAYS = 3; // Only show on leaderboard if active within 1 day +const LEADERBOARD_VISIBLE_DAYS = 5; // Only show on leaderboard if active within 5 days + +// In-memory transfer codes: Map +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); +}