8000 GitHub - Taccooh/warroom: Fan-made companion for wdgwars.pl — battle map, turf watcher, raid planner, loot tours · GitHub
[go: up one dir, main page]

Skip to content
TaccoohPublic

About

Fan-made companion for wdgwars.pl — battle map, turf watcher, raid planner, loot tours

Resources

Stars

15 stars

Watchers

1 watching

Forks

Repository files navigation

Warroom

A fan-made companion PWA for wdgwars.pl (Watch Dogs Go Wars): battle map, turf watcher, raid planner and crew features — built for use on the road.

Not official. WDGWars and LOCOSP do not build, run or endorse this tool. It is built and operated by a single player from the community.

Live instance: https://warroom.mechanics-toolbox.org — sign-ups are capped while the tool is young. What it does and what it stores: https://warroom.mechanics-toolbox.org/about

Features

  • Battle map — your turf glowing gold, enemy gangs in their real colors, unclaimed and virgin cells, distance rings, GSM-mast density and the coverage brush as toggleable layers, full-screen with follow mode (GPS)
  • Watcher — polls the wdgwars API every 5 minutes and reports ownership changes on your turf, with configurable scope (own cells / gang turf / anything near), front detection and web push ("raven post")
  • Planner — easiest flips first: enemy cells with the smallest AP gap, free cells, and virgin land (cells nobody ever scanned), sorted by real GPS distance
  • Loot tour — pick cells, get an auto-optimized route by car, bike or on foot (each with its own routing profile), waypoints snapped to actual ways (OpenStreetMap) and the real route drawn in-app (OSRM), in-app guidance, or export: Google Maps hand-off (10 stops), or a GPX/KML file with every stop plus the routed road line, for OsmAnd, Locus, Garmin, Komoot and Google Earth
  • Coverage brush — opt-in GPS recording of the ground you actually covered while driving: every stamp carries your expected reception radius, the screen stays awake while recording, and an interrupted session resumes on its own
  • Trends — the one thing the game itself cannot show, because its feed only ever reports the present: your own history over weeks, who gained and lost ground in the last 24 h / 48 h / 7 days, the gang table with movement, and who actually holds cells inside your turf. Charts are server-rendered SVG, so the page draws with JavaScript off. The per-player parts need wdgwars to name cell holders, which it stopped doing on 2026-10-28 — see Reading a cell
  • Crew — friends and opt-in live position sharing (auto-expires, no history)
  • EN/DE, installable as PWA, works on phone and desktop

Security model (the short version)

Your wdgwars API key is the entry ticket. Mint a fresh one just for warroom in your wdgwars profile — then pulling it back later costs you nothing else. It is:

  • validated once at sign-up (/api/me), your wdgwars username becomes your login
  • stored encrypted at rest (Fernet/AES) — the master key lives in data/master.key, outside the database
  • used read-only: every wdgwars call the running app makes is a GET (/api/me, /api/team/me, /api/territories, /api/member-territories, /api/leaderboard, /api/me/cells). It never uploads in your name and never changes anything on your wdgwars account. The API client does carry an upload_csv POST for the planned live uploader — nothing calls it today, and if that ships it will be opt-in and said out loud here first
  • instantly dead the moment you rotate your key in your wdgwars profile

Beyond wdgwars, this is what leaves the machine: your browser loads map tiles from tile.openstreetmap.org (they see your viewport and your IP). The server asks Overpass for way geometry per map cell, and OSRM for the route in your chosen travel mode (car, bike or foot — separate engines at the same provider) — that request carries the tour stops plus your own position as the start point, resent live while in-app guidance runs. For the air traffic layer the server also asks OpenSky (the whole world, on a schedule), adsb.lol (the worldwide list of military aircraft) and adsb.fi, with adsb.lol as the fallback (200 km around the map area being viewed) — public requests that carry an area, never a user. Tapping the Google Maps or Street View export hands those stop coordinates to Google. No analytics, no trackers, no update check, no phone-home of any kind.

