Skip to content

Fow web UI / Frontend - #138

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

Fow web UI / Frontend#138
niugnip wants to merge 8 commits into
AsyncTI4:mainfrom
niugnip:fow-web-ui

Conversation

@niugnip

@niugnip niugnip commented Jul 22, 2026

Copy link
Copy Markdown

Renders Fog of War games in the new web UI. Previously MapTogglePage force-redirected every FoW game to the old image-based view; that block is removed.

Requires AsyncTI4/TI4_map_generator_bot#5438— merge that first, or this reads payload fields that don't exist yet.

What it does

  • Fogged/ghost tiles render greyed with their "last seen" label, matching the Discord PNG.
  • Units and control tokens whose owner the viewer can't identify keep their real color but lose the faction seal, decoding the backend's fow:<color> sentinel.
  • Player stats, objective progress, elected laws and expedition completers are hidden for unidentified players.
  • Scorers the viewer can't identify arrive from the backend as a bare unidentifiedScorerCount and render as that many anonymous tokens; hidden players' score-track totals arrive faction-less via hiddenPlayerVps. No hidden identity ever reaches the client.
  • A full-screen login / access-denied state for FoW games, using the existing Discord OAuth pathway.
  • GM view-switching: a GM can preview any player's fogged view, with the panel staying open while that player's data loads.

Ordering under fog

Objective and score listings normally show players in seat order — but seat order is itself hidden information under fog. A fogged viewer gets identified players sorted by color instead (already visible on every token, so it reveals nothing), grouped by state (own, then identified scorers, then non-scorers), with anonymous tokens appended last so no slot is tied to a seat that could be tracked across cards. The GM's unfiltered view keeps normal seat order, since nothing is concealed from them.

An earlier revision shuffled anonymous scorers per-objective to frustrate cross-card correlation; with the backend now sending bare counts instead of factions there is nothing left to correlate, and the shuffle is gone.

The General tab's Factions in Game and Card Pool sections are dropped outright for a fogged viewer — both encode turn-order/game-state information in ways that are simpler to remove than to redact field by field — and its strategy-card list shows played cards rather than unpicked ones, since which cards remain untaken is exactly what the backend's strategy-card redaction hides.

Commits

Reviewable in order; each type-checks standalone.

  1. Render hyperlane connector lines on custom hyperlane tiles — unrelated to FoW. The generic hl tile ships with blank artwork and its lines are composited from per-position matrix data, which the web map never drew. Shape/rotation tables ported from the bot's HyperlaneTileGenerator so it matches the Discord render.
  2. Add Fog of War support to the new web UI — the bulk of the change.
  3. Hide the event log button for FoW games unless viewing as true GM
  4. Fix GM-preview panel closing itself while the previewed player loads — the reset effect was keyed on gameData?.gameName, which goes briefly undefined on every viewAsPlayerId change.
  5. Fix neutral-unit identification bugsgameData.playerData deliberately excludes the neutral (Dicecord) player, but unit tooltips used that same filtered list for identity, so neutral units always fell back to "Unidentified". Adds allPlayerData for identity lookups that must resolve every entity on the board.
  6. Fix React hooks-order violations that crash on re-render — see below.
  7. Consume the fog-redacted score payload, and drop seat order under fog — the frontend counterpart of the backend's identity withholding (see "What it does" and "Ordering under fog" above).
  8. Pass the anonymous-scorer count to the compact view's detail popover — review follow-up: the compact objectives view threaded the count into its chip but not the popover behind it.

The hooks commit

Hooks called after an early return, or short-circuited by ??, change the hook count between renders, so a mounted component re-rendering with a flipped branch throws "Rendered fewer/more hooks than expected".

These were latent for a long time and only became reachable with GM view-switching: previously these components were mounted fresh per navigation, so the branch that skipped a hook was fixed for the component's lifetime. Switching "view as player" re-renders the same mounted components against different data, and the branch flips underneath them.

