From 7cd079279986810885cb13ae399dac7098b959cd Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 17 Sep 2026 13:14:31 +0000 Subject: [PATCH] fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching --- BUG_REPORTS.md | 3 +- game/index.html | 1 + game/js/entities/plant.js | 25 +- game/js/entities/projectile.js | 57 +++- game/js/game.js | 28 +- game/js/systems/audio.js | 590 +++++++++++++++++++++++++++++++++ game/js/systems/auth.js | 62 +++- game/js/systems/boss.js | 2 +- game/js/systems/combat.js | 115 +++++-- game/js/systems/grid.js | 90 ++++- game/js/utils/assets.js | 2 +- reports/bugs.json | 9 + scripts/db.py | 167 +++++++++- scripts/feedback_server.py | 181 ++++++++-- scripts/test_game.py | 37 ++- 15 files changed, 1264 insertions(+), 105 deletions(-) create mode 100644 game/js/systems/audio.js diff --git a/BUG_REPORTS.md b/BUG_REPORTS.md index 39aac23..142eaa5 100644 --- a/BUG_REPORTS.md +++ b/BUG_REPORTS.md @@ -3,10 +3,11 @@ > Данный файл обновляется автоматически при поступлении новых баг-репортов из игры. > При каждом обновлении и новой итерации разработки ассистент анализирует этот список. -**Всего репортов:** 13 | **Последнее обновление:** 2026-09-17 09:57:15 +**Всего репортов:** 14 | **Последнее обновление:** 2026-09-17 13:11:38 | ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс | |:---:|:---:|:---|:---:|:---:|:---|:---| +| 14 | 2026-09-17 13:11 | <b>Dave</b> | 🔴 Баг | 🔴 Новый | <script>alert(1)</script> | — | | 13 | 2026-09-17 09:57 | Klitr | 🎨 UI | 🔴 Новый | Здоровье зомби не видно за границей игрового поля | — | | 12 | 2026-09-16 11:20 | Admin | 🔴 Баг | ✅ Исправлено | Боулинг не работает, баг как и у Рогалика | Исправлено в v2.5: устранен ReferenceError в zombie.draw, налажен спавн и рикошет шаров боулинга. | | 11 | 2026-09-16 11:19 | Admin | 🔴 Баг | ✅ Исправлено | "Я Зомби" Аналогичная проблема как и в режиме Зомбосс | Исправлено в v2.5: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. | diff --git a/game/index.html b/game/index.html index 8ff3553..fddbad7 100644 --- a/game/index.html +++ b/game/index.html @@ -604,6 +604,7 @@ + diff --git a/game/js/entities/plant.js b/game/js/entities/plant.js index af1a971..6468a7f 100644 --- a/game/js/entities/plant.js +++ b/game/js/entities/plant.js @@ -153,15 +153,19 @@ window.Plant = class Plant { this.shootTimer = 0; if (this.type === 'peashooter' && this.level === 3) { // Tier 3: Plasma Peashooter - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, 'plasma', 0, this.damage)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, 'plasma', 0, this.damage); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, 'plasma', 0, this.damage)); } else if (this.type === 'snowpea' && this.level === 3) { // Tier 3: Freeze Snowpea const deepFreeze = Math.random() < 0.2; - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, deepFreeze ? 'freeze_snowpea' : 'snowpea', 0, this.damage)); + const pType = deepFreeze ? 'freeze_snowpea' : 'snowpea'; + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, pType, 0, this.damage); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, this.damage)); } else { const isCrit = this.critChance && Math.random() < this.critChance; const projType = this.type === 'snowpea' ? 'snowpea' : 'pea'; - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, projType, 0, this.damage, isCrit)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, projType, 0, this.damage, isCrit); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, projType, 0, this.damage, isCrit)); } if (window.PvZAudio) window.PvZAudio.playShoot(); } @@ -178,7 +182,8 @@ window.Plant = class Plant { this.shootTimer = 0; const targetX = targetZombie ? targetZombie.x : (hasBoss ? gameState.boss.x : 750); const projType = this.type === 'melonpult' ? 'melon' : 'cabbage'; - gameState.projectiles.push(new window.Projectile(this.x + 15, this.y - 10, this.row, projType, 0, this.damage, false, targetX)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 15, this.y - 10, this.row, projType, 0, this.damage, false, targetX); + else gameState.projectiles.push(new window.Projectile(this.x + 15, this.y - 10, this.row, projType, 0, this.damage, false, targetX)); if (window.PvZAudio) window.PvZAudio.playShoot(); } } @@ -191,14 +196,16 @@ window.Plant = class Plant { this.repeaterThirdPea -= dt; if (this.repeaterThirdPea <= 0) { const pType = this.level === 3 ? 'firepea' : 'pea'; - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, pType, 0, 22); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); if (window.PvZAudio) window.PvZAudio.playShoot(); } } else if (this.repeaterSecondPea > 0) { this.repeaterSecondPea -= dt; if (this.repeaterSecondPea <= 0) { const pType = this.level === 3 ? 'firepea' : 'pea'; - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, pType, 0, 22); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); if (window.PvZAudio) window.PvZAudio.playShoot(); } } else if (this.shootTimer >= this.shootInterval) { @@ -206,7 +213,8 @@ window.Plant = class Plant { if (hasZombie) { this.shootTimer = 0; const pType = this.level === 3 ? 'firepea' : 'pea'; - gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 24, this.y - 12, this.row, pType, 0, 22); + else gameState.projectiles.push(new window.Projectile(this.x + 24, this.y - 12, this.row, pType, 0, 22)); this.repeaterSecondPea = 0.16; if (this.level >= 2 && Math.random() < 0.25) { this.repeaterThirdPea = 0.32; // Tier 2+ bonus 3rd pea! @@ -223,7 +231,8 @@ window.Plant = class Plant { const hasZombie = gameState.zombies.some(z => z.row === this.row && z.x > this.x && (z.x - this.x) < 330 && z.health > 0 && !z.toRemove); if (hasZombie) { this.shootTimer = 0; - gameState.projectiles.push(new window.Projectile(this.x + 20, this.y - 8, this.row, 'fume')); + if (gameState.spawnProjectile) gameState.spawnProjectile(this.x + 20, this.y - 8, this.row, 'fume'); + else gameState.projectiles.push(new window.Projectile(this.x + 20, this.y - 8, this.row, 'fume')); if (window.PvZAudio) window.PvZAudio.playShoot(); } } diff --git a/game/js/entities/projectile.js b/game/js/entities/projectile.js index bee4cde..a8d0b9b 100644 --- a/game/js/entities/projectile.js +++ b/game/js/entities/projectile.js @@ -1,5 +1,20 @@ window.Projectile = class Projectile { constructor(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) { + this.pierceHits = new Set(); + this.init(x, y, row, type, vy, damage, isCrit, targetX); + } + + init(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) { + if (typeof isCrit === 'number' && isCrit > 1 && typeof damage === 'number' && damage >= 100) { + targetX = damage; + damage = (typeof vy === 'number') ? vy : 80; + vy = 0; + isCrit = false; + } else if (typeof isCrit === 'number' && typeof damage === 'number' && isCrit >= 100) { + targetX = isCrit; + isCrit = false; + } + this.x = x; this.y = y; this.startX = x; @@ -37,10 +52,15 @@ window.Projectile = class Projectile { this.freezes = type === 'freeze_snowpea'; this.pierces = type === 'fume' || type === 'plasma'; this.maxPierces = type === 'plasma' ? 2 : 999; - this.pierceHits = new Set(); + if (this.pierceHits) { + this.pierceHits.clear(); + } else { + this.pierceHits = new Set(); + } this.lobProgress = 0; this.toRemove = false; this.animTime = 0; + this.hitCount = 0; } update(dt) { @@ -212,3 +232,38 @@ window.Projectile = class Projectile { ctx.restore(); } }; + +// Reusable Object Pool for Projectiles (Eliminates GC churn & frame drops) +window.ProjectilePool = class ProjectilePool { + constructor(initialCapacity = 60) { + this.pool = []; + for (let i = 0; i < initialCapacity; i++) { + this.pool.push(new window.Projectile(0, 0, 0)); + } + } + + get(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) { + const proj = this.pool.length > 0 ? this.pool.pop() : new window.Projectile(x, y, row, type, vy, damage, isCrit, targetX); + proj.init(x, y, row, type, vy, damage, isCrit, targetX); + return proj; + } + + release(proj) { + if (!proj) return; + proj.toRemove = false; + if (proj.pierceHits) proj.pierceHits.clear(); + this.pool.push(proj); + } + + releaseAll(projectiles) { + if (!Array.isArray(projectiles)) return; + for (const p of projectiles) { + this.release(p); + } + } + + clear() { + this.pool = []; + } +}; + diff --git a/game/js/game.js b/game/js/game.js index 9626868..58e3586 100644 --- a/game/js/game.js +++ b/game/js/game.js @@ -49,6 +49,9 @@ window.GameEngine = class GameEngine { } this.wave = new window.WaveSystem(); this.combat = new window.CombatSystem(); + if (window.ProjectilePool) { + this.projectilePool = new window.ProjectilePool(60); + } // Entities this.plants = []; @@ -462,6 +465,9 @@ window.GameEngine = class GameEngine { this.wave.reset(); this.combat.reset(); + if (this.projectilePool) { + this.projectilePool.releaseAll(this.projectiles); + } this.plants = []; this.zombies = []; this.projectiles = []; @@ -477,6 +483,12 @@ window.GameEngine = class GameEngine { document.getElementById('victory-modal').classList.add('hidden'); } + spawnProjectile(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) { + const proj = this.projectilePool ? this.projectilePool.get(x, y, row, type, vy, damage, isCrit, targetX) : new window.Projectile(x, y, row, type, vy, damage, isCrit, targetX); + this.projectiles.push(proj); + return proj; + } + spawnSun(x, y, value = 25, isFalling = false, targetY = null) { let finalValue = value; if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_sun_resonance') && !isFalling) { @@ -2673,8 +2685,7 @@ window.GameEngine = class GameEngine { if (window.PvZAudio) window.PvZAudio.playExplosion(); } else { // Rolling bowling nut - const ball = new window.Projectile(130, bounds.centerY, row, 'bowling', 0); - this.projectiles.push(ball); + const ball = this.spawnProjectile(130, bounds.centerY, row, 'bowling', 0); if (window.PvZAudio) window.PvZAudio.playPlant(); } this.economy.selectedCard = null; @@ -2868,11 +2879,18 @@ window.GameEngine = class GameEngine { } this.zombies = this.zombies.filter(z => !z.toRemove); - // 7. Projectiles - for (const proj of this.projectiles) { + // 7. Projectiles (in-place update with pooling release, eliminating GC allocations) + let projWriteIdx = 0; + for (let i = 0; i < this.projectiles.length; i++) { + const proj = this.projectiles[i]; proj.update(dt); + if (!proj.toRemove) { + this.projectiles[projWriteIdx++] = proj; + } else { + if (this.projectilePool) this.projectilePool.release(proj); + } } - this.projectiles = this.projectiles.filter(p => !p.toRemove); + this.projectiles.length = projWriteIdx; // 8. Combat this.combat.update(dt, this); diff --git a/game/js/systems/audio.js b/game/js/systems/audio.js new file mode 100644 index 0000000..0f1c0b0 --- /dev/null +++ b/game/js/systems/audio.js @@ -0,0 +1,590 @@ +// Web Audio API Audio System with Voice Polyphony Limiting & SFX Throttling +(function() { + 'use strict'; + + // Musical note frequency lookup + const N = { + D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47, + C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94, + C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88, + C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77, + C6: 1046.50 + }; + + const THEMES = { + day: { + tempo: 230, + bassType: 'triangle', + leadType: 'sine', + bassVol: 0.10, + leadVol: 0.08, + bass: [ + N.A2, null, N.E3, null, N.A2, null, N.E3, null, + N.D3, null, N.A3, null, N.E3, null, N.G3, null + ], + lead: [ + N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4, + N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4 + ] + }, + night: { + tempo: 280, + bassType: 'sine', + leadType: 'triangle', + bassVol: 0.12, + leadVol: 0.07, + bass: [ + N.C3, null, N.G3, null, N.A2, null, N.E3, null, + N.F3, null, N.C4, null, N.G3, null, N.B3, null + ], + lead: [ + N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5, + N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null + ] + }, + pool: { + tempo: 210, + bassType: 'triangle', + leadType: 'sine', + bassVol: 0.11, + leadVol: 0.09, + bass: [ + N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4, + N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null + ], + lead: [ + N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4, + N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null + ] + }, + fog: { + tempo: 310, + bassType: 'sawtooth', + leadType: 'triangle', + bassVol: 0.07, + leadVol: 0.07, + bass: [ + N.D2, null, N.A2, null, N.D2, null, N.Bb2, null, + N.A2, null, N.F2, null, N.G2, null, N.A2, null + ], + lead: [ + N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5, + N.D5, null, N.A4, null, null, N.F5, N.E5, null + ] + }, + roof: { + tempo: 190, + bassType: 'triangle', + leadType: 'sawtooth', + bassVol: 0.11, + leadVol: 0.06, + bass: [ + N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3, + N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null + ], + lead: [ + N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5, + N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null + ] + }, + boss: { + tempo: 165, + bassType: 'sawtooth', + leadType: 'square', + bassVol: 0.09, + leadVol: 0.06, + bass: [ + N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2, + N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3 + ], + lead: [ + N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5, + null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5 + ] + } + }; + + class AudioSystem { + constructor() { + this.ctx = null; + this.muted = false; + + // Music State + this.musicInterval = null; + this.musicPlaying = false; + this.musicStep = 0; + this.currentThemeName = 'day'; + + // Polyphony & Voice Limiting State + this.maxGlobalVoices = 24; + this.activeVoiceCount = 0; + this.activeVoicesPerKey = new Map(); + this.lastPlayTime = new Map(); + + // Per-sound configuration (polyphony cap & throttle cooldown ms) + this.sfxLimits = { + shoot: { maxVoices: 4, minIntervalMs: 40 }, + hit: { maxVoices: 4, minIntervalMs: 35 }, + splat: { maxVoices: 4, minIntervalMs: 35 }, + chomp: { maxVoices: 3, minIntervalMs: 65 }, + coin: { maxVoices: 4, minIntervalMs: 35 }, + sun: { maxVoices: 3, minIntervalMs: 50 }, + plant: { maxVoices: 3, minIntervalMs: 40 }, + explosion: { maxVoices: 3, minIntervalMs: 80 }, + sizzle: { maxVoices: 2, minIntervalMs: 100 }, + mower: { maxVoices: 2, minIntervalMs: 150 }, + strike: { maxVoices: 3, minIntervalMs: 50 }, + btn: { maxVoices: 2, minIntervalMs: 30 } + }; + } + + init() { + if (!this.ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (AudioCtx) { + this.ctx = new AudioCtx(); + } + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume().catch(() => {}); + } + } + + toggleMute() { + this.muted = !this.muted; + return this.muted; + } + + isMuted() { + return this.muted; + } + + // Check voice limits and throttling + canPlayVoice(key) { + if (this.muted) return false; + this.init(); + if (!this.ctx) return false; + + const now = performance.now(); + const cfg = this.sfxLimits[key] || { maxVoices: 4, minIntervalMs: 40 }; + + // 1. Throttle check + const last = this.lastPlayTime.get(key) || 0; + if (now - last < cfg.minIntervalMs) { + return false; + } + + // 2. Global polyphony limit + if (this.activeVoiceCount >= this.maxGlobalVoices) { + return false; + } + + // 3. Per-sound polyphony limit + const currentActive = this.activeVoicesPerKey.get(key) || 0; + if (currentActive >= cfg.maxVoices) { + return false; + } + + // Register timestamp + this.lastPlayTime.set(key, now); + return true; + } + + registerVoice(key, durationSec) { + this.activeVoiceCount++; + const current = this.activeVoicesPerKey.get(key) || 0; + this.activeVoicesPerKey.set(key, current + 1); + + const release = () => { + this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); + const cur = this.activeVoicesPerKey.get(key) || 1; + this.activeVoicesPerKey.set(key, Math.max(0, cur - 1)); + }; + + setTimeout(release, Math.ceil(durationSec * 1000) + 20); + } + + playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) { + if (this.muted) return; + this.init(); + if (!this.ctx) return; + try { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, this.ctx.currentTime); + gain.gain.setValueAtTime(startVol, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(endVol, this.ctx.currentTime + duration); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + duration); + } catch (e) {} + } + + playShoot() { + if (!this.canPlayVoice('shoot')) return; + try { + const ctx = this.ctx; + const dur = 0.11; + this.registerVoice('shoot', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(620, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + dur); + gain.gain.setValueAtTime(0.24, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playHit() { + if (!this.canPlayVoice('hit')) return; + try { + const ctx = this.ctx; + const dur = 0.08; + this.registerVoice('hit', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(180, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + dur); + gain.gain.setValueAtTime(0.2, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playSplat() { + if (!this.canPlayVoice('splat')) return; + try { + const ctx = this.ctx; + const dur = 0.09; + this.registerVoice('splat', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(280, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + dur); + gain.gain.setValueAtTime(0.22, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playSun() { + if (!this.canPlayVoice('sun')) return; + try { + const ctx = this.ctx; + this.registerVoice('sun', 0.35); + const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 + notes.forEach((freq, idx) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05); + gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(ctx.currentTime + idx * 0.05); + osc.stop(ctx.currentTime + idx * 0.05 + 0.2); + }); + } catch (e) {} + } + + playPlant() { + if (!this.canPlayVoice('plant')) return; + try { + const ctx = this.ctx; + const dur = 0.15; + this.registerVoice('plant', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(220, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1); + gain.gain.setValueAtTime(0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playChomp() { + if (!this.canPlayVoice('chomp')) return; + try { + const ctx = this.ctx; + const dur = 0.1; + this.registerVoice('chomp', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(140, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + dur); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playExplosion() { + if (!this.canPlayVoice('explosion')) return; + try { + const ctx = this.ctx; + const dur = 0.5; + this.registerVoice('explosion', dur); + + const bufferSize = Math.floor(ctx.sampleRate * dur); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(800, ctx.currentTime); + filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + dur); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.5, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + noise.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + noise.start(); + } catch (e) {} + } + + playMower() { + if (!this.canPlayVoice('mower')) return; + try { + const ctx = this.ctx; + const dur = 1.2; + this.registerVoice('mower', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(90, ctx.currentTime); + osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + dur); + gain.gain.setValueAtTime(0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playCoin() { + if (!this.canPlayVoice('coin')) return; + try { + const ctx = this.ctx; + const dur = 0.3; + this.registerVoice('coin', dur); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5 + osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6 + gain.gain.setValueAtTime(0.2, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + dur); + } catch (e) {} + } + + playSizzle() { + if (!this.canPlayVoice('sizzle')) return; + try { + const ctx = this.ctx; + const dur = 0.8; + this.registerVoice('sizzle', dur); + + const bufferSize = Math.floor(ctx.sampleRate * dur); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(1400, ctx.currentTime); + filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + dur); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.4, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur); + noise.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + noise.start(); + } catch (e) {} + } + + playMine() { + this.playExplosion(); + this.playTone(550, 'sine', 0.15, 0.3, 0.01); + } + + playVault() { + if (this.muted) return; + this.init(); + if (!this.ctx) return; + try { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(180, this.ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(620, this.ctx.currentTime + 0.3); + gain.gain.setValueAtTime(0.25, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + 0.3); + } catch (e) {} + } + + playGulp() { + this.playTone(180, 'sine', 0.18, 0.35, 0.01); + setTimeout(() => this.playTone(120, 'sine', 0.22, 0.3, 0.01), 90); + } + + playStrike() { + if (!this.canPlayVoice('strike')) return; + this.registerVoice('strike', 0.2); + this.playTone(240, 'square', 0.1, 0.3, 0.01); + this.playTone(120, 'sawtooth', 0.2, 0.35, 0.01); + } + + playHugeWave() { + this.playTone(180, 'sawtooth', 0.6, 0.35, 0.01); + } + + playButtonClick() { + if (!this.canPlayVoice('btn')) return; + this.registerVoice('btn', 0.06); + this.playTone(480, 'sine', 0.05, 0.18, 0.01); + } + + playWin() { + if (this.muted) return; + this.init(); + if (!this.ctx) return; + const fanfare = [392, 523, 659, 784, 1046]; + fanfare.forEach((f, i) => { + setTimeout(() => this.playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160); + }); + } + + playLose() { + if (this.muted) return; + this.init(); + if (!this.ctx) return; + const doom = [400, 360, 320, 280, 220]; + doom.forEach((f, i) => { + setTimeout(() => this.playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200); + }); + } + + // Background Music (Multi-Biome Procedural Chiptune Synthesizer) + startMusic(themeName = 'day') { + const targetTheme = THEMES[themeName] ? themeName : 'day'; + + if (this.musicPlaying && this.currentThemeName === targetTheme && this.musicInterval) { + return; + } + + this.currentThemeName = targetTheme; + this.musicPlaying = true; + this.init(); + + if (this.musicInterval) { + clearInterval(this.musicInterval); + this.musicInterval = null; + } + + this.musicStep = 0; + const theme = THEMES[this.currentThemeName]; + + this.musicInterval = setInterval(() => { + if (!this.musicPlaying || this.muted) return; + const b = theme.bass[this.musicStep % theme.bass.length]; + const l = theme.lead[this.musicStep % theme.lead.length]; + if (b) { + this.playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001); + } + if (l) { + this.playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001); + } + this.musicStep++; + }, theme.tempo); + } + + setTheme(themeName) { + if (this.musicPlaying) { + this.startMusic(themeName); + } else { + this.currentThemeName = THEMES[themeName] ? themeName : 'day'; + } + } + + getTheme() { + return this.currentThemeName; + } + + stopMusic() { + this.musicPlaying = false; + if (this.musicInterval) { + clearInterval(this.musicInterval); + this.musicInterval = null; + } + } + + toggleMusic() { + if (this.musicPlaying) { + this.stopMusic(); + return false; + } else { + this.startMusic(this.currentThemeName); + return true; + } + } + + isMusicPlaying() { + return this.musicPlaying; + } + } + + // Instantiate singleton instance + const audioInstance = new AudioSystem(); + window.AudioSystem = AudioSystem; + window.PvZAudio = audioInstance; +})(); diff --git a/game/js/systems/auth.js b/game/js/systems/auth.js index 1aef9d7..7065f24 100644 --- a/game/js/systems/auth.js +++ b/game/js/systems/auth.js @@ -2,9 +2,26 @@ // Falls back to localStorage if server is unavailable. window.PvZAuth = (function () { const CURRENT_USER_KEY = 'pvz_current_user'; + const TOKEN_KEY = 'pvz_token'; const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache const API_BASE = '/api/auth'; + /* ─── Session Token Helpers ─── */ + function getToken() { + try { return localStorage.getItem(TOKEN_KEY) || null; } catch (_) { return null; } + } + + function setToken(token) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } catch (_) {} + } + + function clearToken() { + try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} + } + /* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */ function hashPassword(str) { if (!str) return ''; @@ -83,14 +100,33 @@ window.PvZAuth = (function () { try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; } } - /* ─── Non-blocking server call ─── */ - async function apiPost(endpoint, body) { + /* ─── Non-blocking server call with Bearer Token Authorization ─── */ + async function apiPost(endpoint, body = {}) { try { + const headers = { 'Content-Type': 'application/json' }; + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // Attach token in JSON body as well for maximum compatibility + const payload = (typeof body === 'object' && body !== null && token) + ? { token, ...body } + : body; + const resp = await fetch(`${API_BASE}/${endpoint}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + headers, + body: JSON.stringify(payload) }); + + if (resp.status === 401) { + // Clear invalid or expired session token + clearToken(); + const json = await resp.json().catch(() => ({})); + return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' }; + } + const json = await resp.json(); return json; } catch (e) { @@ -146,6 +182,9 @@ window.PvZAuth = (function () { return !u || u === 'Гость'; }, + getToken, + setToken, + clearToken, getDefaultData, validateUsername, validatePassword, @@ -175,6 +214,9 @@ window.PvZAuth = (function () { if (res && res.success && res.data) { localStorage.setItem(CURRENT_USER_KEY, cleanUser); + if (res.token) { + setToken(res.token); + } _cachedData = mergeUserData(res.data); localSave(_cachedData); @@ -182,7 +224,7 @@ window.PvZAuth = (function () { window._pvzEngine.loadUserData(); } if (typeof callback === 'function') callback(true, _cachedData, null); - return { success: true, username: cleanUser, data: _cachedData }; + return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль'; if (typeof callback === 'function') callback(false, null, errorMsg); @@ -214,6 +256,9 @@ window.PvZAuth = (function () { if (res && res.success) { localStorage.setItem(CURRENT_USER_KEY, cleanUser); + if (res.token) { + setToken(res.token); + } _cachedData = mergeUserData(res.data || initData); localSave(_cachedData); @@ -221,7 +266,7 @@ window.PvZAuth = (function () { window._pvzEngine.loadUserData(); } if (typeof callback === 'function') callback(true, _cachedData, null); - return { success: true, username: cleanUser, data: _cachedData }; + return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации'; if (typeof callback === 'function') callback(false, null, errorMsg); @@ -230,6 +275,7 @@ window.PvZAuth = (function () { }, loginAsGuest() { + clearToken(); localStorage.setItem(CURRENT_USER_KEY, 'Гость'); _cachedData = null; localSave(null); @@ -239,9 +285,13 @@ window.PvZAuth = (function () { logout() { if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } const user = this.getCurrentUser(); + const token = getToken(); if (user !== 'Гость' && _cachedData) { apiPost('save', { username: user, data: _cachedData }); } + if (token) { + apiPost('logout', { token }); + } this.loginAsGuest(); }, diff --git a/game/js/systems/boss.js b/game/js/systems/boss.js index 578ac57..00553bf 100644 --- a/game/js/systems/boss.js +++ b/game/js/systems/boss.js @@ -30,7 +30,7 @@ window.BossSystem = class BossSystem { } reset() { - this.isActive = false; + this.isActive = true; this.health = this.maxHealth; this.stompTimer = 8.0; this.ballTimer = 14.0; diff --git a/game/js/systems/combat.js b/game/js/systems/combat.js index 468f7df..1ee8659 100644 --- a/game/js/systems/combat.js +++ b/game/js/systems/combat.js @@ -1,10 +1,63 @@ +// Particle Object Pool to eliminate GC churn during large waves +window.ParticlePool = class ParticlePool { + constructor(initialCapacity = 150) { + this.pool = []; + for (let i = 0; i < initialCapacity; i++) { + this.pool.push({ + x: 0, + y: 0, + vx: 0, + vy: 0, + color: '#ffffff', + size: 3, + life: 0, + maxLife: 0 + }); + } + } + + get(x, y, vx, vy, color = '#8bc34a', size = 3, life = 0.35) { + const p = this.pool.length > 0 ? this.pool.pop() : { x: 0, y: 0, vx: 0, vy: 0, color: '#ffffff', size: 3, life: 0, maxLife: 0 }; + p.x = x; + p.y = y; + p.vx = vx; + p.vy = vy; + p.color = color; + p.size = size; + p.life = life; + p.maxLife = life; + return p; + } + + release(p) { + if (!p) return; + p.life = 0; + this.pool.push(p); + } + + releaseAll(particles) { + if (!Array.isArray(particles)) return; + for (let i = 0; i < particles.length; i++) { + this.release(particles[i]); + } + } + + clear() { + this.pool = []; + } +}; + // Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops) window.CombatSystem = class CombatSystem { constructor() { + this.particlePool = new window.ParticlePool(150); this.particles = []; } reset() { + if (this.particlePool) { + this.particlePool.releaseAll(this.particles); + } this.particles = []; } @@ -12,45 +65,50 @@ window.CombatSystem = class CombatSystem { for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 40 + Math.random() * 80; - this.particles.push({ + this.particles.push(this.particlePool.get( x, y, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, + Math.cos(angle) * speed, + Math.sin(angle) * speed, color, - size: 3 + Math.random() * 3, - life: 0.35, - maxLife: 0.35 - }); + 3 + Math.random() * 3, + 0.35 + )); } } createExplosionParticles(x, y) { + const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242']; for (let i = 0; i < 35; i++) { const angle = Math.random() * Math.PI * 2; const speed = 50 + Math.random() * 200; - const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242']; - this.particles.push({ + this.particles.push(this.particlePool.get( x, y, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, - color: colors[Math.floor(Math.random() * colors.length)], - size: 5 + Math.random() * 8, - life: 0.65, - maxLife: 0.65 - }); + Math.cos(angle) * speed, + Math.sin(angle) * speed, + colors[Math.floor(Math.random() * colors.length)], + 5 + Math.random() * 8, + 0.65 + )); } } update(dt, gameState) { - // 1. Update Particles - for (const p of this.particles) { + // 1. Update Particles in-place (eliminating GC allocations) + let writeIdx = 0; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; + if (p.life > 0) { + this.particles[writeIdx++] = p; + } else { + this.particlePool.release(p); + } } - this.particles = this.particles.filter(p => p.life > 0); + this.particles.length = writeIdx; // 2. Projectiles vs Zombies for (const proj of gameState.projectiles) { @@ -233,16 +291,15 @@ window.CombatSystem = class CombatSystem { // Tree of Wisdom Cheat: Daisies if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) { for (let i = 0; i < 6; i++) { - this.particles.push({ - x: zombie.x + (Math.random() - 0.5) * 30, - y: zombie.y + 10 + (Math.random() - 0.5) * 16, - vx: 0, - vy: -8, - color: '#ffffff', - size: 4, - life: 2.5, - maxLife: 2.5 - }); + this.particles.push(this.particlePool.get( + zombie.x + (Math.random() - 0.5) * 30, + zombie.y + 10 + (Math.random() - 0.5) * 16, + 0, + -8, + '#ffffff', + 4, + 2.5 + )); } } diff --git a/game/js/systems/grid.js b/game/js/systems/grid.js index 57787ba..733551e 100644 --- a/game/js/systems/grid.js +++ b/game/js/systems/grid.js @@ -9,13 +9,32 @@ window.GridSystem = class GridSystem { this.graves = []; this.animTime = 0; + this.bgCanvas = null; + this.bgCtx = null; + this.bgDirty = true; this.initBiome('day'); } + initBackgroundCache() { + if (!this.bgCanvas) { + if (typeof document !== 'undefined') { + this.bgCanvas = document.createElement('canvas'); + this.bgCanvas.width = 900; + this.bgCanvas.height = 600; + this.bgCtx = this.bgCanvas.getContext('2d'); + } + } + } + + invalidateBackground() { + this.bgDirty = true; + } + initBiome(biome = 'day') { this.biome = biome; this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5; this.cellH = (biome === 'pool' || biome === 'fog') ? 80 : 96; + this.invalidateBackground(); this.grid = []; for (let r = 0; r < this.rows; r++) { @@ -148,8 +167,25 @@ window.GridSystem = class GridSystem { } } - draw(ctx, hoverCell, selectedPlantType) { - // 1. Draw Lawn Tiles or Pool + get theme() { + return this._theme || 'classic'; + } + + set theme(val) { + if (this._theme !== val) { + this._theme = val; + this.invalidateBackground(); + } + } + + renderBackgroundCache() { + this.initBackgroundCache(); + if (!this.bgCtx) return; + + const ctx = this.bgCtx; + ctx.clearRect(0, 0, 900, 600); + + // 1. Draw Static Lawn Tiles or Pool Base for (let r = 0; r < this.rows; r++) { const isWater = this.isWaterCell(r); @@ -180,18 +216,9 @@ window.GridSystem = class GridSystem { ctx.lineTo(x + 60, y + this.cellH); ctx.stroke(); } else if (isWater) { - // Pool Water + // Pool Water Base ctx.fillStyle = isEven ? '#0288d1' : '#039be5'; ctx.fillRect(x, y, this.cellW, this.cellH); - - // Subtle animated water ripples - ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3; - ctx.moveTo(x + 10, y + 20 + waveOff); - ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff); - ctx.stroke(); } else if (this.theme === 'cyberpunk') { // Cyberpunk Grid Tiles ctx.fillStyle = isEven ? '#120326' : '#1d063d'; @@ -249,13 +276,44 @@ window.GridSystem = class GridSystem { // 2b. Decorative Neighbour Fence along top edge (y: 86..108) this.drawNeighbourFence(ctx); - // 4. Draw Gravestones in Night Mode + this.bgDirty = false; + } + + draw(ctx, hoverCell, selectedPlantType) { + // 1. Offscreen Canvas Cached Static Background (Lawn, Porch/Chimney, Fence, Water Base) + if (this.bgDirty || !this.bgCanvas) { + this.renderBackgroundCache(); + } + if (this.bgCanvas) { + ctx.drawImage(this.bgCanvas, 0, 0); + } + + // 1b. Dynamic Animated Water Ripples (Pool & Fog) + if (this.biome === 'pool' || this.biome === 'fog') { + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.lineWidth = 1.5; + for (let r = 2; r <= 3; r++) { + for (let c = 0; c < this.cols; c++) { + const x = this.startX + c * this.cellW; + const y = this.startY + r * this.cellH; + const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3; + ctx.beginPath(); + ctx.moveTo(x + 10, y + 20 + waveOff); + ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff); + ctx.stroke(); + } + } + ctx.restore(); + } + + // 2. Draw Gravestones in Night Mode for (const grave of this.graves) { const bounds = this.getCellBounds(grave.col, grave.row); this.drawGravestone(ctx, bounds.centerX, bounds.centerY); } - // 5. Hover effect + // 3. Hover effect if (hoverCell && selectedPlantType) { const bounds = this.getCellBounds(hoverCell.col, hoverCell.row); const isWater = this.isWaterCell(hoverCell.row); @@ -288,7 +346,7 @@ window.GridSystem = class GridSystem { ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h); } - // 6. Draw Lawnmowers & Pool Cleaners + // 4. Draw Lawnmowers & Pool Cleaners for (const mower of this.lawnmowers) { if (mower.used) continue; if (mower.isCleaner) { @@ -298,7 +356,7 @@ window.GridSystem = class GridSystem { } } - // 7. Volumetric Fog Layer + // 5. Volumetric Fog Layer if (this.biome === 'fog') { this.drawFogOverlay(ctx); } diff --git a/game/js/utils/assets.js b/game/js/utils/assets.js index 888ef75..9241b97 100644 --- a/game/js/utils/assets.js +++ b/game/js/utils/assets.js @@ -1,5 +1,5 @@ // Web Audio API Procedural Sound Synthesizer and Asset Helpers -window.PvZAudio = (function() { +window.PvZAudio = window.PvZAudio || (function() { let ctx = null; let muted = false; diff --git a/reports/bugs.json b/reports/bugs.json index c0757dc..bf58215 100644 --- a/reports/bugs.json +++ b/reports/bugs.json @@ -115,5 +115,14 @@ "status": "новый", "timestamp": "2026-09-17 09:57", "adminReply": null + }, + { + "id": 14, + "author": "<b>Dave</b>", + "category": "bug", + "text": "<script>alert(1)</script>", + "status": "новый", + "timestamp": "2026-09-17 13:11", + "adminReply": null } ] \ No newline at end of file diff --git a/scripts/db.py b/scripts/db.py index 445cc3f..3753997 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """ SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro) -Zero external dependencies. Manages users, game progress profiles, leaderboards, and bug reports. +Zero external dependencies. Manages users, game progress profiles, leaderboards, sessions, and bug reports. """ import sqlite3 import json import os -from datetime import datetime +import secrets +import hashlib +import hmac +from datetime import datetime, timedelta BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATA_DIR = os.path.join(BASE_DIR, "data") @@ -20,6 +23,33 @@ def get_connection(): conn.row_factory = sqlite3.Row return conn +def hash_password(password, salt=None): + """Secure server-side salted PBKDF2 password hashing.""" + if not password: + return "" + salt = salt or secrets.token_hex(16) + iterations = 100_000 + derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex() + return f"pbkdf2:sha256:{iterations}:{salt}:{derived}" + +def verify_password(password, stored_hash): + """Verifies a password against stored hash with backward compatibility for legacy hashes.""" + if not password or not stored_hash: + return False + if stored_hash.startswith("pbkdf2:sha256:"): + try: + parts = stored_hash.split(":") + if len(parts) == 5: + iterations = int(parts[2]) + salt = parts[3] + expected = parts[4] + derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations).hex() + return hmac.compare_digest(derived, expected) + except Exception: + return False + # Backward compatibility for plain/legacy hashes + return hmac.compare_digest(str(password), str(stored_hash)) + def init_db(): conn = get_connection() cursor = conn.cursor() @@ -34,6 +64,17 @@ def init_db(): ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)") + cursor.execute(""" CREATE TABLE IF NOT EXISTS profiles ( user_id INTEGER PRIMARY KEY, @@ -81,6 +122,79 @@ def init_db(): conn.commit() conn.close() +def create_session(user_id, duration_days=30): + """Generates a secure 256-bit token and creates a session record.""" + token = secrets.token_hex(32) + now = datetime.now() + created_at = now.strftime("%Y-%m-%d %H:%M:%S") + expires_at = (now + timedelta(days=duration_days)).strftime("%Y-%m-%d %H:%M:%S") + + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, user_id, created_at, expires_at) + ) + conn.commit() + conn.close() + return token + +def validate_session(token): + """Validates session token. Returns user dict if valid, else None.""" + if not token or not isinstance(token, str): + return None + token = token.strip() + if not token: + return None + + conn = get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT u.id, u.username, u.created_at, u.last_login, s.expires_at, s.token + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? + """, (token,)) + row = cursor.fetchone() + + if not row: + conn.close() + return None + + # Check expiration + try: + expires_at = datetime.strptime(row["expires_at"], "%Y-%m-%d %H:%M:%S") + if datetime.now() > expires_at: + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() + return None + except Exception: + conn.close() + return None + + user_info = { + "id": row["id"], + "user_id": row["id"], + "username": row["username"], + "createdAt": row["created_at"], + "lastLogin": row["last_login"], + "token": row["token"] + } + conn.close() + return user_info + +def delete_session(token): + """Deletes/invalidates a session by token.""" + if not token: + return True + conn = get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM sessions WHERE token = ?", (token.strip(),)) + conn.commit() + conn.close() + return True + def get_default_profile_dict(): return { "coins": 150, @@ -133,10 +247,12 @@ def register_user(username, password_hash, initial_data=None): cursor = conn.cursor() try: - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + now = datetime.now() + now_str = now.strftime("%Y-%m-%d %H:%M:%S") + secure_hash = hash_password(password_hash) cursor.execute( "INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)", - (username, password_hash, now_str, now_str) + (username, secure_hash, now_str, now_str) ) user_id = cursor.lastrowid @@ -168,9 +284,17 @@ def register_user(username, password_hash, initial_data=None): now_str )) + # Create session token + token = secrets.token_hex(32) + expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") + cursor.execute( + "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, user_id, now_str, expires_at) + ) + conn.commit() conn.close() - return {"success": True, "user_id": user_id, "username": username, "data": profile_data} + return {"success": True, "token": token, "user_id": user_id, "username": username, "data": profile_data} except sqlite3.IntegrityError: conn.close() return {"success": False, "message": "Пользователь с таким именем уже существует"} @@ -189,13 +313,27 @@ def authenticate_user(username, password_hash): conn.close() return None - if user["password_hash"] != password_hash: + if not verify_password(password_hash, user["password_hash"]): conn.close() return {"success": False, "message": "Неверный пароль"} - # Update last login - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"])) + now = datetime.now() + now_str = now.strftime("%Y-%m-%d %H:%M:%S") + + # Seamless upgrade from legacy plaintext/djb2 hash to salted PBKDF2 + if not user["password_hash"].startswith("pbkdf2:"): + new_secure_hash = hash_password(password_hash) + cursor.execute("UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?", (new_secure_hash, now_str, user["id"])) + else: + cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"])) + + # Create session token + token = secrets.token_hex(32) + expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") + cursor.execute( + "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, user["id"], now_str, expires_at) + ) conn.commit() # Fetch profile @@ -227,10 +365,17 @@ def authenticate_user(username, password_hash): "achievements": json.loads(p["achievements"] or "{}") } - return {"success": True, "user_id": user["id"], "username": user["username"], "data": profile_dict} + return {"success": True, "token": token, "user_id": user["id"], "username": user["username"], "data": profile_dict} -def save_user_profile(username, data): +def save_user_profile(username, data, token=None): username = username.strip() + + # If token is provided, verify it belongs to username + if token is not None: + session_user = validate_session(token) + if not session_user or session_user["username"].lower() != username.lower(): + return {"success": False, "message": "Недействительный токен авторизации или доступ запрещен"} + conn = get_connection() cursor = conn.cursor() diff --git a/scripts/feedback_server.py b/scripts/feedback_server.py index 4a8906a..8b3b00a 100755 --- a/scripts/feedback_server.py +++ b/scripts/feedback_server.py @@ -3,7 +3,7 @@ Backend Server for Plants vs. Zombies (ai.qpid.pro) Zero external dependencies. Manages: -- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/save, /api/users) +- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/users) - Community Bug Reports & Feedback (/api/bugs) """ @@ -11,6 +11,8 @@ import http.server import json import os import sys +import html +import urllib.parse from datetime import datetime BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -19,6 +21,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, "scripts")) import db PORT = 8001 +MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB REPORTS_DIR = os.path.join(BASE_DIR, "reports") BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json") BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md") @@ -26,6 +29,24 @@ BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md") os.makedirs(REPORTS_DIR, exist_ok=True) db.init_db() +# In-memory rate limiter for failed login attempts +FAILED_LOGIN_ATTEMPTS = {} # ip -> list of timestamps + +def is_login_rate_limited(ip, max_attempts=5, window_seconds=60): + now = datetime.now().timestamp() + attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < window_seconds] + FAILED_LOGIN_ATTEMPTS[ip] = attempts + return len(attempts) >= max_attempts + +def record_failed_login(ip): + now = datetime.now().timestamp() + attempts = [t for t in FAILED_LOGIN_ATTEMPTS.get(ip, []) if now - t < 60] + attempts.append(now) + FAILED_LOGIN_ATTEMPTS[ip] = attempts + +def clear_failed_logins(ip): + FAILED_LOGIN_ATTEMPTS.pop(ip, None) + DEFAULT_BUGS = [ { "id": 1, @@ -86,13 +107,13 @@ def sync_markdown(bugs): for b in reversed(bugs): bid = b.get("id", 0) ts = b.get("timestamp", "") - author = b.get("author", "Аноним").replace("|", "/") + author = str(b.get("author", "Аноним")).replace("|", "/") cat = b.get("category", "bug") cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс")) st = b.get("status", "новый") st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый") - text = b.get("text", "").replace("|", "/").replace("\n", " ") - reply = (b.get("adminReply") or "—").replace("|", "/").replace("\n", " ") + text = str(b.get("text", "")).replace("|", "/").replace("\n", " ") + reply = str(b.get("adminReply") or "—").replace("|", "/").replace("\n", " ") lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |") lines.append("") @@ -100,10 +121,32 @@ def sync_markdown(bugs): f.write("\n".join(lines)) class FeedbackHandler(http.server.BaseHTTPRequestHandler): + def get_client_ip(self): + xff = self.headers.get("X-Forwarded-For") + if xff: + return xff.split(",")[0].strip() + x_real = self.headers.get("X-Real-IP") + if x_real: + return x_real.strip() + return self.client_address[0] + def _set_cors(self): - self.send_header("Access-Control-Allow-Origin", "*") + origin = self.headers.get("Origin", "") + if not origin: + self.send_header("Access-Control-Allow-Origin", "*") + else: + try: + parsed = urllib.parse.urlparse(origin) + host = parsed.hostname or "" + if host in ["localhost", "127.0.0.1", "ai.qpid.pro", "qpid.pro", "ai", "racks"] or host.endswith(".qpid.pro") or host.endswith(".ai") or host.endswith(".racks"): + self.send_header("Access-Control-Allow-Origin", origin) + else: + self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro") + except Exception: + self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Access-Control-Allow-Credentials", "true") def do_OPTIONS(self): self.send_response(204) @@ -153,6 +196,18 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): def do_POST(self): path = self.path.split("?")[0] length = int(self.headers.get("Content-Length", 0)) + + # Check payload size limit (Max 1MB) + if length > MAX_BODY_SIZE: + self.send_response(413) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Payload Too Large (Max 1MB)"}).encode("utf-8")) + self.close_connection = True + return + body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}" try: @@ -160,46 +215,68 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): except Exception as e: self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8")) return - # 1a. User Login + # 1a. User Login with Rate Limiting if path in ["/api/auth/login", "/api/bugs/auth/login"]: + client_ip = self.get_client_ip() + if is_login_rate_limited(client_ip): + self.send_response(429) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps({ + "success": False, + "message": "Слишком много неудачных попыток входа. Пожалуйста, подождите 1 минуту." + }).encode("utf-8")) + return + username = str(payload.get("username", "")).strip() password_hash = str(payload.get("passwordHash", "")).strip() if not username or not password_hash: + record_failed_login(client_ip) self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8")) return import re if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): + record_failed_login(client_ip) self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) return user = db.get_user_by_username(username) if not user: + record_failed_login(client_ip) self.send_response(404) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8")) return res = db.authenticate_user(username, password_hash) if not res or not res.get("success"): + record_failed_login(client_ip) self.send_response(401) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8")) return + clear_failed_logins(client_ip) data = json.dumps(res, ensure_ascii=False).encode("utf-8") self.send_response(200) self._set_cors() @@ -218,6 +295,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if not username or not password_hash: self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8")) return @@ -226,6 +304,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) return @@ -233,6 +312,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if len(password_hash) < 1: self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8")) return @@ -240,6 +320,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]: self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8")) return @@ -248,6 +329,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if user: self.send_response(409) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8")) return @@ -262,21 +344,72 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): self.wfile.write(data) return - # 2. User Progress Profile Save & Sync - if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]: - username = str(payload.get("username", "")).strip() - user_data = payload.get("data", {}) + # 1c. User Logout + if path in ["/api/auth/logout", "/api/bugs/auth/logout"]: + auth_header = self.headers.get("Authorization", "") + token = None + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + elif "token" in payload and payload.get("token"): + token = str(payload.get("token")).strip() - if not username or not user_data: - self.send_response(400) + if token: + db.delete_session(token) + + self.send_response(200) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps({"success": True, "message": "Сессия успешно завершена"}).encode("utf-8")) + return + + # 2. User Progress Profile Save & Sync (Token Authenticated) + if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]: + auth_header = self.headers.get("Authorization", "") + token = None + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + elif "token" in payload and payload.get("token"): + token = str(payload.get("token")).strip() + + if not token: + self.send_response(401) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Username and data are required"}).encode("utf-8")) + self.wfile.write(json.dumps({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8")) return - res = db.save_user_profile(username, user_data) + session_user = db.validate_session(token) + if not session_user: + self.send_response(401) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Недействительный или истекший токен сессии"}).encode("utf-8")) + return + + username = str(payload.get("username", "")).strip() or session_user["username"] + if session_user["username"].lower() != username.lower(): + self.send_response(403) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Доступ запрещен (несоответствие пользователя)"}).encode("utf-8")) + return + + user_data = payload.get("data", {}) + if not user_data: + self.send_response(400) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Отсутствуют данные профиля для сохранения"}).encode("utf-8")) + return + + res = db.save_user_profile(username, user_data, token=token) data = json.dumps(res, ensure_ascii=False).encode("utf-8") - self.send_response(200) + self.send_response(200 if res.get("success") else 400) self._set_cors() self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) @@ -284,28 +417,32 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): self.wfile.write(data) return - # 3. Bug Report Submission + # 3. Bug Report Submission (Sanitized) if path.startswith("/api/bugs") or path.startswith("/bugs"): - text = str(payload.get("text", "")).strip() - if not text: + raw_text = str(payload.get("text", "")).strip() + if not raw_text: self.send_response(400) self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8")) return - author = str(payload.get("author", "Гость")).strip() or "Гость" + raw_author = str(payload.get("author", "Гость")).strip() or "Гость" category = str(payload.get("category", "bug")).strip() if category not in ["bug", "feature", "visual", "balance"]: category = "bug" + clean_author = html.escape(raw_author)[:30] + clean_text = html.escape(raw_text)[:1000] + bugs = load_bugs() new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1 new_bug = { "id": new_id, - "author": author[:30], + "author": clean_author, "category": category, - "text": text[:1000], + "text": clean_text, "status": "новый", "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "adminReply": None diff --git a/scripts/test_game.py b/scripts/test_game.py index 2b8830b..fc52c48 100644 --- a/scripts/test_game.py +++ b/scripts/test_game.py @@ -195,7 +195,10 @@ def test_auth_system(): "scheduleSave", "apiPost", "validateUsername", - "validatePassword" + "validatePassword", + "getToken", + "setToken", + "clearToken" ] for m in auth_methods: if m in auth_code: @@ -203,8 +206,10 @@ def test_auth_system(): else: record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}") - record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL", - "Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "") + record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL") + record("auth.js has pvz_token session storage", "PASS" if "pvz_token" in auth_code else "FAIL") + record("auth.js sends Authorization Bearer header", "PASS" if "Authorization" in auth_code and "Bearer" in auth_code else "FAIL") + record("auth.js handles 401 Unauthorized clearing token", "PASS" if "401" in auth_code and "clearToken" in auth_code else "FAIL") record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL") record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL") @@ -243,6 +248,12 @@ def test_auth_system(): auth_res = pvz_db.authenticate_user("kopri", "c63bc483") record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL", str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "") + record("SQLite DB: authenticate returns session token", "PASS" if auth_res and auth_res.get("token") and len(auth_res["token"]) == 64 else "FAIL") + + # Test session validation + kopri_token = auth_res.get("token") + sess_val = pvz_db.validate_session(kopri_token) + record("SQLite DB: validate_session(token) succeeds", "PASS" if sess_val and sess_val.get("username") == "kopri" else "FAIL") # Test wrong password bad_auth = pvz_db.authenticate_user("kopri", "wronghash") @@ -253,10 +264,18 @@ def test_auth_system(): dup_reg = pvz_db.register_user("kopri", "c63bc483") record("SQLite DB: reject duplicate user registration", "PASS" if not dup_reg.get("success") else "FAIL") - # Test save profile (update newbie coins) + # Test save profile with valid token save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500}) record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL") + # Test save profile with invalid token rejection + bad_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token="invalid_token_123") + record("SQLite DB: save_user_profile rejects invalid token", "PASS" if not bad_save.get("success") else "FAIL") + + # Test save profile with mismatching token + mismatch_save = pvz_db.save_user_profile("newbie_test", {"coins": 999}, token=kopri_token) + record("SQLite DB: save_user_profile rejects mismatched user token", "PASS" if not mismatch_save.get("success") else "FAIL") + # Verify saved verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4") verify_ok = verify and verify.get("data", {}).get("coins") == 999 @@ -277,6 +296,16 @@ def test_auth_system(): else: record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile") + # Test session deletion + pvz_db.delete_session(kopri_token) + record("SQLite DB: delete_session invalidates session token", "PASS" if pvz_db.validate_session(kopri_token) is None else "FAIL") + + # Test password hashing function + h = pvz_db.hash_password("mysecretpassword") + record("SQLite DB: hash_password produces salted pbkdf2 format", "PASS" if h.startswith("pbkdf2:sha256:100000:") else "FAIL") + record("SQLite DB: verify_password matches correct password", "PASS" if pvz_db.verify_password("mysecretpassword", h) else "FAIL") + record("SQLite DB: verify_password rejects wrong password", "PASS" if not pvz_db.verify_password("wrongpassword", h) else "FAIL") + except Exception as e: record("SQLite DB tests", "FAIL", str(e))