Skip to content

Commit f38503f

Browse files
committed
fix: enhance exports with new assigned nodes
1 parent 4b8bb77 commit f38503f

8 files changed

Lines changed: 68 additions & 22 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,4 @@ scratch/
153153
tmp/
154154
temp/
155155
playground/
156-
.claude/settings.local.json
156+
.claude/

src/factories/inputs/input-row/FactoryInputRow.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ export function FactoryInputRow(props: IFactoryInputRowProps) {
119119
const handleFactoryChange = useCallback(
120120
(selectedFactoryId: string | null) => {
121121
onChangeHandler(`inputs.${index}.factoryId`)(selectedFactoryId);
122+
123+
// `nodeIds` is only meaningful for World inputs. If the user
124+
// moves the source away from WORLD, drop any dormant assignment
125+
// so a future toggle back to WORLD doesn't silently reactivate
126+
// node bindings the user had effectively abandoned.
127+
if (selectedFactoryId !== WORLD_SOURCE_ID) {
128+
useStore.getState().clearInputAssignment(factoryId, index);
129+
}
130+
122131
if (
123132
!selectedFactoryId ||
124133
selectedFactoryId === WORLD_SOURCE_ID ||
@@ -138,7 +147,7 @@ export function FactoryInputRow(props: IFactoryInputRowProps) {
138147
);
139148
}
140149
},
141-
[onChangeHandler, index, input.resource],
150+
[onChangeHandler, index, input.resource, factoryId],
142151
);
143152

144153
const isVisible = useIsFactoryVisible(factoryId, false, input.resource);

src/factories/store/factoryNodeAssignmentsSelectors.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const useNodeAssignments = (
6767
if (!input.nodeIds || input.nodeIds.length === 0) return;
6868
if (!input.resource) return;
6969

70-
// One row per assigned node — the resolution step below joins
70+
// One row per assigned node. The resolution step below joins
7171
// each row back to a NodeAssignmentRef.
7272
for (const nodeId of input.nodeIds) {
7373
parts.push(
@@ -119,7 +119,7 @@ export const useNodeAssignments = (
119119
resource,
120120
};
121121

122-
// Multiple inputs/factories CAN share the same node keep them
122+
// Multiple inputs/factories CAN share the same node, keep them
123123
// as an array so the popup can list all of them.
124124
const arr = result[nodeId] ?? [];
125125
arr.push(ref);
@@ -148,7 +148,7 @@ export const useFactoryInputAssignedNodes = (
148148
);
149149

150150
// Encode `[resource, ...nodeIds]` as a single string. Empty when
151-
// there's nothing to render the consumer can short-circuit.
151+
// there's nothing to render, the consumer can short-circuit.
152152
const signature = useStore(state => {
153153
if (!factoryId || inputIndex == null) return '';
154154
const input = state.factories.factories[factoryId]?.inputs?.[inputIndex];
@@ -171,7 +171,7 @@ export const useFactoryInputAssignedNodes = (
171171
const out: WorldResourceNode[] = [];
172172
for (const id of ids) {
173173
const node = byId.get(id);
174-
if (!node) continue; // orphan node removed by a savegame import
174+
if (!node) continue; // orphan: node removed by a savegame import
175175
if (node.resource !== resource) continue; // resource swap auto-heal
176176
out.push(node);
177177
}

src/games/store/gameFactoriesActions.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,16 @@ export const gameFactoriesActions = createActions({
149149
name: disambiguateFactoryName(payload.factory.name, existingNames),
150150
// Cross-factory references don't exist in the target game, so drop
151151
// them to avoid orphan links pointing at unrelated factories.
152-
inputs: (payload.factory.inputs ?? []).map(input => ({
153-
...input,
154-
factoryId: null,
155-
})),
152+
// `nodeIds` are also game-specific (resource node ids belong to the
153+
// source game, especially after savegame randomizer overrides), so
154+
// strip them to prevent dormant assignments reactivating if the
155+
// user later toggles the source back to WORLD.
156+
inputs: (payload.factory.inputs ?? []).map(
157+
({ nodeIds: _nodeIds, ...rest }) => ({
158+
...rest,
159+
factoryId: null,
160+
}),
161+
),
156162
};
157163
state.factories.factories[newFactoryId] = importedFactory;
158164

@@ -271,8 +277,11 @@ export function serializeFactory(factoryId: string): SerializedFactory {
271277

272278
const cleanedFactory: Factory = {
273279
...cloneDeep(factory),
274-
inputs: (factory.inputs ?? []).map(input => ({
275-
...input,
280+
// Same scrub as the importer side: drop cross-factory `factoryId`
281+
// links AND the game-specific `nodeIds` so the serialized blob has
282+
// no dangling references back to the source game.
283+
inputs: (factory.inputs ?? []).map(({ nodeIds: _nodeIds, ...rest }) => ({
284+
...rest,
276285
factoryId: null,
277286
})),
278287
};

src/map/AssignNodesToInputModal.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface AssignNodesToInputModalProps {
2727
}
2828

2929
// Sentinel for the "Create new World input" option in the input
30-
// dropdown never collides with a real input index.
30+
// dropdown: never collides with a real input index.
3131
const CREATE_NEW_VALUE = '__create__';
3232

3333
interface ExistingAssignment {
@@ -46,7 +46,7 @@ interface ExistingAssignment {
4646
* Hitting "Add" assigns and resets the dropdowns WITHOUT closing,
4747
* so the user can chain "this node is fed by 3 factories" in one
4848
* session.
49-
* 3. "Done" closes. Selection is intentionally NOT cleared here
49+
* 3. "Done" closes. Selection is intentionally NOT cleared here:
5050
* the caller decides whether the multi-select stays alive after
5151
* assignment (e.g. the sum-mode summary keeps it).
5252
*/
@@ -191,7 +191,7 @@ export function AssignNodesToInputModal({
191191

192192
// ─── Remove handler: unassigns ALL matched nodes from a single
193193
// (factory, input) pair. The action mutates one node at a time
194-
// because the underlying store action is per-node this is fine
194+
// because the underlying store action is per-node, this is fine
195195
// in practice, the loop runs inside one React render.
196196
const handleRemove = (entry: ExistingAssignment) => {
197197
const state = useStore.getState();
@@ -245,7 +245,7 @@ export function AssignNodesToInputModal({
245245
</Text>
246246
{existingAssignments.length === 0 ? (
247247
<Text size="xs" c="dimmed">
248-
No assignments yet — pick a factory and input below.
248+
No assignments yet. Pick a factory and input below.
249249
</Text>
250250
) : (
251251
<Table withRowBorders={false} verticalSpacing={4}>
@@ -306,7 +306,7 @@ export function AssignNodesToInputModal({
306306

307307
{factoryOptions.length === 0 ? (
308308
<Text size="xs" c="dimmed">
309-
No factories in this game yet — create one first.
309+
No factories in this game yet. Create one first.
310310
</Text>
311311
) : (
312312
<Group gap="xs" align="flex-end" wrap="nowrap">

src/map/ResourceMarkersLayer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ function buildPopupHtml(
148148
.filter(Boolean)
149149
.join(' ');
150150

151-
// "Assigned to: X · Y" line only rendered when there's an actual
151+
// "Assigned to: X · Y" line, only rendered when there's an actual
152152
// label to show. Reuses `__method` styling to match the other
153153
// metadata lines visually.
154154
const assignmentLine =

src/map/WorldMapView.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export function WorldMapView({ gameId }: WorldMapViewProps) {
144144
hideUsedNodes,
145145
usedNodesList,
146146
sumMode,
147+
selectedNodeIdsList,
147148
collectibleVisibility,
148149
hideCollectedCollectibles,
149150
collectedList,
@@ -156,6 +157,13 @@ export function WorldMapView({ gameId }: WorldMapViewProps) {
156157
hideUsedNodes: mapState?.hideUsedNodes ?? false,
157158
usedNodesList: game?.usedNodes ?? EMPTY_USED_NODES,
158159
sumMode: state.mapSelection?.sumMode ?? false,
160+
// Subscribed here so the filter memo invalidates when the user
161+
// navigates in from the input row's "View on map" button. The
162+
// selection seed has to bypass `hideUsedNodes` (assignment
163+
// automatically marks nodes as used, otherwise the markers
164+
// would arrive on a map that immediately hides them).
165+
selectedNodeIdsList:
166+
state.mapSelection?.selectedNodeIds ?? EMPTY_USED_NODES,
159167
collectibleVisibility:
160168
mapState?.collectibleVisibility ?? EMPTY_COLLECTIBLE_VISIBILITY,
161169
hideCollectedCollectibles: mapState?.hideCollectedCollectibles ?? false,
@@ -170,6 +178,10 @@ export function WorldMapView({ gameId }: WorldMapViewProps) {
170178

171179
const usedNodes = useMemo(() => new Set(usedNodesList), [usedNodesList]);
172180
const collectedIds = useMemo(() => new Set(collectedList), [collectedList]);
181+
const selectedNodeIdsSet = useMemo(
182+
() => new Set(selectedNodeIdsList),
183+
[selectedNodeIdsList],
184+
);
173185

174186
// ─── Node-to-factory assignments. The selector returns the
175187
// full per-node ref array (already filtered for orphans and
@@ -195,8 +207,8 @@ export function WorldMapView({ gameId }: WorldMapViewProps) {
195207

196208
// ─── Assignment modal state. Owned here (not in the marker
197209
// layer) so the same modal instance is reused regardless of
198-
// entry point popup action, sum-mode summary, or future
199-
// callers and so the modal renders inside the React tree
210+
// entry point (popup action, sum-mode summary, or future
211+
// callers), and so the modal renders inside the React tree
200212
// instead of the imperative Leaflet layer.
201213
const [assignTarget, setAssignTarget] = useState<WorldResourceNode | null>(
202214
null,
@@ -213,11 +225,27 @@ export function WorldMapView({ gameId }: WorldMapViewProps) {
213225
// biome-ignore lint/correctness/useExhaustiveDependencies: savegameOverrides is read indirectly via getWorldResourceNodes' useStore.getState() lookup; the dep is required to invalidate the memo on import.
214226
const filteredNodes = useMemo(() => {
215227
return getWorldResourceNodes(gameId).filter(node => {
228+
// Selection always wins. Reason: the user got here either via
229+
// "View on map" from a factory input row (we just programmatic-
230+
// ally selected the assigned nodes) or via an explicit click on
231+
// the marker. Either way, hiding the very thing they pointed
232+
// at would be hostile UX. This also covers the assignment side-
233+
// effect: assigning a node marks it as used, so without this
234+
// override the freshly-assigned nodes would vanish under
235+
// `hideUsedNodes`.
236+
if (selectedNodeIdsSet.has(node.id)) return true;
216237
if (!resourceFilters[node.resource]?.includes(node.purity)) return false;
217238
if (hideUsedNodes && usedNodes.has(node.id)) return false;
218239
return true;
219240
});
220-
}, [gameId, resourceFilters, hideUsedNodes, usedNodes, savegameOverrides]);
241+
}, [
242+
gameId,
243+
resourceFilters,
244+
hideUsedNodes,
245+
usedNodes,
246+
selectedNodeIdsSet,
247+
savegameOverrides,
248+
]);
221249

222250
const filteredCollectibles = useMemo(() => {
223251
return getWorldCollectibles().filter(collectible => {

src/solver/store/solverFactoriesActions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const solverFactoriesActions = createActions({
173173

174174
// ─── Step 3: also mark the nodes as "used" at the game level
175175
// so the map's "hide used" filter behaves consistently.
176-
// Add-only by design see the action JSDoc for why we
176+
// Add-only by design: see the action JSDoc for why we
177177
// never remove from `usedNodes` on unassign.
178178
if (gameId) {
179179
const game = state.games.games[gameId];

0 commit comments

Comments
 (0)