Between users of one instance the line runs like this: everything the Trends page shows about other players comes from the public game feed and the leaderboards — the same numbers wdgwars shows every player anyway. Anything derived from a key (your series, your events, your footprint, your position) is shown to whoever owns that key and to nobody else. And nothing marks who has an account here: the movement tables cover every player in the feed, so signing up never lifts anyone into a list that others are missing from.

Raw AP data (exact positions, BSSIDs, names) is aggregated into map cells on arrival and never stored. The coverage brush is the one deliberate exception: while you actively record, it stores your own GPS trail (point + reception radius) — visible to nobody but you, clearable in one tap, gone with your account. Deleting your account removes everything, immediately. Details: /about

Self-hosting

Requirements: Docker with the compose plugin. Locally there's no build pipeline, no Node — the frontend is server-rendered Jinja2 with vendored Leaflet (CI does run a JS/CSS minify pass before publishing images to GHCR, see Docker image below, but nothing you need for local dev).

An example compose.yml is included, pulling the published image:

services:
  warroom:
    image: ghcr.io/taccooh/warroom:latest
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - ./data:/app/data
    environment:
      - WARROOM_VAPID_SUB=mailto:you@example.com
docker compose up -d
# open http://localhost:8000

To build from source instead, replace image: ... with build: . and run docker compose up -d --build.

HTTPS is not optional

Put a TLS-terminating reverse proxy (Caddy, nginx, …) in front — the app itself speaks plain HTTP on port 8000. Behind a plain http://192.168.x.y:8000 URL it is the browser, not the app, that takes things away:

  • GPS stops working. navigator.geolocation only runs in a secure context — HTTPS, or localhost. No server-side setting can lift that.
  • Push and PWA install stop working, for the same reason: service workers are secure-context only.
  • Login does not stick. The session cookie is Secure, so a plain-HTTP origin refuses to store it.

http://localhost:8000 does count as a secure context, so a desktop-only setup on the same machine works fully. For a phone you need a real certificate. Without owning a domain, the least painful route is Tailscale Serve — an HTTPS *.ts.net hostname, no open ports, no cert warnings:

tailscale serve --bg 8000

Cloudflare Tunnel, or Caddy with a DNS-01 challenge, work just as well if you do own a domain. For a throwaway test you can instead tell Chrome to treat the origin as secure under chrome://flags/#unsafely-treat-insecure-origin-as-secure (add http://192.168.x.y:8000, port included) — that weakens the browser for that origin, so don't leave it on.

Docker image

.github/workflows/docker-publish.yml publishes multi-arch images (linux/amd64, linux/arm64, linux/arm/v7) to GHCR:

  • every push to master that touches app/, Dockerfile, requirements.txt or .github/workflows/docker-publish.yml → ghcr.io/taccooh/warroom:edge (latest master, unreleased)
  • every GitHub Release → :latest, :X, :X.Y and the release tag itself
docker pull ghcr.io/taccooh/warroom:latest
# or pin a version: ghcr.io/taccooh/warroom:1.2, :1
# or track master: ghcr.io/taccooh/warroom:edge

Upgrading

One line, from the directory holding your compose.yml:

docker compose pull && docker compose up -d

There is no migration step, by design. New tables and indexes are CREATE … IF NOT EXISTS in db.SCHEMA, new columns are added by _add_col, and anything derivable from data you already have is backfilled at startup — the sample log added in 1.8.0 reconstructs itself from player_snap in 47 ms over 176k rows, and is idempotent, so a restart costs one pass and changes nothing. New settings always ship with a working default; you only touch .env if you want to change one.

Your database lives in the data/ volume and is never touched by an image change. Downgrading is a docker compose pull away too, but a database written by a newer version may carry columns the older one does not know about — those are ignored, not removed.

