factorio-mod-workshop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fmw/portal.py ADDED
@@ -0,0 +1,336 @@
1
+ """Factorio mod portal access: official JSON API plus discussion scraping.
2
+
3
+ The portal has a public read-only API for mod metadata, but discussions are
4
+ HTML only. The markup is server-rendered with stable semantic classes, so
5
+ BeautifulSoup is enough - no browser needed, no login needed to read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import time
12
+ from dataclasses import dataclass, field, asdict
13
+ from typing import Iterable, NamedTuple
14
+
15
+ import httpx
16
+ from bs4 import BeautifulSoup
17
+
18
+ BASE = "https://mods.factorio.com"
19
+ API_BASE = f"{BASE}/api"
20
+ USER_AGENT = "factorio-mod-workshop/0.1 (+https://github.com/AivanF/factorio-mod-workshop) mod-maintenance-tooling"
21
+
22
+ THREAD_ID_RE = re.compile(r"/discussion/([0-9a-f]{24})")
23
+ REQUEST_DELAY = 0.4 # be a polite citizen; the portal is a free service
24
+
25
+
26
+ class PortalError(RuntimeError):
27
+ pass
28
+
29
+
30
+ class NotFound(PortalError):
31
+ """The portal returned 404 - the mod or thread does not exist there."""
32
+
33
+
34
+ @dataclass
35
+ class Message:
36
+ author: str
37
+ posted_at: str # ISO 8601, exact (from the title= attribute)
38
+ edited_at: str | None
39
+ body: str # markdown-ish plain text
40
+ is_op: bool = False
41
+
42
+
43
+ @dataclass
44
+ class ThreadSummary:
45
+ id: str
46
+ mod: str
47
+ title: str
48
+ tag: str # human name, e.g. "Bugs"
49
+ author: str
50
+ posted_at: str
51
+ reply_count: int
52
+ last_author: str | None
53
+ last_at: str | None
54
+
55
+ @property
56
+ def url(self) -> str:
57
+ return f"{BASE}/mod/{self.mod}/discussion/{self.id}"
58
+
59
+
60
+ @dataclass
61
+ class Thread:
62
+ id: str
63
+ mod: str
64
+ title: str
65
+ tag: str
66
+ messages: list[Message] = field(default_factory=list)
67
+
68
+ @property
69
+ def url(self) -> str:
70
+ return f"{BASE}/mod/{self.mod}/discussion/{self.id}"
71
+
72
+ def to_dict(self) -> dict:
73
+ d = asdict(self)
74
+ d["url"] = self.url
75
+ return d
76
+
77
+
78
+ class Portal:
79
+ def __init__(self, timeout: float = 20.0, delay: float = REQUEST_DELAY):
80
+ self._client = httpx.Client(
81
+ headers={"User-Agent": USER_AGENT},
82
+ timeout=timeout,
83
+ follow_redirects=True,
84
+ )
85
+ self._delay = delay
86
+ self._last_request = 0.0
87
+
88
+ def close(self) -> None:
89
+ self._client.close()
90
+
91
+ def __enter__(self) -> "Portal":
92
+ return self
93
+
94
+ def __exit__(self, *exc) -> None:
95
+ self.close()
96
+
97
+ def _get(self, url: str) -> httpx.Response:
98
+ wait = self._delay - (time.monotonic() - self._last_request)
99
+ if wait > 0:
100
+ time.sleep(wait)
101
+ last_exc: Exception | None = None
102
+ for attempt in range(3):
103
+ try:
104
+ resp = self._client.get(url)
105
+ self._last_request = time.monotonic()
106
+ if resp.status_code == 404:
107
+ raise NotFound(f"404 Not Found: {url}")
108
+ if resp.status_code >= 500:
109
+ raise httpx.HTTPError(f"{resp.status_code} from {url}")
110
+ resp.raise_for_status()
111
+ return resp
112
+ except NotFound:
113
+ raise
114
+ except Exception as exc: # network hiccup / 5xx
115
+ last_exc = exc
116
+ time.sleep(1.5 * (attempt + 1))
117
+ raise PortalError(f"Failed to fetch {url}: {last_exc}")
118
+
119
+ # ------------------------------------------------------------------
120
+ # Official API
121
+ # ------------------------------------------------------------------
122
+ def mod_info(self, mod: str) -> dict:
123
+ """Full mod metadata: releases, deps, changelog, downloads, tags."""
124
+ return self._get(f"{API_BASE}/mods/{mod}/full").json()
125
+
126
+ # ------------------------------------------------------------------
127
+ # Discussions (HTML)
128
+ # ------------------------------------------------------------------
129
+ def _soup(self, url: str) -> BeautifulSoup:
130
+ return BeautifulSoup(self._get(url).text, "html.parser")
131
+
132
+ def discussion_tags(self, mod: str) -> list[tuple[str, str]]:
133
+ """Return [(code, name)] of discussion tag filters offered for the mod."""
134
+ soup = self._soup(f"{BASE}/mod/{mod}/discussion")
135
+ return self._tags_from_soup(soup)
136
+
137
+ @staticmethod
138
+ def _tags_from_soup(soup: BeautifulSoup) -> list[tuple[str, str]]:
139
+ tags: list[tuple[str, str]] = []
140
+ holder = soup.select_one(".discussion-tags-filter")
141
+ if not holder:
142
+ return tags
143
+ for a in holder.select("a[href]"):
144
+ code = a["href"].rstrip("/").rsplit("/", 1)[-1]
145
+ name = a.get_text(strip=True)
146
+ if code and name:
147
+ tags.append((code, name))
148
+ return tags
149
+
150
+ def thread_list(self, mod: str, max_pages: int = 50) -> tuple[list[ThreadSummary], list[str]]:
151
+ """All discussion threads of a mod, plus a list of warnings.
152
+
153
+ The listing shows 50 threads per page and is paginated as
154
+ /discussion/page/N (note: a ?page=N query parameter is silently
155
+ ignored, which looks exactly like truncation). We walk pages until one
156
+ comes back empty.
157
+ """
158
+ warnings: list[str] = []
159
+ by_id: dict[str, ThreadSummary] = {}
160
+
161
+ for page in range(1, max_pages + 1):
162
+ url = f"{BASE}/mod/{mod}/discussion"
163
+ if page > 1:
164
+ url += f"/page/{page}"
165
+ rows = list(self._rows_from_soup(self._soup(url), mod))
166
+ if not rows:
167
+ break
168
+ for summary in rows:
169
+ by_id[summary.id] = summary
170
+ else:
171
+ warnings.append(
172
+ f"stopped after {max_pages} pages - there may be more threads"
173
+ )
174
+
175
+ ordered = sorted(by_id.values(), key=lambda t: t.last_at or t.posted_at, reverse=True)
176
+ return ordered, warnings
177
+
178
+ @staticmethod
179
+ def _rows_from_soup(soup: BeautifulSoup, mod: str) -> Iterable[ThreadSummary]:
180
+ for tr in soup.select("tr.discussion-list-message"):
181
+ link = tr.select_one(".discussion-list-message-title a[href]")
182
+ if not link:
183
+ continue
184
+ m = THREAD_ID_RE.search(link["href"])
185
+ if not m:
186
+ continue
187
+ tds = tr.find_all("td", recursive=False)
188
+
189
+ title_cell = tds[0]
190
+ author_a = title_cell.select_one('a[href^="/user/"]')
191
+ posted_span = title_cell.select_one("span[title]")
192
+
193
+ tag = tds[1].get_text(strip=True) if len(tds) >= 2 else ""
194
+
195
+ reply_count = 0
196
+ last_author = None
197
+ last_at = None
198
+ if len(tds) >= 3:
199
+ meta = tds[2]
200
+ count_m = re.search(r"(\d+)", meta.get_text(" ", strip=True))
201
+ if count_m:
202
+ reply_count = int(count_m.group(1))
203
+ last_a = meta.select_one('a[href^="/user/"]')
204
+ if last_a:
205
+ last_author = last_a.get_text(strip=True)
206
+ last_span = meta.select_one("span[title]")
207
+ if last_span:
208
+ last_at = last_span["title"]
209
+
210
+ posted_at = posted_span["title"] if posted_span else ""
211
+ yield ThreadSummary(
212
+ id=m.group(1),
213
+ mod=mod,
214
+ title=link.get("title") or link.get_text(strip=True),
215
+ tag=tag or "",
216
+ author=author_a.get_text(strip=True) if author_a else "",
217
+ posted_at=posted_at,
218
+ reply_count=reply_count,
219
+ last_author=last_author,
220
+ last_at=last_at or posted_at,
221
+ )
222
+
223
+ def thread(self, mod: str, thread_id: str) -> Thread:
224
+ soup = self._soup(f"{BASE}/mod/{mod}/discussion/{thread_id}")
225
+ title_el = soup.select_one(".discussion-detail-title")
226
+ if not title_el:
227
+ raise PortalError(f"Not a discussion page: {mod}/{thread_id}")
228
+
229
+ tag = ""
230
+ badge = title_el.select_one(".discussion-message-tag")
231
+ if badge:
232
+ tag = badge.get("title") or badge.get_text(strip=True)
233
+ badge.extract()
234
+ title = title_el.get_text(" ", strip=True)
235
+
236
+ messages: list[Message] = []
237
+ for i, block in enumerate(soup.select(".discussion-message")):
238
+ author_el = block.select_one(".discussion-message-author a")
239
+ body_el = block.select_one(".discussion-message-body")
240
+ if not author_el or body_el is None:
241
+ continue
242
+ stamps = [d["title"] for d in block.select(".ml8.grey[title]")]
243
+ messages.append(
244
+ Message(
245
+ author=author_el.get_text(strip=True),
246
+ posted_at=stamps[0] if stamps else "",
247
+ edited_at=stamps[1] if len(stamps) > 1 else None,
248
+ body=html_to_text(body_el),
249
+ is_op=(i == 0),
250
+ )
251
+ )
252
+ return Thread(id=thread_id, mod=mod, title=title, tag=tag, messages=messages)
253
+
254
+
255
+ def html_to_text(el) -> str:
256
+ """Flatten a discussion body to readable plain text, keeping line breaks."""
257
+ # A <br> is usually followed by a literal newline in the source, so replacing
258
+ # it with "\n" directly would double every line break inside a paragraph.
259
+ # Use a sentinel and swallow the surrounding whitespace instead.
260
+ for br in el.find_all("br"):
261
+ br.replace_with("\x00")
262
+ for p in el.find_all("p"):
263
+ p.insert_before("\n\n")
264
+ p.insert_after("\n\n")
265
+ text = el.get_text()
266
+ text = re.sub(r"[ \t]*\x00[ \t]*\n?", "\n", text)
267
+ text = re.sub(r"[ \t]+\n", "\n", text)
268
+ text = re.sub(r"\n{3,}", "\n\n", text)
269
+ return text.strip()
270
+
271
+
272
+ # Portal tag names are long and truncating them mid-word ("Gener", "Ideas")
273
+ # reads badly, so display uses fixed abbreviations instead.
274
+ TAG_ABBREV = {
275
+ "Bugs": "bug",
276
+ "General": "gen",
277
+ "Ideas & suggestions": "idea",
278
+ "Announcements": "ann",
279
+ }
280
+
281
+
282
+ def abbrev_tag(tag: str) -> str:
283
+ return TAG_ABBREV.get(tag, (tag or "?").lower()[:4])
284
+
285
+
286
+ # ----------------------------------------------------------------------
287
+ # Title markers
288
+ # ----------------------------------------------------------------------
289
+ # The portal has no thread state, so maintainers annotate the title once they
290
+ # have dealt with a thread. The convention varies: this repository's own mods
291
+ # use "[FIXED] title" on some mods and "title (fixed)" on others, so both
292
+ # bracket styles and both positions have to be accepted. Derived at read time
293
+ # rather than stored, so editing a title on the portal takes effect without a
294
+ # re-sync.
295
+ MARKER_PREFIX_RE = re.compile(r"^\s*[\[(]\s*([^\])]{1,24}?)\s*[\])]\s*")
296
+ MARKER_SUFFIX_RE = re.compile(r"\s*[\[(]\s*([^\])]{1,24}?)\s*[\])]\s*$")
297
+
298
+ RESOLVED_MARKERS = {
299
+ "FIXED", "DONE", "SOLVED", "CLOSED", "EXPLAINED", "ANSWERED",
300
+ "RELEASED", "WONTFIX", "DUPLICATE", "INVALID", "OUTDATED",
301
+ }
302
+ # Not a resolution: the maintainer judged the report itself mistaken.
303
+ WRONG_MARKERS = {"WRONG", "INVALID REPORT", "NOT A BUG"}
304
+
305
+
306
+ class TitleMarker(NamedTuple):
307
+ marker: str | None # the raw text found, e.g. "FIXED" or "fixed?"
308
+ resolved: bool # maintainer considers the thread dealt with
309
+ wrong: bool # maintainer considers the report mistaken
310
+ clean: str # title with the marker removed
311
+
312
+
313
+ def parse_marker(title: str) -> TitleMarker:
314
+ """Split a thread title into its maintainer marker and the rest.
315
+
316
+ A trailing question mark ("[fixed?]") means the maintainer was unsure, so
317
+ it does not count as resolved.
318
+ """
319
+ title = title or ""
320
+ for regex in (MARKER_PREFIX_RE, MARKER_SUFFIX_RE):
321
+ m = regex.search(title)
322
+ if not m:
323
+ continue
324
+ raw = m.group(1).strip()
325
+ key = raw.rstrip("?").strip().upper()
326
+ if key not in RESOLVED_MARKERS and key not in WRONG_MARKERS:
327
+ continue # an ordinary parenthetical, e.g. "Crash (Space Exploration)"
328
+ tentative = raw.endswith("?")
329
+ clean = (title[: m.start()] + title[m.end():]).strip()
330
+ return TitleMarker(
331
+ marker=raw,
332
+ resolved=key in RESOLVED_MARKERS and not tentative,
333
+ wrong=key in WRONG_MARKERS,
334
+ clean=clean,
335
+ )
336
+ return TitleMarker(marker=None, resolved=False, wrong=False, clean=title.strip())
fmw/store.py ADDED
@@ -0,0 +1,141 @@
1
+ """Workspace persistence: cached portal snapshots and change detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from .config import Workspace
11
+ from .portal import Thread, ThreadSummary
12
+
13
+
14
+ def now_iso() -> str:
15
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
16
+
17
+
18
+ def _write_json(path: Path, data: dict) -> None:
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
21
+
22
+
23
+ def _read_json(path: Path) -> dict | None:
24
+ if not path.exists():
25
+ return None
26
+ return json.loads(path.read_text())
27
+
28
+
29
+ # ----------------------------------------------------------------------
30
+ # Thread list snapshots
31
+ # ----------------------------------------------------------------------
32
+ def load_list(ws: Workspace, mod: str) -> dict | None:
33
+ return _read_json(ws.list_file(mod))
34
+
35
+
36
+ def save_list(ws: Workspace, mod: str, threads: list[ThreadSummary], warnings: list[str]) -> None:
37
+ _write_json(
38
+ ws.list_file(mod),
39
+ {
40
+ "mod": mod,
41
+ "fetched_at": now_iso(),
42
+ "warnings": warnings,
43
+ "threads": [asdict(t) for t in threads],
44
+ },
45
+ )
46
+
47
+
48
+ def load_thread(ws: Workspace, mod: str, thread_id: str) -> dict | None:
49
+ return _read_json(ws.thread_file(mod, thread_id))
50
+
51
+
52
+ def save_thread(ws: Workspace, thread: Thread) -> None:
53
+ data = thread.to_dict()
54
+ data["fetched_at"] = now_iso()
55
+ _write_json(ws.thread_file(thread.mod, thread.id), data)
56
+
57
+
58
+ def cached_thread_ids(ws: Workspace, mod: str) -> set[str]:
59
+ d = ws.mod_cache_dir(mod) / "threads"
60
+ if not d.is_dir():
61
+ return set()
62
+ return {p.stem for p in d.glob("*.json")}
63
+
64
+
65
+ # ----------------------------------------------------------------------
66
+ # Change detection
67
+ # ----------------------------------------------------------------------
68
+ def diff_lists(old: dict | None, new_threads: list[ThreadSummary]) -> list[dict]:
69
+ """Compare a freshly fetched thread list against the cached snapshot."""
70
+ previous = {t["id"]: t for t in (old or {}).get("threads", [])}
71
+ changes: list[dict] = []
72
+ for t in new_threads:
73
+ prev = previous.get(t.id)
74
+ if prev is None:
75
+ changes.append(
76
+ {
77
+ "kind": "new_thread",
78
+ "id": t.id,
79
+ "title": t.title,
80
+ "tag": t.tag,
81
+ "author": t.author,
82
+ "at": t.posted_at,
83
+ "replies": t.reply_count,
84
+ }
85
+ )
86
+ continue
87
+ if t.reply_count != prev.get("reply_count") or (t.last_at or "") != (prev.get("last_at") or ""):
88
+ changes.append(
89
+ {
90
+ "kind": "new_replies",
91
+ "id": t.id,
92
+ "title": t.title,
93
+ "tag": t.tag,
94
+ "author": t.last_author,
95
+ "at": t.last_at,
96
+ "replies": t.reply_count,
97
+ "replies_before": prev.get("reply_count"),
98
+ }
99
+ )
100
+ return changes
101
+
102
+
103
+ def append_changelog(ws: Workspace, mod: str, changes: list[dict]) -> None:
104
+ if not changes:
105
+ return
106
+ path = ws.mod_cache_dir(mod) / "changelog.jsonl"
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ stamp = now_iso()
109
+ with path.open("a") as fh:
110
+ for c in changes:
111
+ fh.write(json.dumps({"seen_at": stamp, "mod": mod, **c}, ensure_ascii=False) + "\n")
112
+
113
+
114
+ def read_changelog(ws: Workspace, mod: str, since: str | None = None) -> list[dict]:
115
+ path = ws.mod_cache_dir(mod) / "changelog.jsonl"
116
+ if not path.exists():
117
+ return []
118
+ out = []
119
+ for line in path.read_text().splitlines():
120
+ if not line.strip():
121
+ continue
122
+ rec = json.loads(line)
123
+ if since and rec.get("seen_at", "") < since:
124
+ continue
125
+ out.append(rec)
126
+ return out
127
+
128
+
129
+ def find_thread_mod(ws: Workspace, thread_id: str) -> str | None:
130
+ """Locate which mod a cached thread belongs to, given only its id."""
131
+ if not ws.cache_dir.is_dir():
132
+ return None
133
+ for mod_dir in sorted(ws.cache_dir.iterdir()):
134
+ if (mod_dir / "threads" / f"{thread_id}.json").exists():
135
+ return mod_dir.name
136
+ # fall back to the list snapshots, in case the thread body was never fetched
137
+ for mod_dir in sorted(ws.cache_dir.iterdir()):
138
+ data = _read_json(mod_dir / "threads.json")
139
+ if data and any(t["id"] == thread_id for t in data.get("threads", [])):
140
+ return mod_dir.name
141
+ return None
@@ -0,0 +1,5 @@
1
+ # Machine-specific: where your mods are checked out, and your local settings.
2
+ # Everything else here - the registry, the tracker, the cache - is shareable,
3
+ # so a workspace can be a git repository several people collaborate in.
4
+ config.yaml
5
+ paths.yaml
@@ -0,0 +1,31 @@
1
+ # fmw workspace
2
+
3
+ This directory holds all mod-specific and personal data for the
4
+ [factorio-mod-workshop](https://github.com/AivanF/factorio-mod-workshop) toolkit.
5
+ It is intentionally kept out of the tool repository so the tooling can be shared
6
+ and the data can stay private.
7
+
8
+ Make it its own git repository:
9
+
10
+ ```sh
11
+ git init
12
+ git add -A && git commit -m "workspace"
13
+ ```
14
+
15
+ ## Layout
16
+
17
+ | Path | Owner | Shared? | Contents |
18
+ | --- | --- | --- | --- |
19
+ | `config.yaml` | you | no, gitignored | which directories to scan, Discord details |
20
+ | `paths.yaml` | `fmw discover` | no, gitignored | where each mod is checked out on this machine |
21
+ | `mods.yaml` | `fmw discover` + you | yes | mod registry, `maintained` and `notes` are yours |
22
+ | `cache/<mod>/threads.json` | tool | yes | snapshot of the discussion list |
23
+ | `cache/<mod>/threads/<id>.json` | tool | yes | full thread contents |
24
+ | `cache/<mod>/changelog.jsonl` | tool | yes | append-only log of detected changes |
25
+ | `threads/<mod>.yaml` | you + agents | yes | your judgement about each conversation |
26
+ | `tracker/<mod>/*.md` | you + agents | yes | tickets: code changes, error or feature |
27
+ | `notes/` | you | yes | anything else |
28
+
29
+ Everything except `config.yaml` and `paths.yaml` is machine-independent, so a workspace can be a git repository that several maintainers collaborate in. Each of them copies `config.example.yaml` to `config.yaml` and runs `fmw discover` to generate their own `paths.yaml`.
30
+
31
+ Never hand-edit `cache/` - it is overwritten on every `fmw sync`.
@@ -0,0 +1,11 @@
1
+ # Copy to config.yaml and edit. Machine-specific, so it is gitignored.
2
+
3
+ # Directories scanned by `fmw discover` for mods (any dir containing info.json).
4
+ mod_repos:
5
+ - ~/path/to/your/factorio-mods-monorepo
6
+ - ~/path/to/another-mod-repo
7
+
8
+ # Optional: where you talk to users outside the mod portal.
9
+ discord:
10
+ invite: ""
11
+ notes: ""
@@ -0,0 +1,11 @@
1
+ # Local, machine-specific configuration. Never committed to the public repo.
2
+
3
+ # Directories scanned by `fmw discover` for mods (any dir containing info.json).
4
+ mod_repos:
5
+ - ~/path/to/your/factorio-mods-monorepo
6
+ - ~/path/to/another-mod-repo
7
+
8
+ # Optional: where you talk to users outside the mod portal.
9
+ discord:
10
+ invite: ""
11
+ notes: ""
@@ -0,0 +1,6 @@
1
+ # Mod registry. Regenerated (merged, never clobbered) by `fmw discover`.
2
+ # Fields you own and should edit by hand:
3
+ # maintained: active | frozen | unknown (frozen mods are skipped unless --all)
4
+ # notes: free text, read by agents before touching the mod
5
+ # priority: optional integer, higher = more attention
6
+ mods: {}
@@ -0,0 +1,40 @@
1
+ # Tracker
2
+
3
+ One Markdown file per item, `tracker/<mod>/<slug>.md`.
4
+
5
+ ```markdown
6
+ ---
7
+ id: a3f9c1 # random, stable, never changes; references use this alone
8
+ mods: [Mining_Drones_Remastered] # first is primary and decides the directory
9
+ kind: error # error | feature - tickets are code changes only
10
+ status: confirmed # new | needs-info | confirmed | in-progress | needs-testing | fixed | released | wontfix | duplicate
11
+ severity: crash # optional: crash | major | minor | trivial
12
+ title: Unknown flag "hidden" when disabling vanilla drills
13
+ created: 2026-09-01
14
+ updated: 2026-09-01
15
+ affects: ["2.2.0"]
16
+ fixed_in: ""
17
+ sources:
18
+ - portal:6a963b260970b611cd38bc3b
19
+ - discord:https://discord.com/channels/...
20
+ - self
21
+ related: []
22
+ duplicate_of: ""
23
+ ---
24
+
25
+ ## Summary
26
+ ## Analysis
27
+ ## Resolution
28
+ ## Test plan
29
+ ```
30
+
31
+ Every enum value is listed in the toolkit's `docs/workspace.md`, under "Field values", along with which ones the code actually acts on.
32
+
33
+ Rules that keep the tooling useful:
34
+
35
+ - `sources:` is the join key. `portal:<thread-id>` entries are what `fmw inbox`, the derived reply obligation and the ticket lines in `fmw threads` look at, so always record them.
36
+ - Several reports of the same problem belong in **one** item with several sources, not in several items. Use `duplicate_of` only when a separate item already exists.
37
+ - Ideas live here as `kind: feature`, whether a user asked or you thought of it. Larger roadmap notes belong in `../notes/`, not here.
38
+ - Threads that need an answer rather than a change never become tickets. Use `fmw mark` instead.
39
+ - Update `status` and `fixed_in` when you release, so the next agent does not re-investigate a solved problem.
40
+ - `needs-testing` means written but not verified in game - the furthest an AI agent can take a fix on its own. Fill in the `## Test plan` section when setting it, and run `fmw testplan` to check a whole batch in one Factorio session.