Found the rest by temporarily enabling eslint-plugin-react-hooks, which is already a dependency but isn't wired into eslint.config.js. That config change is deliberately not included — turning on a repo-wide lint rule is a maintainer call, and it currently reports pre-existing violations elsewhere in the tree. Happy to send it separately if wanted.

Testing

yarn build clean. Exercised against a local FoW save as GM, as a participant, and through GM preview-as-player, including with a player forced unidentifiable to confirm anonymous tokens render from the bare counts.

Known cosmetic uncertainty: the anonymous-player token styling (dark fading centre + crossed-out eye) is new rather than matched to an existing placeholder — the existing ones all use a light-to-dark sphere gradient. Easy to restyle if it doesn't fit.

🤖 Generated with Claude Code

niugnip and others added 8 commits July 21, 2026 18:42
The generic custom hyperlane tile ("hl") ships with blank artwork - its
connection lines are meant to be composited per-position from the
matrix the bot already computes (CustomHyperlaneService). The web map
never drew them, so custom hyperlanes rendered as empty tiles with no
indication of what they connect.

Ports the shape/rotation tables from the bot's HyperlaneTileGenerator
so the web view matches the Discord-rendered map exactly. Static
hyperlane tiles (hl_crossed_0, etc.) are unaffected - their lines are
pre-drawn into the artwork, so only "hl" gets the overlay.

Needs the matching hyperlaneMatrix field on the bot's tile payload.
Renders fogged/ghost tiles with last-seen labels, hides unidentified
unit/control-token faction identity while keeping color, redacts
player stats/objective progress/law and expedition completers for
unidentified players, and adds a full-screen login/access-denied
state for FoW games. Uses the existing Discord OAuth pathway only.

Objective/score listings drop seat ordering for fogged viewers: with
anonymous scorers on the card, a stable per-seat slot would let you
locate a hidden player by position and then track them across
objectives. Those viewers get a state-grouped list with the anonymous
tokens last instead. The GM's own unfiltered view keeps normal seat
order, since nothing is concealed from them.

Also gates the game-state overlay, score breakdown and anchored
player-stat tiles behind the HIDE_PLAYER_INFOS FoW option, again
exempting only the GM's real view - X-Viewer-Is-Gm stays true while a
GM previews a player, so it can't gate this on its own.

Also switches exhausted-planet rendering to the per-tile `exhausted`
field the backend now sends, instead of deriving it from each player's
exhaustedPlanets list. The old approach only worked for players the
viewer can identify, so in FoW an exhausted planet in a visible system
belonging to an unidentified player rendered as ready. Exhaustion of a
visible planet isn't secret - it says nothing about who controls it -
so it now follows tile visibility like the units and tokens on that
same planet already do.