The Dockerfile is a two-stage build: a builder stage with a C toolchain (needed because cffi/httptools/uvloop have no prebuilt wheels for linux/arm/v7, so pip compiles them from source on that platform) and a slim runtime stage that only gets the installed packages and app/, not the toolchain.

Configuration (environment variables)

Variable Default Purpose
WARROOM_DATA ./data Data directory (SQLite DB, master key, VAPID keys)
WARROOM_MASTER_KEY (file) Fernet master key; if unset, generated on first start at data/master.key
WARROOM_MAX_USERS 30 Sign-up cap; registration closes above this count
WARROOM_TZ Europe/Berlin IANA timezone for displayed timestamps (DB stores UTC)
WARROOM_POLL_WORKERS 4 Concurrent per-user poll workers (caps simultaneous wdgwars API requests)
WARROOM_VAPID_SUB (contact) mailto: contact sent to push services (set your own when self-hosting)
WARROOM_PLANNER_LIMIT 25000 Max enemy cells handed to the planner (raise if the log says the cap fired)
WARROOM_ROAD_DRIP 600 Cells per cycle checked against Overpass for a drivable road point
WARROOM_DRIP_WORKERS 3 Parallel road-snap workers, each leading with a different Overpass mirror
WARROOM_ARCHIVE_HOURS 1 Hours between history samples; 0 disables the archive (HTTP API)
WARROOM_ARCHIVE_KEEP_DAYS 0 Retention for the archive in days; 0 keeps everything
WARROOM_AIR_SECONDS 960 (120 with OpenSky account) Seconds between worldwide OpenSky samples for the air traffic layer; 0 disables it
WARROOM_OPENSKY_ID / _SECRET (none) Optional free OpenSky API client; lifts the quota from 400 to 4000 credits/day
WARROOM_AIR_DAILY_MAX 420 Own cap on adsb.lol requests per UTC day (military list, and the 200 km queries when adsb.fi fails; their limit is 500/day per IP). adsb.fi, tried first for area queries, is paced at 1 request/s and has no daily cap

Backups — read this once

data/ contains three things that belong together: warroom.sqlite (the database), master.key (Fernet master key) and vapid.pem (push keys). A database backup without master.key is worthless — the stored API keys can never be decrypted again. Back up the whole data/ directory.

HTTP API

People build their own tooling on top of warroom. These four endpoints are the supported surface for that, and their response shape is meant to stay stable.

Do not build on /api/live. It is the frontend's private channel and ships pre-rendered HTML fragments alongside the data — it changes whenever a template does. Everything a script needs is below.

Authentication

Create a read-only API token in the app (info tab → API tokens) and send it as a bearer token:

curl -H "Authorization: Bearer wr_xxxxxxxx" 'https://your-instance/api/state'

This is the right way to authenticate a script. A token may read the endpoints below and nothing else — it cannot change your account, your position or your crew — it is revocable on its own, and only its SHA-256 hash is stored, so the plaintext exists once, on screen, at creation.

A normal session cookie works too, which is handy for a quick manual call:

curl -c jar -X POST https://your-instance/login \
     -d 'username=YOURNAME' -d 'password=YOURPASSWORD'
curl -b jar 'https://your-instance/api/state'

Do not put a session cookie in a script. It is full access and lives 60 days, so a cron file or a committed config hands your whole account to whoever reads it. Note also that a failed login returns 200 with the login page, not 401 — check that a wr_session cookie was actually set.

Either way, all endpoints answer 401 without valid credentials.

Send a User-Agent of your own. warroom does not filter by user agent, but a proxy in front of it may: the hosted instance sits behind Cloudflare, which turns away Python's default (Python-urllib/3.x) with a 403 before the request ever reaches warroom. Any identifier of your own passes — my-script/1.0 is enough, there is no need to pose as curl. So a 403 comes from the proxy, a 401 from warroom.

The examples below use a shell variable for the header:

