Skip to content

Rewrite the image code into layered packages with pure, thread-safe renders - #5955

Merged
wholton merged 120 commits into
masterfrom
map-generator-rewrite
Oct 22, 2026
Merged

wholton merged 120 commits into
masterfrom
map-generator-rewrite

Conversation

@wholton

@wholton wholton commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Rewrites the bot's image code: MapGenerator, TileGenerator, PlayerAreaGenerator, DrawingUtil and the image code that was scattered across services and models. Image code now lives in layered packages, renders no longer change the game they draw, and queued renders draw from their own copy of the game. Apart from the fog fixes and layout fixes listed below, output is pixel-identical. Golden-image harnesses over 40 real games checked every stage.

Most of the 1,051 changed files are import updates from the package moves. Every commit is small and builds on its own, so it reads best commit by commit.

Structure

  • Packages under ti4.image, layered top-down:

    • map: the map canvas and its sections;
    • playerarea, tile, draft and card;
    • draw: shared drawing helpers;
    • io: fonts, caches, encoding, uploads and asset paths.

    draft may also use tile. Otherwise sibling packages never depend on each other. ImagePackageLayeringTest enforces this and fails if ti4.model or ti4.game imports image code.

  • Model layer: Mapper, TileHelper, PositionMapper, UnitTokenPosition and BoardPosition moved to ti4.model. The move is scripted, so it can be re-run on another branch to fix conflicts mechanically.

  • MapGenerator is replaced by:

    • MapRenderer for the draw order;
    • RenderContext, resolved once per render;
    • one class per section and highlight;
    • MapUploads for the Discord and website uploads.
  • TileGenerator is replaced by TileLayers (one painter per tile step), SystemContextImage and focused unit, token and badge classes.

  • PlayerAreaGenerator is replaced by one class per player-area part, laid out by PlayerAreasSection.

  • DrawingUtil is deleted. Its helpers moved to draw (OutlinedText, PaImage and others), and duplicate copies were merged.

  • Scattered image code:

    • The slice, Nucleus, Mantis and template-preview images moved into ti4.image.draft, and the two slice renderers became one.
    • The Jimbo and colour/decal sample commands draw through ImageGrid.
    • Font getters moved from Storage to Fonts, and FileUploadService became ImageUploads.
    • Tile, token and border asset paths now come from ImageResources.

Behaviour changes

  • Fog of War:

    • System and tile images requested from a thread under a -private channel used to render unfogged. They now use the same viewer rule as the map.
    • A private view with no seated viewer, such as a GM in a private channel, used to crash with a null-pointer error. It now shows a view with no knowledge instead.
    • "Could not find owner" messages from unit rendering went to the main game channel, which leaked information in fog games. They go to the bot log now.
  • Renders no longer change the game. These writes are now explicit steps before or after the render:

    • fog memory;
    • Triad and Grove planet stats;
    • missing debt pool icons;
    • faction emoji normalisation;
    • clearing shown tile distances;
    • creating the neutral player, which only full map renders still do because the objective boxes count it.

    Planets that are no longer in the game are now removed from players on load and save, not while drawing a planet card.

  • Letnev commander: the production badge no longer unlocks it as a side effect of drawing. A test checks that adding the fifth non-fighter ship still unlocks it.

  • Layout fixes from the MapGenerator rewrite, each its own commit:

    • the canvas height fits what was drawn;
    • the header lays out around the expedition tracker, and strategy cards wrap into even rows;
    • the landscape player-area column is fixed;
    • law and event cards grow to fit their text;
    • game mode badges and the spectators line no longer clip.

Thread safety

  • Render copy: MapRenderPipeline.queue and queueImage take a copy of the game on the queueing thread, through an in-memory save and load plus the shown tile distances. The render, the encode, overview suppliers and the website upload use only that copy. The copy takes a median of 1.9 ms per game and 32 ms for the 39-player PBD /game end broken when user no longer exists #1000.
  • Mapper reload: Mapper.init() used to clear and refill the maps renders read. It now builds new maps and swaps each in whole.
  • Static fields: RenderStaticStateTest fails on any new mutable static field in the render path.
  • Tests: RenderThreadSafetyTest covers:
    • the live game changing after a render is queued;
    • two games rendering at once;
    • a Mapper reload during renders.
  • Known gaps (compact TODOs):
    • The markers that stop a player-area warning being repeated are still written to the live game.
    • ExecutionLockManager locks can expire while still held.

