Протокол
Игра не против, чтобы в неё играли программой. Провод описан целиком — хочешь свой клиент, пиши.
Транспорт
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 там, где решение влияет на мир.