Requires the matching backend changes to the web data payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The event log has no fogged variant (backend /events is built from
full, unfiltered state with no per-viewer redaction), so it's only
ever valid for the GM's own real view - never for participants of a
FoW game, and not while the GM is previewing as a specific player.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The panel/selection reset effect was keyed on gameData?.gameName, but
gameData goes briefly undefined on every viewAsPlayerId change too
(it's part of the player-data query key) - so clicking "view as
player X" tripped the same reset meant only for switching games,
closing the panel and dropping the selection right as that player's
data started loading. Keyed on the route param instead, which only
changes on actual navigation.
Testing the backend's neutral-player redaction fix surfaced two more
bugs around the neutral (Dicecord) player, whose units/color/identity
the backend correctly leaves unredacted (it's public knowledge, not a
hidden opponent):

- gameData.playerData deliberately excludes the neutral player (see
  filterPlayersWithAssignedFaction, used for ranking/tab UI), but
  MapUnitDetailsCard was using that same filtered list to resolve
  unit identity - so a neutral unit's owner could never be found and
  it always fell back to the "Unidentified" estimate path regardless
  of the backend fix. Added gameData.allPlayerData (unfiltered) for
  identity/color lookups that need to resolve every entity on the
  board, and pointed the tooltip at it; it now reads "Neutral" with
  correct stats and color instead of "Unidentified".
- FowViewPanel's "Show game as" picker reads the raw query cache
  directly (to always list every player even mid-preview) rather than
  gameData.playerData, so it never filtered the neutral entry out -
  once the backend started including it, "Neutral" started appearing
  there as a selectable preview target. Filtered it with the same
  helper used everywhere else.
Hooks called after an early return (or short-circuited by ??) change
the hook count between renders, so a mounted component re-rendering
with a flipped branch throws "Rendered fewer/more hooks than
expected".

These were latent for a long time and only became reachable with the
new FoW GM view-switching. Previously these components were mounted
fresh per navigation, so the branch that skipped a hook was fixed for
the component's whole lifetime. Switching "view as player" as GM
re-renders the same mounted components against a different player's
data - now the branch flips underneath them and the hook count
changes. Hit it twice in a row while testing (MapUnitDetailsCard,
then FactionControlBorderOverlay) before going looking for the rest.

Found the remaining ones by temporarily enabling
eslint-plugin-react-hooks, which is already a dependency but isn't
wired into eslint.config.js - left that config change out of this
commit, since turning on a repo-wide lint rule is a maintainer call
rather than something this branch should decide:

- MapPlanetDetailsCard: usePlanet() after an early return
- MahactEdictTokens: useFactionColors() after an early return
- MapTooltipPositioner: useAppStore() short-circuited by ??
- PlayerStatsArea: three useMemo calls after an early return
- PlanetCirclesLayer: a useMemo after an early return
- DebtTokens: useFactionColors() after an early return
- Nombox: useFactionColors() after an early return
- ScaledContent: useRef/useState/useIsoLayoutEffect/useMemo all gated
  behind the `enabled` prop
- PathVisualization: useAppStore short-circuited by ??, plus two
  useMemo/useCallback pairs after an early return

Each fix moves the hook calls above any conditional return, using
optional chaining/fallback values where the hook's input depended on
data that might not exist yet, so the hook still runs unconditionally
every render.
Follows the backend no longer sending hidden players' factions:

- Objective cards render unidentifiedScorerCount anonymous tokens
  instead of filtering a list of real factions client-side. With no
  identifiers arriving there is nothing to correlate across cards, so
  the per-objective shuffle that existed to frustrate that is gone.
- ScoreTracker places hidden players from hiddenPlayerVps - bare
  score-track totals with no faction attached - rather than reading
  faction-keyed score breakdowns that no longer include them.
- The General tab's strategy-card list shows played cards instead of
  unpicked ones under fog. Unpicked cards accumulate trade goods, which
  the backend now blanks alongside pickedByFaction (see the bot-side
  commit) - but the tab still needs to stop asking for the unpicked
  list at all, since which cards remain untaken is exactly what that
  redaction hides.
- Factions in Game and Card Pool are hidden outright in the General tab
  under fog, rather than picked apart field by field. Factions in Game
  computes a "next player" badge as the slot after the active player
  within playerData, which already excludes hidden players - if the
  real next player is hidden, the badge lands on the wrong identified
  player, and that mismatch tells a fogged viewer no hidden player's SC
  falls between the active player and this one. Card Pool's deck sizes
  have their own redaction (HIDE_EXPLORES nulls cardPool entirely) that
  this tab wasn't accounting for either. Simpler to drop both sections
  than track every way game-state-shaped data can leak turn order.

Also stops fogged views from leaking seat order: playerData arrives in
seat order, which is itself hidden information, so the objective lists,
the score track, and the player score-summary grid now all sort
identified players by color for a fogged viewer instead - already
visible to them, and revealing nothing extra.

Normal games and the GM's own view are unaffected by any of this - they
keep seat order and the full General tab exactly as before, since
nothing is redacted for them in the first place.
The compact objectives view threaded unidentifiedScorerCount into its
chip but not into the ObjectiveDetailsCard popover behind it, which
defaults to 0 - so under fog the popover silently showed no anonymous
scorers while the chip it expands showed the tokens.

Also rewords a comment that still described the per-objective shuffle
removed with the count-based redaction, and fixes the indentation of
the chip's prop.

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