Repository navigation
Rewrite the image code into layered packages with pure, thread-safe renders - #5955
Merged
Merged
Conversation
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>
🔬 Test Results✅ JSON ValidationNo JSON files changed. ✅ Formatting Checks PassedAll files pass Spotless formatting. ✅ JUnit Tests PassedAll tests passed successfully. |
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rewrites the bot's image code:
MapGenerator,TileGenerator,PlayerAreaGenerator,DrawingUtiland 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,draftandcard;draw: shared drawing helpers;io: fonts, caches, encoding, uploads and asset paths.draftmay also usetile. Otherwise sibling packages never depend on each other.ImagePackageLayeringTestenforces this and fails ifti4.modelorti4.gameimports image code.Model layer:
Mapper,TileHelper,PositionMapper,UnitTokenPositionandBoardPositionmoved toti4.model. The move is scripted, so it can be re-run on another branch to fix conflicts mechanically.MapGenerator is replaced by:
MapRendererfor the draw order;RenderContext, resolved once per render;MapUploadsfor the Discord and website uploads.TileGenerator is replaced by
TileLayers(one painter per tile step),SystemContextImageand 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,PaImageand others), and duplicate copies were merged.Scattered image code:
ti4.image.draft, and the two slice renderers became one.ImageGrid.StoragetoFonts, andFileUploadServicebecameImageUploads.ImageResources.Behaviour changes
Fog of War:
-privatechannel used to render unfogged. They now use the same viewer rule as the map.Renders no longer change the game. These writes are now explicit steps before or after the render:
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:
Thread safety
MapRenderPipeline.queueandqueueImagetake 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.init()used to clear and refill the maps renders read. It now builds new maps and swaps each in whole.RenderStaticStateTestfails on any new mutable static field in the render path.RenderThreadSafetyTestcovers:ExecutionLockManagerlocks can expire while still held.Performance
Neighbours: computed once per render with
NeighbourMap, andPlayer.getNeighbouringPlayersbuilds 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:
None of these was measurable on its own. Encoding is about 74% of render time now.
Measured and dropped:
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:
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