fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching
This commit is contained in:
1 parent
258b1a9cb1
commit
7cd0792799
15 files changed
+1264
-105
No files matched your search
+2
-1
@@ -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: таймер заставки и состояние волны теперь корректно обновляются без раннего выхода из цикла. |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
this.pierceHits = new Set();
|
if (this.pierceHits) {
|
||||||
|
this.pierceHits.clear();
|
||||||
|
} else {
|
||||||
|
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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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": "<b>Dave</b>",
|
||||||
|
"category": "bug",
|
||||||
|
"text": "<script>alert(1)</script>",
|
||||||
|
"status": "новый",
|
||||||
|
"timestamp": "2026-09-17 13:11",
|
||||||
|
"adminReply": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+156
-11
@@ -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")
|
||||||
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
|
|
||||||
|
# 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()
|
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()
|
||||||
|
|
||||||
|
|||||||
+159
-22
@@ -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):
|
||||||
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-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.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._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": False, "message": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8"))
|
||||||
return
|
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")
|
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
@@ -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))
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user