AUTH="Authorization: Bearer wr_xxxxxxxx"
Endpoint Returns
GET /api/state Where you stand now: meta, counts, cells (every cell in your turf with its gang, strength and your own APs — and its holder where wdgwars still names one), planer, events
GET /api/stats Your own measured history, one row per poll: APs, credits, gang rank and points
GET /api/players Standings from the latest sample; ?q= searches by name, ?id= gives one player's current state and history
GET /api/gangs Leaderboard from the latest sample; with ?name= the history of one gang
GET /api/boards The game's own top-50 lists over time; ?board= selects one, ?id= follows one player on it
GET /api/virgin Never-scanned cells in your turf with a reachable point each; ?mode=car|bike|foot — raw material for route planning

since=YYYY-MM-DD HH:MM:SS (UTC) and limit= work on all history endpoints. limit is clamped to 5000.

Planning a drive

/api/virgin lists the cells in your turf nobody has ever scanned — what a route planner needs. Narrow it with bbox=lat_min,lat_max,lng_min,lng_max; by default only cells you can actually reach come back (roads_only=false also returns unclassified ones).

Not everyone wardrives. mode=car (default), bike or foot decides what counts as reachable: a car needs a road, a walker also takes footpaths, steps and bridleways, a cyclist the subset they may ride. Cells a car cannot reach used to vanish from every list — for a war-walker they are perfectly good targets. Without mode= the account's own setting applies (planner tab → Getting around), so a script sees the same world as the app. The tour is routed on the matching OSRM profile too.

curl -H "$AUTH" '…/api/virgin?bbox=42.3691,42.3979,-71.2205,-71.1381'
{"count": 1, "navigate_with": "rlat,rlng", "cells": [
  {"cell_key":"2119_-3561","i":2119,"j":-3561,
   "lat":42.38,"lng":-71.20, "rlat":42.381,"rlng":-71.201, "road":1}]}

Route to rlat/rlng, not lat/lng. The latter is the cell centre, which lands in a field, a forest or a lake often enough to ruin a drive — that is the whole reason the snapped points exist. road is 1 when a drivable point is known, 0 when the cell has none (water, woods), null when the background snapper has not reached it yet; path then carries the OSM way type (path, footway, bridleway, cycleway, steps) for cells only a walker or cyclist can reach.

Reading a cell

Three questions that look alike and are not:

  • my_aps — how many APs you have in that cell. Presence. You can have APs in a cell somebody else holds.
  • held — true only when you personally hold the cell. This is the one status cannot answer.
  • status: "mine" — your gang holds it, which usually is not you. Measured on the hosted instance in August 2026: of 31,687 gang cells, players held 16,260 themselves — barely half.

owner carries the holder's wdgwars id (also in the planner as o), so you can see who to beat, not just which gang. meta.wdg_user_id is your own id in the same space, if you want to compare yourself.

Since 2026-10-28 wdgwars no longer names cell holders. The territory feed sends user id 0 instead; the only holder it still names is the account whose API key made the request. warroom reads 0 as not disclosed and returns owner: null (releases before v1.9.0 pass the 0 through — treat it the same). Gang and strength still arrive, so status, count and gap work as before. What changed:

  • owner is null on practically every cell, and the names table below shrinks with it.
  • held depends on whose key fetches the feed. warroom fetches it once per cycle, with the first account whose key works — the oldest one, in practice. On a single-user instance that is you, and held is as good as ever. On a shared instance it is right for that one account only: everyone else gets held: false on every cell, which there means not disclosed, not not yours.

Names for those ids come with the response as names, a {id: username} lookup table covering everyone appearing in cells and planer. It is one table rather than a name per row on purpose: a turf of 12,000 cells has only a few hundred distinct holders. Ids missing from it have no known name — show the number instead. /api/live carries the same table, since holders change when cells flip.

How complete that table is depends on your instance, not on the software. Names come from two places:

  • the member list of every gang a user of your instance belongs to — complete for those gangs, and it costs no extra request (the poller already fetches it)
  • the leaderboards — global, but only their top slots, a few hundred players

