From 65abbe4aa6331f795fb482f32339a16aef979372 Mon Sep 17 00:00:00 2001 From: AAsige <168331480+AAsige@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:56:50 +0800 Subject: [PATCH] Add files via upload --- linux-native-backend-v1-usable/Dockerfile | 15 + linux-native-backend-v1-usable/README.md | 157 ++ .../VERSION_SNAPSHOT.md | 13 + .../test_turn_progression.cpython-312.pyc | Bin 0 -> 2346 bytes .../test_turn_progression.cpython-314.pyc | Bin 0 -> 2919 bytes .../app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 230 bytes .../app/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 232 bytes .../app/__pycache__/server.cpython-312.pyc | Bin 0 -> 67164 bytes .../app/__pycache__/server.cpython-314.pyc | Bin 0 -> 79567 bytes linux-native-backend-v1-usable/app/server.py | 1332 +++++++++++++++++ linux-native-backend-v1-usable/build-image.sh | 13 + .../data/games.json | 146 ++ .../data/users.json | 10 + .../docker-compose.yml | 12 + linux-native-backend-v1-usable/run.sh | 10 + linux-native-backend-v1-usable/test_local.py | 88 ++ .../test_turn_progression.py | 72 + 18 files changed, 1869 insertions(+) create mode 100644 linux-native-backend-v1-usable/Dockerfile create mode 100644 linux-native-backend-v1-usable/README.md create mode 100644 linux-native-backend-v1-usable/VERSION_SNAPSHOT.md create mode 100644 linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-312.pyc create mode 100644 linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-314.pyc create mode 100644 linux-native-backend-v1-usable/app/__init__.py create mode 100644 linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-312.pyc create mode 100644 linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-314.pyc create mode 100644 linux-native-backend-v1-usable/app/__pycache__/server.cpython-312.pyc create mode 100644 linux-native-backend-v1-usable/app/__pycache__/server.cpython-314.pyc create mode 100644 linux-native-backend-v1-usable/app/server.py create mode 100644 linux-native-backend-v1-usable/build-image.sh create mode 100644 linux-native-backend-v1-usable/data/games.json create mode 100644 linux-native-backend-v1-usable/data/users.json create mode 100644 linux-native-backend-v1-usable/docker-compose.yml create mode 100644 linux-native-backend-v1-usable/run.sh create mode 100644 linux-native-backend-v1-usable/test_local.py create mode 100644 linux-native-backend-v1-usable/test_turn_progression.py diff --git a/linux-native-backend-v1-usable/Dockerfile b/linux-native-backend-v1-usable/Dockerfile new file mode 100644 index 0000000..d6a3028 --- /dev/null +++ b/linux-native-backend-v1-usable/Dockerfile @@ -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"] diff --git a/linux-native-backend-v1-usable/README.md b/linux-native-backend-v1-usable/README.md new file mode 100644 index 0000000..b0b51f8 --- /dev/null +++ b/linux-native-backend-v1-usable/README.md @@ -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//players` +- `DELETE /1.0.0/games//players` +- `GET /1.0.0/games//events` +- `POST /1.0.0/games//events` +- `DELETE /1.0.0/games//events` +- `GET /1.0.0/games//events-and-metadata` +- `POST /1.0.0/games//metadata/lastSeenRevision` +- `POST /1.0.0/devices` +- `GET /debug/games` + +## 认证方式 + +后端兼容原工具里暴露出来的伪 token 机制: + +- `Authorization: Bearer fake-token-` +- 或请求头 `X-User-Id: ` + +## 已知限制 + +- 这份协议实现是逆向恢复版,不保证 100% 覆盖所有隐藏边缘逻辑 +- 目前没有实现 ngrok 辅助启动,因为这不属于后端核心协议 +- 如果后续抓到真实客户端请求样本,还可以继续把返回结构再对齐得更严 diff --git a/linux-native-backend-v1-usable/VERSION_SNAPSHOT.md b/linux-native-backend-v1-usable/VERSION_SNAPSHOT.md new file mode 100644 index 0000000..4d94b6f --- /dev/null +++ b/linux-native-backend-v1-usable/VERSION_SNAPSHOT.md @@ -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. diff --git a/linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-312.pyc b/linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a69423197b0540bdfa6e90969b7a4cbe25a6ad7 GIT binary patch literal 2346 zcmcgtO>7fK6rR~#uNOP9osbfTKpUd8V1z>{6(EF=!2G2W1{J7^6=}QnjLkahU3X?} zup26nsNh&ssiYE8xkgAmbL_pBxKJe;R4G!`12?1O#Hnv~ZO9=~sf4=HzM1zk-@JKm z-kaYB2V)4npRQD^zrgG-$@D*?H(Ap#LJtr@1Usk@j<|qt6{9h zH!!^^K?>=8odzo^d z=S#g<1*keud5X1#0FGdy5D5}_s1Th*P@vixCI{c2w)74!JKRjX#7n^T4-@3|3aR-9i3`g zPJ?oxU!{)YU1w67QA8x?IUW-Omu%m+-O2?})qgJHsLiwU&wJ75! z_>Aj|Xpd_T)&p_26~KahGSuX$f8AqqGej79yEK=#YP22i*%kplql#^3L3qixn4k09 zvR!E~%ZFHtM2K*n`WCT#OT6F1lIH!K%(!Kb)dKtWnUkKyh}3@Ga44TX?fSNF!z5U6 z&EirP+=pqI)?i0U_D!7NoYsm?E1SD?&0_V8@+varx9U{HLkEJ;ux&DYr~ca(r(Lq? z2EwTI52c4Ul)VS=BAIJ21{XHmcZMt~Oq24s1uioG^w$l`SyY9_sZ(aMoP<_^<8pZ$ zCMG6cUCkYxy#khe_P{5H4j!I8MfsfX)n|(g0E#xVYmnP47&IxHb!@kBBkMx!nlxLq zN^{gDvp!sK(}(E1t|Q3AbiE~ZztMmlC8kB1mRq9S^gYw1H+(ZpYWvt`B{Th&t7Vho zKLWXhx;o12T^-rEGP(E3iB9_X%H*CWyE^G(-H1A|t*b_M48M#RyA$0J^x>z!raP(6 zR;CU-X?9X)z~F;K*FgIZyxf@_iQO~0lW1($>h|Ii|E80gUl~d-ZU1U1Wr5vj;$=in z#qMeADYRws(bO|z_o^|lY@|CzdLg|ud9pK>TRL^6Gxo*Om-G*#yat}3&8zv zJiiSH^n1jA2J%0odqrM=Rwvc&Mm3rDXpr~ex5_)41RtTN>5R(S$ivHEn5GM*XPP2r znl+C=$AS5PY04T6HEb8GVpBjCQGgHxn#l(vAo8{62_SdSnlc`#;PsQ{1`RbTqIG!m9J|P5?F&tELbqMnCcxBZa&6fToQlG8=s8U)^tvUx zi-=kE`gGU@!v11h2FM&V$k4fu0!XRscuaW{E8`8~dW@V>ur?uT!eN3)&L?9-8y&o{O$AKUFfPv+aCDx4~=MuyZ`_I literal 0 HcmV?d00001 diff --git a/linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-314.pyc b/linux-native-backend-v1-usable/__pycache__/test_turn_progression.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..828970aebe9a13efffd49072efcccfee11c7426a GIT binary patch literal 2919 zcmd5;O>7fK6rTODy*U0$FtGy(`5^_O#!g!hL{Wqq94M(2$6X+TD#>{eB%s)l1xFX@eAa;Z4x&;zGldT|6a)}l&CRS(>Zf(pmJ*|kY%Bb8P{>Pmj|-prdf z^WOXBJ--|9dl7u!U%qzj2jG5VLi3Xv%(jn1Xc^5RnVUe=89B;<%unza1S)i+S(IvO zH+RFBYS|?;*f})o?nSc#{5&VYF8flg&bXVNYzV2g-Lnqg+;@_98~ z1E?3tVg$*qSuPdbW4GYUQU7c}b`M`cC>uto6M^>OziaoR=1sc6|0DKq!1I(TCI>i6 zOsRAM#tyTrXOto_<_R`UB}a^cf|syP&CG6_jSHjgahO#hsI!Q2GNSyPmkRC3K-1_1 zCvy>$mt-Djh>G@%V9$V4FyoS4)IE);M|K}U10E}UsYLWt<7CayFJTi-Wua|^7qL^B z)7#9yQEnMdnd67Q2FGEx6Me!hz?m{X1n#X1PnovmCYXZR|3_3CPrp1o0yn0?|*RW#OaxFY%WrxIFlxzn%0O`(DeBjC@f(zlh^dp z&6uuIZ3)NHYGx7Z*%=BqR@iZ`0tjxzi)G8FD5|a-RHax^tlqsGjqg@rcrznFUM`~- z=#XokduG#(urqGM60$?X81^yrLlC6xAAwm$GURuTmw8!`McF00=g<%j9E5s~8{*>- zB9J9{pX`-eWEb%JWFK%IwkG?B(2!8%A$k802YdMz3LBD(e64!3Ef20=hMXC~-F!Y=@Um_4@L$|`oQX!4W zR4$cQ%aErrb3&(PJ|k9kY)DBhvzUZyRazPgX=dhdV*|Tz6S)(*gEDqw zsn9&Y?L4B2tDlyOxN`oip^_}yCYSQqjGxn~Mm1n;OAD&W=Imz|XJD~|fwO}>W2T7< z>3lhsxVWH_qSfNu+jh7z{_l*Q)3bY-3?@p1z=L-zPh^wpJP{##Rtr1>Oz64mB{jb; zSfYvZInvEm#CdSUWT`*e(?bGm>e$X(fR}-@ike+g^$a!@YA8CsNfjrK*65y(;SG1& z@5Dj;6JLKVbaaiYg*s~Cu3AUW-Q#P?jj?L<4C9Zk#W#AZ(b2j~=xMJDuFkf)OF9;) zw;_L5t+ns&)V)vdN2{Ta89#Y%WMiosIuE+tfx3kJv9)x)D;W0Pmg;@TA6b=ZzHqHI zT0NMqX0KPx&#R#?Z1J;dNQF(2K;7jId2fr)Lug;$-TsHtu_uymONwqv(bZ_R@9d+F zM0Nb~qmJq775s~oBdlxu*E&a7yCN(O13d9D-VdZv((F`_Ae#*VInGvIRh3S=sxW$A ze1_P#a}iLqzgJb|ClKFeRcTZiR)2)ITVKe2aD^OUrEdz*mpB4j4&jdT|i#00FYnstf$uPp88^hVgHYv7hvnNOBYm4 z2dg1VFw3STm2~JM(BUml5&kRXwKQQB&k~trxiy^?M^L4N?agVnlOFgNW#pHz_H{DERiVByO8zC)?q(pQKY!rBNqzXUG`nFdxkYAa#M`xW~x* z7zKYt(I-e)k#0#V!CS#^xjQHB(A5jyeYq8hZAM}bB12n|Q=5@f5B8sah=zYh?>%>q saP!>i@YbQh%|nCF5tHf_4vD_om4`w{P4ulyel_{c)H4Bz2ko2u37Db$5dZ)H literal 0 HcmV?d00001 diff --git a/linux-native-backend-v1-usable/app/__init__.py b/linux-native-backend-v1-usable/app/__init__.py new file mode 100644 index 0000000..8603468 --- /dev/null +++ b/linux-native-backend-v1-usable/app/__init__.py @@ -0,0 +1 @@ +"""Brass Birmingham Linux-native backend.""" diff --git a/linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-312.pyc b/linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..273a4f5490e5d3850588060ded4bd1839207a431 GIT binary patch literal 230 zcmX@j%ge<81UVNnvUGv;V-N=h7@>^M96-i&h7^V;A|BWTAW%` z9Aju?W^5kgl3JWyl3x&$1TrxxvnUf}qApNDS!z*C4w9+5P*YLPBu> literal 0 HcmV?d00001 diff --git a/linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-314.pyc b/linux-native-backend-v1-usable/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32cb06935e04bd35900b52083d43091fb9127393 GIT binary patch literal 232 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 literal 0 HcmV?d00001 diff --git a/linux-native-backend-v1-usable/app/__pycache__/server.cpython-314.pyc b/linux-native-backend-v1-usable/app/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e13ba305534602e5908c8189f18fccb85756679f GIT binary patch literal 79567 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�pN(V8 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 XcV6y(vBwvmB^a{q=s80MYpVYXa*Rb` literal 0 HcmV?d00001 diff --git a/linux-native-backend-v1-usable/app/server.py b/linux-native-backend-v1-usable/app/server.py new file mode 100644 index 0000000..12d6b4f --- /dev/null +++ b/linux-native-backend-v1-usable/app/server.py @@ -0,0 +1,1332 @@ +from __future__ import annotations + +import argparse +import json +import logging +import re +import socket +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + + +ROOT_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT_DIR / "data" +USERS_FILE = DATA_DIR / "users.json" +GAMES_FILE = DATA_DIR / "games.json" +SERVER_PORT = 8765 +LOGGER = logging.getLogger("brass_backend") + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def get_local_ip() -> str: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("8.8.8.8", 80)) + return sock.getsockname()[0] + except OSError: + return "127.0.0.1" + finally: + sock.close() + + +def load_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def save_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + + +def ensure_player_metadata_slots(game: dict[str, Any]) -> None: + cfg = game.setdefault("GameStartConfiguration", {}) + wanted = int(cfg.get("NumberOfPlayersAsInt", 4) or 4) + players = game.setdefault("PlayerMetadata", []) + while len(players) < wanted: + players.append({}) + + +def normalize_revision(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def make_response(data: Any = None, status: int = 200) -> tuple[int, dict[str, Any]]: + return status, {"data": data} + + +def make_error(message: str, status: int = 400) -> tuple[int, dict[str, Any]]: + return status, {"error": message} + + +def make_auth_response(access_token: str, status: int = 200) -> tuple[int, dict[str, Any]]: + return status, {"access_token": access_token} + + +def make_user_response(user: dict[str, Any], status: int = 200) -> tuple[int, dict[str, Any]]: + return status, { + "id": user["id"], + "email": user["email"], + "name": user.get("name") or user.get("nickname") or user["email"], + } + + +def make_message_response(message: str = "ok", status: int = 200) -> tuple[int, dict[str, Any]]: + return status, {"message": message} + + +def coerce_payload_dict(body: Any) -> dict[str, Any]: + if not isinstance(body, dict): + return {} + nested = body.get("data") + if isinstance(nested, dict): + merged = dict(nested) + for key, value in body.items(): + if key != "data" and key not in merged: + merged[key] = value + return merged + return body + + +def nested_get(payload: Any, path: list[str]) -> Any: + current = payload + for part in path: + if isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + +def make_raw_response(payload: Any, status: int = 200) -> tuple[int, Any]: + return status, payload + + +def ensure_game_metadata_shape(game: dict[str, Any]) -> dict[str, Any]: + metadata = game.setdefault("metadata", {}) + if "PlayerMetadata" not in metadata and "PlayerMetadata" in game: + metadata["PlayerMetadata"] = game["PlayerMetadata"] + if "NetworkGameProgressInformation" not in metadata and "NetworkGameProgressInformation" in game: + metadata["NetworkGameProgressInformation"] = game["NetworkGameProgressInformation"] + if "GameStartConfiguration" not in metadata and "GameStartConfiguration" in game: + metadata["GameStartConfiguration"] = game["GameStartConfiguration"] + if "events" not in metadata and "events" in game: + metadata["events"] = game["events"] + return game + + +def infer_nickname_from_game_name(game_name: Any) -> str | None: + if not isinstance(game_name, str) or not game_name: + return None + if "'的游戏" in game_name: + return game_name.split("'的游戏", 1)[0].strip() or None + if "'s game" in game_name: + return game_name.split("'s game", 1)[0].strip() or None + return None + + +def metadata_values(metadata: dict[str, Any], key: str) -> list[Any]: + value = metadata.get(key) + if isinstance(value, list): + return value + if isinstance(value, dict) and isinstance(value.get("$values"), list): + return value["$values"] + return [] + + +def normalize_player_entry(entry: Any) -> Any: + if entry is None or not isinstance(entry, dict): + return entry + if isinstance(entry.get("metadata"), dict): + normalized = dict(entry["metadata"]) + for key in ( + "PlayerNetworkId", + "NickName", + "GameId", + "AiType", + "PlayerAvatar", + "PlayerStartConfiguration", + "LastSeenEventRevision", + ): + if entry.get(key) is not None and normalized.get(key) is None: + normalized[key] = entry.get(key) + return normalized + return entry + + +def normalize_player_metadata_slots(metadata: dict[str, Any]) -> list[Any]: + players = metadata.get("PlayerMetadata", []) + if isinstance(players, dict) and "$values" in players: + players = players["$values"] + metadata["PlayerMetadata"] = players + if not isinstance(players, list): + players = [] + metadata["PlayerMetadata"] = players + + for idx, entry in enumerate(players): + players[idx] = normalize_player_entry(entry) + + max_players = nested_get(metadata, ["GameStartConfiguration", "NumberOfPlayersAsInt"]) + try: + max_players = int(max_players) + except (TypeError, ValueError): + max_players = len(players) + + while len(players) < max_players: + players.append(None) + if max_players > 0 and len(players) > max_players: + del players[max_players:] + return players + + +def real_player_count(players: list[Any]) -> int: + return sum(1 for entry in players if isinstance(entry, dict)) + + +def sync_network_game_status(metadata: dict[str, Any]) -> list[Any]: + players = normalize_player_metadata_slots(metadata) + max_players = nested_get(metadata, ["GameStartConfiguration", "NumberOfPlayersAsInt"]) + try: + max_players = int(max_players) + except (TypeError, ValueError): + max_players = len(players) + + progress = metadata.setdefault("NetworkGameProgressInformation", {}) + joined = real_player_count(players) + if joined >= max_players and max_players > 0: + progress["CurrentGameStatus"] = "InProgress" + if not progress.get("CurrentPlayerId"): + first_player = next((entry for entry in players if isinstance(entry, dict)), None) + if first_player: + progress["CurrentPlayerId"] = first_player.get("PlayerNetworkId") + elif joined > 0: + progress["CurrentGameStatus"] = "Open" + return players + + +def event_revision(event: dict[str, Any]) -> int: + if not isinstance(event, dict): + return 0 + nested = event.get("Metadata") + if isinstance(nested, dict): + try: + return int(nested.get("Revision") or 0) + except (TypeError, ValueError): + return 0 + try: + return int(event.get("Revision") or 0) + except (TypeError, ValueError): + return 0 + + +def set_event_revision(event: dict[str, Any], revision: int) -> None: + metadata = event.setdefault("Metadata", {}) + if isinstance(metadata, dict): + metadata["Revision"] = revision + event["Revision"] = revision + + +def normalize_events_payload(events: Any) -> list[Any]: + if isinstance(events, dict) and isinstance(events.get("$values"), list): + return list(events["$values"]) + if isinstance(events, list): + flattened: list[Any] = [] + for event in events: + if isinstance(event, dict) and isinstance(event.get("$values"), list): + flattened.extend(event["$values"]) + else: + flattened.append(event) + return flattened + return [events] + + +def _resolve_player_id_from_color(metadata: dict[str, Any], player_color: Any) -> str | None: + if player_color is None: + return None + + players = metadata_values(metadata, "PlayerMetadata") + for player in players: + if not isinstance(player, dict): + continue + + start_cfg = player.get("PlayerStartConfiguration") + if isinstance(start_cfg, dict) and start_cfg.get("Color") == player_color: + return player.get("PlayerNetworkId") or None + + if isinstance(player.get("PlayerStartConfiguration"), dict): + cfg_color = player.get("PlayerStartConfiguration", {}).get("Color") + if cfg_color == player_color: + return player.get("PlayerNetworkId") or None + + if player.get("NickName") and str(player.get("NickName")).lower() == str(player_color).lower(): + return player.get("PlayerNetworkId") or None + + return None + + +def _resolve_next_player_id(metadata: dict[str, Any], current_player_color: Any) -> str | None: + if current_player_color is None: + return None + + start_configs = nested_get(metadata, ["GameStartConfiguration", "Players", "$values"]) + if not isinstance(start_configs, list): + return None + + ordered_colors = [] + for entry in start_configs: + if isinstance(entry, dict): + color = entry.get("Color") + if color is not None: + ordered_colors.append(str(color)) + + if not ordered_colors: + return None + + try: + current_index = ordered_colors.index(str(current_player_color)) + except ValueError: + return None + + next_index = (current_index + 1) % len(ordered_colors) + next_color = ordered_colors[next_index] + return _resolve_player_id_from_color(metadata, next_color) + + +def _resolve_next_player(metadata: dict[str, Any], current_player_color: Any) -> tuple[str | None, str | None]: + if current_player_color is None: + return None, None + + start_configs = nested_get(metadata, ["GameStartConfiguration", "Players", "$values"]) + if not isinstance(start_configs, list): + return None, None + + ordered_colors = [] + for entry in start_configs: + if isinstance(entry, dict): + color = entry.get("Color") + if color is not None: + ordered_colors.append(str(color)) + + if not ordered_colors: + return None, None + + try: + current_index = ordered_colors.index(str(current_player_color)) + except ValueError: + return None, None + + next_index = (current_index + 1) % len(ordered_colors) + next_color = ordered_colors[next_index] + next_player_id = _resolve_player_id_from_color(metadata, next_color) + return next_player_id, next_color + + +def sync_progress_from_events(game: dict[str, Any]) -> None: + metadata = game.setdefault("metadata", {}) + progress = metadata.setdefault("NetworkGameProgressInformation", {}) + game_progress = metadata.setdefault("GameProgressInformation", {}) + events = game.get("events", []) + + if not isinstance(events, list): + return + + current_player_id = None + current_player_color = None + + for event in reversed(events): + if not isinstance(event, dict): + continue + + event_type = str(event.get("$type") or "") + + if "TurnStarted" in event_type and event.get("PlayerColor") is not None: + current_player_id = _resolve_player_id_from_color(metadata, event.get("PlayerColor")) + current_player_color = str(event.get("PlayerColor")) + break + + if "TurnEnded" in event_type: + previous_turn_color = None + for candidate in reversed(events[: events.index(event)]): + if not isinstance(candidate, dict): + continue + candidate_type = str(candidate.get("$type") or "") + if "TurnStarted" in candidate_type and candidate.get("PlayerColor") is not None: + previous_turn_color = str(candidate.get("PlayerColor")) + break + if previous_turn_color is not None: + current_player_id, current_player_color = _resolve_next_player(metadata, previous_turn_color) + break + + if current_player_id: + progress["CurrentPlayerId"] = current_player_id + progress["CurrentGameStatus"] = "InProgress" + progress["CurrentPlayerColor"] = current_player_color or progress.get("CurrentPlayerColor") + current_player = game_progress.setdefault("CurrentPlayer", {}) + if current_player_color: + current_player["Color"] = current_player_color + return + + for event in reversed(events): + if not isinstance(event, dict): + continue + event_type = str(event.get("$type") or "") + if "TurnStarted" in event_type and event.get("PlayerColor") is not None: + player_color = event.get("PlayerColor") + player_id = _resolve_player_id_from_color(metadata, player_color) + if player_id: + progress["CurrentPlayerId"] = player_id + progress["CurrentGameStatus"] = "InProgress" + progress["CurrentPlayerColor"] = str(player_color) + current_player = game_progress.setdefault("CurrentPlayer", {}) + current_player["Color"] = str(player_color) + break + + +def game_display_name(game: dict[str, Any]) -> str: + metadata = game.get("metadata", {}) + return str(nested_get(metadata, ["NetworkGameStartConfiguration", "GameName"]) or "") + + +def game_owner_key(game: dict[str, Any]) -> str: + metadata = game.get("metadata", {}) + players = normalize_player_metadata_slots(metadata) + first_player = next((entry for entry in players if isinstance(entry, dict)), None) + if first_player: + return str(first_player.get("PlayerNetworkId") or first_player.get("NickName") or "") + nickname = infer_nickname_from_game_name(game_display_name(game)) + return nickname or "" + + +def game_dedupe_key(game: dict[str, Any]) -> str: + return f"{game_owner_key(game)}|{game_display_name(game)}" + + +def is_client_safe_game_metadata(metadata: Any) -> bool: + if not isinstance(metadata, dict): + return False + if not metadata.get("$type"): + return False + if not isinstance(metadata.get("GameStartConfiguration"), dict): + return False + if not isinstance(metadata.get("NetworkGameProgressInformation"), dict): + return False + if not isinstance(metadata.get("NetworkGameStartConfiguration"), dict): + return False + player_metadata = metadata.get("PlayerMetadata") + if isinstance(player_metadata, dict): + if not isinstance(player_metadata.get("$values"), list): + return False + elif not isinstance(player_metadata, list): + return False + return True + + +def cleanup_games_on_load(raw_games: list[Any], users: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + games_by_id: dict[str, dict[str, Any]] = {} + user_names = { + user.get("id", ""): user.get("name") or user.get("nickname") or user.get("email") or "" + for user in users + if isinstance(user, dict) + } + + deduped: dict[str, dict[str, Any]] = {} + removed_ids: list[str] = [] + + for entry in raw_games: + if not isinstance(entry, dict) or "id" not in entry: + continue + game = ensure_game_metadata_shape(dict(entry)) + metadata = game.setdefault("metadata", {}) + + # Normalize PlayerMetadata container shape. + normalize_player_metadata_slots(metadata) + + # Repair empty or anonymous owner slot from game name when possible. + players = metadata.get("PlayerMetadata", []) + real_players = [p for p in players if isinstance(p, dict)] + if not real_players: + nickname = infer_nickname_from_game_name(game_display_name(game)) + matched_user_id = None + for uid, uname in user_names.items(): + if uname == nickname: + matched_user_id = uid + break + start_players = nested_get(metadata, ["GameStartConfiguration", "Players", "$values"]) + first_start = start_players[0] if isinstance(start_players, list) and start_players else None + inferred = { + "$type": "BrassApplication.Game.PlayerMetadata, Assembly-CSharp", + "NickName": nickname or "Host", + "AiType": "Human", + "PlayerAvatar": "Arkwright", + "PlayerStartConfiguration": first_start, + "LastSeenEventRevision": 0, + } + if matched_user_id: + inferred["PlayerNetworkId"] = matched_user_id + player_count = nested_get(metadata, ["GameStartConfiguration", "NumberOfPlayersAsInt"]) + try: + player_count = int(player_count) + except (TypeError, ValueError): + player_count = 2 + players = [inferred] + while len(players) < max(player_count, 1): + players.append(None) + metadata["PlayerMetadata"] = players + + sync_network_game_status(metadata) + game["updated_at"] = game.get("updated_at") or game.get("created_at") or now_iso() + games_by_id[game["id"]] = game + + for game in games_by_id.values(): + key = game_dedupe_key(game) or game.get("id", "") + existing = deduped.get(key) + if existing is None: + deduped[key] = game + continue + existing_players = real_player_count(normalize_player_metadata_slots(existing.get("metadata", {}))) + current_players = real_player_count(normalize_player_metadata_slots(game.get("metadata", {}))) + existing_updated = str(existing.get("updated_at") or "") + current_updated = str(game.get("updated_at") or "") + if current_players > existing_players or ( + current_players == existing_players and current_updated >= existing_updated + ): + removed_ids.append(existing["id"]) + deduped[key] = game + else: + removed_ids.append(game["id"]) + + if removed_ids: + LOGGER.warning("[BOOT] removed duplicate/stale game ids=%s", removed_ids) + + return {game["id"]: game for game in deduped.values()} + + +def setup_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) + + +@dataclass +class Storage: + lock: threading.Lock + users: list[dict[str, Any]] + games: dict[str, dict[str, Any]] + tokens: dict[str, str] + + @classmethod + def load(cls) -> "Storage": + DATA_DIR.mkdir(parents=True, exist_ok=True) + users = load_json(USERS_FILE, []) + games_list = load_json(GAMES_FILE, []) + games = cleanup_games_on_load(games_list, users) + return cls(lock=threading.Lock(), users=users, games=games, tokens={}) + + def save_users(self) -> None: + save_json(USERS_FILE, self.users) + + def save_games(self) -> None: + save_json(GAMES_FILE, list(self.games.values())) + + def find_user_by_email(self, email: str) -> dict[str, Any] | None: + email = email.strip().lower() + for user in self.users: + if user.get("email", "").strip().lower() == email: + return user + return None + + def user_by_id(self, user_id: str) -> dict[str, Any] | None: + for user in self.users: + if user.get("id") == user_id: + return user + return None + + +class BrassAPI: + def __init__(self, storage: Storage) -> None: + self.storage = storage + + def repair_game_metadata_for_listing(self, game: dict[str, Any]) -> dict[str, Any]: + game = ensure_game_metadata_shape(dict(game)) + metadata = game.setdefault("metadata", {}) + players = metadata.get("PlayerMetadata") + if isinstance(players, dict) and "$values" in players: + players = players["$values"] + metadata["PlayerMetadata"] = players + + if isinstance(players, list) and len(players) == 0: + game_name = nested_get(metadata, ["NetworkGameStartConfiguration", "GameName"]) + nickname = infer_nickname_from_game_name(game_name) + matched_user = None + if nickname: + for user in self.storage.users: + if user.get("name") == nickname or user.get("nickname") == nickname: + matched_user = user + break + + start_players = nested_get(metadata, ["GameStartConfiguration", "Players", "$values"]) + first_start = start_players[0] if isinstance(start_players, list) and start_players else None + inferred_player = { + "$type": "BrassApplication.Game.PlayerMetadata, Assembly-CSharp", + "NickName": nickname or "Host", + "AiType": "Human", + "PlayerAvatar": "Arkwright", + "PlayerStartConfiguration": first_start, + "LastSeenEventRevision": 0, + } + if matched_user: + inferred_player["PlayerNetworkId"] = matched_user["id"] + + player_count = nested_get(metadata, ["GameStartConfiguration", "NumberOfPlayersAsInt"]) + try: + player_count = int(player_count) + except (TypeError, ValueError): + player_count = 2 + + repaired_players: list[Any] = [inferred_player] + while len(repaired_players) < max(player_count, 1): + repaired_players.append(None) + + metadata["PlayerMetadata"] = repaired_players + LOGGER.warning( + "[GAME] repaired empty PlayerMetadata game_id=%s nickname=%s inferred_user_id=%s", + game.get("id"), + nickname, + matched_user.get("id") if matched_user else None, + ) + + return game + + def parse_body(self, handler: BaseHTTPRequestHandler) -> Any: + length = int(handler.headers.get("Content-Length", "0") or "0") + raw = handler.rfile.read(length) if length else b"" + if not raw: + return {} + + ctype = handler.headers.get("Content-Type", "") + if "application/json" in ctype: + return json.loads(raw.decode("utf-8")) + if "application/x-www-form-urlencoded" in ctype: + form = parse_qs(raw.decode("utf-8"), keep_blank_values=True) + return {key: values[-1] if values else "" for key, values in form.items()} + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + form = parse_qs(raw.decode("utf-8"), keep_blank_values=True) + return {key: values[-1] if values else "" for key, values in form.items()} + + def get_param(self, handler: BaseHTTPRequestHandler, body: Any, key: str, default: Any = None) -> Any: + if isinstance(body, dict) and key in body: + return body[key] + query = parse_qs(urlparse(handler.path).query) + if key in query and query[key]: + return query[key][-1] + return default + + def current_user_id(self, handler: BaseHTTPRequestHandler) -> str | None: + auth = handler.headers.get("Authorization", "") + if auth.startswith("Bearer "): + token = auth.removeprefix("Bearer ").strip() + if token in self.storage.tokens: + user_id = self.storage.tokens[token] + LOGGER.info("[AUTH] bearer token resolved user_id=%s path=%s", user_id, handler.path) + return user_id + if token.startswith("fake-token-"): + user_id = token.removeprefix("fake-token-").strip() + LOGGER.info("[AUTH] fake token resolved user_id=%s path=%s", user_id, handler.path) + return user_id + token = handler.headers.get("access_token", "") + if token: + if token in self.storage.tokens: + user_id = self.storage.tokens[token] + LOGGER.info("[AUTH] access_token resolved user_id=%s path=%s", user_id, handler.path) + return user_id + if token.startswith("fake-token-"): + user_id = token.removeprefix("fake-token-").strip() + LOGGER.info("[AUTH] fake access_token resolved user_id=%s path=%s", user_id, handler.path) + return user_id + direct = handler.headers.get("X-User-Id", "") + if direct: + user_id = direct.strip() + LOGGER.info("[AUTH] X-User-Id resolved user_id=%s path=%s", user_id, handler.path) + return user_id + LOGGER.warning("[AUTH] no auth headers path=%s remote=%s", handler.path, handler.client_address[0]) + return None + + def current_user(self, handler: BaseHTTPRequestHandler) -> dict[str, Any] | None: + user_id = self.current_user_id(handler) + if not user_id: + return None + user = self.storage.user_by_id(user_id) + if user: + LOGGER.info("[AUTH] user resolved user_id=%s email=%s", user_id, user.get("email")) + else: + LOGGER.warning("[AUTH] user not found for user_id=%s path=%s", user_id, handler.path) + return user + + def route(self, handler: BaseHTTPRequestHandler) -> tuple[int, dict[str, Any]]: + parsed = urlparse(handler.path) + path = parsed.path + method = handler.command.upper() + body = self.parse_body(handler) if method in {"POST", "PUT", "PATCH", "DELETE"} else {} + LOGGER.info("[REQ] %s %s remote=%s", method, path, handler.client_address[0]) + + if method == "POST" and path in {"/login", "/login/with/e-mail"}: + return self.login(body) + if method == "POST" and path == "/register": + return self.register(body) + if method == "GET" and path == "/me": + return self.me(handler) + if method == "POST" and path == "/logout": + return make_message_response("ok") + if method == "POST" and path == "/password/email": + return make_message_response("ok") + if method == "GET" and path == "/1.0.0/games": + return self.get_games(handler) + if method == "POST" and path == "/1.0.0/games": + return self.create_game(handler, body) + if method == "POST" and path == "/1.0.0/devices": + return make_response({"message": "ok"}) + if method == "GET" and path == "/debug/games": + return make_response(list(self.storage.games.values())) + + player_match = re.fullmatch(r"/1\.0\.0/games/([^/]+)/players", path) + if player_match: + game_id = player_match.group(1) + if method == "PUT": + return self.join_game(handler, game_id, body) + if method == "DELETE": + return self.leave_game(handler, game_id, body) + + events_match = re.fullmatch(r"/1\.0\.0/games/([^/]+)/events", path) + if events_match: + game_id = events_match.group(1) + if method == "GET": + return self.get_events(game_id) + if method == "POST": + return self.save_events(game_id, body) + if method == "DELETE": + return self.delete_events(game_id) + + metadata_match = re.fullmatch(r"/1\.0\.0/games/([^/]+)/metadata/lastSeenRevision", path) + if metadata_match and method == "POST": + return self.update_last_seen(handler, metadata_match.group(1), body) + + events_meta_match = re.fullmatch(r"/1\.0\.0/games/([^/]+)/events-and-metadata", path) + if events_meta_match: + if method in {"POST", "PUT"}: + return self.save_events_and_metadata(events_meta_match.group(1), body) + if method == "GET": + return self.get_game_snapshot(events_meta_match.group(1)) + + return make_error("Not found", status=404) + + def login(self, body: Any) -> tuple[int, dict[str, Any]]: + email = str(body.get("email", "")).strip().lower() if isinstance(body, dict) else "" + password = str(body.get("password", "")).strip() if isinstance(body, dict) else "" + LOGGER.info("[LOGIN] attempt email=%s", email or "") + if not email or not password: + LOGGER.warning("[LOGIN] rejected missing credentials email=%s", email or "") + return make_error("Missing email or password", status=400) + + user = self.storage.find_user_by_email(email) + if not user: + LOGGER.warning("[LOGIN] user not found email=%s", email) + return make_error("User not found", status=404) + + token = str(uuid.uuid4()) + self.storage.tokens[token] = user["id"] + LOGGER.info("[LOGIN] success email=%s user_id=%s token=%s", email, user["id"], token) + return 200, {"access_token": token, "data": {"access_token": token, "user": make_user_response(user)[1]}} + + def register(self, body: Any) -> tuple[int, dict[str, Any]]: + email = str(body.get("email", "")).strip().lower() if isinstance(body, dict) else "" + password = str(body.get("password", "")).strip() if isinstance(body, dict) else "" + name = str(body.get("name", "")).strip() if isinstance(body, dict) else "" + LOGGER.info("[REGISTER] attempt email=%s name=%s", email or "", name or "") + if not email or not password: + LOGGER.warning("[REGISTER] rejected missing credentials email=%s", email or "") + return make_error("Missing email or password", status=400) + if not name: + LOGGER.warning("[REGISTER] rejected missing name email=%s", email or "") + return make_error("Missing name", status=400) + if self.storage.find_user_by_email(email): + LOGGER.warning("[REGISTER] duplicate email=%s", email) + return make_error("User already exists", status=409) + + user = { + "id": str(uuid.uuid4()), + "email": email, + "nickname": name, + "name": name, + "password": password, + "createdAt": now_iso(), + } + with self.storage.lock: + self.storage.users.append(user) + self.storage.save_users() + LOGGER.info("[REGISTER] success email=%s user_id=%s", email, user["id"]) + return 201, {"data": {"id": user["id"], "email": user["email"], "name": user["name"]}, "id": user["id"], "email": user["email"], "name": user["name"]} + + def me(self, handler: BaseHTTPRequestHandler) -> tuple[int, dict[str, Any]]: + user = self.current_user(handler) + if not user: + LOGGER.warning("[ME] unauthorized path=%s", handler.path) + return make_error("Unauthorized", status=401) + LOGGER.info("[ME] success user_id=%s email=%s", user["id"], user["email"]) + payload = {"id": user["id"], "email": user["email"], "name": user.get("name") or user.get("nickname") or user["email"]} + return 200, {"data": payload, **payload} + + def matches_filter(self, game: dict[str, Any], filters: list[str]) -> bool: + for expression in filters: + if ":" not in expression: + continue + parts = expression.split(":") + if len(parts) < 3: + continue + + field_expr = parts[0] + op = parts[1] + value = ":".join(parts[2:]) + + field_path = field_expr.split("->") + base = game + if field_path[0] == "metadata": + base = game.get("metadata", {}) + field_path = field_path[1:] + + actual = nested_get(base, field_path) if len(field_path) > 1 else base.get(field_path[0]) + actual_str = json.dumps(actual, ensure_ascii=False) if isinstance(actual, (dict, list)) else str(actual) + + if field_expr == "id" and op == "eq" and game.get("id") != value: + return False + if op == "ct" and value not in actual_str: + return False + if op == "nct" and value in actual_str: + return False + if op == "eq" and actual_str != value: + return False + if op == "ne" and actual_str == value: + return False + if op in {"gt", "gte", "lt", "lte"}: + actual_cmp = actual_str + value_cmp = value + if op == "gt" and not (actual_cmp > value_cmp): + return False + if op == "gte" and not (actual_cmp >= value_cmp): + return False + if op == "lt" and not (actual_cmp < value_cmp): + return False + if op == "lte" and not (actual_cmp <= value_cmp): + return False + return True + + def get_games(self, handler: BaseHTTPRequestHandler) -> tuple[int, dict[str, Any]]: + query = parse_qs(urlparse(handler.path).query) + filters = query.get("filter[]", []) + per_page = int(query.get("perPage", ["200"])[-1] or "200") + sort_items = query.get("sort[]", []) + + games = [self.repair_game_metadata_for_listing(game) for game in self.storage.games.values()] + filtered = [game for game in games if self.matches_filter(game, filters)] + for sort_expr in reversed(sort_items): + sort_parts = sort_expr.split(":") + if len(sort_parts) < 2: + continue + field_expr, direction = sort_parts[0], sort_parts[1].upper() + field_path = field_expr.split("->") + reverse = direction in {"DSC", "DESC"} + + def sort_value(game: dict[str, Any]) -> str: + base = game + local_path = list(field_path) + if local_path and local_path[0] == "metadata": + base = game.get("metadata", {}) + local_path = local_path[1:] + if not local_path: + return "" + if len(local_path) > 1: + return str(nested_get(base, local_path) or "") + return str(base.get(local_path[0], "")) + + filtered.sort( + key=sort_value, + reverse=reverse, + ) + + deduped: dict[str, dict[str, Any]] = {} + for game in filtered: + key = game_dedupe_key(game) or game.get("id", "") + existing = deduped.get(key) + if existing is None: + deduped[key] = game + continue + + existing_players = real_player_count(normalize_player_metadata_slots(existing.get("metadata", {}))) + current_players = real_player_count(normalize_player_metadata_slots(game.get("metadata", {}))) + existing_updated = str(existing.get("updated_at") or "") + current_updated = str(game.get("updated_at") or "") + + if current_players > existing_players or ( + current_players == existing_players and current_updated >= existing_updated + ): + deduped[key] = game + + deduped_games = list(deduped.values()) + result = deduped_games if per_page == 0 else deduped_games[:per_page] + response_items = [] + skipped_ids = [] + for game in result: + metadata = game.get("metadata", {}) + if not is_client_safe_game_metadata(metadata): + skipped_ids.append(game.get("id")) + continue + response_items.append( + { + "id": game["id"], + "metadata": metadata, + "created_at": game.get("created_at"), + "updated_at": game.get("updated_at"), + } + ) + LOGGER.info( + "[GAME] list filters=%s sort=%s per_page=%s matched=%s returned=%s", + filters, + sort_items, + per_page, + len(deduped_games), + len(response_items), + ) + if skipped_ids: + LOGGER.warning("[GAME] list skipped malformed game ids=%s", skipped_ids) + LOGGER.info( + "[GAME] list response preview=%s", + json.dumps(response_items[:2], ensure_ascii=False, default=str)[:2000], + ) + return make_response(response_items) + + def create_game(self, handler: BaseHTTPRequestHandler, body: Any) -> tuple[int, dict[str, Any]]: + user = self.current_user(handler) + if not user: + LOGGER.warning("[GAME] create unauthorized") + return make_error("Unauthorized", status=401) + payload = coerce_payload_dict(body) + if not payload: + LOGGER.warning("[GAME] create invalid payload user_id=%s", user["id"]) + return make_error("Invalid payload") + + LOGGER.info( + "[GAME] create payload user_id=%s keys=%s body=%s", + user["id"], + sorted(payload.keys()), + json.dumps(payload, ensure_ascii=False, default=str)[:1000], + ) + + game_id = str( + payload.get("GameId") + or payload.get("gameId") + or payload.get("id") + or payload.get("Id") + or "" + ).strip() + if not game_id: + game_id = str(uuid.uuid4()) + LOGGER.info("[GAME] create auto-generated game_id=%s user_id=%s", game_id, user["id"]) + + with self.storage.lock: + if game_id in self.storage.games: + LOGGER.warning("[GAME] create duplicate game_id=%s user_id=%s", game_id, user["id"]) + return make_error("Game already exists", status=409) + + cfg = payload.get("GameStartConfiguration") or payload.get("gameStartConfiguration") or {} + num_players = int( + cfg.get("NumberOfPlayersAsInt") + or cfg.get("numberOfPlayersAsInt") + or payload.get("NumberOfPlayersAsInt") + or payload.get("numberOfPlayersAsInt") + or payload.get("playersCount") + or 4 + ) + + player_entry = { + "PlayerNetworkId": user["id"], + "email": user["email"], + "name": user.get("name") or user.get("nickname"), + "nickname": user.get("nickname") or user.get("name"), + "createdAt": now_iso(), + "LastSeenEventRevision": 0, + } + + metadata = payload.get("metadata", payload) + if "PlayerMetadata" not in metadata: + metadata["PlayerMetadata"] = [player_entry] + if "NetworkGameProgressInformation" not in metadata: + metadata["NetworkGameProgressInformation"] = {"CurrentGameStatus": "Open"} + if "GameStartConfiguration" not in metadata: + metadata["GameStartConfiguration"] = { + "IsPrivate": bool(cfg.get("IsPrivate", cfg.get("isPrivate", False))), + "NumberOfPlayersAsInt": num_players, + } + sync_network_game_status(metadata) + + game = { + "id": game_id, + "metadata": metadata, + "events": [], + "created_at": now_iso(), + "updated_at": now_iso(), + } + + new_key = game_dedupe_key(game) + to_remove = [] + for existing_id, existing_game in self.storage.games.items(): + if existing_id == game_id: + continue + if game_dedupe_key(existing_game) == new_key: + to_remove.append(existing_id) + for existing_id in to_remove: + LOGGER.warning("[GAME] create removing stale duplicate game_id=%s", existing_id) + self.storage.games.pop(existing_id, None) + + self.storage.games[game_id] = game + self.storage.save_games() + LOGGER.info("[GAME] created game_id=%s owner_user_id=%s", game_id, user["id"]) + return 201, { + "id": game_id, + "data": { + "id": game_id, + "metadata": game.get("metadata", {}), + "created_at": game.get("created_at"), + "updated_at": game.get("updated_at"), + }, + } + + def join_game(self, handler: BaseHTTPRequestHandler, game_id: str, body: Any) -> tuple[int, dict[str, Any]]: + user = self.current_user(handler) + if not user: + LOGGER.warning("[GAME] join unauthorized game_id=%s", game_id) + return make_error("Unauthorized", status=401) + + with self.storage.lock: + game = self.storage.games.get(game_id) + if not game: + LOGGER.warning("[GAME] join missing game game_id=%s user_id=%s", game_id, user["id"]) + return make_error("Game not found", status=404) + + metadata = game.setdefault("metadata", {}) + players = metadata.setdefault("PlayerMetadata", []) + for existing in players: + if isinstance(existing, dict) and existing.get("PlayerNetworkId") == user["id"]: + LOGGER.info("[GAME] join skipped already joined game_id=%s user_id=%s", game_id, user["id"]) + return 200, { + "id": game_id, + "data": { + "id": game_id, + "metadata": game.get("metadata", {}), + "created_at": game.get("created_at"), + "updated_at": game.get("updated_at"), + }, + } + + payload = coerce_payload_dict(body) + join_payload = payload.get("metadata", payload) if payload else {} + if not join_payload.get("PlayerNetworkId"): + join_payload["PlayerNetworkId"] = user["id"] + if not join_payload.get("NickName"): + join_payload["NickName"] = user.get("name") or user.get("nickname") or user["email"] + if not join_payload.get("GameId"): + join_payload["GameId"] = game_id + if not join_payload.get("LastSeenEventRevision"): + join_payload["LastSeenEventRevision"] = 0 + + slot_index = -1 + for idx, existing in enumerate(players): + if existing is None: + slot_index = idx + break + if slot_index >= 0: + players[slot_index] = join_payload + else: + players.append(join_payload) + + sync_network_game_status(metadata) + game["updated_at"] = now_iso() + self.storage.save_games() + LOGGER.info( + "[GAME] joined game_id=%s user_id=%s status=%s", + game_id, + user["id"], + nested_get(metadata, ["NetworkGameProgressInformation", "CurrentGameStatus"]), + ) + return 200, { + "id": game_id, + "data": { + "id": game_id, + "metadata": game.get("metadata", {}), + "created_at": game.get("created_at"), + "updated_at": game.get("updated_at"), + }, + } + + def leave_game(self, handler: BaseHTTPRequestHandler, game_id: str, body: Any) -> tuple[int, dict[str, Any]]: + user = self.current_user(handler) + if not user: + LOGGER.warning("[GAME] leave unauthorized game_id=%s", game_id) + return make_error("Unauthorized", status=401) + + with self.storage.lock: + game = self.storage.games.get(game_id) + if not game: + LOGGER.warning("[GAME] leave missing game game_id=%s user_id=%s", game_id, user["id"]) + return make_error("Game not found", status=404) + + metadata = game.setdefault("metadata", {}) + players = metadata.get("PlayerMetadata", []) + if isinstance(players, dict) and "$values" in players: + players = players["$values"] + metadata["PlayerMetadata"] = players + + new_players = [] + for entry in players: + if not isinstance(entry, dict): + continue + if entry.get("PlayerNetworkId") == user["id"]: + continue + new_players.append(entry) + + metadata["PlayerMetadata"] = new_players + sync_network_game_status(metadata) + game["updated_at"] = now_iso() + self.storage.save_games() + LOGGER.info("[GAME] left game_id=%s user_id=%s", game_id, user["id"]) + return make_response(True) + + def get_events(self, game_id: str) -> tuple[int, dict[str, Any]]: + game = self.storage.games.get(game_id) + if not game: + return make_error("Game not found", status=404) + return make_response(game.get("events", [])) + + def get_game_snapshot(self, game_id: str) -> tuple[int, dict[str, Any]]: + game = self.storage.games.get(game_id) + if not game: + return make_error("Game not found", status=404) + return make_response( + { + "id": game_id, + "metadata": game.get("metadata", {}), + "events": game.get("events", []), + "created_at": game.get("created_at"), + "updated_at": game.get("updated_at"), + } + ) + + def save_events_and_metadata(self, game_id: str, body: Any) -> tuple[int, dict[str, Any]]: + game = self.storage.games.setdefault( + game_id, + {"id": game_id, "metadata": {}, "events": [], "created_at": now_iso(), "updated_at": now_iso()}, + ) + if isinstance(body, dict): + if "events" in body: + incoming_events = normalize_events_payload(body["events"]) + max_revision = max((event_revision(evt) for evt in game.get("events", [])), default=0) + for item in incoming_events: + if not isinstance(item, dict): + continue + event = dict(item) + if not event_revision(event): + max_revision += 1 + set_event_revision(event, max_revision) + else: + max_revision = max(max_revision, event_revision(event)) + game["events"].append(event) + if "metadata" in body and isinstance(body["metadata"], dict): + game["metadata"] = body["metadata"] + sync_network_game_status(game["metadata"]) + sync_progress_from_events(game) + game["updated_at"] = now_iso() + self.storage.save_games() + LOGGER.info( + "[EVENTS_META] saved game_id=%s event_count=%s current_player_id=%s status=%s", + game_id, + len(game.get("events", [])), + nested_get(game.get("metadata", {}), ["NetworkGameProgressInformation", "CurrentPlayerId"]), + nested_get(game.get("metadata", {}), ["NetworkGameProgressInformation", "CurrentGameStatus"]), + ) + return make_response(True) + + def save_events(self, game_id: str, body: Any) -> tuple[int, dict[str, Any]]: + if not isinstance(body, dict): + return make_error("Invalid payload") + game = self.storage.games.get(game_id) + if not game: + return make_error("Game not found", status=404) + + incoming = normalize_events_payload(body.get("events", body.get("data", body))) + if not isinstance(incoming, list): + return make_error("events must be a list") + + with self.storage.lock: + events = game.setdefault("events", []) + max_revision = max((event_revision(evt) for evt in events), default=0) + for item in incoming: + if not isinstance(item, dict): + continue + event = dict(item) + if not event_revision(event): + max_revision += 1 + set_event_revision(event, max_revision) + else: + max_revision = max(max_revision, event_revision(event)) + events.append(event) + + if event.get("$type") == "GameStarted": + game.setdefault("metadata", {}).setdefault( + "NetworkGameProgressInformation", {} + )["CurrentGameStatus"] = "Started" + + if "metadata" in body and isinstance(body["metadata"], dict): + game["metadata"] = body["metadata"] + sync_network_game_status(game["metadata"]) + + sync_progress_from_events(game) + game["updated_at"] = now_iso() + ensure_game_metadata_shape(game) + self.storage.save_games() + LOGGER.info( + "[EVENTS] saved game_id=%s count=%s max_revision=%s current_player_id=%s", + game_id, + len(incoming), + max_revision, + nested_get(game.get("metadata", {}), ["NetworkGameProgressInformation", "CurrentPlayerId"]), + ) + return make_response({"saved": len(incoming), "maxRevision": max_revision}) + + def delete_events(self, game_id: str) -> tuple[int, dict[str, Any]]: + with self.storage.lock: + game = self.storage.games.get(game_id) + if not game: + return make_error("Game not found", status=404) + game["events"] = [] + game["updated_at"] = now_iso() + self.storage.save_games() + return make_message_response("ok") + + def update_last_seen( + self, handler: BaseHTTPRequestHandler, game_id: str, body: Any + ) -> tuple[int, dict[str, Any]]: + user = self.current_user(handler) + if not user: + return make_error("Unauthorized", status=401) + + game = self.storage.games.get(game_id) + if not game: + return make_error("Game not found", status=404) + + revision = normalize_revision( + body.get("LastSeenEventRevision") + if isinstance(body, dict) + else 0 + ) + + with self.storage.lock: + metadata = game.setdefault("metadata", {}) + players = metadata.setdefault("PlayerMetadata", []) + for player in players: + if not isinstance(player, dict): + continue + if player.get("PlayerNetworkId") == user["id"]: + player["LastSeenEventRevision"] = revision + game["updated_at"] = now_iso() + self.storage.save_games() + return make_response(True) + + return make_error("Player not found in game", status=404) + + +class BrassRequestHandler(BaseHTTPRequestHandler): + api: BrassAPI + + def _send(self, status: int, payload: Any) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self) -> None: # noqa: N802 + self.handle_request() + + def do_POST(self) -> None: # noqa: N802 + self.handle_request() + + def do_PUT(self) -> None: # noqa: N802 + self.handle_request() + + def do_DELETE(self) -> None: # noqa: N802 + self.handle_request() + + def log_message(self, format: str, *args: Any) -> None: + return + + def handle_request(self) -> None: + try: + status, payload = self.api.route(self) + except Exception as exc: # noqa: BLE001 + LOGGER.exception("[REQ] unhandled error method=%s path=%s", self.command, self.path) + status, payload = make_error(f"Internal server error: {exc}", 500) + LOGGER.info("[RES] %s %s status=%s remote=%s", self.command, self.path, status, self.client_address[0]) + self._send(status, payload) + + +def build_server(host: str, port: int) -> ThreadingHTTPServer: + storage = Storage.load() + api = BrassAPI(storage) + + class Handler(BrassRequestHandler): + pass + + Handler.api = api + server = ThreadingHTTPServer((host, port), Handler) + return server + + +def main() -> int: + setup_logging() + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=SERVER_PORT) + args = parser.parse_args() + + storage = Storage.load() + print("=" * 60) + print(" Brass Birmingham Server") + print(f"Loaded: {len(storage.users)} users, {len(storage.games)} games") + print(f"Local: http://{get_local_ip()}:{args.port}") + print("=" * 60) + + server = build_server(args.host, args.port) + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/linux-native-backend-v1-usable/build-image.sh b/linux-native-backend-v1-usable/build-image.sh new file mode 100644 index 0000000..73b7108 --- /dev/null +++ b/linux-native-backend-v1-usable/build-image.sh @@ -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}" diff --git a/linux-native-backend-v1-usable/data/games.json b/linux-native-backend-v1-usable/data/games.json new file mode 100644 index 0000000..c713599 --- /dev/null +++ b/linux-native-backend-v1-usable/data/games.json @@ -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": {} + } + } +] \ No newline at end of file diff --git a/linux-native-backend-v1-usable/data/users.json b/linux-native-backend-v1-usable/data/users.json new file mode 100644 index 0000000..7904372 --- /dev/null +++ b/linux-native-backend-v1-usable/data/users.json @@ -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" + } +] \ No newline at end of file diff --git a/linux-native-backend-v1-usable/docker-compose.yml b/linux-native-backend-v1-usable/docker-compose.yml new file mode 100644 index 0000000..9f94a94 --- /dev/null +++ b/linux-native-backend-v1-usable/docker-compose.yml @@ -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 diff --git a/linux-native-backend-v1-usable/run.sh b/linux-native-backend-v1-usable/run.sh new file mode 100644 index 0000000..eed78f4 --- /dev/null +++ b/linux-native-backend-v1-usable/run.sh @@ -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" diff --git a/linux-native-backend-v1-usable/test_local.py b/linux-native-backend-v1-usable/test_local.py new file mode 100644 index 0000000..a1068da --- /dev/null +++ b/linux-native-backend-v1-usable/test_local.py @@ -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() diff --git a/linux-native-backend-v1-usable/test_turn_progression.py b/linux-native-backend-v1-usable/test_turn_progression.py new file mode 100644 index 0000000..43a1bb8 --- /dev/null +++ b/linux-native-backend-v1-usable/test_turn_progression.py @@ -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()