Skip to content

Fow web UI - #5438

Draft
niugnip wants to merge 8 commits into
AsyncTI4:masterfrom
niugnip:fow-web-ui
Draft

Fow web UI#5438
niugnip wants to merge 8 commits into
AsyncTI4:masterfrom
niugnip:fow-web-ui

Conversation

@niugnip

@niugnip niugnip commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Adds Fog of War support to the backend payload that feeds the new web UI, so FoW games can be viewed in it at all. Until now the new UI was hard-blocked for FoW games and players were sent to the old image-based view.

Pairs with AsyncTI4/ti4_web_new#138— this needs to merge first, or the frontend reads fields that don't exist yet.

What it does

/web-data-fow serves a per-viewer filtered payload:

  • Tiles outside current vision are replaced with the viewer's remembered "ghost" snapshot, flagged with the same "last seen" label the Discord PNG already draws.
  • Unit and control-token faction identity is obscured behind a fow:<color> sentinel — the real color still renders (matching DrawingUtil#drawControlToken, which draws the colored token but skips the faction seal), but the faction behind it doesn't.
  • Player stats, score breakdowns, objective progress, elected laws and expedition completers are redacted for players the viewer can't identify.
  • Hidden players' identity never reaches the client at all: objective scorers they can't be tied to are collapsed into a bare unidentifiedScorerCount, their score breakdowns are omitted entirely (score-track totals travel as faction-less hiddenPlayerVps), strategy-card picks are cleared unless the viewer can see that player's stats (with trade goods blanked so unpicked cards can't be identified by elimination), and stat-tile positions are dropped for them.
  • Per-planet exhausted is now populated for on-tile planets. The web UI previously derived this from each player's exhaustedPlanets list, which isn't available for unidentified players, so exhausted planets in visible systems rendered as ready.

The GM/owner gets the unfiltered payload, and can preview any player's fogged view via ?asPlayer= for debugging. X-Viewer-Is-Gm distinguishes "GM previewing a player" from "that player's own view", which are otherwise identical in shape.

FoW games stream only a "refresh" signal over the websocket rather than a patch — the payload differs per viewer, so clients refetch and the fog is re-applied server-side.

Commits

Reviewable in order; each builds standalone.

  1. Send the hyperlane connection matrix in the web tile payload — unrelated to FoW; the web map couldn't draw custom hyperlane tiles without it.
  2. Add Fog of War support for the new web UI — the bulk of the change.
  3. Let the GM see the event log in FoW games
  4. Stop treating the neutral player as a hidden FoW opponent — the neutral (Dicecord) player isn't a "real player" per FoWHelper#canSeeStatsOfPlayer, so it was failing every identity check as though it were a hidden opponent. Also counts neutral production, which affects non-FoW games too, where it simply wasn't counted before.
  5. Add niugnip's server roles — unrelated to the rest; separated so it can be judged on its own.
  6. Restrict the FoW web UI to FoW server staff in production — temporary rollout gate, see below.
  7. Withhold hidden players' identity from the FoW payload — the earlier redaction still sent every scorer's real faction and left the client to render the unidentifiable ones anonymously; the answer was sitting in the JSON. This moves all of that server-side (see the fourth bullet above).
  8. Fail closed on unresolvable factions in the FoW redaction — review follow-up: a faction string that can't be resolved to a player is now redacted rather than passed through, matching the rollout gate's fail-closed policy.

The rollout gate

The gate commit limits the FoW web-data and event endpoints to holders of one of the FoW server's staff roles (server, Game Supervisor, and the Chapter 2 bothelper/supervisor equivalents), in production only (JdaService#isProduction()), so dev bots are unaffected. GMs are not exempt — most hold one of these already, and exempting them would leave nearly every FoW game reachable.

Deliberately scoped to that server's own roles rather than reusing bothelperRoles, which spans every Async and community server plus all admins and developers — far wider than this gate is meant to be.

It fails closed: unresolvable roles are skipped and access is denied rather than treating "couldn't check" as a pass. Revert that single commit to lift the restriction — nothing else depends on it.

This also adds JdaService#hasAnyRole(userId, roleIds...), since the existing role checks need an interaction event and the web API only has a Discord user ID. It reads JDA's member cache, which is populated eagerly (MemberCachePolicy.ALL / ChunkingFilter.ALL), so it stays a synchronous lookup rather than a REST call on the request thread.

It also relabels role 1063464689218105354 from "FoW Server Game Admin" to "FoW Server Game Supervisor", matching what that role is actually called now.

Testing

Full mvn install clean: 327 tests, 0 failures, 0 errors, SpotBugs and Spotless clean. Exercised manually against a local FoW save as GM, as a participant, and via GM preview-as-player. The identity withholding was additionally verified against a running bot with a player forced unidentifiable: their faction no longer appears anywhere in the fogged payload.

