docs: rewrite PLAN.md and update CODEBASE.md, README.md
- Full rewrite of PLAN.md to reflect current architecture (27 REST + 3 WS endpoints, embedded frontend, automation, scheduler, health check, GoReleaser CI) - Added health.go, mods.go, scheduler.go, robfig/cron dep to CODEBASE.md - Added Gitea Actions CI/CD section to CODEBASE.md - Added conventional commits to code style section - Added missing API routes and embed/ to README.md
This commit is contained in:
+43
-12
@@ -4,20 +4,32 @@
|
||||
- **Language / runtime**: Go 1.25 (backend), TypeScript 6 + React 19 (frontend)
|
||||
- **Framework(s)**: Gin v1.12 (HTTP router), gorilla/websocket (WS), TanStack Query v5 (data fetching), Tailwind CSS 4 (styling), Vite 8 (bundler)
|
||||
- **Build system**: Go toolchain (`go build`), Vite/Rollup for frontend, multi-stage Docker build
|
||||
- **Test framework**: None detected
|
||||
- **Test framework**: Go `testing` + Vitest + React Testing Library
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — format: `type(scope): description`
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `perf`
|
||||
- Scopes: `backend`, `frontend`, `ci`, `docker`, or omit for project-wide changes
|
||||
- Examples: `feat(backend): add mod cleanup endpoint`, `fix(frontend): resolve race in modlist editor`, `docs: update PLAN.md to reflect current architecture`
|
||||
|
||||
## Directory map
|
||||
|
||||
```
|
||||
arma3-web-server/
|
||||
├── backend/ # Go backend
|
||||
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, wiring
|
||||
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks, wiring
|
||||
│ ├── embed/
|
||||
│ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary
|
||||
│ │ └── dist/ # Pre-built frontend SPA assets
|
||||
│ ├── internal/
|
||||
│ │ ├── api/ # Gin HTTP handlers + WebSocket endpoints
|
||||
│ │ │ ├── router.go # Route registration, Handler struct + New()
|
||||
│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd
|
||||
│ │ │ ├── configs.go # .cfg file CRUD handlers
|
||||
│ │ │ ├── modlists.go # Modlist CRUD + import + check/download-missing/update-all
|
||||
│ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all
|
||||
│ │ │ ├── mods.go # Mod listing, deletion, bulk cleanup
|
||||
│ │ │ ├── health.go # Comprehensive health check endpoint
|
||||
│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing
|
||||
│ │ ├── models/ # Data structs
|
||||
│ │ │ ├── settings.go # ServerSettings (singleton)
|
||||
@@ -26,9 +38,11 @@ arma3-web-server/
|
||||
│ │ ├── settings.go # JSON load/save from data/settings.json
|
||||
│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR
|
||||
│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json
|
||||
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser
|
||||
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer
|
||||
│ │ ├── mod_manager.go # Workshop + local mod discovery + usage map
|
||||
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver
|
||||
│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods)
|
||||
│ │ ├── scheduler.go # Cron-based scheduled updates
|
||||
│ │ └── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
|
||||
│ ├── go.mod / go.sum
|
||||
│ └── serverfiles/ # Default SERVERFILE_DIR (created at startup)
|
||||
@@ -41,12 +55,14 @@ arma3-web-server/
|
||||
│ │ ├── types/index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ServerPaths
|
||||
│ │ ├── pages/ # 7 route-level page components
|
||||
│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists)
|
||||
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD section
|
||||
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation
|
||||
│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs
|
||||
│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config
|
||||
│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import
|
||||
│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all
|
||||
│ │ │ └── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
|
||||
│ │ │ ├── Mods.tsx # Installed mods table + search + delete + cleanup
|
||||
│ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
|
||||
│ │ │ └── Status.tsx # Health check / deploy status dashboard
|
||||
│ │ └── components/
|
||||
│ │ ├── ConfigEditor.tsx # Monaco editor wrapper
|
||||
│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket
|
||||
@@ -58,7 +74,13 @@ arma3-web-server/
|
||||
├── data/ # Runtime data (mounted volume in Docker)
|
||||
│ ├── presets/ # (unused)
|
||||
│ └── servers/ # (unused)
|
||||
├── dev-deploy/ # Local development runtime data (git-ignored)
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yml # Build-only CI (Go + frontend)
|
||||
│ └── release.yml # GoReleaser-based release on tag push
|
||||
├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime
|
||||
├── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected)
|
||||
├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target)
|
||||
├── docker-compose.yml # Single service with volume mounts + env vars
|
||||
└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean
|
||||
```
|
||||
@@ -72,7 +94,7 @@ arma3-web-server/
|
||||
| `github.com/gin-gonic/gin` | framework | v1.12.0 | HTTP routing, middleware, request binding |
|
||||
| `github.com/gorilla/websocket` | library | v1.5.3 | WebSocket upgrade + message I/O |
|
||||
| `github.com/google/uuid` | utility | v1.6.0 | UUID generation for modlists |
|
||||
| `modernc.org/sqlite` | database | v1.53.0 | Declared but **not used** (file-based chosen) |
|
||||
| `github.com/robfig/cron/v3` | scheduler | v3.0.1 | Cron expression parsing and scheduled task execution |
|
||||
| `golang.org/x/net` | stdlib | v0.51.0 | HTML parser for modlist import |
|
||||
|
||||
### Frontend (npm)
|
||||
@@ -149,8 +171,17 @@ User clicks "Start" (Settings.tsx)
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
- **No CI pipeline** -- no `.github/` or `.gitlab-ci.yml` found
|
||||
- **No tests** -- zero test files anywhere in the codebase
|
||||
- **Docker build**: multi-stage Dockerfile at root -- builds Go binary, builds frontend dist, assembles Alpine image with steamcmd, exposes `:8080`, mounts `/data` and `/servers` volumes
|
||||
- **Deployment**: single docker-compose.yml with one service, volume mounts for persistence
|
||||
- **Local dev**: Makefile targets -- `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload
|
||||
|
||||
### Gitea Actions (`/.gitea/workflows/`)
|
||||
|
||||
- **`ci.yml`** — Triggered on push to any branch or PR. Runs `go build ./...` and `npm ci && npm run build`. Build-only; no tests.
|
||||
- **`release.yml`** — Triggered on `v*` tag push. Runs GoReleaser v2 with `release --clean`, which builds Go binaries (linux/amd64 + windows/amd64), builds frontend via `before.hooks`, creates archives, builds + pushes a Docker image using `Dockerfile.goreleaser`, and publishes a Gitea release.
|
||||
|
||||
### Docker
|
||||
|
||||
- **`Dockerfile`** — Multi-stage build: Go compile → npm build → Debian slim runtime + SteamCMD. Single self-contained image.
|
||||
- **`Dockerfile.goreleaser`** — Single-stage assembly-only Dockerfile for GoReleaser CI. Expects pre-built artifacts injected by GoReleaser.
|
||||
|
||||
### Local dev
|
||||
|
||||
- **Makefile targets** — `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload.
|
||||
|
||||
@@ -10,9 +10,11 @@ A web-based control panel to configure, update, install, and execute a **single*
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
|-------|--------|-----------|
|
||||
| **Backend** | Go 1.24+ (`gin`, `gorilla/websocket`) | Single binary, cross-compiles, goroutines for process streaming |
|
||||
| **Backend** | Go 1.25 (`gin`, `gorilla/websocket`) | Single binary, cross-compiles, goroutines for process streaming |
|
||||
| **Storage** | File-based (JSON on disk) | No database dependency; settings, configs, and modlists are files |
|
||||
| **Frontend** | React 19 + TypeScript + Vite | Fast iteration, rich ecosystem |
|
||||
| **Scheduling** | `robfig/cron/v3` | Cron-based scheduled updates (game + mods) |
|
||||
| **Frontend** | React 19 + TypeScript 6 + Vite 8 | Fast iteration, rich ecosystem |
|
||||
| **Data Fetching** | TanStack Query v5 | Server state, caching, polling, mutations |
|
||||
| **UI Kit** | Tailwind CSS 4 | Dark-theme UI, utility-first styling |
|
||||
| **Code Editor** | Monaco (VS Code) | Syntax-highlighted config editing |
|
||||
| **Terminal** | xterm.js | Live log display from server stdout |
|
||||
@@ -24,49 +26,72 @@ A web-based control panel to configure, update, install, and execute a **single*
|
||||
```
|
||||
arma3-web-server/
|
||||
├── backend/
|
||||
│ ├── cmd/server/main.go # Entry point
|
||||
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks
|
||||
│ ├── embed/
|
||||
│ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary
|
||||
│ │ └── dist/ # Pre-built frontend SPA assets
|
||||
│ ├── internal/
|
||||
│ │ ├── api/ # HTTP handlers (Gin routes)
|
||||
│ │ │ ├── router.go # Route registration
|
||||
│ │ │ ├── settings.go # Server settings + start/stop/restart
|
||||
│ │ │ ├── configs.go # .cfg file CRUD
|
||||
│ │ │ ├── modlists.go # Modlist CRUD
|
||||
│ │ │ └── logs.go # WebSocket handler for live logs
|
||||
│ │ │ ├── router.go # Route registration, Handler struct + New()
|
||||
│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd
|
||||
│ │ │ ├── configs.go # .cfg file CRUD handlers
|
||||
│ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all
|
||||
│ │ │ ├── mods.go # Mod listing, deletion, bulk cleanup
|
||||
│ │ │ ├── health.go # Comprehensive health check endpoint
|
||||
│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing
|
||||
│ │ ├── models/ # Data structs
|
||||
│ │ │ ├── settings.go # ServerSettings (singleton)
|
||||
│ │ │ └── modlist.go # Modlist + ModEntry
|
||||
│ │ └── services/ # Business logic
|
||||
│ │ ├── settings.go # Singleton settings load/save
|
||||
│ │ │ ├── settings.go # ServerSettings (singleton, 15 fields)
|
||||
│ │ │ └── modlist.go # Modlist + ModEntry + ModlistListItem
|
||||
│ │ └── services/ # Business logic layer
|
||||
│ │ ├── settings.go # JSON load/save from data/settings.json
|
||||
│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR
|
||||
│ │ ├── modlist_manager.go # Modlist CRUD (JSON files)
|
||||
│ │ ├── server_process.go # Process lifecycle (start/stop/restart)
|
||||
│ │ └── log_streamer.go # stdout/stderr → WebSocket fan-out
|
||||
│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json
|
||||
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer
|
||||
│ │ ├── mod_manager.go # Workshop + local mod discovery + usage map
|
||||
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver
|
||||
│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods)
|
||||
│ │ ├── scheduler.go # Cron-based scheduled updates
|
||||
│ │ └── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
|
||||
│ ├── go.mod
|
||||
│ └── go.sum
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Router setup (React Router v7)
|
||||
│ │ ├── main.tsx # Entry point (QueryClient + BrowserRouter)
|
||||
│ │ ├── index.css # Tailwind v4 imports
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.ts # Typed fetch wrappers + WS URL builders
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ModInfo, ServerHealth
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Dashboard.tsx
|
||||
│ │ │ ├── Settings.tsx
|
||||
│ │ │ ├── Configs.tsx
|
||||
│ │ │ ├── ConfigEditor.tsx
|
||||
│ │ │ ├── Modlists.tsx
|
||||
│ │ │ ├── ModlistEditor.tsx
|
||||
│ │ │ └── Logs.tsx
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ConfigEditor.tsx # Monaco wrapper
|
||||
│ │ │ ├── LiveTerminal.tsx # xterm.js wrapper
|
||||
│ │ │ └── Layout.tsx # Sidebar nav shell
|
||||
│ │ ├── api/client.ts # Typed fetch wrapper
|
||||
│ │ └── types/index.ts # TypeScript types
|
||||
│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists)
|
||||
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation
|
||||
│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs
|
||||
│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config
|
||||
│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import
|
||||
│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all
|
||||
│ │ │ ├── Mods.tsx # Installed mods table + search + delete + cleanup
|
||||
│ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
|
||||
│ │ │ └── Status.tsx # Health check / deploy status dashboard
|
||||
│ │ └── components/
|
||||
│ │ ├── ConfigEditor.tsx # Monaco editor wrapper
|
||||
│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket
|
||||
│ │ ├── Layout.tsx # Sidebar nav shell
|
||||
│ │ └── ui/ # (empty — reserved for future shared UI primitives)
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── data/ # Runtime data
|
||||
│ ├── settings.json # Singleton server settings
|
||||
│ └── modlists/ # Modlist JSON files (*.json)
|
||||
│ └── vite.config.ts # Proxy /api + /ws to :8080
|
||||
├── data/ # Runtime data (mounted volume in Docker)
|
||||
│ ├── settings.json # Singleton server settings
|
||||
│ └── modlists/ # Modlist JSON files (*.json)
|
||||
├── dev-deploy/ # Local development runtime data (git-ignored)
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yml # Build-only CI (Go + frontend)
|
||||
│ └── release.yml # GoReleaser-based release on tag push
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── Makefile
|
||||
├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime + SteamCMD
|
||||
├── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected)
|
||||
├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target)
|
||||
└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean
|
||||
```
|
||||
|
||||
---
|
||||
@@ -75,13 +100,14 @@ arma3-web-server/
|
||||
|
||||
| Env | Purpose | Default |
|
||||
|-----|---------|---------|
|
||||
| `SERVERFILE_DIR` | Base dir where `arma3server_x64` binary + `userconfig/` live | `""` (must be set) |
|
||||
| `MODS_DIR` | Base dir where `@modname` folders are stored | `""` (must be set) |
|
||||
| `CFG_DIR` | Base dir where `.cfg` config files are stored | `""` (must be set) |
|
||||
| `PROFILES_DIR` | Server profile/save path (ignored in UI) | `$SERVERFILE_DIR/Profiles` |
|
||||
| `SERVERFILE_DIR` | Base dir where `arma3server_x64` binary + `userconfig/` live | `./serverfiles` |
|
||||
| `MODS_DIR` | Base dir where `@modname` folders are stored | `$SERVERFILE_DIR/mods` |
|
||||
| `CFG_DIR` | Base dir where `.cfg` config files are stored | `$SERVERFILE_DIR/cfg` |
|
||||
| `PROFILES_DIR` | Server profile/save/log path | `$SERVERFILE_DIR/profiles` |
|
||||
| `DATA_DIR` | Internal data (settings.json, modlists/) | `./data` |
|
||||
| `LISTEN` | HTTP listen address | `:8080` |
|
||||
| `FRONTEND_DIR` | Path to built frontend files | `../frontend/dist` |
|
||||
|
||||
> Note: `FRONTEND_DIR` was removed — the frontend is now embedded into the Go binary via `//go:embed`.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,6 +127,11 @@ arma3-web-server/
|
||||
"difficulty_presets": "",
|
||||
"active_config": "server",
|
||||
"active_modlist": "uuid-of-modlist",
|
||||
"auto_update_on_startup": false,
|
||||
"auto_start_on_startup": false,
|
||||
"auto_update_mods_on_startup": false,
|
||||
"was_running": false,
|
||||
"scheduled_update": "",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
@@ -151,11 +182,19 @@ POST /api/server/start
|
||||
POST /api/server/stop
|
||||
POST /api/server/restart
|
||||
GET /api/server/status
|
||||
GET /api/server/paths # returns configured directory paths
|
||||
GET /api/server/health # comprehensive health check (binary, paths, disk, mods, steamcmd, frontend)
|
||||
|
||||
# SteamCMD
|
||||
GET /api/server/steamcmd # returns current branch/user/platform
|
||||
POST /api/server/steamcmd/update-game
|
||||
POST /api/server/steamcmd/download-mod
|
||||
GET /api/server/steamcmd/status
|
||||
|
||||
# Logs
|
||||
GET /api/server/logs # list log files (.log, .rpt)
|
||||
GET /api/server/logs/:file # read log file content
|
||||
|
||||
# Configs (.cfg files in $CFG_DIR)
|
||||
GET /api/configs
|
||||
POST /api/configs
|
||||
@@ -172,17 +211,24 @@ PUT /api/modlists/:id
|
||||
DELETE /api/modlists/:id
|
||||
POST /api/modlists/:id/duplicate
|
||||
POST /api/modlists/import (multipart form: file=*.html — parses Arma Launcher HTML preset)
|
||||
GET /api/modlists/:id/export (returns downloadable Arma Launcher HTML preset)
|
||||
GET /api/modlists/:id/check (returns mod entries with downloaded: bool)
|
||||
POST /api/modlists/:id/download-missing
|
||||
POST /api/modlists/:id/update-all
|
||||
|
||||
# Logs
|
||||
GET /ws/server/logs (WebSocket — live server stream)
|
||||
GET /ws/steamcmd/logs (WebSocket — live steamcmd stream)
|
||||
GET /api/server/logs (list log files)
|
||||
GET /api/server/logs/:file (read log file)
|
||||
# Mods (installed on disk)
|
||||
GET /api/mods # combined workshop + local mods with usage info
|
||||
DELETE /api/mods # delete mod by path (validates path prefix)
|
||||
POST /api/mods/cleanup # bulk-delete all mods not referenced by any modlist
|
||||
|
||||
# WebSocket Endpoints
|
||||
GET /ws/server/logs # live server process stdout/stderr
|
||||
GET /ws/steamcmd/logs # live steamcmd output
|
||||
GET /ws/server/rpt # tail latest .rpt crash dump (250ms polling)
|
||||
```
|
||||
|
||||
**Total: 27 REST endpoints + 3 WebSocket endpoints**
|
||||
|
||||
---
|
||||
|
||||
## Process Start Flow
|
||||
@@ -190,24 +236,53 @@ GET /api/server/logs/:file (read log file)
|
||||
```
|
||||
User clicks Start
|
||||
→ Settings loaded from data/settings.json
|
||||
→ Args built from server_parameters + -config=<active_config>.cfg
|
||||
→ -profiles=$PROFILES_DIR auto-appended (unless already in server_parameters)
|
||||
→ -port= derived from ip_port field (unless -port= already in server_parameters)
|
||||
→ Mod path built from active modlist (enabled mods → @modname paths)
|
||||
→ arma3server_x64 spawned via exec.CommandContext
|
||||
→ WasRunning set to true, saved to disk
|
||||
→ If ServerParameters contains %command%:
|
||||
→ Replace %command% with binary path (enables Wine wrappers)
|
||||
→ Split into command + args
|
||||
→ Append auto-args: -config=, -mod=, -profiles=, -port=
|
||||
→ exec.CommandContext(ctx, parts[0], parts[1:]..., autoArgs...)
|
||||
Else:
|
||||
→ Split server_parameters into args
|
||||
→ Append auto-args
|
||||
→ exec.CommandContext(ctx, binPath, args...)
|
||||
→ cmd.Dir = serverfileDir
|
||||
→ stdout/stderr piped to LogStreamer fan-out
|
||||
→ cmd.Start()
|
||||
→ WebSocket clients receive live output
|
||||
→ On exit, process reference cleaned up
|
||||
→ On exit, process reference cleaned up, [SERVER_PROCESS_EXITED] broadcast
|
||||
→ Frontend polls GET /api/server/status every 3-5s → shows "Running" badge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Startup Auto-Tasks
|
||||
|
||||
Configured via the Automation section of the Settings UI. All run asynchronously at boot:
|
||||
|
||||
| Setting | Behavior |
|
||||
|---------|----------|
|
||||
| **Auto-update server on startup** | Runs `steamcmd +app_update` if `steam_user` is set |
|
||||
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist |
|
||||
| **Auto-start server on startup** | Restarts the game server if `was_running` was true when the service last stopped |
|
||||
|
||||
### Scheduled Updates
|
||||
|
||||
A cron expression in the `scheduled_update` field runs game + mod updates on a schedule (e.g. `"0 4 * * *"` for daily at 4 AM). Uses `robfig/cron/v3`. The scheduled update runs even if the game server is currently running.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **File-based storage** — No database. Settings, configs, and modlists are files on disk. Portable, backup-able with simple file copy.
|
||||
2. **Env-defined paths** — `SERVERFILE_DIR`, `MODS_DIR`, `CFG_DIR`, `PROFILES_DIR` are environment variables, not stored in the UI.
|
||||
3. **Configs are real .cfg files** — Stored in `$CFG_DIR`, directly usable by the Arma 3 server's `-config=` parameter.
|
||||
4. **Modlist = mod references + enabled state** — The `-mod=` parameter is built at start time by resolving enabled mods. For each mod, `$MODS_DIR/@name` is tried first (manually placed or symlinked); if not found and a Steam Workshop `id` exists, `$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>` is used as fallback.
|
||||
5. **Single server instance** — Only one server at a time. No multi-instance support.
|
||||
6. **No auth for v1** — JWT auth can be added later without breaking the API design.
|
||||
7. **Docker-first deployment** — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving.
|
||||
3. **Embedded frontend** — The built React SPA is embedded into the Go binary via `//go:embed`, creating a single self-contained deployment artifact. The `NoRoute` handler serves `index.html` for client-side routing.
|
||||
4. **Configs are real .cfg files** — Stored in `$CFG_DIR`, directly usable by the Arma 3 server's `-config=` parameter.
|
||||
5. **Modlist = mod references + enabled state** — The `-mod=` parameter is built at start time by resolving enabled mods. Two-tier resolution: `$MODS_DIR/@name` first (manually placed or symlinked); fallback to `$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>` for workshop downloads.
|
||||
6. **`%command%` substitution** — If `ServerParameters` contains `%command%`, the entire textarea is treated as a shell template with `%command%` replaced by the binary path and auto-args appended. Enables Wine wrappers.
|
||||
7. **Single server instance** — Only one server at a time. No multi-instance support.
|
||||
8. **No auth for v1** — JWT auth can be added later without breaking the API design.
|
||||
9. **Docker-first deployment** — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving. Also supports GoReleaser for automated releases to Gitea.
|
||||
10. **Crash recovery** — `WasRunning` flag persists across restarts. If `auto_start_on_startup` is enabled, the server restarts automatically.
|
||||
11. **SteamCMD async with live streaming** — All steamcmd operations run in background goroutines, output broadcast to `"steamcmd"` key via LogStreamer pub/sub.
|
||||
12. **Health endpoint** — `/api/server/health` checks binary existence, directory writability, disk usage, mod counts, SteamCMD status, and frontend serving.
|
||||
|
||||
@@ -117,11 +117,16 @@ REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:
|
||||
| `GET/POST/PUT/DELETE` | `/api/configs` | CRUD for `.cfg` files |
|
||||
| `GET/POST/PUT/DELETE` | `/api/modlists` | CRUD for modlists |
|
||||
| `POST` | `/api/modlists/import` | Import HTML workshop list |
|
||||
| `GET` | `/api/modlists/:id/export` | Export to downloadable HTML preset |
|
||||
| `GET` | `/api/modlists/:id/check` | Check mod download status |
|
||||
| `POST` | `/api/modlists/:id/download-missing\|update-all` | Bulk workshop operations |
|
||||
| `POST` | `/api/server/steamcmd/update-game\|download-mod` | SteamCMD operations |
|
||||
| `GET` | `/api/mods` | List installed mods (workshop + local) with usage info |
|
||||
| `DELETE` | `/api/mods` | Delete a mod by path |
|
||||
| `POST` | `/api/mods/cleanup` | Bulk-delete all orphaned mods |
|
||||
| `GET` | `/api/server/logs` | List log files |
|
||||
| `GET` | `/api/server/paths` | Show server file paths |
|
||||
| `GET` | `/api/server/health` | Comprehensive health check |
|
||||
| `WS` | `/ws/server/logs\|rpt\|steamcmd/logs` | Live log streaming |
|
||||
|
||||
## Project Structure
|
||||
@@ -129,6 +134,7 @@ REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:
|
||||
```
|
||||
backend/
|
||||
cmd/server/ Entry point
|
||||
embed/ Embedded frontend assets (//go:embed)
|
||||
internal/
|
||||
api/ HTTP handlers and routes
|
||||
models/ Data types
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigManager_ListEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
configs, err := cm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(configs) != 0 {
|
||||
t.Errorf("List() returned %d configs, want 0", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
if err := cm.Create("server", "host = 0.0.0.0"); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
content, err := cm.Get("server")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if content != "host = 0.0.0.0" {
|
||||
t.Errorf("Get() = %q, want %q", content, "host = 0.0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateDuplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "content")
|
||||
err := cm.Create("server", "content2")
|
||||
if err == nil {
|
||||
t.Error("Create() should fail for existing config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Update(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "old")
|
||||
if err := cm.Update("server", "new"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
content, _ := cm.Get("server")
|
||||
if content != "new" {
|
||||
t.Errorf("Get() after Update() = %q, want %q", content, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Delete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "content")
|
||||
if err := cm.Delete("server"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cm.Get("server")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail after Delete()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Duplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "host = 0.0.0.0")
|
||||
if err := cm.Duplicate("server", "backup"); err != nil {
|
||||
t.Fatalf("Duplicate() error = %v", err)
|
||||
}
|
||||
|
||||
content, _ := cm.Get("backup")
|
||||
if content != "host = 0.0.0.0" {
|
||||
t.Errorf("Get(backup) = %q, want %q", content, "host = 0.0.0.0")
|
||||
}
|
||||
|
||||
configs, _ := cm.List()
|
||||
if len(configs) != 2 {
|
||||
t.Errorf("List() returned %d configs, want 2", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_ListWithMultipleFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "s")
|
||||
cm.Create("difficulty", "d")
|
||||
os.WriteFile(filepath.Join(dir, "notacfg.txt"), []byte("txt"), 0644)
|
||||
|
||||
configs, err := cm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(configs) != 2 {
|
||||
t.Errorf("List() returned %d configs, want 2 (should exclude non-.cfg)", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_GetNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
_, err := cm.Get("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail for nonexistent config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateWithPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
// Attempt to create with path traversal
|
||||
err := cm.Create("../../etc/passwd", "content")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it was sanitized to just "passwd.cfg" in the cfgDir
|
||||
path := filepath.Join(dir, "passwd.cfg")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("File should be created with sanitized name in cfgDir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeConfigName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"server", "server"},
|
||||
{"../etc/passwd", "passwd"},
|
||||
{"../../secret.cfg", "secret"},
|
||||
{"server.cfg", "server"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := sanitizeConfigName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeConfigName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLogStreamer_SubscribeUnsubscribe(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
if ch == nil {
|
||||
t.Fatal("Subscribe() returned nil channel")
|
||||
}
|
||||
|
||||
ls.Unsubscribe("server", "client1")
|
||||
|
||||
// Channel should be closed after unsubscribe
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if ok {
|
||||
t.Error("channel should be closed after unsubscribe")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("channel not closed within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_Broadcast(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch1 := ls.Subscribe("server", "client1")
|
||||
ch2 := ls.Subscribe("server", "client2")
|
||||
|
||||
ls.Broadcast("server", "hello")
|
||||
|
||||
for _, ch := range []chan string{ch1, ch2} {
|
||||
select {
|
||||
case line := <-ch:
|
||||
if line != "hello" {
|
||||
t.Errorf("received %q, want %q", line, "hello")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout waiting for broadcast")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_BroadcastNonBlocking(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
|
||||
// Fill the channel (capacity 256)
|
||||
for i := 0; i < 256; i++ {
|
||||
ls.Broadcast("server", "line")
|
||||
}
|
||||
|
||||
// This should not block even though channel is full
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ls.Broadcast("server", "dropped")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good, broadcast didn't block
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("broadcast blocked on full channel")
|
||||
}
|
||||
|
||||
// Drain all queued messages
|
||||
for i := 0; i < 256; i++ {
|
||||
<-ch
|
||||
}
|
||||
|
||||
// Now the next broadcast should succeed
|
||||
ls.Broadcast("server", "after")
|
||||
select {
|
||||
case line := <-ch:
|
||||
if line != "after" {
|
||||
t.Errorf("received %q, want %q", line, "after")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout waiting for broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_Stream(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
|
||||
reader := strings.NewReader("line1\nline2\nline3\n")
|
||||
go ls.Stream("server", reader, "DONE")
|
||||
|
||||
lines := []string{}
|
||||
for i := 0; i < 4; i++ { // 3 lines + DONE close message
|
||||
select {
|
||||
case line := <-ch:
|
||||
lines = append(lines, line)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatalf("timeout waiting for line %d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" || lines[3] != "DONE" {
|
||||
t.Errorf("lines = %v, want [line1 line2 line3 DONE]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_UnsubscribeCleansUpEmptyServer(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ls.Subscribe("server", "client1")
|
||||
ls.Unsubscribe("server", "client1")
|
||||
|
||||
// After unsubscribing the last client, the server entry should be cleaned up
|
||||
ls.mu.RLock()
|
||||
_, exists := ls.subs["server"]
|
||||
ls.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
t.Error("empty server entry should be cleaned up after last unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_MultipleServers(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
chServer := ls.Subscribe("server", "client1")
|
||||
chSteamcmd := ls.Subscribe("steamcmd", "client1")
|
||||
|
||||
ls.Broadcast("server", "server msg")
|
||||
ls.Broadcast("steamcmd", "steamcmd msg")
|
||||
|
||||
select {
|
||||
case line := <-chServer:
|
||||
if line != "server msg" {
|
||||
t.Errorf("server channel received %q, want %q", line, "server msg")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout on server channel")
|
||||
}
|
||||
|
||||
// SteamCMD channel should not have received the server message
|
||||
select {
|
||||
case line := <-chSteamcmd:
|
||||
if line != "steamcmd msg" {
|
||||
t.Errorf("steamcmd channel received %q, want %q", line, "steamcmd msg")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout on steamcmd channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_ConcurrentSubscribeUnsubscribe(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
clientID := "client"
|
||||
ch := ls.Subscribe("server", clientID)
|
||||
_ = ch
|
||||
ls.Unsubscribe("server", clientID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
)
|
||||
|
||||
func TestModlistManager_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, err := mm.Create("My Modlist")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if ml.Name != "My Modlist" {
|
||||
t.Errorf("Name = %q, want %q", ml.Name, "My Modlist")
|
||||
}
|
||||
if ml.ID == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
if len(ml.Mods) != 0 {
|
||||
t.Errorf("Mods should be empty, got %d", len(ml.Mods))
|
||||
}
|
||||
|
||||
got, err := mm.Get(ml.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got.Name != "My Modlist" {
|
||||
t.Errorf("Get().Name = %q, want %q", got.Name, "My Modlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_List(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
mm.Create("List A")
|
||||
mm.Create("List B")
|
||||
|
||||
items, err := mm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("List() returned %d items, want 2", len(items))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, item := range items {
|
||||
names[item.Name] = true
|
||||
}
|
||||
if !names["List A"] || !names["List B"] {
|
||||
t.Errorf("List() missing expected names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Update(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, _ := mm.Create("Original")
|
||||
mods := []models.ModEntry{
|
||||
{ID: "123", Name: "CBA_A3", Enabled: true},
|
||||
{ID: "456", Name: "ACE", Enabled: false},
|
||||
}
|
||||
|
||||
updated, err := mm.Update(ml.ID, "Updated", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if updated.Name != "Updated" {
|
||||
t.Errorf("Name = %q, want %q", updated.Name, "Updated")
|
||||
}
|
||||
if len(updated.Mods) != 2 {
|
||||
t.Errorf("Mods = %d, want 2", len(updated.Mods))
|
||||
}
|
||||
|
||||
got, _ := mm.Get(ml.ID)
|
||||
if got.Name != "Updated" {
|
||||
t.Errorf("Get().Name = %q, want %q", got.Name, "Updated")
|
||||
}
|
||||
if got.Mods[0].ID != "123" {
|
||||
t.Errorf("Get().Mods[0].ID = %q, want %q", got.Mods[0].ID, "123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Delete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, _ := mm.Create("To Delete")
|
||||
if err := mm.Delete(ml.ID); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := mm.Get(ml.ID)
|
||||
if err == nil {
|
||||
t.Error("Get() should fail after Delete()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Duplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
orig, _ := mm.Create("Original")
|
||||
mm.Update(orig.ID, "Original", []models.ModEntry{
|
||||
{ID: "123", Name: "CBA_A3", Enabled: true},
|
||||
})
|
||||
|
||||
dup, err := mm.Duplicate(orig.ID, "Copy")
|
||||
if err != nil {
|
||||
t.Fatalf("Duplicate() error = %v", err)
|
||||
}
|
||||
if dup.Name != "Copy" {
|
||||
t.Errorf("Name = %q, want %q", dup.Name, "Copy")
|
||||
}
|
||||
if dup.ID == orig.ID {
|
||||
t.Error("Duplicate should have different ID")
|
||||
}
|
||||
if len(dup.Mods) != 1 {
|
||||
t.Errorf("Mods = %d, want 1", len(dup.Mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_GetNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
_, err := mm.Get("nonexistent-uuid")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail for nonexistent modlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_DeleteNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
err := mm.Delete("nonexistent-uuid")
|
||||
if err == nil {
|
||||
t.Error("Delete() should fail for nonexistent modlist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
)
|
||||
|
||||
func TestParseModlistHTML_SingleMod(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span class="from-steam">Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 1 {
|
||||
t.Fatalf("got %d mods, want 1", len(mods))
|
||||
}
|
||||
if mods[0].ID != "450814997" {
|
||||
t.Errorf("mods[0].ID = %q, want %q", mods[0].ID, "450814997")
|
||||
}
|
||||
if mods[0].Name != "CBA_A3" {
|
||||
t.Errorf("mods[0].Name = %q, want %q", mods[0].Name, "CBA_A3")
|
||||
}
|
||||
if !mods[0].Enabled {
|
||||
t.Error("mods[0].Enabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_MultipleMods(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ACE3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 2 {
|
||||
t.Fatalf("got %d mods, want 2", len(mods))
|
||||
}
|
||||
if mods[0].Name != "CBA_A3" || mods[1].Name != "ACE3" {
|
||||
t.Errorf("mods = [%q, %q], want [CBA_A3, ACE3]", mods[0].Name, mods[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_Deduplication(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, _ := ParseModlistHTML(strings.NewReader(html))
|
||||
if len(mods) != 1 {
|
||||
t.Errorf("got %d mods, want 1 (deduplication)", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_EmptyTable(t *testing.T) {
|
||||
html := `<html><body><table></table></body></html>`
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 0 {
|
||||
t.Errorf("got %d mods, want 0", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_InvalidHTML(t *testing.T) {
|
||||
// Go's html parser is lenient and returns empty results for garbage input
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader("not html at all <>"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() should not error on lenient parser, got: %v", err)
|
||||
}
|
||||
if len(mods) != 0 {
|
||||
t.Errorf("ParseModlistHTML() returned %d mods for garbage input, want 0", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderModlistHTML(t *testing.T) {
|
||||
mods := []models.ModEntry{
|
||||
{ID: "450814997", Name: "CBA_A3", Enabled: true},
|
||||
{ID: "463939057", Name: "ACE3", Enabled: true},
|
||||
}
|
||||
|
||||
output, err := RenderModlistHTML("Test", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderModlistHTML() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "450814997") {
|
||||
t.Error("output should contain mod ID 450814997")
|
||||
}
|
||||
if !strings.Contains(output, "CBA_A3") {
|
||||
t.Error("output should contain mod name CBA_A3")
|
||||
}
|
||||
if !strings.Contains(output, "463939057") {
|
||||
t.Error("output should contain mod ID 463939057")
|
||||
}
|
||||
if !strings.Contains(output, "<?xml") {
|
||||
t.Error("output should start with XML declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderModlistHTML_SkipsEmptyIDs(t *testing.T) {
|
||||
mods := []models.ModEntry{
|
||||
{ID: "", Name: "No ID", Enabled: true},
|
||||
{ID: "450814997", Name: "CBA_A3", Enabled: true},
|
||||
}
|
||||
|
||||
output, err := RenderModlistHTML("Test", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderModlistHTML() error = %v", err)
|
||||
}
|
||||
if strings.Contains(output, "No ID") {
|
||||
t.Error("output should not contain mod with empty ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNameToModlistName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"my_modlist.html", "my modlist"},
|
||||
{"LibUkraine Final (3).html", "LibUkraine Final (3)"},
|
||||
{"test-file-name.html", "test file name"},
|
||||
{"simple.html", "simple"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := FileNameToModlistName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FileNameToModlistName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanModName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"CBA_A3", "CBA_A3"},
|
||||
{"mod/name", "mod_name"},
|
||||
{"mod\\name", "mod_name"},
|
||||
{" spaces ", "spaces"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := cleanModName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("cleanModName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSettingsManager_LoadDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if s.IPPort != "0.0.0.0:2302" {
|
||||
t.Errorf("IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
|
||||
}
|
||||
if s.ServerParameters != "-server -world=empty -loadMissionToMemory -noPause" {
|
||||
t.Errorf("ServerParameters = %q", s.ServerParameters)
|
||||
}
|
||||
if s.SteamBranch != "stable" {
|
||||
t.Errorf("SteamBranch = %q, want %q", s.SteamBranch, "stable")
|
||||
}
|
||||
if s.SteamUser != "anonymous" {
|
||||
t.Errorf("SteamUser = %q, want %q", s.SteamUser, "anonymous")
|
||||
}
|
||||
if s.Platform != "linux" {
|
||||
t.Errorf("Platform = %q, want %q", s.Platform, "linux")
|
||||
}
|
||||
if s.AutoUpdateOnStartup {
|
||||
t.Error("AutoUpdateOnStartup should be false by default")
|
||||
}
|
||||
if s.WasRunning {
|
||||
t.Error("WasRunning should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_SaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
s.IPPort = "192.168.1.100:2400"
|
||||
s.SteamUser = "myuser"
|
||||
s.Platform = "windows"
|
||||
s.CBASettings = "some_cba_content"
|
||||
s.AutoUpdateOnStartup = true
|
||||
s.WasRunning = true
|
||||
s.ScheduledUpdate = "0 4 * * *"
|
||||
|
||||
if err := sm.Save(s); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(filepath.Join(dir, "settings.json")); os.IsNotExist(err) {
|
||||
t.Fatal("settings.json not created")
|
||||
}
|
||||
|
||||
// Load in a new manager to verify persistence
|
||||
sm2 := NewSettingsManager(dir)
|
||||
s2, err := sm2.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if s2.IPPort != "192.168.1.100:2400" {
|
||||
t.Errorf("IPPort = %q, want %q", s2.IPPort, "192.168.1.100:2400")
|
||||
}
|
||||
if s2.SteamUser != "myuser" {
|
||||
t.Errorf("SteamUser = %q, want %q", s2.SteamUser, "myuser")
|
||||
}
|
||||
if s2.Platform != "windows" {
|
||||
t.Errorf("Platform = %q, want %q", s2.Platform, "windows")
|
||||
}
|
||||
if s2.CBASettings != "some_cba_content" {
|
||||
t.Errorf("CBASettings = %q, want %q", s2.CBASettings, "some_cba_content")
|
||||
}
|
||||
if !s2.AutoUpdateOnStartup {
|
||||
t.Error("AutoUpdateOnStartup should be true")
|
||||
}
|
||||
if !s2.WasRunning {
|
||||
t.Error("WasRunning should be true")
|
||||
}
|
||||
if s2.ScheduledUpdate != "0 4 * * *" {
|
||||
t.Errorf("ScheduledUpdate = %q, want %q", s2.ScheduledUpdate, "0 4 * * *")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_LoadMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v, want nil", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("Load() returned nil settings")
|
||||
}
|
||||
if s.IPPort != "0.0.0.0:2302" {
|
||||
t.Errorf("default IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_SaveUpdatesTimestamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, _ := sm.Load()
|
||||
s.SteamUser = "test"
|
||||
if err := sm.Save(s); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
s2, _ := sm.Load()
|
||||
if s2.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt should be set after Save()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Layout from './Layout'
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders sidebar navigation', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<div>Content</div>
|
||||
</Layout>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Configs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modlists')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mods')).toBeInTheDocument()
|
||||
expect(screen.getByText('Logs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights active route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<Layout>
|
||||
<div>Content</div>
|
||||
</Layout>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
const settingsLink = screen.getByText('Settings')
|
||||
expect(settingsLink).toHaveClass('text-white')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Mods from './Mods'
|
||||
|
||||
const mockList = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
modsApi: {
|
||||
list: (...args: unknown[]) => mockList(...args),
|
||||
remove: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
describe('Mods', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading state then data', async () => {
|
||||
mockList.mockResolvedValue([])
|
||||
const queryClient = createTestQueryClient()
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Mods />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Installed Mods')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders search input after load', async () => {
|
||||
mockList.mockResolvedValue([])
|
||||
const queryClient = createTestQueryClient()
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Mods />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter by name or ID...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no mods message when empty', async () => {
|
||||
mockList.mockResolvedValue([])
|
||||
const queryClient = createTestQueryClient()
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Mods />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No mods found.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fmtSize } from './format'
|
||||
|
||||
describe('fmtSize', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(fmtSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(fmtSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(fmtSize(1024)).toBe('1.0 KB')
|
||||
expect(fmtSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(fmtSize(1048576)).toBe('1.0 MB')
|
||||
expect(fmtSize(5242880)).toBe('5.0 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(fmtSize(1073741824)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(fmtSize(1099511627776)).toBe('1.0 TB')
|
||||
})
|
||||
|
||||
it('handles edge case at 1024 boundary', () => {
|
||||
expect(fmtSize(1023)).toBe('1023 B')
|
||||
expect(fmtSize(1025)).toBe('1.0 KB')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export function fmtSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const v = bytes / Math.pow(1024, i)
|
||||
return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,616 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<html>
|
||||
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||
<head>
|
||||
<meta name="arma:Type" content="list" />
|
||||
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||
<title>Arma 3</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body, th, td {
|
||||
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 3px 30px 3px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: 20px 20px 0 20px;
|
||||
color: white;
|
||||
font-weight: 200;
|
||||
font-family: segoe ui;
|
||||
font-size: 3em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
em {
|
||||
font-variant: italic;
|
||||
color:silver;
|
||||
}
|
||||
|
||||
.before-list {
|
||||
padding: 5px 20px 10px 20px;
|
||||
}
|
||||
|
||||
.mod-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px;
|
||||
color:gray;
|
||||
}
|
||||
|
||||
.whups {
|
||||
color:gray;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #D18F21;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color:#F1AF41;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.from-steam {
|
||||
color: #449EBD;
|
||||
}
|
||||
.from-local {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Arma 3 Mods</h1>
|
||||
<p class="before-list">
|
||||
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||
</p>
|
||||
<div class="mod-list">
|
||||
<table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">[REMASTERED!!!] BBM Varta | Варта ББМ</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3498496846" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3498496846</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">2B9 Vasilek</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3183189354" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3183189354</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">3CB Fortifications</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2970917337" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2970917337</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ace</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=463939057</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ACE3 Arsenal Extended - Core</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2522638637" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2522638637</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Adjustable Walking Speed</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2513253040" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2513253040</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Arma Realistic Map Assets V2</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2982306133" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2982306133</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Aselsan Military Communication Systems</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2407683902" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2407683902</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Better Inventory</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CUP Terrains - Core</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=583496184" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=583496184</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">D-20 M1955</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3356373534" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3356373534</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DCO Soldier FSM</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2825929474" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2825929474</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DCO SoldierFSM Evolution</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3508523598" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3508523598</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DCO UnitScanner</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2811378998" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2811378998</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DCO Vehicle.FSM - Vehicle AI Enhancement (No longer updated)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2760263165" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2760263165</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DUI - Squad Radar</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Map Ace Version</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2467590475" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2467590475</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Movement</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Movement Rework</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Females-TCGM_Girls</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2261045061" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2261045061</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">FPV Drone Crocus</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3045129955" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3045129955</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Gruppe Adler Trenches</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1224892496" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1224892496</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">HAG Objects (SIGNED)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3164954322" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3164954322</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Heavy Weapons Framework</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3328314886" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3328314886</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Hendrix Russian and Ukraine Gear</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3414879940" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3414879940</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Immerse</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=825172265" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=825172265</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">JCA - Infantry Arsenal</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Kamikaze Drone (FPV drones)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2957974874" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2957974874</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">KAT - Advanced Medical</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2020940806" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2020940806</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ladder Tweak Remastered</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2969350304" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2969350304</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">LR Armed Forces of Ukraine</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3266957416" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3266957416</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">LR Modern Armed Forces of Russian Federation Full</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3352497264" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3352497264</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">LR Modern Armed Forces of Russian Federation Lite</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3420356276" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3420356276</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">MT-12 Rapira</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3251627825" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3251627825</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">MT-LB - The Soviet Workhorse</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3615518979" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3615518979</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NIArms All in One</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1208517358" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1208517358</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NMG mod</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2145354279" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2145354279</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">POOK Camonets</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=943981276" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=943981276</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Prone Launcher</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1841047025" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1841047025</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Real Engine Enhanced</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3715450352" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3715450352</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Realistic Trench Digging</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3571588952" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3571588952</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSAFRF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843425103" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843425103</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSGREF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843593391" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843593391</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSSAF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843632231" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843632231</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSTERRACORE</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2288691268" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2288691268</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSUSAF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843577117" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843577117</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ruha</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1368857262" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1368857262</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">SCAR Weapon Family (JCA Expansion)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3345739516" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3345739516</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Simple Armbands</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2778578325" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2778578325</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">SPS Weapons V2</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2811886291" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2811886291</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Switchblade Loitering Munition</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2797736391" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2797736391</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Task Force Arrowhead Radio (BETA!!!)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=894678801" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=894678801</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">tbd_mortars</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3058335345" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3058335345</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">UAF Vehicles Pack</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2965680724" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2965680724</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ukrainian Armed Forces Gear - Monk</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3030483911" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3030483911</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ukrainian Factions Project</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2673284246" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2673284246</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ukrainian Military Technics 2022-2024</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3158623183" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3158623183</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>Created by Arma 3 Launcher by Bohemia Interactive.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user