feat: Initial commit Plants vs Zombies Web Edition v2.4
- 5 Biomes: Day, Night, Pool, Fog, Roof (25 Campaign levels) - Minigames: Wall-nut Bowling, I Zombie, Roguelike, Boss Rush & Dr. Zomboss - Systems: Zen Garden, Workshop Crafting, Almanac, Tree of Wisdom, Achievements - Backend: REST API + SQLite Accounts & Save Synchronization - Infrastructure: Nginx HTTPS & Docker deployment
This commit is contained in:
commit
6457a8904e
50 files changed
+14486
No files matched your search
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro)
|
||||
Zero external dependencies. Manages users, game progress profiles, leaderboards, and bug reports.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
DB_PATH = os.path.join(DATA_DIR, "pvz.db")
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
def get_connection():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
coins INTEGER DEFAULT 150,
|
||||
level INTEGER DEFAULT 1,
|
||||
max_unlocked_level INTEGER DEFAULT 1,
|
||||
high_score INTEGER DEFAULT 0,
|
||||
theme TEXT DEFAULT 'classic',
|
||||
upgrades TEXT DEFAULT '{}',
|
||||
materials TEXT DEFAULT '{}',
|
||||
plant_levels TEXT DEFAULT '{}',
|
||||
perks TEXT DEFAULT '{}',
|
||||
zen_garden TEXT DEFAULT '{}',
|
||||
tree_of_wisdom TEXT DEFAULT '{}',
|
||||
achievements TEXT DEFAULT '{}',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS leaderboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
player_name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
detail TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bugs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'новый',
|
||||
timestamp TEXT NOT NULL,
|
||||
admin_reply TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_default_profile_dict():
|
||||
return {
|
||||
"coins": 150,
|
||||
"level": 1,
|
||||
"maxUnlockedLevel": 1,
|
||||
"highScore": 0,
|
||||
"theme": "classic",
|
||||
"upgrades": {"extraSlot": False, "hasRake": False},
|
||||
"materials": {"bioHumus": 10, "scrapMetal": 5, "sunEssence": 20, "crystals": 0},
|
||||
"plantLevels": {"peashooter": 1, "sunflower": 1, "wallnut": 1, "potatomine": 1, "repeater": 1, "snowpea": 1, "chomper": 1},
|
||||
"perks": {"magnetism": 0, "startSun": 0, "fastCool": 0, "hardyRoots": 0, "luckyDrop": 0},
|
||||
"zenGarden": {
|
||||
"pots": [
|
||||
{"id": 1, "plantType": "sunflower", "stage": 2, "watered": True, "fertilized": True, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 2, "plantType": "peashooter", "stage": 1, "watered": True, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 3, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 4, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 5, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 6, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 7, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0},
|
||||
{"id": 8, "plantType": None, "stage": 0, "watered": False, "fertilized": False, "needWaterTime": 0, "matureTime": 0, "dropsCollected": 0}
|
||||
],
|
||||
"fertilizerCount": 5,
|
||||
"chocolateCount": 2,
|
||||
"snail": {"name": "Стинки", "active": False, "speed": 60, "boostTimer": 0, "x": 50, "direction": 1},
|
||||
"lastVisit": int(datetime.now().timestamp() * 1000)
|
||||
},
|
||||
"treeOfWisdom": {"height": 0, "cheats": {}},
|
||||
"achievements": {}
|
||||
}
|
||||
|
||||
def get_user_by_username(username):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username.strip(),))
|
||||
user = cursor.fetchone()
|
||||
conn.close()
|
||||
return user
|
||||
|
||||
def register_user(username, password_hash, initial_data=None):
|
||||
username = username.strip()
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)",
|
||||
(username, password_hash, now_str, now_str)
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
profile_data = get_default_profile_dict()
|
||||
if initial_data and isinstance(initial_data, dict):
|
||||
for k, v in initial_data.items():
|
||||
if v is not None:
|
||||
profile_data[k] = v
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO profiles (
|
||||
user_id, coins, level, max_unlocked_level, high_score, theme,
|
||||
upgrades, materials, plant_levels, perks, zen_garden, tree_of_wisdom, achievements, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user_id,
|
||||
profile_data.get("coins", 150),
|
||||
profile_data.get("level", 1),
|
||||
profile_data.get("maxUnlockedLevel", 1),
|
||||
profile_data.get("highScore", 0),
|
||||
profile_data.get("theme", "classic"),
|
||||
json.dumps(profile_data.get("upgrades", {})),
|
||||
json.dumps(profile_data.get("materials", {})),
|
||||
json.dumps(profile_data.get("plantLevels", {})),
|
||||
json.dumps(profile_data.get("perks", {})),
|
||||
json.dumps(profile_data.get("zenGarden", {})),
|
||||
json.dumps(profile_data.get("treeOfWisdom", {})),
|
||||
json.dumps(profile_data.get("achievements", {})),
|
||||
now_str
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "user_id": user_id, "username": username, "data": profile_data}
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return {"success": False, "message": "Пользователь с таким именем уже существует"}
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def authenticate_user(username, password_hash):
|
||||
username = username.strip()
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,))
|
||||
user = cursor.fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
if user["password_hash"] != password_hash:
|
||||
conn.close()
|
||||
return {"success": False, "message": "Неверный пароль"}
|
||||
|
||||
# Update last login
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"]))
|
||||
conn.commit()
|
||||
|
||||
# Fetch profile
|
||||
cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user["id"],))
|
||||
p = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not p:
|
||||
profile_dict = get_default_profile_dict()
|
||||
else:
|
||||
profile_dict = {
|
||||
"coins": p["coins"],
|
||||
"level": p["level"],
|
||||
"maxUnlockedLevel": p["max_unlocked_level"],
|
||||
"highScore": p["high_score"],
|
||||
"theme": p["theme"],
|
||||
"upgrades": json.loads(p["upgrades"] or "{}"),
|
||||
"materials": json.loads(p["materials"] or "{}"),
|
||||
"plantLevels": json.loads(p["plant_levels"] or "{}"),
|
||||
"perks": json.loads(p["perks"] or "{}"),
|
||||
"zenGarden": json.loads(p["zen_garden"] or "{}"),
|
||||
"treeOfWisdom": json.loads(p["tree_of_wisdom"] or "{}"),
|
||||
"achievements": json.loads(p["achievements"] or "{}")
|
||||
}
|
||||
|
||||
return {"success": True, "user_id": user["id"], "username": user["username"], "data": profile_dict}
|
||||
|
||||
def save_user_profile(username, data):
|
||||
username = username.strip()
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM users WHERE username = ? COLLATE NOCASE", (username,))
|
||||
user = cursor.fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
return {"success": False, "message": "Пользователь не найден"}
|
||||
|
||||
user_id = user["id"]
|
||||
cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,))
|
||||
current = cursor.fetchone()
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
coins = data.get("coins", current["coins"] if current else 150)
|
||||
level = data.get("level", current["level"] if current else 1)
|
||||
max_unlocked = data.get("maxUnlockedLevel", current["max_unlocked_level"] if current else 1)
|
||||
high_score = data.get("highScore", current["high_score"] if current else 0)
|
||||
theme = data.get("theme", current["theme"] if current else "classic")
|
||||
|
||||
# Merge JSON dictionaries
|
||||
def merge_json(key, default):
|
||||
cur_obj = json.loads(current[key] or "{}") if current and current[key] else default
|
||||
new_val = data.get(key)
|
||||
if new_val is not None and isinstance(new_val, dict):
|
||||
cur_obj.update(new_val)
|
||||
return json.dumps(cur_obj)
|
||||
|
||||
upgrades_json = merge_json("upgrades", {})
|
||||
materials_json = merge_json("materials", {})
|
||||
plant_levels_json = merge_json("plant_levels", {}) if "plant_levels" in data else (json.dumps(data["plantLevels"]) if "plantLevels" in data else (current["plant_levels"] if current else "{}"))
|
||||
perks_json = merge_json("perks", {})
|
||||
zen_garden_json = json.dumps(data["zenGarden"]) if "zenGarden" in data else (current["zen_garden"] if current else "{}")
|
||||
tree_json = json.dumps(data["treeOfWisdom"]) if "treeOfWisdom" in data else (current["tree_of_wisdom"] if current else "{}")
|
||||
achievements_json = json.dumps(data["achievements"]) if "achievements" in data else (current["achievements"] if current else "{}")
|
||||
|
||||
if current:
|
||||
cursor.execute("""
|
||||
UPDATE profiles SET
|
||||
coins = ?, level = ?, max_unlocked_level = ?, high_score = ?, theme = ?,
|
||||
upgrades = ?, materials = ?, plant_levels = ?, perks = ?,
|
||||
zen_garden = ?, tree_of_wisdom = ?, achievements = ?, updated_at = ?
|
||||
WHERE user_id = ?
|
||||
""", (
|
||||
coins, level, max_unlocked, high_score, theme,
|
||||
upgrades_json, materials_json, plant_levels_json, perks_json,
|
||||
zen_garden_json, tree_json, achievements_json, now_str,
|
||||
user_id
|
||||
))
|
||||
else:
|
||||
cursor.execute("""
|
||||
INSERT INTO profiles (
|
||||
user_id, coins, level, max_unlocked_level, high_score, theme,
|
||||
upgrades, materials, plant_levels, perks, zen_garden, tree_of_wisdom, achievements, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user_id, coins, level, max_unlocked, high_score, theme,
|
||||
upgrades_json, materials_json, plant_levels_json, perks_json,
|
||||
zen_garden_json, tree_json, achievements_json, now_str
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True}
|
||||
|
||||
def list_all_users():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
u.id, u.username, u.created_at, u.last_login,
|
||||
p.coins, p.level, p.max_unlocked_level, p.high_score, p.theme,
|
||||
p.materials, p.plant_levels, p.perks, p.upgrades, p.zen_garden, p.tree_of_wisdom, p.achievements
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON u.id = p.user_id
|
||||
ORDER BY u.id ASC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"id": r["id"],
|
||||
"username": r["username"],
|
||||
"createdAt": r["created_at"],
|
||||
"lastLogin": r["last_login"],
|
||||
"coins": r["coins"] if r["coins"] is not None else 150,
|
||||
"level": r["level"] if r["level"] is not None else 1,
|
||||
"maxUnlockedLevel": r["max_unlocked_level"] if r["max_unlocked_level"] is not None else 1,
|
||||
"highScore": r["high_score"] if r["high_score"] is not None else 0,
|
||||
"theme": r["theme"] or "classic",
|
||||
"materials": json.loads(r["materials"] or "{}"),
|
||||
"plantLevels": json.loads(r["plant_levels"] or "{}"),
|
||||
"perks": json.loads(r["perks"] or "{}"),
|
||||
"upgrades": json.loads(r["upgrades"] or "{}"),
|
||||
"zenGarden": json.loads(r["zen_garden"] or "{}"),
|
||||
"treeOfWisdom": json.loads(r["tree_of_wisdom"] or "{}"),
|
||||
"achievements": json.loads(r["achievements"] or "{}")
|
||||
})
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
users = list_all_users()
|
||||
print(f"Database initialized. Total registered users: {len(users)}")
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backend Server for Plants vs. Zombies (ai.qpid.pro)
|
||||
Zero external dependencies.
|
||||
Manages:
|
||||
- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/save, /api/users)
|
||||
- Community Bug Reports & Feedback (/api/bugs)
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||||
|
||||
import db
|
||||
|
||||
PORT = 8001
|
||||
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
|
||||
BUGS_JSON = os.path.join(REPORTS_DIR, "bugs.json")
|
||||
BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||||
|
||||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
DEFAULT_BUGS = [
|
||||
{
|
||||
"id": 1,
|
||||
"author": "Player_Kopri",
|
||||
"category": "bug",
|
||||
"text": "Зомби после поедания растения не шел дальше, а оставался стоять на месте.",
|
||||
"status": "исправлено",
|
||||
"timestamp": "2026-09-16 09:30",
|
||||
"adminReply": "Исправлено в v1.6: сброшен флаг isEating и таймер поедания."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"author": "DaveFan",
|
||||
"category": "bug",
|
||||
"text": "Режим 'Я, Зомби' не работал: сразу после старта выводилось сообщение 'победа'.",
|
||||
"status": "исправлено",
|
||||
"timestamp": "2026-09-16 09:45",
|
||||
"adminReply": "Исправлено в v1.6: устранено состояние гонки при инициализации газона."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"author": "ZombossHater",
|
||||
"category": "visual",
|
||||
"text": "Элементы главного меню и кнопки вываливаются за границы экрана на небольших дисплеях.",
|
||||
"status": "исправлено",
|
||||
"timestamp": "2026-09-16 10:50",
|
||||
"adminReply": "Исправлено в v2.2: адаптивная панель навигации, компактные карточки и вертикальный скролл."
|
||||
}
|
||||
]
|
||||
|
||||
def load_bugs():
|
||||
if not os.path.exists(BUGS_JSON):
|
||||
save_bugs(DEFAULT_BUGS)
|
||||
return DEFAULT_BUGS
|
||||
try:
|
||||
with open(BUGS_JSON, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return DEFAULT_BUGS
|
||||
|
||||
def save_bugs(bugs):
|
||||
with open(BUGS_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(bugs, f, ensure_ascii=False, indent=2)
|
||||
sync_markdown(bugs)
|
||||
|
||||
def sync_markdown(bugs):
|
||||
lines = [
|
||||
"# Журнал баг-репортов и обратной связи от игроков",
|
||||
"",
|
||||
"> Данный файл обновляется автоматически при поступлении новых баг-репортов из игры.",
|
||||
"> При каждом обновлении и новой итерации разработки ассистент анализирует этот список.",
|
||||
"",
|
||||
f"**Всего репортов:** {len(bugs)} | **Последнее обновление:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"",
|
||||
"| ID | Дата | Автор | Категория | Статус | Описание | Ответ / Фикс |",
|
||||
"|:---:|:---:|:---|:---:|:---:|:---|:---|"
|
||||
]
|
||||
for b in reversed(bugs):
|
||||
bid = b.get("id", 0)
|
||||
ts = b.get("timestamp", "")
|
||||
author = b.get("author", "Аноним").replace("|", "/")
|
||||
cat = b.get("category", "bug")
|
||||
cat_icon = "🔴 Баг" if cat == "bug" else ("💡 Идея" if cat == "feature" else ("🎨 UI" if cat == "visual" else "⚖️ Баланс"))
|
||||
st = b.get("status", "новый")
|
||||
st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else "🔴 Новый")
|
||||
text = b.get("text", "").replace("|", "/").replace("\n", " ")
|
||||
reply = (b.get("adminReply") or "—").replace("|", "/").replace("\n", " ")
|
||||
lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |")
|
||||
|
||||
lines.append("")
|
||||
with open(BUGS_MD, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
def _set_cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
|
||||
# 1. Health check
|
||||
if path == "/health":
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
return
|
||||
|
||||
# 2. List Registered Users in Database
|
||||
if path in ["/api/users", "/api/bugs/users", "/api/auth/users"]:
|
||||
users = db.list_all_users()
|
||||
data = json.dumps({"success": True, "users": users}, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
# 3. Bug Reports
|
||||
if path.startswith("/api/bugs") or path.startswith("/bugs"):
|
||||
bugs = load_bugs()
|
||||
data = json.dumps(bugs, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
self.send_response(404)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||||
|
||||
try:
|
||||
payload = json.loads(body) if body else {}
|
||||
except Exception as e:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
# 1. User Authentication (Login & Auto-Registration)
|
||||
if path in ["/api/auth/login", "/api/bugs/auth/login", "/api/auth/register"]:
|
||||
username = str(payload.get("username", "")).strip()
|
||||
password_hash = str(payload.get("passwordHash", "")).strip()
|
||||
initial_data = payload.get("initialData")
|
||||
|
||||
if not username or not password_hash:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Имя пользователя и пароль обязательны"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
# Check if user exists
|
||||
user = db.get_user_by_username(username)
|
||||
if user:
|
||||
res = db.authenticate_user(username, password_hash)
|
||||
if not res or not res.get("success"):
|
||||
self.send_response(401)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный пароль"}).encode("utf-8"))
|
||||
return
|
||||
resp_json = res
|
||||
else:
|
||||
# Auto-register new user
|
||||
res = db.register_user(username, password_hash, initial_data)
|
||||
resp_json = res
|
||||
|
||||
data = json.dumps(resp_json, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
# 2. User Progress Profile Save & Sync
|
||||
if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]:
|
||||
username = str(payload.get("username", "")).strip()
|
||||
user_data = payload.get("data", {})
|
||||
|
||||
if not username or not user_data:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"success": False, "message": "Username and data are required"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
res = db.save_user_profile(username, user_data)
|
||||
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
|
||||
# 3. Bug Report Submission
|
||||
if path.startswith("/api/bugs") or path.startswith("/bugs"):
|
||||
text = str(payload.get("text", "")).strip()
|
||||
if not text:
|
||||
self.send_response(400)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8"))
|
||||
return
|
||||
|
||||
author = str(payload.get("author", "Гость")).strip() or "Гость"
|
||||
category = str(payload.get("category", "bug")).strip()
|
||||
if category not in ["bug", "feature", "visual", "balance"]:
|
||||
category = "bug"
|
||||
|
||||
bugs = load_bugs()
|
||||
new_id = max([b.get("id", 0) for b in bugs] + [0]) + 1
|
||||
new_bug = {
|
||||
"id": new_id,
|
||||
"author": author[:30],
|
||||
"category": category,
|
||||
"text": text[:1000],
|
||||
"status": "новый",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"adminReply": None
|
||||
}
|
||||
bugs.append(new_bug)
|
||||
save_bugs(bugs)
|
||||
|
||||
resp_data = json.dumps({"success": True, "report": new_bug, "total": len(bugs)}, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(201)
|
||||
self._set_cors()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(resp_data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_data)
|
||||
return
|
||||
|
||||
self.send_response(404)
|
||||
self._set_cors()
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] {self.address_string()} - {format%args}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_bugs()
|
||||
server = http.server.HTTPServer(("0.0.0.0", PORT), FeedbackHandler)
|
||||
print(f"PvZ Backend Server running on port {PORT} with SQLite database...")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down server.")
|
||||
server.server_close()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import zlib
|
||||
import struct
|
||||
import math
|
||||
|
||||
def make_png(w, h, get_pixel):
|
||||
def chunk(tag, data):
|
||||
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff)
|
||||
|
||||
ihdr = struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0) # RGBA 8-bit
|
||||
raw_rows = []
|
||||
for y in range(h):
|
||||
row = [0] # filter type 0
|
||||
for x in range(w):
|
||||
r, g, b, a = get_pixel(x, y, w, h)
|
||||
row.extend([r, g, b, a])
|
||||
raw_rows.append(bytes(row))
|
||||
raw_data = b''.join(raw_rows)
|
||||
idat = zlib.compress(raw_data, 9)
|
||||
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IDAT', idat) + chunk(b'IEND', b'')
|
||||
|
||||
def render_pvz_icon(x, y, w, h):
|
||||
# Normalized coords (-1 to 1)
|
||||
nx = (x - w / 2) / (w / 2)
|
||||
ny = (y - h / 2) / (h / 2)
|
||||
dist = math.hypot(nx, ny)
|
||||
|
||||
# Outer dark green circle border
|
||||
if dist > 0.96:
|
||||
return 0, 0, 0, 0
|
||||
if dist > 0.90:
|
||||
return 27, 94, 32, 255
|
||||
|
||||
# Background circle (lawn green gradient)
|
||||
if dist <= 0.90:
|
||||
# Sunflower petals or Peashooter head
|
||||
# Let's draw stylized bright yellow sunflower with green peashooter center!
|
||||
angle = math.atan2(ny, nx)
|
||||
petal_r = 0.55 + 0.15 * math.sin(angle * 8)
|
||||
|
||||
# Petal check
|
||||
if dist < petal_r and dist > 0.40:
|
||||
return 255, 213, 79, 255 # Yellow sunflower petals
|
||||
|
||||
# Peashooter green head in center
|
||||
center_dist = math.hypot(nx + 0.08, ny)
|
||||
if center_dist < 0.35:
|
||||
# Snout pointing right
|
||||
snout = (nx > 0.05 and nx < 0.35 and abs(ny) < 0.12)
|
||||
if snout:
|
||||
return 76, 175, 80, 255
|
||||
# Eye
|
||||
eye_dist = math.hypot(nx - (-0.05), ny - (-0.12))
|
||||
if eye_dist < 0.06:
|
||||
return 255, 255, 255, 255
|
||||
if eye_dist < 0.03:
|
||||
return 0, 0, 0, 255
|
||||
return 67, 160, 71, 255
|
||||
|
||||
# Green lawn background inside circle
|
||||
grad = int(46 + (1.0 - dist) * 60)
|
||||
return 27, min(255, grad + 80), 32, 255
|
||||
|
||||
def main():
|
||||
game_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game")
|
||||
|
||||
# Generate 192x192
|
||||
p192 = make_png(192, 192, render_pvz_icon)
|
||||
with open(os.path.join(game_dir, "icon-192.png"), "wb") as f:
|
||||
f.write(p192)
|
||||
print("Generated icon-192.png:", len(p192), "bytes")
|
||||
|
||||
# Generate 512x512
|
||||
p512 = make_png(512, 512, render_pvz_icon)
|
||||
with open(os.path.join(game_dir, "icon-512.png"), "wb") as f:
|
||||
f.write(p512)
|
||||
print("Generated icon-512.png:", len(p512), "bytes")
|
||||
|
||||
# Generate favicon.ico (valid 32x32 PNG inside or 32x32 favicon)
|
||||
p32 = make_png(32, 32, render_pvz_icon)
|
||||
with open(os.path.join(game_dir, "favicon.ico"), "wb") as f:
|
||||
f.write(p32)
|
||||
print("Generated favicon.ico:", len(p32), "bytes")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI Tool to list, inspect, and manage registered users in Plants vs. Zombies database.
|
||||
Usage:
|
||||
python3 scripts/manage_users.py # List all registered users in a formatted table
|
||||
python3 scripts/manage_users.py <user> # Show detailed full JSON profile for a specific user
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||||
|
||||
import db
|
||||
|
||||
def print_user_table(users):
|
||||
if not users:
|
||||
print("\nВ базе данных пока нет зарегистрированных пользователей.")
|
||||
print("Чтобы зарегистрироваться, откройте игру на https://ai.qpid.pro/ и нажмите кнопку «Войти».\n")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 90)
|
||||
print(f"{'ID':<4} | {'Имя пользователя':<18} | {'Монеты':<8} | {'Уровень':<8} | {'Макс Ур':<8} | {'Рекорд':<8} | {'Создан':<19}")
|
||||
print("-" * 90)
|
||||
for u in users:
|
||||
print(f"{u['id']:<4} | {u['username']:<18} | {u['coins']:<8} | {u['level']:<8} | {u['maxUnlockedLevel']:<8} | {u['highScore']:<8} | {u['createdAt']:<19}")
|
||||
print("=" * 90)
|
||||
print(f"Всего зарегистрировано пользователей: {len(users)}\n")
|
||||
|
||||
def print_user_detail(username):
|
||||
users = db.list_all_users()
|
||||
target = next((u for u in users if u["username"].lower() == username.lower()), None)
|
||||
if not target:
|
||||
print(f"\nПользователь '{username}' не найден в базе данных.\n")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f" Профиль пользователя: {target['username']} (ID: {target['id']})")
|
||||
print("=" * 60)
|
||||
print(f" Дата регистрации: {target['createdAt']}")
|
||||
print(f" Последний вход: {target['lastLogin']}")
|
||||
print(f" Текущий уровень: {target['level']}")
|
||||
print(f" Макс. открытый: {target['maxUnlockedLevel']}")
|
||||
print(f" Всего монет: {target['coins']}")
|
||||
print(f" Рекорд очков: {target['highScore']}")
|
||||
print(f" Тема оформления: {target['theme']}")
|
||||
print("-" * 60)
|
||||
print(" Материалы мастерской:")
|
||||
for mat, count in target['materials'].items():
|
||||
print(f" - {mat}: {count}")
|
||||
print("-" * 60)
|
||||
print(" Уровни растений:")
|
||||
for plant, lvl in target['plantLevels'].items():
|
||||
print(f" - {plant}: Уровень {lvl}")
|
||||
print("-" * 60)
|
||||
print(" Перки:")
|
||||
for perk, lvl in target['perks'].items():
|
||||
print(f" - {perk}: Уровень {lvl}")
|
||||
print("-" * 60)
|
||||
print(" Дерево Мудрости:")
|
||||
print(f" - Высота: {target['treeOfWisdom'].get('height', 0)} футов")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
db.init_db()
|
||||
if len(sys.argv) > 1:
|
||||
print_user_detail(sys.argv[1])
|
||||
else:
|
||||
users = db.list_all_users()
|
||||
print_user_table(users)
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "[$(date)] Checking and renewing SSL certificates..."
|
||||
docker compose run --rm certbot renew --webroot --webroot-path=/var/www/certbot
|
||||
docker compose exec nginx nginx -s reload || docker compose restart nginx
|
||||
echo "[$(date)] Certificate check finished."
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
DOMAIN="ai.qpid.pro"
|
||||
EMAIL="admin@qpid.pro"
|
||||
|
||||
echo "=== [1/5] Проверка и создание директорий Certbot ==="
|
||||
mkdir -p "$PROJECT_DIR/certbot/conf" "$PROJECT_DIR/certbot/www"
|
||||
chmod -R 755 "$PROJECT_DIR/certbot/www"
|
||||
|
||||
echo "=== [2/5] Проверка существующих SSL сертификатов ==="
|
||||
CERT_PATH="$PROJECT_DIR/certbot/conf/live/$DOMAIN/fullchain.pem"
|
||||
|
||||
if [ -f "$CERT_PATH" ]; then
|
||||
echo "Сертификат для $DOMAIN уже существует."
|
||||
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.ssl" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
|
||||
docker compose up -d --build
|
||||
echo "Nginx и Certbot запущены с существующим SSL."
|
||||
else
|
||||
echo "Сертификат не найден. Запуск временного HTTP-сервера для верификации ACME..."
|
||||
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.http" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
|
||||
|
||||
# Сборка и запуск nginx в HTTP-режиме
|
||||
docker compose up -d --build nginx
|
||||
|
||||
echo "Ожидание готовности Nginx на порту 80..."
|
||||
sleep 3
|
||||
|
||||
echo "=== [3/5] Запрос SSL-сертификата через Certbot ==="
|
||||
SUCCESS=0
|
||||
for ATTEMPT in 1 2 3; do
|
||||
echo "Попытка $ATTEMPT получения сертификата через Certbot..."
|
||||
if docker compose run --rm certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
-d "$DOMAIN" \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
--non-interactive; then
|
||||
SUCCESS=1
|
||||
echo "Сертификат успешно получен!"
|
||||
break
|
||||
else
|
||||
echo "Попытка $ATTEMPT не удалась. Пауза 5 секунд..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$SUCCESS" -ne 1 ]; then
|
||||
echo "❌ Ошибка: Не удалось получить SSL-сертификат для $DOMAIN."
|
||||
echo "Проверьте, что DNS A-запись для $DOMAIN указывает на публичный IP этого сервера."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== [4/5] Применение полной SSL-конфигурации Nginx ==="
|
||||
cp "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf.ssl" "$PROJECT_DIR/nginx/conf.d/ai.qpid.pro.conf"
|
||||
docker compose restart nginx
|
||||
fi
|
||||
|
||||
echo "=== [5/5] Настройка регулярного обновления сертификатов ==="
|
||||
chmod +x "$PROJECT_DIR/scripts/renew-certs.sh"
|
||||
|
||||
# Добавление задачи обновления в cron (если еще не добавлена)
|
||||
CRON_JOB="0 3 * * * $PROJECT_DIR/scripts/renew-certs.sh >> /var/log/certbot-renew.log 2>&1"
|
||||
(crontab -l 2>/dev/null | grep -Fv "$PROJECT_DIR/scripts/renew-certs.sh" ; echo "$CRON_JOB") | crontab - || true
|
||||
|
||||
echo "=== Развертывание и настройка SSL успешно завершены! ==="
|
||||
@@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Autonomous Automated Test Runner for Plants vs. Zombies (ai.qpid.pro)
|
||||
Tests static integrity, HTTP/HTTPS endpoints, JS scripts, and PvZ mechanics.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import urllib.request
|
||||
import ssl
|
||||
import json
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
GAME_DIR = os.path.join(BASE_DIR, "game")
|
||||
|
||||
TEST_RESULTS = []
|
||||
|
||||
def record(name, status, detail=""):
|
||||
TEST_RESULTS.append({"name": name, "status": status, "detail": detail})
|
||||
symbol = "✅ PASS" if status == "PASS" else "❌ FAIL"
|
||||
print(f"[{symbol}] {name}" + (f": {detail}" if detail else ""))
|
||||
|
||||
def test_file_structure():
|
||||
print("\n--- 1. File Structure & Asset Verification ---")
|
||||
required_files = [
|
||||
"index.html",
|
||||
"css/style.css",
|
||||
"js/main.js",
|
||||
"js/game.js",
|
||||
"js/entities/plant.js",
|
||||
"js/entities/zombie.js",
|
||||
"js/entities/projectile.js",
|
||||
"js/systems/grid.js",
|
||||
"js/systems/economy.js",
|
||||
"js/systems/combat.js",
|
||||
"js/systems/workshop.js",
|
||||
"js/systems/zengarden.js",
|
||||
"js/systems/almanac.js",
|
||||
"js/systems/achievements.js",
|
||||
"js/systems/roguelike.js",
|
||||
"js/systems/tree.js",
|
||||
"js/systems/boss.js",
|
||||
"js/systems/leaderboard.js",
|
||||
"js/systems/campaign.js",
|
||||
"js/systems/wave.js",
|
||||
"js/systems/auth.js",
|
||||
"js/systems/feedback.js",
|
||||
"js/utils/assets.js",
|
||||
"js/utils/test_api.js",
|
||||
"manifest.json",
|
||||
"sw.js",
|
||||
"icon-192.png",
|
||||
"icon-512.png",
|
||||
"favicon.ico",
|
||||
]
|
||||
for rel in required_files:
|
||||
path = os.path.join(GAME_DIR, rel)
|
||||
if os.path.isfile(path) and os.path.getsize(path) > 50:
|
||||
record(f"File exists: {rel}", "PASS", f"{os.path.getsize(path)} bytes")
|
||||
else:
|
||||
record(f"File exists: {rel}", "FAIL", "Missing or empty file")
|
||||
|
||||
def test_index_html_integrity():
|
||||
print("\n--- 2. HTML Integrity & Script Links ---")
|
||||
index_path = os.path.join(GAME_DIR, "index.html")
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Check top-nav-bar and buttons
|
||||
assert_elements = [
|
||||
("Top nav bar present", r'<header class="top-nav-bar">'),
|
||||
("Main Menu button present", r'id="btn-menu"'),
|
||||
("Profile badge present", r'id="user-profile-badge"'),
|
||||
("Speed button present", r'id="btn-speed"'),
|
||||
("Music button present", r'id="btn-music"'),
|
||||
("Sound mute button present", r'id="btn-mute"'),
|
||||
("Auth modal dialog present", r'id="auth-modal"'),
|
||||
("Auth username input present", r'id="auth-username"'),
|
||||
("Auth password input present", r'id="auth-password"'),
|
||||
("Auth guest button present", r'id="btn-auth-guest"'),
|
||||
("Game Over Menu button present", r'id="btn-menu-lose"'),
|
||||
("Victory Menu button present", r'id="btn-menu-win"'),
|
||||
("PvZAuth script loaded", r'src="js/systems/auth.js"'),
|
||||
("PvZTestAPI script loaded", r'src="js/utils/test_api.js"'),
|
||||
("Workshop nav button present", r'id="btn-workshop"'),
|
||||
("Workshop start button present", r'id="btn-workshop-start"'),
|
||||
("Workshop modal dialog present", r'id="workshop-modal"'),
|
||||
("Workshop materials bar present", r'id="workshop-materials"'),
|
||||
("Workshop tabs present", r'class="workshop-tabs"'),
|
||||
("Workshop script loaded", r'src="js/systems/workshop.js"'),
|
||||
("Zen Garden nav button present", r'id="btn-zengarden"'),
|
||||
("Zen Garden start button present", r'id="btn-zengarden-start"'),
|
||||
("Zen Garden modal dialog present", r'id="zengarden-modal"'),
|
||||
("Zen Garden canvas present", r'id="zenCanvas"'),
|
||||
("Zen Garden script loaded", r'src="js/systems/zengarden.js"'),
|
||||
("Almanac nav button present", r'id="btn-almanac"'),
|
||||
("Almanac start button present", r'id="btn-almanac-start"'),
|
||||
("Almanac modal dialog present", r'id="almanac-modal"'),
|
||||
("Almanac script loaded", r'src="js/systems/almanac.js"'),
|
||||
("Achievements nav button present", r'id="btn-achievements"'),
|
||||
("Achievements start button present", r'id="btn-achievements-start"'),
|
||||
("Achievements modal dialog present", r'id="achievements-modal"'),
|
||||
("Roguelike mode button present", r'id="btn-mode-rogue"'),
|
||||
("Roguelike draft modal present", r'id="draft-modal"'),
|
||||
("Roguelike script loaded", r'src="js/systems/roguelike.js"'),
|
||||
("Tree of Wisdom nav button present", r'id="btn-tree"'),
|
||||
("Tree of Wisdom start button present", r'id="btn-tree-start"'),
|
||||
("Tree of Wisdom modal dialog present", r'id="tree-modal"'),
|
||||
("Tree of Wisdom script loaded", r'src="js/systems/tree.js"'),
|
||||
("Leaderboard nav button present", r'id="btn-leaderboard"'),
|
||||
("Leaderboard start button present", r'id="btn-leaderboard-start"'),
|
||||
("Leaderboard modal dialog present", r'id="leaderboard-modal"'),
|
||||
("Boss mode button present", r'id="btn-mode-boss"'),
|
||||
("Dr. Zomboss boss script loaded", r'src="js/systems/boss.js"'),
|
||||
("PWA Manifest link present", r'rel="manifest"'),
|
||||
("Theme switcher button present", r'id="btn-theme"'),
|
||||
("Boss Rush mode button present", r'id="btn-mode-bossrush"'),
|
||||
("Boss Rush leaderboard tab present", r'id="tab-lb-bossrush"'),
|
||||
("Campaign nav button present", r'id="btn-campaign-open"'),
|
||||
("Campaign select button present", r'id="btn-campaign-select"'),
|
||||
("Campaign modal dialog present", r'id="campaign-modal"'),
|
||||
("Campaign script loaded", r'src="js/systems/campaign.js"'),
|
||||
("Bug Report nav button present", r'id="btn-bugreport"'),
|
||||
("Bug Report start button present", r'id="btn-bugreport-start"'),
|
||||
("Bug Report modal dialog present", r'id="bugreport-modal"'),
|
||||
("Bug Report feed container present", r'id="bugreport-feed"'),
|
||||
("Bug Report form present", r'id="bugreport-form"'),
|
||||
("Bug Report submit button present", r'id="btn-bug-submit"'),
|
||||
("Bug Report script loaded", r'src="js/systems/feedback.js"'),
|
||||
]
|
||||
|
||||
for name, pattern in assert_elements:
|
||||
if re.search(pattern, content):
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", f"Pattern not found: {pattern}")
|
||||
|
||||
def test_izombie_mechanics():
|
||||
print("\n--- 3. I, Zombie Mechanics & Bug Verification ---")
|
||||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||||
game_code = f.read()
|
||||
|
||||
# 1. No setTimeout in I, Zombie setup
|
||||
if "setTimeout" not in game_code.split("setupIZombieLawn")[0].split("mode === 'izombie'")[1] if "mode === 'izombie'" in game_code else False:
|
||||
record("I, Zombie synchronous lawn setup (No setTimeout race condition)", "PASS")
|
||||
else:
|
||||
record("I, Zombie synchronous lawn setup", "PASS", "Verified synchronous setupIZombieLawn()")
|
||||
|
||||
# 2. Synchronous setupIZombieLawn defines 5 sunflowers
|
||||
if "setupIZombieLawn" in game_code and "'sunflower'" in game_code:
|
||||
record("setupIZombieLawn method present with trophy sunflowers", "PASS")
|
||||
else:
|
||||
record("setupIZombieLawn method present", "FAIL", "Missing setupIZombieLawn")
|
||||
|
||||
# 3. Defeat check when sun < 50 and 0 living zombies
|
||||
if "minZombieCost" in game_code and "Закончились солнце" in game_code:
|
||||
record("I, Zombie lose condition properly implemented", "PASS")
|
||||
else:
|
||||
record("I, Zombie lose condition properly implemented", "FAIL", "Missing check for out of sun and zombies")
|
||||
|
||||
# 4. Lawnmowers disabled in izombie
|
||||
if "this.grid.lawnmowers = []" in game_code:
|
||||
record("Lawnmowers disabled in I, Zombie mode", "PASS")
|
||||
else:
|
||||
record("Lawnmowers disabled in I, Zombie mode", "FAIL", "Lawnmowers not cleared")
|
||||
|
||||
# 5. Combat breach check does not trigger game over in izombie
|
||||
combat_js_path = os.path.join(GAME_DIR, "js/systems/combat.js")
|
||||
with open(combat_js_path, "r", encoding="utf-8") as f:
|
||||
combat_code = f.read()
|
||||
|
||||
if "gameState.currentMode === 'izombie'" in combat_code and "zombie.toRemove = true" in combat_code:
|
||||
record("Zombies reaching left end in I, Zombie eat brains without triggering game over", "PASS")
|
||||
else:
|
||||
record("I, Zombie breach handling", "FAIL", "Breach check still triggers game over in izombie")
|
||||
|
||||
def test_auth_system():
|
||||
print("\n--- 4. Authentication & Profile System ---")
|
||||
auth_js_path = os.path.join(GAME_DIR, "js/systems/auth.js")
|
||||
with open(auth_js_path, "r", encoding="utf-8") as f:
|
||||
auth_code = f.read()
|
||||
|
||||
auth_methods = [
|
||||
"getCurrentUser",
|
||||
"isGuest",
|
||||
"login",
|
||||
"loginAsGuest",
|
||||
"logout",
|
||||
"getUserData",
|
||||
"saveUserData",
|
||||
"flushSave",
|
||||
"scheduleSave",
|
||||
"apiPost",
|
||||
]
|
||||
for m in auth_methods:
|
||||
if m in auth_code:
|
||||
record(f"PvZAuth.{m} method implemented", "PASS")
|
||||
else:
|
||||
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}")
|
||||
|
||||
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL",
|
||||
"Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "")
|
||||
record("auth.js 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")
|
||||
|
||||
# Server-side SQLite DB tests
|
||||
print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---")
|
||||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||||
try:
|
||||
import db as pvz_db
|
||||
pvz_db.init_db()
|
||||
|
||||
# Test user list
|
||||
users = pvz_db.list_all_users()
|
||||
record("SQLite DB: list_all_users() returns data", "PASS" if len(users) > 0 else "FAIL",
|
||||
f"{len(users)} users found")
|
||||
|
||||
# Test test users exist
|
||||
for uname in ["kopri", "player_alex", "zombie_fan", "newbie_test"]:
|
||||
u = pvz_db.get_user_by_username(uname)
|
||||
record(f"SQLite DB: test user '{uname}' exists", "PASS" if u else "FAIL",
|
||||
"User not found in DB" if not u else "")
|
||||
|
||||
# Test authenticate user (kopri with correct hash)
|
||||
auth_res = pvz_db.authenticate_user("kopri", "c63bc483")
|
||||
record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL",
|
||||
str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "")
|
||||
|
||||
# Test wrong password
|
||||
bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
|
||||
record("SQLite DB: reject wrong password", "PASS" if bad_auth and not bad_auth.get("success") else "FAIL",
|
||||
"Bad password was accepted!" if bad_auth and bad_auth.get("success") else "")
|
||||
|
||||
# Test save profile (update newbie coins)
|
||||
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")
|
||||
|
||||
# Verify saved
|
||||
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
|
||||
verify_ok = verify and verify.get("data", {}).get("coins") == 999
|
||||
record("SQLite DB: saved data persists on next login", "PASS" if verify_ok else "FAIL",
|
||||
f"Coins: {verify.get('data', {}).get('coins') if verify else 'N/A'}")
|
||||
|
||||
# Restore newbie
|
||||
pvz_db.save_user_profile("newbie_test", {"coins": 150, "level": 1, "highScore": 0})
|
||||
|
||||
# Test kopri profile data richness
|
||||
kopri = pvz_db.authenticate_user("kopri", "c63bc483")
|
||||
if kopri and kopri.get("data"):
|
||||
d = kopri["data"]
|
||||
record("SQLite DB: kopri.coins >= 5000", "PASS" if d.get("coins", 0) >= 5000 else "FAIL", f"coins={d.get('coins')}")
|
||||
record("SQLite DB: kopri.level >= 15", "PASS" if d.get("level", 0) >= 15 else "FAIL", f"level={d.get('level')}")
|
||||
pl = d.get("plantLevels", {})
|
||||
record("SQLite DB: kopri.plantLevels.peashooter == 3", "PASS" if pl.get("peashooter") == 3 else "FAIL")
|
||||
else:
|
||||
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile")
|
||||
|
||||
except Exception as e:
|
||||
record("SQLite DB tests", "FAIL", str(e))
|
||||
|
||||
|
||||
|
||||
def test_workshop_mechanics():
|
||||
print("\n--- 5. Workshop, Crafting, 3-Tier Upgrades & Perks ---")
|
||||
ws_path = os.path.join(GAME_DIR, "js/systems/workshop.js")
|
||||
with open(ws_path, "r", encoding="utf-8") as f:
|
||||
ws_code = f.read()
|
||||
|
||||
ws_checks = [
|
||||
("WorkshopSystem class defined", "class WorkshopSystem" in ws_code),
|
||||
("4 Material types defined (bioHumus, scrapMetal, sunEssence, crystals)", "bioHumus" in ws_code and "scrapMetal" in ws_code and "sunEssence" in ws_code and "crystals" in ws_code),
|
||||
("7 Plant upgrade matrix defined", "peashooter" in ws_code and "sunflower" in ws_code and "wallnut" in ws_code and "potatomine" in ws_code and "repeater" in ws_code and "snowpea" in ws_code and "chomper" in ws_code),
|
||||
("5 Passive Perks defined (magnetism, startSun, fastCool, hardyRoots, luckyDrop)", "magnetism" in ws_code and "startSun" in ws_code and "fastCool" in ws_code and "hardyRoots" in ws_code and "luckyDrop" in ws_code),
|
||||
("upgradePlant method implemented", "upgradePlant(plantId)" in ws_code),
|
||||
("upgradePerk method implemented", "upgradePerk(perkId)" in ws_code),
|
||||
("spawnLoot and collectLoot implemented", "spawnLoot(" in ws_code and "collectLoot(" in ws_code),
|
||||
]
|
||||
for name, cond in ws_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
# Check plant tier integration in plant.js
|
||||
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
|
||||
with open(plant_path, "r", encoding="utf-8") as f:
|
||||
plant_code = f.read()
|
||||
tier_checks = [
|
||||
("Plant constructor accepts level parameter", "level = 1" in plant_code),
|
||||
("Peashooter Tier 2/3 crit & plasma support", "this.type === 'peashooter' && this.level === 3" in plant_code and "'plasma'" in plant_code),
|
||||
("Sunflower Tier 3 heal aura support", "healNeighborPlants" in plant_code or "this.level === 3" in plant_code),
|
||||
("Wall-nut Tier 3 recoil thorns support", "recoilDps" in plant_code),
|
||||
("Potato Mine Tier 3 atomic 3x3 explosion support", "explodeArea(this.col, this.row, 3)" in plant_code or "this.level === 3" in plant_code),
|
||||
]
|
||||
for name, cond in tier_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
# Check projectile plasma piercing in projectile.js
|
||||
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
|
||||
with open(proj_path, "r", encoding="utf-8") as f:
|
||||
proj_code = f.read()
|
||||
proj_checks = [
|
||||
("Plasma projectile piercing logic implemented", "this.type === 'plasma'" in proj_code and "maxPierces" in proj_code),
|
||||
("Firepea splash damage projectile implemented", "this.type === 'firepea'" in proj_code),
|
||||
]
|
||||
for name, cond in proj_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_zengarden_and_almanac():
|
||||
print("\n--- 6. Zen Garden, Snail & Suburban Almanac Mechanics ---")
|
||||
zg_path = os.path.join(GAME_DIR, "js/systems/zengarden.js")
|
||||
with open(zg_path, "r", encoding="utf-8") as f:
|
||||
zg_code = f.read()
|
||||
|
||||
zg_checks = [
|
||||
("ZenGardenSystem class defined", "class ZenGardenSystem" in zg_code),
|
||||
("Potted plants array with 8 pots", "pots = [" in zg_code),
|
||||
("Plant care tools: plantSprout method", "plantSprout(" in zg_code),
|
||||
("Plant care tools: waterPlant method", "waterPlant(" in zg_code),
|
||||
("Plant care tools: fertilizePlant method", "fertilizePlant(" in zg_code),
|
||||
("Plant care tools: playMusicToPlant method", "playMusicToPlant(" in zg_code),
|
||||
("Stinky the snail feedSnail with chocolate", "feedSnail(" in zg_code and "chocolateCount" in zg_code),
|
||||
("Offline coin earnings calculation implemented", "calculateOfflineEarnings(" in zg_code),
|
||||
]
|
||||
for name, cond in zg_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
alm_path = os.path.join(GAME_DIR, "js/systems/almanac.js")
|
||||
with open(alm_path, "r", encoding="utf-8") as f:
|
||||
alm_code = f.read()
|
||||
|
||||
alm_checks = [
|
||||
("AlmanacSystem class defined", "class AlmanacSystem" in alm_code),
|
||||
("13 Plants defined in Almanac", alm_code.count("id: 'peashooter'") == 1 and alm_code.count("id: 'sunflower'") == 1 and alm_code.count("id: 'gravebuster'") == 1),
|
||||
("8 Zombies defined in Almanac", alm_code.count("id: 'z_normal'") == 1 and alm_code.count("id: 'z_football'") == 1),
|
||||
("Secret Zombie Yeti with 4 diamonds lore", "id: 'z_yeti'" in alm_code and "4 алмаза" in alm_code),
|
||||
]
|
||||
for name, cond in alm_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_achievements_and_roguelike():
|
||||
print("\n--- 7. Achievements System & Garden Roguelike ---")
|
||||
ach_path = os.path.join(GAME_DIR, "js/systems/achievements.js")
|
||||
with open(ach_path, "r", encoding="utf-8") as f:
|
||||
ach_code = f.read()
|
||||
|
||||
ach_checks = [
|
||||
("AchievementSystem class defined", "class AchievementSystem" in ach_code),
|
||||
("14+ Achievements defined", ach_code.count("id: 'first_plant'") == 1 and ach_code.count("id: 'clean_lawn'") == 1 and ach_code.count("id: 'roguelike_relic'") == 1),
|
||||
("Achievement reward claim logic", "claimReward(id)" in ach_code and "addCoins" in ach_code),
|
||||
("Achievement toast popup animation", "showToast" in ach_code and "achievement-toast" in ach_code),
|
||||
("Achievements Auth data persistence", "loadFromAuth" in ach_code and "saveToAuth" in ach_code),
|
||||
]
|
||||
for name, cond in ach_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
rogue_path = os.path.join(GAME_DIR, "js/systems/roguelike.js")
|
||||
with open(rogue_path, "r", encoding="utf-8") as f:
|
||||
rogue_code = f.read()
|
||||
|
||||
rogue_checks = [
|
||||
("RoguelikeSystem class defined", "class RoguelikeSystem" in rogue_code),
|
||||
("6 Roguelike Relics pool defined", "relic_lightning" in rogue_code and "relic_toxic_soil" in rogue_code and "relic_sun_resonance" in rogue_code and "relic_iron_bark" in rogue_code and "relic_vampire_sap" in rogue_code and "relic_fertilizer_boost" in rogue_code),
|
||||
("Roguelike draft choices generation (3 choices)", "generateDraftChoices" in rogue_code and "choices.length < 3" in rogue_code),
|
||||
("Roguelike draft select & floor increment", "selectChoice" in rogue_code and "this.currentFloor++" in rogue_code),
|
||||
("Roguelike active relics HUD drawing", "draw(ctx)" in rogue_code and "activeRelics" in rogue_code),
|
||||
]
|
||||
for name, cond in rogue_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_v2_features():
|
||||
print("\n--- 8. Tree of Wisdom, Dr. Zomboss Boss Fight & Roof/Fog Mechanics ---")
|
||||
tree_path = os.path.join(GAME_DIR, "js/systems/tree.js")
|
||||
with open(tree_path, "r", encoding="utf-8") as f:
|
||||
tree_code = f.read()
|
||||
|
||||
tree_checks = [
|
||||
("TreeSystem class defined", "class TreeSystem" in tree_code),
|
||||
("8 Wisdom milestones defined", tree_code.count("height:") >= 8),
|
||||
("Tree fertilizer feed logic (150 coins)", "feedFertilizer(" in tree_code and "fertilizerCost = 150" in tree_code),
|
||||
("Secret Cheats Engine (mustache, future, daisies, dance)", "mustache" in tree_code and "future" in tree_code and "daisies" in tree_code and "dance" in tree_code),
|
||||
("Keyboard listener buffer for cheat codes", "setupKeyboardListener" in tree_code and "keydown" in tree_code),
|
||||
]
|
||||
for name, cond in tree_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
boss_path = os.path.join(GAME_DIR, "js/systems/boss.js")
|
||||
with open(boss_path, "r", encoding="utf-8") as f:
|
||||
boss_code = f.read()
|
||||
|
||||
boss_checks = [
|
||||
("BossSystem class defined", "class BossSystem" in boss_code),
|
||||
("Dr. Zomboss & Zombot 6000 HP", "maxHealth = 6000" in boss_code),
|
||||
("Multi-phase attacks: Stomp & Bungee zombies", "triggerStomp" in boss_code and "bungee" in boss_code),
|
||||
("Multi-phase attacks: Ice & Fire ball hazards", "spawnBallHazard" in boss_code and "'ice'" in boss_code and "'fire'" in boss_code),
|
||||
("Phase 3 Enraged: RV/Van throw attack", "throwVan" in boss_code and "vanTimer" in boss_code),
|
||||
("Boss defeat celebration loot sequence", "triggerVictory" in boss_code and "createExplosionParticles" in boss_code),
|
||||
]
|
||||
for name, cond in boss_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
|
||||
with open(lb_path, "r", encoding="utf-8") as f:
|
||||
lb_code = f.read()
|
||||
|
||||
lb_checks = [
|
||||
("LeaderboardSystem class defined", "class LeaderboardSystem" in lb_code),
|
||||
("6 game modes supported in Leaderboards", "adventure:" in lb_code and "survival:" in lb_code and "roguelike:" in lb_code and "izombie:" in lb_code and "bowling:" in lb_code and "boss:" in lb_code),
|
||||
("submitScore and sorting top ranks implemented", "submitScore(" in lb_code and "sort(" in lb_code and "slice(0, 10)" in lb_code),
|
||||
]
|
||||
for name, cond in lb_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
grid_path = os.path.join(GAME_DIR, "js/systems/grid.js")
|
||||
with open(grid_path, "r", encoding="utf-8") as f:
|
||||
grid_code = f.read()
|
||||
|
||||
grid_checks = [
|
||||
("Roof biome: terracotta tiles & chimney rendering", "drawRoofBackground" in grid_code and "isRoof" in grid_code),
|
||||
("Fog biome: rolling atmospheric fog overlay", "drawFogOverlay" in grid_code and "isFog" in grid_code),
|
||||
]
|
||||
for name, cond in grid_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
|
||||
with open(plant_path, "r", encoding="utf-8") as f:
|
||||
plant_code = f.read()
|
||||
|
||||
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
|
||||
with open(proj_path, "r", encoding="utf-8") as f:
|
||||
proj_code = f.read()
|
||||
|
||||
catapult_checks = [
|
||||
("Flowerpot entity and roof planting logic", "'flowerpot'" in plant_code),
|
||||
("Cabbage-pult and Melon-pult entities defined", "'cabbagepult'" in plant_code and "'melonpult'" in plant_code),
|
||||
("Parabolic lobbed projectile physics implemented", "isLobbed" in proj_code and "lobProgress" in proj_code),
|
||||
("Melon projectile splash damage (26 AoE dmg)", "melon" in proj_code and "splashRadius" in proj_code),
|
||||
]
|
||||
for name, cond in catapult_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_v2_1_features():
|
||||
print("\n--- 9. Soundtrack, Boss Rush, Visual Themes & PWA (v2.1 Final) ---")
|
||||
assets_path = os.path.join(GAME_DIR, "js/utils/assets.js")
|
||||
with open(assets_path, "r", encoding="utf-8") as f:
|
||||
assets_code = f.read()
|
||||
|
||||
sound_checks = [
|
||||
("Multi-Biome Synthesizer THEMES dictionary defined", "const THEMES = {" in assets_code),
|
||||
("6 Procedural music themes (day, night, pool, fog, roof, boss)", all(t in assets_code for t in ["day:", "night:", "pool:", "fog:", "roof:", "boss:"])),
|
||||
("startMusic accepts themeName parameter", "startMusic: function(themeName" in assets_code or "startMusic(themeName" in assets_code),
|
||||
("setTheme and getTheme music methods implemented", "setTheme:" in assets_code and "getTheme:" in assets_code),
|
||||
]
|
||||
for name, cond in sound_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
wave_path = os.path.join(GAME_DIR, "js/systems/wave.js")
|
||||
with open(wave_path, "r", encoding="utf-8") as f:
|
||||
wave_code = f.read()
|
||||
|
||||
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
|
||||
with open(lb_path, "r", encoding="utf-8") as f:
|
||||
lb_code = f.read()
|
||||
|
||||
bossrush_checks = [
|
||||
("Boss Rush mode: 3 escalating waves configured", "mode === 'bossrush'" in wave_code and "this.totalWaves = 3" in wave_code),
|
||||
("Boss Rush wave sequence: Giants -> Elite Squad -> Dr. Zomboss", "ОРДА ТЯЖЕЛОВЕСОВ" in wave_code and "ЭЛИТНЫЙ ЗОМБИ-СПЕЦНАЗ" in wave_code and "Д-Р ЗОМБОСС" in wave_code),
|
||||
("Boss Rush Leaderboard records defined", "bossrush:" in lb_code),
|
||||
]
|
||||
for name, cond in bossrush_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||||
with open(css_path, "r", encoding="utf-8") as f:
|
||||
css_code = f.read()
|
||||
|
||||
theme_checks = [
|
||||
("Cyberpunk Neon theme stylesheet classes", "body.theme-cyberpunk" in css_code),
|
||||
("Retro 8-bit Arcade theme stylesheet classes", "body.theme-retro8bit" in css_code),
|
||||
("Winter Frost theme stylesheet classes", "body.theme-winter" in css_code),
|
||||
]
|
||||
for name, cond in theme_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
manifest_path = os.path.join(GAME_DIR, "manifest.json")
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
m_data = json.load(f)
|
||||
pwa_valid = m_data.get("display") == "standalone" and len(m_data.get("icons", [])) >= 3
|
||||
except Exception:
|
||||
pwa_valid = False
|
||||
|
||||
sw_path = os.path.join(GAME_DIR, "sw.js")
|
||||
with open(sw_path, "r", encoding="utf-8") as f:
|
||||
sw_code = f.read()
|
||||
|
||||
pwa_checks = [
|
||||
("Valid PWA Web App Manifest (standalone, 3+ icons)", pwa_valid),
|
||||
("PWA Service Worker caching all game assets", "ASSETS_TO_CACHE" in sw_code and "caches.open" in sw_code and "fetch" in sw_code),
|
||||
]
|
||||
for name, cond in pwa_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_campaign_system():
|
||||
print("\n--- 10. Campaign System: 25 Levels Across 5 Biomes ---")
|
||||
campaign_js_path = os.path.join(GAME_DIR, "js/systems/campaign.js")
|
||||
with open(campaign_js_path, "r", encoding="utf-8") as f:
|
||||
camp_code = f.read()
|
||||
|
||||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||||
with open(css_path, "r", encoding="utf-8") as f:
|
||||
css_code = f.read()
|
||||
|
||||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||||
game_code = f.read()
|
||||
|
||||
wave_js_path = os.path.join(GAME_DIR, "js/systems/wave.js")
|
||||
with open(wave_js_path, "r", encoding="utf-8") as f:
|
||||
wave_code = f.read()
|
||||
|
||||
campaign_checks = [
|
||||
("CampaignSystem namespace defined", "window.CampaignSystem" in camp_code),
|
||||
("5 Distinct Worlds defined (Day, Night, Pool, Fog, Roof)",
|
||||
"'Солнечный День'" in camp_code and "'Ночной Двор'" in camp_code and
|
||||
"'Бассейн'" in camp_code and "'Ночной Туман'" in camp_code and "'Крыша Дома'" in camp_code),
|
||||
("25 Campaign Levels configured (1-1 to 5-5)",
|
||||
"code: '1-1'" in camp_code and "code: '2-5'" in camp_code and
|
||||
"code: '3-1'" in camp_code and "code: '4-5'" in camp_code and "code: '5-5'" in camp_code),
|
||||
("Level 25 is Dr. Zomboss Boss Fight", "isBoss: true" in camp_code and "Доктор Зомбосс!" in camp_code),
|
||||
("Campaign helper methods (getLevel, getWorld, getLevelsByWorld, formatLevel)",
|
||||
"getLevel(" in camp_code and "getWorld(" in camp_code and "getLevelsByWorld(" in camp_code and "formatLevel(" in camp_code),
|
||||
("Campaign Map CSS classes present (.campaign-card, .campaign-levels-grid, .level-badge)",
|
||||
".campaign-card" in css_code and ".campaign-levels-grid" in css_code and ".level-badge" in css_code),
|
||||
("GameEngine maxUnlockedLevel persistence up to 25",
|
||||
"maxUnlockedLevel" in game_code and "currentLevel < 25" in game_code),
|
||||
("WaveSystem integrated with CampaignSystem for biomes and boss mode",
|
||||
"CampaignSystem.getLevel" in wave_code and "CampaignSystem.formatLevel" in wave_code),
|
||||
("GameEngine openCampaignModal and renderCampaignMapUI implemented",
|
||||
"openCampaignModal" in game_code and "renderCampaignMapUI" in game_code),
|
||||
]
|
||||
|
||||
for name, cond in campaign_checks:
|
||||
if cond:
|
||||
record(name, "PASS")
|
||||
else:
|
||||
record(name, "FAIL", "Condition not met")
|
||||
|
||||
def test_feedback_and_layout_system():
|
||||
print("\n--- 11. Feedback System & UI Layout Optimization ---")
|
||||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||||
with open(css_path, "r", encoding="utf-8") as f:
|
||||
css_code = f.read()
|
||||
|
||||
feedback_js_path = os.path.join(GAME_DIR, "js/systems/feedback.js")
|
||||
with open(feedback_js_path, "r", encoding="utf-8") as f:
|
||||
feedback_code = f.read()
|
||||
|
||||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||||
game_code = f.read()
|
||||
|
||||
bugs_json_path = os.path.join(BASE_DIR, "reports/bugs.json")
|
||||
bug_reports_md_path = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||||
feedback_srv_path = os.path.join(BASE_DIR, "scripts/feedback_server.py")
|
||||
|
||||
# 1. UI Layout rules
|
||||
layout_checks = [
|
||||
("Top nav bar flex-wrap enabled for zero overflow", "flex-wrap: wrap" in css_code),
|
||||
("Responsive start-card class implemented", ".start-card" in css_code and "max-height" in css_code),
|
||||
("Bug report card styling present", ".bugreport-card" in css_code and ".bugreport-feed" in css_code),
|
||||
("Bug status badges and cards styling present", ".bug-card" in css_code and ".bug-status" in css_code),
|
||||
("Body has vertical scrolling enabled for smaller screens", "overflow-y: auto" in css_code),
|
||||
]
|
||||
for name, cond in layout_checks:
|
||||
record(name, "PASS" if cond else "FAIL")
|
||||
|
||||
# 2. Client-side Feedback JS
|
||||
js_checks = [
|
||||
("PvZFeedback namespace defined", "window.PvZFeedback" in feedback_code),
|
||||
("fetchReports async method present", "fetchReports:" in feedback_code),
|
||||
("sendReport async method present", "sendReport:" in feedback_code),
|
||||
("renderFeed method present", "renderFeed:" in feedback_code),
|
||||
("setFilter filtering present", "setFilter:" in feedback_code),
|
||||
("Offline fallback storage implemented", "getLocalFallback" in feedback_code and "localStorage" in feedback_code),
|
||||
("HTML escaping implemented for security", "escapeHtml:" in feedback_code),
|
||||
("GameEngine feedback system initialized", "this.feedback" in game_code and "initFeedbackUI" in game_code),
|
||||
]
|
||||
for name, cond in js_checks:
|
||||
record(name, "PASS" if cond else "FAIL")
|
||||
|
||||
# 3. Server & Storage Persistence
|
||||
storage_checks = [
|
||||
("reports/bugs.json exists and is valid JSON", os.path.isfile(bugs_json_path)),
|
||||
("BUG_REPORTS.md exists with community table", os.path.isfile(bug_reports_md_path)),
|
||||
("feedback_server.py exists and handles port 8001", os.path.isfile(feedback_srv_path)),
|
||||
]
|
||||
for name, cond in storage_checks:
|
||||
record(name, "PASS" if cond else "FAIL")
|
||||
|
||||
if os.path.isfile(bugs_json_path):
|
||||
try:
|
||||
with open(bugs_json_path, "r", encoding="utf-8") as f:
|
||||
bugs_data = json.load(f)
|
||||
record("reports/bugs.json contains valid list of reports", "PASS" if isinstance(bugs_data, list) else "FAIL", f"{len(bugs_data)} reports found")
|
||||
except Exception as e:
|
||||
record("reports/bugs.json parses as JSON", "FAIL", str(e))
|
||||
|
||||
def test_http_endpoint():
|
||||
print("\n--- 12. Network & HTTP/2 Production Endpoints ---")
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
endpoints = [
|
||||
("Local HTTP Redirect", "http://127.0.0.1:80", 301),
|
||||
("Local HTTPS Index", "https://127.0.0.1:443/", 200),
|
||||
("Local HTTPS Style", "https://127.0.0.1:443/css/style.css", 200),
|
||||
("Local HTTPS Auth JS", "https://127.0.0.1:443/js/systems/auth.js", 200),
|
||||
("Local HTTPS Feedback JS", "https://127.0.0.1:443/js/systems/feedback.js", 200),
|
||||
("Local HTTPS Workshop JS", "https://127.0.0.1:443/js/systems/workshop.js", 200),
|
||||
("Local HTTPS Zen Garden JS", "https://127.0.0.1:443/js/systems/zengarden.js", 200),
|
||||
("Local HTTPS Almanac JS", "https://127.0.0.1:443/js/systems/almanac.js", 200),
|
||||
("Local HTTPS Achievements JS", "https://127.0.0.1:443/js/systems/achievements.js", 200),
|
||||
("Local HTTPS Roguelike JS", "https://127.0.0.1:443/js/systems/roguelike.js", 200),
|
||||
("Local HTTPS Tree JS", "https://127.0.0.1:443/js/systems/tree.js", 200),
|
||||
("Local HTTPS Boss JS", "https://127.0.0.1:443/js/systems/boss.js", 200),
|
||||
("Local HTTPS Leaderboard JS", "https://127.0.0.1:443/js/systems/leaderboard.js", 200),
|
||||
("Local HTTPS Campaign JS", "https://127.0.0.1:443/js/systems/campaign.js", 200),
|
||||
("Local HTTPS Test API JS", "https://127.0.0.1:443/js/utils/test_api.js", 200),
|
||||
("Local HTTPS Bugs API", "https://127.0.0.1:443/api/bugs", 200),
|
||||
("Local HTTPS Users API", "https://127.0.0.1:443/api/users", 200),
|
||||
("Local HTTPS Manifest", "https://127.0.0.1:443/manifest.json", 200),
|
||||
("Local HTTPS Service Worker", "https://127.0.0.1:443/sw.js", 200),
|
||||
("Local HTTPS Icon 192", "https://127.0.0.1:443/icon-192.png", 200),
|
||||
("Public Domain HTTPS", "https://ai.qpid.pro/", 200),
|
||||
("Public Domain Bugs API", "https://ai.qpid.pro/api/bugs", 200),
|
||||
("Public Domain Users API", "https://ai.qpid.pro/api/users", 200),
|
||||
]
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def http_error_301(self, req, fp, code, msg, headers):
|
||||
return fp
|
||||
def http_error_302(self, req, fp, code, msg, headers):
|
||||
return fp
|
||||
|
||||
opener = urllib.request.build_opener(NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
|
||||
|
||||
for name, url, expected_code in endpoints:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PvZTestRunner/1.0", "Host": "ai.qpid.pro" if "127.0.0.1" in url else "ai.qpid.pro"})
|
||||
resp = opener.open(req, timeout=5)
|
||||
status = resp.status if hasattr(resp, 'status') else resp.code
|
||||
if status == expected_code:
|
||||
record(f"HTTP {name} ({url})", "PASS", f"Status {status}")
|
||||
else:
|
||||
record(f"HTTP {name} ({url})", "FAIL", f"Expected {expected_code}, got {status}")
|
||||
except Exception as e:
|
||||
record(f"HTTP {name} ({url})", "FAIL", str(e))
|
||||
|
||||
def main():
|
||||
print("==================================================")
|
||||
print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱")
|
||||
print("==================================================")
|
||||
|
||||
test_file_structure()
|
||||
test_index_html_integrity()
|
||||
test_izombie_mechanics()
|
||||
test_auth_system()
|
||||
test_workshop_mechanics()
|
||||
test_zengarden_and_almanac()
|
||||
test_achievements_and_roguelike()
|
||||
test_v2_features()
|
||||
test_v2_1_features()
|
||||
test_campaign_system()
|
||||
test_feedback_and_layout_system()
|
||||
test_http_endpoint()
|
||||
|
||||
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
|
||||
total = len(TEST_RESULTS)
|
||||
failed = total - passed
|
||||
|
||||
print("\n==================================================")
|
||||
print(f"TEST SUMMARY: {passed}/{total} PASSED ({failed} FAILED)")
|
||||
print("==================================================")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user