The gate itself was exercised against a running bot by temporarily forcing it on and substituting a role this test bot can see: a user holding the role got the payload, while a user without it — including an actual participant in the game — got a 403. That confirms the lookup resolves a role from a non-primary guild, hits the member cache synchronously rather than making a REST call, and sits above the participant check rather than only the GM branch.

Not verified: that the four production role IDs themselves resolve, since this bot isn't in the FoW server. The mechanism is proven; only those specific IDs aren't. Worth a look at the first deploy, given it fails closed.

niugnip and others added 8 commits July 22, 2026 09:19
Custom hyperlane tiles ("hl") carry their connection layout in
per-position data rather than in the tile artwork, so the web UI has no
way to draw them without it. Exposes the matrix CustomHyperlaneService
already computes for the Discord renderer.
Serves a per-viewer filtered view of the game: tiles outside current
vision are replaced with the viewer's remembered "ghost" snapshot,
unidentified players' unit/control-token faction identity is obscured
behind a "fow:<color>" sentinel while keeping the real color, and
player stats, score breakdowns, objective progress, elected laws and
expedition completers are redacted for players the viewer can't
identify. Also populates the per-planet exhausted flag for on-tile
planets, which the web UI previously had to derive from each player's
exhaustedPlanets list - unavailable for unidentified players.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/events previously 404'd unconditionally for any FoW game, with no
viewer check at all - the GM couldn't see it either. Extracts the
existing owner/GM-role check (already used by /web-data-fow) into a
shared isGm helper and allows the GM/owner through.

Also drops the "physical board" framing from a few FoW comments -
FoW+ implements plenty that wouldn't exist on a physical board.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The neutral (Dicecord) player isn't a "real player" per
FoWHelper#canSeeStatsOfPlayer, so it was falling through every
identity check as if it were a hidden opponent - excluded from FoW
playerData entirely, and its units/control tokens obscured with the
"fow:<color>" sentinel. Neutral forces are public knowledge, not a
hidden opponent, so those checks now special-case it through.
Registers the admin/developer/bothelper role IDs for niugnip's server,
following the existing per-server pattern in JdaService.
Temporary rollout gate while the new Fog of War web UI is tested: in
production, the FoW web-data and event-log endpoints require one of the
FoW server's staff roles (server, Game Supervisor, and the Chapter 2
bothelper/supervisor equivalents). GMs are not exempt - most hold one
of these already, and exempting them would leave nearly every FoW game
reachable, which defeats the point.

Scoped to that server's own roles rather than reusing bothelperRoles,
which pulls in every Async and community server plus all admins and
developers - far wider than this gate is meant to be.

Only applies when JdaService#isProduction(), so local and dev bots are
unaffected and need no role.

Fails closed: unresolvable roles are skipped and access is denied
rather than treating "couldn't check" as a pass.

Adds JdaService#hasAnyRole(userId, roleIds...), since the existing role
checks need an interaction event and the web API only has a Discord
user ID. It reads JDA's member cache (populated eagerly via
MemberCachePolicy.ALL / ChunkingFilter.ALL), so it stays a synchronous
lookup rather than a REST call on the request thread.

Also relabels role 1063464689218105354 from "FoW Server Game Admin" to
"FoW Server Game Supervisor", matching what the role is actually called
now.

Revert this commit to open the UI up.
Under fog you learn that someone scored an objective, not who - but the
payload was sending every scorer's real faction and leaving it to the
client to render the unidentifiable ones as anonymous tokens. The
answer was sitting in the JSON the whole time. Auditing the rest of the
payload for the same shape turned up three more fields with no
redaction at all.

- objectives: unidentifiable scorers are removed from scoredFactions
  and replaced by a bare unidentifiedScorerCount; peekingFactions is
  filtered the same way.
- scoreBreakdowns: hidden players are left out entirely rather than
  carrying a faction-keyed, SCORED-only breakdown that spelled out
  their whole scoring history.
- hiddenPlayerVps: their score-track totals instead travel as a sorted
  list of bare numbers - enough to place an anonymous token on the
  track, with nothing tying it to a faction.
- strategyCards: pickedByFaction is cleared unless the viewer can see
  that player's stats, and an unplayed card no longer reports that it
  was picked at all. Playing a card is announced, so played (and the
  pick it implies) stays visible.
- statTilePositions: entries are dropped for players whose stats the
  viewer can't see. The tiles sit beside a home system, so a
  faction-keyed map gave away both who was in the game and roughly
  where.

With no faction strings reaching the client there is nothing to
correlate across objective cards, so the per-objective shuffle that
existed to frustrate that correlation is gone too.

Verified against a running bot with a player forced unidentifiable:
their faction no longer appears anywhere in the fogged payload.
Review follow-up: canIdentify and the stat-tile redaction kept a
faction in the payload when it could not be resolved to a player,
while redactPickers redacted the same case. A redaction layer should
treat "couldn't resolve" like "couldn't check" in the rollout gate:
deny rather than pass. All three now fail closed.

Also reattaches the redactFactionProgress javadoc, which the
redactScorers insertion had left stacked on the wrong method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant