Skip to content

Commit 0a160df

Browse files
wormeymanclaude
andauthored
feat(fulgora): island finder for megabase sites (#27) (#207)
* feat(noise): expose the stable integer cell index on Voronoi (#27) * feat(islands): largest inscribed rectangle, checked against a brute-force reference (#27) * feat(islands): survey Fulgora's Voronoi cells to enumerate candidate islands (#27) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6vHdLuzbrJ8ajbZUXDyB5 * refactor(preview): extract createWorkerHost so a second consumer can share the worker pool (#27) * feat(islands): land mask and flood fill from a rendered terrain image (#27) * feat(islands): proximity graph and chain components at big-pole reach (#27) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code * fix(islands): unify minGapTiles and boxGap to same distance metric (#27) minGapTiles was returning empty-tile count (distance - 1) while boxGap returned raw Chebyshev distance. The prefilter checked distance against reachTiles, but minGapTiles checked empty tiles, causing unit mismatch at the boundary. Change minGapTiles to return distance (tile-centre to tile-centre), matching how poles connect at exactly 30-tile reach. Also update test expectations to reflect distance metric, and add test verifying the metrics agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code * feat(islands): orchestrate survey, coarse measure, refine and chain (#27) Two deviations from the brief's verbatim code, both required to make the tests pass on real data at radius 600 / SEED0: - A cell's Voronoi id can classify it non-ocean while the specific sampled point (or its rounded pixel at a given tpp) still renders as ocean - cell classification and per-tile land/water are different questions. Added nearestLandPixel to search the rendered window for the closest actual land pixel before flood-filling, and drop any candidate that still measures zero land after that search. - Fixed a unicorn/no-new-array lint warning (Array.from instead of `new Array(n)`) and removed an unused `vi` import from the brief's test. * fix(islands): dedupe overlapping coarse candidates and stop overcounting progress (#27) Fix round 1 on Task 7's orchestrator, per review: 1. Two findings from review: several adjacent Voronoi cells can each flood-fill an overlapping slice of the same physical island into their own row (islandMask's flood fill deliberately crosses cell boundaries, but surveyIslands enumerates one candidate per cell). Deduplicate with chainComponents(masks, 0) immediately after the coarse pass, keeping the highest-landTiles representative per group, and rank/refine from the deduplicated set. 2. progress `total` assumed every candidate reaches refinement; the zero-land filter and the new dedup pass both drop some first. `total` now starts as the coarse-only count and is extended once the deduplicated refine set is known, so `done` reaches `total` through real ticks alone - the `if (done < total)` fallback stays as a belt-and-braces guard but no longer needs to fire. A necessary supporting fix, found while verifying (1): `windowFor`'s origin was left unaligned (`c.minX - WINDOW_PAD_TILES` raw), so two different candidates' coarse windows sampled incommensurable world-tile grids and almost never shared an exact position - `chainComponents(_, 0)` was measured to merge ZERO of 39 coarse survivors, including a pair independently confirmed (via a full-resolution tpp=1 flood fill) to be 100% the same connected island. Snapping the window origin to a multiple of `tpp` puts every candidate on one shared grid; re-measured after the fix, the same pair collapses into one dedup group, and the known duplicate the reviewer flagged - (2,-4) overlapping (1,-4)/(1,-3) - does too. 39 coarse survivors now collapse to 35; the final radius-600 island count drops from 39 to 35. Added a covering test asserting no two returned rows share a land tile, independently re-rendered on a fixed, grid-aligned resolution rather than reusing findIslands's own internal masks (a first version reused each row's own tpp/origin and produced a false negative for exactly this reason). Strengthened the progress test to check every call's `done` against its own call index, which a fallback-covered overcount fails but a last-tuple-only check does not. * feat(islands): results panel with ranked list and jump-to (#27) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6vHdLuzbrJ8ajbZUXDyB5 * fix(islands): real re-centering, radius bounds, stale jump clear, cancel test (#27) Round 1 fixes on the island finder panel: ElevationPreviewPanel now takes optional centerX/centerY props (default 0, a no-op for every existing caller) that shift the render origin and auto re-render once a render has already happened, so clicking a results row actually moves the client preview instead of only printing a coordinate. App.vue feeds the jump target through those props and clears it on a planet switch. The radius input is clamped to [500, 20000] since FNumberInput has no bounds of its own and search cost grows with the square of the radius. Adds a test for the cancel path (findIslands resolves rather than rejects on abort). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6vHdLuzbrJ8ajbZUXDyB5 * test(islands): pin the survey cost, and record the milestone (#27) * fix(islands): grow render window on border touch, thread island sliders, hoist chain extraction, group refined/unrefined sort, and stop the radius field snapping mid-edit (#27) Applies the five findings from the final whole-branch review of the Fulgora island finder (DO NOT MERGE verdict): - Critical: the render window (candidate bbox + a flat 32-tile pad) clipped multi-cell islands at its own edge, undercounting land/rectangle and flipping which of two candidates ranks higher. `measure()` now re-renders at a doubled pad whenever the isolated mask still touches the window border, capped at 3 growths (32 -> 64 -> 128 -> 256). A result still touching the border after the last growth is marked `clipped: true`, surfaced in the panel the same way `refined` is (a `!` marker beside `~`). - The preset's `fulgora_islands` frequency/size sliders were dropped - IslandFinderPanel built `ctx: { seed0 }` only, so the finder always surveyed and rendered the default grid even when the preview beside it showed a different one. Now reads `elevationCtxFromPreset(preset) .fulgoraIslandControls`, the same helper ElevationPreviewPanel already uses. - `chainComponents` re-extracted each mask's land-tile array on every pairwise call instead of once per mask. Hoisted the extraction; kept `minGapTiles`'s public signature unchanged for its own tests. Measured real-data effect was smaller than expected (~200ms either way at radius 5000, since `boxGap` already discards 99.8%+ of pairs) but the fix removes real redundant work with zero behavior change. - Coarse (unrefined) and refined rows were sorted together by raw rectangle area, letting an unrefined 51st-place row (biased upward by 8x8-tile quantization) outrank a measured one. `compareResults` now groups refined rows before unrefined ones, each sorted by area within its group. - The radius field's setter clamped on every write, snapping "5" to "500" mid-edit while typing toward "5000". Clamping moved to blur and to the start of a search (the actual guarantee), not the setter. Also updates docs/noise/client-preview-ROADMAP.md: records the window-growth cap as a known limit, and replaces the design spec's "chains negligible" framing with the measured combined cost (~365ms at radius 5000). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6vHdLuzbrJ8ajbZUXDyB5 * test(islands): assert the grouped rank order, and cut four tests to refineCount 3 (#27) The final review's fix 4 made `compareResults` rank refined rows above unrefined ones as a group. "sorts by rectangle area, largest first" still asserted a flat area sort, which contradicts that - and it passed only because radius 600 refines every one of its 35 rows at the default count of 50, leaving the unrefined group empty so the two orderings coincide. The re-review said lowering the refine count would make that assertion go red. It does not. The refine set is chosen BY coarse area, so the refined group starts out holding the largest rows and the grouped order still comes out identical to the flat one. Measured across the ladder, smallest refined area against largest unrefined area in tiles: count 1: 19,684 vs 17,920 - no crossing count 2: 17,440 vs 17,280 - no crossing count 3: 14,256 vs 16,640 - CROSSING count 5: 13,800 vs 11,648 - no crossing Only at 3 do the orderings disagree, when cell (-2,-3) refines from a coarse 17,280 to a true 14,256 and falls under (-1,2) at 16,640 and (2,1) at 14,976. The test now asserts the grouped order and carries an explicit anti-vacuity check - a flat sort of the same rows must NOT equal the shipped order - so this going vacuous again is a red line rather than a silent pass. Confirmed by planting the break: dropping the `a.refined !== b.refined` line fails it with "expected false to be true". The same constant cuts the file's cost, which fix 4's grow-and-re-render loop had made the heaviest in the suite. Per test, 50 -> 3: view-terrain 41.2s -> 15.4s, rect-vs-land 41.7s -> 15.4s, the rank test 42.1s -> 15.4s, no-duplicate-island 57.7s -> 30.9s. The file goes 240.4s -> 134.6s (-44%), against a binding CI shard of 389s and a 300s per-test budget. "reports progress" keeps the default on purpose and cannot be cheapened this way: the overcount it guards against was `candidates.length + Math.min(refineCount, candidates.length)`, which differs from the correct value only when refineCount exceeds the DEDUPED count (35, against 41 candidates). At 3 both arithmetics give 44 and the bug is invisible; at 36 or more the refine set is the whole deduped list, so there is nothing to save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114mWC72TAgZzNjMejBpdTt --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c2d4297 commit 0a160df

23 files changed

Lines changed: 2520 additions & 29 deletions

docs/noise/client-preview-ROADMAP.md

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -566,16 +566,87 @@ Done = ore patches overlaid on land, responding to the frequency/size/richness s
566566
and still far under vulcanus (13.82). Full before/after table and the
567567
land-only measurement: `docs/noise/fulgora-elevation-NOTES.md`.
568568

569-
Deferred for Fulgora: scrap resources, cliffs, and the island finder.
570-
Two residuals remain open, and as far as anything measured so far can
571-
tell they are the SAME unexplained post-argmax mechanism rather than two
572-
defects: the original 18 land/ocean and shallow/deep tile mismatches are
573-
boundary-exclusive and are NOT reachable by any model of the four ocean
574-
expressions - the game places water where its own expressions score it
575-
unplaceable - and the eight-land-tile argmax's 124 mismatches are also
576-
boundary-exclusive and are not explained by a sampling offset or an
577-
inflated formula either. Both need the post-argmax transition pass
578-
reverse-engineered, not a constant re-fitted.
569+
Deferred for Fulgora: scrap resources and cliffs (the island finder
570+
below no longer is). Two residuals remain open, and as far as anything
571+
measured so far can tell they are the SAME unexplained post-argmax
572+
mechanism rather than two defects: the original 18 land/ocean and
573+
shallow/deep tile mismatches are boundary-exclusive and are NOT
574+
reachable by any model of the four ocean expressions - the game places
575+
water where its own expressions score it unplaceable - and the
576+
eight-land-tile argmax's 124 mismatches are also boundary-exclusive and
577+
are not explained by a sampling offset or an inflated formula either.
578+
Both need the post-argmax transition pass reverse-engineered, not a
579+
constant re-fitted.
580+
581+
**Fulgora island finder - DONE 2026-08-15** (#27, spec
582+
`docs/superpowers/specs/2026-08-15-fulgora-island-finder-design.md`).
583+
Finds the largest buildable islands, ranked by largest inscribed
584+
rectangle, inside a user-set search radius, and groups nearby islands
585+
into chains (a big power pole's 30-tile wire reach). Five stages, each
586+
its own module under `src/noise/islands/`: survey the search box for
587+
candidate Voronoi cells (`cellSurvey.ts`), rasterize a coarse land mask
588+
per candidate and compute its largest inscribed rectangle
589+
(`islandMask.ts`, `largestRectangle.ts`), refine the top 50 by coarse
590+
area at finer resolution, chain islands whose land comes within 30
591+
tiles (`chainGraph.ts`), and list the results in a sortable UI panel
592+
that re-centers the preview on a click.
593+
594+
**Perf, measured 2026-08-15** (`test/render-cost.perf.spec.ts`,
595+
`FMW_PERF_BLOCK=islands`, min of 3, seed 2967702466): the survey pass
596+
(Stage 1), scanning a 4,000-tile box at the derived `grid / 8` step,
597+
costs **~2.5 us per sample** (84 ms for 33,489 samples, 375 candidates
598+
found) - in line with the design spec's own throwaway measurement of
599+
2.33 us per `cells` evaluation. One coarse measure-shaped render
600+
(Stage 2: a 256x256-tile window at 8 tiles/px, `view: "terrain"`, never
601+
`"all"` - see `findIslands.ts`'s header for why) costs **~24 ms** in
602+
this Node test harness.
603+
604+
**The full end-to-end search time for the default 5,000-tile radius is
605+
unmeasured.** This perf block times the two per-unit costs above, not
606+
the whole survey -> coarse -> refine -> chain pipeline run across the
607+
app's worker pool. The design spec's own ~15s figure for that case
608+
(section 4) is a design-time estimate from a throwaway benchmark, not a
609+
number this task measured - treat it as unconfirmed until a real
610+
end-to-end run is timed and recorded here.
611+
612+
**The chain stage is NOT negligible, and now has a measured figure.**
613+
The design spec's own estimate (section 4) called stage 4 (dedup +
614+
chain, `chainGraph.ts`) negligible beside the coarse/refine renders.
615+
Measured directly on real Fulgora data (radius 5000, seed 2967702466,
616+
2,313 coarse candidates deduping to 2,000): the two `chainComponents`
617+
calls together cost **~365 ms** - small next to the render stages
618+
(which run into the tens of seconds), but a real, countable cost, not
619+
zero. A pre-fix version of `chainGraph.ts` re-extracted each mask's
620+
land-tile array on every pairwise comparison instead of once per mask;
621+
a 2026-08-15 review fix hoists that extraction (`chainGraph.ts`,
622+
`chainComponents`). On this same real-data shape the hoist's own effect
623+
was small (the box-gap prefilter already discards 99.8%+ of pairs
624+
cheaply, so the redundant work it removes is a small fraction of an
625+
already-small stage) - the fix is still correct and worth keeping, just
626+
don't expect it to move the total search time by much.
627+
628+
**The render window's grow-on-border-touch has a known limit.** A
629+
2026-08-15 review fix found that `findIslands.ts`'s render window
630+
(candidate bounding box + a flat pad) could cut a multi-cell island off
631+
at its own edge, undercounting its land and rectangle and even
632+
flipping which of two candidates ranks higher. The fix re-renders at a
633+
doubled pad whenever the isolated island mask still touches the
634+
window's border, capped at 3 growths (pad 32 -> 64 -> 128 -> 256). An
635+
island whose true extent needs more padding than that - i.e. one that
636+
still touches the border after the pad reaches 256 - stays clipped: its
637+
reported rectangle is a real measurement of a truncated slice, not the
638+
whole island, and `IslandResult.clipped` records that so the panel can
639+
say so (the `!` marker beside a clipped row's rectangle, next to the
640+
existing `~` marker for an unrefined one). No case in the current
641+
seed/radius test fixtures needs more than pad 256, but nothing rules
642+
one out at a larger radius.
643+
644+
**Accuracy**: Fulgora's land/ocean split agrees with the real game on
645+
99.86% of positions (Fulgora V1, above), and the residual mismatches
646+
are boundary-exclusive - they sit exactly where an island's edge is,
647+
which is exactly what a rectangle measurement reads. So a reported
648+
rectangle is accurate to about a tile, not exact; the panel says this
649+
once rather than implying an exact answer.
579650

580651
## Milestone 5 - integration
581652

src/App.vue

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref } from "vue";
2+
import { ref, watch } from "vue";
33
import { SUPPORTED_VERSIONS_LABEL } from "./codec/mapExchangeString";
44
import { FACTORIO_TARGET_VERSION } from "./model/factorioTarget";
55
import { BUILD_INFO, BUILD_STAMP } from "./model/buildStamp";
@@ -8,6 +8,7 @@ import AdvancedTab from "./components/AdvancedTab.vue";
88
import ElevationPreviewPanel from "./components/ElevationPreviewPanel.vue";
99
import EnemyTab from "./components/EnemyTab.vue";
1010
import ImportPanel from "./components/ImportPanel.vue";
11+
import IslandFinderPanel from "./components/IslandFinderPanel.vue";
1112
import PresetBar from "./components/PresetBar.vue";
1213
import PreviewPanel from "./components/PreviewPanel.vue";
1314
import ResourcesTab from "./components/ResourcesTab.vue";
@@ -19,6 +20,26 @@ const TABS = ["Resources", "Terrain", "Enemy", "Advanced", "Preview"];
1920
const activeTab = ref("Resources");
2021
const selectedPlanet = ref<Planet>("nauvis");
2122
const showImport = ref(false);
23+
/**
24+
* The last island the finder's results table was jumped to - fed straight to
25+
* ElevationPreviewPanel's `centerX`/`centerY` props, which move the client
26+
* preview's render window onto it. `null` (not 0) while nothing has been
27+
* jumped to yet, so the preview keeps its own "centered on world origin"
28+
* default rather than this component asserting a coordinate.
29+
*/
30+
const jumpTarget = ref<{ x: number; y: number } | null>(null);
31+
32+
function onIslandJump(target: { x: number; y: number }) {
33+
jumpTarget.value = target;
34+
activeTab.value = "Preview";
35+
}
36+
37+
// A jump target named for one planet's geography is meaningless on another -
38+
// switching planets without clearing it would leave a stale coordinate note
39+
// (and a stale render center) pointing at nothing in particular.
40+
watch(selectedPlanet, () => {
41+
jumpTarget.value = null;
42+
});
2243
</script>
2344

2445
<template>
@@ -75,7 +96,17 @@ const showImport = ref(false);
7596
<ResourcesTab v-if="activeTab === 'Resources'" />
7697
<TerrainTab v-else-if="activeTab === 'Terrain'" />
7798
<EnemyTab v-else-if="activeTab === 'Enemy'" />
78-
<ElevationPreviewPanel v-else-if="activeTab === 'Preview'" :planet="selectedPlanet" />
99+
<div v-else-if="activeTab === 'Preview'" class="preview-tab">
100+
<IslandFinderPanel :planet="selectedPlanet" @jump="onIslandJump" />
101+
<p v-if="jumpTarget" class="jump-note" data-test="jump-target">
102+
Centered on {{ jumpTarget.x }}, {{ jumpTarget.y }}.
103+
</p>
104+
<ElevationPreviewPanel
105+
:planet="selectedPlanet"
106+
:center-x="jumpTarget?.x"
107+
:center-y="jumpTarget?.y"
108+
/>
109+
</div>
79110
<AdvancedTab v-else />
80111
</div>
81112
</main>
@@ -188,6 +219,26 @@ const showImport = ref(false);
188219
flex: 1;
189220
}
190221
222+
.preview-tab {
223+
display: flex;
224+
flex-direction: column;
225+
gap: 8px;
226+
/* IslandFinderPanel sizes to its content; ElevationPreviewPanel is the one
227+
that wants the remaining room. */
228+
height: 100%;
229+
}
230+
231+
.preview-tab > :last-child {
232+
flex: 1;
233+
min-height: 0;
234+
}
235+
236+
.jump-note {
237+
margin: 0;
238+
font-size: 0.9em;
239+
color: var(--f-text-dim);
240+
}
241+
191242
.preview {
192243
background: var(--f-panel);
193244
padding: 8px;

src/components/ElevationPreviewPanel.vue

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- src/components/ElevationPreviewPanel.vue -->
22
<script setup lang="ts">
3-
import { computed, ref } from "vue";
3+
import { computed, ref, watch } from "vue";
44
import { elevationCtxFromPreset } from "../model/elevationPreviewCtx";
55
import { PLANET_LABELS, type Planet } from "../model/planets";
66
import { surfaceSeedForPlanet } from "../model/planetSurfaceSeed";
@@ -17,7 +17,20 @@ import { useElevationPreview, type ElevationRenderer } from "./useElevationPrevi
1717
const PREVIEW_PX = 1024;
1818
const TILES_PER_PIXEL = 1;
1919
20-
const props = defineProps<{ renderer?: ElevationRenderer; planet?: Planet }>();
20+
const props = defineProps<{
21+
renderer?: ElevationRenderer;
22+
planet?: Planet;
23+
/**
24+
* World coordinate the render window is centered on - the island finder's
25+
* jump-to target (#27). Both default to 0, which reproduces the original
26+
* "always centered on world origin" behavior exactly (`half` subtracted,
27+
* nothing else). Optional rather than required so every caller that
28+
* predates this - the whole app before Task 8, and most of this file's own
29+
* tests - is unaffected.
30+
*/
31+
centerX?: number;
32+
centerY?: number;
33+
}>();
2134
// In the app no renderer is passed -> build the real worker-backed one (which
2235
// registers its own onBeforeUnmount). Tests inject a fake, so no Worker is made.
2336
const renderer = props.renderer ?? useElevationPreview();
@@ -196,8 +209,8 @@ async function generate() {
196209
view: effectiveView.value,
197210
width: PREVIEW_PX,
198211
height: PREVIEW_PX,
199-
originX: -half,
200-
originY: -half,
212+
originX: (props.centerX ?? 0) - half,
213+
originY: (props.centerY ?? 0) - half,
201214
tilesPerPixel: TILES_PER_PIXEL,
202215
waterLevel: info.ctx.waterLevel,
203216
segmentationMultiplier: info.ctx.segmentationMultiplier,
@@ -241,6 +254,17 @@ async function generate() {
241254
loading.value = false;
242255
}
243256
}
257+
258+
// Re-center on a jump target automatically, but only once the user has
259+
// already asked for a render at least once - a caller that sets centerX/
260+
// centerY before the first Generate click (e.g. mounting straight onto a
261+
// jump target) must not trigger a render the user never asked for.
262+
watch(
263+
() => [props.centerX, props.centerY],
264+
() => {
265+
if (hasRendered.value) void generate();
266+
},
267+
);
244268
</script>
245269

246270
<template>

0 commit comments

Comments
 (0)