diff --git a/.gitignore b/.gitignore index 4c5f206..c972026 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .claude/ +php/config.php +php/data/ diff --git a/php/.gitignore b/php/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/php/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/php/api.php b/php/api.php new file mode 100644 index 0000000..cc57e74 --- /dev/null +++ b/php/api.php @@ -0,0 +1,119 @@ +prepare(' + SELECT s.move_count, s.source, s.created_at, u.name + FROM solutions s JOIN users u ON u.id = s.user_id + WHERE s.level_name = ? + ORDER BY s.move_count ASC + LIMIT 5 + '); + $stmt->execute([$level]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +switch ($action) { + + case 'whoami': { + $u = current_user($pdo); + echo json_encode($u ? ['name' => $u['name'], 'lastLevel' => (int)$u['last_level']] : ['name' => null, 'lastLevel' => 0]); + break; + } + + case 'save_progress': { + $u = current_user($pdo); + if (!$u) { http_response_code(401); echo json_encode(['error' => 'no user']); break; } + $body = read_json_body(); + $level = (int)($body['level'] ?? -1); + if ($level < 0) { http_response_code(400); echo json_encode(['error' => 'bad level']); break; } + $pdo->prepare('UPDATE users SET last_level = ? WHERE id = ?')->execute([$level, $u['id']]); + echo json_encode(['ok' => true]); + break; + } + + case 'set_name': { + $body = read_json_body(); + $name = trim(mb_substr((string)($body['name'] ?? ''), 0, 40)); + if ($name === '') { http_response_code(400); echo json_encode(['error' => 'empty name']); break; } + $u = current_user($pdo); + if ($u) { + $pdo->prepare('UPDATE users SET name = ? WHERE id = ?')->execute([$name, $u['id']]); + } else { + $token = bin2hex(random_bytes(16)); + $pdo->prepare('INSERT INTO users (token, name, created_at) VALUES (?, ?, ?)') + ->execute([$token, $name, time()]); + setcookie('sokoban_token', $token, time() + 60*60*24*365, '/', '', false, true); + } + echo json_encode(['name' => $name]); + break; + } + + case 'top': { + $level = (string)($_GET['level'] ?? ''); + echo json_encode($level === '' ? [] : top5($pdo, $level)); + break; + } + + case 'best': { + $level = (string)($_GET['level'] ?? ''); + $stmt = $pdo->prepare('SELECT moves FROM solutions WHERE level_name = ? ORDER BY move_count ASC LIMIT 1'); + $stmt->execute([$level]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + echo json_encode($row ? ['moves' => json_decode($row['moves'])] : null); + break; + } + + case 'submit': { + $u = current_user($pdo); + if (!$u) { http_response_code(401); echo json_encode(['error' => 'no user']); break; } + + $body = read_json_body(); + $level = (string)($body['level'] ?? ''); + $moves = $body['moves'] ?? null; + $source = ($body['source'] ?? 'player') === 'solver' ? 'solver' : 'player'; + + if ($level === '' || !is_array($moves) || !count($moves) || count($moves) > 20000) { + http_response_code(400); echo json_encode(['error' => 'bad payload']); break; + } + // каждый ход — пара из {-1,0,1}, ничего больше сюда залететь не должно + foreach ($moves as $m) { + if (!is_array($m) || count($m) !== 2 || !in_array($m[0], [-1,0,1], true) || !in_array($m[1], [-1,0,1], true)) { + http_response_code(400); echo json_encode(['error' => 'bad move']); break 2; + } + } + + $moveKey = md5(implode('|', array_map(function($m) { return $m[0] . ',' . $m[1]; }, $moves))); + try { + $pdo->prepare('INSERT INTO solutions (level_name, user_id, moves, move_count, move_key, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') + ->execute([$level, $u['id'], json_encode($moves), count($moves), $moveKey, $source, time()]); + } catch (PDOException $e) { + // UNIQUE(level_name, move_key) — та же последовательность уже есть, не ошибка + } + + // держим только топ-5 по числу ходов на уровень + $pdo->prepare(' + DELETE FROM solutions WHERE level_name = ? AND id NOT IN ( + SELECT id FROM solutions WHERE level_name = ? ORDER BY move_count ASC LIMIT 5 + ) + ')->execute([$level, $level]); + + echo json_encode(top5($pdo, $level)); + break; + } + + default: + http_response_code(404); + echo json_encode(['error' => 'unknown action']); +} diff --git a/php/db.php b/php/db.php new file mode 100644 index 0000000..eacace2 --- /dev/null +++ b/php/db.php @@ -0,0 +1,51 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $pdo->exec('CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + token VARCHAR(64) UNIQUE NOT NULL, + name VARCHAR(40) NOT NULL, + last_level INT NOT NULL DEFAULT 0, + created_at INT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); + + // move_key хранится как md5-хеш последовательности ходов (фикс. длина + // 32 символа) — а не сырой склеенной строкой, у которой длина растёт + // с числом ходов и может упереться в лимит длины ключа InnoDB-индекса. + $pdo->exec('CREATE TABLE IF NOT EXISTS solutions ( + id INT AUTO_INCREMENT PRIMARY KEY, + level_name VARCHAR(64) NOT NULL, + user_id INT NOT NULL, + moves MEDIUMTEXT NOT NULL, + move_count INT NOT NULL, + move_key CHAR(32) NOT NULL, + source VARCHAR(16) NOT NULL DEFAULT "player", + created_at INT NOT NULL, + UNIQUE KEY uniq_level_movekey (level_name, move_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); + + // MySQL/MariaDB не везде понимают "CREATE INDEX IF NOT EXISTS" — просто + // глотаем "уже есть" при повторных запусках. + try { $pdo->exec('CREATE INDEX idx_level_count ON solutions(level_name, move_count)'); } + catch (PDOException $e) {} + + return $pdo; +} + +// Идентичность — долгий cookie (год) с токеном, не PHP-сессия: имя должно +// помниться неделями, а не до закрытия вкладки. +function current_user(PDO $pdo): ?array { + if (empty($_COOKIE['sokoban_token'])) return null; + $stmt = $pdo->prepare('SELECT * FROM users WHERE token = ?'); + $stmt->execute([$_COOKIE['sokoban_token']]); + $u = $stmt->fetch(PDO::FETCH_ASSOC); + return $u ?: null; +} diff --git a/php/index.php b/php/index.php new file mode 100644 index 0000000..df8d5ca --- /dev/null +++ b/php/index.php @@ -0,0 +1,2178 @@ + + +
+ + +🤖 ИИ ищет решение… Ожидайте.
+