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 — Панель администратора и конфигуратор баланса
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💰
+
+
—
+
Всего монет в обороте
+
+
+
+
+
+
🧟
+
+
—
+
Зомби уничтожено (всего)
+
+
+
+
🌱
+
+
—
+
Посажено растений
+
+
+
+
+
+
+
+
+
+
+
+
+
🌟 Double Gold Weekend
+
Удваивает выпадение монет из зомби и в Саду Дзен.
+
+
+
+
+
+
⚡ Fast Cooldowns
+
Ускоренная перезарядка семян для динамичного боя.
+
+
+
+
+
+
☀️ Super Sun Drop
+
Солнце с неба и подсолнухов падает в 2 раза чаще.
+
+
+
+
+
+
🧟 Turbo Zombie Wave
+
Зомби бегут и атакуют с повышенной скоростью (+30%).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Текущий этап: 1-1
+ ☀️ Солнечный День
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📋 Основная информация об этапе
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧔 Диалог Безумного Дэйва перед боем
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧔
+
+
Безумный Дэйв предупреждает:
+
Эй, сосед! Будь начеку!
+
ДАЛЕЕ / В БОЙ ⚔️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Игрок |
+ IP-адрес |
+ Устройство и браузер |
+ Создана |
+ Последняя активность |
+ Токен |
+ Действия |
+
+
+
+ | Загрузка активных сессий... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Время события |
+ Пользователь |
+ IP-адрес |
+ Устройство и браузер |
+ Статус аутентификации |
+
+
+
+ | Загрузка журнала аудита... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Никнейм |
+ Статус |
+ Уровень |
+ Монеты |
+ Рекорд |
+ Регистрация |
+ Последний вход |
+ Действия |
+
+
+
+ | Загрузка игроков... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Увеличивает количество солнца, даваемое подсолнухами и падающее с неба.
+
+
+
+
+
+
Количество солнца в начале каждого уровня.
+
+
+
+
+
+
Вероятность и частота выпадения серебряных и золотых монет.
+
+
+
+
+
+
Интервал падения небесного солнца на дневных уровнях.
+
+
+
+
+
+
Стоимость покупки закрытых растений в магазине Дэйва.
+
+
+
+
+
+
Расход материалов на прокачку 2 и 3 уровней растений.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Запас прочности всех типов зомби и боссов.
+
+
+
+
+
+
Скорость ходьбы и бега зомби по газону.
+
+
+
+
+
+
Урон, наносимый растениям при поедании.
+
+
+
+
+
+
Количество зомби, появляющихся в каждой волне.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Урон всех типов горошин, спор и снарядов катапульт.
+
+
+
+
+
+
Здоровье Стеноорехов, Высоких орехов и обычных трав.
+
+
+
+
+
+
Меньше — быстрее готовность пакетов семян в слотах.
+
+
+
+
+
+
Меньше — чаще Подсолнухи и Солнцегрибы генерируют солнце.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Частота получения новых горшечных ростков в боях.
+
+
+
+
+
+
Ускорение перехода саженца от ростка до взрослого цветка.
+
+
+
+
+
+
Бонус к производству золотых монет при уходе.
+
+
+
+
+
+
Как часто растениям в саду требуется вода.
+
+
+
+
+
+
+
+
+
+
+
📚 Каталог пресетов баланса
+
+
+
🌻 Classic 2009
+
Классический баланс оригинальной Plants vs. Zombies 2009 года. Полная гармония и аутентичность.
+
+
+
+
+
💀 Hardcore
+
Для ветеранов: зомби толще и быстрее (+60% HP, +30% Speed), меньше солнца и золота (-20%), жесткие волны.
+
+
+
+
+
☕ Casual Chill
+
Расслабленный режим: стартовое солнце 150, быстрый откат семян, ослабленные зомби (-40% HP), щедрые монеты.
+
+
+
+
+
💥 Mayhem Mode
+
Полный хаос: тройной спавн зомби, тройной урон растений, фонтаны солнца и золота!
+
+
+
+
+
⚡ Speedrun 2x
+
Скоростное прохождение: зомби и растения работают на скорости 1.5x, мгновенный откат семян.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Дата |
+ Автор |
+ Категория |
+ Статус |
+ Описание проблемы |
+ Ответ / Решение |
+ Действия |
+
+
+
+ | Загрузка баг-репортов... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📤 Экспорт резервной копии
+
Выгрузка полного слепка SQLite базы данных в файл pvz_backup.json для сохранения на локальный диск.
+
+
+
+
+
📥 Восстановление из файла
+
Восстановление базы данных из ранее сохраненного файла бэкапа JSON.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Введите мастер-пароль администратора для получения полного доступа к редактированию баланса, управлению игроками и бэкапам.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧪 Материалы крафта в Мастерской
+
+
+
🌿 Сад Дзен
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚔️ Боевая статистика (Lifetime)
+
+
+
+
0
+
☀️ Солнца собрано
+
+
+
0
+
🌱 Посажено растений
+
+
+
0
+
🎖️ Этапов пройдено
+
+
+
0
+
💰 Всего заработано
+
+
+
+
🧪 Ресурсы и материалы
+
+
+
🌿 Сад Дзен и Рекорды
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 = `
+
+
+ ${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 — Панель администратора и конфигуратор баланса
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💰
+
+
—
+
Всего монет в обороте
+
+
+
+
+
+
🧟
+
+
—
+
Зомби уничтожено (всего)
+
+
+
+
🌱
+
+
—
+
Посажено растений
+
+
+
+
+
+
+
+
+
+
+
+
+
🌟 Double Gold Weekend
+
Удваивает выпадение монет из зомби и в Саду Дзен.
+
+
+
+
+
+
⚡ Fast Cooldowns
+
Ускоренная перезарядка семян для динамичного боя.
+
+
+
+
+
+
☀️ Super Sun Drop
+
Солнце с неба и подсолнухов падает в 2 раза чаще.
+
+
+
+
+
+
🧟 Turbo Zombie Wave
+
Зомби бегут и атакуют с повышенной скоростью (+30%).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Текущий этап: 1-1
+ ☀️ Солнечный День
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📋 Основная информация об этапе
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧔 Диалог Безумного Дэйва перед боем
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧔
+
+
Безумный Дэйв предупреждает:
+
Эй, сосед! Будь начеку!
+
ДАЛЕЕ / В БОЙ ⚔️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Игрок |
+ IP-адрес |
+ Устройство и браузер |
+ Создана |
+ Последняя активность |
+ Токен |
+ Действия |
+
+
+
+ | Загрузка активных сессий... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Время события |
+ Пользователь |
+ IP-адрес |
+ Устройство и браузер |
+ Статус аутентификации |
+
+
+
+ | Загрузка журнала аудита... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Никнейм |
+ Статус |
+ Уровень |
+ Монеты |
+ Рекорд |
+ Регистрация |
+ Последний вход |
+ Действия |
+
+
+
+ | Загрузка игроков... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Увеличивает количество солнца, даваемое подсолнухами и падающее с неба.
+
+
+
+
+
+
Количество солнца в начале каждого уровня.
+
+
+
+
+
+
Вероятность и частота выпадения серебряных и золотых монет.
+
+
+
+
+
+
Интервал падения небесного солнца на дневных уровнях.
+
+
+
+
+
+
Стоимость покупки закрытых растений в магазине Дэйва.
+
+
+
+
+
+
Расход материалов на прокачку 2 и 3 уровней растений.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Запас прочности всех типов зомби и боссов.
+
+
+
+
+
+
Скорость ходьбы и бега зомби по газону.
+
+
+
+
+
+
Урон, наносимый растениям при поедании.
+
+
+
+
+
+
Количество зомби, появляющихся в каждой волне.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Урон всех типов горошин, спор и снарядов катапульт.
+
+
+
+
+
+
Здоровье Стеноорехов, Высоких орехов и обычных трав.
+
+
+
+
+
+
Меньше — быстрее готовность пакетов семян в слотах.
+
+
+
+
+
+
Меньше — чаще Подсолнухи и Солнцегрибы генерируют солнце.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Частота получения новых горшечных ростков в боях.
+
+
+
+
+
+
Ускорение перехода саженца от ростка до взрослого цветка.
+
+
+
+
+
+
Бонус к производству золотых монет при уходе.
+
+
+
+
+
+
Как часто растениям в саду требуется вода.
+
+
+
+
+
+
+
+
+
+
+
📚 Каталог пресетов баланса
+
+
+
🌻 Classic 2009
+
Классический баланс оригинальной Plants vs. Zombies 2009 года. Полная гармония и аутентичность.
+
+
+
+
+
💀 Hardcore
+
Для ветеранов: зомби толще и быстрее (+60% HP, +30% Speed), меньше солнца и золота (-20%), жесткие волны.
+
+
+
+
+
☕ Casual Chill
+
Расслабленный режим: стартовое солнце 150, быстрый откат семян, ослабленные зомби (-40% HP), щедрые монеты.
+
+
+
+
+
💥 Mayhem Mode
+
Полный хаос: тройной спавн зомби, тройной урон растений, фонтаны солнца и золота!
+
+
+
+
+
⚡ Speedrun 2x
+
Скоростное прохождение: зомби и растения работают на скорости 1.5x, мгновенный откат семян.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Дата |
+ Автор |
+ Категория |
+ Статус |
+ Описание проблемы |
+ Ответ / Решение |
+ Действия |
+
+
+
+ | Загрузка баг-репортов... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📤 Экспорт резервной копии
+
Выгрузка полного слепка SQLite базы данных в файл pvz_backup.json для сохранения на локальный диск.
+
+
+
+
+
📥 Восстановление из файла
+
Восстановление базы данных из ранее сохраненного файла бэкапа JSON.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Введите мастер-пароль администратора для получения полного доступа к редактированию баланса, управлению игроками и бэкапам.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧪 Материалы крафта в Мастерской
+
+
+
🌿 Сад Дзен
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚔️ Боевая статистика (Lifetime)
+
+
+
+
0
+
☀️ Солнца собрано
+
+
+
0
+
🌱 Посажено растений
+
+
+
0
+
🎖️ Этапов пройдено
+
+
+
0
+
💰 Всего заработано
+
+
+
+
🧪 Ресурсы и материалы
+
+
+
🌿 Сад Дзен и Рекорды
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 = `
+
+
+ ${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")