Add a Blueprint Info editor: name, icons, description, and grid alignment - #243
Add a Blueprint Info editor: name, icons, description, and grid alignment#243koenigstag wants to merge 30 commits into
Conversation
The blueprint's own name, description, and icons had no editing UI at all: name was a bare mutable field nothing wrote to interactively, description was explicitly marked "unused" (round-tripped but never read or written by the app), and icons only had a private auto-generation path (generateIcons, used by serialize() when none were imported). Adds: - Blueprint.name/description as proper get/set pairs routed through history (undo/redo support), each boxed the same way scheduleStore already is - `history.updateValue` needs `keyof T` on whatever it's handed, and `keyof this` never includes a private member. - Blueprint.getIcon/setIcon, wrapping the existing icons Map with history.updateMap. Clearing all four slots by hand is a real choice rather than a no-op: it puts the blueprint back into the same "auto" state generateIcons uses when nothing was ever set. - BlueprintInfoEditor, a Dialog (not Editor - there's no Entity here) with a name field, a multi-line description field, and four icon slots (BlueprintIconSlot, a Slot like every entity editor's icon pickers) backed by a new FD.acceptedSignalIcons() - items, fluids, and virtual signals. - BlueprintInfoButton, a persistent corner button that opens it - positioned left of EntityInfoPanel's own 270px width so the two, both anchored top-right, never overlap. - TextInput gained optional multiline/height parameters (backing a <textarea> instead of an <input>) for the description field - every other existing call site is unaffected, since both default off. InventoryDialog changes, needed by the icon picker rather than by blueprint info itself - the same computeWidth/scrollbar-anchor/ showRecipePanel changes as the display-panel editor PR, ported here independently since this branch does not depend on that one: - computeWidth widens the dialog when a filter populates more group tabs than the fixed 404px layout was designed for - the icon picker's items+fluids+signals filter is the first caller on this branch to hit that, up to 7 tabs against Space Age data. - The scrollbar thumb was pinned to a fixed VP_X + VP_W offset, correct only at the old fixed width; anchored it to `this.width` instead. - A showRecipePanel flag (default on) lets the icon picker skip the recipe panel, which would otherwise render as a permanently empty bar - none of items/fluids/signals have a recipe.
Right-docked (left of EntityInfoPanel) worked but sat far from everything else in that corner. Left of the website's own DOM overlay (FBE logo, Discord/Github buttons) puts it next to the rest of the persistent chrome instead, and the position no longer depends on screen width, so the resize listener is gone with it.
New "Snap to grid" section in BlueprintInfoEditor - grid size
(Width/Height), grid position (X/Y), and an Absolute/Relative choice -
covering `snap-to-grid`, `absolute-snapping` and
`position-relative-to-grid`, which the editor already round-tripped on
import/export but never exposed or let anyone edit.
Verified the exact serialization rules by decoding three real
exported blueprint strings by hand (Absolute, Relative with a default
position, and grid off) rather than guessing:
- Absolute writes `absolute-snapping: true`.
- Relative at the default `{0, 0}` position omits both
`absolute-snapping` and `position-relative-to-grid` entirely -
`false` and `{0, 0}` are Factorio's own omitted defaults, not
something to write out.
- Grid off omits all three keys together.
`Blueprint.absoluteSnapping`'s default flips to `false` accordingly,
and `serialize()` now gates `absolute-snapping`/`position-relative-to-grid`
on `snap-to-grid` being set (and the position on being non-zero)
instead of forwarding the boxed stores directly - otherwise turning
snapping back off could leave a stale `absolute-snapping: true` behind
from before it was, which the old direct-forward version would have
done since nothing cleared it.
Also: enabling "Snap to grid" from off always selects Absolute, which
is what the game's own default does (confirmed the same way - a
freshly-enabled-and-exported string always carries `absolute-snapping:
true` rather than omitting it).
New `RadioButton` control (circular, mutually-exclusive-by-convention)
for the Absolute/Relative choice - `Checkbox` draws a rounded square,
which reads as an independent toggle rather than one of two options.
Icons moved above Description in the dialog, per request.
.disabled already stopped typing (the real DOM disabled attribute
underneath), but nothing dimmed them: TextInput's own box config
comments out a 'disabled' style, and .alpha only reaches the box
graphic - _updateDOMInput's opacity line is commented out too, citing
a pixi.js worldAlpha/DOM sync issue - so a disabled field looked
identical to an editable one. setInputStyle('opacity', ...) reaches
the DOM element's own CSS directly instead, which the other two routes
each miss half of.
Grid position's X/Y now stay editable whenever the grid itself is on, regardless of which of Absolute/Relative is picked - only Blueprint.serialize cares which one is chosen, so switching between them and back doesn't lose whatever was typed there. The decorative Absolute row's X/Y (no Blueprint field backs them - see the comment where they're built) go the other way: enabled only while grid is on *and* Absolute is the active choice, disabled otherwise, since there's no data behind them to preserve across a switch.
actual content width instead of guessed coordinates Two bugs from the same root cause: Grid size's Width/Height weren't flush with the dialog's right edge, and Grid position/Absolute's X/Y ran past it entirely. Both rows used fixed x-coordinates guessed from a mock-up rather than derived from the dialog's real content width - 336px, the same width BlueprintInfoEditor's Name/Description TextInputs already use. COL1_X/COL2_X now anchor the two value columns' input boxes to that width, and every field label (Width:/Height:/X:/Y:, four of them across three rows) right-aligns to its own input at a fixed 3px gap via makeFieldLabel, rather than each sitting at its own guessed x - which had put "X:"/"Y:" far from their input while "Width:"/"Height:" sat close to theirs.
…labels RadioButton draws its own label at local y=0, but every field label (Width:/Height:/X:/Y:) sits at its row's y+8 - the offset that centres it against the neighbouring 24px-tall input. Absolute's radio and its own X:/Y: label shared a row but not that offset, so "Absolute" read 8px higher than the text right next to it. Both radios now position at +8 too, which also centres their circle against the row's input boxes better than the unshifted position had.
…nto blueprint-info-editor
…Fan#222 position-relative-to-grid belongs to Absolute snapping, not Relative - measured against Factorio 2.0.77 (issue FactoryGameFan#226, tools/oracle/fixtures/blueprint-snapping.json), reversing the guess the original comment made from three hand-decoded strings. Inverts the condition in Blueprint.serialize() and reconciles BlueprintAlignment's two X/Y pairs into the one backed by the real field, enabled only under Absolute. Also fixes positionIsDefault, which conflated an explicit {0, 0} with an absent position and stripped both - tests/blueprint-snapping.spec.ts pins that an explicit {0, 0} passes through unchanged, so this now checks only for undefined. And moves BlueprintInfoButton inside the `if (!isMobile.any)` guard in UIContainer, where it belongs alongside quickbarPanel/wiresPanel - the editor is view-only on mobile. Merges wormeyman-space-age-support in first, which is where blueprint-snapping.spec.ts and its fixture/probe landed.
…print-info-editor
…Button The only way to switch entries in a loaded blueprint book was a bare "BP Book Index" number field in the settings pane, with no names, icons, or indication of nesting. BookDialog walks the book's raw entries directly (not the flattened index space Book.selectBlueprint reads) and renders them as an always-expanded tree - nested books get a header row and indented children, planners get a dimmed placeholder row, and a depth guard of 10 stops runaway recursion on a malformed book. Rows scroll with the same mask+wheel+thumb pattern InventoryDialog already uses. BookButton opens it, sitting one slot pitch right of BlueprintInfoButton and visible only while a book is loaded, rather than living in ToolsPanel (FactoryGameFan#221) - this feature has no real dependency on that PR's work, so it gets its own minimal QuickActions (getCurrentBook/selectBookEntry) instead of pulling in the larger interface FactoryGameFan#221 introduces. Editor.init takes a single options object for the same reason FactoryGameFan#221 landed on that shape: a required quickActions and an optional logger don't have to fight over positional order. Also collapses three copies of the same index-switching logic (the settings pane callback, testApi.selectBookIndex, and now QuickActions.selectBookEntry) into one function.
…rintInfoButton" This reverts commit 392d2f4.
…t, pin no-grid edge case Entity.acceptedDisplayPanelIcons duplicated acceptedSignalIcons() in factorioData.ts verbatim; it now calls that function instead of recomputing the same list. Also adds a sixth test to blueprint-snapping.spec.ts pinning that absolute-snapping/position-relative-to-grid are stripped on serialize when there is no snap-to-grid to be relative to - a state the in-game GUI cannot reach, and one the game's own measured fixture shows it cannot reliably round-trip either.
TextInput is the one control here not drawn with pixi: it appends an <input> to document.body and keeps it positioned over the canvas, so nothing pixi draws on top can occlude it. Opening the icon picker over Blueprint Info left six fields showing through it - the name box printing "Blueprint" across the picker's slot row, and five more invisible only because their background is none, all still answering elementFromPoint and so eating the clicks aimed at the slots underneath. Dialog now hides the DOM fields of every dialog that is not the topmost, and gives them back when the one above closes. Topmost rather than "not overlapped" because occlusion is not a question the DOM can answer for canvas content - there is no partial hiding to be had, so the dialog stack decides it. The walk is recursive since BlueprintAlignment's inputs sit in a nested container. TextInput._dom_visible already existed with no way to set it; domVisible applies the change immediately rather than waiting for a render, because _needsUpdate watches only the transform and the canvas rect and a field that never moves again would keep its old visibility forever. Covered in tests/text-input.spec.ts, which is the only spec that asserts on real DOM. Mutation-checked: dropping the call from the constructor fails only the hidden assertion, dropping it from close() only the restored one. Its reads sync on a frame rather than a timeout - _onAdded hides the element and only onRender puts it back, which made a first draft fail 1 run in 6, and an unrendered frame would satisfy the hidden assertion for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A real game screenshot showed two separate X/Y pairs in the grid alignment section - "Grid position" above the Absolute/Relative choice, and a second pair beside "Absolute" itself - where the editor only had one. Decoding blueprint strings exported at each step of a manual game test (not reasoning about it) settled what each one actually does: typing into "Grid position" writes no blueprint field at all, neither snap-to-grid, absolute-snapping nor position-relative-to-grid. It moves every entity's own position by the negation of what was typed, baked directly into the exported entity coordinates, then resets to 0. "Absolute"'s own X/Y is the one that round-trips as position-relative-to-grid. Entity.forceMoveBy bypasses position's collision/wire-reach checks, which compare a moving entity against every other entity's current position - unsafe mid-shift, where that's a mix of already-moved and not-yet-moved entities, even though a uniform translation can't actually introduce a collision or break a wire's reach. Blueprint.translateEntities wraps every entity's forceMoveBy in one undo transaction. BlueprintAlignment's layout now mirrors the game: Grid position gated only by Snap to grid, Absolute's X/Y on its own row gated by Snap to grid and Absolute being selected.
TextInput's 'changed' event fires on every keystroke (the DOM 'input'
event), not on blur. commitGridPositionNudge() resets both fields' text
back to '0' on every commit, so wiring it to 'changed' meant the field
wiped itself back to '0' after the very first character typed - it
looked like the box refused to hold more than one digit.
TextInput._onBlurred already called _setState('DEFAULT') but had its
emit('blur') commented out; uncommented it and moved Grid position's
commit there instead; blur fires once per edit rather than once per
keystroke. The other fields in BlueprintAlignment stay on 'changed' -
their commits re-write the blueprint with the current full text rather
than clearing anything, so repeating them per keystroke is harmless.
Updates text-input.spec.ts's DOM field count/visibility assertions,
which pinned six TextInputs in Blueprint Info before Grid position
added two more.
…yGameFan#222 review items Grid position's premise was refuted by review: Blueprint.serialize() re-centres every exported position on getCenter()'s bounding box, recomputed on every call. Translating every entity by (dx, dy) moves that box's centre by exactly (dx, dy) too, so subtracting the shifted centre from the shifted positions always reproduces the pre-translation numbers - a uniform translation is invisible to a bounding-box recentre by construction, for any implementation built on moving entities. It also explains a second reported bug for free: translating only entities and not tiles let the two drift apart relative to each other once re-centred, since getCenter() averages both together. Replaces it with Blueprint.gridPositionOffset, an accumulated IPoint applied once inside serialize() against the already-computed centre - after re-centring, not before it, which a recentre cannot undo. Live entity/tile positions never move, so PositionGrid, rendering and every other model-level read are unaffected; only the exported string differs. Entity.forceMoveBy and Blueprint.translateEntities, built for the old approach, are removed as dead code. Also: - BlueprintInfoEditor: BlueprintIconSlot.updateContent() called F.CreateIcon() uncaught, which throws for a `space-location` icon (planet names - vulcanus, fulgora, gleba) since nothing in FD covers that category; took the whole dialog down on five corpus files. Wrapped in try/catch with a warning and a blank slot. - BlueprintAlignment: every text field now commits on 'blur' rather than 'changed' (which fires per keystroke). Width/Height/Grid position already had reasons to; Absolute X/Y needed it too - a leading '-' parses to 0 and was immediately echoed back by the post-commit refresh, so a negative value could never be typed at all. Also fixes a second bug the blur switch exposed: blurring Absolute X/Y after only tabbing through them (never typing) still wrote positionRelativeToGrid, turning "never set" into an explicit {0, 0} - a real difference to serialize(), not a cosmetic one. A dirty flag, set on 'changed' and checked before the blur-commit, makes an untouched blur a no-op again. - parseGridSize() floors Grid size at 1: clearing the Width/Height box parsed to 0 through parseGridValue and exported an invalid snap-to-grid the game will not accept back. - Checkbox and RadioButton were near-identical (constructor scaffolding, the checked-swap-graphics setter, hover wiring) apart from their drawn shape and what a click does to the state. Extracted the shared machinery into ToggleControl, taking the shape-drawing function and click behaviour as constructor arguments rather than overridden methods, so neither needs `this` before `super()` has run. - Exported BlueprintAlignment's layout constants and BlueprintInfoEditor's ALIGNMENT_X/Y instead of tests/blueprint-grid- position.spec.ts hand-copying them, so a layout change cannot silently desync what the spec clicks. tests/blueprint-grid-position.spec.ts is rewritten throughout: every assertion now reads the *exported* positions (encodeLoaded + decodeBlueprintString) rather than the live model, which is exactly the gap that let the original bug through - the model position did change under the old implementation, and nothing checked the string that mattered.
|
PR recreated after the repo detach (was #222). Ready for review — waiting on any fixes/comments. |
…t' into blueprint-info-editor
wormeyman-space-age-support added tests/spec-modifier-keys.test.ts (issue FactoryGameFan#208) after this branch diverged, and it flags exactly the Control+A this branch's blueprint-grid-position.spec.ts used for select-all - correct on Linux, wrong on macOS. Switched to ControlOrMeta+A, matching display-panel-editor.spec.ts's existing fix for the same trap.
Round-tripping real exports through the game (not the earlier screenshot-only premise) showed "Grid position" does move entities - typing a value shifts the blueprint's content so -floor(min entity/tile position) reads back exactly what was typed. The earlier oracle probe's refutation of this doesn't transfer: it set blueprint_position_relative_to_grid, which is Absolute X/Y, a different field with no relation to Grid position's own formula. Blueprint.getGridPositionDisplay() computes that formula against the current model, and BlueprintAlignment's commit now solves for whatever gridPositionOffset reproduces a typed target instead of treating the field as a one-shot nudge that resets to 0 - it keeps showing the current value the same way Width/Height and Absolute X/Y already do.
The old assertion only checked the state after two undos, which a single coalesced X+Y commit would also satisfy: the second undo would land on the checkbox's own snapToGrid transaction instead, and gridPositionOffset applies in serialize() regardless of snapToGrid, so positions come out the same either way. Verified by mutating BlueprintAlignment to commit only on Y's blur - the old assertion passed, the new intermediate-state check after one undo fails.
wormeyman
left a comment
There was a problem hiding this comment.
Review of #243
I read the full diff at 735292c. The doc comments on gridPositionOffset and commitGridPosition made the design easy to follow, and one open question in them sent me off to measure the real game, so this is longer than a normal review.
Your central argument is right, and I have measured proof of it. The premise in 8529c2f, that blueprint-grid-position.json measured a different field, holds up. I built an interactive probe against Factorio 2.0.77 and found the game's blueprint panel carries three X/Y pairs, two of which were on screen at once holding different values. "Grid position" writes no key into the export at all; it translates the coordinates. blueprint_position_relative_to_grid is the neighbour. The fixture and probe are on branch probe-grid-position-gui.
The same measurement turned up one thing the formula gets wrong, which is finding 15 below. It is numbered last because I measured it last, not because it matters least.
Findings 1-4 change the exported blueprint without telling the user, 5-8 are worth fixing before merge, and 9-14 are minor. Two are marked PLAUSIBLE where I could not fully confirm reachability.
Blocking - these change the export silently
1. gridPositionOffset survives snapping being turned off - Blueprint.ts:1126
serialize() applies gridPositionOffset unconditionally, but refreshEnabled() (BlueprintAlignment.ts:370) disables the Grid position fields once snapToGrid is undefined.
Tick Snap to grid, type 3/4 into Grid position, untick Snap to grid. All three snapping keys leave the export, but gridPositionOffsetStore still holds the solved offset, so every exported position stays shifted for the rest of the session with no field left to zero it. Undo is the only way back. tests/blueprint-grid-position.spec.ts records this in a comment without asserting it is wanted.
2. refreshFromBlueprint leaves m_PositionDirty set - BlueprintAlignment.ts:342
The flag is set on 'changed' (line 279) and cleared only in commitPosition (line 335).
Take a blueprint with snap-to-grid and no position-relative-to-grid. Type "12" into Absolute X, then click the Relative radio. Pixi's pointerdown runs before the DOM blur, so the radio handler calls refreshFromBlueprint(), which rewrites X to "0" and leaves the flag set; blur then fires and commitPosition() writes {x:0, y:0}. The typed value is lost, and the blueprint gains an explicit origin position where it had none, which serialize() treats as significant via positionIsUnset (Blueprint.ts:1177). Unticking Snap to grid is the same sequence.
3. Grid position hides an offset on an empty blueprint - BlueprintAlignment.ts:322
The editor opens on an empty blueprint, where getGridPositionDisplay() early-returns {0,0} (Blueprint.ts:1087).
Open Blueprint Info, tick Snap to grid, type 5 into Grid position X, Tab. current is {0,0}, so delta.x is -5 and the offset becomes {x:-5,y:0}. The event fires refreshFromBlueprint, which reads {0,0} again and resets the box to "0". The user watches the value vanish, and every entity placed afterwards exports shifted by -5 with nothing on screen saying why.
4. First rejected keystroke wipes a restricted box - TextInput.ts:384
_restrict_value is only written inside _applyRestriction, never by the text setter. This one predates the PR, but nothing committed an emptied box to the model before the blur wiring existed, so the PR is what makes it reachable.
Load a blueprint with snap-to-grid {x:16,y:16} and click into Width. It shows "16", but _restrict_value is still '', because the constructor sets .value programmatically and fires no input event. Type a non-digit first: _applyRestriction rejects "16x" and assigns this.text = this._restrict_value, which is ''. Tab out and commitSize() runs parseGridSize('') = 1, so the grid becomes 1x1. The same path on Absolute X over a loaded {x:-5} gives {x:0}. tests/text-input.spec.ts:195 cannot catch it because it types a valid character first, which seeds _restrict_value.
Worth fixing before merge
5. Corner button closes a covered dialog - UIContainer.ts:103
toggleBlueprintInfoEditor closes the info dialog even when it is not the top one. The button is added at line 42, after dialogsContainer at line 34, so it draws above every dialog and stays clickable at (152,6), outside the centred ones.
Open Blueprint Info, click an icon slot to open the picker, then click the corner button. close() destroys the dialog underneath and orphans the picker; blueprintInfoEditor is then undefined, so the next click stacks a fresh Blueprint Info dialog under the still-open picker.
6. Ticking snap-to-grid needs two Ctrl+Z - BlueprintAlignment.ts:240
snapToGrid = {...} commits one transaction and absoluteSnapping = true commits another, since each setter ends in history.updateValue(...).commit(). One undo leaves snap-to-grid set with absolute-snapping back to false, a state the user never chose. History.startTransaction()/commitTransaction() already exists, and Blueprint's own load path uses it to group writes like these.
7. Checkbox handler uses the unfloored parser - BlueprintAlignment.ts:243
The enable-snapping branch parses Width/Height with parseGridValue rather than parseGridSize. That function's own comment (lines 40-48) explains that writing a 0 straight through produced a snap-to-grid {"x":0,"y":4} the game will not accept back, and commitSize (line 296) uses the floored parser for exactly that reason. This handler is the one writer left that can still produce it, and finding 4 makes it reachable today.
8. Name and description commit per keystroke - BlueprintInfoEditor.ts:110 and :137
Both use 'changed', which is one DOM input event per keystroke, so a 20-character name is 20 separate Change blueprint name transactions and undoing a rename takes 20 presses. This is the pattern your own BlueprintAlignment.ts:93-102 documents as the reason those fields moved to 'blur'.
Minor
9. Grid position goes stale - BlueprintAlignment.ts:287 (PLAUSIBLE)
The dialog subscribes to the four alignment events, but getGridPositionDisplay() derives from every entity and tile position. Place or delete an entity that extends the minimum corner while the dialog is open and the box keeps its old value, and blurring it then commits that stale value as a fresh target.
10. Math.min spread over every coordinate - Blueprint.ts:1102
Math.min(...xs) throws RangeError somewhere around 100k entities plus tiles, taking the dialog constructor with it. getCenter() at line 744 does the same min/max as data.reduce((min, d) => Math.min(min, ...), Infinity), with neither that limit nor the two full-length array allocations per call.
11. package-lock.json carries a 156-line libc strip
156 deletions and one addition: 52 libc fields removed, 32 ["glibc"] and 20 ["musl"], from optional platform binaries. npm reads that field to skip a binary built for the wrong C library. It is the signature of regenerating under npm 11 while the root devEngines pins ^12. Nothing here needs a lockfile change, so reverting the file is the fix.
12. Hardcoded (170,24) instead of the hook this PR adds - tests/text-input.spec.ts:279
BlueprintInfoButton sets position.set(152, 6) on a 36x36 button and the spec encodes the derived centre by hand. This PR already moved that button once, in 6826096d. window.__fbe_test.openBlueprintInfoEditor() was added in this same PR (tests/helpers/fbe-test-api.ts:49) for this purpose, and blueprint-grid-position.spec.ts:88 uses it.
13. Doc comment reverses the real ordering - BlueprintInfoEditor.ts:94
The comment says Editor.loadBlueprint calls Dialog.closeAll() before swapping G.bp. In Editor.ts, line 326 assigns G.bp = bp, lines 328-329 construct and initBP() a new BlueprintContainer, and line 330 calls Dialog.closeAll() last. That ordering is the whole justification given for not guarding against the dialog outliving its blueprint, so it is worth correcting even if the guard turns out to be unnecessary.
14. serialize() mutates icons outside history - Blueprint.ts:1109 (PLAUSIBLE)
serialize() calls generateIcons(), which writes into this.icons without History and without emitting 'icon'. Pre-existing, but newly reachable now that icons are user-editable and observed by BlueprintIconSlot: clear all four slots, then Ctrl+C. The icons repopulate, the slots keep drawing empty, and Ctrl+Z cannot remove them.
15. The corner reads entity centres, the game reads edges - Blueprint.ts:1102
Measured on Factorio 2.0.77, the version this editor targets. The probe has to be interactive because no scripting API reaches the field. The result is survivingReadings: ["entityEdgesAndTiles"], with all three controls passing and the three rival readings killed on every scored row.
getGridPositionDisplay() builds its minimum from e.position.x, the entity centre:
const xs = [...this.entities.valuesArray().map(e => e.position.x), ...tiles]The game takes the entity's edge. On the measured row where Grid position is 3, your formula gives 2. The probe layout was built to separate exactly this: a 3x3 assembling machine decides the x minimum, where the centre floors to 1 and the edge to 0, and a lone tile decides y. The y axis agreed at 5 under both readings, because a tile set that minimum and a tile has no footprint. That is why every earlier case agreed, and it matches your doc comment already flagging the footprint half as untested.
So the error is floor(size/2) on whichever axis a multi-tile entity sets the minimum. A blueprint edged by belts agrees; one edged by an assembler is a tile out.
What hides it is that the editor stays self-consistent. commitGridPosition solves for the offset that makes getGridPositionDisplay() read the target back, using the same formula on both sides, so the box always shows what was typed and no round-trip inside the editor can expose the difference. It shows up only against the game: export with Grid position 3 and Factorio's own panel reads 4.
One caveat decides the fix. For assembling-machine-1 the tile footprint edge (9.0) and the collision-box edge (about 9.1) floor to the same integer, so this measurement cannot say which one the game uses. Separating them needs an entity whose collision box is inset by more than the fractional part. That is worth settling before choosing an implementation, and tile_width is not the field to reach for: it is a centring parity rather than a size (issue #142).
Out of scope, but adjacent
serialize()'s getItemTypeForBp has no space-location arm. That is on the base branch and predates this PR, so a planet-name icon round-trips as type: 'item'. Making icons editable makes the existing loss easier to hit.
On CI
Everything is green except claude-review, which fails on Could not fetch an OIDC token. That is the standard restriction on fork PRs, not a problem with your code. All four Playwright shards pass, and I confirmed blueprint-grid-position.spec.ts, blueprint-snapping.spec.ts and text-input.spec.ts actually ran rather than being skipped.
|
Two corrections to finding 15 above. Neither changes it, but both were stated as measurements. The assembler's collision edge is 9.3, not "about 9.1". Its
What this does not change: A second layout to settle which edge is on #249. It uses a |
|
Finding 15 is now settled. The open half was "which edge", and a second measured run answers it: the game reads each entity's tile footprint edge, and counts tiles. Two scored rows, on a layout built so that one entity splits all three candidate rules apart on the y axis while tiles decide x:
Each wrong reading misses by exactly one tile on y, in opposite directions, and ignoring tiles misses by seven on x. Four controls passed. Fixture and probe are on #249. So the change to const xs = [...this.entities.valuesArray().map(e => e.position.x), ...tiles]becomes the same minimum taken over Two things worth knowing before writing it.
Do not reach for One unrelated thing the run turned up, filed as its own issue: the game refused Grid position 3,5 on this layout with "Grid position and blueprint grid position coordinates need to be either all even or all odd", having accepted the same 3,5 on the previous layout. So the rule depends on the blueprint's own content, and |
Run 2 settles the half PR #243 left open. Two scored rows, four controls passing: the game reads each entity's **tile footprint edge** and counts tiles. Each wrong reading misses by exactly one tile on y in opposite directions, and ignoring tiles misses by seven on x. The methodological note is the one worth keeping. Run 1 reported a single surviving rule and it was overstated - the candidate that would have tied with it had not been written down, so "one survivor" meant "one of the rules I thought of". Adding the fifth reading made the same data report two, which is the honest answer. The rival list is part of the instrument. Also records #251 and #252, both found on the way rather than looked for: data.json disagrees with the running game about every rail's collision box and nothing else's, and the game validates this field three ways where the editor validates it none, having refused 3,5 on one layout after accepting it on another. And the Lua trap that cost two runs: a ]] inside a --[[ comment closes it, so a header explaining a collision box in table notation turned the rest of the file into code and the mod never registered its on_init. The run reports the same message a factorio_version mismatch gives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RiP5JRvEpzDXM4AY5ouGN
Two interactive runs on Factorio 2.0.77 plus a headless one, settling what the blueprint GUI's "Grid position" field does to an exported blueprint. The field writes no key into the export. It translates the entity and tile coordinates so that -floor(min corner) = T, where the corner is taken over each entity's tile footprint edge and over tiles, and it is an absolute target rather than a relative nudge. Run 2 scored two rows with four controls passing; each wrong candidate missed by exactly one tile on y in opposite directions, and ignoring tiles missed by seven on x. That confirms PR #243's premise and refutes one detail of it: getGridPositionDisplay() reads entity centres, so it is off by floor(size/2) whenever a multi-tile entity sets the minimum. The editor stays self-consistent, which is what hides it - commitGridPosition solves for the offset with the same formula it reads back with - so only a comparison against the game exposes it. The apparent conflict with blueprint-grid-position.json was a name collision: that fixture measured blueprint_position_relative_to_grid, a different field on a different row of the same panel. Both are right about different questions. factorio-oracle's docs/method.md has been corrected accordingly. Run 1's answer was overstated and the fix was not a re-run. It reported one surviving rule, but the collision-box-edge candidate had never been written down, so "one survivor" meant "one of the rules I thought of". Adding it as a fifth reading made run 1's own captured data report two. The rival list is part of the instrument. Also here, both found while checking something else: - probe-rail-box-orientation, a headless run written only to verify one number another probe hardcoded. That control fired: data.json disagrees with the running game about every rail's collision box and nothing else's - 16 of 155 entities, worst 1.45 tiles on legacy-curved-rail - while the other 139 agree to within Factorio's 1/256 position quantum. Issue #251. It also found the rail's box does not rotate with direction, and that a rail requested at (20,20) is placed at (21,21). - The game validates this field three ways and BlueprintAlignment.ts validates it none, refusing 3,5 on one layout after accepting it on another. Issue #252. Four review findings from #249's own review are fixed, three of which were instrument bugs that would each have produced a confident answer with nothing flagged. tools/oracle/fixtures is now exempt from oxfmt, because the generator and the formatter each claimed to own a generated file's canonical form. No production code changes.
…t' into blueprint-info-editor
…InfoEditor and Grid position
- snapToGrid off now clears gridPositionOffset in the same undo step, so
turning the grid off doesn't leave every future export silently shifted
- getCenter()/getGridPositionDisplay() share one footprint-edge reading
(footprintData/minCorner) instead of getGridPositionDisplay reading each
entity's raw centre - a multi-tile entity now displays the same corner
Blueprint.serialize() actually exports
- getGridPositionDisplay() no longer special-cases an empty blueprint to
{0, 0}, which used to visibly reset a just-typed target back to 0
- refreshFromBlueprint() clears BlueprintAlignment's dirty flag, closing a
pointerdown-before-blur race where switching Absolute -> Relative while
mid-edit in Absolute X committed a phantom {0, 0} position
- TextInput's text setter now also updates _restrict_value, so a rejected
keystroke reverts to the last real value instead of a stale ''
- a second click of the corner button only closes BlueprintInfoEditor when
it is the topmost dialog, instead of closing it out from under whatever
is open on top
- ticking Snap to grid is one undo step instead of two, and the checkbox's
own Width/Height read is floored at 1 like every other writer in the file
- Name/Description commit on blur instead of per keystroke
- generateIcons() now writes through setIcon() inside a History
transaction instead of bypassing it, so the icon it auto-fills in is
undoable and updates any open icon slot control
Closes FactoryGameFan#243
wormeyman
left a comment
There was a problem hiding this comment.
Reviewed c4db8c0c against the fifteen findings from my last pass. Fourteen are fixed, and I checked each one against the code rather than against the commit message. The remaining one is fixed for the case I measured, with the gap it cannot close written down at the site.
Finding 15 is implemented exactly as measured
getGridPositionDisplay() now reads the corner through minCorner(footprintData()), the same reading getCenter() uses, and getEntitySize supplies the footprint so no new geometry was needed.
Two things I checked rather than assumed:
getCenter()'s arithmetic came through the extraction byte for byte, so the round-trip fixture could not have moved. git diff shows the min/max reduces unchanged, only relocated.
The new test at tests/blueprint-grid-position.spec.ts:316 pins the case that actually separates the two readings, and the numbers work out. A assembling-machine-1 at (10.5, 10.5) has its footprint edge at 9 and its centre at 10.5, and getCenter() rounds the 9..12 box to 11, so the edge reading gives -floor(9 - 11) = 2 where a centre reading gives -floor(10.5 - 11) = 1. That is the difference the game settled, and the test cites the fixture.
Finding 10 dropped the Math.min(...) spread for a reduce, so the 100k-entity RangeError is gone along with the two array allocations per call.
Finding 11 is fully reverted. git diff base...HEAD -- package-lock.json is empty, so the 156-line libc strip is gone rather than partially undone.
Finding 9, and the half it does not reach
create-entity, remove-entity and create-tile are hooked now, and the comment at BlueprintAlignment.ts:316 says plainly that create-tile has no remove-tile counterpart to hook. That is the right way to leave a gap.
One more case falls in the same hole and is not mentioned: Entity emits 'position' (Entity.ts:300), so moving an entity that sets the minimum corner leaves the box showing its old value, exactly like deleting one used to. Blurring it afterwards then commits the stale reading as a fresh target, which is the same failure the three new hooks close. Worth either hooking or naming alongside remove-tile.
Three small things
Closes #243 in the commit body points at this PR, not at an issue. Since the merge squashes the body into history, it is worth correcting before it lands.
Finding 5's fix reads dialogsContainer.children[len - 1] to decide what is topmost, while Dialog.closeLast() reads s_openDialogs[len - 1]. Two answers to one question, and they agree only as long as nothing else is ever added to dialogsContainer. This repo's own rule for that is in CLAUDE.md under Asking the real game: a bound written down twice is one you have to keep in sync, and the one that loses is the one nothing tests. A Dialog.isTopmost(this) beside the existing isOpen would leave one copy.
tests/text-input.spec.ts reaches the hook through (window as any).__fbe_test, which steps around the single declare global in tests/helpers/fbe-test-api.ts. blueprint-grid-position.spec.ts:88 uses the typed one.
One consequence worth knowing
Routing generateIcons() through setIcon inside a transaction means serialize() can now write to history. On a blueprint with no icons yet, the first Ctrl+C fills them in and spends one undo step doing it. That is the fix working as asked, since the whole complaint in finding 14 was that those icons could not be undone. It is just worth knowing that copying is no longer read-only against the model.
Adjacent, and not this PR's problem
The guard at the top of generateIcons() is correct, and I checked the reason given for it. History.commitTransaction() returns false on an empty transaction before it reaches this.activeTransaction = undefined, and startTransaction() only builds a new Transaction when activeTransaction is undefined. So an empty transaction anywhere leaves the stale one open, and the next unrelated startTransaction adopts it, keeps its label, and returns false.
That is on the base branch, not in this PR, and this PR is the only place I have seen it written down. It deserves its own issue.
CI
Green on c4db8c0c. claude-review fails on the OIDC token again, which is the fork-PR restriction rather than anything in the code. I confirmed blueprint-info-editor.spec.ts and blueprint-grid-position.spec.ts both actually ran in shard 1, and text-input.spec.ts in shard 4, rather than being collected and skipped.
wormeyman
left a comment
There was a problem hiding this comment.
A second pass over c4db8c0c turned up six things my first review missed, and one of them is bad enough that I am switching to request changes. Everything below I traced through the code myself.
Sorry for the two-comment review. The first pass checked the fix against my old findings, which is exactly the pass that could not see any of this.
Blocking: a failed icon generation kills undo for the whole session
generateIcons() opens a transaction at Blueprint.ts:989 and commits it at :1032. In between, at :1016, iconPairs[0][0] is dereferenced without a guard.
getIconPairs skips every entity whose Entity.getItemName is undefined, so a blueprint whose entities are all non-minable gives back [], and iconPairs[0][0] throws a TypeError. The throw escapes generateIcons before commitTransaction() runs, so transactionCount is stuck at 1 and activeTransaction is never cleared.
encode() catches it into a toast, so the app carries on and nothing points at History. But the damage is already done. updateValue() calls startTransaction() and Action.commit() calls commitTransaction(), so from that moment every edit runs 1 -> 2 -> 1 and commitTransaction returns at the transactionCount === 0 check without pushing anything to transactionHistory. Edits still apply. Nothing is ever recorded. Undo and redo are dead for the rest of the session, silently.
This is reachable by hand, not just in theory. 20 of the 155 entities in data.json have no minable.result, and two of them can be placed straight from the inventory:
captive-biter-spawner <- item "captive-biter-spawner"
space-platform-hub <- item "space-platform-hub"
So: new blueprint, place a space-platform-hub, press Ctrl+C. That is a space platform blueprint at its most basic, which is squarely what this fork is for.
Before this PR the same throw was harmless, because generateIcons wrote into a plain Map and touched no history at all. Routing it through setIcon is what turned a caught error into a permanent one.
The same root: Ctrl+C spends the user's next Ctrl+Z
serialize() calls generateIcons(), so on a blueprint with no icons yet the first copy writes to history. Build something from scratch, place a chest, press Ctrl+C, then press Ctrl+Z expecting the chest back. You get the auto-generated icon removed instead, and the chest stays.
Your own new spec works around this at blueprint-info-editor.spec.ts:197:
Settles the icon-autogeneration transaction (finding #14, below) out of the way first [...] it would otherwise be the transaction the first undo below reverts instead of the checkbox's.
That was the right call for the test. It is worth reading as a signal about the feature, though, because a user gets no encodeLoaded() call to settle it for them.
My finding 14 asked for those icons to be undoable, and this is a fair reading of it. But serialize() is the wrong place to write from, and moving generation out of it fixes this and the blocking item above together, since a throw outside a transaction goes back to costing nothing.
A rejected keystroke writes a grid position
TextInput._onInputInput (TextInput.ts:307) runs _applyRestriction and then emits 'changed' unconditionally, so a character the restriction just threw away still reports a change.
Open Blueprint Info on a blueprint that has never carried position-relative-to-grid. Tick Snap to grid, click Absolute X showing 0, type a. The restriction rejects it and puts 0 back, so nothing changed, but 'changed' still fires and BlueprintAlignment.ts:303 sets m_PositionDirty. Tab away and commitPosition() writes {x: 0, y: 0}.
It reaches the export. positionIsUnset at Blueprint.ts:1247 is positionRelativeToGrid === undefined, and the key is emitted whenever that is false, so the blueprint now carries "position-relative-to-grid":{"x":0,"y":0} where it previously had no such key. That is the finding-2 phantom origin arriving by a path the dirty flag does not cover.
An untouched blur pushes an undo entry
The comment at BlueprintAlignment.ts:356 says a zero delta is a no-op because gridPositionOffset's own pointsEqual guard catches it. That holds once the store has a value, and not before.
pointsEqual(undefined, {x: 0, y: 0}) returns false (Blueprint.ts:105). The getter hands back ?? {x: 0, y: 0} while the setter's guard compares the raw store field, so on a fresh blueprint the two differ, the write goes through, and a Change blueprint grid position offset transaction lands on the stack. Click into Grid position X and straight back out without typing, and your next Ctrl+Z undoes that instead of your last real edit.
The new entity subscriptions discard a field being typed into
This one is a consequence of what I asked for in finding 9, so it is on me as much as on you.
refreshFromBlueprint() overwrites m_XInput.text at :395 and clears m_PositionDirty at :410. It is now subscribed to create-entity and remove-entity at :327.
Open Blueprint Info, type 12 into Absolute X without blurring, then place or delete an entity on the canvas around the dialog. Ctrl+Z does it too, since undo re-creates entities. The field is rewritten from the model and the flag is cleared, so the 12 is gone and the later blur commits nothing. That is the same edit-destroying refresh you removed from the 'changed' path, coming back through the entity events.
Control+z is invisible to the #208 guard
Five new call sites use Control+z (blueprint-info-editor.spec.ts:210, :281, :312, and blueprint-grid-position.spec.ts:302, :311).
TEXT_EDIT_KEYS is ['A', 'C', 'V', 'X', 'Z'] and the CHORD regex at spec-modifier-keys.test.ts:104 carries only the g flag, so a lowercase z never matches. The guard reports zero violations for these files and no allowlist entry explains them, which means the check silently covers nothing here. The convention it can see is Control+KeyZ, as at chest-filters.spec.ts:390.
These presses go to the app rather than to a focused input, so they work today. The problem is that the guard stops watching the file.
Everything from my first review still stands
The fourteen fixes I verified are all still good, and the finding-15 work in particular is exactly right. None of the above touches it.
|
Splitting out the parts of my last comment that are not yours, so it is clearer what this PR actually has to carry. Two of them predate this branch and are now tracked separately:
So the blocking item on this PR is narrower than I made it sound: it is that routing
That leaves four things on this PR from my last comment: the icon transaction escalation, Ctrl+C spending the next Ctrl+Z, the rejected keystroke committing |
… notes (#263) * Skip the Claude review workflow on pull requests from forks GitHub withholds repository secrets from a `pull_request` event raised by a fork, so `secrets.CLAUDE_CODE_OAUTH_TOKEN` resolves to an empty string and the action fails every time. Measured across the open backlog: `claude-review` failed on all five fork PRs (#227, #242, #243, #258, and #249 before it merged) and passed on both in-repo ones (#257, #260). That is the whole pattern - it is not a misconfiguration the workflow can fix, it is what the event is for. The failure blocks nothing, which is the problem. Every fork PR opens with a red X, and a check that is always red is a check nobody reads - so a real failure in it would be missed. A job-level `if` turns it grey instead. The alternative is `pull_request_target`, which does get fork PRs reviewed but hands base-repo secrets to a fork's code. Every outside contribution here arrives from a fork, so that trade is not available. The comment at the guard says so, since the next person to notice the skipped runs will reach for it. In-repo branches, Renovate's included, still run. Also corrects a stale note in CLAUDE.md: the `ajv` entry still described `ModdedBlueprintError` and `TrainBlueprintError` as declared-but-never-thrown, and #262 deleted both. The point it was making survives - ajv is ~100 kB and nothing branches on its result - so the entry keeps that and records what went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N3pm7fQQDv6HTVz1TEpmE * Correct CLAUDE.md's vite-plus entries against what the repo actually pins Three corrections, each measured rather than read off the file. The documented local-install command did not set the version at all. It read `VP_VERSION=0.2.8 VP_NODE_MANAGER=yes curl -fsSL https://vite.plus | bash`, and an assignment ahead of a command applies to that command alone - `curl` got the variables and the `bash` on the far side of the pipe read an empty string. Measured against a stub script, which printed `VP_VERSION=[]`. The installer then falls back to `VP_VERSION="${VP_VERSION:-latest}"`, read off the script itself, so anyone following that line installed `latest` rather than the pin. That is the "green, and wrong" split the same file warns about one section down, with a local toolchain silently different from the lockfile's and CI's. The command now downloads the script and runs it with the variables ahead of `bash`, matching setup-vp/action.yml, and sets VP_HOME for the layout reason #260 established. Syntax-checked with `fish -n`, since it is a fish block. The pin is 0.2.9 everywhere in the repo - root, editor and website package.json, the root overrides alias, and VP_VERSION in setup-vp/action.yml - while the file still said 0.2.8 in three places. It also claimed 0.2.8 was `latest` as of 2026-08-11; `npm view vite-plus dist-tags` gives 0.3.0 today. That entry has now gone stale twice, which is its own best argument, so it says so and points at the command to re-measure with. And the installer-checksum note said the hash did not move across 0.2.6 -> 0.2.8, so a bump usually leaves it alone. True when written, and it is the reassurance that made 2026-08-24 expensive: the script rotated with VP_VERSION untouched and every job on every branch failed at `Set up Vite+`. The note now records that a hash can move with no bump at all. Re-fetched today and the current sha256 still matches the pin, so nothing in CI needs changing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N3pm7fQQDv6HTVz1TEpmE --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t' into blueprint-info-editor # Conflicts: # packages/editor/src/core/Blueprint.ts
…nt-info-editor # Conflicts: # packages/editor/src/core/Blueprint.ts
wormeyman
left a comment
There was a problem hiding this comment.
Re-reviewed after the merges. Two of the six items from the last round are closed, though not by changes here. The base branch moved underneath the PR:
549b2a96(#256) made the modifier guard case-insensitive, which closes theControl+zitem outright.83a23595(#258) guards the empty icon-pair list, which removes the trigger for the session-wide undo kill. The mechanism is still there.generateIconshand-rollsstartTransactionandcommitTransaction, andHistory.transaction(text, fn)withtry/finallynow exists on the base branch. Any throw between those two lines still leaves the transaction open and kills undo for the rest of the session, silently.
The other four are untouched. The last author commit is c4db8c0c (08-22), which predates the review; everything since is a merge.
One new blocker
It comes out of finding 14's own fix. Routing icon generation through History was the right call for the bypass, but serialize() calls generateIcons(), and History.commitTransaction truncates the redo stack before pushing. So on a blueprint carrying no icons, Ctrl+C destroys redo:
Load a blueprint with no icons. Make three edits. Undo three times, so redo holds three entries. Press Ctrl+C. All three are gone.
Same path for the share URL and the export dialog. blueprint-info-editor.spec.ts:285 pins the current behaviour, so that test has to move too. The fix is not to revert finding 14. It is to stop a read (serialize) from triggering a write.
Five changes close all of it
- Move
generateIcons()out ofserialize(). - Wrap what is left of
generateIconsinhistory.transaction(...). One line. - Gate
'changed'inTextInput._onInputInputon the value actually having changed. A rejected keystroke currently writesposition-relative-to-grid: {x:0, y:0}into a blueprint that never carried the key. - Stop
refreshFromBlueprintclobbering a focused field, and cache or debounce the entity-event refresh. Typing into Absolute X and then placing an entity loses what you typed. Separately, with the dialog open, everyremove-entityrebuilds two arrays sized to the whole blueprint. - Compare against the store's getter in the grid-position setter.
pointsEqual(undefined, {x:0, y:0})is false, so tabbing through Grid position without typing pushes an undo entry.
The measured half is right
The -floor(min corner) rule over footprint edges and tiles matches what the game does, and the snapToGrid && absoluteSnapping gate matches the fixture. Finding 15 is implemented exactly as measured, and the new specs each fail against the pre-fix code.
The five changes the last review round asked for. ## serialize() no longer writes `generateIcons()` is now `computeAutoIcons()`: it derives the icons a blueprint with none of its own exports, hands them back, and writes nothing. `serialize()` uses the result directly. Routing that generation through History was right for the bypass in finding 14, but `serialize()` was the caller, so copying became a write - and `commitTransaction` trims the redo stack before pushing. Three edits, three undos and one Ctrl+C threw all three redos away. The share URL and ExportDialog serialize too, so a read the user never asked for could cost them their redo stack. Finding 14 stays closed, from the other side: there is no History bypass left because there is no write. `this.icons` now holds only what someone chose, which is what makes setIcon's "clearing every slot returns to auto" true rather than incidental. It also takes FactoryGameFan#255's escalation off this path for good. `getIconPairs` skips every entity with no `minable.result`, so a blueprint of nothing but a `space-platform-hub` threw on `iconPairs[0][0]` between a `startTransaction` and its commit, leaving the transaction open and undo dead for the session. There is no transaction here to leave open now. ## A rejected keystroke is not a change `TextInput._onInputInput` ran the restriction and emitted 'changed' unconditionally, so a character the restriction had just thrown away still set `m_PositionDirty` and the next blur wrote `position-relative-to-grid: {0, 0}` into a blueprint that had never carried the key. Gated on the value actually differing, for the restricted case only - an unrestricted field's `input` event already means it changed. ## A model-driven refresh leaves a focused field alone `refreshFromBlueprint` takes `preserveFocused`, used by the entity hooks: an entity arriving mid-edit no longer rewrites the box being typed into, nor clears the dirty flag that commits it on blur. A refresh the dialog's own controls ask for still rewrites everything, which the Relative-radio race depends on. Those hooks are also coalesced onto the next frame now. They arrive one per entity and each runs `getGridPositionDisplay()` over the whole blueprint, so deleting a 500-entity selection with the dialog open ran 500 whole-blueprint reads. ## An untouched Grid position blur writes nothing The setter compared the raw store field, and `pointsEqual(undefined, {x: 0, y: 0})` is false, so clicking into Grid position and straight back out pushed a transaction and stole the next Ctrl+Z. Compared against the getter, which is what `serialize()` reads. ## Tests Four new, each mutation-checked against the running editor: removing the fix fails the test, with the number quoted in each spec's own comment. The dialog specs also had a race that predates all of this and only shows outside CI: `TextInput` appends its `<input>` on `added` but positions it in its own render step, so for one frame the field sits at `0,0 336x19` instead of `472,200`. A click computed from `topDialogBounds()` lands on the canvas until then, and fails silently - the typing that follows reaches the app's keybinds instead. Two specs failed here every run for that reason. Both copies of `openBlueprintInfo` are now one helper that waits for the field to arrive under the dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-submission of #222, recreated after the repo detach.
Summary
history.updateValueso edits are undoable like everything else.snap-to-grid,absolute-snappingandposition-relative-to-grid, the three blueprint-string fields the editor already decoded/re-encoded but never exposed:absolute-snapping: truebehind in the exported string.RadioButtoncontrol (circular) for the Absolute/Relative choice, sinceCheckboxdraws a rounded square that reads as an independent toggle rather than one of two options.Test plan
vp check/vp testpass