К содержимому
Протокол

Протокол

Игра не против, чтобы в неё играли программой. Провод описан целиком — хочешь свой клиент, пиши.

Транспорт

TCP. TLS нет и не будет: вместо него рукопожатие на X25519 с закреплённым публичным ключом сервера и шифрование ChaCha20-Poly1305 по направлениям. Сертификаты и удостоверяющие центры в этой схеме не участвуют.

клиент → сервер   32 байта   эфемерный публичный ключ
сервер → клиент   48 байт    эфемерный публичный ключ + тег подтверждения

Ключи выводятся так:

salt = Ec_pub || Es_pub
ikm  = DH(Ec, Es) || DH(Ec, S_static)
okm  = HKDF-SHA256(salt, ikm, "wif-transport-v1", 64)
k_c2s = okm[0..32]      k_s2c = okm[32..64]

Тег подтверждения — это AEAD(k_s2c, nonce 0, aad = salt, пустое тело). Проверив его, клиент знает, что говорит с сервером, у которого есть закреплённый приватный ключ. Обычный MITM тег не подделает.

Дальше каждый кадр идёт так:

[u32 длина шифротекста][ChaCha20-Poly1305(u16 msg_id || payload)]

Счётчик nonce — 64-битный, свой на каждое направление. Ответы сервера начинают с единицы: нулевой занят тегом подтверждения.

Порты

| порт | назначение | | --- | --- | | 7101 | login-сервер: login.wif.kz | | 7201 | мир: game.wif.kz |

Вход

| id | напр. | сообщение | | --- | --- | --- | | 0x0001 | C→S | LoginRequest: str account, str password | | 0x0002 | S→C | LoginResponse: u8 status, при ok — u64 account_id, тикет, str world_host, u16 world_port | | 0x0010 | C→S | EnterWorld: тикет, str name (сервер игнорирует), u16 proto_version | | 0x0011 | S→C | EnterWorldAck: u8 status, u32 eid, позиция, u8 tick_hz |

Имя берётся из аккаунта по тикету, а не из сообщения: иначе была бы имперсонация. Поле осталось только ради совместимости кадра.

Движение

Позиция от клиента не принимается вообще — сервер считает её сам.

| id | напр. | сообщение | | --- | --- | --- | | 0x0017 | C→S | MoveInput: батч команд ввода, около 20 в секунду | | 0x0019 | S→C | Snapshot3: тик, время мира, номер последней обработанной команды, своё состояние и соседи в зоне интереса |

Клиент присылает намерение, сервер отвечает состоянием и номером последней обработанной команды — по нему клиент сверяет своё предсказание.

Мир и желание

| id | напр. | сообщение | | --- | --- | --- | | 0x0020 | S→C | WorldSpec: сид, версия генератора, версия правил, параметры | | 0x0021 | S→C | RuleSet: правила изменились, мир надо перестроить | | 0x0022 | S→C | WorldPresentation: u64 world_id, u64 revision, 42 байта WINT | | 0x0023 | S→C | WorldTransitionFrame: текущий переход A→B с прогрессом и токеном ограждения |

WINT v1 — ровно 42 байта

bytes[4] magic = "WINT"
u16      version = 1
i32      mountain_amplitude_mm
i32      ocean_surface_level_mm
i32      ocean_depth_mm
u32      ocean_color_red_ppm
u32      ocean_color_green_ppm
u32      ocean_color_blue_ppm
u32      surface_gravity_um_per_s2
u32      day_length_ms
u8       star_count
u8       moon_count
u8       viewpoint     // 1 = планета, 2 = луна газового гиганта
u8       flags         // bit0 = кольца; остальные биты обязаны быть нулём

Декодер принимает только версию 1 и ровно 42 байта. Диапазоны: горы 0..400000 мм, уровень океана −100000..100000 мм, глубина 0..300000 мм, каждый канал цвета 0..1000000 ppm, гравитация 500000..30000000 мкм/с², сутки 10000..86400000 мс, звёзды 1..3, луны 0..12.

Кольца допустимы только с точки обзора «луна газового гиганта», и такой точке нужна хотя бы одна луна. Компилятор на главной показывает это на живом примере.

Числа

Все поля little-endian. Целые — потому что детерминизм важнее удобства: одинаковые входные данные обязаны давать одинаковый мир на C++, Swift и Python. Поэтому в проводе нет ни одного float там, где решение влияет на мир.