Skip to content

Commit bebd52f

Browse files
Maselkovclaude
andauthored
Move the craft, cancel and stock-rule checks out of the routes (#198)
The MCP endpoint coming next must check what the page's routes check, so the logic moves into functions both can call: - commands.request_cancel(): the busy-CPU and same-job checks. The job identity helpers (output_identity, same_output) move with it from tracking, which imports commands, not the other way round. - commands.craft_request_error(): the craft request's input checks. - stock.item_from(), set_alert(), set_target() and rules(): stock rule validation, the craftable check and the rules listing. - planner.rules_for_user(): the stock rules a plan warns about. No change to what the routes accept or answer, except that a craft request's amount can no longer be true (which counted as 1). Co-authored-by: Maselkov <25772111+Maselkov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dd6fa89 commit bebd52f

7 files changed

Lines changed: 243 additions & 201 deletions

File tree

‎server/gcm/commands.py‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import threading
1212
import time
1313

14-
from gcm import icons, inventory, store
14+
from gcm import icons, inventory, state, store
1515

1616

1717
class CommandQueue:
@@ -196,6 +196,19 @@ def _expire_craft_request(req_id, reason):
196196
store.stock.record_auto_result(req_id, "failed", reason)
197197

198198

199+
def craft_request_error(label, internal, amount, kind, variant):
200+
"""Why a craft request can't be queued as given, or None."""
201+
if not label or not internal:
202+
return "missing label/internal"
203+
if isinstance(amount, bool) or not isinstance(amount, (int, float)) or amount <= 0:
204+
return "amount must be a positive number"
205+
if kind not in ("item", "fluid"):
206+
return "kind must be item or fluid"
207+
if variant is not None and not isinstance(variant, str):
208+
return "variant must be a string"
209+
return None
210+
211+
199212
def queue_craft(user_id, label, mod, internal, damage, amount, kind, variant=None,
200213
variant_name=None, source="manual", target_key=None):
201214
"""Queues a craft request for craft_monitor.lua and writes its history
@@ -260,6 +273,73 @@ def queue_craft(user_id, label, mod, internal, damage, amount, kind, variant=Non
260273
)
261274

262275

276+
def output_identity(job):
277+
"""{mod, internal, damage} of what the job on this CPU is making, the
278+
form craft_monitor.lua compares against the CPU's finalOutput() - or
279+
None when that isn't reported."""
280+
if not job.get("final_output_internal"):
281+
return None
282+
return {
283+
"mod": job.get("final_output_mod"),
284+
"internal": job.get("final_output_internal"),
285+
"damage": job.get("final_output_damage"),
286+
}
287+
288+
289+
def same_output(a, b):
290+
"""Whether two output identities name the same item."""
291+
return all(a.get(key) == b.get(key) for key in ("mod", "internal", "damage"))
292+
293+
294+
def request_cancel(user_id, cpu_name, shown_output=None):
295+
"""Queues cancelling the job on a busy CPU. Returns (id, None), or
296+
(None, (error message, HTTP status)) when it can't be cancelled.
297+
shown_output is what the user saw the job making (output_identity's
298+
form), if known: when the CPU has moved on to another job since, the
299+
one they meant to cancel is gone, and nothing is queued."""
300+
if not cpu_name:
301+
return None, ("missing cpu_name", 400)
302+
303+
# Can only cancel a CPU that's actually busy right now - same
304+
# reasoning as craft-request's own "only a busy CPU can be pinned"
305+
# check, and it doesn't make sense to queue a cancel for something
306+
# with nothing running on it.
307+
with state.crafts_lock:
308+
jobs = state.crafts.get("jobs", [])
309+
job = next((j for j in jobs if j.get("name") == cpu_name), None)
310+
if job is None:
311+
return None, ("unknown cpu_name", 404)
312+
if not job.get("busy"):
313+
return None, ("CPU is not currently busy - nothing to cancel", 400)
314+
315+
# A CPU runs one job after another, so the cancel names the job too:
316+
# what it's making, as the user saw it and as the latest status
317+
# report has it. If those already differ, the job the user meant to
318+
# cancel is gone. Lua checks it once more against the CPU itself
319+
# right before cancelling. None when the CPU has no Crafting Monitor
320+
# to report its output - then only the CPU name is known.
321+
expected_output = output_identity(job)
322+
if isinstance(shown_output, dict) and expected_output and not same_output(expected_output, shown_output):
323+
return None, ("a different job is now running on this CPU - not cancelled", 409)
324+
325+
created_at = time.time()
326+
req_id = cancel_requests.add(
327+
{
328+
"user_id": user_id,
329+
"cpu_name": cpu_name,
330+
"expected_output": expected_output,
331+
# status: pending -> resolved
332+
"success": None,
333+
"reason": None,
334+
"created_at": created_at,
335+
},
336+
lambda req_id: store.requests.record_cancel(
337+
req_id, user_id, cpu_name, created_at, job.get("final_output"), job.get("final_output_icon")
338+
),
339+
)
340+
return req_id, None
341+
342+
263343
def reset():
264344
craft_requests.reset()
265345
cancel_requests.reset()

‎server/gcm/planner.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,16 @@ def _mark_missing(root, items):
578578
node["missing_below"] = below
579579

580580

581+
def rules_for_user(user_id):
582+
"""The stock rules a plan for this user warns about taking an item
583+
below, as plan()'s rules: the user's own alerts and the base's
584+
enabled keep-in-stock targets."""
585+
rules = [(a["key"], a["below"], "below your alert") for a in store.stock.alerts_for_user(user_id)]
586+
rules += [(t["key"], t["keep_at_least"], "below its keep-at-least target")
587+
for t in store.stock.targets() if t["enabled"]]
588+
return rules
589+
590+
581591
def cached_plan(patterns, stock, stock_version, key, amount, choices=None, rules=None, describe=None):
582592
"""plan(), reusing one of the last few if nothing it depends on has
583593
changed: the pattern list, the stock (stock_version says when it

‎server/gcm/routes/craft_requests.py‎

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@
88
submitting or cancelling a craft additionally requires the
99
operator or admin role."""
1010

11-
import time
1211

1312
from flask import Blueprint, g, jsonify, request
1413

15-
from gcm import auth, commands, state, stock, store, tracking
14+
from gcm import auth, commands, stock, store, tracking
1615

1716

1817
bp = Blueprint("craft_requests", __name__)
@@ -34,15 +33,9 @@ def craft_request_post():
3433
variant = payload.get("variant") or None
3534
variant_name = payload.get("variant_name") or None
3635

37-
if not label or not internal:
38-
return jsonify({"error": "missing label/internal"}), 400
39-
if not isinstance(amount, (int, float)) or amount <= 0:
40-
return jsonify({"error": "amount must be a positive number"}), 400
41-
if kind not in ("item", "fluid"):
42-
return jsonify({"error": "kind must be item or fluid"}), 400
43-
44-
if variant is not None and not isinstance(variant, str):
45-
return jsonify({"error": "variant must be a string"}), 400
36+
error = commands.craft_request_error(label, internal, amount, kind, variant)
37+
if error:
38+
return jsonify({"error": error}), 400
4639

4740
req_id = commands.queue_craft(
4841
user_id, label, mod, internal, damage, amount, kind, variant, variant_name
@@ -145,50 +138,10 @@ def craft_cancel_post():
145138
user_id = g.user["id"]
146139

147140
payload = request.get_json(silent=True) or {}
148-
cpu_name = payload.get("cpu_name")
149-
if not cpu_name:
150-
return jsonify({"error": "missing cpu_name"}), 400
151-
152-
# Can only cancel a CPU that's actually busy right now - same
153-
# reasoning as craft-request's own "only a busy CPU can be pinned"
154-
# check, and it doesn't make sense to queue a cancel for something
155-
# with nothing running on it.
156-
with state.crafts_lock:
157-
jobs = state.crafts.get("jobs", [])
158-
job = next((j for j in jobs if j.get("name") == cpu_name), None)
159-
if job is None:
160-
return jsonify({"error": "unknown cpu_name"}), 404
161-
if not job.get("busy"):
162-
return jsonify({"error": "CPU is not currently busy - nothing to cancel"}), 400
163-
164-
# A CPU runs one job after another, so the cancel names the job too:
165-
# what it's making, as the browser showed it and as the latest status
166-
# report has it. If those already differ, the job the user meant to
167-
# cancel is gone. Lua checks it once more against the CPU itself
168-
# right before cancelling. None when the CPU has no Crafting Monitor
169-
# to report its output - then only the CPU name is known.
170-
expected_output = tracking.output_identity(job)
171-
shown = payload.get("expected_output")
172-
if isinstance(shown, dict) and expected_output and not tracking.same_output(
173-
expected_output, shown
174-
):
175-
return jsonify({"error": "a different job is now running on this CPU - not cancelled"}), 409
176-
177-
created_at = time.time()
178-
req_id = commands.cancel_requests.add(
179-
{
180-
"user_id": user_id,
181-
"cpu_name": cpu_name,
182-
"expected_output": expected_output,
183-
# status: pending -> resolved
184-
"success": None,
185-
"reason": None,
186-
"created_at": created_at,
187-
},
188-
lambda req_id: store.requests.record_cancel(
189-
req_id, user_id, cpu_name, created_at, job.get("final_output"), job.get("final_output_icon")
190-
),
191-
)
141+
req_id, error = commands.request_cancel(user_id, payload.get("cpu_name"), payload.get("expected_output"))
142+
if error:
143+
message, status = error
144+
return jsonify({"error": message}), status
192145
return jsonify({"ok": True, "id": req_id})
193146

194147

‎server/gcm/routes/network.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -470,10 +470,7 @@ def network_plan_get():
470470
if (path is not None or reveal) and request.args.get("version") != version:
471471
return jsonify({"stale": True})
472472

473-
user_id = g.user["id"]
474-
rules = [(a["key"], a["below"], "below your alert") for a in store.stock.alerts_for_user(user_id)]
475-
rules += [(t["key"], t["keep_at_least"], "below its keep-at-least target")
476-
for t in store.stock.targets() if t["enabled"]]
473+
rules = planner.rules_for_user(g.user["id"])
477474
result = planner.cached_plan(pattern_list, stock_levels, stock_version, key, amount, choices, rules,
478475
describe=inventory.describe)
479476
if result is None:

‎server/gcm/routes/stock.py‎

Lines changed: 12 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -4,135 +4,38 @@
44

55
from flask import Blueprint, g, jsonify, request
66

7-
from gcm import auth, state, stock, store
7+
from gcm import auth, stock, store
88

99

1010
bp = Blueprint("stock", __name__)
1111

1212

13-
def _item_from(payload):
14-
"""The item a rule is about, from a request body, or an error
15-
message."""
16-
item = {
17-
"label": payload.get("label"),
18-
"mod": payload.get("mod") or None,
19-
"internal": payload.get("internal"),
20-
"damage": payload.get("damage"),
21-
"kind": payload.get("kind") or "item",
22-
"variant": payload.get("variant") or None,
23-
}
24-
if not item["label"] or not item["internal"]:
25-
return None, "missing label/internal"
26-
if item["kind"] not in ("item", "fluid", "essentia"):
27-
return None, "kind must be item, fluid or essentia"
28-
if item["damage"] is not None and (isinstance(item["damage"], bool) or not isinstance(item["damage"], int)):
29-
return None, "damage must be a whole number"
30-
if item["variant"] is not None and not isinstance(item["variant"], str):
31-
return None, "variant must be a string"
32-
return item, None
33-
34-
35-
def _key(item):
36-
return store.items.item_key(item["mod"], item["internal"], item["damage"], item["kind"], item["variant"])
37-
38-
39-
def _positive(value):
40-
return not isinstance(value, bool) and isinstance(value, (int, float)) and value > 0
41-
42-
43-
def _network_item(key):
44-
with state.network_lock:
45-
return next((it for it in state.network["items"] if store.items.key_of(it) == key), None)
46-
47-
4813
@bp.route("/api/stock/rules", methods=["GET"])
4914
@auth.viewer_required
5015
def stock_rules_get():
5116
"""Every keep-in-stock target, and the signed-in user's own alerts,
5217
each with the item's amount in the latest scan (null before any)."""
53-
with state.network_lock:
54-
items = state.network["items"]
55-
scanned = state.network["updated_at"] is not None or bool(items)
56-
sizes = stock.current_sizes(items)
57-
names = stock.variant_names(items)
58-
with state.crafts_lock:
59-
jobs = list(state.crafts["jobs"])
60-
pending = stock.pending_auto_requests()
61-
62-
def with_item(rule):
63-
current = sizes.get(rule["key"], 0) if scanned else None
64-
return {
65-
"key": rule["key"],
66-
"label": rule["label"],
67-
"mod": rule["mod"],
68-
"internal": rule["internal"],
69-
"damage": rule["damage"],
70-
"kind": rule["kind"],
71-
"variant": rule["variant"],
72-
"icon": stock.rule_icon(rule, names),
73-
"current": current,
74-
}
75-
76-
targets = []
77-
for t in store.stock.targets():
78-
row = with_item(t)
79-
low = row["current"] is not None and row["current"] < t["keep_at_least"]
80-
# Only while low: a CPU making the item for some other reason
81-
# isn't this target's doing.
82-
cpu = stock.busy_cpu_making(t, jobs) if low else None
83-
if t["key"] in pending:
84-
status = "requested"
85-
elif cpu:
86-
status = "crafting"
87-
elif not t["enabled"]:
88-
status = "off"
89-
elif low and t["last_status"] in ("failed", "waiting"):
90-
status = t["last_status"]
91-
else:
92-
status = "low" if low else "ok"
93-
targets.append({
94-
**row,
95-
"keep_at_least": t["keep_at_least"],
96-
"refill_to": t["refill_to"],
97-
"enabled": t["enabled"],
98-
"status": status,
99-
"reason": t["last_reason"] if status in ("failed", "waiting") else None,
100-
"cpu": cpu,
101-
"last_requested_at": t["last_requested_at"],
102-
"last_status": t["last_status"],
103-
"last_reason": t["last_reason"],
104-
})
105-
106-
alerts = []
107-
user = auth.session_user()
108-
if user:
109-
for a in store.stock.alerts_for_user(user["id"]):
110-
row = with_item(a)
111-
alerts.append({**row, "below": a["below"], "low": row["current"] is not None and row["current"] < a["below"]})
112-
return jsonify({"targets": targets, "alerts": alerts})
18+
return jsonify(stock.rules(auth.session_user()))
11319

11420

11521
@bp.route("/api/stock/alert", methods=["POST"])
11622
@auth.login_required
11723
def stock_alert_set():
11824
payload = request.get_json(silent=True) or {}
119-
item, error = _item_from(payload)
25+
item, error = stock.item_from(payload)
26+
error = error or stock.set_alert(g.user["id"], item, payload.get("below"))
12027
if error:
12128
return jsonify({"error": error}), 400
122-
below = payload.get("below")
123-
if not _positive(below):
124-
return jsonify({"error": "below must be a positive number"}), 400
125-
store.stock.set_alert(g.user["id"], item, below)
12629
return jsonify({"ok": True})
12730

12831

12932
@bp.route("/api/stock/alert/delete", methods=["POST"])
13033
@auth.login_required
13134
def stock_alert_delete():
132-
item, error = _item_from(request.get_json(silent=True) or {})
35+
item, error = stock.item_from(request.get_json(silent=True) or {})
13336
if error:
13437
return jsonify({"error": error}), 400
135-
store.stock.delete_alert(g.user["id"], _key(item))
38+
store.stock.delete_alert(g.user["id"], stock.rule_key(item))
13639
return jsonify({"ok": True})
13740

13841

@@ -141,32 +44,21 @@ def stock_alert_delete():
14144
@auth.operator_required
14245
def stock_target_set():
14346
payload = request.get_json(silent=True) or {}
144-
item, error = _item_from(payload)
47+
item, error = stock.item_from(payload)
48+
error = error or stock.set_target(
49+
g.user["id"], item, payload.get("keep_at_least"), payload.get("refill_to"), payload.get("enabled", True)
50+
)
14551
if error:
14652
return jsonify({"error": error}), 400
147-
keep, refill = payload.get("keep_at_least"), payload.get("refill_to")
148-
if not _positive(keep) or not _positive(refill):
149-
return jsonify({"error": "keep_at_least and refill_to must be positive numbers"}), 400
150-
if refill <= keep:
151-
return jsonify({"error": "refill_to must be more than keep_at_least"}), 400
152-
enabled = payload.get("enabled", True)
153-
if not isinstance(enabled, bool):
154-
return jsonify({"error": "enabled must be true or false"}), 400
155-
# The game can only make what it has a pattern for, and the last
156-
# scan is the only place that says.
157-
network_item = _network_item(_key(item))
158-
if not (network_item and network_item.get("isCraftable")):
159-
return jsonify({"error": "only items the network can craft can be kept in stock"}), 400
160-
store.stock.set_target(g.user["id"], item, keep, refill, enabled)
16153
return jsonify({"ok": True})
16254

16355

16456
@bp.route("/api/stock/target/delete", methods=["POST"])
16557
@auth.login_required
16658
@auth.operator_required
16759
def stock_target_delete():
168-
item, error = _item_from(request.get_json(silent=True) or {})
60+
item, error = stock.item_from(request.get_json(silent=True) or {})
16961
if error:
17062
return jsonify({"error": error}), 400
171-
store.stock.delete_target(_key(item))
63+
store.stock.delete_target(stock.rule_key(item))
17264
return jsonify({"ok": True})

0 commit comments

Comments
 (0)