Add files via upload

This commit is contained in:
AAsige
2026-07-30 20:56:50 +08:00
committed by GitHub
commit 65abbe4aa6
18 changed files with 1869 additions and 0 deletions
+15
View File
@@ -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"]
+157
View File
@@ -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.
@@ -0,0 +1 @@
"""Brass Birmingham Linux-native backend."""
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
+10
View File
@@ -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()