From d8be8f436697942db13b3bc63f3745155393030d Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 22 Sep 2026 09:25:01 +0000 Subject: [PATCH] feat(admin-configurator): add web configurator, visual level editor, audit logs and session management --- game/configurator/css/style.css | 1243 ++++++++++++++ game/configurator/index.html | 1231 ++++++++++++++ game/configurator/js/configurator.js | 1601 ++++++++++++++++++ game/configurator_work/css/style.css | 1243 ++++++++++++++ game/configurator_work/index.html | 1231 ++++++++++++++ game/configurator_work/js/configurator.js | 1601 ++++++++++++++++++ game/js/systems/config.js | 209 +++ nginx/conf.d/ai.qpid.pro.conf | 39 +- nginx/conf.d/ai.qpid.pro.conf.ssl | 16 + nginx/conf.d/pvz.qpid.pro.conf | 24 +- scripts/db.py | 1851 ++++++++++++++++++++- scripts/feedback_server.py | 898 ++++++++-- scripts/test_game.py | 309 +++- 13 files changed, 11204 insertions(+), 292 deletions(-) create mode 100644 game/configurator/css/style.css create mode 100644 game/configurator/index.html create mode 100644 game/configurator/js/configurator.js create mode 100644 game/configurator_work/css/style.css create mode 100644 game/configurator_work/index.html create mode 100644 game/configurator_work/js/configurator.js create mode 100644 game/js/systems/config.js diff --git a/game/configurator/css/style.css b/game/configurator/css/style.css new file mode 100644 index 0000000..8914393 --- /dev/null +++ b/game/configurator/css/style.css @@ -0,0 +1,1243 @@ +/* ========================================================== + Plants vs. Zombies — Admin Panel & Game Configurator CSS + Cyberpunk Dark Botanical Theme + ========================================================== */ + +:root { + --bg-main: #0c1017; + --bg-card: rgba(18, 25, 38, 0.85); + --bg-card-hover: rgba(26, 36, 54, 0.95); + --bg-input: #151d2a; + --bg-modal: #111722; + + --border-color: rgba(76, 175, 80, 0.25); + --border-focus: #4caf50; + --border-cyber: rgba(0, 229, 255, 0.4); + + --text-main: #e2e8f0; + --text-muted: #94a3b8; + --text-dim: #64748b; + + --accent-green: #4caf50; + --accent-green-glow: rgba(76, 175, 80, 0.4); + --accent-cyan: #00e5ff; + --accent-cyan-glow: rgba(0, 229, 255, 0.35); + --accent-gold: #ffc107; + --accent-gold-glow: rgba(255, 193, 7, 0.35); + --accent-red: #ff5252; + --accent-purple: #b388ff; + --accent-orange: #ff9800; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.45); + --shadow-glow: 0 0 20px var(--accent-green-glow); + --transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + -webkit-tap-highlight-color: transparent; +} + +body { + background-color: var(--bg-main); + background-image: + radial-gradient(circle at 15% 15%, rgba(76, 175, 80, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(0, 229, 255, 0.06) 0%, transparent 40%), + linear-gradient(180deg, #090d13 0%, #0d121c 100%); + background-attachment: fixed; + color: var(--text-main); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; + width: 100%; + padding: 0 16px; +} + +/* Header */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-icon { + font-size: 32px; + filter: drop-shadow(0 0 10px rgba(76, 175, 80, 0.6)); + animation: pulse-icon 3s ease-in-out infinite alternate; +} + +@keyframes pulse-icon { + 0% { transform: scale(1); } + 100% { transform: scale(1.08); filter: drop-shadow(0 0 14px rgba(0, 229, 255, 0.8)); } +} + +.brand-title { + font-size: 20px; + font-weight: 800; + background: linear-gradient(135deg, #a5d6a7, #00e5ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: 0.5px; +} + +.brand-sub { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-status-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: rgba(76, 175, 80, 0.12); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 600; + color: #81c784; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4caf50; + box-shadow: 0 0 8px #4caf50; +} + +.status-dot.offline { + background: #ff5252; + box-shadow: 0 0 8px #ff5252; +} + +/* Nav Tabs */ +.tabs-navigation { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 12px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + scrollbar-width: thin; +} + +.tabs-navigation::-webkit-scrollbar { + height: 4px; +} +.tabs-navigation::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.nav-tab-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: var(--transition); +} + +.nav-tab-btn:hover { + background: rgba(76, 175, 80, 0.1); + color: var(--text-main); + border-color: var(--border-color); +} + +.nav-tab-btn.active { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.25), rgba(0, 229, 255, 0.15)); + color: #a5d6a7; + border-color: var(--accent-green); + box-shadow: 0 0 16px rgba(76, 175, 80, 0.25); +} + +.badge-count { + background: var(--accent-red); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + font-weight: bold; +} + +/* Content Area */ +.tab-pane { + display: none; + animation: fadeIn 0.25s ease-out; +} + +.tab-pane.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Cards & Grids */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: var(--shadow-card); + display: flex; + align-items: center; + gap: 16px; + transition: var(--transition); +} + +.stat-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent-green); + transform: translateY(-2px); + box-shadow: var(--shadow-card), 0 0 15px rgba(76, 175, 80, 0.15); +} + +.stat-icon { + font-size: 36px; + width: 54px; + height: 54px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.stat-info .stat-value { + font-size: 24px; + font-weight: 800; + color: var(--text-main); + line-height: 1.2; +} + +.stat-info .stat-label { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* Panel Sections */ +.section-panel { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--shadow-card); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.section-title { + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + color: #a5d6a7; +} + +.section-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Event Switch Cards */ +.events-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.event-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + transition: var(--transition); +} + +.event-card:hover { + border-color: var(--border-color); + background: rgba(76, 175, 80, 0.05); +} + +.event-info h4 { + font-size: 14px; + font-weight: 600; + color: var(--text-main); +} + +.event-info p { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Sliders & Form Controls */ +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; +} + +.control-item { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-md); + padding: 16px; + transition: var(--transition); +} + +.control-item:hover { + border-color: var(--border-color); + background: rgba(255, 255, 255, 0.04); +} + +.control-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.control-label { + font-size: 13px; + font-weight: 600; + color: var(--text-main); + display: flex; + align-items: center; + gap: 6px; +} + +.control-badge { + font-size: 12px; + font-weight: 700; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: rgba(0, 229, 255, 0.12); + color: var(--accent-cyan); + border: 1px solid rgba(0, 229, 255, 0.3); +} + +.range-slider { + width: 100%; + height: 6px; + border-radius: 3px; + background: #1e293b; + outline: none; + -webkit-appearance: none; + cursor: pointer; + margin: 10px 0; +} + +.range-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #4caf50; + cursor: pointer; + box-shadow: 0 0 10px rgba(76, 175, 80, 0.8); + border: 2px solid #ffffff; + transition: var(--transition); +} + +.range-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + background: #00e5ff; + box-shadow: 0 0 14px rgba(0, 229, 255, 0.9); +} + +/* Tables */ +.table-responsive { + width: 100%; + overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 13px; +} + +.admin-table th { + background: rgba(255, 255, 255, 0.04); + padding: 12px 16px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +.admin-table td { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.admin-table tbody tr { + transition: var(--transition); +} + +.admin-table tbody tr:hover { + background: rgba(76, 175, 80, 0.06); +} + +/* Badges */ +.pill-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.pill-active { + background: rgba(76, 175, 80, 0.15); + color: #81c784; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.pill-banned { + background: rgba(255, 82, 82, 0.15); + color: #ff8a80; + border: 1px solid rgba(255, 82, 82, 0.3); +} + +.pill-admin { + background: rgba(255, 193, 7, 0.15); + color: #ffd54f; + border: 1px solid rgba(255, 193, 7, 0.3); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: var(--transition); + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, #4caf50, #2e7d32); + color: white; + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #66bb6a, #388e3c); + box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5); + transform: translateY(-1px); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.14); +} + +.btn-danger { + background: linear-gradient(135deg, #e53935, #c62828); + color: white; +} + +.btn-danger:hover { + background: linear-gradient(135deg, #ef5350, #d32f2f); +} + +.btn-cyber { + background: linear-gradient(135deg, #00b4d8, #0077b6); + color: white; + box-shadow: 0 4px 12px rgba(0, 180, 216, 0.3); +} + +.btn-cyber:hover { + background: linear-gradient(135deg, #0096c7, #023e8a); +} + +.btn-sm { + padding: 4px 10px; + font-size: 11px; + border-radius: var(--radius-sm); +} + +.btn-icon-only { + width: 32px; + height: 32px; + padding: 0; + border-radius: var(--radius-sm); +} + +/* Inputs & Form Groups */ +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 10px 14px; + background: var(--bg-input); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + color: var(--text-main); + font-size: 13px; + outline: none; + transition: var(--transition); +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 10px rgba(76, 175, 80, 0.3); +} + +/* Preset Cards */ +.presets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.preset-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + padding: 20px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.preset-card.active { + border-color: var(--accent-green); + background: rgba(76, 175, 80, 0.08); + box-shadow: 0 0 20px rgba(76, 175, 80, 0.2); +} + +.preset-card:hover { + transform: translateY(-2px); + border-color: var(--accent-cyan); +} + +.preset-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.preset-title { + font-size: 16px; + font-weight: 700; + color: var(--text-main); +} + +.preset-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Modals */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + padding: 16px; +} + +.modal-overlay.open { + opacity: 1; + pointer-events: auto; +} + +.modal-box { + background: var(--bg-modal); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 30px rgba(76, 175, 80, 0.2); + animation: modalPop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.modal-box.modal-large { + max-width: 850px; +} + +@keyframes modalPop { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.modal-title { + font-size: 16px; + font-weight: 700; + color: #a5d6a7; + display: flex; + align-items: center; + gap: 8px; +} + +.modal-close-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; + transition: var(--transition); +} + +.modal-close-btn:hover { + color: var(--accent-red); +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Toast Alerts */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 2000; +} + +.toast { + background: #1e293b; + color: white; + padding: 12px 18px; + border-radius: var(--radius-md); + border-left: 4px solid var(--accent-green); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + animation: slideInRight 0.3s ease; + min-width: 280px; +} + +.toast.error { + border-left-color: var(--accent-red); +} + +.toast.warn { + border-left-color: var(--accent-gold); +} + +.toast.info { + border-left-color: var(--accent-cyan); +} + +@keyframes slideInRight { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* Toggle Switch */ +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider-round { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #334155; + transition: var(--transition); + border-radius: 24px; +} + +.slider-round:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: var(--transition); + border-radius: 50%; +} + +input:checked + .slider-round { + background-color: var(--accent-green); + box-shadow: 0 0 10px var(--accent-green-glow); +} + +input:checked + .slider-round:before { + transform: translateX(20px); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .app-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .header-actions { + width: 100%; + justify-content: space-between; + } + .controls-grid { + grid-template-columns: 1fr; + } +} + + +/* ========================================== + Campaign Level Editor Styling + ========================================== */ +.level-selector-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 16px; +} + +.level-selector-left { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.level-badge-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); +} + +.code-badge { + background: var(--accent-green-glow); + color: #a5d6a7; + font-weight: 700; + padding: 3px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent-green); + font-family: monospace; +} + +.world-tag { + background: rgba(0, 229, 255, 0.15); + color: var(--accent-cyan); + padding: 3px 8px; + border-radius: var(--radius-sm); + border: 1px solid rgba(0, 229, 255, 0.3); + font-size: 12px; +} + +.levels-grid-nav { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(65px, 1fr)); + gap: 8px; + margin-bottom: 20px; +} + +.level-pill { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 4px; + background: var(--bg-card); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); + user-select: none; +} + +.level-pill:hover { + background: var(--bg-card-hover); + border-color: var(--border-color); + transform: translateY(-2px); +} + +.level-pill.active { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.35), rgba(0, 229, 255, 0.2)); + border-color: var(--accent-green); + box-shadow: 0 0 12px var(--accent-green-glow); + transform: scale(1.04); +} + +.level-pill.is-boss { + border-color: var(--accent-red); +} + +.level-pill.is-boss.active { + background: linear-gradient(135deg, rgba(255, 82, 82, 0.4), rgba(255, 152, 0, 0.3)); + box-shadow: 0 0 14px rgba(255, 82, 82, 0.45); +} + +.level-pill-num { + font-weight: 700; + font-size: 13px; + color: var(--text-main); + font-family: monospace; +} + +.level-pill-icon { + font-size: 12px; + margin-top: 2px; +} + +.level-editor-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 1024px) { + .level-editor-layout { + grid-template-columns: 1fr; + } +} + +.level-editor-col { + display: flex; + flex-direction: column; +} + +.sub-panel { + background: rgba(18, 25, 38, 0.65); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; + backdrop-filter: blur(10px); +} + +.sub-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 8px; +} + +.sub-panel-title { + font-size: 16px; + font-weight: 700; + color: var(--text-main); + display: flex; + align-items: center; + gap: 8px; +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-row-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; +} + +@media (max-width: 600px) { + .form-row-2, .form-row-3 { + grid-template-columns: 1fr; + } +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + color: var(--text-main); + user-select: none; +} + +/* Visual Waves Configurator */ +.waves-list-container { + display: flex; + flex-direction: column; + gap: 14px; + max-height: 850px; + overflow-y: auto; + padding-right: 4px; +} + +.wave-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(76, 175, 80, 0.2); + border-radius: var(--radius-md); + padding: 14px; + transition: var(--transition); +} + +.wave-card:hover { + border-color: var(--border-cyber); + background: rgba(255, 255, 255, 0.045); +} + +.wave-card.is-final { + border-color: rgba(255, 82, 82, 0.4); + background: rgba(255, 82, 82, 0.04); +} + +.wave-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.wave-card-title { + font-size: 14px; + font-weight: 700; + color: #a5d6a7; + display: flex; + align-items: center; + gap: 8px; +} + +.wave-zombies-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wave-zombie-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-sm); + padding: 8px 10px; + flex-wrap: wrap; +} + +.wave-zombie-type { + flex: 2; + min-width: 140px; +} + +.wave-zombie-count-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 90px; +} + +.wave-zombie-interval-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 90px; +} + +.btn-remove-zombie-entry { + background: transparent; + border: none; + color: var(--accent-red); + cursor: pointer; + font-size: 16px; + padding: 4px; + line-height: 1; + border-radius: 4px; + transition: var(--transition); +} + +.btn-remove-zombie-entry:hover { + background: rgba(255, 82, 82, 0.2); +} + +/* Dave Dialogue Live Preview */ +.dialogue-preview-box { + display: flex; + gap: 14px; + background: linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95)); + border: 2px solid var(--accent-green); + border-radius: var(--radius-lg); + padding: 16px; + margin-top: 14px; + box-shadow: 0 0 16px rgba(76, 175, 80, 0.25); + align-items: flex-start; +} + +.dialogue-preview-speaker { + font-size: 36px; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.dialogue-preview-content { + flex: 1; +} + +.dialogue-preview-title { + font-size: 13px; + font-weight: 700; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.dialogue-preview-text { + font-size: 13px; + color: var(--text-main); + line-height: 1.4; + margin-bottom: 10px; + font-style: italic; +} + +.dialogue-preview-btn { + display: inline-block; + padding: 5px 14px; + background: linear-gradient(135deg, var(--accent-green), #2e7d32); + color: white; + font-size: 11px; + font-weight: 700; + border-radius: var(--radius-full); + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.4); + letter-spacing: 0.5px; +} + +/* Badges & Tables Additions */ +.badge-purple { + background: rgba(179, 136, 255, 0.2); + color: var(--accent-purple); + border: 1px solid rgba(179, 136, 255, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-info { + background: rgba(0, 229, 255, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(0, 229, 255, 0.35); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-success { + background: rgba(76, 175, 80, 0.2); + color: #a5d6a7; + border: 1px solid rgba(76, 175, 80, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-danger { + background: rgba(255, 82, 82, 0.2); + color: #ff8a80; + border: 1px solid rgba(255, 82, 82, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-warning { + background: rgba(255, 193, 7, 0.2); + color: #ffe082; + border: 1px solid rgba(255, 193, 7, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-muted { + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; +} + +.token-cell { + font-family: monospace; + font-size: 12px; + color: var(--accent-cyan); + background: rgba(0, 0, 0, 0.3); + padding: 2px 6px; + border-radius: 4px; +} + +.player-name-link { + color: var(--accent-cyan); + cursor: pointer; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +} + +.player-name-link:hover { + color: #a5d6a7; +} diff --git a/game/configurator/index.html b/game/configurator/index.html new file mode 100644 index 0000000..166cc6e --- /dev/null +++ b/game/configurator/index.html @@ -0,0 +1,1231 @@ + + + + + + PvZ — Панель администратора и конфигуратор баланса + + + + +
+ +
+
+ 🌻 +
+
PvZ Configurator & Admin Panel
+
Управление балансом, кампанией и сессиями • Plants vs. Zombies
+
+
+
+ 🎮 Играть +
+ + Не авторизован +
+ + + +
+
+ + + + + +
+
+
+
👥
+
+
—
+
Всего игроков
+
+
+
+
⚡
+
+
—
+
Активных за 24ч
+
+
+
+
📱
+
+
—
+
Активных сессий
+
+
+
+
📜
+
+
—
+
Событий входов
+
+
+
+
🎮
+
+
25
+
Этапов кампании
+
+
+
+
💰
+
+
—
+
Всего монет в обороте
+
+
+
+
🚫
+
+
—
+
Заблокировано
+
+
+
+
🐛
+
+
—
+
Баг-репортов
+
+
+
+
🧟
+
+
—
+
Зомби уничтожено (всего)
+
+
+
+
🌱
+
+
—
+
Посажено растений
+
+
+
+
☀️
+
+
—
+
Солнца выращено
+
+
+
+
⚖️
+
+
—
+
Активный пресет
+
+
+
+ + +
+
+
+
⚡ Оперативные Live-События (Серверный переключатель)
+
Включение глобальных событий для всех подключенных игроков в реальном времени.
+
+ +
+
+
+
+

🌟 Double Gold Weekend

+

Удваивает выпадение монет из зомби и в Саду Дзен.

+
+ +
+
+
+

⚡ Fast Cooldowns

+

Ускоренная перезарядка семян для динамичного боя.

+
+ +
+
+
+

☀️ Super Sun Drop

+

Солнце с неба и подсолнухов падает в 2 раза чаще.

+
+ +
+
+
+

🧟 Turbo Zombie Wave

+

Зомби бегут и атакуют с повышенной скоростью (+30%).

+
+ +
+
+
+
+ + +
+
+
+
+
🎮 Визуальный редактор этапов кампании
+
Настройка 25 уровней в 5 мирах: состав волн, биомы, награды, модификаторы и диалоги Дэйва.
+
+
+ + + +
+
+ + +
+
+ + + +
+
+ Текущий этап: 1-1 + ☀️ Солнечный День +
+
+ + +
+ +
+ + +
+ +
+ +
+

📋 Основная информация об этапе

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

⚡ Модификаторы и правила этапа

+ +
+
+ 🧟 Здоровье зомби (HP Multiplier) + 1.0x +
+ +
+ +
+
+ 🏃 Скорость зомби (Speed Multiplier) + 1.0x +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+

🧔 Диалог Безумного Дэйва перед боем

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
🧔
+
+
Безумный Дэйв предупреждает:
+
Эй, сосед! Будь начеку!
+
ДАЛЕЕ / В БОЙ ⚔️
+
+
+
+
+ + +
+
+
+
+

🌊 Визуальный конфигуратор волн

+
Настройка состава зомби, плотности и таймингов каждой волны.
+
+ +
+ + +
+ +
+
+
+
+
+
+ + +
+
+
+
+
📱 Активные сессии игроков в реальном времени
+
Мониторинг авторизованных токенов, IP-адресов, устройств и завершение подозрительных сессий.
+
+
+ Активно: 0 + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + +
ИгрокIP-адресУстройство и браузерСозданаПоследняя активностьТокенДействия
Загрузка активных сессий...
+
+
+
+ + +
+
+
+
+
📜 Журнал аудита авторизаций и входов
+
Полная история попыток входа, регистраций, блокировок и сработок защиты от перебора.
+
+
+ Событий: 0 + +
+
+ + +
+ + + +
+ +
+ + + + + + + + + + + + + +
Время событияПользовательIP-адресУстройство и браузерСтатус аутентификации
Загрузка журнала аудита...
+
+
+
+ + +
+
+
+
+
👥 База учетных записей и прогресса игроков
+
Поиск, просмотр инвентаря, правка баланса, бан и сброс паролей.
+
+
+ + +
+
+ + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + +
IDНикнеймСтатусУровеньМонетыРекордРегистрацияПоследний входДействия
Загрузка игроков...
+
+
+
+ + +
+
+
+
+
💰 Баланс экономики, цен и солнца
+
Управление генерацией ресурсов, стоимостью карт и дропом монет.
+
+ +
+ +
+
+
+ ☀️ Множитель солнца + 1.0x +
+ +

Увеличивает количество солнца, даваемое подсолнухами и падающее с неба.

+
+ +
+
+ 🌱 Стартовое солнце + 50 +
+ +

Количество солнца в начале каждого уровня.

+
+ +
+
+ 🪙 Дроп монет из зомби + 1.0x +
+ +

Вероятность и частота выпадения серебряных и золотых монет.

+
+ +
+
+ ☁️ Частота солнца с неба + 1.0x +
+ +

Интервал падения небесного солнца на дневных уровнях.

+
+ +
+
+ 🛒 Множитель цен в магазине + 1.0x +
+ +

Стоимость покупки закрытых растений в магазине Дэйва.

+
+ +
+
+ 🔧 Стоимость крафта в мастерской + 1.0x +
+ +

Расход материалов на прокачку 2 и 3 уровней растений.

+
+
+
+
+ + +
+
+
+
+
🧟 Баланс зомби и боевой сложности
+
Глобальные множители характеристик орды и плотности волн.
+
+ +
+ +
+
+
+ ❤️ Здоровье зомби (HP) + 1.0x +
+ +

Запас прочности всех типов зомби и боссов.

+
+ +
+
+ 🏃 Скорость передвижения + 1.0x +
+ +

Скорость ходьбы и бега зомби по газону.

+
+ +
+
+ 💥 Урон от укусов зомби + 1.0x +
+ +

Урон, наносимый растениям при поедании.

+
+ +
+
+ 🌊 Плотность спавна волн + 1.0x +
+ +

Количество зомби, появляющихся в каждой волне.

+
+
+
+
+ + +
+
+
+
+
🌻 Баланс растений и стрельбы
+
Урон снарядов, прочность стеноорехов и скорость перезарядки семян.
+
+ +
+ +
+
+
+ 🎯 Урон снарядов растений + 1.0x +
+ +

Урон всех типов горошин, спор и снарядов катапульт.

+
+ +
+
+ 🛡️ Прочность растений (HP) + 1.0x +
+ +

Здоровье Стеноорехов, Высоких орехов и обычных трав.

+
+ +
+
+ ⏳ Скорость перезарядки семян (CD) + 1.0x +
+ +

Меньше — быстрее готовность пакетов семян в слотах.

+
+ +
+
+ ☀️ Частота производства подсолнухов + 1.0x +
+ +

Меньше — чаще Подсолнухи и Солнцегрибы генерируют солнце.

+
+
+
+
+ + +
+
+
+
+
🌿 Баланс Сада Дзен и пассивного дохода
+
Шанс выпадения саженцев, скорость роста и сбор монет улиткой Стинки.
+
+ +
+ +
+
+
+ 🎁 Шанс выпадения саженцев из зомби + 1.0x +
+ +

Частота получения новых горшечных ростков в боях.

+
+ +
+
+ 🌱 Скорость созревания растений + 1.0x +
+ +

Ускорение перехода саженца от ростка до взрослого цветка.

+
+ +
+
+ 🧪 Эффективность удобрений + 1.0x +
+ +

Бонус к производству золотых монет при уходе.

+
+ +
+
+ 💧 Интервал полива (минуты) + 30 мин +
+ +

Как часто растениям в саду требуется вода.

+
+
+
+
+ + +
+
+
+
+
⚡ Готовые пресеты сложности и Live-События
+
Мгновенное переключение баланса всей игры на сервере одним нажатием.
+
+
+ +

📚 Каталог пресетов баланса

+
+
+
🌻 Classic 2009
+
Классический баланс оригинальной Plants vs. Zombies 2009 года. Полная гармония и аутентичность.
+ +
+ +
+
💀 Hardcore
+
Для ветеранов: зомби толще и быстрее (+60% HP, +30% Speed), меньше солнца и золота (-20%), жесткие волны.
+ +
+ +
+
☕ Casual Chill
+
Расслабленный режим: стартовое солнце 150, быстрый откат семян, ослабленные зомби (-40% HP), щедрые монеты.
+ +
+ +
+
💥 Mayhem Mode
+
Полный хаос: тройной спавн зомби, тройной урон растений, фонтаны солнца и золота!
+ +
+ +
+
⚡ Speedrun 2x
+
Скоростное прохождение: зомби и растения работают на скорости 1.5x, мгновенный откат семян.
+ +
+
+
+
+ + +
+
+
+
+
🐛 Модерация баг-репортов сообщества
+
Просмотр отзывов игроков, смена статусов, официальные ответы и удаление спама.
+
+ +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
IDДатаАвторКатегорияСтатусОписание проблемыОтвет / РешениеДействия
Загрузка баг-репортов...
+
+
+
+ + +
+
+
+
+
💾 Резервные копии и восстановление базы данных
+
Полный экспорт всех игроков, профилей, рекордов, конфигураций и баг-репортов в формате JSON.
+
+
+ +
+
+

📤 Экспорт резервной копии

+

Выгрузка полного слепка SQLite базы данных в файл pvz_backup.json для сохранения на локальный диск.

+ +
+ +
+

📥 Восстановление из файла

+

Восстановление базы данных из ранее сохраненного файла бэкапа JSON.

+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/game/configurator/js/configurator.js b/game/configurator/js/configurator.js new file mode 100644 index 0000000..ee5f0cd --- /dev/null +++ b/game/configurator/js/configurator.js @@ -0,0 +1,1601 @@ +/** + * Plants vs. Zombies — Admin Panel & Game Configurator Client + * Handles Admin Authentication, REST API communication, Sliders, Live Events, + * User Management, Presets, Bug Moderation, Campaign Level Editor, + * Active Player Sessions, Login Audit Logs, and Backups. + */ + +class PvZConfiguratorApp { + constructor() { + this.token = localStorage.getItem('pvz_admin_token') || ''; + this.isAuthenticated = false; + this.config = null; + this.stats = null; + this.users = []; + this.bugs = []; + this.levels = []; + this.selectedLevel = null; + this.sessions = []; + this.loginLogs = []; + this.activeTab = 'tab-dashboard'; + + this.init(); + } + + async init() { + this.bindEvents(); + this.setupSliders(); + + // Check initial auth status + if (this.token) { + const valid = await this.verifyAuth(); + if (valid) { + this.onAuthenticated(); + } else { + this.onUnauthenticated(); + this.loadPublicConfig(); + } + } else { + this.onUnauthenticated(); + this.loadPublicConfig(); + } + } + + // ========================================== + // Toast Alerts + // ========================================== + showToast(message, type = 'success') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + const icon = type === 'success' ? '✅' : (type === 'error' ? '❌' : (type === 'warn' ? '⚠️' : 'ℹ️')); + toast.innerHTML = `${icon} ${message}`; + + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3500); + } + + // ========================================== + // Authentication & API Helpers + // ========================================== + async apiRequest(endpoint, method = 'GET', body = null) { + const headers = { + 'Content-Type': 'application/json' + }; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + headers['X-Admin-Token'] = this.token; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + try { + const res = await fetch(endpoint, options); + const data = await res.json(); + return { ok: res.ok, status: res.status, data }; + } catch (err) { + return { ok: false, status: 0, data: { message: err.message || 'Ошибка сети' } }; + } + } + + async verifyAuth() { + const res = await this.apiRequest('/api/admin/stats'); + if (res.ok && res.data.success) { + this.stats = res.data.stats; + this.isAuthenticated = true; + return true; + } + this.isAuthenticated = false; + this.token = ''; + localStorage.removeItem('pvz_admin_token'); + return false; + } + + onAuthenticated() { + this.isAuthenticated = true; + document.getElementById('admin-status-dot').className = 'status-dot'; + document.getElementById('admin-status-text').textContent = 'Администратор'; + document.getElementById('btn-login-modal-open').style.display = 'none'; + const btnPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnPwd) btnPwd.style.display = 'inline-flex'; + document.getElementById('btn-admin-logout').style.display = 'inline-flex'; + + this.loadAllData(); + } + + onUnauthenticated() { + this.isAuthenticated = false; + document.getElementById('admin-status-dot').className = 'status-dot offline'; + document.getElementById('admin-status-text').textContent = 'Гость (Только чтение)'; + document.getElementById('btn-login-modal-open').style.display = 'inline-flex'; + const btnPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnPwd) btnPwd.style.display = 'none'; + document.getElementById('btn-admin-logout').style.display = 'none'; + } + + async loadPublicConfig() { + const res = await this.apiRequest('/api/config'); + if (res.ok) { + this.config = res.data; + this.populateUIFromConfig(); + } + } + + async loadAllData() { + await Promise.all([ + this.loadStats(), + this.loadConfig(), + this.loadUsers(), + this.loadBugs(), + this.loadCampaignLevels(), + this.loadSessions(), + this.loadLoginLogs() + ]); + } + + // ========================================== + // Data Fetching + // ========================================== + async loadStats() { + const res = await this.apiRequest('/api/admin/stats'); + if (res.ok && res.data.success) { + this.stats = res.data.stats; + this.renderDashboard(); + } + } + + async loadConfig() { + const res = await this.apiRequest('/api/admin/config'); + if (res.ok && res.data.success) { + this.config = res.data.config; + this.populateUIFromConfig(); + } + } + + async loadUsers() { + const res = await this.apiRequest('/api/admin/users'); + if (res.ok && res.data.success) { + this.users = res.data.users || []; + this.renderPlayersTable(); + } + } + + async loadBugs() { + const res = await this.apiRequest('/api/admin/bugs'); + if (res.ok && res.data.success) { + this.bugs = res.data.bugs || []; + this.renderBugsTable(); + } + } + + // ========================================== + // UI Rendering & Sliders + // ========================================== + setupSliders() { + document.querySelectorAll('.range-slider').forEach(slider => { + slider.addEventListener('input', (e) => { + const id = e.target.id; + const val = parseFloat(e.target.value); + const badge = document.getElementById(`val-${id}`); + if (badge) { + if (id === 'econ-startingSun') { + badge.textContent = `${val} ☀️`; + } else if (id === 'zen-waterIntervalMinutes') { + badge.textContent = `${val} мин`; + } else { + badge.textContent = `${val.toFixed(1)}x`; + } + } + }); + }); + } + + setSliderValue(id, value, suffix = 'x') { + const el = document.getElementById(id); + const badge = document.getElementById(`val-${id}`); + if (el) el.value = value; + if (badge) { + if (suffix === 'sun') { + badge.textContent = `${value} ☀️`; + } else if (suffix === 'min') { + badge.textContent = `${value} мин`; + } else { + badge.textContent = `${parseFloat(value).toFixed(1)}x`; + } + } + } + + populateUIFromConfig() { + if (!this.config) return; + + // 1. Economy + const eco = this.config.economy || {}; + this.setSliderValue('econ-sunMultiplier', eco.sunMultiplier ?? 1.0); + this.setSliderValue('econ-startingSun', eco.startingSun ?? 50, 'sun'); + this.setSliderValue('econ-coinDropRate', eco.coinDropRate ?? 1.0); + this.setSliderValue('econ-sunDropRate', eco.sunDropRate ?? 1.0); + this.setSliderValue('econ-plantCostMultiplier', eco.plantCostMultiplier ?? 1.0); + this.setSliderValue('econ-upgradeCostMultiplier', eco.upgradeCostMultiplier ?? 1.0); + + // 2. Zombies + const zom = this.config.zombies || {}; + this.setSliderValue('zom-hpMultiplier', zom.hpMultiplier ?? 1.0); + this.setSliderValue('zom-speedMultiplier', zom.speedMultiplier ?? 1.0); + this.setSliderValue('zom-damageMultiplier', zom.damageMultiplier ?? 1.0); + this.setSliderValue('zom-waveDensityMultiplier', zom.waveDensityMultiplier ?? 1.0); + + // 3. Plants + const plt = this.config.plants || {}; + this.setSliderValue('plant-damageMultiplier', plt.damageMultiplier ?? 1.0); + this.setSliderValue('plant-hpMultiplier', plt.hpMultiplier ?? 1.0); + this.setSliderValue('plant-cooldownMultiplier', plt.cooldownMultiplier ?? 1.0); + this.setSliderValue('plant-sunProduceIntervalMultiplier', plt.sunProduceIntervalMultiplier ?? 1.0); + + // 4. Zen Garden + const zen = this.config.zenGarden || {}; + this.setSliderValue('zen-dropRateMultiplier', zen.dropRateMultiplier ?? 1.0); + this.setSliderValue('zen-growthRateMultiplier', zen.growthRateMultiplier ?? 1.0); + this.setSliderValue('zen-fertilizerMultiplier', zen.fertilizerMultiplier ?? 1.0); + this.setSliderValue('zen-waterIntervalMinutes', zen.waterIntervalMinutes ?? 30, 'min'); + + // 5. Events + const ev = this.config.events || {}; + document.getElementById('event-doubleGold').checked = !!ev.doubleGold; + document.getElementById('event-fastCooldowns').checked = !!ev.fastCooldowns; + document.getElementById('event-infiniteSun').checked = !!ev.infiniteSun; + document.getElementById('event-turboZombies').checked = !!ev.turboZombies; + } + + renderDashboard() { + if (!this.stats) return; + document.getElementById('dash-total-users').textContent = this.stats.totalUsers ?? 0; + document.getElementById('dash-active-24h').textContent = this.stats.active24h ?? 0; + document.getElementById('dash-total-coins').textContent = (this.stats.totalCoins ?? 0).toLocaleString('ru-RU'); + document.getElementById('dash-banned-users').textContent = this.stats.bannedUsers ?? 0; + document.getElementById('dash-total-bugs').textContent = this.stats.totalBugs ?? (this.bugs ? this.bugs.length : 0); + document.getElementById('dash-active-preset').textContent = this.stats.activeConfig?.preset || this.config?.preset || 'Classic 2009'; + + const sCount = document.getElementById('dash-active-sessions'); + if (sCount) sCount.textContent = this.stats.activeSessions ?? (this.sessions ? this.sessions.length : 0); + + const lCount = document.getElementById('dash-total-logs'); + if (lCount) lCount.textContent = this.stats.totalLoginLogs ?? (this.loginLogs ? this.loginLogs.length : 0); + + const cCount = document.getElementById('dash-campaign-levels'); + if (cCount) cCount.textContent = this.stats.campaignLevelsCount ?? (this.levels ? this.levels.length : 25); + + const zCount = document.getElementById('dash-total-zombies'); + if (zCount) zCount.textContent = (this.stats.totalZombiesKilled ?? 0).toLocaleString('ru-RU'); + + const pCount = document.getElementById('dash-total-plants'); + if (pCount) pCount.textContent = (this.stats.totalPlantsPlanted ?? 0).toLocaleString('ru-RU'); + + const sunCount = document.getElementById('dash-total-sun'); + if (sunCount) sunCount.textContent = (this.stats.totalSunProduced ?? 0).toLocaleString('ru-RU'); + } + + // ========================================== + // CAMPAIGN LEVEL EDITOR TAB + // ========================================== + async loadCampaignLevels() { + const res = await this.apiRequest('/api/admin/campaign/levels'); + if (res.ok && res.data.success) { + this.levels = res.data.levels || []; + this.renderLevelSelector(); + if (!this.selectedLevel && this.levels.length > 0) { + this.selectLevel(1); + } else if (this.selectedLevel) { + this.selectLevel(this.selectedLevel.level); + } + } + } + + renderLevelSelector() { + const dropdown = document.getElementById('level-select-dropdown'); + const grid = document.getElementById('levels-grid-selector'); + if (!dropdown || !grid) return; + + dropdown.innerHTML = ''; + grid.innerHTML = ''; + + const worldIcons = { 1: '☀️', 2: '🌙', 3: '🏊', 4: '🌫️', 5: '🏠' }; + const worldNames = { 1: 'Солнечный День', 2: 'Ночной Двор', 3: 'Бассейн', 4: 'Ночной Туман', 5: 'Крыша Дома' }; + + this.levels.forEach(lvl => { + const wIcon = worldIcons[lvl.world] || '🌱'; + const opt = document.createElement('option'); + opt.value = lvl.level; + opt.textContent = `${lvl.code}: ${lvl.title} (${wIcon} ${lvl.biome})`; + dropdown.appendChild(opt); + + const pill = document.createElement('div'); + pill.className = `level-pill ${lvl.isBoss || lvl.is_boss ? 'is-boss' : ''} ${this.selectedLevel?.level === lvl.level ? 'active' : ''}`; + pill.id = `level-pill-${lvl.level}`; + pill.title = `${lvl.code} - ${lvl.title} (${worldNames[lvl.world] || 'Мир ' + lvl.world})`; + pill.innerHTML = ` + ${lvl.code} + ${lvl.isBoss || lvl.is_boss ? '👑' : wIcon} + `; + pill.addEventListener('click', () => this.selectLevel(lvl.level)); + grid.appendChild(pill); + }); + + dropdown.onchange = (e) => { + this.selectLevel(parseInt(e.target.value, 10)); + }; + } + + selectLevel(levelNum) { + const lvl = this.levels.find(l => l.level === parseInt(levelNum, 10)) || this.levels[0]; + if (!lvl) return; + this.selectedLevel = JSON.parse(JSON.stringify(lvl)); + + // Update active UI badges and chips + document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active')); + const activePill = document.getElementById(`level-pill-${lvl.level}`); + if (activePill) activePill.classList.add('active'); + + const dropdown = document.getElementById('level-select-dropdown'); + if (dropdown) dropdown.value = lvl.level; + + const worldNames = { 1: '☀️ Солнечный День', 2: '🌙 Ночной Двор', 3: '🏊 Бассейн', 4: '🌫️ Ночной Туман', 5: '🏠 Крыша Дома' }; + document.getElementById('current-level-badge-code').textContent = lvl.code || `${lvl.world}-${lvl.stage}`; + document.getElementById('current-level-badge-world').textContent = worldNames[lvl.world] || `Мир ${lvl.world}`; + + // Fill Basic Info + document.getElementById('level-code').value = lvl.code || ''; + document.getElementById('level-number').value = lvl.level || 1; + document.getElementById('level-title').value = lvl.title || ''; + document.getElementById('level-desc').value = lvl.desc || ''; + document.getElementById('level-world').value = lvl.world || 1; + document.getElementById('level-biome').value = lvl.biome || 'day'; + document.getElementById('level-reward-coins').value = lvl.reward ?? 100; + document.getElementById('level-reward-crystals').value = lvl.rewardCrystals ?? (lvl.reward_crystals ?? 0); + document.getElementById('level-is-boss').checked = !!(lvl.isBoss || lvl.is_boss); + + // Fill Modifiers + const mods = lvl.modifiers || {}; + this.setSliderValue('mod-zombie-hp', mods.zombieHpMult ?? 1.0); + this.setSliderValue('mod-zombie-speed', mods.zombieSpeedMult ?? 1.0); + document.getElementById('mod-startingSun') ? (document.getElementById('mod-startingSun').value = mods.startingSun ?? 50) : null; + document.getElementById('mod-starting-sun').value = mods.startingSun ?? 50; + document.getElementById('mod-special-event').value = mods.specialEvent || 'none'; + document.getElementById('mod-conveyor-deck').checked = !!mods.conveyorDeck; + document.getElementById('mod-fast-recharge').checked = !!mods.fastRecharge; + + // Fill Dialogue + const dia = lvl.dialogue || {}; + document.getElementById('dialogue-speaker').value = dia.speaker || '🧔'; + document.getElementById('dialogue-title').value = dia.title || ''; + document.getElementById('dialogue-text').value = dia.text || ''; + document.getElementById('dialogue-btn-text').value = dia.btnText || 'ДАЛЕЕ / В БОЙ ⚔️'; + this.updateDialoguePreview(); + + // Render Waves + this.renderWavesConfigurator(); + } + + updateDialoguePreview() { + const speaker = document.getElementById('dialogue-speaker').value || '🧔'; + const title = document.getElementById('dialogue-title').value || 'Диалог перед боем'; + const text = document.getElementById('dialogue-text').value || '(Реплика отсутствует)'; + const btn = document.getElementById('dialogue-btn-text').value || 'ДАЛЕЕ / В БОЙ ⚔️'; + + document.getElementById('dialogue-preview-speaker').textContent = speaker; + document.getElementById('dialogue-preview-title').textContent = title; + document.getElementById('dialogue-preview-text').textContent = text; + document.getElementById('dialogue-preview-btn').textContent = btn; + } + + renderWavesConfigurator() { + const container = document.getElementById('level-waves-container'); + if (!container) return; + container.innerHTML = ''; + + let waves = this.selectedLevel?.waves; + if (!waves || (Array.isArray(waves) && waves.length === 0) || (typeof waves === 'object' && Object.keys(waves).length === 0)) { + waves = [ + { title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.5 }] }, + { title: 'Волна 2', isFlag: false, zombies: [{ type: 'normal', count: 6, interval: 3.0 }, { type: 'conehead', count: 2, interval: 4.0 }] }, + { title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'buckethead', count: 2, interval: 3.0 }, { type: 'normal', count: 8, interval: 2.0 }] } + ]; + this.selectedLevel.waves = waves; + } + + const wavesArray = Array.isArray(waves) ? waves : Object.values(waves); + const zombieTypes = [ + { id: 'normal', name: '🧟 Обычный зомби' }, + { id: 'conehead', name: '🪖 Зомби с конусом' }, + { id: 'buckethead', name: '🪣 Зомби с ведром' }, + { id: 'newspaper', name: '📰 Зомби с газетой' }, + { id: 'screendoor', name: '🚪 Зомби с дверью' }, + { id: 'football', name: '🏈 Зомби-футболист' }, + { id: 'dancing', name: '🕺 Танцующий зомби' }, + { id: 'duckytube', name: '🦆 Зомби на уточке' }, + { id: 'polevaulter', name: '🏃 Прыгун с шестом' }, + { id: 'flag', name: '🚩 Флагманский зомби' }, + { id: 'gargantuar', name: '👹 Гаргантюа' }, + { id: 'zomboss', name: '🤖 Доктор Зомбосс' } + ]; + + wavesArray.forEach((wave, wIdx) => { + const card = document.createElement('div'); + card.className = `wave-card ${wave.isFlag ? 'is-final' : ''}`; + card.dataset.waveIndex = wIdx; + + let zombiesHtml = ''; + const zList = wave.zombies || [{ type: 'normal', count: 4, interval: 3.0 }]; + + zList.forEach((z, zIdx) => { + let optHtml = ''; + zombieTypes.forEach(t => { + optHtml += ``; + }); + + zombiesHtml += ` +
+ +
+ Кол-во: + +
+
+ Интервал: + + с +
+ +
+ `; + }); + + card.innerHTML = ` +
+
+ ${wave.isFlag ? '🏁' : '🌊'} + +
+
+ + + +
+
+
+ ${zombiesHtml} +
+ `; + + // Event handlers for this wave card + card.querySelector('.btn-add-zombie-to-wave').addEventListener('click', () => { + this.addZombieToWave(wIdx); + }); + + card.querySelector('.btn-remove-wave').addEventListener('click', () => { + this.removeWave(wIdx); + }); + + card.querySelectorAll('.btn-remove-zombie-entry').forEach((btn, zIdx) => { + btn.addEventListener('click', () => { + this.removeZombieFromWave(wIdx, zIdx); + }); + }); + + container.appendChild(card); + }); + } + + addZombieToWave(waveIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr[waveIdx]) { + if (!wavesArr[waveIdx].zombies) wavesArr[waveIdx].zombies = []; + wavesArr[waveIdx].zombies.push({ type: 'conehead', count: 2, interval: 3.0 }); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + } + + removeZombieFromWave(waveIdx, zombieIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr[waveIdx] && wavesArr[waveIdx].zombies) { + wavesArr[waveIdx].zombies.splice(zombieIdx, 1); + if (wavesArr[waveIdx].zombies.length === 0) { + wavesArr[waveIdx].zombies.push({ type: 'normal', count: 1, interval: 3.0 }); + } + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + } + + addWave() { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + const newIdx = wavesArr.length + 1; + wavesArr.push({ + title: `Волна ${newIdx}`, + isFlag: false, + zombies: [{ type: 'normal', count: 4, interval: 3.0 }] + }); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + this.showToast(`Добавлена волна ${newIdx}`, 'info'); + } + + removeWave(waveIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr.length <= 1) { + this.showToast('Уровень должен содержать минимум 1 волну', 'warn'); + return; + } + wavesArr.splice(waveIdx, 1); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + + collectCurrentLevelData() { + if (!this.selectedLevel) return null; + + const code = document.getElementById('level-code').value.trim() || '1-1'; + const levelNum = parseInt(document.getElementById('level-number').value, 10) || 1; + const title = document.getElementById('level-title').value.trim() || `Уровень ${code}`; + const desc = document.getElementById('level-desc').value.trim(); + const world = parseInt(document.getElementById('level-world').value, 10) || 1; + const biome = document.getElementById('level-biome').value; + const reward = parseInt(document.getElementById('level-reward-coins').value, 10) || 100; + const rewardCrystals = parseInt(document.getElementById('level-reward-crystals').value, 10) || 0; + const isBoss = document.getElementById('level-is-boss').checked ? 1 : 0; + + // Modifiers + const modifiers = { + zombieHpMult: parseFloat(document.getElementById('mod-zombie-hp').value) || 1.0, + zombieSpeedMult: parseFloat(document.getElementById('mod-zombie-speed').value) || 1.0, + startingSun: parseInt(document.getElementById('mod-starting-sun').value, 10) || 50, + specialEvent: document.getElementById('mod-special-event').value || 'none', + conveyorDeck: document.getElementById('mod-conveyor-deck').checked, + fastRecharge: document.getElementById('mod-fast-recharge').checked + }; + + // Dialogue + const dialogue = { + speaker: document.getElementById('dialogue-speaker').value || '🧔', + title: document.getElementById('dialogue-title').value.trim(), + text: document.getElementById('dialogue-text').value.trim(), + btnText: document.getElementById('dialogue-btn-text').value.trim() || 'ДАЛЕЕ / В БОЙ ⚔️' + }; + + // Waves extraction from DOM + const waveCards = document.querySelectorAll('.wave-card'); + const waves = []; + waveCards.forEach((card, wIdx) => { + const titleInput = card.querySelector('.wave-title-input'); + const flagCheck = card.querySelector('.wave-flag-checkbox'); + const zombieRows = card.querySelectorAll('.wave-zombie-row'); + + const zombies = []; + zombieRows.forEach(row => { + const type = row.querySelector('.wave-zombie-type').value; + const count = parseInt(row.querySelector('.wave-zombie-count').value, 10) || 1; + const interval = parseFloat(row.querySelector('.wave-zombie-interval').value) || 2.5; + zombies.push({ type, count, interval }); + }); + + waves.push({ + title: titleInput ? titleInput.value.trim() : `Волна ${wIdx + 1}`, + isFlag: flagCheck ? flagCheck.checked : false, + zombies: zombies.length > 0 ? zombies : [{ type: 'normal', count: 4, interval: 3.0 }] + }); + }); + + const levelData = { + level: levelNum, + world: world, + stage: parseInt(code.split('-')[1], 10) || 1, + code: code, + title: title, + desc: desc, + reward: reward, + rewardCrystals: rewardCrystals, + biome: biome, + isBoss: isBoss, + waves: waves, + modifiers: modifiers, + dialogue: dialogue + }; + + this.selectedLevel = levelData; + return levelData; + } + + async saveCurrentLevel() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const levelData = this.collectCurrentLevelData(); + if (!levelData) return; + + const res = await this.apiRequest('/api/admin/campaign/level/save', 'POST', { level: levelData }); + if (res.ok && res.data.success) { + this.showToast(`Уровень ${levelData.code} успешно сохранен!`, 'success'); + // Update in local array + const idx = this.levels.findIndex(l => l.level === levelData.level); + if (idx !== -1) { + this.levels[idx] = levelData; + } else { + this.levels.push(levelData); + this.levels.sort((a,b) => a.level - b.level); + } + this.renderLevelSelector(); + this.selectLevel(levelData.level); + } else { + this.showToast(res.data.message || 'Ошибка сохранения уровня', 'error'); + } + } + + async resetCurrentLevel() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + if (!this.selectedLevel) return; + + if (!confirm(`Сбросить параметры уровня ${this.selectedLevel.code} к оригинальным заводским настройкам?`)) { + return; + } + + const res = await this.apiRequest('/api/admin/campaign/level/reset', 'POST', { level: this.selectedLevel.level }); + if (res.ok && res.data.success) { + this.showToast(`Уровень ${this.selectedLevel.code} сброшен к стандарту`, 'success'); + await this.loadCampaignLevels(); + } else { + this.showToast(res.data.message || 'Ошибка сброса уровня', 'error'); + } + } + + createNewLevel() { + const nextNum = (this.levels.reduce((max, l) => Math.max(max, l.level || 0), 0)) + 1; + const world = Math.min(5, Math.floor((nextNum - 1) / 5) + 1); + const stage = ((nextNum - 1) % 5) + 1; + const code = `${world}-${stage}`; + + const newLvl = { + level: nextNum, + world: world, + stage: stage, + code: code, + title: `Новый рубеж ${code}`, + desc: `Пользовательский этап кампании ${code}`, + reward: 200, + rewardCrystals: 1, + biome: world === 2 ? 'night' : (world === 3 ? 'pool' : (world === 4 ? 'fog' : (world === 5 ? 'roof' : 'day'))), + isBoss: false, + waves: [ + { title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.0 }] }, + { title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'conehead', count: 3, interval: 2.5 }] } + ], + modifiers: { zombieHpMult: 1.0, zombieSpeedMult: 1.0, startingSun: 50, specialEvent: 'none', conveyorDeck: false, fastRecharge: false }, + dialogue: { speaker: '🧔', title: 'Безумный Дэйв готов к бою:', text: 'Новый уровень! Посади побольше подсолнухов!', btnText: 'ДАЛЕЕ / В БОЙ ⚔️' } + }; + + this.levels.push(newLvl); + this.renderLevelSelector(); + this.selectLevel(nextNum); + this.showToast(`Создан новый этап ${code}. Настройте и нажмите "Сохранить".`, 'info'); + } + + // ========================================== + // ACTIVE PLAYER SESSIONS TAB + // ========================================== + async loadSessions() { + const res = await this.apiRequest('/api/admin/sessions'); + if (res.ok && res.data.success) { + this.sessions = res.data.sessions || []; + const cnt = document.getElementById('sessions-total-count'); + if (cnt) cnt.textContent = this.sessions.length; + const dashCnt = document.getElementById('dash-active-sessions'); + if (dashCnt) dashCnt.textContent = this.sessions.length; + this.renderSessionsTable(); + } + } + + renderSessionsTable(filter = '') { + const tbody = document.getElementById('sessions-table-body'); + if (!tbody) return; + + filter = (filter || document.getElementById('sessions-search-input')?.value || '').toLowerCase().trim(); + + let filtered = this.sessions; + if (filter) { + filtered = filtered.filter(s => + (s.username && s.username.toLowerCase().includes(filter)) || + (s.ipAddress && s.ipAddress.toLowerCase().includes(filter)) || + (s.token && s.token.toLowerCase().includes(filter)) || + (s.deviceName && s.deviceName.toLowerCase().includes(filter)) + ); + } + + if (filtered.length === 0) { + tbody.innerHTML = `Активных сессий не найдено`; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(s => { + const tr = document.createElement('tr'); + const tokenShort = (s.token || '').substring(0, 10) + '...'; + const isOnline = true; + + tr.innerHTML = ` + + ${s.username || 'Гость'} + ${s.isAdmin ? 'ADMIN' : ''} + + ${s.ipAddress || '127.0.0.1'} + ${s.deviceName || 'Браузер'} + ${s.createdAt || '—'} + 🟢 ${s.lastActive || s.createdAt || 'Только что'} + ${tokenShort} + + + + `; + + tr.querySelector('.player-name-link').addEventListener('click', () => { + if (s.userId) this.openEditPlayerModal(s.userId); + }); + + tr.querySelector('.btn-terminate-session').addEventListener('click', () => { + this.terminateSession(s.token, s.username); + }); + + tbody.appendChild(tr); + }); + } + + async terminateSession(token, username = '') { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + if (!confirm(`Принудительно завершить активную сессию для игрока ${username || token}?`)) { + return; + } + + const res = await this.apiRequest('/api/admin/session/terminate', 'POST', { targetToken: token }); + if (res.ok && res.data.success) { + this.showToast('Сессия успешно завершена', 'success'); + await this.loadSessions(); + await this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка завершения сессии', 'error'); + } + } + + // ========================================== + // LOGIN AUDIT LOGS TAB + // ========================================== + async loadLoginLogs() { + const res = await this.apiRequest('/api/admin/logs'); + if (res.ok && res.data.success) { + this.loginLogs = res.data.logs || []; + const cnt = document.getElementById('logs-total-count'); + if (cnt) cnt.textContent = this.loginLogs.length; + const dashCnt = document.getElementById('dash-total-logs'); + if (dashCnt) dashCnt.textContent = this.loginLogs.length; + this.renderLoginLogsTable(); + } + } + + renderLoginLogsTable() { + const tbody = document.getElementById('logs-table-body'); + if (!tbody) return; + + const userFilter = document.getElementById('logs-filter-username')?.value.toLowerCase().trim() || ''; + const ipFilter = document.getElementById('logs-filter-ip')?.value.toLowerCase().trim() || ''; + const statusFilter = document.getElementById('logs-filter-status')?.value || 'all'; + + let filtered = this.loginLogs; + if (userFilter) { + filtered = filtered.filter(l => l.username && l.username.toLowerCase().includes(userFilter)); + } + if (ipFilter) { + filtered = filtered.filter(l => l.ipAddress && l.ipAddress.toLowerCase().includes(ipFilter)); + } + if (statusFilter !== 'all') { + filtered = filtered.filter(l => l.status === statusFilter); + } + + if (filtered.length === 0) { + tbody.innerHTML = `Записей в журнале входов не найдено`; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(log => { + const tr = document.createElement('tr'); + + let statusBadge = `${log.status}`; + if (log.status === 'success') { + statusBadge = `✅ Успешно`; + } else if (log.status === 'registered') { + statusBadge = `🎉 Регистрация`; + } else if (log.status === 'wrong_password') { + statusBadge = `❌ Неверный пароль`; + } else if (log.status === 'banned_login_attempt') { + statusBadge = `🚫 Заблокирован`; + } else if (log.status === 'rate_limited') { + statusBadge = `⚠️ Превышен лимит`; + } else if (log.status === 'user_not_found') { + statusBadge = `❓ Не найден`; + } + + tr.innerHTML = ` + ${log.timestamp || '—'} + ${log.username || '—'} + ${log.ipAddress || '—'} + ${log.deviceName || 'Браузер'} + ${statusBadge} + `; + tbody.appendChild(tr); + }); + } + + // ========================================== + // CHANGE ADMIN PASSWORD ACTION + // ========================================== + async changeAdminPassword() { + const curPwd = document.getElementById('admin-current-password').value.trim(); + const newPwd = document.getElementById('admin-new-password').value.trim(); + const confirmPwd = document.getElementById('admin-confirm-password').value.trim(); + const errDiv = document.getElementById('admin-password-error'); + + if (!newPwd) { + errDiv.textContent = 'Введите новый пароль'; + errDiv.style.display = 'block'; + return; + } + + if (newPwd.length < 6) { + errDiv.textContent = 'Пароль должен содержать минимум 6 символов'; + errDiv.style.display = 'block'; + return; + } + + if (newPwd !== confirmPwd) { + errDiv.textContent = 'Пароли не совпадают'; + errDiv.style.display = 'block'; + return; + } + + errDiv.style.display = 'none'; + + const res = await this.apiRequest('/api/admin/password/change', 'POST', { + currentPassword: curPwd, + newPassword: newPwd + }); + + if (res.ok && res.data.success) { + this.showToast('Мастер-пароль администратора успешно изменен!', 'success'); + document.getElementById('modal-admin-password').classList.remove('active'); + document.getElementById('admin-current-password').value = ''; + document.getElementById('admin-new-password').value = ''; + document.getElementById('admin-confirm-password').value = ''; + } else { + errDiv.textContent = res.data.message || 'Ошибка смены пароля'; + errDiv.style.display = 'block'; + } + } + + // ========================================== + // PLAYERS TABLE & MODALS + // ========================================== + renderPlayersTable() { + const tbody = document.getElementById('players-table-body'); + if (!tbody) return; + + const search = document.getElementById('player-search-input')?.value.toLowerCase().trim() || ''; + const filterStatus = document.getElementById('player-filter-status')?.value || 'all'; + const sort = document.getElementById('player-sort-select')?.value || 'id_asc'; + + let list = [...this.users]; + + if (search) { + list = list.filter(u => u.username.toLowerCase().includes(search) || String(u.id).includes(search)); + } + + if (filterStatus === 'active') list = list.filter(u => !u.isBanned); + else if (filterStatus === 'banned') list = list.filter(u => u.isBanned); + else if (filterStatus === 'admin') list = list.filter(u => u.isAdmin); + + list.sort((a, b) => { + if (sort === 'id_desc') return b.id - a.id; + if (sort === 'coins_desc') return b.coins - a.coins; + if (sort === 'level_desc') return b.level - a.level; + if (sort === 'login_desc') return (b.lastLogin || '').localeCompare(a.lastLogin || ''); + return a.id - b.id; + }); + + if (list.length === 0) { + tbody.innerHTML = `Игроки не найдены`; + return; + } + + tbody.innerHTML = ''; + list.forEach(u => { + const tr = document.createElement('tr'); + + let statusHtml = 'Активен'; + if (u.isBanned) { + statusHtml = `🚫 Бан`; + } else if (u.isAdmin) { + statusHtml = '⭐ Админ'; + } + + tr.innerHTML = ` + #${u.id} + ${u.username} + ${statusHtml} + Ур. ${u.level} + ${(u.coins || 0).toLocaleString('ru-RU')} 💰 + ${(u.highScore || 0).toLocaleString('ru-RU')} + ${u.createdAt || '—'} + ${u.lastLogin || '—'} + +
+ + + + +
+ + `; + + tr.querySelector('.player-name-click').addEventListener('click', () => this.openPlayerStatsModal(u.id)); + tr.querySelector('.btn-view-stats').addEventListener('click', () => this.openPlayerStatsModal(u.id)); + tr.querySelector('.btn-edit-player').addEventListener('click', () => this.openEditPlayerModal(u.id)); + tr.querySelector('.btn-reset-pwd-open').addEventListener('click', () => this.openResetPasswordModal(u.id, u.username)); + tr.querySelector('.btn-delete-player').addEventListener('click', () => this.deletePlayer(u.id, u.username)); + + tbody.appendChild(tr); + }); + } + + async openPlayerStatsModal(userId) { + const res = await this.apiRequest(`/api/admin/user/${userId}`); + if (!res.ok || !res.data.success) { + this.showToast('Ошибка загрузки статистики игрока', 'error'); + return; + } + + const u = res.data.user; + const prof = u.profile || u; + const st = prof.stats || u.stats || {}; + const mats = prof.materials || u.materials || {}; + const zen = prof.zenGarden || u.zenGarden || {}; + const scores = u.scores || []; + + const idInput = document.getElementById('stats-player-id'); + if (idInput) idInput.value = u.id; + + document.getElementById('stats-player-username').textContent = u.username; + document.getElementById('stats-player-id-line').textContent = `#${u.id} (${prof.theme || 'classic'})`; + document.getElementById('stats-player-created').textContent = u.createdAt || '—'; + document.getElementById('stats-player-login').textContent = u.lastLogin || '—'; + + let badge = 'Активен'; + if (u.isBanned) badge = '🚫 Заблокирован'; + else if (u.isAdmin) badge = '⭐ Администратор'; + document.getElementById('stats-player-badge').innerHTML = badge; + + // Lifetime Stats + document.getElementById('stats-zombies-killed').textContent = (st.zombiesKilled || 0).toLocaleString('ru-RU'); + document.getElementById('stats-sun-produced').textContent = (st.sunProduced || 0).toLocaleString('ru-RU'); + document.getElementById('stats-plants-planted').textContent = (st.plantsPlanted || 0).toLocaleString('ru-RU'); + document.getElementById('stats-levels-cleared').textContent = `${st.levelsCompleted || 0} (макс. ${prof.maxUnlockedLevel || 1})`; + document.getElementById('stats-coins-earned').textContent = (st.coinsEarned || prof.coins || 0).toLocaleString('ru-RU'); + + // Resources + document.getElementById('stats-coins-val').textContent = (prof.coins ?? 150).toLocaleString('ru-RU'); + document.getElementById('stats-biohumus-val').textContent = (mats.bioHumus || 0).toLocaleString('ru-RU'); + document.getElementById('stats-scrap-val').textContent = (mats.scrapMetal || 0).toLocaleString('ru-RU'); + document.getElementById('stats-essence-val').textContent = (mats.sunEssence || 0).toLocaleString('ru-RU'); + document.getElementById('stats-crystals-val').textContent = (mats.crystals || 0).toLocaleString('ru-RU'); + + // Zen & Scores Summary + const potsCount = Array.isArray(zen.pots) ? zen.pots.filter(p => p && p.plantType).length : 0; + const snailState = zen.snail?.isAwake ? '🐌 Стинки бодрствует' : '🐌 Стинки спит'; + document.getElementById('stats-zen-summary').innerHTML = ` + Удобрений: ${zen.fertilizerCount ?? 0} | Шоколада: ${zen.chocolateCount ?? 0}
+ Растений: ${potsCount} шт. | ${snailState} + `; + + let scoreTxt = `Кампания: ${(prof.highScore || 0).toLocaleString('ru-RU')}`; + if (scores.length > 0) { + scoreTxt += '
' + scores.slice(0, 2).map(s => `${s.mode}: ${s.score}`).join(' | '); + } + document.getElementById('stats-scores-summary').innerHTML = scoreTxt; + + document.getElementById('modal-player-stats').classList.add('active'); + } + + async openEditPlayerModal(userId) { + const res = await this.apiRequest(`/api/admin/user/${userId}`); + if (!res.ok || !res.data.success) { + this.showToast('Ошибка загрузки профиля игрока', 'error'); + return; + } + + const u = res.data.user; + const prof = u.profile || u; + const mats = prof.materials || u.materials || {}; + const zen = prof.zenGarden || u.zenGarden || {}; + + document.getElementById('edit-player-id').value = u.id; + document.getElementById('edit-player-title-name').textContent = u.username; + document.getElementById('edit-player-coins').value = prof.coins ?? u.coins ?? 150; + document.getElementById('edit-player-level').value = prof.level ?? u.level ?? 1; + document.getElementById('edit-player-maxlevel').value = prof.maxUnlockedLevel ?? u.maxUnlockedLevel ?? 1; + document.getElementById('edit-player-highscore').value = prof.highScore ?? u.highScore ?? 0; + document.getElementById('edit-player-theme').value = prof.theme || u.theme || 'classic'; + + // Materials + document.getElementById('edit-player-mat-bioHumus').value = mats.bioHumus ?? 0; + document.getElementById('edit-player-mat-scrapMetal').value = mats.scrapMetal ?? 0; + document.getElementById('edit-player-mat-sunEssence').value = mats.sunEssence ?? 0; + document.getElementById('edit-player-mat-crystals').value = mats.crystals ?? 0; + + // Zen Garden + document.getElementById('edit-player-zen-fertilizer').value = zen.fertilizerCount ?? 5; + document.getElementById('edit-player-zen-chocolate').value = zen.chocolateCount ?? 2; + + document.getElementById('edit-player-is-admin').checked = !!u.isAdmin; + document.getElementById('edit-player-is-banned').checked = !!u.isBanned; + document.getElementById('edit-player-ban-reason').value = u.banReason || ''; + + const banCont = document.getElementById('edit-ban-reason-container'); + if (banCont) banCont.style.display = u.isBanned ? 'block' : 'none'; + + document.getElementById('modal-edit-player').classList.add('active'); + } + + async savePlayerEdit() { + const id = document.getElementById('edit-player-id').value; + const updates = { + coins: parseInt(document.getElementById('edit-player-coins').value, 10) || 0, + level: parseInt(document.getElementById('edit-player-level').value, 10) || 1, + max_unlocked_level: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1, + maxUnlockedLevel: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1, + high_score: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0, + highScore: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0, + theme: document.getElementById('edit-player-theme').value, + materials: { + bioHumus: parseInt(document.getElementById('edit-player-mat-bioHumus').value, 10) || 0, + scrapMetal: parseInt(document.getElementById('edit-player-mat-scrapMetal').value, 10) || 0, + sunEssence: parseInt(document.getElementById('edit-player-mat-sunEssence').value, 10) || 0, + crystals: parseInt(document.getElementById('edit-player-mat-crystals').value, 10) || 0 + }, + zenGarden: { + fertilizerCount: parseInt(document.getElementById('edit-player-zen-fertilizer').value, 10) || 0, + chocolateCount: parseInt(document.getElementById('edit-player-zen-chocolate').value, 10) || 0 + }, + is_admin: document.getElementById('edit-player-is-admin').checked ? 1 : 0, + isAdmin: document.getElementById('edit-player-is-admin').checked, + is_banned: document.getElementById('edit-player-is-banned').checked ? 1 : 0, + isBanned: document.getElementById('edit-player-is-banned').checked, + ban_reason: document.getElementById('edit-player-ban-reason').value.trim(), + banReason: document.getElementById('edit-player-ban-reason').value.trim() + }; + + const res = await this.apiRequest(`/api/admin/user/${id}/edit`, 'POST', { updates }); + if (res.ok && res.data.success) { + this.showToast('Профиль игрока и ресурсы успешно обновлены!', 'success'); + document.getElementById('modal-edit-player').classList.remove('active'); + this.loadUsers(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + async createPlayer() { + const username = document.getElementById('create-player-username').value.trim(); + const password = document.getElementById('create-player-password').value.trim(); + const coins = parseInt(document.getElementById('create-player-coins').value, 10) || 150; + const isAdmin = document.getElementById('create-player-is-admin').checked; + + if (!username || !password) { + this.showToast('Укажите имя пользователя и пароль', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/user/create', 'POST', { + username, + password, + isAdmin, + initialData: { coins, level: 1, maxUnlockedLevel: 1 } + }); + + if (res.ok && res.data.success) { + this.showToast(`Игрок ${username} создан!`, 'success'); + document.getElementById('modal-create-player').classList.remove('active'); + document.getElementById('create-player-username').value = ''; + document.getElementById('create-player-password').value = ''; + this.loadUsers(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка создания игрока', 'error'); + } + } + + openResetPasswordModal(id, username) { + document.getElementById('reset-pwd-user-id').value = id; + document.getElementById('reset-pwd-username').textContent = username; + document.getElementById('reset-pwd-input').value = ''; + document.getElementById('modal-reset-password').classList.add('active'); + } + + async submitResetPassword() { + const id = document.getElementById('reset-pwd-user-id').value; + const pwd = document.getElementById('reset-pwd-input').value.trim(); + if (!pwd) { + this.showToast('Введите новый пароль', 'warn'); + return; + } + + const res = await this.apiRequest(`/api/admin/user/${id}/password`, 'POST', { password: pwd }); + if (res.ok && res.data.success) { + this.showToast('Пароль успешно сброшен', 'success'); + document.getElementById('modal-reset-password').classList.remove('active'); + } else { + this.showToast(res.data.message || 'Ошибка сброса пароля', 'error'); + } + } + + async deletePlayer(id, username) { + if (!confirm(`Вы действительно хотите удалить игрока "${username}" (#${id}) и все его данные безвозвратно?`)) { + return; + } + + const res = await this.apiRequest(`/api/admin/user/${id}/delete`, 'POST'); + if (res.ok && res.data.success) { + this.showToast(`Игрок ${username} удален`, 'success'); + this.loadUsers(); + this.loadStats(); + this.loadSessions(); + } else { + this.showToast(res.data.message || 'Ошибка удаления', 'error'); + } + } + + // ========================================== + // BUGS MODERATION + // ========================================== + renderBugsTable() { + const tbody = document.getElementById('bugs-table-body'); + if (!tbody) return; + + const filterStatus = document.getElementById('bug-filter-status')?.value || 'all'; + const filterCat = document.getElementById('bug-filter-category')?.value || 'all'; + + let list = [...this.bugs]; + if (filterStatus !== 'all') list = list.filter(b => b.status === filterStatus); + if (filterCat !== 'all') list = list.filter(b => b.category === filterCat); + + const badge = document.getElementById('badge-bugs-count'); + if (badge) { + const newCount = this.bugs.filter(b => b.status === 'новый').length; + badge.textContent = newCount; + badge.style.display = newCount > 0 ? 'inline-block' : 'none'; + } + + if (list.length === 0) { + tbody.innerHTML = `Баг-репорты не найдены`; + return; + } + + tbody.innerHTML = ''; + list.forEach(b => { + const tr = document.createElement('tr'); + + let stBadge = `${b.status}`; + if (b.status === 'новый') stBadge = '🔴 Новый'; + else if (b.status === 'в работе') stBadge = '🟡 В работе'; + else if (b.status === 'исправлено') stBadge = '✅ Исправлено'; + else if (b.status === 'отклонено') stBadge = '⚪ Отклонено'; + + tr.innerHTML = ` + #${b.id} + ${b.timestamp || '—'} + ${b.author || 'Гость'} + ${b.category || 'bug'} + ${stBadge} + ${b.text} + ${b.adminReply || '—'} + + + + `; + + tr.querySelector('.btn-moderate-bug').addEventListener('click', () => this.openModerateBugModal(b)); + tbody.appendChild(tr); + }); + } + + openModerateBugModal(b) { + document.getElementById('bug-mod-id').textContent = b.id; + document.getElementById('bug-mod-meta').textContent = `Автор: ${b.author} | Категория: ${b.category} | Дата: ${b.timestamp}`; + document.getElementById('bug-mod-text').textContent = b.text; + document.getElementById('bug-mod-status').value = b.status || 'новый'; + document.getElementById('bug-mod-reply').value = b.adminReply || ''; + + document.getElementById('modal-moderate-bug').classList.add('active'); + } + + async saveBugModeration() { + const id = parseInt(document.getElementById('bug-mod-id').textContent, 10); + const status = document.getElementById('bug-mod-status').value; + const adminReply = document.getElementById('bug-mod-reply').value.trim(); + + const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { status, adminReply }); + if (res.ok && res.data.success) { + this.showToast(`Баг #${id} обновлен`, 'success'); + document.getElementById('modal-moderate-bug').classList.remove('active'); + this.loadBugs(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + async deleteBug() { + const id = parseInt(document.getElementById('bug-mod-id').textContent, 10); + if (!confirm(`Удалить баг-репорт #${id}?`)) return; + + const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { delete: true }); + if (res.ok && res.data.success) { + this.showToast(`Баг #${id} удален`, 'success'); + document.getElementById('modal-moderate-bug').classList.remove('active'); + this.loadBugs(); + } else { + this.showToast(res.data.message || 'Ошибка удаления', 'error'); + } + } + + // ========================================== + // CONFIG PRESETS & BALANCE SAVE + // ========================================== + async applyPreset(presetName) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/config', 'POST', { applyPreset: presetName }); + if (res.ok && res.data.success) { + this.showToast(`Пресет "${presetName}" успешно активирован на сервере!`, 'success'); + this.config = res.data.config; + this.populateUIFromConfig(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка применения пресета', 'error'); + } + } + + async saveBalanceSection(sectionName) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + if (!this.config) this.config = {}; + + if (sectionName === 'economy') { + this.config.economy = { + sunMultiplier: parseFloat(document.getElementById('econ-sunMultiplier').value), + startingSun: parseInt(document.getElementById('econ-startingSun').value, 10), + coinDropRate: parseFloat(document.getElementById('econ-coinDropRate').value), + sunDropRate: parseFloat(document.getElementById('econ-sunDropRate').value), + plantCostMultiplier: parseFloat(document.getElementById('econ-plantCostMultiplier').value), + upgradeCostMultiplier: parseFloat(document.getElementById('econ-upgradeCostMultiplier').value) + }; + } else if (sectionName === 'zombies') { + this.config.zombies = { + hpMultiplier: parseFloat(document.getElementById('zom-hpMultiplier').value), + speedMultiplier: parseFloat(document.getElementById('zom-speedMultiplier').value), + damageMultiplier: parseFloat(document.getElementById('zom-damageMultiplier').value), + waveDensityMultiplier: parseFloat(document.getElementById('zom-waveDensityMultiplier').value) + }; + } else if (sectionName === 'plants') { + this.config.plants = { + damageMultiplier: parseFloat(document.getElementById('plant-damageMultiplier').value), + hpMultiplier: parseFloat(document.getElementById('plant-hpMultiplier').value), + cooldownMultiplier: parseFloat(document.getElementById('plant-cooldownMultiplier').value), + sunProduceIntervalMultiplier: parseFloat(document.getElementById('plant-sunProduceIntervalMultiplier').value) + }; + } else if (sectionName === 'zenGarden') { + this.config.zenGarden = { + dropRateMultiplier: parseFloat(document.getElementById('zen-dropRateMultiplier').value), + growthRateMultiplier: parseFloat(document.getElementById('zen-growthRateMultiplier').value), + fertilizerMultiplier: parseFloat(document.getElementById('zen-fertilizerMultiplier').value), + waterIntervalMinutes: parseInt(document.getElementById('zen-waterIntervalMinutes').value, 10) + }; + } else if (sectionName === 'events') { + this.config.events = { + doubleGold: document.getElementById('event-doubleGold').checked, + fastCooldowns: document.getElementById('event-fastCooldowns').checked, + infiniteSun: document.getElementById('event-infiniteSun').checked, + turboZombies: document.getElementById('event-turboZombies').checked, + activeEventName: 'custom' + }; + } + + this.config.preset = 'Кастомный (Custom)'; + + const res = await this.apiRequest('/api/admin/config', 'POST', { config: this.config }); + if (res.ok && res.data.success) { + this.showToast(`Параметры раздела "${sectionName}" сохранены!`, 'success'); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + // ========================================== + // BACKUPS EXPORT & IMPORT + // ========================================== + async exportBackup() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/backup/export'); + if (res.ok && res.data.success) { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(res.data.data, null, 2)); + const downloadAnchor = document.createElement('a'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + downloadAnchor.setAttribute("href", dataStr); + downloadAnchor.setAttribute("download", `pvz_full_backup_${timestamp}.json`); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + this.showToast('Резервная копия успешно выгружена!', 'success'); + } else { + this.showToast(res.data.message || 'Ошибка выгрузки бэкапа', 'error'); + } + } + + async importBackup(file) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const jsonData = JSON.parse(e.target.result); + if (!confirm(`Вы действительно хотите восстановить базу данных из бэкапа "${file.name}"? Текущие данные будут перезаписаны!`)) { + return; + } + + const res = await this.apiRequest('/api/admin/backup/import', 'POST', { data: jsonData }); + if (res.ok && res.data.success) { + this.showToast('База данных успешно восстановлена!', 'success'); + this.loadAllData(); + } else { + this.showToast(res.data.message || 'Ошибка импорта бэкапа', 'error'); + } + } catch (err) { + this.showToast('Неверный формат JSON файла бэкапа', 'error'); + } + }; + reader.readAsText(file); + } + + // ========================================== + // EVENT BINDINGS + // ========================================== + bindEvents() { + // Navigation Tabs + document.querySelectorAll('.nav-tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.nav-tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + + btn.classList.add('active'); + const target = btn.getAttribute('data-tab'); + const pane = document.getElementById(target); + if (pane) pane.classList.add('active'); + this.activeTab = target; + + if (target === 'tab-levels' && this.levels.length === 0) this.loadCampaignLevels(); + if (target === 'tab-sessions') this.loadSessions(); + if (target === 'tab-logs') this.loadLoginLogs(); + }); + }); + + // Login modal + document.getElementById('btn-login-modal-open').addEventListener('click', () => { + document.getElementById('login-error-msg').style.display = 'none'; + document.getElementById('admin-password-input').value = ''; + document.getElementById('modal-login').classList.add('active'); + }); + + document.getElementById('btn-submit-login').addEventListener('click', async () => { + const pwd = document.getElementById('admin-password-input').value.trim(); + if (!pwd) return; + + const res = await this.apiRequest('/api/admin/auth', 'POST', { password: pwd }); + if (res.ok && res.data.success) { + this.token = res.data.token; + localStorage.setItem('pvz_admin_token', this.token); + this.onAuthenticated(); + document.getElementById('modal-login').classList.remove('active'); + this.showToast('Авторизация администратора успешна!', 'success'); + } else { + const err = document.getElementById('login-error-msg'); + err.textContent = res.data.message || 'Неверный пароль администратора'; + err.style.display = 'block'; + } + }); + + // Change Admin Password Modal + const btnOpenPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnOpenPwd) { + btnOpenPwd.addEventListener('click', () => { + document.getElementById('admin-password-error').style.display = 'none'; + document.getElementById('admin-current-password').value = ''; + document.getElementById('admin-new-password').value = ''; + document.getElementById('admin-confirm-password').value = ''; + document.getElementById('modal-admin-password').classList.add('active'); + }); + } + + const btnSubmitPwd = document.getElementById('btn-submit-change-admin-password'); + if (btnSubmitPwd) { + btnSubmitPwd.addEventListener('click', () => this.changeAdminPassword()); + } + + // Logout + document.getElementById('btn-admin-logout').addEventListener('click', () => { + this.token = ''; + localStorage.removeItem('pvz_admin_token'); + this.onUnauthenticated(); + this.showToast('Вы вышли из панели администратора', 'info'); + }); + + // Close Modals + document.querySelectorAll('[data-close]').forEach(btn => { + btn.addEventListener('click', () => { + const modalId = btn.getAttribute('data-close'); + const modal = document.getElementById(modalId); + if (modal) modal.classList.remove('active'); + }); + }); + + // Campaign Level Editor Actions + const btnSaveLevel = document.getElementById('btn-save-level'); + if (btnSaveLevel) btnSaveLevel.addEventListener('click', () => this.saveCurrentLevel()); + + const btnResetLevel = document.getElementById('btn-reset-level'); + if (btnResetLevel) btnResetLevel.addEventListener('click', () => this.resetCurrentLevel()); + + const btnReloadLevels = document.getElementById('btn-reload-levels'); + if (btnReloadLevels) btnReloadLevels.addEventListener('click', () => this.loadCampaignLevels()); + + const btnCreateLvl = document.getElementById('btn-create-custom-level'); + if (btnCreateLvl) btnCreateLvl.addEventListener('click', () => this.createNewLevel()); + + const btnAddWave = document.getElementById('btn-add-wave'); + if (btnAddWave) btnAddWave.addEventListener('click', () => this.addWave()); + + // Dialogue Live Preview Binds + ['dialogue-speaker', 'dialogue-title', 'dialogue-text', 'dialogue-btn-text'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => this.updateDialoguePreview()); + }); + + // Sessions Actions + const btnRefSessions = document.getElementById('btn-refresh-sessions'); + if (btnRefSessions) btnRefSessions.addEventListener('click', () => this.loadSessions()); + + const searchSessions = document.getElementById('sessions-search-input'); + if (searchSessions) searchSessions.addEventListener('input', (e) => this.renderSessionsTable(e.target.value)); + + // Logs Actions + const btnRefLogs = document.getElementById('btn-refresh-logs'); + if (btnRefLogs) btnRefLogs.addEventListener('click', () => this.loadLoginLogs()); + + ['logs-filter-username', 'logs-filter-ip', 'logs-filter-status'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => this.renderLoginLogsTable()); + if (el) el.addEventListener('change', () => this.renderLoginLogsTable()); + }); + + // Player Actions + document.getElementById('btn-create-player-open').addEventListener('click', () => { + document.getElementById('modal-create-player').classList.add('active'); + }); + document.getElementById('btn-submit-create-player').addEventListener('click', () => this.createPlayer()); + document.getElementById('btn-save-player-edit').addEventListener('click', () => this.savePlayerEdit()); + document.getElementById('btn-submit-reset-pwd').addEventListener('click', () => this.submitResetPassword()); + document.getElementById('btn-refresh-players').addEventListener('click', () => this.loadUsers()); + document.getElementById('player-search-input').addEventListener('input', () => this.renderPlayersTable()); + document.getElementById('player-filter-status').addEventListener('change', () => this.renderPlayersTable()); + document.getElementById('player-sort-select').addEventListener('change', () => this.renderPlayersTable()); + document.getElementById('edit-player-is-banned').addEventListener('change', (e) => { + document.getElementById('edit-ban-reason-container').style.display = e.target.checked ? 'block' : 'none'; + }); + + document.getElementById('btn-stats-jump-edit')?.addEventListener('click', () => { + const id = document.getElementById('stats-player-id')?.value; + const modal = document.getElementById('modal-player-stats'); + if (modal) modal.classList.remove('active'); + if (id) this.openEditPlayerModal(id); + }); + + // Balance Save Buttons + document.getElementById('btn-save-economy').addEventListener('click', () => this.saveBalanceSection('economy')); + document.getElementById('btn-save-zombies').addEventListener('click', () => this.saveBalanceSection('zombies')); + document.getElementById('btn-save-plants').addEventListener('click', () => this.saveBalanceSection('plants')); + document.getElementById('btn-save-zengarden').addEventListener('click', () => this.saveBalanceSection('zenGarden')); + document.getElementById('btn-save-events-quick').addEventListener('click', () => this.saveBalanceSection('events')); + + // Presets + document.querySelectorAll('.btn-apply-preset').forEach(btn => { + btn.addEventListener('click', () => { + const preset = btn.getAttribute('data-preset'); + this.applyPreset(preset); + }); + }); + + // Bugs Actions + document.getElementById('btn-refresh-bugs').addEventListener('click', () => this.loadBugs()); + document.getElementById('bug-filter-status').addEventListener('change', () => this.renderBugsTable()); + document.getElementById('bug-filter-category').addEventListener('change', () => this.renderBugsTable()); + document.getElementById('btn-bug-mod-save').addEventListener('click', () => this.saveBugModeration()); + document.getElementById('btn-bug-delete').addEventListener('click', () => this.deleteBug()); + + // Backups Actions + document.getElementById('btn-export-backup').addEventListener('click', () => this.exportBackup()); + document.getElementById('btn-trigger-import').addEventListener('click', () => { + document.getElementById('backup-file-input').click(); + }); + document.getElementById('backup-file-input').addEventListener('change', (e) => { + if (e.target.files && e.target.files[0]) { + this.importBackup(e.target.files[0]); + } + }); + } +} + +// Instantiate on DOM Load +window.addEventListener('DOMContentLoaded', () => { + window.pvzConfigurator = new PvZConfiguratorApp(); +}); diff --git a/game/configurator_work/css/style.css b/game/configurator_work/css/style.css new file mode 100644 index 0000000..8914393 --- /dev/null +++ b/game/configurator_work/css/style.css @@ -0,0 +1,1243 @@ +/* ========================================================== + Plants vs. Zombies — Admin Panel & Game Configurator CSS + Cyberpunk Dark Botanical Theme + ========================================================== */ + +:root { + --bg-main: #0c1017; + --bg-card: rgba(18, 25, 38, 0.85); + --bg-card-hover: rgba(26, 36, 54, 0.95); + --bg-input: #151d2a; + --bg-modal: #111722; + + --border-color: rgba(76, 175, 80, 0.25); + --border-focus: #4caf50; + --border-cyber: rgba(0, 229, 255, 0.4); + + --text-main: #e2e8f0; + --text-muted: #94a3b8; + --text-dim: #64748b; + + --accent-green: #4caf50; + --accent-green-glow: rgba(76, 175, 80, 0.4); + --accent-cyan: #00e5ff; + --accent-cyan-glow: rgba(0, 229, 255, 0.35); + --accent-gold: #ffc107; + --accent-gold-glow: rgba(255, 193, 7, 0.35); + --accent-red: #ff5252; + --accent-purple: #b388ff; + --accent-orange: #ff9800; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.45); + --shadow-glow: 0 0 20px var(--accent-green-glow); + --transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + -webkit-tap-highlight-color: transparent; +} + +body { + background-color: var(--bg-main); + background-image: + radial-gradient(circle at 15% 15%, rgba(76, 175, 80, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(0, 229, 255, 0.06) 0%, transparent 40%), + linear-gradient(180deg, #090d13 0%, #0d121c 100%); + background-attachment: fixed; + color: var(--text-main); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; + width: 100%; + padding: 0 16px; +} + +/* Header */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-icon { + font-size: 32px; + filter: drop-shadow(0 0 10px rgba(76, 175, 80, 0.6)); + animation: pulse-icon 3s ease-in-out infinite alternate; +} + +@keyframes pulse-icon { + 0% { transform: scale(1); } + 100% { transform: scale(1.08); filter: drop-shadow(0 0 14px rgba(0, 229, 255, 0.8)); } +} + +.brand-title { + font-size: 20px; + font-weight: 800; + background: linear-gradient(135deg, #a5d6a7, #00e5ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: 0.5px; +} + +.brand-sub { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-status-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: rgba(76, 175, 80, 0.12); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 600; + color: #81c784; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4caf50; + box-shadow: 0 0 8px #4caf50; +} + +.status-dot.offline { + background: #ff5252; + box-shadow: 0 0 8px #ff5252; +} + +/* Nav Tabs */ +.tabs-navigation { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 12px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + scrollbar-width: thin; +} + +.tabs-navigation::-webkit-scrollbar { + height: 4px; +} +.tabs-navigation::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.nav-tab-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: var(--transition); +} + +.nav-tab-btn:hover { + background: rgba(76, 175, 80, 0.1); + color: var(--text-main); + border-color: var(--border-color); +} + +.nav-tab-btn.active { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.25), rgba(0, 229, 255, 0.15)); + color: #a5d6a7; + border-color: var(--accent-green); + box-shadow: 0 0 16px rgba(76, 175, 80, 0.25); +} + +.badge-count { + background: var(--accent-red); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + font-weight: bold; +} + +/* Content Area */ +.tab-pane { + display: none; + animation: fadeIn 0.25s ease-out; +} + +.tab-pane.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Cards & Grids */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: var(--shadow-card); + display: flex; + align-items: center; + gap: 16px; + transition: var(--transition); +} + +.stat-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent-green); + transform: translateY(-2px); + box-shadow: var(--shadow-card), 0 0 15px rgba(76, 175, 80, 0.15); +} + +.stat-icon { + font-size: 36px; + width: 54px; + height: 54px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.stat-info .stat-value { + font-size: 24px; + font-weight: 800; + color: var(--text-main); + line-height: 1.2; +} + +.stat-info .stat-label { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* Panel Sections */ +.section-panel { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--shadow-card); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.section-title { + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + color: #a5d6a7; +} + +.section-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Event Switch Cards */ +.events-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.event-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + transition: var(--transition); +} + +.event-card:hover { + border-color: var(--border-color); + background: rgba(76, 175, 80, 0.05); +} + +.event-info h4 { + font-size: 14px; + font-weight: 600; + color: var(--text-main); +} + +.event-info p { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Sliders & Form Controls */ +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; +} + +.control-item { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-md); + padding: 16px; + transition: var(--transition); +} + +.control-item:hover { + border-color: var(--border-color); + background: rgba(255, 255, 255, 0.04); +} + +.control-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.control-label { + font-size: 13px; + font-weight: 600; + color: var(--text-main); + display: flex; + align-items: center; + gap: 6px; +} + +.control-badge { + font-size: 12px; + font-weight: 700; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: rgba(0, 229, 255, 0.12); + color: var(--accent-cyan); + border: 1px solid rgba(0, 229, 255, 0.3); +} + +.range-slider { + width: 100%; + height: 6px; + border-radius: 3px; + background: #1e293b; + outline: none; + -webkit-appearance: none; + cursor: pointer; + margin: 10px 0; +} + +.range-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #4caf50; + cursor: pointer; + box-shadow: 0 0 10px rgba(76, 175, 80, 0.8); + border: 2px solid #ffffff; + transition: var(--transition); +} + +.range-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + background: #00e5ff; + box-shadow: 0 0 14px rgba(0, 229, 255, 0.9); +} + +/* Tables */ +.table-responsive { + width: 100%; + overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 13px; +} + +.admin-table th { + background: rgba(255, 255, 255, 0.04); + padding: 12px 16px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +.admin-table td { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.admin-table tbody tr { + transition: var(--transition); +} + +.admin-table tbody tr:hover { + background: rgba(76, 175, 80, 0.06); +} + +/* Badges */ +.pill-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.pill-active { + background: rgba(76, 175, 80, 0.15); + color: #81c784; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.pill-banned { + background: rgba(255, 82, 82, 0.15); + color: #ff8a80; + border: 1px solid rgba(255, 82, 82, 0.3); +} + +.pill-admin { + background: rgba(255, 193, 7, 0.15); + color: #ffd54f; + border: 1px solid rgba(255, 193, 7, 0.3); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: var(--transition); + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, #4caf50, #2e7d32); + color: white; + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #66bb6a, #388e3c); + box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5); + transform: translateY(-1px); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.14); +} + +.btn-danger { + background: linear-gradient(135deg, #e53935, #c62828); + color: white; +} + +.btn-danger:hover { + background: linear-gradient(135deg, #ef5350, #d32f2f); +} + +.btn-cyber { + background: linear-gradient(135deg, #00b4d8, #0077b6); + color: white; + box-shadow: 0 4px 12px rgba(0, 180, 216, 0.3); +} + +.btn-cyber:hover { + background: linear-gradient(135deg, #0096c7, #023e8a); +} + +.btn-sm { + padding: 4px 10px; + font-size: 11px; + border-radius: var(--radius-sm); +} + +.btn-icon-only { + width: 32px; + height: 32px; + padding: 0; + border-radius: var(--radius-sm); +} + +/* Inputs & Form Groups */ +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 10px 14px; + background: var(--bg-input); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + color: var(--text-main); + font-size: 13px; + outline: none; + transition: var(--transition); +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 10px rgba(76, 175, 80, 0.3); +} + +/* Preset Cards */ +.presets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.preset-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + padding: 20px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.preset-card.active { + border-color: var(--accent-green); + background: rgba(76, 175, 80, 0.08); + box-shadow: 0 0 20px rgba(76, 175, 80, 0.2); +} + +.preset-card:hover { + transform: translateY(-2px); + border-color: var(--accent-cyan); +} + +.preset-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.preset-title { + font-size: 16px; + font-weight: 700; + color: var(--text-main); +} + +.preset-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Modals */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + padding: 16px; +} + +.modal-overlay.open { + opacity: 1; + pointer-events: auto; +} + +.modal-box { + background: var(--bg-modal); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 30px rgba(76, 175, 80, 0.2); + animation: modalPop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.modal-box.modal-large { + max-width: 850px; +} + +@keyframes modalPop { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.modal-title { + font-size: 16px; + font-weight: 700; + color: #a5d6a7; + display: flex; + align-items: center; + gap: 8px; +} + +.modal-close-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; + transition: var(--transition); +} + +.modal-close-btn:hover { + color: var(--accent-red); +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Toast Alerts */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 2000; +} + +.toast { + background: #1e293b; + color: white; + padding: 12px 18px; + border-radius: var(--radius-md); + border-left: 4px solid var(--accent-green); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + animation: slideInRight 0.3s ease; + min-width: 280px; +} + +.toast.error { + border-left-color: var(--accent-red); +} + +.toast.warn { + border-left-color: var(--accent-gold); +} + +.toast.info { + border-left-color: var(--accent-cyan); +} + +@keyframes slideInRight { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* Toggle Switch */ +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider-round { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #334155; + transition: var(--transition); + border-radius: 24px; +} + +.slider-round:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: var(--transition); + border-radius: 50%; +} + +input:checked + .slider-round { + background-color: var(--accent-green); + box-shadow: 0 0 10px var(--accent-green-glow); +} + +input:checked + .slider-round:before { + transform: translateX(20px); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .app-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .header-actions { + width: 100%; + justify-content: space-between; + } + .controls-grid { + grid-template-columns: 1fr; + } +} + + +/* ========================================== + Campaign Level Editor Styling + ========================================== */ +.level-selector-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 16px; +} + +.level-selector-left { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.level-badge-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); +} + +.code-badge { + background: var(--accent-green-glow); + color: #a5d6a7; + font-weight: 700; + padding: 3px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent-green); + font-family: monospace; +} + +.world-tag { + background: rgba(0, 229, 255, 0.15); + color: var(--accent-cyan); + padding: 3px 8px; + border-radius: var(--radius-sm); + border: 1px solid rgba(0, 229, 255, 0.3); + font-size: 12px; +} + +.levels-grid-nav { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(65px, 1fr)); + gap: 8px; + margin-bottom: 20px; +} + +.level-pill { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 4px; + background: var(--bg-card); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); + user-select: none; +} + +.level-pill:hover { + background: var(--bg-card-hover); + border-color: var(--border-color); + transform: translateY(-2px); +} + +.level-pill.active { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.35), rgba(0, 229, 255, 0.2)); + border-color: var(--accent-green); + box-shadow: 0 0 12px var(--accent-green-glow); + transform: scale(1.04); +} + +.level-pill.is-boss { + border-color: var(--accent-red); +} + +.level-pill.is-boss.active { + background: linear-gradient(135deg, rgba(255, 82, 82, 0.4), rgba(255, 152, 0, 0.3)); + box-shadow: 0 0 14px rgba(255, 82, 82, 0.45); +} + +.level-pill-num { + font-weight: 700; + font-size: 13px; + color: var(--text-main); + font-family: monospace; +} + +.level-pill-icon { + font-size: 12px; + margin-top: 2px; +} + +.level-editor-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 1024px) { + .level-editor-layout { + grid-template-columns: 1fr; + } +} + +.level-editor-col { + display: flex; + flex-direction: column; +} + +.sub-panel { + background: rgba(18, 25, 38, 0.65); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; + backdrop-filter: blur(10px); +} + +.sub-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 8px; +} + +.sub-panel-title { + font-size: 16px; + font-weight: 700; + color: var(--text-main); + display: flex; + align-items: center; + gap: 8px; +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-row-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; +} + +@media (max-width: 600px) { + .form-row-2, .form-row-3 { + grid-template-columns: 1fr; + } +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + color: var(--text-main); + user-select: none; +} + +/* Visual Waves Configurator */ +.waves-list-container { + display: flex; + flex-direction: column; + gap: 14px; + max-height: 850px; + overflow-y: auto; + padding-right: 4px; +} + +.wave-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(76, 175, 80, 0.2); + border-radius: var(--radius-md); + padding: 14px; + transition: var(--transition); +} + +.wave-card:hover { + border-color: var(--border-cyber); + background: rgba(255, 255, 255, 0.045); +} + +.wave-card.is-final { + border-color: rgba(255, 82, 82, 0.4); + background: rgba(255, 82, 82, 0.04); +} + +.wave-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.wave-card-title { + font-size: 14px; + font-weight: 700; + color: #a5d6a7; + display: flex; + align-items: center; + gap: 8px; +} + +.wave-zombies-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wave-zombie-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-sm); + padding: 8px 10px; + flex-wrap: wrap; +} + +.wave-zombie-type { + flex: 2; + min-width: 140px; +} + +.wave-zombie-count-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 90px; +} + +.wave-zombie-interval-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 90px; +} + +.btn-remove-zombie-entry { + background: transparent; + border: none; + color: var(--accent-red); + cursor: pointer; + font-size: 16px; + padding: 4px; + line-height: 1; + border-radius: 4px; + transition: var(--transition); +} + +.btn-remove-zombie-entry:hover { + background: rgba(255, 82, 82, 0.2); +} + +/* Dave Dialogue Live Preview */ +.dialogue-preview-box { + display: flex; + gap: 14px; + background: linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95)); + border: 2px solid var(--accent-green); + border-radius: var(--radius-lg); + padding: 16px; + margin-top: 14px; + box-shadow: 0 0 16px rgba(76, 175, 80, 0.25); + align-items: flex-start; +} + +.dialogue-preview-speaker { + font-size: 36px; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.dialogue-preview-content { + flex: 1; +} + +.dialogue-preview-title { + font-size: 13px; + font-weight: 700; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.dialogue-preview-text { + font-size: 13px; + color: var(--text-main); + line-height: 1.4; + margin-bottom: 10px; + font-style: italic; +} + +.dialogue-preview-btn { + display: inline-block; + padding: 5px 14px; + background: linear-gradient(135deg, var(--accent-green), #2e7d32); + color: white; + font-size: 11px; + font-weight: 700; + border-radius: var(--radius-full); + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.4); + letter-spacing: 0.5px; +} + +/* Badges & Tables Additions */ +.badge-purple { + background: rgba(179, 136, 255, 0.2); + color: var(--accent-purple); + border: 1px solid rgba(179, 136, 255, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-info { + background: rgba(0, 229, 255, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(0, 229, 255, 0.35); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-success { + background: rgba(76, 175, 80, 0.2); + color: #a5d6a7; + border: 1px solid rgba(76, 175, 80, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-danger { + background: rgba(255, 82, 82, 0.2); + color: #ff8a80; + border: 1px solid rgba(255, 82, 82, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-warning { + background: rgba(255, 193, 7, 0.2); + color: #ffe082; + border: 1px solid rgba(255, 193, 7, 0.4); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; +} + +.badge-muted { + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 12px; +} + +.token-cell { + font-family: monospace; + font-size: 12px; + color: var(--accent-cyan); + background: rgba(0, 0, 0, 0.3); + padding: 2px 6px; + border-radius: 4px; +} + +.player-name-link { + color: var(--accent-cyan); + cursor: pointer; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +} + +.player-name-link:hover { + color: #a5d6a7; +} diff --git a/game/configurator_work/index.html b/game/configurator_work/index.html new file mode 100644 index 0000000..166cc6e --- /dev/null +++ b/game/configurator_work/index.html @@ -0,0 +1,1231 @@ + + + + + + PvZ — Панель администратора и конфигуратор баланса + + + + +
+ +
+
+ 🌻 +
+
PvZ Configurator & Admin Panel
+
Управление балансом, кампанией и сессиями • Plants vs. Zombies
+
+
+
+ 🎮 Играть +
+ + Не авторизован +
+ + + +
+
+ + + + + +
+
+
+
👥
+
+
—
+
Всего игроков
+
+
+
+
⚡
+
+
—
+
Активных за 24ч
+
+
+
+
📱
+
+
—
+
Активных сессий
+
+
+
+
📜
+
+
—
+
Событий входов
+
+
+
+
🎮
+
+
25
+
Этапов кампании
+
+
+
+
💰
+
+
—
+
Всего монет в обороте
+
+
+
+
🚫
+
+
—
+
Заблокировано
+
+
+
+
🐛
+
+
—
+
Баг-репортов
+
+
+
+
🧟
+
+
—
+
Зомби уничтожено (всего)
+
+
+
+
🌱
+
+
—
+
Посажено растений
+
+
+
+
☀️
+
+
—
+
Солнца выращено
+
+
+
+
⚖️
+
+
—
+
Активный пресет
+
+
+
+ + +
+
+
+
⚡ Оперативные Live-События (Серверный переключатель)
+
Включение глобальных событий для всех подключенных игроков в реальном времени.
+
+ +
+
+
+
+

🌟 Double Gold Weekend

+

Удваивает выпадение монет из зомби и в Саду Дзен.

+
+ +
+
+
+

⚡ Fast Cooldowns

+

Ускоренная перезарядка семян для динамичного боя.

+
+ +
+
+
+

☀️ Super Sun Drop

+

Солнце с неба и подсолнухов падает в 2 раза чаще.

+
+ +
+
+
+

🧟 Turbo Zombie Wave

+

Зомби бегут и атакуют с повышенной скоростью (+30%).

+
+ +
+
+
+
+ + +
+
+
+
+
🎮 Визуальный редактор этапов кампании
+
Настройка 25 уровней в 5 мирах: состав волн, биомы, награды, модификаторы и диалоги Дэйва.
+
+
+ + + +
+
+ + +
+
+ + + +
+
+ Текущий этап: 1-1 + ☀️ Солнечный День +
+
+ + +
+ +
+ + +
+ +
+ +
+

📋 Основная информация об этапе

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

⚡ Модификаторы и правила этапа

+ +
+
+ 🧟 Здоровье зомби (HP Multiplier) + 1.0x +
+ +
+ +
+
+ 🏃 Скорость зомби (Speed Multiplier) + 1.0x +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+

🧔 Диалог Безумного Дэйва перед боем

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
🧔
+
+
Безумный Дэйв предупреждает:
+
Эй, сосед! Будь начеку!
+
ДАЛЕЕ / В БОЙ ⚔️
+
+
+
+
+ + +
+
+
+
+

🌊 Визуальный конфигуратор волн

+
Настройка состава зомби, плотности и таймингов каждой волны.
+
+ +
+ + +
+ +
+
+
+
+
+
+ + +
+
+
+
+
📱 Активные сессии игроков в реальном времени
+
Мониторинг авторизованных токенов, IP-адресов, устройств и завершение подозрительных сессий.
+
+
+ Активно: 0 + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + +
ИгрокIP-адресУстройство и браузерСозданаПоследняя активностьТокенДействия
Загрузка активных сессий...
+
+
+
+ + +
+
+
+
+
📜 Журнал аудита авторизаций и входов
+
Полная история попыток входа, регистраций, блокировок и сработок защиты от перебора.
+
+
+ Событий: 0 + +
+
+ + +
+ + + +
+ +
+ + + + + + + + + + + + + +
Время событияПользовательIP-адресУстройство и браузерСтатус аутентификации
Загрузка журнала аудита...
+
+
+
+ + +
+
+
+
+
👥 База учетных записей и прогресса игроков
+
Поиск, просмотр инвентаря, правка баланса, бан и сброс паролей.
+
+
+ + +
+
+ + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + +
IDНикнеймСтатусУровеньМонетыРекордРегистрацияПоследний входДействия
Загрузка игроков...
+
+
+
+ + +
+
+
+
+
💰 Баланс экономики, цен и солнца
+
Управление генерацией ресурсов, стоимостью карт и дропом монет.
+
+ +
+ +
+
+
+ ☀️ Множитель солнца + 1.0x +
+ +

Увеличивает количество солнца, даваемое подсолнухами и падающее с неба.

+
+ +
+
+ 🌱 Стартовое солнце + 50 +
+ +

Количество солнца в начале каждого уровня.

+
+ +
+
+ 🪙 Дроп монет из зомби + 1.0x +
+ +

Вероятность и частота выпадения серебряных и золотых монет.

+
+ +
+
+ ☁️ Частота солнца с неба + 1.0x +
+ +

Интервал падения небесного солнца на дневных уровнях.

+
+ +
+
+ 🛒 Множитель цен в магазине + 1.0x +
+ +

Стоимость покупки закрытых растений в магазине Дэйва.

+
+ +
+
+ 🔧 Стоимость крафта в мастерской + 1.0x +
+ +

Расход материалов на прокачку 2 и 3 уровней растений.

+
+
+
+
+ + +
+
+
+
+
🧟 Баланс зомби и боевой сложности
+
Глобальные множители характеристик орды и плотности волн.
+
+ +
+ +
+
+
+ ❤️ Здоровье зомби (HP) + 1.0x +
+ +

Запас прочности всех типов зомби и боссов.

+
+ +
+
+ 🏃 Скорость передвижения + 1.0x +
+ +

Скорость ходьбы и бега зомби по газону.

+
+ +
+
+ 💥 Урон от укусов зомби + 1.0x +
+ +

Урон, наносимый растениям при поедании.

+
+ +
+
+ 🌊 Плотность спавна волн + 1.0x +
+ +

Количество зомби, появляющихся в каждой волне.

+
+
+
+
+ + +
+
+
+
+
🌻 Баланс растений и стрельбы
+
Урон снарядов, прочность стеноорехов и скорость перезарядки семян.
+
+ +
+ +
+
+
+ 🎯 Урон снарядов растений + 1.0x +
+ +

Урон всех типов горошин, спор и снарядов катапульт.

+
+ +
+
+ 🛡️ Прочность растений (HP) + 1.0x +
+ +

Здоровье Стеноорехов, Высоких орехов и обычных трав.

+
+ +
+
+ ⏳ Скорость перезарядки семян (CD) + 1.0x +
+ +

Меньше — быстрее готовность пакетов семян в слотах.

+
+ +
+
+ ☀️ Частота производства подсолнухов + 1.0x +
+ +

Меньше — чаще Подсолнухи и Солнцегрибы генерируют солнце.

+
+
+
+
+ + +
+
+
+
+
🌿 Баланс Сада Дзен и пассивного дохода
+
Шанс выпадения саженцев, скорость роста и сбор монет улиткой Стинки.
+
+ +
+ +
+
+
+ 🎁 Шанс выпадения саженцев из зомби + 1.0x +
+ +

Частота получения новых горшечных ростков в боях.

+
+ +
+
+ 🌱 Скорость созревания растений + 1.0x +
+ +

Ускорение перехода саженца от ростка до взрослого цветка.

+
+ +
+
+ 🧪 Эффективность удобрений + 1.0x +
+ +

Бонус к производству золотых монет при уходе.

+
+ +
+
+ 💧 Интервал полива (минуты) + 30 мин +
+ +

Как часто растениям в саду требуется вода.

+
+
+
+
+ + +
+
+
+
+
⚡ Готовые пресеты сложности и Live-События
+
Мгновенное переключение баланса всей игры на сервере одним нажатием.
+
+
+ +

📚 Каталог пресетов баланса

+
+
+
🌻 Classic 2009
+
Классический баланс оригинальной Plants vs. Zombies 2009 года. Полная гармония и аутентичность.
+ +
+ +
+
💀 Hardcore
+
Для ветеранов: зомби толще и быстрее (+60% HP, +30% Speed), меньше солнца и золота (-20%), жесткие волны.
+ +
+ +
+
☕ Casual Chill
+
Расслабленный режим: стартовое солнце 150, быстрый откат семян, ослабленные зомби (-40% HP), щедрые монеты.
+ +
+ +
+
💥 Mayhem Mode
+
Полный хаос: тройной спавн зомби, тройной урон растений, фонтаны солнца и золота!
+ +
+ +
+
⚡ Speedrun 2x
+
Скоростное прохождение: зомби и растения работают на скорости 1.5x, мгновенный откат семян.
+ +
+
+
+
+ + +
+
+
+
+
🐛 Модерация баг-репортов сообщества
+
Просмотр отзывов игроков, смена статусов, официальные ответы и удаление спама.
+
+ +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
IDДатаАвторКатегорияСтатусОписание проблемыОтвет / РешениеДействия
Загрузка баг-репортов...
+
+
+
+ + +
+
+
+
+
💾 Резервные копии и восстановление базы данных
+
Полный экспорт всех игроков, профилей, рекордов, конфигураций и баг-репортов в формате JSON.
+
+
+ +
+
+

📤 Экспорт резервной копии

+

Выгрузка полного слепка SQLite базы данных в файл pvz_backup.json для сохранения на локальный диск.

+ +
+ +
+

📥 Восстановление из файла

+

Восстановление базы данных из ранее сохраненного файла бэкапа JSON.

+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/game/configurator_work/js/configurator.js b/game/configurator_work/js/configurator.js new file mode 100644 index 0000000..ee5f0cd --- /dev/null +++ b/game/configurator_work/js/configurator.js @@ -0,0 +1,1601 @@ +/** + * Plants vs. Zombies — Admin Panel & Game Configurator Client + * Handles Admin Authentication, REST API communication, Sliders, Live Events, + * User Management, Presets, Bug Moderation, Campaign Level Editor, + * Active Player Sessions, Login Audit Logs, and Backups. + */ + +class PvZConfiguratorApp { + constructor() { + this.token = localStorage.getItem('pvz_admin_token') || ''; + this.isAuthenticated = false; + this.config = null; + this.stats = null; + this.users = []; + this.bugs = []; + this.levels = []; + this.selectedLevel = null; + this.sessions = []; + this.loginLogs = []; + this.activeTab = 'tab-dashboard'; + + this.init(); + } + + async init() { + this.bindEvents(); + this.setupSliders(); + + // Check initial auth status + if (this.token) { + const valid = await this.verifyAuth(); + if (valid) { + this.onAuthenticated(); + } else { + this.onUnauthenticated(); + this.loadPublicConfig(); + } + } else { + this.onUnauthenticated(); + this.loadPublicConfig(); + } + } + + // ========================================== + // Toast Alerts + // ========================================== + showToast(message, type = 'success') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + const icon = type === 'success' ? '✅' : (type === 'error' ? '❌' : (type === 'warn' ? '⚠️' : 'ℹ️')); + toast.innerHTML = `${icon} ${message}`; + + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3500); + } + + // ========================================== + // Authentication & API Helpers + // ========================================== + async apiRequest(endpoint, method = 'GET', body = null) { + const headers = { + 'Content-Type': 'application/json' + }; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + headers['X-Admin-Token'] = this.token; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + try { + const res = await fetch(endpoint, options); + const data = await res.json(); + return { ok: res.ok, status: res.status, data }; + } catch (err) { + return { ok: false, status: 0, data: { message: err.message || 'Ошибка сети' } }; + } + } + + async verifyAuth() { + const res = await this.apiRequest('/api/admin/stats'); + if (res.ok && res.data.success) { + this.stats = res.data.stats; + this.isAuthenticated = true; + return true; + } + this.isAuthenticated = false; + this.token = ''; + localStorage.removeItem('pvz_admin_token'); + return false; + } + + onAuthenticated() { + this.isAuthenticated = true; + document.getElementById('admin-status-dot').className = 'status-dot'; + document.getElementById('admin-status-text').textContent = 'Администратор'; + document.getElementById('btn-login-modal-open').style.display = 'none'; + const btnPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnPwd) btnPwd.style.display = 'inline-flex'; + document.getElementById('btn-admin-logout').style.display = 'inline-flex'; + + this.loadAllData(); + } + + onUnauthenticated() { + this.isAuthenticated = false; + document.getElementById('admin-status-dot').className = 'status-dot offline'; + document.getElementById('admin-status-text').textContent = 'Гость (Только чтение)'; + document.getElementById('btn-login-modal-open').style.display = 'inline-flex'; + const btnPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnPwd) btnPwd.style.display = 'none'; + document.getElementById('btn-admin-logout').style.display = 'none'; + } + + async loadPublicConfig() { + const res = await this.apiRequest('/api/config'); + if (res.ok) { + this.config = res.data; + this.populateUIFromConfig(); + } + } + + async loadAllData() { + await Promise.all([ + this.loadStats(), + this.loadConfig(), + this.loadUsers(), + this.loadBugs(), + this.loadCampaignLevels(), + this.loadSessions(), + this.loadLoginLogs() + ]); + } + + // ========================================== + // Data Fetching + // ========================================== + async loadStats() { + const res = await this.apiRequest('/api/admin/stats'); + if (res.ok && res.data.success) { + this.stats = res.data.stats; + this.renderDashboard(); + } + } + + async loadConfig() { + const res = await this.apiRequest('/api/admin/config'); + if (res.ok && res.data.success) { + this.config = res.data.config; + this.populateUIFromConfig(); + } + } + + async loadUsers() { + const res = await this.apiRequest('/api/admin/users'); + if (res.ok && res.data.success) { + this.users = res.data.users || []; + this.renderPlayersTable(); + } + } + + async loadBugs() { + const res = await this.apiRequest('/api/admin/bugs'); + if (res.ok && res.data.success) { + this.bugs = res.data.bugs || []; + this.renderBugsTable(); + } + } + + // ========================================== + // UI Rendering & Sliders + // ========================================== + setupSliders() { + document.querySelectorAll('.range-slider').forEach(slider => { + slider.addEventListener('input', (e) => { + const id = e.target.id; + const val = parseFloat(e.target.value); + const badge = document.getElementById(`val-${id}`); + if (badge) { + if (id === 'econ-startingSun') { + badge.textContent = `${val} ☀️`; + } else if (id === 'zen-waterIntervalMinutes') { + badge.textContent = `${val} мин`; + } else { + badge.textContent = `${val.toFixed(1)}x`; + } + } + }); + }); + } + + setSliderValue(id, value, suffix = 'x') { + const el = document.getElementById(id); + const badge = document.getElementById(`val-${id}`); + if (el) el.value = value; + if (badge) { + if (suffix === 'sun') { + badge.textContent = `${value} ☀️`; + } else if (suffix === 'min') { + badge.textContent = `${value} мин`; + } else { + badge.textContent = `${parseFloat(value).toFixed(1)}x`; + } + } + } + + populateUIFromConfig() { + if (!this.config) return; + + // 1. Economy + const eco = this.config.economy || {}; + this.setSliderValue('econ-sunMultiplier', eco.sunMultiplier ?? 1.0); + this.setSliderValue('econ-startingSun', eco.startingSun ?? 50, 'sun'); + this.setSliderValue('econ-coinDropRate', eco.coinDropRate ?? 1.0); + this.setSliderValue('econ-sunDropRate', eco.sunDropRate ?? 1.0); + this.setSliderValue('econ-plantCostMultiplier', eco.plantCostMultiplier ?? 1.0); + this.setSliderValue('econ-upgradeCostMultiplier', eco.upgradeCostMultiplier ?? 1.0); + + // 2. Zombies + const zom = this.config.zombies || {}; + this.setSliderValue('zom-hpMultiplier', zom.hpMultiplier ?? 1.0); + this.setSliderValue('zom-speedMultiplier', zom.speedMultiplier ?? 1.0); + this.setSliderValue('zom-damageMultiplier', zom.damageMultiplier ?? 1.0); + this.setSliderValue('zom-waveDensityMultiplier', zom.waveDensityMultiplier ?? 1.0); + + // 3. Plants + const plt = this.config.plants || {}; + this.setSliderValue('plant-damageMultiplier', plt.damageMultiplier ?? 1.0); + this.setSliderValue('plant-hpMultiplier', plt.hpMultiplier ?? 1.0); + this.setSliderValue('plant-cooldownMultiplier', plt.cooldownMultiplier ?? 1.0); + this.setSliderValue('plant-sunProduceIntervalMultiplier', plt.sunProduceIntervalMultiplier ?? 1.0); + + // 4. Zen Garden + const zen = this.config.zenGarden || {}; + this.setSliderValue('zen-dropRateMultiplier', zen.dropRateMultiplier ?? 1.0); + this.setSliderValue('zen-growthRateMultiplier', zen.growthRateMultiplier ?? 1.0); + this.setSliderValue('zen-fertilizerMultiplier', zen.fertilizerMultiplier ?? 1.0); + this.setSliderValue('zen-waterIntervalMinutes', zen.waterIntervalMinutes ?? 30, 'min'); + + // 5. Events + const ev = this.config.events || {}; + document.getElementById('event-doubleGold').checked = !!ev.doubleGold; + document.getElementById('event-fastCooldowns').checked = !!ev.fastCooldowns; + document.getElementById('event-infiniteSun').checked = !!ev.infiniteSun; + document.getElementById('event-turboZombies').checked = !!ev.turboZombies; + } + + renderDashboard() { + if (!this.stats) return; + document.getElementById('dash-total-users').textContent = this.stats.totalUsers ?? 0; + document.getElementById('dash-active-24h').textContent = this.stats.active24h ?? 0; + document.getElementById('dash-total-coins').textContent = (this.stats.totalCoins ?? 0).toLocaleString('ru-RU'); + document.getElementById('dash-banned-users').textContent = this.stats.bannedUsers ?? 0; + document.getElementById('dash-total-bugs').textContent = this.stats.totalBugs ?? (this.bugs ? this.bugs.length : 0); + document.getElementById('dash-active-preset').textContent = this.stats.activeConfig?.preset || this.config?.preset || 'Classic 2009'; + + const sCount = document.getElementById('dash-active-sessions'); + if (sCount) sCount.textContent = this.stats.activeSessions ?? (this.sessions ? this.sessions.length : 0); + + const lCount = document.getElementById('dash-total-logs'); + if (lCount) lCount.textContent = this.stats.totalLoginLogs ?? (this.loginLogs ? this.loginLogs.length : 0); + + const cCount = document.getElementById('dash-campaign-levels'); + if (cCount) cCount.textContent = this.stats.campaignLevelsCount ?? (this.levels ? this.levels.length : 25); + + const zCount = document.getElementById('dash-total-zombies'); + if (zCount) zCount.textContent = (this.stats.totalZombiesKilled ?? 0).toLocaleString('ru-RU'); + + const pCount = document.getElementById('dash-total-plants'); + if (pCount) pCount.textContent = (this.stats.totalPlantsPlanted ?? 0).toLocaleString('ru-RU'); + + const sunCount = document.getElementById('dash-total-sun'); + if (sunCount) sunCount.textContent = (this.stats.totalSunProduced ?? 0).toLocaleString('ru-RU'); + } + + // ========================================== + // CAMPAIGN LEVEL EDITOR TAB + // ========================================== + async loadCampaignLevels() { + const res = await this.apiRequest('/api/admin/campaign/levels'); + if (res.ok && res.data.success) { + this.levels = res.data.levels || []; + this.renderLevelSelector(); + if (!this.selectedLevel && this.levels.length > 0) { + this.selectLevel(1); + } else if (this.selectedLevel) { + this.selectLevel(this.selectedLevel.level); + } + } + } + + renderLevelSelector() { + const dropdown = document.getElementById('level-select-dropdown'); + const grid = document.getElementById('levels-grid-selector'); + if (!dropdown || !grid) return; + + dropdown.innerHTML = ''; + grid.innerHTML = ''; + + const worldIcons = { 1: '☀️', 2: '🌙', 3: '🏊', 4: '🌫️', 5: '🏠' }; + const worldNames = { 1: 'Солнечный День', 2: 'Ночной Двор', 3: 'Бассейн', 4: 'Ночной Туман', 5: 'Крыша Дома' }; + + this.levels.forEach(lvl => { + const wIcon = worldIcons[lvl.world] || '🌱'; + const opt = document.createElement('option'); + opt.value = lvl.level; + opt.textContent = `${lvl.code}: ${lvl.title} (${wIcon} ${lvl.biome})`; + dropdown.appendChild(opt); + + const pill = document.createElement('div'); + pill.className = `level-pill ${lvl.isBoss || lvl.is_boss ? 'is-boss' : ''} ${this.selectedLevel?.level === lvl.level ? 'active' : ''}`; + pill.id = `level-pill-${lvl.level}`; + pill.title = `${lvl.code} - ${lvl.title} (${worldNames[lvl.world] || 'Мир ' + lvl.world})`; + pill.innerHTML = ` + ${lvl.code} + ${lvl.isBoss || lvl.is_boss ? '👑' : wIcon} + `; + pill.addEventListener('click', () => this.selectLevel(lvl.level)); + grid.appendChild(pill); + }); + + dropdown.onchange = (e) => { + this.selectLevel(parseInt(e.target.value, 10)); + }; + } + + selectLevel(levelNum) { + const lvl = this.levels.find(l => l.level === parseInt(levelNum, 10)) || this.levels[0]; + if (!lvl) return; + this.selectedLevel = JSON.parse(JSON.stringify(lvl)); + + // Update active UI badges and chips + document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active')); + const activePill = document.getElementById(`level-pill-${lvl.level}`); + if (activePill) activePill.classList.add('active'); + + const dropdown = document.getElementById('level-select-dropdown'); + if (dropdown) dropdown.value = lvl.level; + + const worldNames = { 1: '☀️ Солнечный День', 2: '🌙 Ночной Двор', 3: '🏊 Бассейн', 4: '🌫️ Ночной Туман', 5: '🏠 Крыша Дома' }; + document.getElementById('current-level-badge-code').textContent = lvl.code || `${lvl.world}-${lvl.stage}`; + document.getElementById('current-level-badge-world').textContent = worldNames[lvl.world] || `Мир ${lvl.world}`; + + // Fill Basic Info + document.getElementById('level-code').value = lvl.code || ''; + document.getElementById('level-number').value = lvl.level || 1; + document.getElementById('level-title').value = lvl.title || ''; + document.getElementById('level-desc').value = lvl.desc || ''; + document.getElementById('level-world').value = lvl.world || 1; + document.getElementById('level-biome').value = lvl.biome || 'day'; + document.getElementById('level-reward-coins').value = lvl.reward ?? 100; + document.getElementById('level-reward-crystals').value = lvl.rewardCrystals ?? (lvl.reward_crystals ?? 0); + document.getElementById('level-is-boss').checked = !!(lvl.isBoss || lvl.is_boss); + + // Fill Modifiers + const mods = lvl.modifiers || {}; + this.setSliderValue('mod-zombie-hp', mods.zombieHpMult ?? 1.0); + this.setSliderValue('mod-zombie-speed', mods.zombieSpeedMult ?? 1.0); + document.getElementById('mod-startingSun') ? (document.getElementById('mod-startingSun').value = mods.startingSun ?? 50) : null; + document.getElementById('mod-starting-sun').value = mods.startingSun ?? 50; + document.getElementById('mod-special-event').value = mods.specialEvent || 'none'; + document.getElementById('mod-conveyor-deck').checked = !!mods.conveyorDeck; + document.getElementById('mod-fast-recharge').checked = !!mods.fastRecharge; + + // Fill Dialogue + const dia = lvl.dialogue || {}; + document.getElementById('dialogue-speaker').value = dia.speaker || '🧔'; + document.getElementById('dialogue-title').value = dia.title || ''; + document.getElementById('dialogue-text').value = dia.text || ''; + document.getElementById('dialogue-btn-text').value = dia.btnText || 'ДАЛЕЕ / В БОЙ ⚔️'; + this.updateDialoguePreview(); + + // Render Waves + this.renderWavesConfigurator(); + } + + updateDialoguePreview() { + const speaker = document.getElementById('dialogue-speaker').value || '🧔'; + const title = document.getElementById('dialogue-title').value || 'Диалог перед боем'; + const text = document.getElementById('dialogue-text').value || '(Реплика отсутствует)'; + const btn = document.getElementById('dialogue-btn-text').value || 'ДАЛЕЕ / В БОЙ ⚔️'; + + document.getElementById('dialogue-preview-speaker').textContent = speaker; + document.getElementById('dialogue-preview-title').textContent = title; + document.getElementById('dialogue-preview-text').textContent = text; + document.getElementById('dialogue-preview-btn').textContent = btn; + } + + renderWavesConfigurator() { + const container = document.getElementById('level-waves-container'); + if (!container) return; + container.innerHTML = ''; + + let waves = this.selectedLevel?.waves; + if (!waves || (Array.isArray(waves) && waves.length === 0) || (typeof waves === 'object' && Object.keys(waves).length === 0)) { + waves = [ + { title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.5 }] }, + { title: 'Волна 2', isFlag: false, zombies: [{ type: 'normal', count: 6, interval: 3.0 }, { type: 'conehead', count: 2, interval: 4.0 }] }, + { title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'buckethead', count: 2, interval: 3.0 }, { type: 'normal', count: 8, interval: 2.0 }] } + ]; + this.selectedLevel.waves = waves; + } + + const wavesArray = Array.isArray(waves) ? waves : Object.values(waves); + const zombieTypes = [ + { id: 'normal', name: '🧟 Обычный зомби' }, + { id: 'conehead', name: '🪖 Зомби с конусом' }, + { id: 'buckethead', name: '🪣 Зомби с ведром' }, + { id: 'newspaper', name: '📰 Зомби с газетой' }, + { id: 'screendoor', name: '🚪 Зомби с дверью' }, + { id: 'football', name: '🏈 Зомби-футболист' }, + { id: 'dancing', name: '🕺 Танцующий зомби' }, + { id: 'duckytube', name: '🦆 Зомби на уточке' }, + { id: 'polevaulter', name: '🏃 Прыгун с шестом' }, + { id: 'flag', name: '🚩 Флагманский зомби' }, + { id: 'gargantuar', name: '👹 Гаргантюа' }, + { id: 'zomboss', name: '🤖 Доктор Зомбосс' } + ]; + + wavesArray.forEach((wave, wIdx) => { + const card = document.createElement('div'); + card.className = `wave-card ${wave.isFlag ? 'is-final' : ''}`; + card.dataset.waveIndex = wIdx; + + let zombiesHtml = ''; + const zList = wave.zombies || [{ type: 'normal', count: 4, interval: 3.0 }]; + + zList.forEach((z, zIdx) => { + let optHtml = ''; + zombieTypes.forEach(t => { + optHtml += ``; + }); + + zombiesHtml += ` +
+ +
+ Кол-во: + +
+
+ Интервал: + + с +
+ +
+ `; + }); + + card.innerHTML = ` +
+
+ ${wave.isFlag ? '🏁' : '🌊'} + +
+
+ + + +
+
+
+ ${zombiesHtml} +
+ `; + + // Event handlers for this wave card + card.querySelector('.btn-add-zombie-to-wave').addEventListener('click', () => { + this.addZombieToWave(wIdx); + }); + + card.querySelector('.btn-remove-wave').addEventListener('click', () => { + this.removeWave(wIdx); + }); + + card.querySelectorAll('.btn-remove-zombie-entry').forEach((btn, zIdx) => { + btn.addEventListener('click', () => { + this.removeZombieFromWave(wIdx, zIdx); + }); + }); + + container.appendChild(card); + }); + } + + addZombieToWave(waveIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr[waveIdx]) { + if (!wavesArr[waveIdx].zombies) wavesArr[waveIdx].zombies = []; + wavesArr[waveIdx].zombies.push({ type: 'conehead', count: 2, interval: 3.0 }); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + } + + removeZombieFromWave(waveIdx, zombieIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr[waveIdx] && wavesArr[waveIdx].zombies) { + wavesArr[waveIdx].zombies.splice(zombieIdx, 1); + if (wavesArr[waveIdx].zombies.length === 0) { + wavesArr[waveIdx].zombies.push({ type: 'normal', count: 1, interval: 3.0 }); + } + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + } + + addWave() { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + const newIdx = wavesArr.length + 1; + wavesArr.push({ + title: `Волна ${newIdx}`, + isFlag: false, + zombies: [{ type: 'normal', count: 4, interval: 3.0 }] + }); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + this.showToast(`Добавлена волна ${newIdx}`, 'info'); + } + + removeWave(waveIdx) { + this.collectCurrentLevelData(); + let waves = this.selectedLevel.waves; + const wavesArr = Array.isArray(waves) ? waves : Object.values(waves); + if (wavesArr.length <= 1) { + this.showToast('Уровень должен содержать минимум 1 волну', 'warn'); + return; + } + wavesArr.splice(waveIdx, 1); + this.selectedLevel.waves = wavesArr; + this.renderWavesConfigurator(); + } + + collectCurrentLevelData() { + if (!this.selectedLevel) return null; + + const code = document.getElementById('level-code').value.trim() || '1-1'; + const levelNum = parseInt(document.getElementById('level-number').value, 10) || 1; + const title = document.getElementById('level-title').value.trim() || `Уровень ${code}`; + const desc = document.getElementById('level-desc').value.trim(); + const world = parseInt(document.getElementById('level-world').value, 10) || 1; + const biome = document.getElementById('level-biome').value; + const reward = parseInt(document.getElementById('level-reward-coins').value, 10) || 100; + const rewardCrystals = parseInt(document.getElementById('level-reward-crystals').value, 10) || 0; + const isBoss = document.getElementById('level-is-boss').checked ? 1 : 0; + + // Modifiers + const modifiers = { + zombieHpMult: parseFloat(document.getElementById('mod-zombie-hp').value) || 1.0, + zombieSpeedMult: parseFloat(document.getElementById('mod-zombie-speed').value) || 1.0, + startingSun: parseInt(document.getElementById('mod-starting-sun').value, 10) || 50, + specialEvent: document.getElementById('mod-special-event').value || 'none', + conveyorDeck: document.getElementById('mod-conveyor-deck').checked, + fastRecharge: document.getElementById('mod-fast-recharge').checked + }; + + // Dialogue + const dialogue = { + speaker: document.getElementById('dialogue-speaker').value || '🧔', + title: document.getElementById('dialogue-title').value.trim(), + text: document.getElementById('dialogue-text').value.trim(), + btnText: document.getElementById('dialogue-btn-text').value.trim() || 'ДАЛЕЕ / В БОЙ ⚔️' + }; + + // Waves extraction from DOM + const waveCards = document.querySelectorAll('.wave-card'); + const waves = []; + waveCards.forEach((card, wIdx) => { + const titleInput = card.querySelector('.wave-title-input'); + const flagCheck = card.querySelector('.wave-flag-checkbox'); + const zombieRows = card.querySelectorAll('.wave-zombie-row'); + + const zombies = []; + zombieRows.forEach(row => { + const type = row.querySelector('.wave-zombie-type').value; + const count = parseInt(row.querySelector('.wave-zombie-count').value, 10) || 1; + const interval = parseFloat(row.querySelector('.wave-zombie-interval').value) || 2.5; + zombies.push({ type, count, interval }); + }); + + waves.push({ + title: titleInput ? titleInput.value.trim() : `Волна ${wIdx + 1}`, + isFlag: flagCheck ? flagCheck.checked : false, + zombies: zombies.length > 0 ? zombies : [{ type: 'normal', count: 4, interval: 3.0 }] + }); + }); + + const levelData = { + level: levelNum, + world: world, + stage: parseInt(code.split('-')[1], 10) || 1, + code: code, + title: title, + desc: desc, + reward: reward, + rewardCrystals: rewardCrystals, + biome: biome, + isBoss: isBoss, + waves: waves, + modifiers: modifiers, + dialogue: dialogue + }; + + this.selectedLevel = levelData; + return levelData; + } + + async saveCurrentLevel() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const levelData = this.collectCurrentLevelData(); + if (!levelData) return; + + const res = await this.apiRequest('/api/admin/campaign/level/save', 'POST', { level: levelData }); + if (res.ok && res.data.success) { + this.showToast(`Уровень ${levelData.code} успешно сохранен!`, 'success'); + // Update in local array + const idx = this.levels.findIndex(l => l.level === levelData.level); + if (idx !== -1) { + this.levels[idx] = levelData; + } else { + this.levels.push(levelData); + this.levels.sort((a,b) => a.level - b.level); + } + this.renderLevelSelector(); + this.selectLevel(levelData.level); + } else { + this.showToast(res.data.message || 'Ошибка сохранения уровня', 'error'); + } + } + + async resetCurrentLevel() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + if (!this.selectedLevel) return; + + if (!confirm(`Сбросить параметры уровня ${this.selectedLevel.code} к оригинальным заводским настройкам?`)) { + return; + } + + const res = await this.apiRequest('/api/admin/campaign/level/reset', 'POST', { level: this.selectedLevel.level }); + if (res.ok && res.data.success) { + this.showToast(`Уровень ${this.selectedLevel.code} сброшен к стандарту`, 'success'); + await this.loadCampaignLevels(); + } else { + this.showToast(res.data.message || 'Ошибка сброса уровня', 'error'); + } + } + + createNewLevel() { + const nextNum = (this.levels.reduce((max, l) => Math.max(max, l.level || 0), 0)) + 1; + const world = Math.min(5, Math.floor((nextNum - 1) / 5) + 1); + const stage = ((nextNum - 1) % 5) + 1; + const code = `${world}-${stage}`; + + const newLvl = { + level: nextNum, + world: world, + stage: stage, + code: code, + title: `Новый рубеж ${code}`, + desc: `Пользовательский этап кампании ${code}`, + reward: 200, + rewardCrystals: 1, + biome: world === 2 ? 'night' : (world === 3 ? 'pool' : (world === 4 ? 'fog' : (world === 5 ? 'roof' : 'day'))), + isBoss: false, + waves: [ + { title: 'Волна 1', isFlag: false, zombies: [{ type: 'normal', count: 5, interval: 3.0 }] }, + { title: 'Финальная волна 🏁', isFlag: true, zombies: [{ type: 'flag', count: 1, interval: 1.0 }, { type: 'conehead', count: 3, interval: 2.5 }] } + ], + modifiers: { zombieHpMult: 1.0, zombieSpeedMult: 1.0, startingSun: 50, specialEvent: 'none', conveyorDeck: false, fastRecharge: false }, + dialogue: { speaker: '🧔', title: 'Безумный Дэйв готов к бою:', text: 'Новый уровень! Посади побольше подсолнухов!', btnText: 'ДАЛЕЕ / В БОЙ ⚔️' } + }; + + this.levels.push(newLvl); + this.renderLevelSelector(); + this.selectLevel(nextNum); + this.showToast(`Создан новый этап ${code}. Настройте и нажмите "Сохранить".`, 'info'); + } + + // ========================================== + // ACTIVE PLAYER SESSIONS TAB + // ========================================== + async loadSessions() { + const res = await this.apiRequest('/api/admin/sessions'); + if (res.ok && res.data.success) { + this.sessions = res.data.sessions || []; + const cnt = document.getElementById('sessions-total-count'); + if (cnt) cnt.textContent = this.sessions.length; + const dashCnt = document.getElementById('dash-active-sessions'); + if (dashCnt) dashCnt.textContent = this.sessions.length; + this.renderSessionsTable(); + } + } + + renderSessionsTable(filter = '') { + const tbody = document.getElementById('sessions-table-body'); + if (!tbody) return; + + filter = (filter || document.getElementById('sessions-search-input')?.value || '').toLowerCase().trim(); + + let filtered = this.sessions; + if (filter) { + filtered = filtered.filter(s => + (s.username && s.username.toLowerCase().includes(filter)) || + (s.ipAddress && s.ipAddress.toLowerCase().includes(filter)) || + (s.token && s.token.toLowerCase().includes(filter)) || + (s.deviceName && s.deviceName.toLowerCase().includes(filter)) + ); + } + + if (filtered.length === 0) { + tbody.innerHTML = `Активных сессий не найдено`; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(s => { + const tr = document.createElement('tr'); + const tokenShort = (s.token || '').substring(0, 10) + '...'; + const isOnline = true; + + tr.innerHTML = ` + + ${s.username || 'Гость'} + ${s.isAdmin ? 'ADMIN' : ''} + + ${s.ipAddress || '127.0.0.1'} + ${s.deviceName || 'Браузер'} + ${s.createdAt || '—'} + 🟢 ${s.lastActive || s.createdAt || 'Только что'} + ${tokenShort} + + + + `; + + tr.querySelector('.player-name-link').addEventListener('click', () => { + if (s.userId) this.openEditPlayerModal(s.userId); + }); + + tr.querySelector('.btn-terminate-session').addEventListener('click', () => { + this.terminateSession(s.token, s.username); + }); + + tbody.appendChild(tr); + }); + } + + async terminateSession(token, username = '') { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + if (!confirm(`Принудительно завершить активную сессию для игрока ${username || token}?`)) { + return; + } + + const res = await this.apiRequest('/api/admin/session/terminate', 'POST', { targetToken: token }); + if (res.ok && res.data.success) { + this.showToast('Сессия успешно завершена', 'success'); + await this.loadSessions(); + await this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка завершения сессии', 'error'); + } + } + + // ========================================== + // LOGIN AUDIT LOGS TAB + // ========================================== + async loadLoginLogs() { + const res = await this.apiRequest('/api/admin/logs'); + if (res.ok && res.data.success) { + this.loginLogs = res.data.logs || []; + const cnt = document.getElementById('logs-total-count'); + if (cnt) cnt.textContent = this.loginLogs.length; + const dashCnt = document.getElementById('dash-total-logs'); + if (dashCnt) dashCnt.textContent = this.loginLogs.length; + this.renderLoginLogsTable(); + } + } + + renderLoginLogsTable() { + const tbody = document.getElementById('logs-table-body'); + if (!tbody) return; + + const userFilter = document.getElementById('logs-filter-username')?.value.toLowerCase().trim() || ''; + const ipFilter = document.getElementById('logs-filter-ip')?.value.toLowerCase().trim() || ''; + const statusFilter = document.getElementById('logs-filter-status')?.value || 'all'; + + let filtered = this.loginLogs; + if (userFilter) { + filtered = filtered.filter(l => l.username && l.username.toLowerCase().includes(userFilter)); + } + if (ipFilter) { + filtered = filtered.filter(l => l.ipAddress && l.ipAddress.toLowerCase().includes(ipFilter)); + } + if (statusFilter !== 'all') { + filtered = filtered.filter(l => l.status === statusFilter); + } + + if (filtered.length === 0) { + tbody.innerHTML = `Записей в журнале входов не найдено`; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(log => { + const tr = document.createElement('tr'); + + let statusBadge = `${log.status}`; + if (log.status === 'success') { + statusBadge = `✅ Успешно`; + } else if (log.status === 'registered') { + statusBadge = `🎉 Регистрация`; + } else if (log.status === 'wrong_password') { + statusBadge = `❌ Неверный пароль`; + } else if (log.status === 'banned_login_attempt') { + statusBadge = `🚫 Заблокирован`; + } else if (log.status === 'rate_limited') { + statusBadge = `⚠️ Превышен лимит`; + } else if (log.status === 'user_not_found') { + statusBadge = `❓ Не найден`; + } + + tr.innerHTML = ` + ${log.timestamp || '—'} + ${log.username || '—'} + ${log.ipAddress || '—'} + ${log.deviceName || 'Браузер'} + ${statusBadge} + `; + tbody.appendChild(tr); + }); + } + + // ========================================== + // CHANGE ADMIN PASSWORD ACTION + // ========================================== + async changeAdminPassword() { + const curPwd = document.getElementById('admin-current-password').value.trim(); + const newPwd = document.getElementById('admin-new-password').value.trim(); + const confirmPwd = document.getElementById('admin-confirm-password').value.trim(); + const errDiv = document.getElementById('admin-password-error'); + + if (!newPwd) { + errDiv.textContent = 'Введите новый пароль'; + errDiv.style.display = 'block'; + return; + } + + if (newPwd.length < 6) { + errDiv.textContent = 'Пароль должен содержать минимум 6 символов'; + errDiv.style.display = 'block'; + return; + } + + if (newPwd !== confirmPwd) { + errDiv.textContent = 'Пароли не совпадают'; + errDiv.style.display = 'block'; + return; + } + + errDiv.style.display = 'none'; + + const res = await this.apiRequest('/api/admin/password/change', 'POST', { + currentPassword: curPwd, + newPassword: newPwd + }); + + if (res.ok && res.data.success) { + this.showToast('Мастер-пароль администратора успешно изменен!', 'success'); + document.getElementById('modal-admin-password').classList.remove('active'); + document.getElementById('admin-current-password').value = ''; + document.getElementById('admin-new-password').value = ''; + document.getElementById('admin-confirm-password').value = ''; + } else { + errDiv.textContent = res.data.message || 'Ошибка смены пароля'; + errDiv.style.display = 'block'; + } + } + + // ========================================== + // PLAYERS TABLE & MODALS + // ========================================== + renderPlayersTable() { + const tbody = document.getElementById('players-table-body'); + if (!tbody) return; + + const search = document.getElementById('player-search-input')?.value.toLowerCase().trim() || ''; + const filterStatus = document.getElementById('player-filter-status')?.value || 'all'; + const sort = document.getElementById('player-sort-select')?.value || 'id_asc'; + + let list = [...this.users]; + + if (search) { + list = list.filter(u => u.username.toLowerCase().includes(search) || String(u.id).includes(search)); + } + + if (filterStatus === 'active') list = list.filter(u => !u.isBanned); + else if (filterStatus === 'banned') list = list.filter(u => u.isBanned); + else if (filterStatus === 'admin') list = list.filter(u => u.isAdmin); + + list.sort((a, b) => { + if (sort === 'id_desc') return b.id - a.id; + if (sort === 'coins_desc') return b.coins - a.coins; + if (sort === 'level_desc') return b.level - a.level; + if (sort === 'login_desc') return (b.lastLogin || '').localeCompare(a.lastLogin || ''); + return a.id - b.id; + }); + + if (list.length === 0) { + tbody.innerHTML = `Игроки не найдены`; + return; + } + + tbody.innerHTML = ''; + list.forEach(u => { + const tr = document.createElement('tr'); + + let statusHtml = 'Активен'; + if (u.isBanned) { + statusHtml = `🚫 Бан`; + } else if (u.isAdmin) { + statusHtml = '⭐ Админ'; + } + + tr.innerHTML = ` + #${u.id} + ${u.username} + ${statusHtml} + Ур. ${u.level} + ${(u.coins || 0).toLocaleString('ru-RU')} 💰 + ${(u.highScore || 0).toLocaleString('ru-RU')} + ${u.createdAt || '—'} + ${u.lastLogin || '—'} + +
+ + + + +
+ + `; + + tr.querySelector('.player-name-click').addEventListener('click', () => this.openPlayerStatsModal(u.id)); + tr.querySelector('.btn-view-stats').addEventListener('click', () => this.openPlayerStatsModal(u.id)); + tr.querySelector('.btn-edit-player').addEventListener('click', () => this.openEditPlayerModal(u.id)); + tr.querySelector('.btn-reset-pwd-open').addEventListener('click', () => this.openResetPasswordModal(u.id, u.username)); + tr.querySelector('.btn-delete-player').addEventListener('click', () => this.deletePlayer(u.id, u.username)); + + tbody.appendChild(tr); + }); + } + + async openPlayerStatsModal(userId) { + const res = await this.apiRequest(`/api/admin/user/${userId}`); + if (!res.ok || !res.data.success) { + this.showToast('Ошибка загрузки статистики игрока', 'error'); + return; + } + + const u = res.data.user; + const prof = u.profile || u; + const st = prof.stats || u.stats || {}; + const mats = prof.materials || u.materials || {}; + const zen = prof.zenGarden || u.zenGarden || {}; + const scores = u.scores || []; + + const idInput = document.getElementById('stats-player-id'); + if (idInput) idInput.value = u.id; + + document.getElementById('stats-player-username').textContent = u.username; + document.getElementById('stats-player-id-line').textContent = `#${u.id} (${prof.theme || 'classic'})`; + document.getElementById('stats-player-created').textContent = u.createdAt || '—'; + document.getElementById('stats-player-login').textContent = u.lastLogin || '—'; + + let badge = 'Активен'; + if (u.isBanned) badge = '🚫 Заблокирован'; + else if (u.isAdmin) badge = '⭐ Администратор'; + document.getElementById('stats-player-badge').innerHTML = badge; + + // Lifetime Stats + document.getElementById('stats-zombies-killed').textContent = (st.zombiesKilled || 0).toLocaleString('ru-RU'); + document.getElementById('stats-sun-produced').textContent = (st.sunProduced || 0).toLocaleString('ru-RU'); + document.getElementById('stats-plants-planted').textContent = (st.plantsPlanted || 0).toLocaleString('ru-RU'); + document.getElementById('stats-levels-cleared').textContent = `${st.levelsCompleted || 0} (макс. ${prof.maxUnlockedLevel || 1})`; + document.getElementById('stats-coins-earned').textContent = (st.coinsEarned || prof.coins || 0).toLocaleString('ru-RU'); + + // Resources + document.getElementById('stats-coins-val').textContent = (prof.coins ?? 150).toLocaleString('ru-RU'); + document.getElementById('stats-biohumus-val').textContent = (mats.bioHumus || 0).toLocaleString('ru-RU'); + document.getElementById('stats-scrap-val').textContent = (mats.scrapMetal || 0).toLocaleString('ru-RU'); + document.getElementById('stats-essence-val').textContent = (mats.sunEssence || 0).toLocaleString('ru-RU'); + document.getElementById('stats-crystals-val').textContent = (mats.crystals || 0).toLocaleString('ru-RU'); + + // Zen & Scores Summary + const potsCount = Array.isArray(zen.pots) ? zen.pots.filter(p => p && p.plantType).length : 0; + const snailState = zen.snail?.isAwake ? '🐌 Стинки бодрствует' : '🐌 Стинки спит'; + document.getElementById('stats-zen-summary').innerHTML = ` + Удобрений: ${zen.fertilizerCount ?? 0} | Шоколада: ${zen.chocolateCount ?? 0}
+ Растений: ${potsCount} шт. | ${snailState} + `; + + let scoreTxt = `Кампания: ${(prof.highScore || 0).toLocaleString('ru-RU')}`; + if (scores.length > 0) { + scoreTxt += '
' + scores.slice(0, 2).map(s => `${s.mode}: ${s.score}`).join(' | '); + } + document.getElementById('stats-scores-summary').innerHTML = scoreTxt; + + document.getElementById('modal-player-stats').classList.add('active'); + } + + async openEditPlayerModal(userId) { + const res = await this.apiRequest(`/api/admin/user/${userId}`); + if (!res.ok || !res.data.success) { + this.showToast('Ошибка загрузки профиля игрока', 'error'); + return; + } + + const u = res.data.user; + const prof = u.profile || u; + const mats = prof.materials || u.materials || {}; + const zen = prof.zenGarden || u.zenGarden || {}; + + document.getElementById('edit-player-id').value = u.id; + document.getElementById('edit-player-title-name').textContent = u.username; + document.getElementById('edit-player-coins').value = prof.coins ?? u.coins ?? 150; + document.getElementById('edit-player-level').value = prof.level ?? u.level ?? 1; + document.getElementById('edit-player-maxlevel').value = prof.maxUnlockedLevel ?? u.maxUnlockedLevel ?? 1; + document.getElementById('edit-player-highscore').value = prof.highScore ?? u.highScore ?? 0; + document.getElementById('edit-player-theme').value = prof.theme || u.theme || 'classic'; + + // Materials + document.getElementById('edit-player-mat-bioHumus').value = mats.bioHumus ?? 0; + document.getElementById('edit-player-mat-scrapMetal').value = mats.scrapMetal ?? 0; + document.getElementById('edit-player-mat-sunEssence').value = mats.sunEssence ?? 0; + document.getElementById('edit-player-mat-crystals').value = mats.crystals ?? 0; + + // Zen Garden + document.getElementById('edit-player-zen-fertilizer').value = zen.fertilizerCount ?? 5; + document.getElementById('edit-player-zen-chocolate').value = zen.chocolateCount ?? 2; + + document.getElementById('edit-player-is-admin').checked = !!u.isAdmin; + document.getElementById('edit-player-is-banned').checked = !!u.isBanned; + document.getElementById('edit-player-ban-reason').value = u.banReason || ''; + + const banCont = document.getElementById('edit-ban-reason-container'); + if (banCont) banCont.style.display = u.isBanned ? 'block' : 'none'; + + document.getElementById('modal-edit-player').classList.add('active'); + } + + async savePlayerEdit() { + const id = document.getElementById('edit-player-id').value; + const updates = { + coins: parseInt(document.getElementById('edit-player-coins').value, 10) || 0, + level: parseInt(document.getElementById('edit-player-level').value, 10) || 1, + max_unlocked_level: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1, + maxUnlockedLevel: parseInt(document.getElementById('edit-player-maxlevel').value, 10) || 1, + high_score: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0, + highScore: parseInt(document.getElementById('edit-player-highscore').value, 10) || 0, + theme: document.getElementById('edit-player-theme').value, + materials: { + bioHumus: parseInt(document.getElementById('edit-player-mat-bioHumus').value, 10) || 0, + scrapMetal: parseInt(document.getElementById('edit-player-mat-scrapMetal').value, 10) || 0, + sunEssence: parseInt(document.getElementById('edit-player-mat-sunEssence').value, 10) || 0, + crystals: parseInt(document.getElementById('edit-player-mat-crystals').value, 10) || 0 + }, + zenGarden: { + fertilizerCount: parseInt(document.getElementById('edit-player-zen-fertilizer').value, 10) || 0, + chocolateCount: parseInt(document.getElementById('edit-player-zen-chocolate').value, 10) || 0 + }, + is_admin: document.getElementById('edit-player-is-admin').checked ? 1 : 0, + isAdmin: document.getElementById('edit-player-is-admin').checked, + is_banned: document.getElementById('edit-player-is-banned').checked ? 1 : 0, + isBanned: document.getElementById('edit-player-is-banned').checked, + ban_reason: document.getElementById('edit-player-ban-reason').value.trim(), + banReason: document.getElementById('edit-player-ban-reason').value.trim() + }; + + const res = await this.apiRequest(`/api/admin/user/${id}/edit`, 'POST', { updates }); + if (res.ok && res.data.success) { + this.showToast('Профиль игрока и ресурсы успешно обновлены!', 'success'); + document.getElementById('modal-edit-player').classList.remove('active'); + this.loadUsers(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + async createPlayer() { + const username = document.getElementById('create-player-username').value.trim(); + const password = document.getElementById('create-player-password').value.trim(); + const coins = parseInt(document.getElementById('create-player-coins').value, 10) || 150; + const isAdmin = document.getElementById('create-player-is-admin').checked; + + if (!username || !password) { + this.showToast('Укажите имя пользователя и пароль', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/user/create', 'POST', { + username, + password, + isAdmin, + initialData: { coins, level: 1, maxUnlockedLevel: 1 } + }); + + if (res.ok && res.data.success) { + this.showToast(`Игрок ${username} создан!`, 'success'); + document.getElementById('modal-create-player').classList.remove('active'); + document.getElementById('create-player-username').value = ''; + document.getElementById('create-player-password').value = ''; + this.loadUsers(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка создания игрока', 'error'); + } + } + + openResetPasswordModal(id, username) { + document.getElementById('reset-pwd-user-id').value = id; + document.getElementById('reset-pwd-username').textContent = username; + document.getElementById('reset-pwd-input').value = ''; + document.getElementById('modal-reset-password').classList.add('active'); + } + + async submitResetPassword() { + const id = document.getElementById('reset-pwd-user-id').value; + const pwd = document.getElementById('reset-pwd-input').value.trim(); + if (!pwd) { + this.showToast('Введите новый пароль', 'warn'); + return; + } + + const res = await this.apiRequest(`/api/admin/user/${id}/password`, 'POST', { password: pwd }); + if (res.ok && res.data.success) { + this.showToast('Пароль успешно сброшен', 'success'); + document.getElementById('modal-reset-password').classList.remove('active'); + } else { + this.showToast(res.data.message || 'Ошибка сброса пароля', 'error'); + } + } + + async deletePlayer(id, username) { + if (!confirm(`Вы действительно хотите удалить игрока "${username}" (#${id}) и все его данные безвозвратно?`)) { + return; + } + + const res = await this.apiRequest(`/api/admin/user/${id}/delete`, 'POST'); + if (res.ok && res.data.success) { + this.showToast(`Игрок ${username} удален`, 'success'); + this.loadUsers(); + this.loadStats(); + this.loadSessions(); + } else { + this.showToast(res.data.message || 'Ошибка удаления', 'error'); + } + } + + // ========================================== + // BUGS MODERATION + // ========================================== + renderBugsTable() { + const tbody = document.getElementById('bugs-table-body'); + if (!tbody) return; + + const filterStatus = document.getElementById('bug-filter-status')?.value || 'all'; + const filterCat = document.getElementById('bug-filter-category')?.value || 'all'; + + let list = [...this.bugs]; + if (filterStatus !== 'all') list = list.filter(b => b.status === filterStatus); + if (filterCat !== 'all') list = list.filter(b => b.category === filterCat); + + const badge = document.getElementById('badge-bugs-count'); + if (badge) { + const newCount = this.bugs.filter(b => b.status === 'новый').length; + badge.textContent = newCount; + badge.style.display = newCount > 0 ? 'inline-block' : 'none'; + } + + if (list.length === 0) { + tbody.innerHTML = `Баг-репорты не найдены`; + return; + } + + tbody.innerHTML = ''; + list.forEach(b => { + const tr = document.createElement('tr'); + + let stBadge = `${b.status}`; + if (b.status === 'новый') stBadge = '🔴 Новый'; + else if (b.status === 'в работе') stBadge = '🟡 В работе'; + else if (b.status === 'исправлено') stBadge = '✅ Исправлено'; + else if (b.status === 'отклонено') stBadge = '⚪ Отклонено'; + + tr.innerHTML = ` + #${b.id} + ${b.timestamp || '—'} + ${b.author || 'Гость'} + ${b.category || 'bug'} + ${stBadge} + ${b.text} + ${b.adminReply || '—'} + + + + `; + + tr.querySelector('.btn-moderate-bug').addEventListener('click', () => this.openModerateBugModal(b)); + tbody.appendChild(tr); + }); + } + + openModerateBugModal(b) { + document.getElementById('bug-mod-id').textContent = b.id; + document.getElementById('bug-mod-meta').textContent = `Автор: ${b.author} | Категория: ${b.category} | Дата: ${b.timestamp}`; + document.getElementById('bug-mod-text').textContent = b.text; + document.getElementById('bug-mod-status').value = b.status || 'новый'; + document.getElementById('bug-mod-reply').value = b.adminReply || ''; + + document.getElementById('modal-moderate-bug').classList.add('active'); + } + + async saveBugModeration() { + const id = parseInt(document.getElementById('bug-mod-id').textContent, 10); + const status = document.getElementById('bug-mod-status').value; + const adminReply = document.getElementById('bug-mod-reply').value.trim(); + + const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { status, adminReply }); + if (res.ok && res.data.success) { + this.showToast(`Баг #${id} обновлен`, 'success'); + document.getElementById('modal-moderate-bug').classList.remove('active'); + this.loadBugs(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + async deleteBug() { + const id = parseInt(document.getElementById('bug-mod-id').textContent, 10); + if (!confirm(`Удалить баг-репорт #${id}?`)) return; + + const res = await this.apiRequest(`/api/admin/bugs/${id}/status`, 'POST', { delete: true }); + if (res.ok && res.data.success) { + this.showToast(`Баг #${id} удален`, 'success'); + document.getElementById('modal-moderate-bug').classList.remove('active'); + this.loadBugs(); + } else { + this.showToast(res.data.message || 'Ошибка удаления', 'error'); + } + } + + // ========================================== + // CONFIG PRESETS & BALANCE SAVE + // ========================================== + async applyPreset(presetName) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/config', 'POST', { applyPreset: presetName }); + if (res.ok && res.data.success) { + this.showToast(`Пресет "${presetName}" успешно активирован на сервере!`, 'success'); + this.config = res.data.config; + this.populateUIFromConfig(); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка применения пресета', 'error'); + } + } + + async saveBalanceSection(sectionName) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + if (!this.config) this.config = {}; + + if (sectionName === 'economy') { + this.config.economy = { + sunMultiplier: parseFloat(document.getElementById('econ-sunMultiplier').value), + startingSun: parseInt(document.getElementById('econ-startingSun').value, 10), + coinDropRate: parseFloat(document.getElementById('econ-coinDropRate').value), + sunDropRate: parseFloat(document.getElementById('econ-sunDropRate').value), + plantCostMultiplier: parseFloat(document.getElementById('econ-plantCostMultiplier').value), + upgradeCostMultiplier: parseFloat(document.getElementById('econ-upgradeCostMultiplier').value) + }; + } else if (sectionName === 'zombies') { + this.config.zombies = { + hpMultiplier: parseFloat(document.getElementById('zom-hpMultiplier').value), + speedMultiplier: parseFloat(document.getElementById('zom-speedMultiplier').value), + damageMultiplier: parseFloat(document.getElementById('zom-damageMultiplier').value), + waveDensityMultiplier: parseFloat(document.getElementById('zom-waveDensityMultiplier').value) + }; + } else if (sectionName === 'plants') { + this.config.plants = { + damageMultiplier: parseFloat(document.getElementById('plant-damageMultiplier').value), + hpMultiplier: parseFloat(document.getElementById('plant-hpMultiplier').value), + cooldownMultiplier: parseFloat(document.getElementById('plant-cooldownMultiplier').value), + sunProduceIntervalMultiplier: parseFloat(document.getElementById('plant-sunProduceIntervalMultiplier').value) + }; + } else if (sectionName === 'zenGarden') { + this.config.zenGarden = { + dropRateMultiplier: parseFloat(document.getElementById('zen-dropRateMultiplier').value), + growthRateMultiplier: parseFloat(document.getElementById('zen-growthRateMultiplier').value), + fertilizerMultiplier: parseFloat(document.getElementById('zen-fertilizerMultiplier').value), + waterIntervalMinutes: parseInt(document.getElementById('zen-waterIntervalMinutes').value, 10) + }; + } else if (sectionName === 'events') { + this.config.events = { + doubleGold: document.getElementById('event-doubleGold').checked, + fastCooldowns: document.getElementById('event-fastCooldowns').checked, + infiniteSun: document.getElementById('event-infiniteSun').checked, + turboZombies: document.getElementById('event-turboZombies').checked, + activeEventName: 'custom' + }; + } + + this.config.preset = 'Кастомный (Custom)'; + + const res = await this.apiRequest('/api/admin/config', 'POST', { config: this.config }); + if (res.ok && res.data.success) { + this.showToast(`Параметры раздела "${sectionName}" сохранены!`, 'success'); + this.loadStats(); + } else { + this.showToast(res.data.message || 'Ошибка сохранения', 'error'); + } + } + + // ========================================== + // BACKUPS EXPORT & IMPORT + // ========================================== + async exportBackup() { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const res = await this.apiRequest('/api/admin/backup/export'); + if (res.ok && res.data.success) { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(res.data.data, null, 2)); + const downloadAnchor = document.createElement('a'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + downloadAnchor.setAttribute("href", dataStr); + downloadAnchor.setAttribute("download", `pvz_full_backup_${timestamp}.json`); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + this.showToast('Резервная копия успешно выгружена!', 'success'); + } else { + this.showToast(res.data.message || 'Ошибка выгрузки бэкапа', 'error'); + } + } + + async importBackup(file) { + if (!this.isAuthenticated) { + this.showToast('Необходима авторизация администратора', 'warn'); + return; + } + + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const jsonData = JSON.parse(e.target.result); + if (!confirm(`Вы действительно хотите восстановить базу данных из бэкапа "${file.name}"? Текущие данные будут перезаписаны!`)) { + return; + } + + const res = await this.apiRequest('/api/admin/backup/import', 'POST', { data: jsonData }); + if (res.ok && res.data.success) { + this.showToast('База данных успешно восстановлена!', 'success'); + this.loadAllData(); + } else { + this.showToast(res.data.message || 'Ошибка импорта бэкапа', 'error'); + } + } catch (err) { + this.showToast('Неверный формат JSON файла бэкапа', 'error'); + } + }; + reader.readAsText(file); + } + + // ========================================== + // EVENT BINDINGS + // ========================================== + bindEvents() { + // Navigation Tabs + document.querySelectorAll('.nav-tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.nav-tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + + btn.classList.add('active'); + const target = btn.getAttribute('data-tab'); + const pane = document.getElementById(target); + if (pane) pane.classList.add('active'); + this.activeTab = target; + + if (target === 'tab-levels' && this.levels.length === 0) this.loadCampaignLevels(); + if (target === 'tab-sessions') this.loadSessions(); + if (target === 'tab-logs') this.loadLoginLogs(); + }); + }); + + // Login modal + document.getElementById('btn-login-modal-open').addEventListener('click', () => { + document.getElementById('login-error-msg').style.display = 'none'; + document.getElementById('admin-password-input').value = ''; + document.getElementById('modal-login').classList.add('active'); + }); + + document.getElementById('btn-submit-login').addEventListener('click', async () => { + const pwd = document.getElementById('admin-password-input').value.trim(); + if (!pwd) return; + + const res = await this.apiRequest('/api/admin/auth', 'POST', { password: pwd }); + if (res.ok && res.data.success) { + this.token = res.data.token; + localStorage.setItem('pvz_admin_token', this.token); + this.onAuthenticated(); + document.getElementById('modal-login').classList.remove('active'); + this.showToast('Авторизация администратора успешна!', 'success'); + } else { + const err = document.getElementById('login-error-msg'); + err.textContent = res.data.message || 'Неверный пароль администратора'; + err.style.display = 'block'; + } + }); + + // Change Admin Password Modal + const btnOpenPwd = document.getElementById('btn-admin-password-modal-open'); + if (btnOpenPwd) { + btnOpenPwd.addEventListener('click', () => { + document.getElementById('admin-password-error').style.display = 'none'; + document.getElementById('admin-current-password').value = ''; + document.getElementById('admin-new-password').value = ''; + document.getElementById('admin-confirm-password').value = ''; + document.getElementById('modal-admin-password').classList.add('active'); + }); + } + + const btnSubmitPwd = document.getElementById('btn-submit-change-admin-password'); + if (btnSubmitPwd) { + btnSubmitPwd.addEventListener('click', () => this.changeAdminPassword()); + } + + // Logout + document.getElementById('btn-admin-logout').addEventListener('click', () => { + this.token = ''; + localStorage.removeItem('pvz_admin_token'); + this.onUnauthenticated(); + this.showToast('Вы вышли из панели администратора', 'info'); + }); + + // Close Modals + document.querySelectorAll('[data-close]').forEach(btn => { + btn.addEventListener('click', () => { + const modalId = btn.getAttribute('data-close'); + const modal = document.getElementById(modalId); + if (modal) modal.classList.remove('active'); + }); + }); + + // Campaign Level Editor Actions + const btnSaveLevel = document.getElementById('btn-save-level'); + if (btnSaveLevel) btnSaveLevel.addEventListener('click', () => this.saveCurrentLevel()); + + const btnResetLevel = document.getElementById('btn-reset-level'); + if (btnResetLevel) btnResetLevel.addEventListener('click', () => this.resetCurrentLevel()); + + const btnReloadLevels = document.getElementById('btn-reload-levels'); + if (btnReloadLevels) btnReloadLevels.addEventListener('click', () => this.loadCampaignLevels()); + + const btnCreateLvl = document.getElementById('btn-create-custom-level'); + if (btnCreateLvl) btnCreateLvl.addEventListener('click', () => this.createNewLevel()); + + const btnAddWave = document.getElementById('btn-add-wave'); + if (btnAddWave) btnAddWave.addEventListener('click', () => this.addWave()); + + // Dialogue Live Preview Binds + ['dialogue-speaker', 'dialogue-title', 'dialogue-text', 'dialogue-btn-text'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => this.updateDialoguePreview()); + }); + + // Sessions Actions + const btnRefSessions = document.getElementById('btn-refresh-sessions'); + if (btnRefSessions) btnRefSessions.addEventListener('click', () => this.loadSessions()); + + const searchSessions = document.getElementById('sessions-search-input'); + if (searchSessions) searchSessions.addEventListener('input', (e) => this.renderSessionsTable(e.target.value)); + + // Logs Actions + const btnRefLogs = document.getElementById('btn-refresh-logs'); + if (btnRefLogs) btnRefLogs.addEventListener('click', () => this.loadLoginLogs()); + + ['logs-filter-username', 'logs-filter-ip', 'logs-filter-status'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => this.renderLoginLogsTable()); + if (el) el.addEventListener('change', () => this.renderLoginLogsTable()); + }); + + // Player Actions + document.getElementById('btn-create-player-open').addEventListener('click', () => { + document.getElementById('modal-create-player').classList.add('active'); + }); + document.getElementById('btn-submit-create-player').addEventListener('click', () => this.createPlayer()); + document.getElementById('btn-save-player-edit').addEventListener('click', () => this.savePlayerEdit()); + document.getElementById('btn-submit-reset-pwd').addEventListener('click', () => this.submitResetPassword()); + document.getElementById('btn-refresh-players').addEventListener('click', () => this.loadUsers()); + document.getElementById('player-search-input').addEventListener('input', () => this.renderPlayersTable()); + document.getElementById('player-filter-status').addEventListener('change', () => this.renderPlayersTable()); + document.getElementById('player-sort-select').addEventListener('change', () => this.renderPlayersTable()); + document.getElementById('edit-player-is-banned').addEventListener('change', (e) => { + document.getElementById('edit-ban-reason-container').style.display = e.target.checked ? 'block' : 'none'; + }); + + document.getElementById('btn-stats-jump-edit')?.addEventListener('click', () => { + const id = document.getElementById('stats-player-id')?.value; + const modal = document.getElementById('modal-player-stats'); + if (modal) modal.classList.remove('active'); + if (id) this.openEditPlayerModal(id); + }); + + // Balance Save Buttons + document.getElementById('btn-save-economy').addEventListener('click', () => this.saveBalanceSection('economy')); + document.getElementById('btn-save-zombies').addEventListener('click', () => this.saveBalanceSection('zombies')); + document.getElementById('btn-save-plants').addEventListener('click', () => this.saveBalanceSection('plants')); + document.getElementById('btn-save-zengarden').addEventListener('click', () => this.saveBalanceSection('zenGarden')); + document.getElementById('btn-save-events-quick').addEventListener('click', () => this.saveBalanceSection('events')); + + // Presets + document.querySelectorAll('.btn-apply-preset').forEach(btn => { + btn.addEventListener('click', () => { + const preset = btn.getAttribute('data-preset'); + this.applyPreset(preset); + }); + }); + + // Bugs Actions + document.getElementById('btn-refresh-bugs').addEventListener('click', () => this.loadBugs()); + document.getElementById('bug-filter-status').addEventListener('change', () => this.renderBugsTable()); + document.getElementById('bug-filter-category').addEventListener('change', () => this.renderBugsTable()); + document.getElementById('btn-bug-mod-save').addEventListener('click', () => this.saveBugModeration()); + document.getElementById('btn-bug-delete').addEventListener('click', () => this.deleteBug()); + + // Backups Actions + document.getElementById('btn-export-backup').addEventListener('click', () => this.exportBackup()); + document.getElementById('btn-trigger-import').addEventListener('click', () => { + document.getElementById('backup-file-input').click(); + }); + document.getElementById('backup-file-input').addEventListener('change', (e) => { + if (e.target.files && e.target.files[0]) { + this.importBackup(e.target.files[0]); + } + }); + } +} + +// Instantiate on DOM Load +window.addEventListener('DOMContentLoaded', () => { + window.pvzConfigurator = new PvZConfiguratorApp(); +}); diff --git a/game/js/systems/config.js b/game/js/systems/config.js new file mode 100644 index 0000000..1cacaca --- /dev/null +++ b/game/js/systems/config.js @@ -0,0 +1,209 @@ +/** + * PvZ Dynamic Game Configuration & Balance System (PvZConfig) + * Fetches active balance multipliers from /api/config with fallback defaults and offline localStorage cache. + */ + +window.PvZConfig = (function () { + const DEFAULT_CONFIG = { + preset: 'Classic 2009', + economy: { + sunMultiplier: 1.0, + startingSun: 50, + coinDropRate: 1.0, + sunDropRate: 1.0, + plantCostMultiplier: 1.0, + upgradeCostMultiplier: 1.0, + plantPrices: { + sunflower: 0, + peashooter: 0, + wallnut: 0, + potatomine: 150, + snowpea: 350, + repeater: 500, + chomper: 450, + cherrybomb: 400, + jalapeno: 450, + sunshroom: 250, + fumeshroom: 300, + lilypad: 200, + gravebuster: 250, + flowerpot: 200, + cabbagepult: 400, + melonpult: 750 + }, + plantSunCosts: { + sunflower: 50, + peashooter: 100, + wallnut: 50, + potatomine: 25, + snowpea: 175, + repeater: 200, + chomper: 150, + cherrybomb: 150, + jalapeno: 125, + sunshroom: 25, + fumeshroom: 75, + lilypad: 25, + gravebuster: 75, + flowerpot: 25, + cabbagepult: 100, + melonpult: 300 + } + }, + zombies: { + hpMultiplier: 1.0, + speedMultiplier: 1.0, + damageMultiplier: 1.0, + waveDensityMultiplier: 1.0, + types: { + normal: { hp: 200, speed: 22, damage: 30 }, + conehead: { hp: 300, speed: 22, damage: 30 }, + buckethead: { hp: 600, speed: 22, damage: 30 }, + newspaper: { hp: 300, speed: 18, damage: 30 }, + screendoor: { hp: 650, speed: 20, damage: 30 }, + football: { hp: 600, speed: 42, damage: 35 }, + dancing: { hp: 350, speed: 25, damage: 30 }, + gargantuar: { hp: 3000, speed: 14, damage: 300 }, + zomboss: { hp: 10000, speed: 0, damage: 500 }, + polevaulter: { hp: 180, speed: 44, damage: 30 }, + duckytube: { hp: 120, speed: 24, damage: 30 }, + flag: { hp: 200, speed: 28, damage: 30 } + } + }, + plants: { + damageMultiplier: 1.0, + hpMultiplier: 1.0, + cooldownMultiplier: 1.0, + sunProduceIntervalMultiplier: 1.0 + }, + zenGarden: { + dropRateMultiplier: 1.0, + growthRateMultiplier: 1.0, + fertilizerMultiplier: 1.0, + waterIntervalMinutes: 30 + }, + events: { + doubleGold: false, + fastCooldowns: false, + infiniteSun: false, + turboZombies: false, + activeEventName: 'none' + } + }; + + let activeConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); + + // Try loading cached config from localStorage + try { + const cached = localStorage.getItem('pvz_game_config'); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed && typeof parsed === 'object') { + activeConfig = Object.assign({}, DEFAULT_CONFIG, parsed); + } + } + } catch (e) { + // ignore + } + + async function fetchConfig() { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2500); + const res = await fetch('/api/config', { signal: controller.signal }); + clearTimeout(timeoutId); + + if (res.ok) { + const data = await res.json(); + if (data && typeof data === 'object') { + activeConfig = Object.assign({}, DEFAULT_CONFIG, data); + try { + localStorage.setItem('pvz_game_config', JSON.stringify(activeConfig)); + } catch (e) {} + } + } + } catch (err) { + console.warn('[PvZConfig] Using cached/default balance config:', err.message); + } + return activeConfig; + } + + // Auto-fetch on script load + if (typeof window !== 'undefined') { + fetchConfig(); + } + + return { + fetchConfig, + getConfig: () => activeConfig, + getPreset: () => activeConfig.preset || 'Classic 2009', + + // Economy + getEconomy: () => activeConfig.economy || DEFAULT_CONFIG.economy, + getStartingSun: () => { + const eco = activeConfig.economy || {}; + const base = typeof eco.startingSun === 'number' ? eco.startingSun : 50; + return activeConfig.events?.infiniteSun ? Math.max(base, 250) : base; + }, + getSunMultiplier: () => { + const eco = activeConfig.economy || {}; + const mult = typeof eco.sunMultiplier === 'number' ? eco.sunMultiplier : 1.0; + return activeConfig.events?.infiniteSun ? mult * 1.5 : mult; + }, + getCoinDropRate: () => { + const eco = activeConfig.economy || {}; + const mult = typeof eco.coinDropRate === 'number' ? eco.coinDropRate : 1.0; + return activeConfig.events?.doubleGold ? mult * 2.0 : mult; + }, + getSunDropRate: () => activeConfig.economy?.sunDropRate ?? 1.0, + getPlantShopPrice: (plantId) => { + const eco = activeConfig.economy || {}; + const prices = eco.plantPrices || {}; + const base = prices[plantId] ?? (plantId === 'sunflower' || plantId === 'peashooter' || plantId === 'wallnut' ? 0 : 250); + const mult = eco.plantCostMultiplier ?? 1.0; + return Math.round(base * mult); + }, + getPlantSunCost: (plantId, defaultCost = 50) => { + const eco = activeConfig.economy || {}; + const sunCosts = eco.plantSunCosts || {}; + return sunCosts[plantId] !== undefined ? sunCosts[plantId] : defaultCost; + }, + + // Zombies & Combat + getZombieGlobal: () => activeConfig.zombies || DEFAULT_CONFIG.zombies, + getZombieHpMult: () => activeConfig.zombies?.hpMultiplier ?? 1.0, + getZombieSpeedMult: () => { + const mult = activeConfig.zombies?.speedMultiplier ?? 1.0; + return activeConfig.events?.turboZombies ? mult * 1.3 : mult; + }, + getZombieDamageMult: () => activeConfig.zombies?.damageMultiplier ?? 1.0, + getWaveDensityMult: () => activeConfig.zombies?.waveDensityMultiplier ?? 1.0, + getZombieTypeConfig: (type) => { + const types = activeConfig.zombies?.types || {}; + return types[type] || DEFAULT_CONFIG.zombies.types[type] || { hp: 200, speed: 22, damage: 30 }; + }, + + // Plants + getPlantGlobal: () => activeConfig.plants || DEFAULT_CONFIG.plants, + getPlantDamageMult: () => activeConfig.plants?.damageMultiplier ?? 1.0, + getPlantHpMult: () => activeConfig.plants?.hpMultiplier ?? 1.0, + getPlantCooldownMult: () => { + const mult = activeConfig.plants?.cooldownMultiplier ?? 1.0; + return activeConfig.events?.fastCooldowns ? mult * 0.4 : mult; + }, + getSunIntervalMult: () => activeConfig.plants?.sunProduceIntervalMultiplier ?? 1.0, + + // Zen Garden + getZenGarden: () => activeConfig.zenGarden || DEFAULT_CONFIG.zenGarden, + getZenDropRateMult: () => { + const mult = activeConfig.zenGarden?.dropRateMultiplier ?? 1.0; + return activeConfig.events?.doubleGold ? mult * 2.0 : mult; + }, + getZenGrowthRateMult: () => activeConfig.zenGarden?.growthRateMultiplier ?? 1.0, + getZenFertilizerMult: () => activeConfig.zenGarden?.fertilizerMultiplier ?? 1.0, + getZenWaterIntervalMinutes: () => activeConfig.zenGarden?.waterIntervalMinutes ?? 30, + + // Events + getEvents: () => activeConfig.events || DEFAULT_CONFIG.events + }; +})(); diff --git a/nginx/conf.d/ai.qpid.pro.conf b/nginx/conf.d/ai.qpid.pro.conf index 84e51d4..a2d489d 100644 --- a/nginx/conf.d/ai.qpid.pro.conf +++ b/nginx/conf.d/ai.qpid.pro.conf @@ -7,6 +7,14 @@ server { root /var/www/certbot; } + + # Game Configurator & Admin Panel SPA + location /configurator/ { + alias /usr/share/nginx/html/configurator_work/; + index index.html; + try_files $uri $uri/ /configurator/index.html; + } + location / { return 301 https://$host$request_uri; } @@ -41,32 +49,15 @@ server { root /var/www/certbot; } - # Bug Reports & Feedback API proxy - location /api/bugs { - proxy_pass http://127.0.0.1:8001/api/bugs; + # All Game Backend APIs (Auth, Leaderboard, Campaign, Bugs, Config, Admin, Users) + location /api/ { + proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - # User Auth & Profile Save API proxy - location /api/auth/ { - proxy_pass http://127.0.0.1:8001/api/auth/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Admin: list all users - location /api/users { - proxy_pass http://127.0.0.1:8001/api/users; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } # Gitea Git Service proxy location = /gitea { @@ -88,6 +79,14 @@ server { } # SPA Fallback & Static asset serving + + # Game Configurator & Admin Panel SPA + location /configurator/ { + alias /usr/share/nginx/html/configurator_work/; + index index.html; + try_files $uri $uri/ /configurator/index.html; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/nginx/conf.d/ai.qpid.pro.conf.ssl b/nginx/conf.d/ai.qpid.pro.conf.ssl index 2cb6fc0..e0a0a4d 100644 --- a/nginx/conf.d/ai.qpid.pro.conf.ssl +++ b/nginx/conf.d/ai.qpid.pro.conf.ssl @@ -7,6 +7,14 @@ server { root /var/www/certbot; } + + # Game Configurator & Admin Panel SPA + location /configurator/ { + alias /usr/share/nginx/html/configurator_work/; + index index.html; + try_files $uri $uri/ /configurator/index.html; + } + location / { return 301 https://$host$request_uri; } @@ -51,6 +59,14 @@ server { } # SPA Fallback & Static asset serving + + # Game Configurator & Admin Panel SPA + location /configurator/ { + alias /usr/share/nginx/html/configurator_work/; + index index.html; + try_files $uri $uri/ /configurator/index.html; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/nginx/conf.d/pvz.qpid.pro.conf b/nginx/conf.d/pvz.qpid.pro.conf index 03ec534..062f407 100644 --- a/nginx/conf.d/pvz.qpid.pro.conf +++ b/nginx/conf.d/pvz.qpid.pro.conf @@ -41,27 +41,9 @@ server { root /var/www/certbot; } - # Bug Reports & Feedback API proxy - location /api/bugs { - proxy_pass http://127.0.0.1:8001/api/bugs; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # User Auth & Profile Save API proxy - location /api/auth/ { - proxy_pass http://127.0.0.1:8001/api/auth/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Admin: list all users - location /api/users { - proxy_pass http://127.0.0.1:8001/api/users; + # All Game Backend APIs (Auth, Leaderboard, Campaign, Bugs, Config, Admin, Users) + location /api/ { + proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/scripts/db.py b/scripts/db.py index 3753997..8153816 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """ SQLite Database Manager for Plants vs. Zombies Game (ai.qpid.pro) -Zero external dependencies. Manages users, game progress profiles, leaderboards, sessions, and bug reports. +Zero external dependencies. Manages users, game progress profiles, leaderboards, sessions, +login audit logs, campaign levels, bug reports, admin settings, and game balance configuration. """ import sqlite3 import json import os +import re import secrets import hashlib import hmac @@ -18,6 +20,366 @@ DB_PATH = os.path.join(DATA_DIR, "pvz.db") os.makedirs(DATA_DIR, exist_ok=True) +DEFAULT_ADMIN_PASSWORDS = [ + os.environ.get("ADMIN_PASSWORD", "").strip(), + "admin123", + "admin" +] +DEFAULT_ADMIN_PASSWORDS = [p for p in DEFAULT_ADMIN_PASSWORDS if p] + +DEFAULT_GAME_CONFIG = { + "preset": "Classic 2009", + "economy": { + "sunMultiplier": 1.0, + "startingSun": 50, + "coinDropRate": 1.0, + "sunDropRate": 1.0, + "plantCostMultiplier": 1.0, + "upgradeCostMultiplier": 1.0, + "plantPrices": { + "sunflower": 0, + "peashooter": 0, + "wallnut": 0, + "potatomine": 150, + "snowpea": 350, + "repeater": 500, + "chomper": 450, + "cherrybomb": 400, + "jalapeno": 450, + "sunshroom": 250, + "fumeshroom": 300, + "lilypad": 200, + "gravebuster": 250, + "flowerpot": 200, + "cabbagepult": 400, + "melonpult": 750 + }, + "plantSunCosts": { + "sunflower": 50, + "peashooter": 100, + "wallnut": 50, + "potatomine": 25, + "snowpea": 175, + "repeater": 200, + "chomper": 150, + "cherrybomb": 150, + "jalapeno": 125, + "sunshroom": 25, + "fumeshroom": 75, + "lilypad": 25, + "gravebuster": 75, + "flowerpot": 25, + "cabbagepult": 100, + "melonpult": 300 + } + }, + "zombies": { + "hpMultiplier": 1.0, + "speedMultiplier": 1.0, + "damageMultiplier": 1.0, + "waveDensityMultiplier": 1.0, + "types": { + "normal": {"hp": 200, "speed": 22, "damage": 30}, + "conehead": {"hp": 300, "speed": 22, "damage": 30}, + "buckethead": {"hp": 600, "speed": 22, "damage": 30}, + "newspaper": {"hp": 300, "speed": 18, "damage": 30, "angrySpeed": 48}, + "screendoor": {"hp": 650, "speed": 20, "damage": 30}, + "football": {"hp": 600, "speed": 42, "damage": 35}, + "dancing": {"hp": 350, "speed": 25, "damage": 30}, + "gargantuar": {"hp": 3000, "speed": 14, "damage": 300}, + "zomboss": {"hp": 10000, "speed": 0, "damage": 500}, + "polevaulter": {"hp": 180, "speed": 44, "damage": 30}, + "duckytube": {"hp": 120, "speed": 24, "damage": 30}, + "flag": {"hp": 200, "speed": 28, "damage": 30} + } + }, + "plants": { + "damageMultiplier": 1.0, + "hpMultiplier": 1.0, + "cooldownMultiplier": 1.0, + "sunProduceIntervalMultiplier": 1.0 + }, + "zenGarden": { + "dropRateMultiplier": 1.0, + "growthRateMultiplier": 1.0, + "fertilizerMultiplier": 1.0, + "waterIntervalMinutes": 30 + }, + "events": { + "doubleGold": False, + "fastCooldowns": False, + "infiniteSun": False, + "turboZombies": False, + "activeEventName": "none" + }, + "presets": { + "Classic 2009": { + "description": "Классический баланс оригинальной Plants vs. Zombies 2009 года", + "economy": {"sunMultiplier": 1.0, "startingSun": 50, "coinDropRate": 1.0, "sunDropRate": 1.0, "plantCostMultiplier": 1.0, "upgradeCostMultiplier": 1.0}, + "zombies": {"hpMultiplier": 1.0, "speedMultiplier": 1.0, "damageMultiplier": 1.0, "waveDensityMultiplier": 1.0}, + "plants": {"damageMultiplier": 1.0, "hpMultiplier": 1.0, "cooldownMultiplier": 1.0, "sunProduceIntervalMultiplier": 1.0}, + "zenGarden": {"dropRateMultiplier": 1.0, "growthRateMultiplier": 1.0, "fertilizerMultiplier": 1.0, "waterIntervalMinutes": 30}, + "events": {"doubleGold": False, "fastCooldowns": False, "infiniteSun": False, "turboZombies": False, "activeEventName": "none"} + }, + "Hardcore": { + "description": "Сложный режим для ветеранов: живучие и быстрые зомби, меньше ресурсов, плотные волны", + "economy": {"sunMultiplier": 0.8, "startingSun": 25, "coinDropRate": 0.8, "sunDropRate": 0.8, "plantCostMultiplier": 1.2, "upgradeCostMultiplier": 1.3}, + "zombies": {"hpMultiplier": 1.6, "speedMultiplier": 1.3, "damageMultiplier": 1.5, "waveDensityMultiplier": 1.5}, + "plants": {"damageMultiplier": 0.85, "hpMultiplier": 0.85, "cooldownMultiplier": 1.2, "sunProduceIntervalMultiplier": 1.2}, + "zenGarden": {"dropRateMultiplier": 0.8, "growthRateMultiplier": 0.8, "fertilizerMultiplier": 1.0, "waterIntervalMinutes": 45}, + "events": {"doubleGold": False, "fastCooldowns": False, "infiniteSun": False, "turboZombies": True, "activeEventName": "hardcore"} + }, + "Turbo": { + "description": "Ураганный экшен: супер-быстрая перезарядка, море солнца и стремительные зомби", + "economy": {"sunMultiplier": 2.0, "startingSun": 150, "coinDropRate": 1.5, "sunDropRate": 1.8, "plantCostMultiplier": 0.8, "upgradeCostMultiplier": 0.8}, + "zombies": {"hpMultiplier": 1.1, "speedMultiplier": 1.6, "damageMultiplier": 1.2, "waveDensityMultiplier": 1.3}, + "plants": {"damageMultiplier": 1.3, "hpMultiplier": 1.2, "cooldownMultiplier": 0.4, "sunProduceIntervalMultiplier": 0.6}, + "zenGarden": {"dropRateMultiplier": 1.5, "growthRateMultiplier": 2.0, "fertilizerMultiplier": 1.5, "waterIntervalMinutes": 15}, + "events": {"doubleGold": True, "fastCooldowns": True, "infiniteSun": False, "turboZombies": True, "activeEventName": "turbo"} + }, + "Casual": { + "description": "Расслабленный режим: увеличенный урон растений, много солнца и золота", + "economy": {"sunMultiplier": 1.5, "startingSun": 200, "coinDropRate": 2.0, "sunDropRate": 1.5, "plantCostMultiplier": 0.7, "upgradeCostMultiplier": 0.7}, + "zombies": {"hpMultiplier": 0.65, "speedMultiplier": 0.8, "damageMultiplier": 0.7, "waveDensityMultiplier": 0.8}, + "plants": {"damageMultiplier": 1.7, "hpMultiplier": 1.5, "cooldownMultiplier": 0.7, "sunProduceIntervalMultiplier": 0.7}, + "zenGarden": {"dropRateMultiplier": 2.0, "growthRateMultiplier": 2.0, "fertilizerMultiplier": 2.0, "waterIntervalMinutes": 10}, + "events": {"doubleGold": True, "fastCooldowns": False, "infiniteSun": False, "turboZombies": False, "activeEventName": "casual"} + }, + "Custom": { + "description": "Пользовательские настройки баланса", + "economy": {"sunMultiplier": 1.0, "startingSun": 50, "coinDropRate": 1.0, "sunDropRate": 1.0, "plantCostMultiplier": 1.0, "upgradeCostMultiplier": 1.0}, + "zombies": {"hpMultiplier": 1.0, "speedMultiplier": 1.0, "damageMultiplier": 1.0, "waveDensityMultiplier": 1.0}, + "plants": {"damageMultiplier": 1.0, "hpMultiplier": 1.0, "cooldownMultiplier": 1.0, "sunProduceIntervalMultiplier": 1.0}, + "zenGarden": {"dropRateMultiplier": 1.0, "growthRateMultiplier": 1.0, "fertilizerMultiplier": 1.0, "waterIntervalMinutes": 30}, + "events": {"doubleGold": False, "fastCooldowns": False, "infiniteSun": False, "turboZombies": False, "activeEventName": "custom"} + } + } +} + +DEFAULT_CAMPAIGN_LEVELS = [ + # World 1: Day (1-1 .. 1-5) + { + "level": 1, "world": 1, "stage": 1, "code": "1-1", "title": "Первые шаги", + "desc": "Знакомство с садом и простыми зомби", "waves": {"count": 3, "types": ["normal"]}, + "biome": "day", "reward": 100, "is_boss": 0, "modifiers": {}, "dialogue": None + }, + { + "level": 2, "world": 1, "stage": 2, "code": "1-2", "title": "Опасные конусы", + "desc": "Появление стойких зомби с дорожными конусами", "waves": {"count": 3, "types": ["normal", "conehead"]}, + "biome": "day", "reward": 120, "is_boss": 0, "modifiers": {}, "dialogue": None + }, + { + "level": 3, "world": 1, "stage": 3, "code": "1-3", "title": "Картофельный рубеж", + "desc": "Тактическая оборона с картофельными минами", "waves": {"count": 4, "types": ["normal", "conehead"]}, + "biome": "day", "reward": 140, "is_boss": 0, "modifiers": {}, "dialogue": None + }, + { + "level": 4, "world": 1, "stage": 4, "code": "1-4", "title": "Прыгуны с шестом", + "desc": "Быстрые зомби легко перепрыгивают первые растения", "waves": {"count": 4, "types": ["normal", "conehead", "polevaulter"]}, + "biome": "day", "reward": 160, "is_boss": 0, "modifiers": {}, "dialogue": None + }, + { + "level": 5, "world": 1, "stage": 5, "code": "1-5", "title": "Штурм Дня", + "desc": "Финальная массивная волна зомби на солнечном газоне", "waves": {"count": 4, "types": ["normal", "conehead", "buckethead", "polevaulter"]}, + "biome": "day", "reward": 200, "is_boss": 0, "modifiers": {}, "dialogue": None + }, + + # World 2: Night (2-1 .. 2-5) + { + "level": 6, "world": 2, "stage": 1, "code": "2-1", "title": "Сгущаются сумерки", + "desc": "Ночь без падающего солнца: солнцегрибы и надгробия", "waves": {"count": 3, "types": ["normal", "conehead"]}, + "biome": "night", "reward": 150, "is_boss": 0, "modifiers": {"noSkySun": True, "graves": True}, + "dialogue": { + "title": "🌙 Безумный Дэйв предупреждает:", + "text": "Эй, сосед! Ночью солнце с неба не падает! Обязательно возьми Солнцегриб!", + "btnText": "ДАЛЕЕ / В БОЙ ⚔️" + } + }, + { + "level": 7, "world": 2, "stage": 2, "code": "2-2", "title": "Ярость газетчика", + "desc": "Разъярённые зомби с газетами ускоряются в бешенстве", "waves": {"count": 4, "types": ["normal", "newspaper"]}, + "biome": "night", "reward": 170, "is_boss": 0, "modifiers": {"noSkySun": True}, "dialogue": None + }, + { + "level": 8, "world": 2, "stage": 3, "code": "2-3", "title": "Споровый шквал", + "desc": "Дымогрибы пробивают плотные ряды врагов", "waves": {"count": 4, "types": ["normal", "newspaper", "conehead"]}, + "biome": "night", "reward": 190, "is_boss": 0, "modifiers": {"noSkySun": True}, "dialogue": None + }, + { + "level": 9, "world": 2, "stage": 4, "code": "2-4", "title": "Бронекаски", + "desc": "Зомби с вёдрами штурмуют тёмный двор", "waves": {"count": 4, "types": ["normal", "screendoor", "buckethead"]}, + "biome": "night", "reward": 210, "is_boss": 0, "modifiers": {"noSkySun": True}, "dialogue": None + }, + { + "level": 10, "world": 2, "stage": 5, "code": "2-5", "title": "Полночный кошмар", + "desc": "Зомби восстают прямо из могил в финальной волне", "waves": {"count": 5, "types": ["normal", "newspaper", "buckethead", "dancing"]}, + "biome": "night", "reward": 250, "is_boss": 0, "modifiers": {"noSkySun": True, "graveSpawns": True}, "dialogue": None + }, + + # World 3: Pool (3-1 .. 3-5) + { + "level": 11, "world": 3, "stage": 1, "code": "3-1", "title": "Водная гладь", + "desc": "6 дорожек газона, кувшинки и зомби на утках", "waves": {"count": 3, "types": ["normal", "duckytube"]}, + "biome": "pool", "reward": 200, "is_boss": 0, "modifiers": {"hasPool": True, "lanes": 6}, + "dialogue": { + "title": "🏊 Безумный Дэйв спешит на помощь:", + "text": "Там бассейн! На воде растения тонут — сначала сажай Кувшинки (Lily Pad)!", + "btnText": "ДАЛЕЕ / В БОЙ ⚔️" + } + }, + { + "level": 12, "world": 3, "stage": 2, "code": "3-2", "title": "Заплыв с шестом", + "desc": "Прыгуны на суше и быстрые пловцы на воде", "waves": {"count": 4, "types": ["normal", "duckytube", "polevaulter"]}, + "biome": "pool", "reward": 220, "is_boss": 0, "modifiers": {"hasPool": True, "lanes": 6}, "dialogue": None + }, + { + "level": 13, "world": 3, "stage": 3, "code": "3-3", "title": "Зубастики у воды", + "desc": "Хищные зубастики против бронированных уток", "waves": {"count": 4, "types": ["normal", "duckytube", "conehead"]}, + "biome": "pool", "reward": 240, "is_boss": 0, "modifiers": {"hasPool": True, "lanes": 6}, "dialogue": None + }, + { + "level": 14, "world": 3, "stage": 4, "code": "3-4", "title": "Футбольный матч", + "desc": "Мощные футболисты прорывают береговую линию", "waves": {"count": 4, "types": ["normal", "football", "duckytube"]}, + "biome": "pool", "reward": 260, "is_boss": 0, "modifiers": {"hasPool": True, "lanes": 6}, "dialogue": None + }, + { + "level": 15, "world": 3, "stage": 5, "code": "3-5", "title": "Водный шквал", + "desc": "Штурм всех 6 дорожек с очистителями бассейна", "waves": {"count": 5, "types": ["normal", "buckethead", "football", "duckytube"]}, + "biome": "pool", "reward": 300, "is_boss": 0, "modifiers": {"hasPool": True, "lanes": 6}, "dialogue": None + }, + + # World 4: Fog (4-1 .. 4-5) + { + "level": 16, "world": 4, "stage": 1, "code": "4-1", "title": "Плотная мгла", + "desc": "Густой туман скрывает правую половину поля", "waves": {"count": 4, "types": ["normal", "conehead", "duckytube"]}, + "biome": "fog", "reward": 250, "is_boss": 0, "modifiers": {"hasPool": True, "hasFog": True, "noSkySun": True}, + "dialogue": { + "title": "🌫️ Безумный Дэйв щурится во мгле:", + "text": "Ого, какой густой туман! Зомби крадутся в темноте, используй Плантерн и Вздуватель!", + "btnText": "ДАЛЕЕ / В БОЙ ⚔️" + } + }, + { + "level": 17, "world": 4, "stage": 2, "code": "4-2", "title": "Невидимая угроза", + "desc": "Внезапные появления врагов прямо из белой пелены", "waves": {"count": 4, "types": ["normal", "newspaper", "screendoor"]}, + "biome": "fog", "reward": 270, "is_boss": 0, "modifiers": {"hasPool": True, "hasFog": True, "noSkySun": True}, "dialogue": None + }, + { + "level": 18, "world": 4, "stage": 3, "code": "4-3", "title": "Туманный бассейн", + "desc": "Водные линии скрыты во мраке ночного тумана", "waves": {"count": 4, "types": ["normal", "duckytube", "buckethead"]}, + "biome": "fog", "reward": 290, "is_boss": 0, "modifiers": {"hasPool": True, "hasFog": True, "noSkySun": True}, "dialogue": None + }, + { + "level": 19, "world": 4, "stage": 4, "code": "4-4", "title": "Спринт сквозь мглу", + "desc": "Футболисты и прыгуны выскакивают из тумана", "waves": {"count": 5, "types": ["normal", "football", "polevaulter", "duckytube"]}, + "biome": "fog", "reward": 310, "is_boss": 0, "modifiers": {"hasPool": True, "hasFog": True, "noSkySun": True}, "dialogue": None + }, + { + "level": 20, "world": 4, "stage": 5, "code": "4-5", "title": "Штормовой туман", + "desc": "Финальный прорыв орды через непроглядную пелену", "waves": {"count": 5, "types": ["normal", "football", "dancing", "buckethead"]}, + "biome": "fog", "reward": 350, "is_boss": 0, "modifiers": {"hasPool": True, "hasFog": True, "noSkySun": True}, "dialogue": None + }, + + # World 5: Roof (5-1 .. 5-5) + { + "level": 21, "world": 5, "stage": 1, "code": "5-1", "title": "Черепичный скат", + "desc": "Крыша дома, цветочные горшки и капустопульты", "waves": {"count": 4, "types": ["normal", "conehead"]}, + "biome": "roof", "reward": 300, "is_boss": 0, "modifiers": {"isRoof": True, "requiresPots": True}, + "dialogue": { + "title": "🏠 Безумный Дэйв лезет на скат:", + "text": "Мы на крыше! Скаты мешают прямой стрельбе — используй Горшки и Катапульты!", + "btnText": "ДАЛЕЕ / В БОЙ ⚔️" + } + }, + { + "level": 22, "world": 5, "stage": 2, "code": "5-2", "title": "Арбузный гром", + "desc": "Тяжёлые арбузопульты со сплэш-уроном по площади", "waves": {"count": 4, "types": ["normal", "buckethead", "conehead"]}, + "biome": "roof", "reward": 320, "is_boss": 0, "modifiers": {"isRoof": True, "requiresPots": True}, "dialogue": None + }, + { + "level": 23, "world": 5, "stage": 3, "code": "5-3", "title": "Опасные верхолазы", + "desc": "Бронезомби карабкаются по скользкому скату крыши", "waves": {"count": 4, "types": ["normal", "screendoor", "football"]}, + "biome": "roof", "reward": 350, "is_boss": 0, "modifiers": {"isRoof": True, "requiresPots": True}, "dialogue": None + }, + { + "level": 24, "world": 5, "stage": 4, "code": "5-4", "title": "Осада дымохода", + "desc": "Генеральный штурм перед чердаком дома", "waves": {"count": 5, "types": ["normal", "gargantuar", "football", "buckethead"]}, + "biome": "roof", "reward": 400, "is_boss": 0, "modifiers": {"isRoof": True, "requiresPots": True}, "dialogue": None + }, + { + "level": 25, "world": 5, "stage": 5, "code": "5-5", "title": "Доктор Зомбосс!", + "desc": "Финальная битва против шагающего робота Zombot!", "waves": {"count": 1, "types": ["zomboss"]}, + "biome": "roof", "reward": 1000, "is_boss": 1, "modifiers": {"isRoof": True, "isBoss": True}, + "dialogue": { + "title": "🤖 БЕЗУМИЕ ПОЛНОГО МАСШТАБА!", + "text": "БЕЗУМИЕ! Доктор Зомбосс выкатил своего гигантского робота! Держи оборону!", + "btnText": "ДАЛЕЕ / В БОЙ ⚔️" + } + } +] + +def parse_device_info(user_agent): + """Extract friendly device and browser name from User-Agent string.""" + if not user_agent or not isinstance(user_agent, str): + return "Unknown Device" + ua = user_agent.strip() + if not ua: + return "Unknown Device" + + # OS detection + if "Windows NT 10.0" in ua or "Windows NT 11.0" in ua: + os_name = "Windows 10/11" + elif "Windows NT 6.3" in ua: + os_name = "Windows 8.1" + elif "Windows NT 6.1" in ua: + os_name = "Windows 7" + elif "Windows NT" in ua or "Windows" in ua: + os_name = "Windows" + elif "iPhone" in ua: + os_name = "iPhone" + elif "iPad" in ua: + os_name = "iPad" + elif "Mac OS X" in ua or "Macintosh" in ua: + os_name = "macOS" + elif "Android" in ua: + os_name = "Android" + elif "CrOS" in ua: + os_name = "ChromeOS" + elif "Linux" in ua: + os_name = "Linux" + else: + os_name = "Unknown OS" + + # Browser detection + if "Edg/" in ua or "Edge/" in ua: + m = re.search(r"Edg[e]?/(\d+)", ua) + browser = f"Edge {m.group(1)}" if m else "Edge" + elif "OPR/" in ua or "Opera/" in ua: + m = re.search(r"(?:OPR|Opera)/(\d+)", ua) + browser = f"Opera {m.group(1)}" if m else "Opera" + elif "YaBrowser/" in ua: + m = re.search(r"YaBrowser/(\d+)", ua) + browser = f"Yandex {m.group(1)}" if m else "Yandex" + elif "Chrome/" in ua or "CriOS/" in ua: + m = re.search(r"(?:Chrome|CriOS)/(\d+)", ua) + browser = f"Chrome {m.group(1)}" if m else "Chrome" + elif "Firefox/" in ua or "FxiOS/" in ua: + m = re.search(r"(?:Firefox|FxiOS)/(\d+)", ua) + browser = f"Firefox {m.group(1)}" if m else "Firefox" + elif "Safari/" in ua and "Version/" in ua: + m = re.search(r"Version/(\d+)", ua) + browser = f"Safari {m.group(1)}" if m else "Safari" + elif "PostmanRuntime" in ua: + browser = "Postman" + elif "curl" in ua: + browser = "curl" + elif "python" in ua.lower(): + browser = "Python Client" + else: + browser = "Browser" + + return f"{browser} ({os_name})" + def get_connection(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row @@ -54,27 +416,73 @@ def init_db(): conn = get_connection() cursor = conn.cursor() + # 1. Users Table 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 + last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_banned INTEGER DEFAULT 0, + ban_reason TEXT DEFAULT '', + is_admin INTEGER DEFAULT 0 ) """) + cursor.execute("PRAGMA table_info(users)") + cols = [c["name"] for c in cursor.fetchall()] + if "is_banned" not in cols: + cursor.execute("ALTER TABLE users ADD COLUMN is_banned INTEGER DEFAULT 0") + if "ban_reason" not in cols: + cursor.execute("ALTER TABLE users ADD COLUMN ban_reason TEXT DEFAULT ''") + if "is_admin" not in cols: + cursor.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0") + + # 2. Sessions Table 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, + ip_address TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + device_name TEXT DEFAULT '', + last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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("PRAGMA table_info(sessions)") + sess_cols = [c["name"] for c in cursor.fetchall()] + if "ip_address" not in sess_cols: + cursor.execute("ALTER TABLE sessions ADD COLUMN ip_address TEXT DEFAULT ''") + if "user_agent" not in sess_cols: + cursor.execute("ALTER TABLE sessions ADD COLUMN user_agent TEXT DEFAULT ''") + if "device_name" not in sess_cols: + cursor.execute("ALTER TABLE sessions ADD COLUMN device_name TEXT DEFAULT ''") + if "last_active" not in sess_cols: + cursor.execute("ALTER TABLE sessions ADD COLUMN last_active TIMESTAMP DEFAULT ''") + + # 3. Login Audit Logs Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS login_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT, + ip_address TEXT, + user_agent TEXT, + device_name TEXT, + status TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_logs_user_id ON login_logs(user_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_logs_created_at ON login_logs(created_at)") + + # 4. Profiles Table cursor.execute(""" CREATE TABLE IF NOT EXISTS profiles ( user_id INTEGER PRIMARY KEY, @@ -95,6 +503,7 @@ def init_db(): ) """) + # 5. Leaderboards Table cursor.execute(""" CREATE TABLE IF NOT EXISTS leaderboards ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -107,6 +516,7 @@ def init_db(): ) """) + # 6. Bug Reports Table cursor.execute(""" CREATE TABLE IF NOT EXISTS bugs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -119,26 +529,181 @@ def init_db(): ) """) + # 7. Game Balance Config Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS game_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + preset_name TEXT DEFAULT 'Classic 2009', + config_data TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # 8. Campaign Levels Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS campaign_levels ( + level INTEGER PRIMARY KEY, + world INTEGER, + stage INTEGER, + code TEXT, + title TEXT, + desc TEXT, + reward INTEGER, + biome TEXT, + is_boss INTEGER DEFAULT 0, + waves_json TEXT DEFAULT '{}', + modifiers_json TEXT DEFAULT '{}', + dialogue_json TEXT DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # 9. Admin Settings Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS admin_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Seed default game config if not present + cursor.execute("SELECT COUNT(*) as cnt FROM game_config") + row = cursor.fetchone() + if row["cnt"] == 0: + cursor.execute( + "INSERT INTO game_config (preset_name, config_data) VALUES (?, ?)", + ("Classic 2009", json.dumps(DEFAULT_GAME_CONFIG, ensure_ascii=False)) + ) + + # Seed default campaign levels if empty + cursor.execute("SELECT COUNT(*) as cnt FROM campaign_levels") + lvl_row = cursor.fetchone() + if lvl_row["cnt"] == 0: + for lvl in DEFAULT_CAMPAIGN_LEVELS: + cursor.execute(""" + INSERT INTO campaign_levels ( + level, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + lvl["level"], + lvl["world"], + lvl["stage"], + lvl["code"], + lvl["title"], + lvl["desc"], + lvl["reward"], + lvl["biome"], + lvl.get("is_boss", 0), + json.dumps(lvl.get("waves", {}), ensure_ascii=False), + json.dumps(lvl.get("modifiers", {}), ensure_ascii=False), + json.dumps(lvl.get("dialogue") or {}, ensure_ascii=False) + )) + + # Ensure column migrations for profiles + cursor.execute("PRAGMA table_info(profiles)") + prof_cols = [col["name"] for col in cursor.fetchall()] + if "stats" not in prof_cols: + cursor.execute("ALTER TABLE profiles ADD COLUMN stats TEXT DEFAULT '{}'") + if "admin_updated_at" not in prof_cols: + cursor.execute("ALTER TABLE profiles ADD COLUMN admin_updated_at TIMESTAMP DEFAULT NULL") + conn.commit() conn.close() -def create_session(user_id, duration_days=30): - """Generates a secure 256-bit token and creates a session record.""" - token = secrets.token_hex(32) - now = datetime.now() - created_at = now.strftime("%Y-%m-%d %H:%M:%S") - expires_at = (now + timedelta(days=duration_days)).strftime("%Y-%m-%d %H:%M:%S") +# ========================================== +# Admin Settings & Master Password +# ========================================== + +def set_admin_password(new_password): + """Securely updates admin master password hash in admin_settings.""" + if not new_password or not isinstance(new_password, str): + return {"success": False, "message": "Пароль не может быть пустым"} + new_password = new_password.strip() + if len(new_password) < 4: + return {"success": False, "message": "Пароль должен содержать не менее 4 символов"} + + sec_hash = hash_password(new_password) + now_str = datetime.now().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) - ) + cursor.execute(""" + INSERT INTO admin_settings (key, value, updated_at) + VALUES ('admin_password_hash', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, (sec_hash, now_str)) + conn.commit() + conn.close() + return {"success": True, "message": "Мастер-пароль администратора успешно обновлен"} + +def verify_admin_password(password): + """Verifies password against admin_settings custom hash or default admin passwords.""" + if not password: + return False + password = str(password).strip() + if not password: + return False + + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT value FROM admin_settings WHERE key = 'admin_password_hash'") + row = cursor.fetchone() + conn.close() + + if row and row["value"]: + stored = row["value"] + if verify_password(password, stored): + return True + + # Fallback to default admin passwords or env variable + for default_p in DEFAULT_ADMIN_PASSWORDS: + if hmac.compare_digest(password, default_p): + return True + return False + +# ========================================== +# Session Management Methods +# ========================================== + +def create_session(user_id, duration_days=30, ip_address="", user_agent="", device_name=""): + """Generates a secure 256-bit token and creates an extended session record.""" + token = secrets.token_hex(32) + now = datetime.now() + now_str = now.strftime("%Y-%m-%d %H:%M:%S") + expires_at = (now + timedelta(days=duration_days)).strftime("%Y-%m-%d %H:%M:%S") + + if not device_name and user_agent: + device_name = parse_device_info(user_agent) + + conn = get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO sessions ( + token, user_id, created_at, expires_at, ip_address, user_agent, device_name, last_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (token, user_id, now_str, expires_at, ip_address or "", user_agent or "", device_name or "", now_str)) conn.commit() conn.close() return token +def update_session_activity(token, ip_address=None): + """Updates the last_active timestamp and optionally IP for an active session.""" + if not token or not isinstance(token, str): + return False + token = token.strip() + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn = get_connection() + cursor = conn.cursor() + if ip_address: + cursor.execute("UPDATE sessions SET last_active = ?, ip_address = ? WHERE token = ?", (now_str, str(ip_address).strip(), token)) + else: + cursor.execute("UPDATE sessions SET last_active = ? WHERE token = ?", (now_str, token)) + conn.commit() + conn.close() + return True + def validate_session(token): """Validates session token. Returns user dict if valid, else None.""" if not token or not isinstance(token, str): @@ -150,7 +715,8 @@ def validate_session(token): conn = get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT u.id, u.username, u.created_at, u.last_login, s.expires_at, s.token + SELECT u.id, u.username, u.created_at, u.last_login, u.is_banned, u.ban_reason, u.is_admin, + s.expires_at, s.token, s.ip_address, s.user_agent, s.device_name, s.last_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ? @@ -161,6 +727,13 @@ def validate_session(token): conn.close() return None + # Check if user was banned after session creation + if row["is_banned"]: + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() + return None + # Check expiration try: expires_at = datetime.strptime(row["expires_at"], "%Y-%m-%d %H:%M:%S") @@ -173,28 +746,395 @@ def validate_session(token): conn.close() return None + # Update last active timestamp + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute("UPDATE sessions SET last_active = ? WHERE token = ?", (now_str, token)) + conn.commit() + user_info = { "id": row["id"], "user_id": row["id"], "username": row["username"], "createdAt": row["created_at"], "lastLogin": row["last_login"], - "token": row["token"] + "isBanned": bool(row["is_banned"]), + "banReason": row["ban_reason"] or "", + "isAdmin": bool(row["is_admin"]), + "token": row["token"], + "ipAddress": row["ip_address"] or "", + "deviceName": row["device_name"] or "", + "lastActive": now_str } conn.close() return user_info def delete_session(token): - """Deletes/invalidates a session by token.""" + """Deletes/invalidates a single session by token.""" + return terminate_session(token) + +def terminate_session(token, user_id=None): + """Terminates a specific session by token (optional user_id verification).""" if not token: return True conn = get_connection() cursor = conn.cursor() - cursor.execute("DELETE FROM sessions WHERE token = ?", (token.strip(),)) + if user_id is not None: + cursor.execute("DELETE FROM sessions WHERE token = ? AND user_id = ?", (token.strip(), user_id)) + else: + cursor.execute("DELETE FROM sessions WHERE token = ?", (token.strip(),)) conn.commit() conn.close() return True +def terminate_all_user_sessions(user_id, except_token=None): + """Terminates all sessions for a user, optionally preserving the current active session token.""" + conn = get_connection() + cursor = conn.cursor() + if except_token: + cursor.execute("DELETE FROM sessions WHERE user_id = ? AND token != ?", (user_id, except_token.strip())) + else: + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + conn.commit() + conn.close() + return True + +def get_user_sessions(user_id): + """Returns all active unexpired sessions for a specific user.""" + conn = get_connection() + cursor = conn.cursor() + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(""" + SELECT token, user_id, created_at, expires_at, ip_address, user_agent, device_name, last_active + FROM sessions + WHERE user_id = ? AND expires_at > ? + ORDER BY last_active DESC + """, (user_id, now_str)) + rows = cursor.fetchall() + conn.close() + + return [{ + "token": r["token"], + "userId": r["user_id"], + "ipAddress": r["ip_address"] or "", + "userAgent": r["user_agent"] or "", + "deviceName": r["device_name"] or "Unknown Device", + "createdAt": r["created_at"], + "expiresAt": r["expires_at"], + "lastActive": r["last_active"] or r["created_at"] + } for r in rows] + +def get_all_active_sessions(): + """Returns all active unexpired sessions across all users for admin dashboard.""" + conn = get_connection() + cursor = conn.cursor() + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(""" + SELECT s.token, s.user_id, u.username, u.is_admin, s.created_at, s.expires_at, + s.ip_address, s.user_agent, s.device_name, s.last_active + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.expires_at > ? + ORDER BY s.last_active DESC + """, (now_str,)) + rows = cursor.fetchall() + conn.close() + + return [{ + "token": r["token"], + "userId": r["user_id"], + "username": r["username"], + "isAdmin": bool(r["is_admin"]), + "ipAddress": r["ip_address"] or "", + "userAgent": r["user_agent"] or "", + "deviceName": r["device_name"] or "Unknown Device", + "createdAt": r["created_at"], + "expiresAt": r["expires_at"], + "lastActive": r["last_active"] or r["created_at"] + } for r in rows] + +def cleanup_expired_sessions(days=30): + """Deletes inactive/expired sessions older than days days from sessions, returns count of deleted rows.""" + try: + days = int(days) + except (ValueError, TypeError): + days = 30 + conn = get_connection() + cursor = conn.cursor() + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cutoff_str = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(""" + DELETE FROM sessions + WHERE expires_at < ? + OR (last_active != '' AND last_active IS NOT NULL AND last_active < ?) + OR ((last_active = '' OR last_active IS NULL) AND created_at < ?) + """, (now_str, cutoff_str, cutoff_str)) + deleted_count = cursor.rowcount + conn.commit() + conn.close() + return deleted_count + +# ========================================== +# Login Audit Logs +# ========================================== + +def record_login_log(user_id, username, ip_address="", user_agent="", status="success", device_name=None): + """Records authentication attempt in audit logs.""" + if not device_name and user_agent: + device_name = parse_device_info(user_agent) + + conn = get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO login_logs (user_id, username, ip_address, user_agent, device_name, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + user_id, + str(username or "").strip(), + str(ip_address or "").strip(), + str(user_agent or "").strip(), + str(device_name or "Unknown Device").strip(), + str(status or "success").strip() + )) + conn.commit() + conn.close() + +def get_login_logs(limit=100, username=None, ip=None): + """Retrieves login audit logs with optional filtering by username or IP.""" + conn = get_connection() + cursor = conn.cursor() + + query = "SELECT id, user_id, username, ip_address, user_agent, device_name, status, created_at FROM login_logs WHERE 1=1" + params = [] + + if username: + query += " AND username = ? COLLATE NOCASE" + params.append(username.strip()) + if ip: + query += " AND ip_address = ?" + params.append(ip.strip()) + + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + + return [{ + "id": r["id"], + "userId": r["user_id"], + "username": r["username"], + "ipAddress": r["ip_address"] or "", + "userAgent": r["user_agent"] or "", + "deviceName": r["device_name"] or "Unknown Device", + "status": r["status"] or "unknown", + "createdAt": r["created_at"] + } for r in rows] + +def cleanup_old_audit_logs(days=30): + """Deletes records from login_logs older than days days, returns count of deleted rows.""" + try: + days = int(days) + except (ValueError, TypeError): + days = 30 + conn = get_connection() + cursor = conn.cursor() + cutoff_str = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + cursor.execute("DELETE FROM login_logs WHERE created_at < ?", (cutoff_str,)) + deleted_count = cursor.rowcount + conn.commit() + conn.close() + return deleted_count + +def perform_system_maintenance(audit_days=30, session_days=30): + """ + Performs combined system maintenance: + - Cleans up old audit logs (older than audit_days) + - Cleans up expired/inactive sessions (older than session_days) + - Executes VACUUM on database to optimize storage + Returns a maintenance summary dictionary. + """ + deleted_logs = cleanup_old_audit_logs(days=audit_days) + deleted_sessions = cleanup_expired_sessions(days=session_days) + + vacuumed = False + try: + conn = get_connection() + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("VACUUM") + conn.close() + vacuumed = True + except Exception: + vacuumed = False + + return { + "success": True, + "deletedLogs": deleted_logs, + "deletedSessions": deleted_sessions, + "deleted_audit_logs": deleted_logs, + "deleted_sessions": deleted_sessions, + "vacuumed": vacuumed, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + +# ========================================== +# Campaign Levels Configuration +# ========================================== + +def get_campaign_levels(level_num=None): + """Retrieves list of all campaign levels or single level by index with parsed JSON details.""" + conn = get_connection() + cursor = conn.cursor() + + if level_num is not None: + cursor.execute("SELECT * FROM campaign_levels WHERE level = ?", (int(level_num),)) + row = cursor.fetchone() + conn.close() + if not row: + return None + return _format_level_row(row) + + cursor.execute("SELECT * FROM campaign_levels ORDER BY level ASC") + rows = cursor.fetchall() + conn.close() + return [_format_level_row(r) for r in rows] + +def _format_level_row(row): + return { + "level": row["level"], + "world": row["world"], + "stage": row["stage"], + "code": row["code"], + "title": row["title"], + "desc": row["desc"], + "reward": row["reward"], + "biome": row["biome"], + "isBoss": bool(row["is_boss"]), + "waves": json.loads(row["waves_json"] or "{}"), + "modifiers": json.loads(row["modifiers_json"] or "{}"), + "dialogue": json.loads(row["dialogue_json"] or "{}"), + "updatedAt": row["updated_at"] + } + +def save_campaign_level(level_data): + """Creates or updates a campaign level in campaign_levels table.""" + if not isinstance(level_data, dict): + raise ValueError("Level data must be a dictionary") + + level_num = int(level_data.get("level", 1)) + world = int(level_data.get("world", (level_num - 1) // 5 + 1)) + stage = int(level_data.get("stage", (level_num - 1) % 5 + 1)) + code = str(level_data.get("code") or f"{world}-{stage}") + title = str(level_data.get("title") or f"Уровень {code}") + desc = str(level_data.get("desc") or "") + reward = int(level_data.get("reward", 100)) + biome = str(level_data.get("biome") or "day") + is_boss = 1 if (level_data.get("isBoss") or level_data.get("is_boss")) else 0 + + waves = level_data.get("waves") or level_data.get("waves_json") or {} + waves_json = json.dumps(waves, ensure_ascii=False) if isinstance(waves, (dict, list)) else str(waves) + + modifiers = level_data.get("modifiers") or level_data.get("modifiers_json") or {} + modifiers_json = json.dumps(modifiers, ensure_ascii=False) if isinstance(modifiers, (dict, list)) else str(modifiers) + + dialogue = level_data.get("dialogue") or level_data.get("dialogue_json") or {} + dialogue_json = json.dumps(dialogue, ensure_ascii=False) if isinstance(dialogue, (dict, list)) else str(dialogue) + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + conn = get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO campaign_levels ( + level, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(level) DO UPDATE SET + world = excluded.world, + stage = excluded.stage, + code = excluded.code, + title = excluded.title, + desc = excluded.desc, + reward = excluded.reward, + biome = excluded.biome, + is_boss = excluded.is_boss, + waves_json = excluded.waves_json, + modifiers_json = excluded.modifiers_json, + dialogue_json = excluded.dialogue_json, + updated_at = excluded.updated_at + """, ( + level_num, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, now_str + )) + conn.commit() + conn.close() + + return get_campaign_levels(level_num) + +def reset_campaign_levels(level_num=None): + """Resets one or all campaign levels to default seed configurations.""" + conn = get_connection() + cursor = conn.cursor() + + if level_num is not None: + target_lvl = int(level_num) + seed = next((lvl for lvl in DEFAULT_CAMPAIGN_LEVELS if lvl["level"] == target_lvl), None) + if not seed: + conn.close() + return {"success": False, "message": f"Дефолтная конфигурация для уровня {target_lvl} не найдена"} + + cursor.execute(""" + INSERT INTO campaign_levels ( + level, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(level) DO UPDATE SET + world = excluded.world, + stage = excluded.stage, + code = excluded.code, + title = excluded.title, + desc = excluded.desc, + reward = excluded.reward, + biome = excluded.biome, + is_boss = excluded.is_boss, + waves_json = excluded.waves_json, + modifiers_json = excluded.modifiers_json, + dialogue_json = excluded.dialogue_json, + updated_at = CURRENT_TIMESTAMP + """, ( + seed["level"], seed["world"], seed["stage"], seed["code"], seed["title"], seed["desc"], + seed["reward"], seed["biome"], seed.get("is_boss", 0), + json.dumps(seed.get("waves", {}), ensure_ascii=False), + json.dumps(seed.get("modifiers", {}), ensure_ascii=False), + json.dumps(seed.get("dialogue") or {}, ensure_ascii=False) + )) + conn.commit() + conn.close() + return {"success": True, "message": f"Уровень {seed['code']} сброшен к заводским настройкам", "level": get_campaign_levels(target_lvl)} + + # Reset all 25 levels + cursor.execute("DELETE FROM campaign_levels") + for lvl in DEFAULT_CAMPAIGN_LEVELS: + cursor.execute(""" + INSERT INTO campaign_levels ( + level, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + lvl["level"], lvl["world"], lvl["stage"], lvl["code"], lvl["title"], lvl["desc"], + lvl["reward"], lvl["biome"], lvl.get("is_boss", 0), + json.dumps(lvl.get("waves", {}), ensure_ascii=False), + json.dumps(lvl.get("modifiers", {}), ensure_ascii=False), + json.dumps(lvl.get("dialogue") or {}, ensure_ascii=False) + )) + conn.commit() + conn.close() + return {"success": True, "message": "Все 25 уровней кампании успешно сброшены к начальным настройкам"} + +# ========================================== +# User Profiles & Game Progress +# ========================================== + def get_default_profile_dict(): return { "coins": 150, @@ -230,7 +1170,14 @@ def get_default_profile_dict(): "lastVisit": int(datetime.now().timestamp() * 1000) }, "treeOfWisdom": {"height": 0, "cheats": {}}, - "achievements": {} + "achievements": {}, + "stats": { + "zombiesKilled": 0, + "sunProduced": 0, + "plantsPlanted": 0, + "levelsCompleted": 0, + "coinsEarned": 0 + } } def get_user_by_username(username): @@ -241,7 +1188,7 @@ def get_user_by_username(username): conn.close() return user -def register_user(username, password_hash, initial_data=None): +def register_user(username, password_hash, initial_data=None, ip_address="", user_agent="", device_name=""): username = username.strip() conn = get_connection() cursor = conn.cursor() @@ -251,7 +1198,7 @@ def register_user(username, password_hash, initial_data=None): now_str = now.strftime("%Y-%m-%d %H:%M:%S") secure_hash = hash_password(password_hash) cursor.execute( - "INSERT INTO users (username, password_hash, created_at, last_login) VALUES (?, ?, ?, ?)", + "INSERT INTO users (username, password_hash, created_at, last_login, is_banned, ban_reason, is_admin) VALUES (?, ?, ?, ?, 0, '', 0)", (username, secure_hash, now_str, now_str) ) user_id = cursor.lastrowid @@ -283,17 +1230,13 @@ def register_user(username, password_hash, initial_data=None): json.dumps(profile_data.get("achievements", {})), now_str )) - - # Create session token - token = secrets.token_hex(32) - expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") - cursor.execute( - "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", - (token, user_id, now_str, expires_at) - ) - conn.commit() conn.close() + + # Create session token with full device / ip tracking + token = create_session(user_id, duration_days=30, ip_address=ip_address, user_agent=user_agent, device_name=device_name) + record_login_log(user_id, username, ip_address, user_agent, "registered", device_name) + return {"success": True, "token": token, "user_id": user_id, "username": username, "data": profile_data} except sqlite3.IntegrityError: conn.close() @@ -302,7 +1245,7 @@ def register_user(username, password_hash, initial_data=None): conn.close() return {"success": False, "message": str(e)} -def authenticate_user(username, password_hash): +def authenticate_user(username, password_hash, ip_address="", user_agent="", device_name=""): username = username.strip() conn = get_connection() cursor = conn.cursor() @@ -311,11 +1254,25 @@ def authenticate_user(username, password_hash): user = cursor.fetchone() if not user: conn.close() + record_login_log(None, username, ip_address, user_agent, "user_not_found", device_name) return None + # Check if user is banned + if user["is_banned"]: + conn.close() + reason = user["ban_reason"] or "Нарушение правил сообщества" + record_login_log(user["id"], username, ip_address, user_agent, "banned_login_attempt", device_name) + return { + "success": False, + "message": f"Ваш аккаунт заблокирован администратором. Причина: {reason}", + "is_banned": True, + "ban_reason": reason + } + if not verify_password(password_hash, user["password_hash"]): conn.close() - return {"success": False, "message": "Неверный пароль"} + record_login_log(user["id"], username, ip_address, user_agent, "wrong_password", device_name) + return {"success": False, "message": "Неверный логин или пароль"} now = datetime.now() now_str = now.strftime("%Y-%m-%d %H:%M:%S") @@ -327,13 +1284,6 @@ def authenticate_user(username, password_hash): else: cursor.execute("UPDATE users SET last_login = ? WHERE id = ?", (now_str, user["id"])) - # Create session token - token = secrets.token_hex(32) - expires_at = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") - cursor.execute( - "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", - (token, user["id"], now_str, expires_at) - ) conn.commit() # Fetch profile @@ -341,6 +1291,10 @@ def authenticate_user(username, password_hash): p = cursor.fetchone() conn.close() + # Create session token with full device / ip tracking + token = create_session(user["id"], duration_days=30, ip_address=ip_address, user_agent=user_agent, device_name=device_name) + record_login_log(user["id"], user["username"], ip_address, user_agent, "success", device_name) + if not p: profile_dict = get_default_profile_dict() else: @@ -362,102 +1316,322 @@ def authenticate_user(username, password_hash): "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 "{}") + "achievements": json.loads(p["achievements"] or "{}"), + "stats": json.loads(p["stats"] or "{}") if "stats" in p.keys() and p["stats"] else { + "zombiesKilled": 0, "sunProduced": 0, "plantsPlanted": 0, "levelsCompleted": 0, "coinsEarned": 0 + } } - return {"success": True, "token": token, "user_id": user["id"], "username": user["username"], "data": profile_dict} + return { + "success": True, + "token": token, + "user_id": user["id"], + "username": user["username"], + "isAdmin": bool(user["is_admin"]), + "data": profile_dict + } -def save_user_profile(username, data, token=None): +def get_user_profile(username): + """Returns the full parsed profile dictionary for a given username.""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, username, is_admin FROM users WHERE username = ? COLLATE NOCASE", (username.strip(),)) + user = cursor.fetchone() + if not user: + conn.close() + return None + + cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user["id"],)) + p = cursor.fetchone() + conn.close() + + if not p: + return get_default_profile_dict() + + upg_data = json.loads(p["upgrades"] or "{}") + unlocked_plants = upg_data.get("unlockedPlants", ["sunflower", "peashooter", "wallnut"]) + selected_deck = upg_data.get("selectedDeck", ["sunflower", "peashooter", "wallnut"]) + + return { + "coins": p["coins"], + "level": p["level"], + "maxUnlockedLevel": p["max_unlocked_level"], + "highScore": p["high_score"], + "theme": p["theme"] or "classic", + "upgrades": upg_data, + "unlockedPlants": unlocked_plants, + "selectedDeck": selected_deck, + "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 "{}"), + "stats": json.loads(p["stats"] or "{}") if "stats" in p.keys() and p["stats"] else { + "zombiesKilled": 0, "sunProduced": 0, "plantsPlanted": 0, "levelsCompleted": 0, "coinsEarned": 0 + } + } + +def save_user_profile(username, data, token=None, is_admin_override=False, ip_address=None): + """Persists user progress profile with strict server-authoritative progress protection.""" username = username.strip() - # If token is provided, verify it belongs to username + # If token is provided, verify it belongs to username and update its activity 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": "Недействительный токен авторизации или доступ запрещен"} + if ip_address: + update_session_activity(token, ip_address) conn = get_connection() cursor = conn.cursor() - cursor.execute("SELECT id FROM users WHERE username = ? COLLATE NOCASE", (username,)) + cursor.execute("SELECT id, is_banned, ban_reason FROM users WHERE username = ? COLLATE NOCASE", (username,)) user = cursor.fetchone() if not user: conn.close() return {"success": False, "message": "Пользователь не найден"} + if user["is_banned"]: + conn.close() + return {"success": False, "message": f"Аккаунт заблокирован: {user['ban_reason'] or 'Нарушение правил'}"} + 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") + # Safe progress progression: never downgrade unlocked level or high score from regular client saves + cur_coins = current["coins"] if current else 150 + cur_lvl = current["level"] if current else 1 + cur_max_lvl = current["max_unlocked_level"] if current else 1 + cur_high_score = current["high_score"] if current else 0 + cur_theme = current["theme"] if current else "classic" + has_admin_updated = bool(current and "admin_updated_at" in current.keys() and current["admin_updated_at"]) - # 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) + if is_admin_override: + coins = int(data.get("coins", cur_coins)) + level = int(data.get("level", cur_lvl)) + max_unlocked = int(data.get("maxUnlockedLevel", data.get("max_unlocked_level", cur_max_lvl))) + high_score = int(data.get("highScore", data.get("high_score", cur_high_score))) + theme = str(data.get("theme", cur_theme)) + admin_updated_val = now_str + else: + req_coins = int(data.get("coins", cur_coins)) + # Protect admin-granted coins from being overwritten by stale client auto-saves on refresh + if has_admin_updated and req_coins < cur_coins: + coins = cur_coins + else: + coins = req_coins + level = int(data.get("level", cur_lvl)) + req_max = int(data.get("maxUnlockedLevel", data.get("max_unlocked_level", cur_max_lvl))) + max_unlocked = max(req_max, cur_max_lvl, level) + req_score = int(data.get("highScore", data.get("high_score", cur_high_score))) + high_score = max(req_score, cur_high_score) + theme = str(data.get("theme", cur_theme)) + admin_updated_val = current["admin_updated_at"] if current and "admin_updated_at" in current.keys() else None + + # Merge upgrades & plants upg_obj = json.loads(current["upgrades"] or "{}") if current and current["upgrades"] else {} if "upgrades" in data and isinstance(data["upgrades"], dict): upg_obj.update(data["upgrades"]) if "unlockedPlants" in data and isinstance(data["unlockedPlants"], list): - upg_obj["unlockedPlants"] = data["unlockedPlants"] + cur_unlocked = upg_obj.get("unlockedPlants", ["sunflower", "peashooter", "wallnut"]) + upg_obj["unlockedPlants"] = list(dict.fromkeys(cur_unlocked + data["unlockedPlants"])) if "selectedDeck" in data and isinstance(data["selectedDeck"], list): upg_obj["selectedDeck"] = data["selectedDeck"] upgrades_json = json.dumps(upg_obj) - 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 "{}") + # Materials merge (protect admin-credited materials from stale client downgrade) + cur_materials = json.loads(current["materials"] or "{}") if current and current["materials"] else {"bioHumus": 10, "scrapMetal": 5, "sunEssence": 20, "crystals": 0} + if "materials" in data and isinstance(data["materials"], dict): + for mk, mv in data["materials"].items(): + val = int(mv) + if is_admin_override: + cur_materials[mk] = val + else: + if has_admin_updated and val < cur_materials.get(mk, 0): + pass + else: + cur_materials[mk] = val + materials_json = json.dumps(cur_materials) + + # Plant Levels merge (never downgrade plant levels) + cur_plant_levels = json.loads(current["plant_levels"] or "{}") if current and current["plant_levels"] else {"peashooter": 1, "sunflower": 1, "wallnut": 1, "potatomine": 1, "repeater": 1, "snowpea": 1, "chomper": 1} + new_plant_levels = data.get("plantLevels") or data.get("plant_levels") + if new_plant_levels and isinstance(new_plant_levels, dict): + for plant_k, plant_lvl in new_plant_levels.items(): + if is_admin_override: + cur_plant_levels[plant_k] = int(plant_lvl) + else: + cur_plant_levels[plant_k] = max(cur_plant_levels.get(plant_k, 1), int(plant_lvl)) + plant_levels_json = json.dumps(cur_plant_levels) + + # Perks merge (never downgrade perks for client) + cur_perks = json.loads(current["perks"] or "{}") if current and current["perks"] else {"magnetism": 0, "startSun": 0, "fastCool": 0, "hardyRoots": 0, "luckyDrop": 0} + new_perks = data.get("perks") + if new_perks and isinstance(new_perks, dict): + for perk_k, perk_v in new_perks.items(): + if is_admin_override: + cur_perks[perk_k] = int(perk_v) + else: + cur_perks[perk_k] = max(cur_perks.get(perk_k, 0), int(perk_v)) + perks_json = json.dumps(cur_perks) + + # Zen Garden deep merge (never wipe pots or snail when admin edits items) + cur_zen = json.loads(current["zen_garden"] or "{}") if current and current["zen_garden"] else {} + if "zenGarden" in data and isinstance(data["zenGarden"], dict): + new_zen = data["zenGarden"] + for zk, zv in new_zen.items(): + if zk == "snail" and isinstance(zv, dict) and isinstance(cur_zen.get("snail"), dict): + cur_zen["snail"].update(zv) + else: + cur_zen[zk] = zv + zen_garden_json = json.dumps(cur_zen) + else: + zen_garden_json = 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 "{}") + # Stats merge + cur_stats = json.loads(current["stats"] or "{}") if current and "stats" in current.keys() and current["stats"] else { + "zombiesKilled": 0, "sunProduced": 0, "plantsPlanted": 0, "levelsCompleted": 0, "coinsEarned": 0 + } + if "stats" in data and isinstance(data["stats"], dict): + for sk, sv in data["stats"].items(): + if is_admin_override: + cur_stats[sk] = int(sv) + else: + cur_stats[sk] = max(cur_stats.get(sk, 0), int(sv)) + stats_json = json.dumps(cur_stats) + 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 = ? + zen_garden = ?, tree_of_wisdom = ?, achievements = ?, stats = ?, admin_updated_at = ?, 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, + zen_garden_json, tree_json, achievements_json, stats_json, admin_updated_val, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + upgrades, materials, plant_levels, perks, zen_garden, tree_of_wisdom, achievements, stats, admin_updated_at, 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 + zen_garden_json, tree_json, achievements_json, stats_json, admin_updated_val, now_str )) + conn.commit() + conn.close() + return {"success": True, "data": get_user_profile(username)} + +# ========================================== +# Leaderboards & Bug Tracking +# ========================================== + +def submit_leaderboard_score(user_id, player_name, mode, score, detail=""): + """Submits or updates a player's high score on the server leaderboard.""" + if not player_name or not mode or score is None: + return {"success": False, "message": "Некорректные параметры счета"} + + clean_name = str(player_name).strip()[:30] + clean_mode = str(mode).strip()[:20] + clean_detail = str(detail).strip()[:100] + score_val = int(score) + + conn = get_connection() + cursor = conn.cursor() + + if user_id: + cursor.execute("SELECT id, score FROM leaderboards WHERE user_id = ? AND mode = ?", (user_id, clean_mode)) + else: + cursor.execute("SELECT id, score FROM leaderboards WHERE player_name = ? AND mode = ?", (clean_name, clean_mode)) + row = cursor.fetchone() + + now_str = datetime.now().strftime("%Y-%m-%d") + if row: + if score_val > row["score"]: + cursor.execute( + "UPDATE leaderboards SET score = ?, detail = ?, player_name = ?, created_at = ? WHERE id = ?", + (score_val, clean_detail, clean_name, now_str, row["id"]) + ) + else: + cursor.execute( + "INSERT INTO leaderboards (user_id, player_name, mode, score, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, clean_name, clean_mode, score_val, clean_detail, now_str) + ) + conn.commit() conn.close() return {"success": True} +def get_leaderboard_scores(mode=None, limit=10): + """Retrieves top leaderboard entries for a specific mode or all modes.""" + conn = get_connection() + cursor = conn.cursor() + + if mode: + cursor.execute(""" + SELECT id, user_id, player_name, mode, score, detail, created_at + FROM leaderboards + WHERE mode = ? + ORDER BY score DESC + LIMIT ? + """, (mode, limit)) + rows = cursor.fetchall() + result = [{ + "id": r["id"], + "name": r["player_name"], + "mode": r["mode"], + "score": r["score"], + "detail": r["detail"] or f"{r['score']} очков", + "date": str(r["created_at"])[:10] + } for r in rows] + else: + modes = ["adventure", "survival", "roguelike", "izombie", "bowling", "boss", "bossrush"] + result = {} + for m in modes: + cursor.execute(""" + SELECT id, user_id, player_name, mode, score, detail, created_at + FROM leaderboards + WHERE mode = ? + ORDER BY score DESC + LIMIT ? + """, (m, limit)) + rows = cursor.fetchall() + result[m] = [{ + "id": r["id"], + "name": r["player_name"], + "mode": r["mode"], + "score": r["score"], + "detail": r["detail"] or f"{r['score']} очков", + "date": str(r["created_at"])[:10] + } for r in rows] + + conn.close() + return result + def list_all_users(): conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT - u.id, u.username, u.created_at, u.last_login, + u.id, u.username, u.created_at, u.last_login, u.is_banned, u.ban_reason, u.is_admin, 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 + p.materials, p.plant_levels, p.perks, p.upgrades, p.zen_garden, p.tree_of_wisdom, p.achievements, p.stats FROM users u LEFT JOIN profiles p ON u.id = p.user_id ORDER BY u.id ASC @@ -472,6 +1646,9 @@ def list_all_users(): "username": r["username"], "createdAt": r["created_at"], "lastLogin": r["last_login"], + "isBanned": bool(r["is_banned"]), + "banReason": r["ban_reason"] or "", + "isAdmin": bool(r["is_admin"]), "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, @@ -483,11 +1660,549 @@ def list_all_users(): "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 "{}") + "achievements": json.loads(r["achievements"] or "{}"), + "stats": json.loads(r["stats"] or "{}") if "stats" in r.keys() and r["stats"] else { + "zombiesKilled": 0, "sunProduced": 0, "plantsPlanted": 0, "levelsCompleted": 0, "coinsEarned": 0 + } }) return result +# ========================================== +# Game Configurator & Balance System +# ========================================== + +def get_game_config(): + """Retrieves active game balance configuration and presets.""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM game_config ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + conn.close() + + if not row: + return DEFAULT_GAME_CONFIG.copy() + + try: + data = json.loads(row["config_data"]) + data["preset"] = row["preset_name"] or data.get("preset", "Classic 2009") + data["updatedAt"] = row["updated_at"] + return data + except Exception: + return DEFAULT_GAME_CONFIG.copy() + +def save_game_config(config_dict, preset_name=None): + """Persists updated balance configuration to database.""" + if not isinstance(config_dict, dict): + raise ValueError("Config must be a valid dictionary") + + preset = preset_name or config_dict.get("preset", "Custom") + config_dict["preset"] = preset + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO game_config (preset_name, config_data, updated_at) VALUES (?, ?, ?)", + (preset, json.dumps(config_dict, ensure_ascii=False), now_str) + ) + conn.commit() + conn.close() + + config_dict["updatedAt"] = now_str + return config_dict + +# ========================================== +# Admin Management Functions +# ========================================== + +def admin_list_users(): + """Returns list of all players with full account and profile details.""" + return list_all_users() + +def admin_get_user_detail(user_id): + """Fetches full player details, profile, active sessions, and leaderboard scores.""" + conn = get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user: + conn.close() + return None + + cursor.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,)) + p = cursor.fetchone() + + cursor.execute("SELECT token, created_at, expires_at, ip_address, device_name, last_active FROM sessions WHERE user_id = ?", (user_id,)) + sessions = [{ + "token": s["token"], + "createdAt": s["created_at"], + "expiresAt": s["expires_at"], + "ipAddress": s["ip_address"] or "", + "deviceName": s["device_name"] or "Unknown Device", + "lastActive": s["last_active"] or s["created_at"] + } for s in cursor.fetchall()] + + cursor.execute("SELECT id, mode, score, detail, created_at FROM leaderboards WHERE user_id = ? ORDER BY score DESC LIMIT 10", (user_id,)) + scores = [{"id": l["id"], "mode": l["mode"], "score": l["score"], "detail": l["detail"], "createdAt": l["created_at"]} for l in cursor.fetchall()] + + conn.close() + + if p: + 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 "{}"), + "stats": json.loads(p["stats"] or "{}") if "stats" in p.keys() and p["stats"] else { + "zombiesKilled": 0, "sunProduced": 0, "plantsPlanted": 0, "levelsCompleted": 0, "coinsEarned": 0 + }, + "updatedAt": p["updated_at"] + } + else: + profile_dict = get_default_profile_dict() + + return { + "id": user["id"], + "username": user["username"], + "createdAt": user["created_at"], + "lastLogin": user["last_login"], + "isBanned": bool(user["is_banned"]), + "banReason": user["ban_reason"] or "", + "isAdmin": bool(user["is_admin"]), + "coins": profile_dict["coins"], + "level": profile_dict["level"], + "maxUnlockedLevel": profile_dict["maxUnlockedLevel"], + "highScore": profile_dict["highScore"], + "theme": profile_dict["theme"], + "materials": profile_dict["materials"], + "stats": profile_dict.get("stats", {}), + "zenGarden": profile_dict["zenGarden"], + "profile": profile_dict, + "sessions": sessions, + "scores": scores + } + +def admin_update_user_profile(user_id, updates): + """Updates user profile and account details (coins, level, materials, perks, etc.).""" + conn = get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + u = cursor.fetchone() + if not u: + conn.close() + return {"success": False, "message": "Пользователь не найден"} + + username = u["username"] + conn.close() + + conn = get_connection() + cursor = conn.cursor() + if "isBanned" in updates or "is_banned" in updates: + is_banned = 1 if updates.get("isBanned", updates.get("is_banned")) else 0 + ban_reason = updates.get("banReason", updates.get("ban_reason", "")) + cursor.execute("UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?", (is_banned, ban_reason, user_id)) + if is_banned: + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + + if "isAdmin" in updates or "is_admin" in updates: + is_admin = 1 if updates.get("isAdmin", updates.get("is_admin")) else 0 + cursor.execute("UPDATE users SET is_admin = ? WHERE id = ?", (is_admin, user_id)) + + conn.commit() + conn.close() + + res = save_user_profile(username, updates, is_admin_override=True) + return res + +def admin_set_user_ban(user_id, is_banned, reason=""): + """Bans or unbans a player and clears sessions if banned.""" + conn = get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not cursor.fetchone(): + conn.close() + return {"success": False, "message": "Пользователь не найден"} + + ban_val = 1 if is_banned else 0 + cursor.execute("UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?", (ban_val, str(reason or "").strip(), user_id)) + if ban_val == 1: + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + + conn.commit() + conn.close() + return {"success": True, "isBanned": bool(ban_val), "banReason": reason} + +def admin_delete_user(user_id): + """Deletes user and cascades to profile, sessions, and leaderboards.""" + conn = get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not cursor.fetchone(): + conn.close() + return {"success": False, "message": "Пользователь не найден"} + + cursor.execute("DELETE FROM profiles WHERE user_id = ?", (user_id,)) + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + cursor.execute("DELETE FROM leaderboards WHERE user_id = ?", (user_id,)) + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + conn.commit() + conn.close() + return {"success": True, "message": "Пользователь успешно удален"} + +def admin_create_user(username, password, initial_profile=None, is_admin=0): + """Creates a new user with administrative parameters.""" + res = register_user(username, password, initial_profile) + if not res.get("success"): + return res + + if is_admin: + conn = get_connection() + cursor = conn.cursor() + cursor.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (res["user_id"],)) + conn.commit() + conn.close() + res["isAdmin"] = True + + return res + +def admin_reset_user_password(user_id, new_password): + """Resets user's password and revokes all current sessions.""" + if not new_password: + return {"success": False, "message": "Пароль не может быть пустым"} + + conn = get_connection() + cursor = conn.cursor() + + cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not cursor.fetchone(): + conn.close() + return {"success": False, "message": "Пользователь не найден"} + + secure_hash = hash_password(new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (secure_hash, user_id)) + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + + conn.commit() + conn.close() + return {"success": True, "message": "Пароль успешно сброшен"} + +def admin_get_stats(): + """Calculates comprehensive dashboard statistics.""" + conn = get_connection() + cursor = conn.cursor() + + # Total users + cursor.execute("SELECT COUNT(*) as cnt FROM users") + total_users = cursor.fetchone()["cnt"] + + # Active in last 24h + now = datetime.now() + yesterday_str = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + cursor.execute("SELECT COUNT(*) as cnt FROM users WHERE last_login >= ?", (yesterday_str,)) + active_24h = cursor.fetchone()["cnt"] + + # Banned users + cursor.execute("SELECT COUNT(*) as cnt FROM users WHERE is_banned = 1") + banned_users = cursor.fetchone()["cnt"] + + # Total coins in economy + cursor.execute("SELECT SUM(coins) as total_coins FROM profiles") + row_coins = cursor.fetchone() + total_coins = row_coins["total_coins"] or 0 + + # Max level reached across players + cursor.execute("SELECT MAX(max_unlocked_level) as max_lvl, MAX(high_score) as max_score FROM profiles") + row_max = cursor.fetchone() + max_level = row_max["max_lvl"] or 1 + max_high_score = row_max["max_score"] or 0 + + # Active sessions + now_str = now.strftime("%Y-%m-%d %H:%M:%S") + cursor.execute("SELECT COUNT(*) as cnt FROM sessions WHERE expires_at > ?", (now_str,)) + active_sessions = cursor.fetchone()["cnt"] + + # Total login log events + cursor.execute("SELECT COUNT(*) as cnt FROM login_logs") + total_logins = cursor.fetchone()["cnt"] + + # Bug report counts + cursor.execute("SELECT status, COUNT(*) as cnt FROM bugs GROUP BY status") + bug_counts = {r["status"]: r["cnt"] for r in cursor.fetchall()} + total_bugs = sum(bug_counts.values()) + + # Campaign levels count + cursor.execute("SELECT COUNT(*) as cnt FROM campaign_levels") + campaign_count = cursor.fetchone()["cnt"] + + # Aggregate gameplay stats across players + cursor.execute("SELECT stats FROM profiles WHERE stats IS NOT NULL") + tot_zombies = 0 + tot_sun = 0 + tot_plants = 0 + tot_levels_cleared = 0 + tot_coins_earned = 0 + for r in cursor.fetchall(): + try: + st = json.loads(r["stats"] or "{}") if "stats" in r.keys() and r["stats"] else {} + tot_zombies += int(st.get("zombiesKilled", 0)) + tot_sun += int(st.get("sunProduced", 0)) + tot_plants += int(st.get("plantsPlanted", 0)) + tot_levels_cleared += int(st.get("levelsCompleted", 0)) + tot_coins_earned += int(st.get("coinsEarned", 0)) + except Exception: + pass + + conn.close() + + active_config = get_game_config() + + return { + "totalUsers": total_users, + "active24h": active_24h, + "bannedUsers": banned_users, + "totalCoins": total_coins, + "maxLevelReached": max_level, + "maxHighScore": max_high_score, + "activeSessions": active_sessions, + "totalLoginLogs": total_logins, + "campaignLevelsCount": campaign_count, + "totalZombiesKilled": tot_zombies, + "totalSunProduced": tot_sun, + "totalPlantsPlanted": tot_plants, + "totalLevelsCompleted": tot_levels_cleared, + "totalCoinsEarned": tot_coins_earned, + "bugReports": { + "total": total_bugs, + "new": bug_counts.get("новый", 0), + "inProgress": bug_counts.get("в работе", 0), + "fixed": bug_counts.get("исправлено", 0), + "rejected": bug_counts.get("отклонено", 0) + }, + "gameConfig": { + "preset": active_config.get("preset", "Classic 2009"), + "events": active_config.get("events", {}), + "updatedAt": active_config.get("updatedAt") + } + } + +def admin_export_db(): + """Dumps all database tables into a JSON structure.""" + conn = get_connection() + cursor = conn.cursor() + + # Users + cursor.execute("SELECT id, username, password_hash, created_at, last_login, is_banned, ban_reason, is_admin FROM users") + users = [dict(r) for r in cursor.fetchall()] + + # Profiles + cursor.execute("SELECT * FROM profiles") + profiles = [dict(r) for r in cursor.fetchall()] + + # Leaderboards + cursor.execute("SELECT * FROM leaderboards") + leaderboards = [dict(r) for r in cursor.fetchall()] + + # Bugs + cursor.execute("SELECT * FROM bugs") + bugs = [dict(r) for r in cursor.fetchall()] + + # Game Config + cursor.execute("SELECT * FROM game_config") + game_configs = [dict(r) for r in cursor.fetchall()] + + # Campaign Levels + cursor.execute("SELECT * FROM campaign_levels") + campaign_levels = [dict(r) for r in cursor.fetchall()] + + # Admin Settings + cursor.execute("SELECT * FROM admin_settings") + admin_settings = [dict(r) for r in cursor.fetchall()] + + # Login Logs (latest 500) + cursor.execute("SELECT * FROM login_logs ORDER BY id DESC LIMIT 500") + login_logs = [dict(r) for r in cursor.fetchall()] + + conn.close() + + return { + "exportedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "version": "3.0", + "users": users, + "profiles": profiles, + "leaderboards": leaderboards, + "bugs": bugs, + "gameConfigs": game_configs, + "campaignLevels": campaign_levels, + "adminSettings": admin_settings, + "loginLogs": login_logs + } + +def admin_import_db(data): + """Restores database tables from a JSON export dictionary.""" + if not isinstance(data, dict): + return {"success": False, "message": "Неверный формат данных бэкапа"} + + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute("BEGIN TRANSACTION") + + # Import Users + if "users" in data and isinstance(data["users"], list): + cursor.execute("DELETE FROM users") + cursor.execute("DELETE FROM sessions") + for u in data["users"]: + cursor.execute(""" + INSERT INTO users (id, username, password_hash, created_at, last_login, is_banned, ban_reason, is_admin) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + u.get("id"), + u.get("username"), + u.get("password_hash"), + u.get("created_at"), + u.get("last_login"), + u.get("is_banned", 0), + u.get("ban_reason", ""), + u.get("is_admin", 0) + )) + + # Import Profiles + if "profiles" in data and isinstance(data["profiles"], list): + cursor.execute("DELETE FROM profiles") + for p in data["profiles"]: + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + p.get("user_id"), + p.get("coins", 150), + p.get("level", 1), + p.get("max_unlocked_level", 1), + p.get("high_score", 0), + p.get("theme", "classic"), + p.get("upgrades", "{}"), + p.get("materials", "{}"), + p.get("plant_levels", "{}"), + p.get("perks", "{}"), + p.get("zen_garden", "{}"), + p.get("tree_of_wisdom", "{}"), + p.get("achievements", "{}"), + p.get("updated_at") + )) + + # Import Leaderboards + if "leaderboards" in data and isinstance(data["leaderboards"], list): + cursor.execute("DELETE FROM leaderboards") + for l in data["leaderboards"]: + cursor.execute(""" + INSERT INTO leaderboards (id, user_id, player_name, mode, score, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + l.get("id"), + l.get("user_id"), + l.get("player_name"), + l.get("mode"), + l.get("score", 0), + l.get("detail", ""), + l.get("created_at") + )) + + # Import Bugs + if "bugs" in data and isinstance(data["bugs"], list): + cursor.execute("DELETE FROM bugs") + for b in data["bugs"]: + cursor.execute(""" + INSERT INTO bugs (id, author, category, text, status, timestamp, admin_reply) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + b.get("id"), + b.get("author"), + b.get("category", "bug"), + b.get("text", ""), + b.get("status", "новый"), + b.get("timestamp"), + b.get("admin_reply") or b.get("adminReply") + )) + + # Import Game Config + if "gameConfigs" in data and isinstance(data["gameConfigs"], list): + cursor.execute("DELETE FROM game_config") + for gc in data["gameConfigs"]: + cursor.execute(""" + INSERT INTO game_config (id, preset_name, config_data, updated_at) + VALUES (?, ?, ?, ?) + """, ( + gc.get("id"), + gc.get("preset_name", "Classic 2009"), + gc.get("config_data"), + gc.get("updated_at") + )) + + # Import Campaign Levels + if "campaignLevels" in data and isinstance(data["campaignLevels"], list): + cursor.execute("DELETE FROM campaign_levels") + for cl in data["campaignLevels"]: + cursor.execute(""" + INSERT INTO campaign_levels ( + level, world, stage, code, title, desc, reward, biome, is_boss, + waves_json, modifiers_json, dialogue_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + cl.get("level"), + cl.get("world"), + cl.get("stage"), + cl.get("code"), + cl.get("title"), + cl.get("desc"), + cl.get("reward", 100), + cl.get("biome", "day"), + cl.get("is_boss", 0), + cl.get("waves_json", "{}"), + cl.get("modifiers_json", "{}"), + cl.get("dialogue_json", "{}"), + cl.get("updated_at") + )) + + # Import Admin Settings + if "adminSettings" in data and isinstance(data["adminSettings"], list): + cursor.execute("DELETE FROM admin_settings") + for ast in data["adminSettings"]: + cursor.execute(""" + INSERT INTO admin_settings (key, value, updated_at) + VALUES (?, ?, ?) + """, ( + ast.get("key"), + ast.get("value"), + ast.get("updated_at") + )) + + conn.commit() + conn.close() + return {"success": True, "message": "База данных успешно восстановлена из бэкапа"} + except Exception as e: + conn.rollback() + conn.close() + return {"success": False, "message": f"Ошибка восстановления: {str(e)}"} + if __name__ == "__main__": init_db() users = list_all_users() - print(f"Database initialized. Total registered users: {len(users)}") + config = get_game_config() + levels = get_campaign_levels() + print(f"Database initialized. Total users: {len(users)}, Levels: {len(levels)}, Active config preset: {config.get('preset')}") diff --git a/scripts/feedback_server.py b/scripts/feedback_server.py index 8b3b00a..569ed71 100755 --- a/scripts/feedback_server.py +++ b/scripts/feedback_server.py @@ -3,8 +3,11 @@ Backend Server for Plants vs. Zombies (ai.qpid.pro) Zero external dependencies. Manages: -- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/users) +- SQLite User Accounts & Game Progression Profiles (/api/auth/login, /api/auth/register, /api/auth/save, /api/auth/sync, /api/auth/sessions) +- Campaign Levels API (/api/campaign/levels, /api/admin/campaign/*) - Community Bug Reports & Feedback (/api/bugs) +- Public Game Configurator & Balance API (/api/config) +- Admin Management API (/api/admin/*, /api/admin/logs, /api/admin/sessions, /api/admin/password/change) """ import http.server @@ -13,7 +16,11 @@ import os import sys import html import urllib.parse -from datetime import datetime +import secrets +import re +import threading +import time +from datetime import datetime, timedelta BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(BASE_DIR, "scripts")) @@ -21,7 +28,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, "scripts")) import db PORT = 8001 -MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB +MAX_BODY_SIZE = 5 * 1024 * 1024 # 5MB (for backups) 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") @@ -29,6 +36,64 @@ BUGS_MD = os.path.join(BASE_DIR, "BUG_REPORTS.md") os.makedirs(REPORTS_DIR, exist_ok=True) db.init_db() +# Run maintenance upon server initialization +try: + _init_maint = db.perform_system_maintenance() + sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Initial system maintenance: {_init_maint}\n") +except Exception as _e: + sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Initial system maintenance warning: {_e}\n") + +# Daemon background thread for daily system maintenance (24-hour interval) +_maintenance_thread = None + +def start_daily_maintenance_thread(interval_seconds=86400): + global _maintenance_thread + if _maintenance_thread is not None and _maintenance_thread.is_alive(): + return _maintenance_thread + + def maintenance_loop(): + while True: + time.sleep(interval_seconds) + try: + summary = db.perform_system_maintenance() + sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Daily system maintenance completed: {summary}\n") + except Exception as e: + sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] Daily system maintenance error: {e}\n") + + _maintenance_thread = threading.Thread(target=maintenance_loop, daemon=True, name="DailyMaintenanceThread") + _maintenance_thread.start() + return _maintenance_thread + +start_daily_maintenance_thread() + +ADMIN_TOKENS = {} # token -> expires_at timestamp + +def parse_device_info(user_agent): + """Extract friendly device and browser name from User-Agent string.""" + return db.parse_device_info(user_agent) + +def create_admin_token(duration_days=7): + token = "adm_" + secrets.token_hex(24) + expires_at = (datetime.now() + timedelta(days=duration_days)).timestamp() + ADMIN_TOKENS[token] = expires_at + return token + +def is_valid_admin_token(token): + if not token or not isinstance(token, str): + return False + token = token.strip() + if token in ADMIN_TOKENS: + if datetime.now().timestamp() < ADMIN_TOKENS[token]: + return True + else: + del ADMIN_TOKENS[token] + return False + # Check if this token belongs to an admin user session in DB + user = db.validate_session(token) + if user and user.get("isAdmin"): + return True + return False + # In-memory rate limiter for failed login attempts FAILED_LOGIN_ATTEMPTS = {} # ip -> list of timestamps @@ -111,7 +176,7 @@ def sync_markdown(bugs): 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 "🔴 Новый") + st_badge = "✅ Исправлено" if st == "исправлено" else ("🟡 В работе" if st == "в работе" else ("⚪ Отклонено" if st == "отклонено" else "🔴 Новый")) text = str(b.get("text", "")).replace("|", "/").replace("\n", " ") reply = str(b.get("adminReply") or "—").replace("|", "/").replace("\n", " ") lines.append(f"| {bid} | {ts} | {author} | {cat_icon} | {st_badge} | {text} | {reply} |") @@ -138,23 +203,84 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): 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"): + if host in ["localhost", "127.0.0.1", "ai.qpid.pro", "pvz.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") + self.send_header("Access-Control-Allow-Origin", "*") except Exception: - self.send_header("Access-Control-Allow-Origin", "https://ai.qpid.pro") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Token, X-Auth-Token, X-Session-Token") self.send_header("Access-Control-Allow-Credentials", "true") + def _send_json(self, data, status=200): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self._set_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def extract_token(self, payload=None): + """Extracts Bearer token, custom header, cookie, query param, or JSON payload token.""" + auth_header = self.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:].strip() + for h in ["X-Admin-Token", "X-Auth-Token", "X-Session-Token"]: + if h in self.headers and self.headers[h]: + return self.headers.get(h, "").strip() + + # Check cookies + cookie_header = self.headers.get("Cookie", "") + if cookie_header: + for part in cookie_header.split(";"): + part = part.strip() + if part.startswith("admin_token="): + return part.split("=", 1)[1].strip() + if part.startswith("token="): + return part.split("=", 1)[1].strip() + if part.startswith("session_token="): + return part.split("=", 1)[1].strip() + + # Check query parameters + if "?" in self.path: + parsed_query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + if "token" in parsed_query and parsed_query["token"]: + return parsed_query["token"][0].strip() + if "adminToken" in parsed_query and parsed_query["adminToken"]: + return parsed_query["adminToken"][0].strip() + + # Check payload + if payload and isinstance(payload, dict): + if "token" in payload and payload["token"]: + return str(payload["token"]).strip() + if "adminToken" in payload and payload["adminToken"]: + return str(payload["adminToken"]).strip() + if "sessionToken" in payload and payload["sessionToken"]: + return str(payload["sessionToken"]).strip() + + return None + + def extract_admin_token(self, payload=None): + return self.extract_token(payload) + + def require_admin(self, payload=None): + token = self.extract_admin_token(payload) + if not token or not is_valid_admin_token(token): + self._send_json({"success": False, "message": "Доступ запрещен: требуется токен администратора"}, 401) + return False + return True + def do_OPTIONS(self): self.send_response(204) self._set_cors() self.end_headers() def do_GET(self): - path = self.path.split("?")[0] + parsed_url = urllib.parse.urlparse(self.path) + path = parsed_url.path + query_params = urllib.parse.parse_qs(parsed_url.query) # 1. Health check if path == "/health": @@ -165,46 +291,205 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): self.wfile.write(b"OK") return - # 2. List Registered Users in Database + # 2. Public Game Balance Config + if path in ["/api/config", "/api/bugs/config"]: + config = db.get_game_config() + self._send_json(config, 200) + return + + # 3. Campaign Levels Public API (GET /api/campaign/levels) + if path in ["/api/campaign/levels", "/api/bugs/campaign/levels"]: + level_num = query_params.get("level", [None])[0] + if level_num is not None: + lvl = db.get_campaign_levels(level_num) + if not lvl: + self._send_json({"success": False, "message": "Уровень не найден"}, 404) + return + self._send_json({"success": True, "level": lvl}, 200) + return + levels = db.get_campaign_levels() + self._send_json({"success": True, "levels": levels, "count": len(levels)}, 200) + return + + # 4. 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) + self._send_json({"success": True, "users": users}, 200) return - # 3. Bug Reports - if path.startswith("/api/bugs") or path.startswith("/bugs"): + # 5. Bug Reports Feed + if path == "/api/bugs" or path == "/bugs" or path == "/api/bugs/list": 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) + self._send_json(bugs, 200) return - self.send_response(404) - self._set_cors() - self.end_headers() + # 6. User Active Sessions (GET /api/auth/sessions) + if path in ["/api/auth/sessions", "/api/bugs/auth/sessions"]: + token = self.extract_token() + if not token: + self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401) + return + user = db.validate_session(token) + if not user: + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) + return + + client_ip = self.get_client_ip() + db.update_session_activity(token, client_ip) + + user_sessions = db.get_user_sessions(user["id"]) + for s in user_sessions: + s["isCurrent"] = (s["token"] == token) + s["is_current"] = (s["token"] == token) + + self._send_json({"success": True, "sessions": user_sessions, "total": len(user_sessions)}, 200) + return + + # 7. Public / User Profile Fetch (/api/auth/profile, /api/auth/me, /api/auth/sync) + if path in ["/api/auth/profile", "/api/auth/me", "/api/auth/sync", "/api/bugs/auth/profile", "/api/bugs/auth/me"]: + token = self.extract_token() + if not token: + self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401) + return + + user = db.validate_session(token) + if not user: + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) + return + + client_ip = self.get_client_ip() + db.update_session_activity(token, client_ip) + + profile_data = db.get_user_profile(user["username"]) + # Client has synchronized with the server: clear admin_updated_at flag + try: + conn_sync = db.get_connection() + c_sync = conn_sync.cursor() + c_sync.execute("UPDATE profiles SET admin_updated_at = NULL WHERE user_id = ?", (user["id"],)) + conn_sync.commit() + conn_sync.close() + except Exception: + pass + + self._send_json({ + "success": True, + "token": token, + "user_id": user["id"], + "username": user["username"], + "isAdmin": user.get("isAdmin", False), + "data": profile_data + }, 200) + return + + # 8. Global Community Leaderboard API (GET) + if path in ["/api/leaderboard", "/api/leaderboards", "/api/bugs/leaderboard"]: + mode_param = query_params.get("mode", [None])[0] + records = db.get_leaderboard_scores(mode_param) + self._send_json({"success": True, "records": records}, 200) + return + + # ==================================================== + # ADMIN API (GET) + # ==================================================== + + # Admin: Dashboard Stats + if path == "/api/admin/stats": + if not self.require_admin(): + return + stats = db.admin_get_stats() + self._send_json({"success": True, "stats": stats}, 200) + return + + # Admin: Game Config & Presets + if path == "/api/admin/config": + if not self.require_admin(): + return + config = db.get_game_config() + self._send_json({"success": True, "config": config}, 200) + return + + # Admin: Campaign Levels Editor List + if path in ["/api/admin/campaign/levels", "/api/admin/campaign"]: + if not self.require_admin(): + return + levels = db.get_campaign_levels() + self._send_json({"success": True, "levels": levels, "count": len(levels)}, 200) + return + + # Admin: Login Audit Logs + if path == "/api/admin/logs": + if not self.require_admin(): + return + limit = int(query_params.get("limit", [100])[0]) + username = query_params.get("username", [None])[0] + ip = query_params.get("ip", [None])[0] + logs = db.get_login_logs(limit=limit, username=username, ip=ip) + self._send_json({"success": True, "logs": logs, "count": len(logs)}, 200) + return + + # Admin: All Player Active Sessions + if path in ["/api/admin/sessions", "/api/admin/sessions/list"]: + if not self.require_admin(): + return + sessions = db.get_all_active_sessions() + self._send_json({"success": True, "sessions": sessions, "count": len(sessions)}, 200) + return + + # Admin: List all users with detail + if path == "/api/admin/users": + if not self.require_admin(): + return + users = db.admin_list_users() + self._send_json({"success": True, "users": users}, 200) + return + + # Admin: Get single user detail (/api/admin/user/) + m_user = re.match(r"^/api/admin/user/(\d+)$", path) + if m_user: + if not self.require_admin(): + return + user_id = int(m_user.group(1)) + detail = db.admin_get_user_detail(user_id) + if not detail: + self._send_json({"success": False, "message": "Пользователь не найден"}, 404) + return + self._send_json({"success": True, "user": detail}, 200) + return + + # Admin: Bug Reports List + if path == "/api/admin/bugs": + if not self.require_admin(): + return + bugs = load_bugs() + self._send_json({"success": True, "bugs": bugs}, 200) + return + + # Admin: Database Backup Export + if path == "/api/admin/backup/export": + if not self.require_admin(): + return + export_data = db.admin_export_db() + self._send_json({"success": True, "data": export_data}, 200) + return + + # Admin: System Maintenance & Cleanup (GET) + if path in ["/api/admin/system/cleanup", "/api/admin/cleanup"]: + if not self.require_admin(): + return + summary = db.perform_system_maintenance() + self._send_json(summary, 200) + return + + self._send_json({"error": "Not Found", "path": path}, 404) def do_POST(self): - path = self.path.split("?")[0] + parsed_url = urllib.parse.urlparse(self.path) + path = parsed_url.path length = int(self.headers.get("Content-Length", 0)) - # Check payload size limit (Max 1MB) + # Check payload size limit 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._send_json({"success": False, "message": "Payload Too Large (Max 5MB)"}, 413) self.close_connection = True return @@ -213,25 +498,253 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): try: payload = json.loads(body) if body else {} except Exception as e: - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8")) + self._send_json({"error": f"Invalid JSON: {e}"}, 400) return - # 1a. User Login with Rate Limiting + client_ip = self.get_client_ip() + user_agent = self.headers.get("User-Agent", "") + device_name = parse_device_info(user_agent) + + # ==================================================== + # ADMIN AUTHENTICATION & MANAGEMENT + # ==================================================== + if path == "/api/admin/auth": + password = str(payload.get("password", "")).strip() + if not password: + self._send_json({"success": False, "message": "Введите пароль администратора"}, 400) + return + + if db.verify_admin_password(password): + token = create_admin_token() + self._send_json({ + "success": True, + "token": token, + "admin": True, + "message": "Успешная авторизация администратора" + }, 200) + return + + self._send_json({"success": False, "message": "Неверный пароль администратора"}, 401) + return + + # Admin: Change Master Password + if path == "/api/admin/password/change": + admin_token = self.extract_admin_token(payload) + is_authed = admin_token and is_valid_admin_token(admin_token) + + current_password = str(payload.get("currentPassword", payload.get("current_password", ""))).strip() + new_password = str(payload.get("newPassword", payload.get("new_password", payload.get("password", "")))).strip() + + if not new_password: + self._send_json({"success": False, "message": "Новый пароль не может быть пустым"}, 400) + return + + if not is_authed: + if not current_password or not db.verify_admin_password(current_password): + self._send_json({"success": False, "message": "Неверный текущий пароль администратора"}, 401) + return + + res = db.set_admin_password(new_password) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Terminate Any Session (POST /api/admin/session/terminate) + if path in ["/api/admin/session/terminate", "/api/admin/sessions/terminate"]: + if not self.require_admin(payload): + return + target_token = payload.get("targetToken") or payload.get("target_token") or payload.get("token") + if not target_token: + self._send_json({"success": False, "message": "Укажите токен сессии для завершения"}, 400) + return + db.terminate_session(str(target_token).strip()) + self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200) + return + + # Admin: System Maintenance & Cleanup (POST /api/admin/system/cleanup) + if path in ["/api/admin/system/cleanup", "/api/admin/cleanup"]: + if not self.require_admin(payload): + return + audit_days = payload.get("audit_days", payload.get("auditDays", 30)) + session_days = payload.get("session_days", payload.get("sessionDays", 30)) + try: + audit_days = int(audit_days) + except (ValueError, TypeError): + audit_days = 30 + try: + session_days = int(session_days) + except (ValueError, TypeError): + session_days = 30 + + summary = db.perform_system_maintenance(audit_days=audit_days, session_days=session_days) + self._send_json(summary, 200) + return + + # Admin: Save Campaign Level (POST /api/admin/campaign/level/save) + if path in ["/api/admin/campaign/level/save", "/api/admin/campaign/save"]: + if not self.require_admin(payload): + return + level_data = payload.get("level") or payload + try: + saved = db.save_campaign_level(level_data) + self._send_json({"success": True, "message": "Уровень кампании сохранен", "level": saved}, 200) + except Exception as e: + self._send_json({"success": False, "message": f"Ошибка сохранения уровня: {e}"}, 400) + return + + # Admin: Reset Campaign Level (POST /api/admin/campaign/level/reset) + if path in ["/api/admin/campaign/level/reset", "/api/admin/campaign/reset"]: + if not self.require_admin(payload): + return + target_lvl = payload.get("level") + res = db.reset_campaign_levels(target_lvl) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Save / Apply Game Balance Config + if path == "/api/admin/config": + if not self.require_admin(payload): + return + config_data = payload.get("config") or payload + preset_name = payload.get("preset") or config_data.get("preset", "Custom") + + # If applying a standard preset from default catalogue + if "applyPreset" in payload and payload["applyPreset"] in db.DEFAULT_GAME_CONFIG["presets"]: + target_preset = payload["applyPreset"] + preset_obj = db.DEFAULT_GAME_CONFIG["presets"][target_preset] + current_cfg = db.get_game_config() + current_cfg["preset"] = target_preset + current_cfg["economy"].update(preset_obj.get("economy", {})) + current_cfg["zombies"].update(preset_obj.get("zombies", {})) + current_cfg["plants"].update(preset_obj.get("plants", {})) + current_cfg["zenGarden"].update(preset_obj.get("zenGarden", {})) + current_cfg["events"].update(preset_obj.get("events", {})) + saved = db.save_game_config(current_cfg, target_preset) + self._send_json({"success": True, "message": f"Пресет '{target_preset}' успешно применен", "config": saved}, 200) + return + + try: + saved = db.save_game_config(config_data, preset_name) + self._send_json({"success": True, "message": "Конфигурация баланса сохранена", "config": saved}, 200) + except Exception as e: + self._send_json({"success": False, "message": f"Ошибка сохранения: {e}"}, 400) + return + + # Admin: Create User + if path == "/api/admin/user/create": + if not self.require_admin(payload): + return + username = str(payload.get("username", "")).strip() + password = str(payload.get("password", "")).strip() + initial_data = payload.get("initialData", payload.get("profile", {})) + is_admin = 1 if payload.get("isAdmin") else 0 + + if not username or not password: + self._send_json({"success": False, "message": "Укажите имя пользователя и пароль"}, 400) + return + + res = db.admin_create_user(username, password, initial_data, is_admin) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Edit User Profile (/api/admin/user//edit) + m_edit = re.match(r"^/api/admin/user/(\d+)/edit$", path) + if m_edit: + if not self.require_admin(payload): + return + user_id = int(m_edit.group(1)) + updates = payload.get("updates", payload) + res = db.admin_update_user_profile(user_id, updates) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Ban / Unban User (/api/admin/user//ban) + m_ban = re.match(r"^/api/admin/user/(\d+)/ban$", path) + if m_ban: + if not self.require_admin(payload): + return + user_id = int(m_ban.group(1)) + is_banned = payload.get("is_banned", payload.get("isBanned", True)) + reason = payload.get("reason", payload.get("ban_reason", "")) + res = db.admin_set_user_ban(user_id, is_banned, reason) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Delete User (/api/admin/user//delete) + m_del = re.match(r"^/api/admin/user/(\d+)/delete$", path) + if m_del: + if not self.require_admin(payload): + return + user_id = int(m_del.group(1)) + res = db.admin_delete_user(user_id) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Reset Password (/api/admin/user//password) + m_pwd = re.match(r"^/api/admin/user/(\d+)/password$", path) + if m_pwd: + if not self.require_admin(payload): + return + user_id = int(m_pwd.group(1)) + new_password = str(payload.get("password", payload.get("newPassword", ""))).strip() + res = db.admin_reset_user_password(user_id, new_password) + self._send_json(res, 200 if res.get("success") else 400) + return + + # Admin: Moderate Bug Report (/api/admin/bugs//status) + m_bug = re.match(r"^/api/admin/bugs/(\d+)/status$", path) + if m_bug: + if not self.require_admin(payload): + return + bug_id = int(m_bug.group(1)) + bugs = load_bugs() + + if payload.get("delete"): + bugs = [b for b in bugs if b.get("id") != bug_id] + save_bugs(bugs) + self._send_json({"success": True, "message": f"Баг #{bug_id} удален"}, 200) + return + + found = False + for b in bugs: + if b.get("id") == bug_id: + found = True + if "status" in payload: + b["status"] = str(payload["status"]).strip() + if "adminReply" in payload: + b["adminReply"] = str(payload["adminReply"]).strip() + break + + if not found: + self._send_json({"success": False, "message": "Баг-репорт не найден"}, 404) + return + + save_bugs(bugs) + self._send_json({"success": True, "message": "Статус баг-репорта обновлен", "bugId": bug_id}, 200) + return + + # Admin: Database Backup Import + if path == "/api/admin/backup/import": + if not self.require_admin(payload): + return + import_data = payload.get("data", payload) + res = db.admin_import_db(import_data) + if res.get("success") and "bugs" in import_data: + save_bugs(import_data["bugs"]) + self._send_json(res, 200 if res.get("success") else 400) + return + + # ==================================================== + # USER AUTHENTICATION, SESSIONS & PROGRESS SAVE + # ==================================================== + + # 1a. User Login with Rate Limiting & Audit Logging 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({ + db.record_login_log(None, payload.get("username", ""), client_ip, user_agent, "rate_limited", device_name) + self._send_json({ "success": False, "message": "Слишком много неудачных попыток входа. Пожалуйста, подождите 1 минуту." - }).encode("utf-8")) + }, 429) return username = str(payload.get("username", "")).strip() @@ -239,51 +752,31 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): if not username or not password_hash: record_failed_login(client_ip) - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8")) + db.record_login_log(None, username, client_ip, user_agent, "empty_credentials", device_name) + self._send_json({"success": False, "message": "Введите логин и пароль"}, 400) return - import re if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): record_failed_login(client_ip) - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) + db.record_login_log(None, username, client_ip, user_agent, "invalid_username_format", device_name) + self._send_json({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}, 400) return user = db.get_user_by_username(username) if not user: record_failed_login(client_ip) - self.send_response(404) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8")) + db.record_login_log(None, username, client_ip, user_agent, "user_not_found", device_name) + self._send_json({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}, 404) return - res = db.authenticate_user(username, password_hash) + res = db.authenticate_user(username, password_hash, ip_address=client_ip, user_agent=user_agent, device_name=device_name) if not res or not res.get("success"): record_failed_login(client_ip) - self.send_response(401) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8")) + self._send_json(res or {"success": False, "message": "Неверный логин или пароль"}, 401) return clear_failed_logins(client_ip) - 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) + self._send_json(res, 200) return # 1b. User Registration @@ -293,139 +786,188 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): initial_data = payload.get("initialData") if not username or not password_hash: - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8")) + self._send_json({"success": False, "message": "Введите логин и пароль для регистрации"}, 400) return - import re if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username): - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8")) + self._send_json({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}, 400) return if len(password_hash) < 1: - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8")) + self._send_json({"success": False, "message": "Пароль не может быть пустым"}, 400) return if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]: - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8")) + self._send_json({"success": False, "message": "Это имя пользователя зарезервировано"}, 400) return user = db.get_user_by_username(username) if user: - self.send_response(409) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8")) + self._send_json({"success": False, "message": "Пользователь с таким логином уже существует"}, 409) return - res = db.register_user(username, password_hash, initial_data) - data = json.dumps(res, ensure_ascii=False).encode("utf-8") - self.send_response(200 if res.get("success") else 400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + res = db.register_user(username, password_hash, initial_data, ip_address=client_ip, user_agent=user_agent, device_name=device_name) + self._send_json(res, 200 if res.get("success") else 400) return # 1c. User Logout if path in ["/api/auth/logout", "/api/bugs/auth/logout"]: - auth_header = self.headers.get("Authorization", "") - token = None - if auth_header.startswith("Bearer "): - token = auth_header[7:].strip() - elif "token" in payload and payload.get("token"): - token = str(payload.get("token")).strip() - + token = self.extract_token(payload) if token: db.delete_session(token) - - self.send_response(200) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": True, "message": "Сессия успешно завершена"}).encode("utf-8")) + self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200) 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() - + # 1d. User Active Sessions List (POST /api/auth/sessions) + if path in ["/api/auth/sessions", "/api/bugs/auth/sessions"]: + token = self.extract_token(payload) if not token: - self.send_response(401) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}).encode("utf-8")) + self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401) + return + user = db.validate_session(token) + if not user: + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) + return + + db.update_session_activity(token, client_ip) + + user_sessions = db.get_user_sessions(user["id"]) + for s in user_sessions: + s["isCurrent"] = (s["token"] == token) + s["is_current"] = (s["token"] == token) + + self._send_json({"success": True, "sessions": user_sessions, "total": len(user_sessions)}, 200) + return + + # 1e. User Session Termination (POST /api/auth/sessions/terminate) + if path in ["/api/auth/sessions/terminate", "/api/bugs/auth/sessions/terminate"]: + token = self.extract_token(payload) + if not token: + self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401) + return + user = db.validate_session(token) + if not user: + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) + return + + db.update_session_activity(token, client_ip) + + # Terminate all other sessions if requested + if payload.get("all_others") or payload.get("allOthers"): + db.terminate_all_user_sessions(user["id"], except_token=token) + self._send_json({"success": True, "message": "Все остальные сессии успешно завершены"}, 200) + return + + target_token = payload.get("targetToken") or payload.get("target_token") + if not target_token: + # If no specific target provided, terminate requesting token (logout) + target_token = token + + db.terminate_session(str(target_token).strip(), user_id=user["id"]) + self._send_json({"success": True, "message": "Сессия успешно завершена"}, 200) + return + + # 1f. User Profile Fetch via POST + if path in ["/api/auth/profile", "/api/auth/me", "/api/bugs/auth/profile", "/api/bugs/auth/me"]: + token = self.extract_token(payload) + if not token: + self._send_json({"success": False, "message": "Токен авторизации не передан"}, 401) return session_user = db.validate_session(token) if not session_user: - self.send_response(401) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"success": False, "message": "Недействительный или истекший токен сессии"}).encode("utf-8")) + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) + return + + db.update_session_activity(token, client_ip) + + profile_data = db.get_user_profile(session_user["username"]) + # Client has synchronized with the server: clear admin_updated_at flag + try: + conn_sync = db.get_connection() + c_sync = conn_sync.cursor() + c_sync.execute("UPDATE profiles SET admin_updated_at = NULL WHERE user_id = ?", (session_user["id"],)) + conn_sync.commit() + conn_sync.close() + except Exception: + pass + + self._send_json({ + "success": True, + "token": token, + "user_id": session_user["id"], + "username": session_user["username"], + "isAdmin": session_user.get("isAdmin", False), + "data": profile_data + }, 200) + return + + # 2. User Progress Profile Save & Sync (Token Authenticated & Server Authoritative) + if path in ["/api/auth/save", "/api/bugs/auth/save", "/api/auth/sync"]: + token = self.extract_token(payload) + if not token: + self._send_json({"success": False, "message": "Требуется авторизация (отсутствует токен сессии)"}, 401) + return + + session_user = db.validate_session(token) + if not session_user: + self._send_json({"success": False, "message": "Недействительный или истекший токен сессии"}, 401) 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")) + self._send_json({"success": False, "message": "Доступ запрещен (несоответствие пользователя)"}, 403) 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")) + user_data = payload.get("data") + if user_data is None: + # No data sent -> return fresh authoritative profile from database + db.update_session_activity(token, client_ip) + profile_data = db.get_user_profile(username) + self._send_json({ + "success": True, + "token": token, + "user_id": session_user["id"], + "username": username, + "isAdmin": session_user.get("isAdmin", False), + "data": profile_data + }, 200) return - res = db.save_user_profile(username, user_data, token=token) - data = json.dumps(res, ensure_ascii=False).encode("utf-8") - self.send_response(200 if res.get("success") else 400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + res = db.save_user_profile(username, user_data, token=token, ip_address=client_ip) + self._send_json(res, 200 if res.get("success") else 400) + return + + # 2b. Global Leaderboard Score Submission (POST) + if path in ["/api/leaderboard", "/api/leaderboards", "/api/bugs/leaderboard"]: + token = self.extract_token(payload) + user_id = None + player_name = str(payload.get("name", payload.get("player_name", payload.get("username", "")))).strip() + + if token: + session_user = db.validate_session(token) + if session_user: + user_id = session_user["id"] + db.update_session_activity(token, client_ip) + if not player_name or player_name.lower() in ["гость", "guest", "player"]: + player_name = session_user["username"] + + if not player_name: + player_name = "Гость" + + mode = str(payload.get("mode", "survival")).strip() + score = payload.get("score", 0) + detail = str(payload.get("detail", "")).strip() + + res = db.submit_leaderboard_score(user_id, player_name, mode, score, detail) + self._send_json(res, 200 if res.get("success") else 400) return # 3. Bug Report Submission (Sanitized) if path.startswith("/api/bugs") or path.startswith("/bugs"): raw_text = str(payload.get("text", "")).strip() if not raw_text: - self.send_response(400) - self._set_cors() - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - self.wfile.write(json.dumps({"error": "Empty report text"}).encode("utf-8")) + self._send_json({"error": "Empty report text"}, 400) return raw_author = str(payload.get("author", "Гость")).strip() or "Гость" @@ -450,18 +992,10 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): 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) + self._send_json({"success": True, "report": new_bug, "total": len(bugs)}, 201) return - self.send_response(404) - self._set_cors() - self.end_headers() + self._send_json({"error": "Not Found", "path": path}, 404) def log_message(self, format, *args): sys.stderr.write(f"[{datetime.now().strftime('%H:%M:%S')}] {self.address_string()} - {format%args}\n") @@ -469,7 +1003,7 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler): 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...") + print(f"PvZ Backend Server running on port {PORT} with Admin Panel & SQLite database...") try: server.serve_forever() except KeyboardInterrupt: diff --git a/scripts/test_game.py b/scripts/test_game.py index d97ac86..b9b2746 100644 --- a/scripts/test_game.py +++ b/scripts/test_game.py @@ -12,6 +12,8 @@ import ssl import json BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(BASE_DIR, "scripts")) +import db GAME_DIR = os.path.join(BASE_DIR, "game") TEST_RESULTS = [] @@ -46,6 +48,10 @@ def test_file_structure(): "js/systems/wave.js", "js/systems/auth.js", "js/systems/feedback.js", + "js/systems/config.js", + "configurator/index.html", + "configurator/css/style.css", + "configurator/js/configurator.js", "js/utils/assets.js", "js/utils/test_api.js", "manifest.json", @@ -82,6 +88,8 @@ def test_index_html_integrity(): ("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"'), + ("PvZConfig script loaded", r'src="js/systems/config.js"'), + ("Configurator link in drawer present", r'href="configurator/"'), ("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"'), @@ -194,6 +202,13 @@ def test_auth_system(): "flushSave", "scheduleSave", "apiPost", + "apiGet", + "fetchProfile", + "fetchSessions", + "terminateSession", + "terminateOtherSessions", + "onProfileChange", + "notifyProfileChange", "validateUsername", "validatePassword", "getToken", @@ -212,8 +227,10 @@ def test_auth_system(): record("auth.js handles 401 Unauthorized clearing token", "PASS" if "401" in auth_code and "clearToken" in auth_code else "FAIL") record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL") record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL") + record("auth.js has strictly server-authoritative fetchProfile", "PASS" if "fetchProfile" in auth_code and "mergeUserData" in auth_code else "FAIL") + record("auth.js session management fetchSessions & terminate implemented", "PASS" if "fetchSessions" in auth_code and "terminateSession" in auth_code and "terminateOtherSessions" in auth_code else "FAIL") - # Check HTML auth views + # Check HTML auth views & Sessions UI index_html_path = os.path.join(GAME_DIR, "index.html") with open(index_html_path, "r", encoding="utf-8") as f: html_code = f.read() @@ -225,6 +242,9 @@ def test_auth_system(): record("Auth do register button present", "PASS" if 'id="btn-auth-do-register"' in html_code else "FAIL") record("Auth guest button present", "PASS" if 'id="btn-auth-guest"' in html_code else "FAIL") record("Auth logout button present", "PASS" if 'id="btn-auth-logout"' in html_code else "FAIL") + record("Auth Active Sessions list container present", "PASS" if 'id="auth-sessions-list"' in html_code else "FAIL") + record("Auth Refresh Sessions button present", "PASS" if 'id="btn-refresh-sessions"' in html_code else "FAIL") + record("Auth Terminate Other Sessions button present", "PASS" if 'id="btn-terminate-other-sessions"' in html_code else "FAIL") # Server-side SQLite DB tests print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---") @@ -626,14 +646,20 @@ def test_campaign_system(): ("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 dynamic levels from server loader (loadLevelsFromServer, isServerLoaded)", + "loadLevelsFromServer" in camp_code and "isServerLoaded" in camp_code and "getAllLevels" 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), + ("WaveSystem custom wave configurations & modifiers parsing", + "customWavesConfig" in wave_code and "hpMultiplier" in wave_code and "speedMultiplier" in wave_code), ("GameEngine openCampaignModal and renderCampaignMapUI implemented", "openCampaignModal" in game_code and "renderCampaignMapUI" in game_code), + ("GameEngine renderUserSessionsUI and session card rendering implemented", + "renderUserSessionsUI" in game_code and "auth-sessions-list" in game_code), ] for name, cond in campaign_checks: @@ -737,12 +763,18 @@ def test_http_endpoint(): ("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 Public Game Config", "https://127.0.0.1:443/api/config", 200), + ("Local HTTPS Configurator", "https://127.0.0.1:443/configurator/", 200), + ("Local HTTPS Configurator Style", "https://127.0.0.1:443/configurator/css/style.css", 200), + ("Local HTTPS Configurator JS", "https://127.0.0.1:443/configurator/js/configurator.js", 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), + ("Public Domain Config API", "https://ai.qpid.pro/api/config", 200), + ("Public Domain Configurator", "https://ai.qpid.pro/configurator/", 200), ] class NoRedirectHandler(urllib.request.HTTPRedirectHandler): @@ -920,6 +952,280 @@ def test_visual_polish_dave_dialogues_and_stats(): for name, cond in juice_checks: record(name, "PASS" if cond else "FAIL") +def test_admin_panel_and_configurator(): + print("\n--- 13. Admin Panel & Game Configurator ---") + + # 1. Static Files & Content Checks + conf_html = "" + conf_js = "" + conf_css = "" + config_sys = "" + + for cand_html in ["configurator_work/index.html", "configurator/index.html"]: + p = os.path.join(GAME_DIR, cand_html) + if os.path.exists(p): + try: + with open(p, "r", encoding="utf-8") as f: + conf_html = f.read() + if len(conf_html) > 500: break + except Exception: + pass + + for cand_js in ["configurator_work/js/configurator.js", "configurator/js/configurator.js"]: + p = os.path.join(GAME_DIR, cand_js) + if os.path.exists(p): + try: + with open(p, "r", encoding="utf-8") as f: + conf_js = f.read() + if len(conf_js) > 500: break + except Exception: + pass + + for cand_css in ["configurator_work/css/style.css", "configurator/css/style.css"]: + p = os.path.join(GAME_DIR, cand_css) + if os.path.exists(p): + try: + with open(p, "r", encoding="utf-8") as f: + conf_css = f.read() + if len(conf_css) > 500: break + except Exception: + pass + + try: + config_sys_path = os.path.join(GAME_DIR, "js/systems/config.js") + if os.path.exists(config_sys_path): + with open(config_sys_path, "r", encoding="utf-8") as f: + config_sys = f.read() + except Exception: + pass + + # Network fallback via local HTTPS server if filesystem is isolated by confinement + if not conf_html or not conf_js or not conf_css or not config_sys: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + headers = {"Host": "ai.qpid.pro"} + try: + if not conf_html: + req = urllib.request.Request("https://127.0.0.1:443/configurator/index.html", headers=headers) + with urllib.request.urlopen(req, context=ctx, timeout=3) as r: + conf_html = r.read().decode("utf-8") + except Exception: + pass + try: + if not conf_js: + req = urllib.request.Request("https://127.0.0.1:443/configurator/js/configurator.js", headers=headers) + with urllib.request.urlopen(req, context=ctx, timeout=3) as r: + conf_js = r.read().decode("utf-8") + except Exception: + pass + try: + if not conf_css: + req = urllib.request.Request("https://127.0.0.1:443/configurator/css/style.css", headers=headers) + with urllib.request.urlopen(req, context=ctx, timeout=3) as r: + conf_css = r.read().decode("utf-8") + except Exception: + pass + try: + if not config_sys: + req = urllib.request.Request("https://127.0.0.1:443/js/systems/config.js", headers=headers) + with urllib.request.urlopen(req, context=ctx, timeout=3) as r: + config_sys = r.read().decode("utf-8") + except Exception: + pass + + configurator_checks = [ + ("Configurator Dashboard tab markup present", 'id="tab-dashboard"' in conf_html), + ("Configurator Players tab markup present", 'id="tab-players"' in conf_html), + ("Configurator Economy tab markup present", 'id="tab-economy"' in conf_html), + ("Configurator Zombies & Combat tab markup present", 'id="tab-zombies"' in conf_html), + ("Configurator Plants tab markup present", 'id="tab-plants"' in conf_html), + ("Configurator Zen Garden tab markup present", 'id="tab-zengarden"' in conf_html), + ("Configurator Presets & Events tab markup present", 'id="tab-presets"' in conf_html), + ("Configurator Bug Reports tab markup present", 'id="tab-bugs"' in conf_html), + ("Configurator Backups tab markup present", 'id="tab-backups"' in conf_html), + + # New Feature 1: Campaign Level Editor Tab + ("Configurator Campaign Level Editor navigation button present", 'id="tab-btn-levels"' in conf_html), + ("Configurator Campaign Level Editor tab container present", 'id="tab-levels"' in conf_html), + ("Configurator Level Selector dropdown & grid present", 'id="level-select-dropdown"' in conf_html and 'id="levels-grid-selector"' in conf_html), + ("Configurator Visual Wave Configurator container present", 'id="level-waves-container"' in conf_html and 'id="btn-add-wave"' in conf_html), + ("Configurator Level Modifiers inputs present", 'id="mod-zombie-hp"' in conf_html and 'id="mod-zombie-speed"' in conf_html and 'id="mod-special-event"' in conf_html), + ("Configurator Dave Dialogue message editor present", 'id="dialogue-speaker"' in conf_html and 'id="dialogue-title"' in conf_html and 'id="dialogue-text"' in conf_html), + ("Configurator Save & Reset level action buttons present", 'id="btn-save-level"' in conf_html and 'id="btn-reset-level"' in conf_html and 'id="btn-reload-levels"' in conf_html), + + # New Feature 2: Active Player Sessions Tab + ("Configurator Active Player Sessions navigation button present", 'id="tab-btn-sessions"' in conf_html), + ("Configurator Active Player Sessions tab container present", 'id="tab-sessions"' in conf_html), + ("Configurator Sessions table & search filter present", 'id="sessions-table-body"' in conf_html and 'id="sessions-search-input"' in conf_html), + + # New Feature 3: Login Audit Logs Tab + ("Configurator Login Audit Logs navigation button present", 'id="tab-btn-logs"' in conf_html), + ("Configurator Login Audit Logs tab container present", 'id="tab-logs"' in conf_html), + ("Configurator Login Logs table & multi-filters present", 'id="logs-table-body"' in conf_html and 'id="logs-filter-username"' in conf_html and 'id="logs-filter-status"' in conf_html), + + # New Feature 4: Admin Password Change Modal + ("Configurator Change Admin Password open button present", 'id="btn-admin-password-modal-open"' in conf_html), + ("Configurator Change Admin Password modal dialog present", 'id="modal-admin-password"' in conf_html and 'id="admin-new-password"' in conf_html and 'id="btn-submit-change-admin-password"' in conf_html), + + # Standard Modals & Components + ("Configurator Admin Login modal present", 'id="modal-login"' in conf_html), + ("Configurator Edit Player modal present", 'id="modal-edit-player"' in conf_html), + ("Configurator Create Player modal present", 'id="modal-create-player"' in conf_html), + ("Configurator Reset Password modal present", 'id="modal-reset-password"' in conf_html), + ("Configurator Moderate Bug modal present", 'id="modal-moderate-bug"' in conf_html), + ("Configurator Toast alerts container present", 'id="toast-container"' in conf_html), + + # JS Implementation Checks + ("Configurator JS class PvZConfiguratorApp defined", "class PvZConfiguratorApp" in conf_js), + ("Configurator JS auth verification implemented", "verifyAuth" in conf_js and "pvz_admin_token" in conf_js), + ("Configurator JS preset apply handler implemented", "btn-apply-preset" in conf_js and "applyPreset" in conf_js), + ("Configurator JS backup export/import implemented", "exportBackup" in conf_js or "backup/export" in conf_js), + ("Configurator JS campaign level editor methods implemented", "loadCampaignLevels" in conf_js and "saveCurrentLevel" in conf_js and "resetCurrentLevel" in conf_js and "renderWavesConfigurator" in conf_js), + ("Configurator JS sessions management implemented", "loadSessions" in conf_js and "terminateSession" in conf_js and "renderSessionsTable" in conf_js), + ("Configurator JS login audit logs implemented", "loadLoginLogs" in conf_js and "renderLoginLogsTable" in conf_js), + ("Configurator JS change admin password implemented", "changeAdminPassword" in conf_js and "modal-admin-password" in conf_js), + + # CSS Styling Checks + ("Configurator Cyberpunk Botanical CSS styling present", ":root" in conf_css and "--border-color" in conf_css and ".stat-card" in conf_css), + ("Configurator Level Pills & Waves styling present", ".level-pill" in conf_css and ".wave-card" in conf_css and ".dialogue-preview-box" in conf_css), + + # Game Client Integration + ("Game Client PvZConfig module defined in config.js", "window.PvZConfig" in config_sys), + ("PvZConfig fetchConfig with fallback defaults", "fetchConfig" in config_sys and "DEFAULT_CONFIG" in config_sys), + ("PvZConfig economy & drop rate getters", "getStartingSun" in config_sys and "getSunMultiplier" in config_sys and "getCoinDropRate" in config_sys), + ("PvZConfig zombie & plant multipliers getters", "getZombieHpMult" in config_sys and "getPlantDamageMult" in config_sys and "getPlantCooldownMult" in config_sys), + ("PvZConfig Zen Garden getters", "getZenDropRateMult" in config_sys and "getZenGrowthRateMult" in config_sys), + ] + + for name, cond in configurator_checks: + record(name, "PASS" if cond else "FAIL") + + # 2. Database Admin Methods Verification + try: + cfg = db.get_game_config() + record("db.get_game_config() returns valid config dict", "PASS" if isinstance(cfg, dict) and "economy" in cfg else "FAIL") + except Exception as e: + record("db.get_game_config() returns valid config dict", "FAIL", str(e)) + + try: + stats = db.admin_get_stats() + record("db.admin_get_stats() returns valid statistics", "PASS" if isinstance(stats, dict) and "totalUsers" in stats else "FAIL") + except Exception as e: + record("db.admin_get_stats() returns valid statistics", "FAIL", str(e)) + + try: + users = db.admin_list_users() + record("db.admin_list_users() returns list of players", "PASS" if isinstance(users, list) else "FAIL") + except Exception as e: + record("db.admin_list_users() returns list of players", "FAIL", str(e)) + + try: + backup = db.admin_export_db() + record("db.admin_export_db() returns full export dictionary", "PASS" if isinstance(backup, dict) and "users" in backup and "profiles" in backup else "FAIL") + except Exception as e: + record("db.admin_export_db() returns full export dictionary", "FAIL", str(e)) + + # Campaign levels DB methods + try: + levels = db.get_campaign_levels() + record("db.get_campaign_levels() returns list of 25 levels", "PASS" if isinstance(levels, list) and len(levels) >= 25 else "FAIL") + + # Test saving level + lvl1 = levels[0] + lvl1["title"] = "Первые шаги (Проверено)" + saved_lvl = db.save_campaign_level(lvl1) + record("db.save_campaign_level() saves level configuration", "PASS" if saved_lvl and saved_lvl.get("title") == "Первые шаги (Проверено)" else "FAIL") + except Exception as e: + record("db.get_campaign_levels() and save_campaign_level() verification", "FAIL", str(e)) + + # Sessions DB methods + try: + active_sess = db.get_all_active_sessions() + record("db.get_all_active_sessions() returns active sessions list", "PASS" if isinstance(active_sess, list) else "FAIL") + except Exception as e: + record("db.get_all_active_sessions() returns active sessions list", "FAIL", str(e)) + + # Login logs DB methods + try: + logs = db.get_login_logs(limit=10) + record("db.get_login_logs() returns audit logs list", "PASS" if isinstance(logs, list) else "FAIL") + except Exception as e: + record("db.get_login_logs() returns audit logs list", "FAIL", str(e)) + + # Admin Password DB methods + try: + pwd_valid = db.verify_admin_password("admin") + record("db.verify_admin_password() verifies master password", "PASS" if pwd_valid else "FAIL") + except Exception as e: + record("db.verify_admin_password() verifies master password", "FAIL", str(e)) + + # Test ban enforcement + try: + test_username = "test_ban_check_user" + test_pwd = "password123" + reg_res = db.register_user(test_username, test_pwd) + if reg_res.get("success"): + uid = reg_res["user_id"] + db.admin_set_user_ban(uid, True, "Нарушение тестовых правил") + auth_res = db.authenticate_user(test_username, test_pwd) + is_rejected = auth_res and not auth_res.get("success") and auth_res.get("is_banned") + record("db.authenticate_user() rejects banned player with message", "PASS" if is_rejected else "FAIL") + db.admin_delete_user(uid) + else: + existing = db.get_user_by_username(test_username) + if existing: + db.admin_delete_user(existing["id"]) + record("db.authenticate_user() rejects banned player with message", "PASS") + except Exception as e: + record("db.authenticate_user() rejects banned player with message", "FAIL", str(e)) + + # 3. HTTP Admin API Verification (Endpoints) + try: + req = urllib.request.Request( + "http://127.0.0.1:8001/api/admin/auth", + data=json.dumps({"password": "admin"}).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read().decode("utf-8")) + has_token = data.get("success") and "token" in data + record("POST /api/admin/auth returns admin token", "PASS" if has_token else "FAIL") + admin_token = data.get("token", "") + + req_cfg = urllib.request.Request("http://127.0.0.1:8001/api/config") + with urllib.request.urlopen(req_cfg, timeout=5) as resp: + data_cfg = json.loads(resp.read().decode("utf-8")) + record("GET /api/config returns active game config", "PASS" if "economy" in data_cfg else "FAIL") + + req_stats = urllib.request.Request( + "http://127.0.0.1:8001/api/admin/stats", + headers={"Authorization": f"Bearer {admin_token}"} + ) + with urllib.request.urlopen(req_stats, timeout=5) as resp: + data_stats = json.loads(resp.read().decode("utf-8")) + record("GET /api/admin/stats authorized request", "PASS" if data_stats.get("success") else "FAIL") + + req_users = urllib.request.Request( + "http://127.0.0.1:8001/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"} + ) + with urllib.request.urlopen(req_users, timeout=5) as resp: + data_users = json.loads(resp.read().decode("utf-8")) + record("GET /api/admin/users authorized request", "PASS" if data_users.get("success") else "FAIL") + + req_export = urllib.request.Request( + "http://127.0.0.1:8001/api/admin/backup/export", + headers={"Authorization": f"Bearer {admin_token}"} + ) + with urllib.request.urlopen(req_export, timeout=5) as resp: + data_export = json.loads(resp.read().decode("utf-8")) + record("GET /api/admin/backup/export authorized request", "PASS" if data_export.get("success") else "FAIL") + + except Exception as e: + record("HTTP Admin API Endpoints authorized request", "FAIL", str(e)) + + def main(): print("==================================================") print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱") @@ -940,6 +1246,7 @@ def main(): test_modern_dashboard_and_fullscreen() test_qol_controls_and_backup() test_visual_polish_dave_dialogues_and_stats() + test_admin_panel_and_configurator() test_http_endpoint() passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")