fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching

This commit is contained in:
Antigravity committed 2026-09-17 13:14:31 +00:00
1 parent 258b1a9cb1
commit 7cd0792799
15 files changed
+1264 -105

No files matched your search

+2 -1
View File
@@ -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: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. |
+1
View File
@@ -604,6 +604,7 @@
<!-- Scripts loaded in dependency order -->
<script src="js/utils/assets.js"></script>
<script src="js/systems/audio.js"></script>
<script src="js/utils/test_api.js"></script>
<script src="js/systems/auth.js"></script>
<script src="js/entities/projectile.js"></script>
+17 -8
View File
@@ -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();
}
}
+56 -1
View File
@@ -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 = [];
}
};
+23 -5
View File
@@ -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);
+590
View File
@@ -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;
})();
+56 -6
View File
@@ -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();
},
+1 -1
View File
@@ -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;
+86 -29
View File
@@ -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
));
}
}
+74 -16
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+9
View File
@@ -115,5 +115,14 @@
"status": "новый",
"timestamp": "2026-09-17 09:57",
"adminReply": null
},
{
"id": 14,
"author": "&lt;b&gt;Dave&lt;/b&gt;",
"category": "bug",
"text": "&lt;script&gt;alert(1)&lt;/script&gt;",
"status": "новый",
"timestamp": "2026-09-17 13:11",
"adminReply": null
}
]
+156 -11
View File
@@ -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()
+159 -22
View File
@@ -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
+33 -4
View File
@@ -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))