Add files via upload
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.14.6-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=8765
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY data ./data
|
||||||
|
|
||||||
|
EXPOSE 8765
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.server", "--host", "0.0.0.0", "--port", "8765"]
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Brass Birmingham Linux Native Backend
|
||||||
|
|
||||||
|
这是基于现有客户端协议重写的 Linux 原生后端。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
这份实现不是从源码直接移植,而是基于下面两部分反推出来的:
|
||||||
|
|
||||||
|
- `BrassHost.exe` 中提取出的 Python 服务端字节码
|
||||||
|
- `Assembly-CSharp.dll` 中提取出的客户端接口字符串
|
||||||
|
|
||||||
|
它已经覆盖了客户端联机所需的核心接口:
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 注册
|
||||||
|
- 当前用户
|
||||||
|
- 房间列表
|
||||||
|
- 创建房间
|
||||||
|
- 加入房间
|
||||||
|
- 离开房间
|
||||||
|
- 事件同步
|
||||||
|
- `lastSeenRevision` 更新
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
linux-native-backend/
|
||||||
|
app/
|
||||||
|
__init__.py
|
||||||
|
server.py
|
||||||
|
data/
|
||||||
|
games.json
|
||||||
|
users.json
|
||||||
|
run.sh
|
||||||
|
test_local.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 启动
|
||||||
|
|
||||||
|
先构建 image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd linux-native-backend
|
||||||
|
chmod +x build-image.sh
|
||||||
|
./build-image.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
brass-birmingham-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你想自定义名字或 tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMAGE_NAME=myrepo/brass-backend IMAGE_TAG=v1 ./build-image.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐直接用 Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd linux-native-backend
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 `Dockerfile` 使用的是明确存在的基础镜像标签:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python:3.14.6-slim-bookworm
|
||||||
|
```
|
||||||
|
|
||||||
|
这样比浮动别名更稳,也更方便排查镜像拉取问题。
|
||||||
|
|
||||||
|
## 目录挂载
|
||||||
|
|
||||||
|
`docker-compose.yml` 已经把本地数据目录挂载到容器里:
|
||||||
|
|
||||||
|
```text
|
||||||
|
./data -> /app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
所以容器重建后,用户和房间数据仍会保留。
|
||||||
|
|
||||||
|
## 手动构建与运行
|
||||||
|
|
||||||
|
如果你不想用 Compose,也可以直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd linux-native-backend
|
||||||
|
docker build -t brass-birmingham-backend .
|
||||||
|
docker run -d \
|
||||||
|
--name brass-birmingham-backend \
|
||||||
|
-p 8765:8765 \
|
||||||
|
-v "$(pwd)/data:/app/data" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
brass-birmingham-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 直接本机启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd linux-native-backend
|
||||||
|
chmod +x run.sh
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.0.0.0:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
- `POST /login`
|
||||||
|
- `POST /login/with/e-mail`
|
||||||
|
- `POST /register`
|
||||||
|
- `GET /me`
|
||||||
|
- `POST /logout`
|
||||||
|
- `POST /password/email`
|
||||||
|
- `GET /1.0.0/games`
|
||||||
|
- `POST /1.0.0/games`
|
||||||
|
- `PUT /1.0.0/games/<game_id>/players`
|
||||||
|
- `DELETE /1.0.0/games/<game_id>/players`
|
||||||
|
- `GET /1.0.0/games/<game_id>/events`
|
||||||
|
- `POST /1.0.0/games/<game_id>/events`
|
||||||
|
- `DELETE /1.0.0/games/<game_id>/events`
|
||||||
|
- `GET /1.0.0/games/<game_id>/events-and-metadata`
|
||||||
|
- `POST /1.0.0/games/<game_id>/metadata/lastSeenRevision`
|
||||||
|
- `POST /1.0.0/devices`
|
||||||
|
- `GET /debug/games`
|
||||||
|
|
||||||
|
## 认证方式
|
||||||
|
|
||||||
|
后端兼容原工具里暴露出来的伪 token 机制:
|
||||||
|
|
||||||
|
- `Authorization: Bearer fake-token-<user_id>`
|
||||||
|
- 或请求头 `X-User-Id: <user_id>`
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- 这份协议实现是逆向恢复版,不保证 100% 覆盖所有隐藏边缘逻辑
|
||||||
|
- 目前没有实现 ngrok 辅助启动,因为这不属于后端核心协议
|
||||||
|
- 如果后续抓到真实客户端请求样本,还可以继续把返回结构再对齐得更严
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# First Usable Version
|
||||||
|
|
||||||
|
This directory is the current working version of the Linux native backend.
|
||||||
|
|
||||||
|
Snapshot label:
|
||||||
|
|
||||||
|
`v1-usable`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Captures the current Docker-based backend layout.
|
||||||
|
- Includes the current protocol compatibility work for login, room listing, join flow, and turn progression.
|
||||||
|
- Intended as the first rollback point before further fixes.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
"""Brass Birmingham Linux-native backend."""
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-brass-birmingham-backend}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Built image: ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "test-game-001",
|
||||||
|
"GameId": "test-game-001",
|
||||||
|
"created_at": "2026-07-28T15:53:26Z",
|
||||||
|
"updated_at": "2026-07-29T20:04:38Z",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Metadata": {
|
||||||
|
"Revision": 14
|
||||||
|
},
|
||||||
|
"Revision": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Metadata": {
|
||||||
|
"Revision": 15
|
||||||
|
},
|
||||||
|
"Revision": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PlayerMetadata": [
|
||||||
|
{
|
||||||
|
"PlayerNetworkId": "1225a3f1-f5d1-4bb8-8d0b-1c10f8a753ba",
|
||||||
|
"email": "host@example.com",
|
||||||
|
"name": "Host",
|
||||||
|
"nickname": "Host",
|
||||||
|
"createdAt": "2026-07-28T15:53:26Z",
|
||||||
|
"LastSeenEventRevision": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"NetworkGameProgressInformation": {
|
||||||
|
"CurrentGameStatus": "Started"
|
||||||
|
},
|
||||||
|
"GameStartConfiguration": {
|
||||||
|
"IsPrivate": false,
|
||||||
|
"NumberOfPlayersAsInt": 4
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"PlayerMetadata": [
|
||||||
|
{
|
||||||
|
"PlayerNetworkId": "1225a3f1-f5d1-4bb8-8d0b-1c10f8a753ba",
|
||||||
|
"email": "host@example.com",
|
||||||
|
"name": "Host",
|
||||||
|
"nickname": "Host",
|
||||||
|
"createdAt": "2026-07-28T15:53:26Z",
|
||||||
|
"LastSeenEventRevision": 0
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
],
|
||||||
|
"NetworkGameProgressInformation": {
|
||||||
|
"CurrentGameStatus": "Started"
|
||||||
|
},
|
||||||
|
"GameStartConfiguration": {
|
||||||
|
"IsPrivate": false,
|
||||||
|
"NumberOfPlayersAsInt": 4
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "GameStarted",
|
||||||
|
"Revision": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"GameProgressInformation": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1225a3f1-f5d1-4bb8-8d0b-1c10f8a753ba",
|
||||||
|
"email": "host@example.com",
|
||||||
|
"nickname": "Host",
|
||||||
|
"name": "Host",
|
||||||
|
"password": "123456",
|
||||||
|
"createdAt": "2026-07-28T15:53:26Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
brass-backend:
|
||||||
|
image: brass-birmingham-backend:latest
|
||||||
|
container_name: brass-birmingham-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8765:8765"
|
||||||
|
volumes:
|
||||||
|
- /mnt/disk2/dockdata/data:/app/data
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 8765
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
HOST="${HOST:-0.0.0.0}"
|
||||||
|
PORT="${PORT:-8765}"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
exec python3 -m app.server --host "$HOST" --port "$PORT"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from app.server import build_server
|
||||||
|
|
||||||
|
|
||||||
|
def call(method: str, url: str, payload: dict | None = None, headers: dict | None = None) -> tuple[int, dict]:
|
||||||
|
data = None
|
||||||
|
req_headers = {"Content-Type": "application/json"}
|
||||||
|
if headers:
|
||||||
|
req_headers.update(headers)
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=data, method=method, headers=req_headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
return response.status, json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
server = build_server("127.0.0.1", 18765)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
base = "http://127.0.0.1:18765"
|
||||||
|
status, payload = call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/register",
|
||||||
|
{"email": "host@example.com", "password": "123456", "name": "Host"},
|
||||||
|
)
|
||||||
|
assert status in {201, 409}, (status, payload)
|
||||||
|
|
||||||
|
status, payload = call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/login",
|
||||||
|
{"email": "host@example.com", "password": "123456"},
|
||||||
|
)
|
||||||
|
assert status == 200, (status, payload)
|
||||||
|
token = payload["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
status, payload = call("GET", f"{base}/me", headers=headers)
|
||||||
|
assert status == 200, (status, payload)
|
||||||
|
|
||||||
|
status, payload = call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/1.0.0/games",
|
||||||
|
{
|
||||||
|
"GameId": "test-game-001",
|
||||||
|
"GameStartConfiguration": {"NumberOfPlayersAsInt": 4, "IsPrivate": False},
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert status in {201, 409}, (status, payload)
|
||||||
|
|
||||||
|
status, payload = call("GET", f"{base}/1.0.0/games?perPage=20", headers=headers)
|
||||||
|
assert status == 200 and isinstance(payload.get("data"), list), (status, payload)
|
||||||
|
|
||||||
|
status, payload = call(
|
||||||
|
"POST",
|
||||||
|
f"{base}/1.0.0/games/test-game-001/events",
|
||||||
|
{"events": [{"$type": "GameStarted"}]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert status == 200 and isinstance(payload, dict), (status, payload)
|
||||||
|
|
||||||
|
status, payload = call(
|
||||||
|
"GET",
|
||||||
|
f"{base}/1.0.0/games/test-game-001/events-and-metadata",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert status == 200, (status, payload)
|
||||||
|
|
||||||
|
server.shutdown()
|
||||||
|
print("local test passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from app.server import sync_progress_from_events
|
||||||
|
|
||||||
|
|
||||||
|
class TurnProgressionTests(unittest.TestCase):
|
||||||
|
def test_turn_ended_advances_to_next_player(self) -> None:
|
||||||
|
metadata = {
|
||||||
|
"GameStartConfiguration": {
|
||||||
|
"NumberOfPlayersAsInt": 2,
|
||||||
|
"Players": {
|
||||||
|
"$values": [
|
||||||
|
{"Color": "Yellow", "SittingOrder": 0},
|
||||||
|
{"Color": "Blue", "SittingOrder": 1},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"PlayerMetadata": [
|
||||||
|
{"PlayerNetworkId": "player-yellow", "NickName": "Yellow"},
|
||||||
|
{"PlayerNetworkId": "player-blue", "NickName": "Blue"},
|
||||||
|
],
|
||||||
|
"NetworkGameProgressInformation": {},
|
||||||
|
}
|
||||||
|
game = {
|
||||||
|
"metadata": metadata,
|
||||||
|
"events": [
|
||||||
|
{"$type": "BoardGameRules.Entities.GamePhases.Events.TurnStarted, Assembly-CSharp", "PlayerColor": "Yellow"},
|
||||||
|
{"$type": "BoardGameRules.Entities.GamePhases.Events.TurnEnded, Assembly-CSharp"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_progress_from_events(game)
|
||||||
|
|
||||||
|
self.assertEqual(metadata["NetworkGameProgressInformation"]["CurrentPlayerId"], "player-blue")
|
||||||
|
|
||||||
|
def test_turn_started_after_turn_ended_is_accepted(self) -> None:
|
||||||
|
metadata = {
|
||||||
|
"GameStartConfiguration": {
|
||||||
|
"NumberOfPlayersAsInt": 2,
|
||||||
|
"Players": {
|
||||||
|
"$values": [
|
||||||
|
{"Color": "Yellow", "SittingOrder": 0},
|
||||||
|
{"Color": "Blue", "SittingOrder": 1},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"PlayerMetadata": [
|
||||||
|
{"PlayerNetworkId": "player-yellow", "NickName": "Yellow"},
|
||||||
|
{"PlayerNetworkId": "player-blue", "NickName": "Blue"},
|
||||||
|
],
|
||||||
|
"NetworkGameProgressInformation": {},
|
||||||
|
}
|
||||||
|
game = {
|
||||||
|
"metadata": metadata,
|
||||||
|
"events": [
|
||||||
|
{"$type": "BoardGameRules.Entities.GamePhases.Events.TurnStarted, Assembly-CSharp", "PlayerColor": "Yellow"},
|
||||||
|
{"$type": "BoardGameRules.Entities.GamePhases.Events.TurnEnded, Assembly-CSharp"},
|
||||||
|
{"$type": "BoardGameRules.Entities.GamePhases.Events.TurnStarted, Assembly-CSharp", "PlayerColor": "Blue"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_progress_from_events(game)
|
||||||
|
|
||||||
|
self.assertEqual(metadata["NetworkGameProgressInformation"]["CurrentPlayerId"], "player-blue")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user