Bsp asyncti4 map picker signed - #18
Open
ben-phillips wants to merge 23 commits into
Open
Conversation
AsyncTI4's web UI is a SPA over a public JSON endpoint (bot.asyncti4.com/api/public/game/<id>/web-data), served with `access-control-allow-origin: *` — so a battle can be pulled straight out of a live game from the browser with no proxy. The dialog takes a game id or link, lists every system and planet holding units (contested locations first), and imports the chosen one: both factions, the units standing there, and each side's researched technologies. Damaged and galvanized stacks come across as PRE_DAMAGED and PRE_GALVANIZED, since `unitStates` upstream is [healthy, damaged, galvanized, damaged galvanized]. A space battle also picks up the PDS and space docks on the system's planets, which is where its space cannon fire comes from. Import goes through `loadConfig`, so the result lands in the URL like any other configuration and stays shareable. The alias tables were derived from AsyncTI4's own tech catalogue and cover the base game, POK, codices, Thunder's Edge and Twilight's Fall (whose factions are colour ids upstream, matched here by flagship and mech name). Cards are mapped only where the printing matches what this calculator implements — `md_base` and the pre-ΩΩ X-89s do something else entirely, so they are reported as unmodelled rather than silently simulated wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twilight's Fall keeps its unit upgrades in a shared card deck rather than a research track, so upstream records them in `unitsOwned` — the unit sheets a player has — and not in `techs`, where the import was looking. Nothing came across. They map to the TF_UPGRADE_* ability cards, which are switched on rather than flipping a unit's UPGRADED flag the way TI4's upgrades do. Four upgrade cards upstream have no counterpart here (Floating Factories, Helios Entity, Production Biomes, Valefar Prime); all four differ only in movement, production or cost, so they are skipped silently rather than reported. Neither combat is confined to its own area of the map, so neither is the import any more. A ground battle now takes the fleet overhead as well as the planet: those ships carry the invading troops and fire the bombardment. A space battle keeps the ground forces riding in the space area, since they are what an invasion commits. Adds a trimmed fixture from a real TF game (sample-tf) covering the colour faction ids, the owned-card upgrades and the shared ability deck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Occupancy is a poor way to find the fight. Two players sharing a planet often just means one side's structures sitting on ground the other holds, and a space contest barely shows up at all: the loser's ships are gone by the time the next snapshot is taken. Across three real games — a TI4 one, a Twilight's Fall one, and one paused mid-combat — not one location has two players' units in the same space area, and the game that is actually in combat has no contested location anywhere on its map. `gameState.activeCombat` says outright which system or planet is being fought over and by whom, so use it: that location sorts first, is marked in the list, and seeds both sides. Its participants come from the combat rather than the location, because a side can be in the fight without holding the ground — at frac7 in sample-combat the defender's fleet is already destroyed and only its space cannon on a nearby planet is still shooting. Where no combat is open the ordering now prefers a shared space area over a shared planet, per the same reasoning, then everything quiet. Alliance mode is out of scope: modelling it needs more than two players' units on one side. Also fixes an unrelated defect this surfaced. PRE_DAMAGED's `damagedUnits` never declared a `defaultItemValue`, so reconciliation filled unlisted units in as bare `[type]` 1-tuples, which the URL codec cannot tell from an order-mode list. Damage counts were silently lost on refresh or through a shared link, with or without an import; PRE_GALVANIZED already declared one. Covered by a round-trip test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AsyncTI4 is maintained by another project and offers no public catalogue
endpoint — everything but /public/game/<id> is behind auth — so the
vocabulary tables have to stay hardcoded. What can be done is depend on
less of it, notice sooner when it moves, and fail loudly rather than
silently.
Depend on less: isTwilightsFallMode, eliminated and controlledBy were read
from the payload and never used. Of what remains, only playerData and
tileUnitData are load-bearing; every other field now degrades to null on
anything unexpected instead of failing the parse, so a type change in a
display label can no longer take a whole import down.
Notice sooner: AsyncTI4 stamps each payload with `versionSchema` (7 today)
and its own UI reads it. A mismatch is now surfaced as a note on the
import — a warning, not a refusal, since most bumps won't touch the few
fields read here.
Fail loudly, two ways:
- A local test asserts every value in the mapping tables still resolves
to a real faction, unit type or ability key, and that every faction
and TF upgrade the calculator models is reachable. Renaming an ability
here would otherwise break the import silently.
- `npm run check:asyncti4` fetches the three games the fixtures were cut
from and checks the assumptions the import rests on: schema version,
faction and unit vocabulary, and that unitStates is still four wide,
since damage and galvanize are read positionally. Kept out of the
normal suite — it needs the network, and a red result means upstream
changed rather than this repo being broken. Green and silent today, so
any future output is signal.
Test data moves to tests/async-ti4/fixtures/ with a README covering
provenance and how to re-cut one; src/async-ti4/ is now production code
only, matching how the rest of the project keeps its test data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixtures carried real player names and were labelled with the games
they came from. Game names and usernames are now placeholders, and no
test, comment or doc names a source game. What's left is the vocabulary
under test — faction ids, colours, technologies, unit placement — which
would defeat the point if it were invented. `isTwilightsFallMode` and
`eliminated` are dropped too, since the import stopped reading them, so a
fixture now shows exactly what it depends on.
The live check no longer counts on any game being in a particular state. A
combat resolves within a turn or two, so its presence is never asserted —
only its shape when there happens to be one. Games also finish and get
archived: upstream answers 400 for a game it no longer has, which was
being reported as an outright incompatibility. That's now a warning to
replace the sample, and the sample list can be swapped without editing
code:
ASYNCTI4_GAMES=abc123,def456 npm run check:asyncti4
A run that reaches no game still fails, since it has proved nothing, but
says the samples have likely finished rather than blaming the mappings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the seven cards in the generic technology deck were mapped, so every faction's own technology was silently dropped — Sardakk's Valkyrie Particle Weave, Last Bastion's Proxima Targeting VI, and seven more. They are researched technologies like any other, and the import claimed to carry a side's technologies across. Sweeping AsyncTI4's catalogue for cards whose name matches an ability modelled here turned up exactly these nine and nothing else, so the gap is now closed rather than patched. Temporal Command Suite maps to a pair of keys: Nekro reimplements that one card under its own key while reusing the shared key for the rest, and a side is only given the key its faction actually has. A test now asserts every technology modelled here — generic deck and faction decks both — is reachable from some alias. That is the check that would have caught this, and the earlier table tests did not: they verified every mapping pointed at something real, never that everything real could be reached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract check had no default game list, since committing ids would put a lasting pointer to other people's games back into history. That left it needing an environment variable to do anything, which is the surest way for a maintenance tool to go unrun. It now reads `.asyncti4-games` at the repo root — one id per line, `#` comments allowed — so the ids are set once and stay out of version control. `ASYNCTI4_GAMES` still overrides for a one-off, and with neither the run explains how to make the file rather than just failing. The check is worth reaching for in one situation, so the throw site for "Unexpected data format from AsyncTI4" now names it: that error is what an upstream change looks like from inside the app, and it is where someone debugging one will be standing. The runtime message is unchanged — it is read by someone in a browser, not by whoever fixes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the location dropdown with the board: one hexagon per system, wearing the icon of whoever holds it and the number of units standing there. Tap a system to see its areas — space and each planet — and pick one. Contested systems and any live combat are tinted. Whoever holds the chosen location becomes the DEFENDER. The import's job is to say what you would be attacking into; an attacking fleet is rarely assembled from one system, picking up fighters and ground forces on the way, so it stays a manual job in the battle card. A live combat is the exception and keeps its own ordering, since its participants arrive with the active player first. No image assets. Positions come from AsyncTI4's ring numbering, which is a plain hex ring walk — start at the top, six legs of N steps — verified against all 1311 entries of the position table their own map uses, so three-ring six-player maps and the larger seven- and eight-player ones lay out from the same code with no per-map data. The eleven off-grid slots are placed here rather than copied: the four corner slots holding disconnected homes (Mallice, Creuss, Crimson Rebellion) drop into the corners of the bounding box that a hex ring map always leaves empty, costing no width, and the seven-tile fracture gets a strip above. Upstream parks those in the corners of a large canvas, which is what made an earlier measurement look like it needed 39px hexes. Hexes are 56px on a 390px phone and tile with no gaps between them, so a tap that misses a centre still lands on a system. Systems holding nothing are drawn but not offered, keeping the board's shape without adding dead tab stops. Anything the grid cannot place is reported rather than dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hexagon was summing ships and ground forces into one number under one
icon, which said nothing useful: a system with three ships and sixty
infantry read the same as one with sixty ships, and there is no cap on
what a tile can hold.
The hexagon is now about the space area alone — whose ships hold it and
how many. Ground presence is a dot per planet holding units, and the
counts live in the area list below, which has room to say that a planet
holds sixty infantry. Areas are listed space first, since that is the
commoner pick and an invasion is settled in orbit before it reaches the
ground.
Contested space is the state worth spotting, so it now reads as a ring
rather than another shade of fill. A clipped hexagon cannot show a border
— clip-path cuts it away — so each cell is an outline layer with an inset
face on top.
Two colour bugs found while making that visible:
- The accent scale is only defined inside a `.theme-*` scope. Outside
one, every `--color-accent*` resolves to `initial`, so the dialog's
highlights had been silently painting nothing. The board now sits in
`theme-defender`, which is also what it means: the location you pick
becomes the defender.
- In dark mode `--color-background-input-field` is 80% of the very
colour the dialog is painted in, so every hexagon dissolved into the
background and left the icons floating. Faces use the muted background
instead, checked in both themes.
A selected system fills solid rather than taking another tint, because the
defender accent and the amber marking a live combat are too close in hue
to tell apart as shades.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the map made visible. The dialog ran off the top and bottom of the screen with no way to reach either end. `.content` asked for `overflow-y: auto` but was never given a height to overflow, so instead of scrolling it simply grew, and the centring wrapper pushed it past both edges at once. It now stops at the height of its wrapper, which also gains vertical padding so it clears the screen edges. This is in the shared dialog, so the settings and supported- abilities dialogs get it too — both checked for regressions. The map had no floor: a bigger board just meant smaller hexagons, forever. It now stops shrinking at a 52px hexagon — enough for the ~44px tap target given flat-top hexes are shorter than they are wide — and scrolls sideways below that instead. Measured on a 390x844 phone with synthetic boards, since no sample game has more than three rings: 3 rings (6 players) 56px hexes, no scrolling 4 rings (7-8 players) 52px hexes, scrolls a little 5 rings (expansions) 52px hexes, scrolls more At 360x640 with a four-ring board the dialog fits and the import button is reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things. The dialog was wearing its own clothes. Load and Import were icon buttons with text stuffed into them; field labels, area names and the game id all picked their own type. They now use what the rest of the app uses — heading font, uppercase, letter-spaced for controls and labels, mono for identifiers — and Import takes the defender accent, since that is the side the dialog fills. Space now spans the full width above the planets, which share a row two at a time and wrap. It is a different fight from the ground below it, not one option among the planets, and the layout should say so before the labels do. The "ground" chip is gone with it: a planet name sitting under a full-width Space row no longer needs telling apart. Planets were showing their raw keys — `Mrte`, `Meharxull`. They now read Mecatol Rex and Mehar Xull, from a table of 574 planets; there is no public endpoint serving the catalogue. A key with no entry falls back to title case, which is right often enough and never worse than the raw id. Two things that bit while building it: planet records take several shapes and no single pattern finds them all, and homebrew reskins reuse other planets' holder keys — one claims `mirage`, which renamed the real Mirage to "Illusion" until the planet's own id was given precedence. The planet dots move from the top of a hexagon to below the ship count, so a system reads top to bottom as space then ground. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A planet with nothing standing on it was skipped entirely: no dot on its system, no row in the area list, no way to pick it. That hid the commonest invasion target there is — 17 of the 60 planets in the sample game hold no units, and 8 of those are owned by someone. Everra on 308 is held by Sol with an empty surface, and did not appear at all. Every planet is now listed. With nobody standing on it, the planet's holder is the only clue to who you would be fighting, so `controlledBy` comes back into the payload schema — dropped earlier as unused, and now the one field that answers the question. A planet nobody holds reads as unclaimed rather than as a faction with zero units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bare total said how much was somewhere but nothing about what. The area buttons — where you choose which fight you mean — now use the notation the outcomes table already uses: a short unit name, a count in front only when there is more than one, a trailing `-` on a damaged stack, ordered like every other unit list in the app. The Cabal fleet at frac4 reads `W-, 2Cr, 3F`. The hexagons keep a plain total. They are for scanning the board — where the weight is and whose — and a composition in a 56px cell either crowds out the faction icon or shrinks past reading. Galvanize folds in as healthy rather than damaged: it is a mark on a unit, not a wound, and only the damaged states carry the `-`. A lone planet now spans the full width like the space area above it, since there is nothing for it to sit beside. The import button goes back to the neutral colours it had before — it is not the accent-coloured thing I made it — with more padding and height than it started with, which is what was tight about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon disappeared from a system the moment it was selected. It had only ever cleared the face layer by accident: `opacity: 65%` gave it a stacking context, which lifted it into the same paint step as the absolutely positioned face above it. Raising the opacity to full on selection removed the stacking context and dropped the icon behind the fill. Contested systems lost theirs the same way. The icon is positioned now, so it paints above the face whatever its opacity, and inverts with the number and the planet dots. The game id input was `--control-size` while the Load button beside it had grown to `--space-9`, so they no longer lined up. Both are `--space-9`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Units of other players can sit on a planet without defending it, so counting them overstated what an invasion would face. Mecatol Rex read as `Embers of Muaat vs Deepwrought Scholarate · 4M, 15I, SD` when the fourth mech belongs to a player merely coexisting there; it now reads `Embers of Muaat · 3M, 15I, SD`. Such planets also stop counting as contested, which matches how they actually sit: two players sharing a planet is not the signal a shared space area is. Where the controller has nothing standing there the rule is skipped — showing an empty planet while hiding somebody's army would be worse than the overstatement it fixes. This is display only. Picking a side still imports that side's own units, so a coexisting player can still be chosen and brought across. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Planet name, then owner, then units, each on its own line, with the units in mono like every other identifier-ish string in the app. A long fleet no longer has to share a line with the faction that owns it. Also drops the branch handling an owner with no units on their own planet: a coexisting player would have taken control of it, so the state does not arise, and the code claimed to handle something real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The import button was lost below the fold. Dropping the explainer under the side selects and the line restating the current pick helped but not enough — the board alone is most of the dialog's height — so the button is pinned to the bottom of the dialog and stays put while everything scrolls under it. Reachable without scrolling at 390x844 and at 360x640. The line restating the selection was saying what the highlighted button already says, and had to be kept in step with it besides. Tapping a system now picks a fight in it straight away rather than only when there was a single option: the space battle, or the system's first planet where nobody is in orbit. Both orderings come from one place now, so the list and the default can't disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AsyncTI4 lists a space station among its system's planets, but there is no ground for an invasion to land on, so one was offering itself as a battle and counting towards a system's planet dots. Revelation in pbd27134 is the one you spotted — on 316 rather than 317 — and there are fourteen in the catalogue, of which four turn up across the three sample games. They are marked `SPACESTATION` in AsyncTI4's planet types, which the game payload does not carry, so the set is kept alongside the planet names. Also pluralises a system's unit count in its tooltip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things, all about systems with nothing standing in them. Every system now offers its space, occupied or not, and every hexagon is clickable. A fight can be planned somewhere nobody is sitting, and in an anomaly the surroundings decide as much as the fleets do. A system holding nobody shows no icon and no count rather than a bare zero. A planet nobody holds and nobody stands on is dropped: it is not a battle, troops just land on it. In a four-ring game that is 41 of them, and carrying them made quiet systems look busy. Anomalies are hatched. AsyncTI4's payload says only whether a system is one, so which kind comes from the tile catalogue, and the two the calculator models — nebula and entropic scar — are set on both sides of an import. The nebula is registered on the defender alone, so its entry for the attacker is dropped as the config loads. The space area names its system. A hexagon whose planets are all locked or unclaimed was otherwise anonymous: Mallice sits behind a locked nexus on a corner slot with nothing to identify it, and dropping unclaimed planets would have made that worse rather than better. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hyperlane joins systems rather than being one — ships pass through and cannot stop there — and a blank draft tile is setup left behind. Both occupy a position, so both are still drawn and the board keeps its shape, but neither offers a battle and neither can be clicked. Eighteen of the thirty-eight tiles in one sample game are hyperlanes. Making empty systems selectable had a consequence I missed: a game with no contested location opened on bare space, with both sides blank and the import button disabled. Anywhere with somebody in it now outranks anywhere empty, so the dialog opens on a battle that can actually be run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverses skipping a planet nobody holds and nobody stands on. Such a planet is still somewhere a landing can be planned, and leaving it out made systems look emptier than they are — Mallice worst of all, sitting behind a locked nexus on a corner slot with nothing else on the tile. They are drawn with a dashed border and muted text, so a defended planet still reads differently at a glance, but they select like any other. Co-Authored-By: Claude Opus 5 (1M context) <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.
Potential implementation for Issue #16
Added in the ability to put in an async game, and then pick the hex that you want to run a battle for. It will load in environment / tech / unit counts.
My goal was to have an easy to use visual map without pulling in a bunch of image assets and duplicating what's already handled in async.
General concern:
I'm not sure how often async changes their data structure (it's v7 currently). Not sure if this feature will be a total headache to maintain...
Open items:
Unclaimed planets: I currently have it showing indications for all planets, but it could show only planets that are owned by other players.
Anomalies: all anomalies are currently shown on the map, could be changed to only show combat anomalies (and grav wells?)
Coexisting units: currently it just shows the units for the owner on the planet
Not addressed:
Adjacent PDS are not included. I didn't want to start getting into adjacency checks, so it does not consider PDS from other players / systems when loading in units.