Performance

  • Neighbours: computed once per render with NeighbourMap, and Player.getNeighbouringPlayers builds a neighbour graph instead of being quadratic.

  • WebP method 2: about half the encode time. Files are 4–10% larger on normal maps and up to about 21% larger on the widest fog maps. The largest map in the corpus is 7.1 MB, under Discord's 10 MB limit.

  • Corpus render time: 118 s down to 47 s. A typical map went from about 710 ms to 440 ms, and PBD Draw relic when purge fragments #100 from 42.6 s to 3.7 s.

  • Small changes in the last stage:

    • WebP is encoded only for the Discord upload;
    • colours are looked up in an index;
    • the unit id set is built once per Mapper load;
    • the unused website-only render path is removed.

    None of these was measurable on its own. Encoding is about 74% of render time now.

  • Measured and dropped:

    • Encoding a contiguous copy of the canvas saved nothing.
    • A per-render memo for unit lookups would save about 1% of a render and touch 7 call sites.

Verification

  • Golden harnesses (gated, off in CI) render 40 real saves:

    • MapRenderGoldenHarness: 3,711 map, tile, context-image and overview renders;
    • ImageGeneratorsGoldenHarness: 684 cases;
    • ScatteredImagesGoldenHarness: 233 cases.

    They compare pixels, website overlays, random-call counts, game state and messages.

  • Every stage except the fog fixes was identical in all three harnesses. The Stage 4 run rendered every game from its copy.

  • The fog stage's differences were all classified:

    • 8 fog views that used to crash now render;
    • 45 renders no longer send channel messages;
    • all game-state changes come from the moved writes listed above;
    • no other image changed.
  • Tests: 1,763 tests pass, with 0 failures.

Not covered by real data: no save in the corpus uses map segments, extra boards or ghost hexes, so those paths were checked with synthetic games only. A testbed check on a live game with segments would be worth doing.

🤖 Generated with Claude Code

wholton and others added 29 commits October 21, 2026 07:42
Ignis Aurora unit upgrades (baldrick_ff2, baldrick_dn2, ...) were removed in #2799
while ended games still hold them, so hasInfantryII and the unit-upgrade/purged
tech paths threw an NPE and failed the whole map render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compute presence, per-position adjacent players and direct neighbour sets
once per call in PlayerNeighbourGraph instead of re-scanning the board for
every other player. Results are unchanged (sets) and now come back in
getRealPlayers() order. playerIsInSystem checks hasIIHQ before the tile-map
scan behind isMecatol.

Characterization test compares against the legacy algorithm on the test
saves and synthetic 6-32 player games with hyperlanes, wormholes, Creuss,
Travel Ban, dummy, eliminated and spectator seats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2799 replaced the baldrick_* unit upgrade techs and units with the base
ones (baldrick_ff2 -> ff2, baldrick_fighter2 -> fighter2, ...) without
migrating saves. Rewrite those ids in every tech list and units owned,
for games created on or before the removal, ended games included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RenderRandom delegates to ThreadLocalRandom in production. Test modes
(neutral and eager) make every render-reachable random call constant and
count calls per tag, so renders can be compared pixel for pixel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
NeighbourMap memoizes Player.getNeighbouringPlayers(false) per player and
derives the equivalent-neighbour set from it instead of recomputing every
other player's neighbours (quadratic on mega games). Neighbour and guild
ship icons are now drawn in game.getRealPlayers() order instead of
HashSet order, so renders are repeatable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
MapRenderGoldenHarness renders a directory of saves for every display type,
fog private view and segment, with pinned randomness, clock, AFK state and
network emoji fetches, and records a raster hash, website overlays, random
call counts and timings per render. It runs only with -Dgolden.saves and
compares against a stored baseline with -Dgolden.mode=compare.
MapGenerator gains package-private drawCanvas() and overlays() so the canvas
can be inspected without encoding it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The player area draws a shuffled spelling of the relic name, which made
renders differ between runs. Found by the golden harness determinism check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The record hash used the enum's identity hash, so HashMap<UnitKey, ...>
iteration order, and with it the order in which overlapping units are drawn
on a tile, changed between bot restarts. Found by the golden harness
determinism check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Both are skipped unless their golden.* system property is set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Measured on 37 corpus canvases (two concurrent encodes, as in production):
encode time -60% overall (typical 3-8 player map -47%, 29 player map -84%)
for +13% file size overall (+4 to +10% for typical maps, up to +21% for the
widest maps). PSNR is within 0.4 dB of method 4. Thread level gave under 8%
and is left off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FogView owns the viewer, the visible systems and the fog-substituted tile map
that MapGenerator used to hold in four mutable fields. WebsiteOverlays replaces
the static addWebsiteOverlay helpers and keeps the null-model guard.
ObjectiveBox and PlayerAreaGenerator now take them instead of a MapGenerator.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…asLayout

