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
+1261 -102

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 | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс | | ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс |
|:---:|:---:|:---|:---:|:---:|:---|:---| |:---:|:---:|:---|:---:|:---:|:---|:---|
| 14 | 2026-09-17 13:11 | <b>Dave</b> | 🔴 Баг | 🔴 Новый | <script>alert(1)</script> | — |
| 13 | 2026-09-17 09:57 | Klitr | 🎨 UI | 🔴 Новый | Здоровье зомби не видно за границей игрового поля | — | | 13 | 2026-09-17 09:57 | Klitr | 🎨 UI | 🔴 Новый | Здоровье зомби не видно за границей игрового поля | — |
| 12 | 2026-09-16 11:20 | Admin | 🔴 Баг | ✅ Исправлено | Боулинг не работает, баг как и у Рогалика | Исправлено в v2.5: устранен ReferenceError в zombie.draw, налажен спавн и рикошет шаров боулинга. | | 12 | 2026-09-16 11:20 | Admin | 🔴 Баг | ✅ Исправлено | Боулинг не работает, баг как и у Рогалика | Исправлено в v2.5: устранен ReferenceError в zombie.draw, налажен спавн и рикошет шаров боулинга. |
| 11 | 2026-09-16 11:19 | Admin | 🔴 Баг | ✅ Исправлено | "Я Зомби" Аналогичная проблема как и в режиме Зомбосс | Исправлено в v2.5: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. | | 11 | 2026-09-16 11:19 | Admin | 🔴 Баг | ✅ Исправлено | "Я Зомби" Аналогичная проблема как и в режиме Зомбосс | Исправлено в v2.5: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. |
+1
View File
@@ -604,6 +604,7 @@
<!-- Scripts loaded in dependency order --> <!-- Scripts loaded in dependency order -->
<script src="js/utils/assets.js"></script> <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/utils/test_api.js"></script>
<script src="js/systems/auth.js"></script> <script src="js/systems/auth.js"></script>
<script src="js/entities/projectile.js"></script> <script src="js/entities/projectile.js"></script>
+17 -8
View File
@@ -153,15 +153,19 @@ window.Plant = class Plant {
this.shootTimer = 0; this.shootTimer = 0;
if (this.type === 'peashooter' && this.level === 3) { if (this.type === 'peashooter' && this.level === 3) {
// Tier 3: Plasma Peashooter // 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) { } else if (this.type === 'snowpea' && this.level === 3) {
// Tier 3: Freeze Snowpea // Tier 3: Freeze Snowpea
const deepFreeze = Math.random() < 0.2; 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 { } else {
const isCrit = this.critChance && Math.random() < this.critChance; const isCrit = this.critChance && Math.random() < this.critChance;
const projType = this.type === 'snowpea' ? 'snowpea' : 'pea'; 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(); if (window.PvZAudio) window.PvZAudio.playShoot();
} }
@@ -178,7 +182,8 @@ window.Plant = class Plant {
this.shootTimer = 0; this.shootTimer = 0;
const targetX = targetZombie ? targetZombie.x : (hasBoss ? gameState.boss.x : 750); const targetX = targetZombie ? targetZombie.x : (hasBoss ? gameState.boss.x : 750);
const projType = this.type === 'melonpult' ? 'melon' : 'cabbage'; 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(); if (window.PvZAudio) window.PvZAudio.playShoot();
} }
} }
@@ -191,14 +196,16 @@ window.Plant = class Plant {
this.repeaterThirdPea -= dt; this.repeaterThirdPea -= dt;
if (this.repeaterThirdPea <= 0) { if (this.repeaterThirdPea <= 0) {
const pType = this.level === 3 ? 'firepea' : 'pea'; 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(); if (window.PvZAudio) window.PvZAudio.playShoot();
} }
} else if (this.repeaterSecondPea > 0) { } else if (this.repeaterSecondPea > 0) {
this.repeaterSecondPea -= dt; this.repeaterSecondPea -= dt;
if (this.repeaterSecondPea <= 0) { if (this.repeaterSecondPea <= 0) {
const pType = this.level === 3 ? 'firepea' : 'pea'; 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(); if (window.PvZAudio) window.PvZAudio.playShoot();
} }
} else if (this.shootTimer >= this.shootInterval) { } else if (this.shootTimer >= this.shootInterval) {
@@ -206,7 +213,8 @@ window.Plant = class Plant {
if (hasZombie) { if (hasZombie) {
this.shootTimer = 0; this.shootTimer = 0;
const pType = this.level === 3 ? 'firepea' : 'pea'; 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; this.repeaterSecondPea = 0.16;
if (this.level >= 2 && Math.random() < 0.25) { if (this.level >= 2 && Math.random() < 0.25) {
this.repeaterThirdPea = 0.32; // Tier 2+ bonus 3rd pea! 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); 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) { if (hasZombie) {
this.shootTimer = 0; 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(); if (window.PvZAudio) window.PvZAudio.playShoot();
} }
} }
+55
View File
@@ -1,5 +1,20 @@
window.Projectile = class Projectile { window.Projectile = class Projectile {
constructor(x, y, row, type = 'pea', vy = 0, damage = 20, isCrit = false, targetX = 750) { 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.x = x;
this.y = y; this.y = y;
this.startX = x; this.startX = x;
@@ -37,10 +52,15 @@ window.Projectile = class Projectile {
this.freezes = type === 'freeze_snowpea'; this.freezes = type === 'freeze_snowpea';
this.pierces = type === 'fume' || type === 'plasma'; this.pierces = type === 'fume' || type === 'plasma';
this.maxPierces = type === 'plasma' ? 2 : 999; this.maxPierces = type === 'plasma' ? 2 : 999;
if (this.pierceHits) {
this.pierceHits.clear();
} else {
this.pierceHits = new Set(); this.pierceHits = new Set();
}
this.lobProgress = 0; this.lobProgress = 0;
this.toRemove = false; this.toRemove = false;
this.animTime = 0; this.animTime = 0;
this.hitCount = 0;
} }
update(dt) { update(dt) {
@@ -212,3 +232,38 @@ window.Projectile = class Projectile {
ctx.restore(); 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.wave = new window.WaveSystem();
this.combat = new window.CombatSystem(); this.combat = new window.CombatSystem();
if (window.ProjectilePool) {
this.projectilePool = new window.ProjectilePool(60);
}
// Entities // Entities
this.plants = []; this.plants = [];
@@ -462,6 +465,9 @@ window.GameEngine = class GameEngine {
this.wave.reset(); this.wave.reset();
this.combat.reset(); this.combat.reset();
if (this.projectilePool) {
this.projectilePool.releaseAll(this.projectiles);
}
this.plants = []; this.plants = [];
this.zombies = []; this.zombies = [];
this.projectiles = []; this.projectiles = [];
@@ -477,6 +483,12 @@ window.GameEngine = class GameEngine {
document.getElementById('victory-modal').classList.add('hidden'); 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) { spawnSun(x, y, value = 25, isFalling = false, targetY = null) {
let finalValue = value; let finalValue = value;
if (this.roguelike && this.currentMode === 'roguelike' && this.roguelike.hasRelic('relic_sun_resonance') && !isFalling) { 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(); if (window.PvZAudio) window.PvZAudio.playExplosion();
} else { } else {
// Rolling bowling nut // Rolling bowling nut
const ball = new window.Projectile(130, bounds.centerY, row, 'bowling', 0); const ball = this.spawnProjectile(130, bounds.centerY, row, 'bowling', 0);
this.projectiles.push(ball);
if (window.PvZAudio) window.PvZAudio.playPlant(); if (window.PvZAudio) window.PvZAudio.playPlant();
} }
this.economy.selectedCard = null; this.economy.selectedCard = null;
@@ -2868,11 +2879,18 @@ window.GameEngine = class GameEngine {
} }
this.zombies = this.zombies.filter(z => !z.toRemove); this.zombies = this.zombies.filter(z => !z.toRemove);
// 7. Projectiles // 7. Projectiles (in-place update with pooling release, eliminating GC allocations)
for (const proj of this.projectiles) { let projWriteIdx = 0;
for (let i = 0; i < this.projectiles.length; i++) {
const proj = this.projectiles[i];
proj.update(dt); 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 // 8. Combat
this.combat.update(dt, this); 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. // Falls back to localStorage if server is unavailable.
window.PvZAuth = (function () { window.PvZAuth = (function () {
const CURRENT_USER_KEY = 'pvz_current_user'; const CURRENT_USER_KEY = 'pvz_current_user';
const TOKEN_KEY = 'pvz_token';
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
const API_BASE = '/api/auth'; 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) ─── */ /* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */
function hashPassword(str) { function hashPassword(str) {
if (!str) return ''; if (!str) return '';
@@ -83,14 +100,33 @@ window.PvZAuth = (function () {
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; } try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
} }
/* ─── Non-blocking server call ─── */ /* ─── Non-blocking server call with Bearer Token Authorization ─── */
async function apiPost(endpoint, body) { async function apiPost(endpoint, body = {}) {
try { 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}`, { const resp = await fetch(`${API_BASE}/${endpoint}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify(body) 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(); const json = await resp.json();
return json; return json;
} catch (e) { } catch (e) {
@@ -146,6 +182,9 @@ window.PvZAuth = (function () {
return !u || u === 'Гость'; return !u || u === 'Гость';
}, },
getToken,
setToken,
clearToken,
getDefaultData, getDefaultData,
validateUsername, validateUsername,
validatePassword, validatePassword,
@@ -175,6 +214,9 @@ window.PvZAuth = (function () {
if (res && res.success && res.data) { if (res && res.success && res.data) {
localStorage.setItem(CURRENT_USER_KEY, cleanUser); localStorage.setItem(CURRENT_USER_KEY, cleanUser);
if (res.token) {
setToken(res.token);
}
_cachedData = mergeUserData(res.data); _cachedData = mergeUserData(res.data);
localSave(_cachedData); localSave(_cachedData);
@@ -182,7 +224,7 @@ window.PvZAuth = (function () {
window._pvzEngine.loadUserData(); window._pvzEngine.loadUserData();
} }
if (typeof callback === 'function') callback(true, _cachedData, null); 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 { } else {
const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль'; const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль';
if (typeof callback === 'function') callback(false, null, errorMsg); if (typeof callback === 'function') callback(false, null, errorMsg);
@@ -214,6 +256,9 @@ window.PvZAuth = (function () {
if (res && res.success) { if (res && res.success) {
localStorage.setItem(CURRENT_USER_KEY, cleanUser); localStorage.setItem(CURRENT_USER_KEY, cleanUser);
if (res.token) {
setToken(res.token);
}
_cachedData = mergeUserData(res.data || initData); _cachedData = mergeUserData(res.data || initData);
localSave(_cachedData); localSave(_cachedData);
@@ -221,7 +266,7 @@ window.PvZAuth = (function () {
window._pvzEngine.loadUserData(); window._pvzEngine.loadUserData();
} }
if (typeof callback === 'function') callback(true, _cachedData, null); 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 { } else {
const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации'; const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации';
if (typeof callback === 'function') callback(false, null, errorMsg); if (typeof callback === 'function') callback(false, null, errorMsg);
@@ -230,6 +275,7 @@ window.PvZAuth = (function () {
}, },
loginAsGuest() { loginAsGuest() {
clearToken();
localStorage.setItem(CURRENT_USER_KEY, 'Гость'); localStorage.setItem(CURRENT_USER_KEY, 'Гость');
_cachedData = null; _cachedData = null;
localSave(null); localSave(null);
@@ -239,9 +285,13 @@ window.PvZAuth = (function () {
logout() { logout() {
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
const user = this.getCurrentUser(); const user = this.getCurrentUser();
const token = getToken();
if (user !== 'Гость' && _cachedData) { if (user !== 'Гость' && _cachedData) {
apiPost('save', { username: user, data: _cachedData }); apiPost('save', { username: user, data: _cachedData });
} }
if (token) {
apiPost('logout', { token });
}
this.loginAsGuest(); this.loginAsGuest();
}, },
+1 -1
View File
@@ -30,7 +30,7 @@ window.BossSystem = class BossSystem {
} }
reset() { reset() {
this.isActive = false; this.isActive = true;
this.health = this.maxHealth; this.health = this.maxHealth;
this.stompTimer = 8.0; this.stompTimer = 8.0;
this.ballTimer = 14.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) // Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops)
window.CombatSystem = class CombatSystem { window.CombatSystem = class CombatSystem {
constructor() { constructor() {
this.particlePool = new window.ParticlePool(150);
this.particles = []; this.particles = [];
} }
reset() { reset() {
if (this.particlePool) {
this.particlePool.releaseAll(this.particles);
}
this.particles = []; this.particles = [];
} }
@@ -12,45 +65,50 @@ window.CombatSystem = class CombatSystem {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 80; const speed = 40 + Math.random() * 80;
this.particles.push({ this.particles.push(this.particlePool.get(
x, x,
y, y,
vx: Math.cos(angle) * speed, Math.cos(angle) * speed,
vy: Math.sin(angle) * speed, Math.sin(angle) * speed,
color, color,
size: 3 + Math.random() * 3, 3 + Math.random() * 3,
life: 0.35, 0.35
maxLife: 0.35 ));
});
} }
} }
createExplosionParticles(x, y) { createExplosionParticles(x, y) {
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242'];
for (let i = 0; i < 35; i++) { for (let i = 0; i < 35; i++) {
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const speed = 50 + Math.random() * 200; const speed = 50 + Math.random() * 200;
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242']; this.particles.push(this.particlePool.get(
this.particles.push({
x, x,
y, y,
vx: Math.cos(angle) * speed, Math.cos(angle) * speed,
vy: Math.sin(angle) * speed, Math.sin(angle) * speed,
color: colors[Math.floor(Math.random() * colors.length)], colors[Math.floor(Math.random() * colors.length)],
size: 5 + Math.random() * 8, 5 + Math.random() * 8,
life: 0.65, 0.65
maxLife: 0.65 ));
});
} }
} }
update(dt, gameState) { update(dt, gameState) {
// 1. Update Particles // 1. Update Particles in-place (eliminating GC allocations)
for (const p of this.particles) { let writeIdx = 0;
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
p.x += p.vx * dt; p.x += p.vx * dt;
p.y += p.vy * dt; p.y += p.vy * dt;
p.life -= 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 // 2. Projectiles vs Zombies
for (const proj of gameState.projectiles) { for (const proj of gameState.projectiles) {
@@ -233,16 +291,15 @@ window.CombatSystem = class CombatSystem {
// Tree of Wisdom Cheat: Daisies // Tree of Wisdom Cheat: Daisies
if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) { if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) {
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
this.particles.push({ this.particles.push(this.particlePool.get(
x: zombie.x + (Math.random() - 0.5) * 30, zombie.x + (Math.random() - 0.5) * 30,
y: zombie.y + 10 + (Math.random() - 0.5) * 16, zombie.y + 10 + (Math.random() - 0.5) * 16,
vx: 0, 0,
vy: -8, -8,
color: '#ffffff', '#ffffff',
size: 4, 4,
life: 2.5, 2.5
maxLife: 2.5 ));
});
} }
} }
+74 -16
View File
@@ -9,13 +9,32 @@ window.GridSystem = class GridSystem {
this.graves = []; this.graves = [];
this.animTime = 0; this.animTime = 0;
this.bgCanvas = null;
this.bgCtx = null;
this.bgDirty = true;
this.initBiome('day'); 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') { initBiome(biome = 'day') {
this.biome = biome; this.biome = biome;
this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5; this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5;
this.cellH = (biome === 'pool' || biome === 'fog') ? 80 : 96; this.cellH = (biome === 'pool' || biome === 'fog') ? 80 : 96;
this.invalidateBackground();
this.grid = []; this.grid = [];
for (let r = 0; r < this.rows; r++) { for (let r = 0; r < this.rows; r++) {
@@ -148,8 +167,25 @@ window.GridSystem = class GridSystem {
} }
} }
draw(ctx, hoverCell, selectedPlantType) { get theme() {
// 1. Draw Lawn Tiles or Pool 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++) { for (let r = 0; r < this.rows; r++) {
const isWater = this.isWaterCell(r); const isWater = this.isWaterCell(r);
@@ -180,18 +216,9 @@ window.GridSystem = class GridSystem {
ctx.lineTo(x + 60, y + this.cellH); ctx.lineTo(x + 60, y + this.cellH);
ctx.stroke(); ctx.stroke();
} else if (isWater) { } else if (isWater) {
// Pool Water // Pool Water Base
ctx.fillStyle = isEven ? '#0288d1' : '#039be5'; ctx.fillStyle = isEven ? '#0288d1' : '#039be5';
ctx.fillRect(x, y, this.cellW, this.cellH); 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') { } else if (this.theme === 'cyberpunk') {
// Cyberpunk Grid Tiles // Cyberpunk Grid Tiles
ctx.fillStyle = isEven ? '#120326' : '#1d063d'; ctx.fillStyle = isEven ? '#120326' : '#1d063d';
@@ -249,13 +276,44 @@ window.GridSystem = class GridSystem {
// 2b. Decorative Neighbour Fence along top edge (y: 86..108) // 2b. Decorative Neighbour Fence along top edge (y: 86..108)
this.drawNeighbourFence(ctx); 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) { for (const grave of this.graves) {
const bounds = this.getCellBounds(grave.col, grave.row); const bounds = this.getCellBounds(grave.col, grave.row);
this.drawGravestone(ctx, bounds.centerX, bounds.centerY); this.drawGravestone(ctx, bounds.centerX, bounds.centerY);
} }
// 5. Hover effect // 3. Hover effect
if (hoverCell && selectedPlantType) { if (hoverCell && selectedPlantType) {
const bounds = this.getCellBounds(hoverCell.col, hoverCell.row); const bounds = this.getCellBounds(hoverCell.col, hoverCell.row);
const isWater = this.isWaterCell(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); 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) { for (const mower of this.lawnmowers) {
if (mower.used) continue; if (mower.used) continue;
if (mower.isCleaner) { if (mower.isCleaner) {
@@ -298,7 +356,7 @@ window.GridSystem = class GridSystem {
} }
} }
// 7. Volumetric Fog Layer // 5. Volumetric Fog Layer
if (this.biome === 'fog') { if (this.biome === 'fog') {
this.drawFogOverlay(ctx); this.drawFogOverlay(ctx);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// Web Audio API Procedural Sound Synthesizer and Asset Helpers // Web Audio API Procedural Sound Synthesizer and Asset Helpers
window.PvZAudio = (function() { window.PvZAudio = window.PvZAudio || (function() {
let ctx = null; let ctx = null;
let muted = false; let muted = false;
+9
View File
@@ -115,5 +115,14 @@
"status": "новый", "status": "новый",
"timestamp": "2026-09-17 09:57", "timestamp": "2026-09-17 09:57",
"adminReply": null "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
} }
] ]
+155 -10
View File
@@ -1,13 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro) 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 sqlite3
import json import json
import os 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__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data") DATA_DIR = os.path.join(BASE_DIR, "data")
@@ -20,6 +23,33 @@ def get_connection():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn 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(): def init_db():
conn = get_connection() conn = get_connection()
cursor = conn.cursor() 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(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS profiles ( CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
@@ -81,6 +122,79 @@ def init_db():
conn.commit() conn.commit()
conn.close() 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(): def get_default_profile_dict():
return { return {
"coins": 150, "coins": 150,
@@ -133,10 +247,12 @@ def register_user(username, password_hash, initial_data=None):
cursor = conn.cursor() cursor = conn.cursor()
try: 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( cursor.execute(
"INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)", "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 user_id = cursor.lastrowid
@@ -168,9 +284,17 @@ def register_user(username, password_hash, initial_data=None):
now_str 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.commit()
conn.close() 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: except sqlite3.IntegrityError:
conn.close() conn.close()
return {"success": False, "message": "Пользователь с таким именем уже существует"} return {"success": False, "message": "Пользователь с таким именем уже существует"}
@@ -189,13 +313,27 @@ def authenticate_user(username, password_hash):
conn.close() conn.close()
return None return None
if user["password_hash"] != password_hash: if not verify_password(password_hash, user["password_hash"]):
conn.close() conn.close()
return {"success": False, "message": "Неверный пароль"} return {"success": False, "message": "Неверный пароль"}
# Update last login now = datetime.now()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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"])) 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() conn.commit()
# Fetch profile # Fetch profile
@@ -227,10 +365,17 @@ def authenticate_user(username, password_hash):
"achievements": json.loads(p["achievements"] or "{}") "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() 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() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
+158 -21
View File
@@ -3,7 +3,7 @@
Backend Server for Plants vs. Zombies (ai.qpid.pro) Backend Server for Plants vs. Zombies (ai.qpid.pro)
Zero external dependencies. Zero external dependencies.
Manages: 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) - Community Bug Reports & Feedback (/api/bugs)
""" """
@@ -11,6 +11,8 @@ import http.server
import json import json
import os import os
import sys import sys
import html
import urllib.parse
from datetime import datetime from datetime import datetime
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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 import db
PORT = 8001 PORT = 8001
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB
REPORTS_DIR = os.path.join(BASE_DIR, "reports") REPORTS_DIR = os.path.join(BASE_DIR, "reports")
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json") BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md") 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) os.makedirs(REPORTS_DIR, exist_ok=True)
db.init_db() 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 = [ DEFAULT_BUGS = [
{ {
"id": 1, "id": 1,
@@ -86,13 +107,13 @@ def sync_markdown(bugs):
for b in reversed(bugs): for b in reversed(bugs):
bid = b.get("id", 0) bid = b.get("id", 0)
ts = b.get("timestamp", "") ts = b.get("timestamp", "")
author = b.get("author", "Аноним").replace("|", "/") author = str(b.get("author", "Аноним")).replace("|", "/")
cat = b.get("category", "bug") cat = b.get("category", "bug")
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс")) cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
st = b.get("status", "новый") st = b.get("status", "новый")
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый") st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый")
text = b.get("text", "").replace("|", "/").replace("\n", " ") text = str(b.get("text", "")).replace("|", "/").replace("\n", " ")
reply = (b.get("adminReply") or "—").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(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
lines.append("") lines.append("")
@@ -100,10 +121,32 @@ def sync_markdown(bugs):
f.write("\n".join(lines)) f.write("\n".join(lines))
class FeedbackHandler(http.server.BaseHTTPRequestHandler): 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): def _set_cors(self):
origin = self.headers.get("Origin", "")
if not origin:
self.send_header("Access-Control-Allow-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-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): def do_OPTIONS(self):
self.send_response(204) self.send_response(204)
@@ -153,6 +196,18 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
path = self.path.split("?")[0] path = self.path.split("?")[0]
length = int(self.headers.get("Content-Length", 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 "{}" body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try: try:
@@ -160,46 +215,68 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8")) self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
return return
# 1a. User Login # 1a. User Login with Rate Limiting
if path in ["/api/auth/login", "/api/bugs/auth/login"]: 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() username = str(payload.get("username", "")).strip()
password_hash = str(payload.get("passwordHash", "")).strip() password_hash = str(payload.get("passwordHash", "")).strip()
if not username or not password_hash: if not username or not password_hash:
record_failed_login(client_ip)
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8"))
return return
import re import re
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
record_failed_login(client_ip)
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
return return
user = db.get_user_by_username(username) user = db.get_user_by_username(username)
if not user: if not user:
record_failed_login(client_ip)
self.send_response(404) self.send_response(404)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8"))
return return
res = db.authenticate_user(username, password_hash) res = db.authenticate_user(username, password_hash)
if not res or not res.get("success"): if not res or not res.get("success"):
record_failed_login(client_ip)
self.send_response(401) self.send_response(401)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8")) self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8"))
return return
clear_failed_logins(client_ip)
data = json.dumps(res, ensure_ascii=False).encode("utf-8") data = json.dumps(res, ensure_ascii=False).encode("utf-8")
self.send_response(200) self.send_response(200)
self._set_cors() self._set_cors()
@@ -218,6 +295,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if not username or not password_hash: if not username or not password_hash:
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8"))
return return
@@ -226,6 +304,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
return return
@@ -233,6 +312,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if len(password_hash) < 1: if len(password_hash) < 1:
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8"))
return return
@@ -240,6 +320,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]: if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]:
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8"))
return return
@@ -248,6 +329,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
if user: if user:
self.send_response(409) self.send_response(409)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8")) self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8"))
return return
@@ -262,21 +344,72 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(data) self.wfile.write(data)
return return
# 2. User Progress Profile Save & Sync # 1c. User Logout
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]: if path in ["/api/auth/logout", "/api/bugs/auth/logout"]:
username = str(payload.get("username", "")).strip() auth_header = self.headers.get("Authorization", "")
user_data = payload.get("data", {}) 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: if token:
self.send_response(400) db.delete_session(token)
self.send_response(200)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() 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": True, "message": "Сессия успешно завершена"}).encode("utf-8"))
return return
res = db.save_user_profile(username, user_data) # 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": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8"))
return
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") 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._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
@@ -284,28 +417,32 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(data) self.wfile.write(data)
return return
# 3. Bug Report Submission # 3. Bug Report Submission (Sanitized)
if path.startswith("/api/bugs") or path.startswith("/bugs"): if path.startswith("/api/bugs") or path.startswith("/bugs"):
text = str(payload.get("text", "")).strip() raw_text = str(payload.get("text", "")).strip()
if not text: if not raw_text:
self.send_response(400) self.send_response(400)
self._set_cors() self._set_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8")) self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8"))
return return
author = str(payload.get("author", "Гость")).strip() or "Гость" raw_author = str(payload.get("author", "Гость")).strip() or "Гость"
category = str(payload.get("category", "bug")).strip() category = str(payload.get("category", "bug")).strip()
if category not in ["bug", "feature", "visual", "balance"]: if category not in ["bug", "feature", "visual", "balance"]:
category = "bug" category = "bug"
clean_author = html.escape(raw_author)[:30]
clean_text = html.escape(raw_text)[:1000]
bugs = load_bugs() bugs = load_bugs()
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1 new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
new_bug = { new_bug = {
"id": new_id, "id": new_id,
"author": author[:30], "author": clean_author,
"category": category, "category": category,
"text": text[:1000], "text": clean_text,
"status": "новый", "status": "новый",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"adminReply": None "adminReply": None
+33 -4
View File
@@ -195,7 +195,10 @@ def test_auth_system():
"scheduleSave", "scheduleSave",
"apiPost", "apiPost",
"validateUsername", "validateUsername",
"validatePassword" "validatePassword",
"getToken",
"setToken",
"clearToken"
] ]
for m in auth_methods: for m in auth_methods:
if m in auth_code: if m in auth_code:
@@ -203,8 +206,10 @@ def test_auth_system():
else: else:
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}") 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", 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 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 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") 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") 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", 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 "") 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 # Test wrong password
bad_auth = pvz_db.authenticate_user("kopri", "wronghash") bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
@@ -253,10 +264,18 @@ def test_auth_system():
dup_reg = pvz_db.register_user("kopri", "c63bc483") dup_reg = pvz_db.register_user("kopri", "c63bc483")
record("SQLite DB: reject duplicate user registration", "PASS" if not dup_reg.get("success") else "FAIL") 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}) 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") 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 saved
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4") verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
verify_ok = verify and verify.get("data", {}).get("coins") == 999 verify_ok = verify and verify.get("data", {}).get("coins") == 999
@@ -277,6 +296,16 @@ def test_auth_system():
else: else:
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile") 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: except Exception as e:
record("SQLite DB tests", "FAIL", str(e)) record("SQLite DB tests", "FAIL", str(e))