Задачи выполняются строго по порядку. Каждая следующая задача зависит от предыдущей. Перед началом каждой задачи прочитай CLAUDE.md — там полные правила игры и архитектура.
Создай базовую структуру Rust workspace:
/
├── Cargo.toml ← workspace
├── engine/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── server/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml (workspace):
[workspace]
members = ["engine", "server"]
resolver = "2"engine/Cargo.toml:
[package]
name = "cellwar-engine"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"server/Cargo.toml:
[package]
name = "cellwar-server"
version = "0.1.0"
edition = "2021"
[dependencies]
cellwar-engine = { path = "../engine" }
actix-web = "4"
actix-ws = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
dashmap = "5"Убедись что cargo build проходит без ошибок.
В engine/src/lib.rs определи все основные типы. Никакой логики пока — только типы и их реализация Display/Debug/Clone/Serialize/Deserialize.
// Координата на поле
pub struct Position { pub x: u32, pub y: u32 }
// Тип юнита
pub enum UnitType {
Infantry,
Fortification { turns_standing: u8 }, // 1, 2, 3 (на 3 → Factory)
Factory,
}
// Юнит
pub struct Unit {
pub id: u32,
pub owner: u8, // player_id
pub unit_type: UnitType,
pub position: Position,
pub acted: bool, // сходил ли в этом ходу
}
// Действие юнита — всегда вектор
// (0,0) = стоять, (dx,dy) = двигаться/атаковать/производить
// Движок интерпретирует исходя из типа юнита и целевой клетки
pub struct Action {
pub unit_id: u32,
pub dx: i32, // -1, 0, 1
pub dy: i32, // -1, 0, 1
}
// Конфигурация игры
pub struct GameConfig {
pub width: u32,
pub height: u32,
pub player_count: u8,
}
// Клетка поля
pub enum Cell {
Empty,
Unit(u32), // unit_id
}
// Полное состояние игры (только для сервера)
pub struct GameState {
pub config: GameConfig,
pub cells: Vec<Vec<Cell>>, // [y][x]
pub units: HashMap<u32, Unit>,
pub next_unit_id: u32,
pub players: Vec<u8>, // активные игроки в порядке хода
pub current_player_index: usize,
pub round: u32,
pub snapshots: Vec<Snapshot>, // снапшоты после каждого хода игрока
}
// Снапшот для rollback
pub struct Snapshot {
pub player_id: u8,
pub round: u32,
pub cells: Vec<Vec<Cell>>,
pub units: HashMap<u32, Unit>,
}
// Видимое состояние для игрока (с туманом войны)
pub enum TileVisibility {
Visible(Cell), // видно прямо сейчас
LastKnown(Cell), // последнее известное состояние
Fog, // неизвестно
}
pub struct PlayerView {
pub player_id: u8,
pub width: u32,
pub height: u32,
pub tiles: Vec<Vec<TileVisibility>>,
pub my_units: Vec<Unit>,
pub current_player: u8,
pub round: u32,
pub winner: Option<u8>,
}
// Ошибки
pub enum GameError {
InvalidAction(String),
NotYourTurn,
UnitAlreadyActed,
UnitNotFound,
GameOver,
}Убедись что всё компилируется.
В engine/src/lib.rs реализуй следующие функции:
- Создаёт пустое поле
- Расставляет начальные юниты (по одному пехотинцу на игрока)
- Позиции распределяются равномерно по полю (например для 2 игроков — левый верхний и правый нижний углы с отступом 1)
- Возвращает начальное состояние
- Возвращает все допустимые действия для юнита в виде векторов
(dx, dy) - Пехотинец:
(0,0)+ все(dx,dy)ведущие на пустые/вражеские соседние клетки - Укрепление: те же что у пехотинца (движок сам применит правила)
- Фабрика: все
(dx,dy)ведущие на свободные соседние клетки (если нет — пустой список) - Учитывает границы поля, клетки своих юнитов исключаются
- Вычисляет какие клетки видит игрок
- Пехотинец: радиус 1, Укрепление: радиус 2, Фабрика: радиус 1
- Возвращает PlayerView с туманом войны
Убедись что все функции компилируются и покрыты unit-тестами:
#[cfg(test)]
mod tests {
#[test]
fn test_new_game_places_units() { ... }
#[test]
fn test_valid_actions_infantry() { ... }
#[test]
fn test_fog_of_war() { ... }
}Реализуй функцию:
apply_action(state: &mut GameState, player_id: u8, unit_id: u32, action: Action) -> Result<(), GameError>
Обрабатывай все случаи согласно правилам в CLAUDE.md. Вектор (dx, dy) интерпретируется так:
(0, 0) для Пехотинца:
- Превращается в Укрепление с turns_standing = 1
(dx, dy) для Пехотинца:
- Целевая клетка пуста → перемещение
- Целевая клетка содержит врага-пехотинца → враг уничтожен, пехотинец занимает клетку
- Целевая клетка содержит врага-укрепление → атакующий уничтожен, укрепление → пехотинец
- Целевая клетка содержит врага-фабрику → фабрика уничтожена, пехотинец занимает клетку
- Целевая клетка содержит своего → GameError::InvalidAction
(0, 0) для Укрепления:
- turns_standing < 3 → turns_standing += 1
- turns_standing == 3 → превращается в Фабрику
(dx, dy) для Укрепления:
- Укрепление сначала становится Пехотинцем, затем применяются правила пехотинца выше
(dx, dy) для Фабрики:
- Целевая клетка свободна → создаёт нового Пехотинца с acted = true
(0, 0)→ GameError::InvalidAction (фабрика не может стоять)
После каждого действия: unit.acted = true
- Проверяет что все юниты игрока сходили (acted == true)
- Убивает фабрики у которых нет свободных соседних клеток
- Делает снапшот текущего состояния
- Переходит к следующему игроку
- Сбрасывает acted = false для юнитов следующего игрока
- Проверяет победителя
- Возвращает player_id если у него остались юниты а у всех остальных нет
Покрой тестами все боевые случаи:
#[test]
fn test_infantry_attacks_infantry() { ... }
#[test]
fn test_infantry_attacks_fortification_first_hit() { ... }
#[test]
fn test_two_infantry_destroy_fortification() { ... }
#[test]
fn test_fortification_becomes_factory() { ... }
#[test]
fn test_factory_produces_unit() { ... }
#[test]
fn test_factory_dies_no_space() { ... }Реализуй:
- Удаляет все юниты игрока с поля
- Удаляет игрока из списка активных игроков
- Если текущий ход был этого игрока — переходит к следующему
- Восстанавливает состояние из снапшота
- Сохраняет список активных игроков (не восстанавливает выбывших)
Тесты:
#[test]
fn test_remove_player_during_turn() { ... }
#[test]
fn test_rollback_restores_state() { ... }
#[test]
fn test_winner_after_disconnect() { ... }В server/src/main.rs создай Actix-web сервер:
Структуры данных сервера:
// Активная игровая сессия
struct GameSession {
state: Arc<Mutex<GameState>>,
players: HashMap<u8, PlayerConnection>,
}
struct PlayerConnection {
token: String,
tx: Option<mpsc::Sender<ServerMessage>>, // WebSocket канал
connected: bool,
disconnect_timer: Option<JoinHandle<()>>,
}
// Сообщения сервера → клиенту
enum ServerMessage {
StateUpdate(PlayerView),
YourTurn,
GameOver { winner: u8 },
PlayerDisconnected { player_id: u8 },
Rollback,
}Маршруты:
POST /game/create
POST /game/{id}/join
GET /game/{id}/state
POST /game/{id}/action
POST /game/{id}/end_turn
WS /game/{id}/ws
Реализуй POST /game/create и POST /game/{id}/join полностью.
Остальные маршруты — заглушки возвращающие 501 Not Implemented.
Реализуй оставшиеся маршруты:
- Требует заголовок
X-Player-Token - Возвращает
PlayerViewдля этого игрока (с туманом войны)
{
"unit_id": 1,
"action": { "type": "Move", "direction": "N" }
}- Проверяет токен
- Проверяет что сейчас ход этого игрока
- Применяет действие через движок
- Рассылает обновление всем игрокам через WebSocket
- Завершает ход игрока
- Уведомляет следующего игрока через WebSocket
- Принимает WebSocket соединение
- При подключении: шлёт текущее состояние
- При отключении: запускает таймер (30 секунд), потом вызывает remove_player + rollback
Создай файл nginx/nginx.conf:
events {}
http {
upstream game_server {
server localhost:8080;
}
server {
listen 80;
# Статика клиента
location / {
root /var/www/client;
index index.html;
try_files $uri $uri/ /index.html;
}
# API сервера
location /api/ {
proxy_pass http://game_server/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# WebSocket
location /ws/ {
proxy_pass http://game_server/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}
}Создай nginx/Dockerfile:
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.confСоздай client/index.html — одностраничное приложение на Vanilla JS.
Экраны:
- Лобби: поле ввода размера карты, кнопка "Создать игру", поле ввода game_id + кнопка "Подключиться"
- Игра: сетка поля, панель состояния, кнопка "Завершить ход"
Иконки юнитов (SVG встроенные):
- Пехотинец → ⚔️ меч
- Укрепление → 🛡️ щит с цифрой счётчика (1, 2) внутри
- Фабрика → ⚙️ шестерёнка
Цвет иконки = цвет игрока (игрок 1 = синий, игрок 2 = красный, игрок 3 = зелёный и т.д.)
Состояния юнитов:
- Обычный → полная яркость, кликабелен
- Уже сходил (acted = true) → иконка тусклая (opacity: 0.35), некликабелен
- Фабрика пока недоступна (боевые ещё не все сходили) → тусклая, некликабелна
- Выбран → подсветка клетки (яркая рамка)
Цвета клеток:
- Туман войны → тёмно-серый (#2a2a2a)
- Пустая видимая → светло-серый (#e0e0e0)
- Последнее известное состояние → серый (#999), слегка затемнён
Выбор юнита (мышь и клавиатура единая модель):
- Левый клик на своего юнита (не acted, доступен) → выбрать
- Tab → переключиться на следующий доступный юнит (пропускать acted и недоступные фабрики)
- Shift+Tab → переключиться на предыдущий
- Клик на пустое место → снять выбор
Установка вектора хода (после выбора юнита):
Мышь:
- Первый правый клик на соседнюю клетку → установить вектор, показать превью
- Правый клик на другую клетку → сменить вектор, превью обновляется
- Правый клик на ту же клетку (вектор уже установлен) → подтвердить ход (как Enter)
- Правый клик на клетку самого юнита → вектор (0,0) "стоять" + превью. Повторный правый клик → подтвердить. Только для боевых юнитов.
Клавиатура:
- Первое нажатие стрелки → вектор появляется в том направлении. Недопустимые направления (край карты, свой юнит) игнорируются.
- Последующие стрелки → перемещают вектор по сетке 3x3. Недопустимые клетки пропускаются.
- Для боевого юнита: стрелки могут вернуть вектор на (0,0) → "стоять".
- Для фабрики: вернуть вектор на (0,0) после первого нажатия нельзя.
- Enter → подтвердить ход по текущему вектору.
Превью хода (показывается сразу при установке вектора, до подтверждения):
Атака пехотинцем или укреплением:
- Цель — пехотинец врага → череп 💀 на клетке цели
- Цель — укрепление врага, первый удар → иконка цели меняется на меч (станет пехотинцем) + череп 💀 на атакующем
- Цель — укрепление врага, второй удар (уже пехотинец после первого удара) → череп 💀 на цели
- Цель — фабрика врага → череп 💀 на цели
Фабрика производит:
- Если производство заблокирует последнюю свободную клетку своей другой фабрики → череп 💀 на той фабрике
- Только для своих фабрик (чужие не показываем)
Умирающая фабрика (уже сейчас нет свободных клеток):
- Иконка заменяется на череп 💀 прямо на поле
- Недоступна для выбора
Визуализация вектора:
- Стрелка от центра клетки юнита до целевой клетки
- Целевая клетка подсвечивается рамкой
- При (0,0) → точка на юните, рамка вокруг его клетки
WebSocket:
- При получении StateUpdate → перерисовать поле полностью
- При получении YourTurn → показать уведомление "Ваш ход"
- При получении GameOver → показать баннер с победителем
- При получении Rollback → показать уведомление "Игрок вышел, ход пересчитан"
- При получении PlayerDisconnected → показать уведомление
Создай ai_client/random_bot.py — простейший AI который делает случайные допустимые ходы.
import requests
import random
import time
SERVER = "http://localhost:8080"
def join_game(game_id):
r = requests.post(f"{SERVER}/game/{game_id}/join")
return r.json()["player_token"], r.json()["player_id"]
def get_state(game_id, token):
r = requests.get(f"{SERVER}/game/{game_id}/state",
headers={"X-Player-Token": token})
return r.json()
def send_action(game_id, token, unit_id, dx, dy):
r = requests.post(f"{SERVER}/game/{game_id}/action",
headers={"X-Player-Token": token},
json={"unit_id": unit_id, "dx": dx, "dy": dy})
return r.json()
def end_turn(game_id, token):
requests.post(f"{SERVER}/game/{game_id}/end_turn",
headers={"X-Player-Token": token})
def play(game_id):
token, player_id = join_game(game_id)
print(f"Joined as player {player_id}")
while True:
state = get_state(game_id, token)
if state.get("winner"):
print(f"Game over. Winner: {state['winner']}")
break
if state["current_player"] != player_id:
time.sleep(0.5)
continue
# Ходим каждым своим юнитом
for unit in state["my_units"]:
if not unit["acted"]:
actions = unit["valid_actions"] # список {"dx": .., "dy": ..}
if actions:
action = random.choice(actions)
send_action(game_id, token, unit["id"], action["dx"], action["dy"])
end_turn(game_id, token)
if __name__ == "__main__":
import sys
play(sys.argv[1])Создай docker-compose.yml для запуска всего стека:
version: '3.8'
services:
server:
build:
context: .
dockerfile: server/Dockerfile
ports:
- "8080:8080"
nginx:
build: ./nginx
ports:
- "80:80"
volumes:
- ./client:/var/www/client
depends_on:
- serverСоздай server/Dockerfile:
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN cargo build --release -p cellwar-server
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/cellwar-server /usr/local/bin/
CMD ["cellwar-server"]Создай tests/integration_test.py на Python:
- Тест: создать игру, два бота играют до победы
- Тест: один бот отключается, второй побеждает
- Тест: rollback при отключении во время хода
def test_full_game():
# Создать игру
# Подключить двух random_bot
# Убедиться что игра завершается победителем
pass
def test_disconnect_wins():
# Подключить двух игроков
# Один отключается
# Убедиться что второй получает победу
passБинарный формат MessagePack. Расширение .cwreplay.
Структура:
struct Replay {
// Метаданные
version: u8, // версия формата
match_id: String, // уникальный id матча
started_at: u64, // unix timestamp начала
ended_at: u64, // unix timestamp конца
winner: u8, // player_id победителя
players: Vec<PlayerMeta>, // имена и id игроков
// Конфигурация
config: GameConfig, // размер поля, кол-во игроков
// Все ходы матча
events: Vec<ReplayEvent>,
}
struct PlayerMeta {
player_id: u8,
name: String, // имя или "Bot" или "Anonymous"
is_ai: bool,
}
struct ReplayEvent {
timestamp: u64, // unix timestamp события
round: u32,
player_id: u8,
event_type: ReplayEventType,
}
enum ReplayEventType {
Action { unit_id: u32, dx: i32, dy: i32 },
EndTurn,
PlayerDisconnected,
Rollback,
}- Сервер пишет события в
ReplayRecorderна протяжении всего матча - По окончании матча (победа или все отключились) — сериализует в MessagePack и сохраняет файл
- Файл именуется:
{match_id}.cwreplay - Добавить endpoint:
GET /game/{id}/replay— скачать файл реплея после завершения матча
Реплей воспроизводится на клиенте — сервер только отдаёт файл. Клиент сам прогоняет события через движок и рендерит результат.
Важно: при воспроизведении реплея карта полностью открыта — туман войны отсутствует, видны все юниты всех игроков одновременно.
Два режима воспроизведения доступны одновременно (переключатель в UI):
Режим 1 — Шаг за шагом:
- Кнопки ← → для перехода между ходами
- Показывается какой игрок ходит, номер раунда
- Подсвечивается юнит который только что сходил
- Можно перейти к любому моменту матча
Режим 2 — Реальное время:
- Воспроизводится с реальными задержками между ходами (из timestamp)
- Кнопка Pause/Resume
- Слайдер скорости: 0.5x, 1x, 2x, 4x
- Прогресс-бар матча, можно кликнуть для перемотки
Вариант А — В игровом клиенте:
- Кнопка "Watch Replay" после окончания матча
- Кнопка "Load Replay" в лобби — загрузить
.cwreplayфайл с диска
Вариант Б — Отдельная страница в браузере:
- Маршрут
/replay/{match_id}— загружает реплей с сервера и воспроизводит - Можно поделиться ссылкой
-
В
engine/добавитьreplay.rs:- Типы
Replay,ReplayEvent,ReplayEventType,PlayerMeta ReplayRecorder— накапливает события во время матчаfn record_action(recorder, round, player_id, unit_id, dx, dy)fn record_end_turn(recorder, round, player_id)fn finalize(recorder, winner, ended_at) -> Replayfn save_replay(replay: &Replay, path: &str) -> Result<(), Error>— сериализация в MessagePackfn load_replay(path: &str) -> Result<Replay, Error>— десериализация
- Типы
-
В
server/подключитьReplayRecorderк каждой сессии, вызыватьfinalizeи сохранять файл по окончании матча. Добавить endpointGET /game/{id}/replay. -
В
client/добавить:- Страницу
/replay/{match_id} - Кнопку "Watch Replay" после конца матча
- Кнопку "Load Replay" в лобби
- Плеер с двумя режимами (шаг за шагом + реальное время)
- Открытая карта без тумана войны
- Подсветка последнего сходившего юнита
- Страницу
Зависимость для MessagePack в engine/Cargo.toml:
rmp-serde = "1"Отдельный бинарник cellwar-cli в /cli. Подключается напрямую к движку без сервера и сети.
=== CellWar CLI ===
Map size (NxM): 10x10
Number of players: 2
Player 1: [H]uman / [P]ython bot? H
Player 2: [H]uman / [P]ython bot? P
Bot command: python3 bots/random_bot.py
Поле рисуется ASCII-символами. Каждая клетка — 2 символа (символ + цифра или пробел):
0 1 2 3 4 5 6 7 8 9
0 . . . . . . . . . .
1 . . . . . . . . . .
2 . 1I . . . . . . . .
3 . . . . . . . . . .
4 . . . . . . . . . .
5 . . . . . . . . . .
6 . . . . . . . . 2F .
7 . . . . . . . . . .
Обозначения:
.— пустая клетка?— туман войны1I— игрок 1, пехотинец (Infantry)1F1— игрок 1, укрепление (Fortification), счётчик 11A— игрок 1, фабрика (Academy)1I*— юнит уже сходил в этом ходу (звёздочка)
Цвета через ANSI escape codes если терминал поддерживает:
- Игрок 1 — синий
- Игрок 2 — красный
- Игрок 3 — зелёный
- Туман — тёмно-серый
=== Round 3 | Player 1's turn ===
Units: 1I@(2,2) 1F2@(3,4) 1A@(5,5)*
Select unit (x,y) or 'q' to quit: 2,2
Unit: Infantry at (2,2)
Valid moves: (1,1) (2,1) (3,1) (1,2) (3,2) (1,3) (2,3) (3,3) [0,0=stay]
Enter move (dx,dy): 0,1
Unit: Fortification[2] at (3,4)
Valid moves: ...
Enter move (dx,dy): 0,0
All combat units done. Factories:
Unit: Factory at (5,5) — already acted this turn.
Turn ended.
Python бот запускается как subprocess. Общение через stdin/stdout в формате JSON:
Сервер → бот (stdin):
{"type": "state", "view": {...}}Бот → сервер (stdout):
{"type": "action", "unit_id": 1, "dx": 0, "dy": 1}После всех юнитов:
{"type": "end_turn"}Бот получает PlayerView (с туманом войны) — честная игра.
[package]
name = "cellwar-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
cellwar-engine = { path = "../engine" }
serde_json = "1"[workspace]
members = ["engine", "server", "cli"]cargo run -p cellwar-cli- → 2. → 3. → 4. → 5. (движок полностью) 14. (консольный клиент — рекомендуется сразу после задачи 5, удобно для отладки движка)
- → 7. (сервер)
- (nginx)
- (клиент)
- (AI клиент)
- (docker)
- (тесты)
- (реплеи)
После каждой задачи: cargo test должен проходить без ошибок.