MapGenerator's constructor interleaved fog setup, frame fitting, segment
selection, pinned corners, connection strip sizing and the below-map height
estimate through mutable fields. They are now resolved once into immutable
BoardLayout (ring and frame arithmetic, tile origins, segment framing via
FrameResolver) and MapCanvasLayout (canvas size, height estimate kept exactly).
The score track, priority track, objectives and connection strip move to their
own sections because the layout needs their widths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
MapRenderer produces a MapRenderResult (canvas, encoded bytes, website overlays,
website eligibility). MapImageEncoder picks webp or jpg by size, MapUploads
performs the Discord and website uploads with the same gates as before, and
RenderTimings keeps the debug bot-log message format. MapRenderPipeline now
calls these instead of driving a MapGenerator through its lifecycle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The board section draws the setup tiles and every TileStep in the order the old
MapGenerator used, including the ghost hex pass between the Tile and Extras
steps. The dead min/max coordinate bookkeeping and the unused global unit
coordinate aggregation are dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The header stays one section because the deck row and turn order x positions
depend on the game name width and the round label width. Its parts are now
separate classes: SegmentTitle, GameModeBadges, ServerIcon, StrategyCardRow,
CardDeckRow (a list of DeckBadge drawn by one loop instead of eleven copies),
TurnOrderTracker and ExpeditionTracker. Graphics state and landscape offsets
are carried over exactly as they were.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
LawsAndEventsSection draws both card kinds through CardBox and TextWrap. The
wrap helper reproduces today's breaks: laws break at the last space, events
break mid-word. Law quirks that depend on call order are preserved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Hex-tile stats and the old anchor stats are separate classes, and the off-board
highlights are a per-display-type strategy (legendaries, wormholes, anomalies,
traits, tech skips, attachments) that returns the count the speaker token is
offset by. Draw order within each player is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… enums

MapRenderer.paint now owns the draw order: board, connection strip, header,
score and priority tracks, objectives, laws, events, map-side stats, then the
player areas through PlayerAreasSection. MapGenerator keeps only the nested
HorizontalAlign and VerticalAlign enums until they move to top-level types.
Frame, height and layout tests, the golden harness adapter and the benches are
repointed at MapRenderer without changing any assertion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mechanical: the nested enums of the now-empty MapGenerator become top-level
enums, every import and qualified use is updated, and MapGenerator is deleted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The canvas height used to be a per-player guess that clipped big games
(long spectator lists, 28-player stats) and left empty tails elsewhere.
drawAllPlayerAreas now returns its real bottom, including the wrapped
spectators line. The map is drawn onto a canvas allocated from the old
estimate plus one fifth and cropped to the drawn content plus a margin; if
the content is taller, only the player areas are redrawn on a taller
canvas (a full repaint for landscape, where the board shares the rows).
The encoder picks webp or jpg from the cropped size.

Tests that asserted the old estimate now assert that the content fits and
that no large empty tail remains.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Strategy cards, round, card decks, turn order and the expedition tracker
were placed by independent formulas, so at narrow canvas widths or high
player counts the tracker covered the turn order and the decks. HeaderLayout
now places measured boxes in a flow: the tracker keeps its classic position
unless something would touch it, then slides to the right edge before
anything wraps, and whatever still collides moves to the next line below it.
Strategy cards use balanced rows for every game and their website overlays
follow the row they are drawn on. The tracker is also kept inside the top of
the canvas for stats renders.

Strips that already had room draw exactly the same pixels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Events broke their body text mid-word while laws already broke at the last
space. Events now use the same break, and TextWrap falls back to the
mid-word cut when a line has no space to break at, instead of leaving the
first line empty.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Landscape renders placed the info column at the map width but measured it
from the map's own edges: the turn order added the shift twice, the
expedition tracker was left over the map, the round wrap check used the map
strip width, and the player areas started at mapWidth + 10 while using
mapWidth as their right edge (a negative width, so no frames and no
reinforcement tiles). The header strip now measures from the right edge of
the landscape canvas, the player areas fill the column from mapWidth + 10 to
the canvas edge, and the height estimate uses the same column.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nders

