Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python. Each phase file is fully self-contained: lists Java source files to read, output files to create, implementation patterns, REST endpoint contracts, and a completion checklist. A future agent can execute any single phase without rescanning the Java project. Phases: - 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas - 02: Auth & Users — JWT middleware, RBAC, user CRUD - 03: CFG parser + server process — server.cfg round-trip, start/stop - 04: Server settings — general/network/logging/security/difficulty - 05: Mod management — mod CRUD, presets, settings, WebSocket progress - 06: Steam integration — SteamCMD queue, Workshop API, python-a2s - 07: Missions, CDLC, Discord, APScheduler jobs - 08: Middleware & polish — global exception handler, SPA redirect, structlog - 09: Testing — pytest-asyncio, respx, 80% coverage target
297 lines
12 KiB
Markdown
297 lines
12 KiB
Markdown
# Phase 6 — Steam Integration
|
|
|
|
**Status**: PENDING
|
|
**Depends on**: Phase 5 complete (ModService, ModInstallProgressHandler); Phase 3 complete (ProcessService)
|
|
**Next phase**: `phase-07-missions-cdlc-discord-jobs.md`
|
|
|
|
---
|
|
|
|
## Goal
|
|
|
|
SteamCMD task queue, Steam Web API client (httpx-based), workshop mod querying/installing, server game update scheduling, WebSocket progress broadcasting, player list via python-a2s, and the steam settings endpoint.
|
|
|
|
After this phase: workshop mod browsing, install, and Arma server update all work end-to-end.
|
|
|
|
---
|
|
|
|
## Java Source Files to Read
|
|
|
|
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
|
|
|
|
| File | What to extract |
|
|
|------|----------------|
|
|
| `<JAVA_SRC>domain/steam/SteamServiceImpl.java` | scheduleWorkshopModDownload(), scheduleArmaUpdate(), queryWorkshopMods(), getWorkshopMod(), getServerPlayers(), isServerRunning(), canUseWorkshop() |
|
|
| `<JAVA_SRC>domain/steam/SteamWebApiService.java` | queryWorkshopMods(), getWorkshopMod(), getWorkshopMods() — HTTP + caching |
|
|
| `<JAVA_SRC>domain/steam/SteamCmdHandler.java` | queueSteamTask(), @Scheduled consumer loop every 5s, retry logic |
|
|
| `<JAVA_SRC>domain/steam/SteamUtils.java` | ARMA_APP_ID = 107410 |
|
|
| `<JAVA_SRC>domain/steam/SteamArmaBranch.java` | Enum: PUBLIC, EXPERIMENTAL |
|
|
| `<JAVA_SRC>domain/steam/SteamTaskRetryPolicy.java` | Max retries, retryable exception check |
|
|
| `<JAVA_SRC>domain/steam/handler/WorkshopModDownloadTaskHandler.java` | steamcmd command construction + execution |
|
|
| `<JAVA_SRC>domain/steam/handler/WorkshopBatchModDownloadTaskHandler.java` | Batch download command |
|
|
| `<JAVA_SRC>domain/steam/handler/GameUpdateTaskHandler.java` | Server update command |
|
|
| `<JAVA_SRC>domain/steam/helper/SteamCmdModInstallHelper.java` | Post-install: move files, update DB, broadcast WS status |
|
|
| `<JAVA_SRC>domain/steam/model/WorkshopMod.java` | Fields: title, workshopFileId, previewUrl, children:[long] |
|
|
| `<JAVA_SRC>domain/steam/model/ArmaWorkshopQueryResponse.java` | Fields: nextCursor, mods |
|
|
| `<JAVA_SRC>domain/steam/model/WorkshopQueryParams.java` | Fields: cursor, searchText |
|
|
| `<JAVA_SRC>domain/steam/model/SteamTask.java` | Base + Type enum (WORKSHOP_MOD_INSTALL, GAME_UPDATE, WORKSHOP_BATCH_MOD_DOWNLOAD) |
|
|
| `<JAVA_SRC>domain/steam/model/QueuedSteamTask.java` | Fields: id(UUID), steamTask, attemptCount |
|
|
| `<JAVA_SRC>domain/steam/model/WorkshopModInstallSteamTask.java` | Fields: fileId, title, forced, issuer |
|
|
| `<JAVA_SRC>domain/steam/model/GameUpdateSteamTask.java` | Fields: issuer |
|
|
| `<JAVA_SRC>domain/steam/model/WorkshopBatchModDownloadTask.java` | Fields: Map<fileId, title>, forced, issuer |
|
|
| `<JAVA_SRC>domain/steam/model/SteamCmdWorkshopDownloadParameters.java` | CLI args list |
|
|
| `<JAVA_SRC>domain/steam/model/SteamCmdAppUpdateParameters.java` | CLI args list for game update |
|
|
| `<JAVA_SRC>domain/steam/model/ModDownloadResult.java` | Fields: success, modDirectory |
|
|
| `<JAVA_SRC>web/WorkshopRestController.java` | All workshop endpoints with exact JSON shapes |
|
|
| `<JAVA_SRC>web/SteamSettingsController.java` | GET/POST /api/v1/settings/steam |
|
|
|
|
---
|
|
|
|
## Output Files to Create
|
|
|
|
```
|
|
src/domain/steam/__init__.py
|
|
src/domain/steam/steam_service.py
|
|
src/domain/steam/steam_web_api_service.py
|
|
src/domain/steam/steam_cmd_handler.py
|
|
src/domain/steam/models.py (WorkshopMod, ArmaWorkshopQueryResponse,
|
|
WorkshopQueryParams, SteamTask + Type enum,
|
|
QueuedSteamTask, WorkshopModInstallSteamTask,
|
|
GameUpdateSteamTask, WorkshopBatchModDownloadTask)
|
|
src/web/schemas/steam.py
|
|
src/web/workshop_router.py
|
|
src/web/steam_settings_router.py
|
|
```
|
|
|
|
Update `src/main.py`: register `workshop_router`, `steam_settings_router`; in `lifespan` start `asyncio.create_task(run_task_loop())`.
|
|
|
|
---
|
|
|
|
## Implementation Notes
|
|
|
|
### ARMA_APP_ID
|
|
|
|
```python
|
|
ARMA_APP_ID = 107410
|
|
```
|
|
|
|
### Steam Web API — httpx replaces Java steam-web-api-client library
|
|
|
|
```python
|
|
# src/domain/steam/steam_web_api_service.py
|
|
import httpx
|
|
from cachetools import TTLCache
|
|
|
|
_query_cache: TTLCache = TTLCache(maxsize=100, ttl=300) # 5-min TTL
|
|
|
|
STEAM_API_BASE = "https://api.steampowered.com"
|
|
|
|
class SteamWebApiService:
|
|
def __init__(self, api_key: str):
|
|
self._api_key = api_key
|
|
self._client = httpx.AsyncClient(timeout=15.0)
|
|
|
|
async def query_workshop_mods(self, params: WorkshopQueryParams) -> ArmaWorkshopQueryResponse:
|
|
if not self._api_key:
|
|
raise MissingSteamApiKeyException()
|
|
cache_key = f"q:{params.cursor}:{params.search_text}"
|
|
if cache_key in _query_cache:
|
|
return _query_cache[cache_key]
|
|
resp = await self._client.post(
|
|
f"{STEAM_API_BASE}/IPublishedFileService/QueryFiles/v1/",
|
|
data={
|
|
"key": self._api_key,
|
|
"appid": ARMA_APP_ID,
|
|
"cursor": params.cursor or "*",
|
|
"numperpage": 10,
|
|
"search_text": params.search_text or "",
|
|
"return_previews": 1,
|
|
"return_children": 1,
|
|
"query_type": 3, # RANKED_BY_TREND
|
|
"filetype": 0,
|
|
}
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json().get("response", {})
|
|
result = ArmaWorkshopQueryResponse(
|
|
next_cursor=data.get("next_cursor"),
|
|
mods=[_convert_mod(m) for m in data.get("publishedfiledetails", [])]
|
|
)
|
|
_query_cache[cache_key] = result
|
|
return result
|
|
|
|
async def get_workshop_mod(self, mod_id: int) -> WorkshopMod | None:
|
|
# GET /IPublishedFileService/GetDetails/v1/?key=...&publishedfileids[0]=...
|
|
...
|
|
```
|
|
|
|
### SteamCMD task queue — asyncio background task
|
|
|
|
```python
|
|
# src/domain/steam/steam_cmd_handler.py
|
|
import asyncio, uuid
|
|
from collections import deque
|
|
|
|
_queue: deque[QueuedSteamTask] = deque()
|
|
_current: QueuedSteamTask | None = None
|
|
MAX_RETRIES = 3
|
|
|
|
def queue_task(task: SteamTask) -> uuid.UUID:
|
|
task_id = uuid.uuid4()
|
|
_queue.append(QueuedSteamTask(id=task_id, steam_task=task, attempt_count=0))
|
|
return task_id
|
|
|
|
def get_tasks_by_type(task_type: SteamTask.Type) -> list[SteamTask]:
|
|
tasks = [q.steam_task for q in _queue if q.steam_task.type == task_type]
|
|
if _current and _current.steam_task.type == task_type:
|
|
tasks.append(_current.steam_task)
|
|
return tasks
|
|
|
|
async def run_task_loop():
|
|
"""Registered with asyncio.create_task() in lifespan."""
|
|
global _current
|
|
while True:
|
|
await asyncio.sleep(5)
|
|
if not _queue:
|
|
continue
|
|
queued = _queue.popleft()
|
|
_current = queued
|
|
try:
|
|
await _dispatch(queued.steam_task)
|
|
except RetryableException:
|
|
if queued.attempt_count < MAX_RETRIES:
|
|
_queue.appendleft(QueuedSteamTask(
|
|
id=queued.id,
|
|
steam_task=queued.steam_task,
|
|
attempt_count=queued.attempt_count + 1,
|
|
))
|
|
except Exception as e:
|
|
log.error("Steam task failed permanently: %s", e)
|
|
finally:
|
|
_current = None
|
|
```
|
|
|
|
### SteamCMD command construction
|
|
|
|
```python
|
|
def build_workshop_download_command(
|
|
steamcmd_path: str, username: str, password: str,
|
|
workshop_content_path: str, file_id: int
|
|
) -> list[str]:
|
|
return [
|
|
steamcmd_path,
|
|
"+login", username, password,
|
|
"+force_install_dir", workshop_content_path,
|
|
"+workshop_download_item", str(ARMA_APP_ID), str(file_id),
|
|
"+quit",
|
|
]
|
|
|
|
def build_game_update_command(
|
|
steamcmd_path: str, username: str, password: str, install_dir: str
|
|
) -> list[str]:
|
|
return [
|
|
steamcmd_path,
|
|
"+login", username, password,
|
|
"+force_install_dir", install_dir,
|
|
"+app_update", str(ARMA_APP_ID), "validate",
|
|
"+quit",
|
|
]
|
|
```
|
|
|
|
Run with `asyncio.create_subprocess_exec()`. On success, the mod appears at:
|
|
`<workshop_content_path>/steamapps/workshop/content/107410/<fileId>/`
|
|
|
|
Post-install steps:
|
|
1. Move directory to `<server_dir>/mods/@<name>`
|
|
2. Normalize filenames to lowercase
|
|
3. Insert/update `InstalledModEntity` in DB
|
|
4. Broadcast `WorkshopModInstallationStatus` via `ModInstallProgressHandler.broadcast()`
|
|
|
|
### Player list — python-a2s
|
|
|
|
```python
|
|
import a2s
|
|
|
|
async def get_server_players() -> list[ArmaServerPlayer]:
|
|
try:
|
|
players = await asyncio.to_thread(
|
|
a2s.players, ("localhost", 2303), timeout=3.0
|
|
)
|
|
return [ArmaServerPlayer(name=p.name, score=p.score, duration=p.duration)
|
|
for p in players]
|
|
except Exception:
|
|
return []
|
|
```
|
|
|
|
### Steam settings persistence
|
|
|
|
After a POST to `/api/v1/settings/steam`, write back to `config/aswg_default_config.properties`:
|
|
|
|
```python
|
|
from jproperties import Properties
|
|
|
|
def save_settings_to_file():
|
|
p = Properties()
|
|
with open("config/aswg_default_config.properties", "rb") as f:
|
|
p.load(f)
|
|
p["aswg.steamcmd.path"] = settings.steamcmd_path
|
|
p["aswg.steamcmd.username"] = settings.steamcmd_username
|
|
# etc.
|
|
with open("config/aswg_default_config.properties", "wb") as f:
|
|
p.store(f)
|
|
```
|
|
|
|
### REST Endpoints
|
|
|
|
**Workshop** (`/api/v1/workshop`)
|
|
```
|
|
GET /api/v1/workshop/active → {active: bool} no auth required
|
|
POST /api/v1/workshop/query body:{cursor,searchText} WORKSHOP_INSTALL
|
|
→ {nextCursor, mods:[...]}
|
|
GET /api/v1/workshop/installed-items → {mods:[...], modsUnderInstallation:[...]} WORKSHOP_VIEW
|
|
GET /api/v1/workshop/download-queue → {downloadingMods:[{fileId,modName,installAttemptCount,issuer}]} WORKSHOP_VIEW
|
|
GET /api/v1/workshop/mod/{id}/dependencies → {modId, dependencies:[{modId,modName,status}]} WORKSHOP_VIEW
|
|
POST /api/v1/workshop/install body:{fileId,modName,installDependencies} WORKSHOP_INSTALL
|
|
→ {fileId: 123456} HTTP 200
|
|
```
|
|
|
|
**Steam Settings** (`/api/v1/settings/steam`)
|
|
```
|
|
GET /api/v1/settings/steam → {steamCmdPath,steamCmdUsername,steamCmdPassword:null,
|
|
steamCmdWorkshopContentPath,steamWebApiToken} STEAM_SETTINGS_UPDATE
|
|
POST /api/v1/settings/steam body: same shape → 200 STEAM_SETTINGS_UPDATE
|
|
POST /api/v1/settings/steam/password-change body:{password} → 200 STEAM_SETTINGS_UPDATE
|
|
```
|
|
|
|
Note: `steamCmdPassword` is always `null` in GET responses (never returned to client).
|
|
|
|
### canUseWorkshop()
|
|
|
|
```python
|
|
def can_use_workshop(self) -> bool:
|
|
path = settings.steamcmd_path
|
|
return bool(path) and Path(path).exists()
|
|
```
|
|
|
|
---
|
|
|
|
## Completion Checklist
|
|
|
|
- [ ] `GET /api/v1/workshop/active` returns `{active: bool}` based on steamcmd path existence
|
|
- [ ] `POST /api/v1/workshop/query` returns paginated workshop results with cursor
|
|
- [ ] Workshop query results cached 5 minutes (TTLCache)
|
|
- [ ] `GET /api/v1/workshop/installed-items` returns installed + in-progress lists
|
|
- [ ] `GET /api/v1/workshop/download-queue` returns tasks with fileId, modName, issuer
|
|
- [ ] `POST /api/v1/workshop/install` queues SteamCMD task, returns `{fileId}`
|
|
- [ ] SteamCMD task loop started in lifespan, polls every 5 seconds
|
|
- [ ] After download: mod moves to `server_dir/mods/`, DB record inserted, WS broadcast sent
|
|
- [ ] `GET /api/v1/settings/steam` never returns password field value
|
|
- [ ] `POST /api/v1/settings/steam` persists to properties file
|
|
- [ ] `python-a2s` player query works (returns empty list if server offline)
|
|
|
|
## Contract for Phase 7
|
|
|
|
Phase 7 imports:
|
|
- `from src.domain.steam.steam_service import SteamService`
|
|
- `from src.domain.steam.steam_cmd_handler import queue_task, run_task_loop, get_tasks_by_type`
|
|
- `from src.domain.steam.models import SteamTask, WorkshopModInstallSteamTask, GameUpdateSteamTask`
|