Skip to content

Commit d284074

Browse files
committed
feat(settings): make Factorio paths configurable in Settings
The executable, user-data folder, and mods folder were env-var-only with Linux-only defaults. Each now resolves env var → value stored in Settings (Game data → Factorio paths) → per-OS default, probing the usual Steam and standalone install locations on Windows, macOS, and Linux. Resolution happens at call time, so a change applies without a restart. One resolver (factorio-paths.server.ts) replaces the copies that had drifted across the data sync, launch button, companion-mod installer, and screenshot tool — fixing the import step reading ~/.factorio/script-output regardless of FACTORIO_DATA_DIR. Adds FACTORIO_MODS_DIR for an independently relocated mods folder. Path settings follow PYOPS_HIDE_STORAGE_PATHS: hidden instances neither show nor accept them.
1 parent c066faa commit d284074

16 files changed

Lines changed: 643 additions & 63 deletions

app/e2e/mut/factorio-paths.e2e.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { expect, test } from "@playwright/test";
4+
import { MUT_DATA_DIR, goto } from "./helpers";
5+
6+
/** Settings → Game data → Factorio paths: a custom executable path saves into
7+
* app-config.json, survives a reload, warns when it doesn't exist on disk, and
8+
* "Use platform defaults" clears it back to the probed default. */
9+
test("Factorio paths save, persist, and reset to platform defaults", async ({ page }) => {
10+
const readConfig = (): { factorioBin?: string } => {
11+
const file = join(MUT_DATA_DIR, "app-config.json");
12+
return existsSync(file)
13+
? (JSON.parse(readFileSync(file, "utf8")) as { factorioBin?: string })
14+
: {};
15+
};
16+
17+
await goto(page, "/settings?tab=data");
18+
const bin = page.getByLabel("Factorio executable");
19+
await expect(bin).toBeVisible();
20+
const saveButton = page.getByRole("button", { name: "Save paths" });
21+
const defaultsButton = page.getByRole("button", { name: "Use platform defaults" });
22+
23+
// start from a clean slate even on a warm re-run
24+
if (await defaultsButton.isEnabled()) {
25+
await defaultsButton.click();
26+
await expect(bin).toHaveValue("");
27+
}
28+
await expect(saveButton).toBeDisabled(); // nothing dirty yet
29+
30+
// a nonexistent custom path: saves, then flags that it isn't on disk
31+
const customBin = "/tmp/pyops-e2e/does-not-exist/factorio";
32+
await bin.fill(customBin);
33+
await expect(saveButton).toBeEnabled();
34+
await saveButton.click();
35+
await expect(saveButton).toBeDisabled(); // refetch landed — no longer dirty
36+
await expect(page.getByText(`Not found on disk: ${customBin}`)).toBeVisible();
37+
expect(readConfig().factorioBin).toBe(customBin);
38+
39+
// the stored value survives a reload
40+
await goto(page, "/settings?tab=data");
41+
await expect(page.getByLabel("Factorio executable")).toHaveValue(customBin);
42+
43+
// reset: field empties, placeholder shows the probed default, config key drops
44+
await page.getByRole("button", { name: "Use platform defaults" }).click();
45+
const cleared = page.getByLabel("Factorio executable");
46+
await expect(cleared).toHaveValue("");
47+
await expect(cleared).toHaveAttribute("placeholder", /factorio/i);
48+
await expect.poll(() => readConfig().factorioBin).toBeUndefined();
49+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { useState } from "react";
3+
import { Check } from "lucide-react";
4+
import { factorioPathsFn, setFactorioPathsFn, type FactorioPathField } from "../../server/factorio";
5+
import { Button } from "#/components/ui/button.tsx";
6+
import { Card, CardHeader, CardTitle } from "#/components/ui/card.tsx";
7+
import { Input } from "#/components/ui/input.tsx";
8+
import { FieldLabel } from "#/components/ui/label.tsx";
9+
import { Skeleton } from "#/components/ui/skeleton.tsx";
10+
import { InfoHint } from "../info-hint";
11+
12+
type PathKey = "bin" | "dataDir" | "modsDir";
13+
14+
const FIELDS: { key: PathKey; label: string; hint: string; env: string }[] = [
15+
{
16+
key: "bin",
17+
label: "Factorio executable",
18+
hint: "The game binary, e.g. Factorio/bin/x64/factorio — used by game-data sync and Launch Factorio.",
19+
env: "FACTORIO_BIN",
20+
},
21+
{
22+
key: "dataDir",
23+
label: "Factorio user-data folder",
24+
hint: "Contains Factorio's configuration, saves, mods, and script-output — not the installation folder.",
25+
env: "FACTORIO_DATA_DIR",
26+
},
27+
{
28+
key: "modsDir",
29+
label: "Factorio mods folder",
30+
hint: "The folder that directly contains mod-list.json and installed mods. Defaults to <user-data>/mods.",
31+
env: "FACTORIO_MODS_DIR",
32+
},
33+
];
34+
35+
/** Where Factorio lives on this machine — stored in app config, editable here.
36+
* Env vars always win; blank fields fall back to the per-OS platform default. */
37+
export function FactorioPathsCard() {
38+
const qc = useQueryClient();
39+
const paths = useQuery({ queryKey: ["factorioPaths"], queryFn: () => factorioPathsFn() });
40+
const save = useMutation({
41+
mutationFn: (d: { bin?: string; dataDir?: string; modsDir?: string }) =>
42+
setFactorioPathsFn({ data: d }),
43+
onSuccess: () => {
44+
setDraft({});
45+
// paths feed the sync, launch button, companion installer, and drift checks
46+
void qc.invalidateQueries({ queryKey: ["factorioPaths"] });
47+
void qc.invalidateQueries({ queryKey: ["companionStatus"] });
48+
void qc.invalidateQueries({ queryKey: ["dataStatus"] });
49+
void qc.invalidateQueries({ queryKey: ["modDrift"] });
50+
},
51+
});
52+
const [draft, setDraft] = useState<Partial<Record<PathKey, string>>>({});
53+
54+
const d = paths.data;
55+
if (!d) {
56+
return (
57+
<Card>
58+
<CardHeader>
59+
<CardTitle>Factorio paths</CardTitle>
60+
</CardHeader>
61+
<div className="space-y-2 px-3 pb-3">
62+
{FIELDS.map((f) => (
63+
<Skeleton key={f.key} className="h-14 w-full" />
64+
))}
65+
</div>
66+
</Card>
67+
);
68+
}
69+
if (d.hidden) {
70+
return (
71+
<Card>
72+
<CardHeader>
73+
<CardTitle>Factorio paths</CardTitle>
74+
</CardHeader>
75+
<div className="px-3 pb-3 text-sm text-muted-foreground">
76+
Path settings are hidden for this instance.
77+
</div>
78+
</Card>
79+
);
80+
}
81+
82+
const fieldValue = (key: PathKey) => draft[key] ?? d[key].stored;
83+
const dirty = FIELDS.some((f) => fieldValue(f.key) !== d[f.key].stored);
84+
const anyStored = FIELDS.some((f) => d[f.key].stored !== "");
85+
86+
const saveAll = () => {
87+
const patch: Partial<Record<PathKey, string>> = {};
88+
for (const f of FIELDS) {
89+
if (fieldValue(f.key) !== d[f.key].stored) patch[f.key] = fieldValue(f.key);
90+
}
91+
save.mutate(patch);
92+
};
93+
94+
return (
95+
<Card>
96+
<CardHeader>
97+
<CardTitle>Factorio paths</CardTitle>
98+
<InfoHint content="Where PyOps finds your Factorio install. Blank fields use the platform default; the FACTORIO_BIN / FACTORIO_DATA_DIR / FACTORIO_MODS_DIR env vars take priority when set." />
99+
</CardHeader>
100+
<div className="space-y-3 px-3 pb-3 text-sm">
101+
{FIELDS.map((f) => (
102+
<PathFieldRow
103+
key={f.key}
104+
label={f.label}
105+
hint={f.hint}
106+
env={f.env}
107+
field={d[f.key]}
108+
value={fieldValue(f.key)}
109+
onChange={(v) => setDraft((prev) => ({ ...prev, [f.key]: v }))}
110+
/>
111+
))}
112+
<div className="flex flex-wrap items-center gap-2 pt-1">
113+
<Button variant="outline" size="sm" onClick={saveAll} disabled={!dirty || save.isPending}>
114+
Save paths
115+
</Button>
116+
<Button
117+
variant="ghost"
118+
size="sm"
119+
onClick={() => save.mutate({ bin: "", dataDir: "", modsDir: "" })}
120+
disabled={save.isPending || (!anyStored && !dirty)}
121+
className="text-muted-foreground"
122+
>
123+
Use platform defaults
124+
</Button>
125+
</div>
126+
{save.isError && (
127+
<p className="text-sm text-destructive">Save failed: {save.error.message}</p>
128+
)}
129+
</div>
130+
</Card>
131+
);
132+
}
133+
134+
/** One path field: env-set fields show the winning value read-only; otherwise an
135+
* input whose placeholder is the platform default in effect when left blank. */
136+
function PathFieldRow({
137+
label,
138+
hint,
139+
env,
140+
field,
141+
value,
142+
onChange,
143+
}: {
144+
label: string;
145+
hint: string;
146+
env: string;
147+
field: FactorioPathField;
148+
value: string;
149+
onChange: (v: string) => void;
150+
}) {
151+
return (
152+
<div>
153+
<FieldLabel>{label}</FieldLabel>
154+
{field.source === "env" ? (
155+
<div className="mt-1 flex items-center gap-1 text-sm text-success">
156+
<Check className="size-3.5 shrink-0" />
157+
<span className="min-w-0 truncate" title={field.effective}>
158+
Set via {env} env (wins): {field.effective}
159+
</span>
160+
</div>
161+
) : (
162+
<Input
163+
value={value}
164+
placeholder={field.fallback}
165+
aria-label={label}
166+
onChange={(e) => onChange(e.target.value)}
167+
className="mt-1 w-full font-mono"
168+
/>
169+
)}
170+
<p className="mt-0.5 text-sm text-muted-foreground">{hint}</p>
171+
{!field.exists && (
172+
<p className="text-sm break-all text-warning">Not found on disk: {field.effective}</p>
173+
)}
174+
</div>
175+
);
176+
}

app/src/db/import-factorio.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,17 @@
1111
*/
1212
import Database from "better-sqlite3";
1313
import { readdirSync, readFileSync } from "node:fs";
14-
import { homedir } from "node:os";
1514
import { dirname, join } from "node:path";
1615
import { synthesizePass2 } from "./synthesize.ts";
1716
import { temperatureFedDrain, type TempFedFluid } from "./fluid-energy.ts";
1817
import {
1918
REFERENCE_DATA_FORMAT_META_KEY,
2019
REFERENCE_DATA_FORMAT_VERSION,
2120
} from "../lib/data-format.ts";
21+
import { factorioScriptOutputDir } from "../server/factorio-paths.server.ts";
2222
import { PROJECTS_DIR } from "../server/paths.server.ts";
2323
import { configureSqliteConnection } from "../server/provision.ts";
2424

25-
const DEFAULT_DUMP = join(homedir(), ".factorio", "script-output", "data-raw-dump.json");
26-
2725
// All prototype types that are "items" (can be inventory contents / recipe components).
2826
const ITEM_TYPES = [
2927
"item",
@@ -202,7 +200,7 @@ export type ImportSummary = {
202200
export function importFactorioDump(
203201
opts: { dumpPath?: string; dbUrl?: string } = {},
204202
): ImportSummary {
205-
const DUMP = opts.dumpPath ?? DEFAULT_DUMP;
203+
const DUMP = opts.dumpPath ?? join(factorioScriptOutputDir(), "data-raw-dump.json");
206204
const DB_URL = opts.dbUrl ?? process.env.DATABASE_URL ?? join(PROJECTS_DIR, "default.db");
207205

208206
const raw = JSON.parse(readFileSync(DUMP, "utf8")) as Record<string, Record<string, any>>;

app/src/routes/settings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { BlockShareCard } from "../components/block-share-card.tsx";
2121
import { ProjectBackupCard } from "../components/project-backup-card.tsx";
2222
import { HorizonPicker } from "../components/horizon-picker";
2323
import { CompanionModCard } from "../components/companion-mod-card";
24+
import { FactorioPathsCard } from "../components/settings/factorio-paths-card.tsx";
2425
import { FactorySolverDebugCard } from "../components/settings/factory-solver-debug-card.tsx";
2526
import { DriftChanges } from "../components/drift-changes";
2627
import { driftModal } from "../lib/drift-store";
@@ -197,6 +198,8 @@ function GameDataTab() {
197198
</div>
198199
</Card>
199200

201+
<FactorioPathsCard />
202+
200203
<ModDriftCard data={drift.data} />
201204

202205
<ModsCard mods={status.data?.mods ?? []} />

app/src/server/agent-tools.server.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,24 +1610,13 @@ export const gameScreenshot = tool({
16101610
}),
16111611
execute: async ({ panel, crop, scale }) => {
16121612
try {
1613-
const { homedir, tmpdir } = await import("node:os");
1613+
const { tmpdir } = await import("node:os");
16141614
const path = await import("node:path");
16151615
const { stat } = await import("node:fs/promises");
1616-
1617-
// Where Factorio writes script-output, per OS (overridable for odd installs).
1618-
const scriptOutput = () => {
1619-
if (process.env.FACTORIO_SCRIPT_OUTPUT) return process.env.FACTORIO_SCRIPT_OUTPUT;
1620-
const home = homedir();
1621-
if (process.platform === "win32") {
1622-
const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
1623-
return path.join(appdata, "Factorio", "script-output");
1624-
}
1625-
if (process.platform === "darwin") {
1626-
return path.join(home, "Library", "Application Support", "factorio", "script-output");
1627-
}
1628-
return path.join(home, ".factorio", "script-output");
1629-
};
1630-
const raw = path.join(scriptOutput(), "pyops-shot.png");
1616+
// Where Factorio writes script-output — shared resolver (env → Settings →
1617+
// per-OS default), same as the data sync.
1618+
const { factorioScriptOutputDir } = await import("./factorio-paths.server.ts");
1619+
const raw = path.join(factorioScriptOutputDir(), "pyops-shot.png");
16311620
const statRaw = () => stat(raw).catch(() => null);
16321621
const before = await statRaw().then((s) => s?.mtimeMs ?? 0);
16331622

app/src/server/app-config.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export type AppConfig = {
2323
openrouterApiKey?: string;
2424
model?: string;
2525
factorySolverDebug?: boolean;
26+
// Factorio install paths (Settings → Game data). Env vars still win; see
27+
// factorio-paths.server.ts for the resolution order.
28+
factorioBin?: string;
29+
factorioDataDir?: string;
30+
factorioModsDir?: string;
2631
};
2732

2833
export function readAppConfig(): AppConfig {

app/src/server/companion-mod.server.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,18 @@
1515
*/
1616
import { cp, lstat, mkdir, readFile, realpath, rm, symlink } from "node:fs/promises";
1717
import { existsSync } from "node:fs";
18-
import { homedir, platform } from "node:os";
18+
import { platform } from "node:os";
1919
import { join } from "node:path";
2020

21+
import { factorioModsDir } from "./factorio-paths.server.ts";
2122
import { MOD_SOURCE_DIR } from "./paths.server.ts";
2223

23-
const FACTORIO_DATA = process.env.FACTORIO_DATA_DIR ?? join(homedir(), ".factorio");
24-
const MODS_DIR = join(FACTORIO_DATA, "mods");
2524
// the bundled mod source (sibling mod/ dir in the source tree; overridable for a
2625
// packaged build via PYOPS_MOD_DIR — see server/paths.ts)
2726
const SOURCE_DIR = MOD_SOURCE_DIR;
28-
const TARGET = join(MODS_DIR, "pyops");
27+
// resolved per call (Settings can change it at runtime): the mods folder and the
28+
// install target <mods>/pyops (the folder name must equal the mod's info.json name)
29+
const target = () => join(factorioModsDir(), "pyops");
2930

3031
export type CompanionPlatform = "linux" | "mac" | "windows" | "other";
3132
export type InstallMethod = "symlink" | "copy";
@@ -69,6 +70,7 @@ export type CompanionStatus = {
6970
};
7071

7172
export async function companionStatus(): Promise<CompanionStatus> {
73+
const TARGET = target();
7274
const platformName = detectPlatform();
7375
const sourceVersion = await readVersion(SOURCE_DIR);
7476

@@ -102,7 +104,7 @@ export async function companionStatus(): Promise<CompanionStatus> {
102104

103105
return {
104106
platform: platformName,
105-
modsDir: MODS_DIR,
107+
modsDir: factorioModsDir(),
106108
sourceDir: SOURCE_DIR,
107109
symlinkIsJunction: platformName === "windows",
108110
installed,
@@ -117,6 +119,7 @@ export async function companionStatus(): Promise<CompanionStatus> {
117119
/** Remove an existing <mods>/pyops only if we can prove it's ours — a symlink, or
118120
* a directory whose info.json declares name "pyops". Refuses anything else. */
119121
async function removeExisting(): Promise<void> {
122+
const TARGET = target();
120123
if (!existsSync(TARGET)) return;
121124
const st = await lstat(TARGET);
122125
if (st.isSymbolicLink()) {
@@ -140,13 +143,13 @@ async function removeExisting(): Promise<void> {
140143

141144
export async function installCompanion(method: InstallMethod): Promise<CompanionStatus> {
142145
if (!existsSync(SOURCE_DIR)) throw new Error(`mod source not found at ${SOURCE_DIR}`);
143-
await mkdir(MODS_DIR, { recursive: true });
146+
await mkdir(factorioModsDir(), { recursive: true });
144147
await removeExisting();
145148
if (method === "symlink") {
146149
// junction on Windows (no admin/Developer Mode needed); dir symlink elsewhere
147-
await symlink(SOURCE_DIR, TARGET, platform() === "win32" ? "junction" : "dir");
150+
await symlink(SOURCE_DIR, target(), platform() === "win32" ? "junction" : "dir");
148151
} else {
149-
await cp(SOURCE_DIR, TARGET, { recursive: true });
152+
await cp(SOURCE_DIR, target(), { recursive: true });
150153
}
151154
return companionStatus();
152155
}

0 commit comments

Comments
 (0)