The badges are drawn 150 px above the name row. Renders without the map above
the header put that row at y = 100, so the Absol, DS, TIGL, MiltyMod and No
Swap icons were clipped by the top of the canvas and, on stats renders,
overlapped the timestamp line. Those renders now reserve room above the
header when a badge is shown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t it

The spectators line starts at the left margin but wrapped at the full canvas
width, so long lines ran 10 px off the right edge and lost their last
characters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
When a law or event wrapped at a space, the space stayed at the start of the
second line and indented it by one character. The break now consumes the
space.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Body text that needs more than two lines kept running past the card, into
the neighbouring card in the other column and off the canvas for the longest
homebrew texts. The body now shrinks in steps down to 16 px until the second
line fits, and is cut with an ellipsis beyond that.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cards whose text needs more than two lines used to shrink the text and end
in an ellipsis. The body now keeps its standard size and wraps onto as many
lines as it needs, and the card grows by one line step per extra line with
the standard bottom padding. Cards are laid out in rows of two, and both
cards in a row take the height of the taller one so their borders line up.
The player-area height estimate and the website overlays use the grown
heights, so the player areas below are neither clipped nor overlapped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🔬 Test Results

✅ JSON Validation

No JSON files changed.


✅ Formatting Checks Passed

All files pass Spotless formatting.


✅ JUnit Tests Passed

All tests passed successfully.

wholton and others added 19 commits October 21, 2026 19:28
…er reads visibility instead of inside the visibility checks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…while the board is painted

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… of while the planet card is drawn

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…me is loaded or saved instead of while a planet card is drawn

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…he fallback icon without storing it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…l back to a plain one without storing it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…f messaging the game channel or the player

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ate it in a step after the map layout when a board render has units to draw

The objective boxes count the neutral player, and the render used to create it as a side effect of the production badge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nstead of adding the original trait to the planet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…k and the pings so the production badge and the Fog of War Plus vortex can use them safely

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…one and sends no messages, on built games and on saved games when they are available

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… is resolved once at the start

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ilds can be compared render by render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…hey are queued

The render threads used to paint the interaction's live game while the handler kept changing and saving it. The copy is an in-memory save and load round trip plus the shown tile distances, which the save file does not keep. The image count is now incremented when the render is queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s for thread safety

Mapper.init() used to clear and refill the maps renders were reading. Each map is now built fresh and swapped in, colour copies included. The tile painter and wormhole spot maps are read-only, the golden harnesses are isolated from parallel runs, and a test fails on new mutable statics in the render path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Website-only renders and renders without a Discord upload used to pay for the encode too. The Discord bytes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d set once per Mapper load

The index keeps the old precedence: the first colour by alias, name or any alias. The unit id set keeps the iteration order a rebuild gives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… uploads to Discord

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wholton wholton changed the title Rewrite MapGenerator into focused render classes Rewrite the image code into layered packages with pure, thread-safe renders Oct 22, 2026
wholton and others added 7 commits October 21, 2026 23:57
…apper

BoardPosition is left as position parsing, and PositionMapper owns all tile pixel coordinates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It holds the galaxy map's grid (adjacency, rings, corners) and the pixel positions of everything drawn on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the ACD2 handler import conflicts and points master's new Mapper imports at ti4.model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop saved every game a migration covered, changed or not. The removed Ignis Aurora upgrade migration covers every game created before 23 January 2025, ended ones included, so startup re-saved thousands of untouched games and published each one to the website, which failed for many old saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the old migrations

The cutoff moves to today so games created after 23 January 2025 are covered too. The commented-out Garbozia, misspelled agenda and locked agent migrations are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ainst_all_games instead of a startup migration

The startup migration is removed, so the migration registry is empty again. The developer command, which last held the already-run most-points winner backfill, now rewrites the removed upgrade ids in every game, with a dry run by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ports the Stoneborn player-area changes from the deleted PlayerAreaGenerator: the Fragmentum token supplies go into TheodisiTokenSupplies and the Stoneborn icon on its relic fragments into SupplyBlock. Master's new Mapper imports point at ti4.model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wholton
wholton merged commit 6355964 into master Oct 22, 2026
3 checks passed
@wholton
wholton deleted the map-generator-rewrite branch October 22, 2026 06:32
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