Compare commits

...
14 Commits
Author SHA1 Message Date
cacto a9e4828dd9 Слияние feature/deploy-cp-instead-of-rsync 2026-07-26 23:25:30 +05:00
cacto ddab8de27b Заменить rsync на cp/rm в деплое — rsync нет в образе раннера 2026-07-26 23:25:30 +05:00
cacto b582bb7cf4 Слияние feature/deploy-internal-clone 2026-07-26 23:18:34 +05:00
cacto 1bc94980f8 Клонировать по внутреннему адресу Gitea вместо actions/checkout
actions/checkout тянет свой код с github.com и берёт URL из внутреннего
server_url — оба варианта требуют сети, которой у джоб-контейнера
раннера нет вообще (ни наружу, ни туда). Ручной git clone по
http://gitea:3000/... остаётся внутри docker-сети раннера/Gitea.
2026-07-26 23:18:34 +05:00
cacto 684277ebff Слияние feature/revert-checkout-action 2026-07-26 23:02:51 +05:00
cacto 28f8deff5f Вернуть actions/checkout@v4 — сеть раннера починена 2026-07-26 23:02:51 +05:00
cacto 3fe09de3c9 Слияние feature/fix-checkout-dns 2026-07-26 22:30:46 +05:00
cacto 6b3ba11faf Обойти DNS-проблему в деплое: клонировать по публичному URL
actions/checkout использует внутренний server_url Gitea (http://gitea:3000/...),
который джоб-контейнер раннера не резолвит (не в docker-сети Gitea).
Явный git clone по https://git.cactoz.su/... не зависит от этой внутренней сети.
2026-07-26 22:30:46 +05:00
cacto 85b2f006ae Слияние feature/fix-caddy-network 2026-07-26 22:03:25 +05:00
cacto 971ebdc11f Исправить имя внешней сети Caddy: caddy_net -> proxy 2026-07-26 22:03:25 +05:00
cacto c85300c751 Слияние feature/ci-deploy: автодеплой на cacto-homelab 2026-07-26 21:37:27 +05:00
cacto fb4ccc351f Автодеплой через Gitea Actions на cacto-homelab
.gitea/workflows/deploy.yml: пуш в main -> build+up прямо на runner'е
(docker.sock смонтирован, registry и SSH не нужны).
docker-compose.prod.yml: внешняя сеть caddy_net, container_name cactoz-nginx
для reverse_proxy из Caddy.
gameserver/Dockerfile: зависимости (uSockets/json) вынесены в отдельный
кэшируемый слой — пересборка после правки src/ упала с ~6.5 мин до ~9 сек.
2026-07-26 21:37:21 +05:00
cacto 5649961e81 Слияние feature/lobby-rooms: лобби комнат 2026-07-26 21:22:26 +05:00
cacto 864868de49 Лобби: комнаты на сервере (C++) и UI на клиенте
Сервер: Lobby/Room/Player — create/join/leave/list_rooms, broadcast обновлений,
проверка авторизации (hello) и состояния комнаты (waiting/full/in_progress).
Клиент: полноценный UI лобби (создание, список, вход в комнату, выход),
блокировка кнопок до установки WS-соединения.
nginx: no-cache для JS/CSS (нет хеширования имён файлов).
2026-07-26 21:22:20 +05:00
12 changed files with 603 additions and 43 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# actions/checkout скачивает свой код с github.com и берёт URL из
# внутреннего server_url Gitea — оба варианта требуют сети, которой
# у джоб-контейнера раннера нет. Клонируем напрямую по внутреннему
# адресу Gitea в docker-сети раннера.
- name: Checkout
run: git clone --depth=1 --branch main http://gitea:3000/cacto/cactoz.su.git repo
# Runner уже на cacto-homelab и имеет доступ к docker.sock хоста,
# поэтому образ собирается прямо на месте — без registry и без SSH.
- name: Sync to deploy directory
run: |
rm -rf /data/docker/compose/cactoz
mkdir -p /data/docker/compose/cactoz
cp -a repo/. /data/docker/compose/cactoz/
rm -rf /data/docker/compose/cactoz/.git
- name: Build and restart
working-directory: /data/docker/compose/cactoz
run: |
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
+24
View File
@@ -0,0 +1,24 @@
services:
nginx:
container_name: cactoz-nginx
build:
context: .
dockerfile: nginx/Dockerfile
networks:
- internal
- proxy
depends_on:
- gameserver
restart: unless-stopped
gameserver:
build: ./gameserver
networks:
- internal
restart: unless-stopped
networks:
internal:
# уже создана Caddy-стеком; сюда только подключаемся
proxy:
external: true
+90 -1
View File
@@ -1 +1,90 @@
/* TODO: лобби и игровое поле — этапы 3-6 */ .lobby-panel {
border: var(--border);
background: var(--color-bg-raised);
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
max-width: 640px;
}
.lobby-panel__row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.prompt-inline {
color: var(--color-accent-dim);
}
.lobby-panel input {
background: var(--color-bg);
border: 1px solid var(--color-accent-dim);
color: var(--color-fg);
font-family: var(--font-mono);
padding: 0.3rem 0.5rem;
}
.lobby-panel button {
background: transparent;
border: 1px solid var(--color-accent-dim);
color: var(--color-accent);
font-family: var(--font-mono);
padding: 0.35rem 0.75rem;
cursor: pointer;
}
.lobby-panel button:hover:not(:disabled) {
border-color: var(--color-accent);
box-shadow: 0 0 6px var(--color-accent-dim);
}
.lobby-panel button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.lobby-error {
color: var(--color-silver);
border-left: 2px solid var(--color-accent-dim);
padding-left: 0.6rem;
margin: 0 0 1rem;
}
.room-list,
.room-players {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.room-list__item {
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: var(--border);
padding-bottom: 0.5rem;
}
.room-list__name {
flex: 1;
}
.room-list__count,
.room-list__state {
color: var(--color-silver);
font-size: 0.85rem;
}
.room-list__empty {
color: var(--color-silver);
}
.room-players li::before {
content: '- ';
color: var(--color-accent-dim);
}
+126 -11
View File
@@ -1,21 +1,136 @@
// TODO: полноценный UI лобби (список/создание/присоединение комнат) — этап 3.
// Пока — проверка связки клиент <-> gameserver (hello -> lobby.list_rooms).
import { Network, ServerMessage } from './network.js'; import { Network, ServerMessage } from './network.js';
export function connectAndProbe() { const container = document.getElementById('lobby');
const net = new Network();
net.on('_open', () => net.hello('cacto')); let net;
let rooms = [];
let currentRoom = null;
let errorMessage = '';
let connected = false;
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
function roomRow(room) {
const full = room.players >= room.max_players;
return `
<li class="room-list__item">
<span class="room-list__name">${room.name}</span>
<span class="tag">${room.mode}</span>
<span class="room-list__count">${room.players}/${room.max_players}</span>
<span class="room-list__state">${room.state}</span>
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>войти</button>
</li>`;
}
function errorBanner() {
return errorMessage ? `<p class="lobby-error">! ${errorMessage}</p>` : '';
}
function renderLobbyView() {
const dis = connected ? '' : 'disabled';
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
<div class="lobby-panel__row">
<label class="prompt-inline">nickname</label>
<input id="nickname-input" type="text" value="${nickname}" maxlength="20" ${dis} />
</div>
<div class="lobby-panel__row">
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
<button data-action="create" data-mode="pvp" ${dis}>создать комнату (pvp)</button>
<button data-action="refresh" ${dis}>обновить список</button>
</div>
<ul class="room-list">
${rooms.length === 0 ? '<li class="room-list__empty">пока пусто — создай комнату</li>' : rooms.map(roomRow).join('')}
</ul>
</div>`;
}
function renderRoomView() {
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
<h3 class="prompt">room ${currentRoom.name}</h3>
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
<ul class="room-players">
${currentRoom.players.map((p) => `<li>${p.nickname}</li>`).join('')}
</ul>
<button data-action="leave">выйти в лобби</button>
</div>`;
}
function render() {
if (currentRoom) {
renderRoomView();
} else {
renderLobbyView();
}
}
container?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
errorMessage = '';
switch (btn.dataset.action) {
case 'create':
net.createRoom(btn.dataset.mode);
break;
case 'join':
net.joinRoom(btn.dataset.roomId);
break;
case 'leave':
currentRoom = null;
net.leaveRoom();
render();
break;
case 'refresh':
net.listRooms();
break;
}
});
container?.addEventListener('change', (e) => {
if (e.target.id === 'nickname-input') {
nickname = e.target.value.trim() || nickname;
}
});
export function initLobby() {
net = new Network();
net.on('_open', () => net.hello(nickname));
net.on(ServerMessage.HELLO_ACK, () => { net.on(ServerMessage.HELLO_ACK, () => {
console.log('[gameserver] hello.ack received'); connected = true;
net.listRooms(); net.listRooms();
}); });
net.on(ServerMessage.LOBBY_ROOMS, (payload) => { net.on('_close', () => {
console.log('[gameserver] rooms:', payload.rooms); connected = false;
}); render();
net.on(ServerMessage.ERROR, (payload) => {
console.error('[gameserver] error:', payload);
}); });
net.on(ServerMessage.LOBBY_ROOMS, (payload) => {
rooms = payload.rooms;
render();
});
net.on(ServerMessage.LOBBY_ROOM_JOINED, (payload) => {
currentRoom = payload;
render();
});
net.on(ServerMessage.LOBBY_ROOM_UPDATED, (payload) => {
if (currentRoom) {
currentRoom.players = payload.players;
render();
}
});
net.on(ServerMessage.ERROR, (payload) => {
console.error('[gameserver] error:', payload);
errorMessage = payload.message;
render();
});
render();
return net; return net;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5 // TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
import { connectAndProbe } from './lobby.js'; import { initLobby } from './lobby.js';
connectAndProbe(); initLobby();
+12
View File
@@ -33,6 +33,18 @@ export class Network {
listRooms() { listRooms() {
this.send(ClientMessage.LOBBY_LIST_ROOMS); this.send(ClientMessage.LOBBY_LIST_ROOMS);
} }
createRoom(mode) {
this.send(ClientMessage.LOBBY_CREATE_ROOM, { mode });
}
joinRoom(roomId) {
this.send(ClientMessage.LOBBY_JOIN_ROOM, { room_id: roomId });
}
leaveRoom() {
this.send(ClientMessage.LOBBY_LEAVE_ROOM);
}
} }
export { ServerMessage }; export { ServerMessage };
+1
View File
@@ -39,6 +39,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL)
add_executable(gameserver add_executable(gameserver
src/main.cpp src/main.cpp
src/Lobby.cpp
) )
target_include_directories(gameserver PRIVATE target_include_directories(gameserver PRIVATE
${uwebsockets_SOURCE_DIR}/src ${uwebsockets_SOURCE_DIR}/src
+9 -1
View File
@@ -3,9 +3,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates zlib1g-dev \ build-essential cmake git ca-certificates zlib1g-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Кэшируемый слой: конфигурация + сборка зависимостей (uSockets, json).
# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих
# исходниках — они не компилируются, т.к. просим собрать только usockets.
COPY CMakeLists.txt ./ COPY CMakeLists.txt ./
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target usockets -j"$(nproc)"
# Реальный код поверх уже собранных зависимостей — пересобирается за секунды.
COPY src ./src COPY src ./src
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)" RUN cmake --build build --target gameserver -j"$(nproc)"
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \ RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
+227
View File
@@ -0,0 +1,227 @@
#include "Lobby.hpp"
#include <algorithm>
#include <iomanip>
#include <sstream>
void Lobby::send(WS *ws, const std::string &type, json payload) {
json msg = {{"type", type}, {"payload", std::move(payload)}};
ws->send(msg.dump(), uWS::OpCode::TEXT);
}
void Lobby::sendError(WS *ws, const std::string &code, const std::string &message) {
send(ws, "error", {{"code", code}, {"message", message}});
}
std::string Lobby::generateRoomId() {
std::ostringstream oss;
oss << "cactus-room-" << std::setw(2) << std::setfill('0') << next_room_id_++;
return oss.str();
}
json Lobby::roomsSummary() const {
json rooms = json::array();
for (const auto &[id, room] : rooms_) {
rooms.push_back({
{"id", room.id},
{"name", room.id},
{"mode", room.mode},
{"players", room.players.size()},
{"max_players", kMaxPlayersPerRoom},
{"state", room.state},
});
}
return rooms;
}
json Lobby::roomPlayers(const Room &room) const {
json players = json::array();
for (size_t i = 0; i < room.players.size(); i++) {
players.push_back({
{"id", room.players[i].player_id},
{"nickname", room.players[i].nickname},
{"slot", i},
});
}
return players;
}
void Lobby::broadcastRoomsToLobby() {
json rooms = roomsSummary();
for (WS *client : sockets_) {
if (client->getUserData()->room_id.empty()) {
send(client, "lobby.rooms", {{"rooms", rooms}});
}
}
}
void Lobby::broadcastRoomUpdated(const Room &room, WS *exclude) {
json payload = {{"players", roomPlayers(room)}};
for (const auto &p : room.players) {
if (p.ws != exclude) {
send(p.ws, "lobby.room_updated", payload);
}
}
}
void Lobby::onOpen(WS *ws) {
sockets_.insert(ws);
}
void Lobby::onClose(WS *ws) {
removePlayerFromRoom(ws);
sockets_.erase(ws);
}
void Lobby::onMessage(WS *ws, std::string_view message) {
json request;
std::string type;
try {
request = json::parse(message);
type = request.value("type", "");
} catch (const std::exception &e) {
sendError(ws, "bad_json", e.what());
return;
}
json payload = request.value("payload", json::object());
if (type == "hello") {
handleHello(ws, payload);
} else if (type == "lobby.list_rooms") {
handleListRooms(ws);
} else if (type == "lobby.create_room") {
handleCreateRoom(ws, payload);
} else if (type == "lobby.join_room") {
handleJoinRoom(ws, payload);
} else if (type == "lobby.leave_room") {
handleLeaveRoom(ws);
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
}
void Lobby::handleHello(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->player_id.empty()) {
data->player_id = "player-" + std::to_string(next_player_id_++);
}
data->nickname = payload.value("nickname", "anon");
send(ws, "hello.ack", {{"player_id", data->player_id}});
}
void Lobby::handleListRooms(WS *ws) {
send(ws, "lobby.rooms", {{"rooms", roomsSummary()}});
}
void Lobby::handleCreateRoom(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->player_id.empty()) {
sendError(ws, "not_authenticated", "send hello first");
return;
}
if (!data->room_id.empty()) {
sendError(ws, "already_in_room", "leave the current room first");
return;
}
std::string mode = payload.value("mode", "");
if (mode != "coop" && mode != "pvp") {
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
return;
}
Room room;
room.id = generateRoomId();
room.mode = mode;
room.players.push_back({data->player_id, data->nickname, ws});
data->room_id = room.id;
std::string room_id = room.id;
rooms_[room_id] = std::move(room);
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
{"mode", mode},
{"players", roomPlayers(rooms_[room_id])},
});
broadcastRoomsToLobby();
}
void Lobby::handleJoinRoom(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->player_id.empty()) {
sendError(ws, "not_authenticated", "send hello first");
return;
}
if (!data->room_id.empty()) {
sendError(ws, "already_in_room", "leave the current room first");
return;
}
std::string room_id = payload.value("room_id", "");
auto it = rooms_.find(room_id);
if (it == rooms_.end()) {
sendError(ws, "room_not_found", "no such room: " + room_id);
return;
}
Room &room = it->second;
if (room.state != "waiting") {
sendError(ws, "room_in_progress", "room is not accepting new players");
return;
}
if (room.players.size() >= kMaxPlayersPerRoom) {
sendError(ws, "room_full", "room is full");
return;
}
room.players.push_back({data->player_id, data->nickname, ws});
data->room_id = room_id;
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
{"mode", room.mode},
{"players", roomPlayers(room)},
});
broadcastRoomUpdated(room, ws);
broadcastRoomsToLobby();
}
void Lobby::handleLeaveRoom(WS *ws) {
if (ws->getUserData()->room_id.empty()) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
removePlayerFromRoom(ws);
broadcastRoomsToLobby();
}
void Lobby::removePlayerFromRoom(WS *ws) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
return;
}
auto it = rooms_.find(data->room_id);
data->room_id.clear();
if (it == rooms_.end()) {
return;
}
Room &room = it->second;
room.players.erase(
std::remove_if(room.players.begin(), room.players.end(),
[ws](const RoomPlayer &p) { return p.ws == ws; }),
room.players.end());
if (room.players.empty()) {
rooms_.erase(it);
} else {
broadcastRoomUpdated(room);
}
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <App.h>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using json = nlohmann::json;
struct PerSocketData {
std::string player_id;
std::string nickname;
std::string room_id; // пусто, если игрок в лобби, а не в комнате
};
using WS = uWS::WebSocket<false, true, PerSocketData>;
struct RoomPlayer {
std::string player_id;
std::string nickname;
WS *ws;
};
struct Room {
std::string id;
std::string mode; // "coop" | "pvp"
std::string state = "waiting"; // waiting | playing | finished
std::vector<RoomPlayer> players;
};
// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5.
class Lobby {
public:
void onOpen(WS *ws);
void onMessage(WS *ws, std::string_view message);
void onClose(WS *ws);
private:
static constexpr size_t kMaxPlayersPerRoom = 2;
std::unordered_map<std::string, Room> rooms_;
std::unordered_set<WS *> sockets_;
int next_player_id_ = 1;
int next_room_id_ = 1;
void handleHello(WS *ws, const json &payload);
void handleListRooms(WS *ws);
void handleCreateRoom(WS *ws, const json &payload);
void handleJoinRoom(WS *ws, const json &payload);
void handleLeaveRoom(WS *ws);
void removePlayerFromRoom(WS *ws);
void broadcastRoomsToLobby();
void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr);
json roomsSummary() const;
json roomPlayers(const Room &room) const;
std::string generateRoomId();
static void send(WS *ws, const std::string &type, json payload);
static void sendError(WS *ws, const std::string &code, const std::string &message);
};
+10 -28
View File
@@ -1,41 +1,23 @@
// Каркас (этап 1 из TZ.md): проверка связки nginx <-ws-> gameserver. // Игровая логика (тик, коллизии) — этапы 4-5 из TZ.md.
// Лобби/комнаты/игровая логика — следующие этапы.
#include <App.h> #include <App.h>
#include <iostream> #include <iostream>
#include <nlohmann/json.hpp>
using json = nlohmann::json; #include "Lobby.hpp"
struct PerSocketData {};
int main() { int main() {
Lobby lobby;
uWS::App() uWS::App()
.ws<PerSocketData>("/ws", { .ws<PerSocketData>("/ws", {
.open = [](auto *ws) { .open = [&lobby](auto *ws) {
std::cout << "client connected\n"; lobby.onOpen(ws);
}, },
.message = [](auto *ws, std::string_view message, uWS::OpCode) { .message = [&lobby](auto *ws, std::string_view message, uWS::OpCode) {
json response; lobby.onMessage(ws, message);
try {
json request = json::parse(message);
std::string type = request.value("type", "");
if (type == "hello") {
response = {{"type", "hello.ack"}, {"payload", {{"player_id", "stub"}}}};
} else if (type == "lobby.list_rooms") {
response = {{"type", "lobby.rooms"}, {"payload", {{"rooms", json::array()}}}};
} else {
response = {{"type", "error"},
{"payload", {{"code", "unknown_type"}, {"message", "unknown message type: " + type}}}};
}
} catch (const std::exception &e) {
response = {{"type", "error"}, {"payload", {{"code", "bad_json"}, {"message", e.what()}}}};
}
ws->send(response.dump(), uWS::OpCode::TEXT);
}, },
.close = [](auto *ws, int, std::string_view) { .close = [&lobby](auto *ws, int, std::string_view) {
std::cout << "client disconnected\n"; lobby.onClose(ws);
}, },
}) })
.listen(9001, [](auto *token) { .listen(9001, [](auto *token) {
+6
View File
@@ -8,6 +8,12 @@ server {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
# без хешей в именах файлов — просим браузер всегда переспрашивать свежесть
location ~* \.(js|css)$ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /ws { location /ws {
proxy_pass http://gameserver:9001; proxy_pass http://gameserver:9001;
proxy_http_version 1.1; proxy_http_version 1.1;