So a gang nobody on your instance belongs to stays anonymous. On the hosted instance, spanning 22 gangs, that means 100 % coverage inside those gangs and 69 % of all players seen; within one turf it tends to run higher, because neighbours are more often in large gangs. A single-user instance sees its own gang plus the boards, and correspondingly more bare ids. Every new user from a gang not yet represented unlocks that gang's whole roster at once.

gap is how many more APs you need to take an enemy cell:

gap = max(0, count - my_aps + 1)

count is the holder's AP total there, the +1 because you need more than they have. gap: 0 therefore means you are already ahead and it should flip — not that it is impossible. gap: null means unknown, never zero: either it is not an enemy cell, or wdgwars is hiding enemy strength (it did for all team cells during the Lone Silverback event). Do not fall back to 0 there, or every fogged cell looks like a free win.

Looking a player up

?q= searches names, anywhere in the string, case-insensitively, and answers the question the territory feed cannot: which gang is this player in.

curl -H "$AUTH" '…/api/players?q=nightowl'
{"query":"nightowl","players":[{"player_id":4711,"username":"nightowl",
  "gang":"Example Gang","gang_id":42,"cells":4233,"aps":327700}]}

?id= adds a current block (gang, cells, APs as of the latest sample that contains the player, dated by its ts) and a registration block next to the history.

Since the feed stopped naming holders (see Reading a cell), gang, cells and aps come back null from ?q= for everyone except the account whose key fetches the feed. The search still resolves a name to its id, and ?id= still returns what was last known — check current.ts for how old that is.

Dating an account. wdgwars does not report when someone signed up, but ids are handed out sequentially and every gang member list carries a joined_at. So if a higher id was already in a gang at time T, this account must predate T:

"registration": {
  "registered_before": "2026-05-27 14:36:33",
  "joined_gang":       "2026-05-28 09:11:02",
  "anchor_below": {"player_id": 4708, "joined_at": "2026-05-26 23:33:31"},
  "anchor_above": {"player_id": 4713, "joined_at": "2026-05-27 14:36:33"}
}

registered_before is a hard bound. There is no equally hard lower bound — an old account can join a gang at any time, so a date below only shows where id allocation stood, not when that player started. The two anchors are returned so you can judge how tight the bracket is: neighbouring ids a few hours apart pin it down, a gap of hundreds does not. Accuracy grows with the number of gangs your instance can see.

Names come from two places: the gang member lists (every player in a gang any user belongs to) and the leaderboards (their top 50). A player nobody has ever shared a gang or a top-50 slot with stays a bare id — an empty result means "not known here", not "does not exist". Someone known by name but absent from the snapshots is still returned, just without figures.

The history archive

The wdgwars feed is a snapshot that overwrites itself — it tells you who holds a cell right now, never how that changed. Since the poller fetches the global feed every cycle anyway (every gang cell with its strength and, until 2026-10-28, its holder), warroom samples it every WARROOM_ARCHIVE_HOURS into player_snap and gang_snap. That turns "is this rival gaining on me?" into a query.

Five things worth knowing, because the numbers are easy to misread:

  • Per-player history ends on 2026-10-28 for almost everyone. That evening (UTC) wdgwars stopped naming cell holders in the feed — see Reading a cell. Since then player_snap only gains rows for the account whose key fetches the feed: /api/players lists that one player, and every other history stops there. The gang series runs on, because cells and aps are counted per gang and need no holder — except players, which counted distinct holders and now reads 0 or 1. For individual players, the boards are the series that still moves.

  • player_id is the wdgwars user id from the feed, not a warroom account.

  • cells is what a player controls. The feed names one owner per cell, so a player holding APs in a cell someone else controls is not counted there. The game's own cell ranking counts every cell a player has APs in and runs 2–4× higher. Both are valid measures of different things — don't compare them.

  • Players without a gang are missing entirely. The feed carries gang territory only; every cell in it has a gang_id. A strong solo player can be completely invisible here.

  • The archive cannot be backfilled. Whatever is not sampled while the feed is in memory is gone; history starts the day you switch it on.

# Standings from the latest sample (since 2026-10-28: the feed account only)
curl -H "$AUTH" 'https://
846E
your-instance/api/players?limit=20'
# How one player developed over the last week
curl -H "$AUTH" 'https://your-instance/api/players?id=4711&since=2026-08-21 00:00:00'

Leaderboards and names

The territory feed hands out numeric ids and no names. The game's leaderboards do both, so warroom samples them alongside and keeps a growing player_id → username table. The gang member lists fill the same table — see the coverage note below. Names then appear in /api/players and /api/boards; they are null for anyone neither source has named.

Sampling the boards also covers the blind spot above: they are the only place a gang-less player appears at all. Seven boards are kept — today, week, all_time, cells, hunters, flock, arcade — each with the position and the figure it ranks by (ranks_by in the response says which). all_time additionally carries wifi and ble split out.

The response carries sampled_at (and board_sampled_at on the ?id= form): when that board was last written. A board that drops out of the upstream feed — the cells board does this now and then — stops being sampled and its standings age with no other signal, so check this timestamp before trusting the numbers as current. On the ?id= form it also tells a stale board apart from a player who left the top 50: if board_sampled_at is fresh but the newest ts in history is old, the player dropped out; if board_sampled_at itself is old, the whole board is behind.

# Who leads all-time, with names
curl -H "$AUTH" 'https://your-instance/api/boards?board=all_time'
# One player's climb; gaps mean they were outside the top 50 that hour
curl -H "$AUTH" 'https://your-instance/api/boards?board=cells&id=4711'

rank and points come from wdgwars, cells/aps/players are counted from the feed — they measure different things and will disagree. A gang can rank high on points while controlling fewer cells than the one below it.

/api/gangs also carries the gang's official ap_count and member_count. Those are the real totals; aps and players beside them only cover what the feed shows (controlled cells, owners seen) and are systematically lower. Both are null outside the top 50 — that leaderboard is capped, the feed is not. Since 2026-10-28 players no longer counts anything useful (holders are not named any more); member_count is the figure to use.

License

AGPL-3.0-or-later — © 2026 St4bleground st4bleground@proton.me

Why AGPL: warroom is a hosted tool that people trust with API keys. The AGPL's network clause means anyone who runs a modified version for others must publish their modifications — the same transparency this repo exists to provide.

Third-party

See THIRD_PARTY_NOTICES.md — Leaflet (BSD-2-Clause, vendored), Germania One font (SIL OFL 1.1), OpenStreetMap data (ODbL), and for the air traffic layer aircraft data from adsb.fi, adsb.lol (ODbL) and the OpenSky Network, each under its own terms and credited on the About page. Splash and icon artwork was generated by the author (Stable Diffusion) and is covered by the repository license.

Development

No build pipeline locally. Python 3.12, dependencies from requirements.txt:

python -m venv .venv && .venv/Scripts/pip install -r requirements.txt  # or bin/ on Linux
.venv/Scripts/python -m uvicorn app.main:app --reload

Using the devcontainer instead? Dependencies are installed automatically on container creation (postCreateCommand), so you can skip the venv/pip install step above and go straight to uvicorn app.main:app --reload. This only applies inside the devcontainer — local development still needs the setup above.

The SQLite schema migrates itself on startup (CREATE TABLE IF NOT EXISTS plus additive column migrations). Issues and pull requests are welcome — for bigger changes, open an issue first.

Note for forks: the /about page describes this operator's instance (contact address, backup retention, sign-up cap). If you host your own, edit app/templates/about.html and CONTACT_MAIL/WARROOM_VAPID_SUB to match your setup.

Contact

st4bleground@proton.me — or ask openly on the WDGWars Discord.

About

Fan-made companion for wdgwars.pl — battle map, turf watcher, raid planner, loot tours

Resources

Stars

15 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

0