Skip to content

Commit e2ff2ab

Browse files
committed
feat(planner): count pin on a goal's producer supply-pushes instead of fighting the goal
Pinning a recipe's building count (exact) while it also produces a pinned goal created two independent LP constraints that fought: 2 foundries make 0.133/s steel, a 0.14/s goal needs 2.1, and =-count plus >=-floor can't both hold, so the block went infeasible over a rounding sliver. But pinning a building count IS the user switching that block to supply-push ('I built N of these, size around them'), so the goal should yield. A count pin on a producer of a pinned goal now relaxes that goal's rate floor (the pin drives output); the doc goal is untouched, so naming and factory rollups are unaffected. Scope keeps the honest cases: a CAP pin still lets the shortfall flag (a ceiling the goal may exceed), and a count pin on a mid-chain row stays a hard constraint that can legitimately conflict. The result carries goalSuperseded (target vs what the pinned buildings make, and whole buildings the target would need); the goal card shows it as a soft note so the relaxation isn't silent. Mirrors YAFC, where fixing a row is the spec and output is a consequence with no separate goal to collide. Verified live on the steel-plate block: 2 foundries, 0.133/s, note 'pinned 2 -> 0.13/s . 0.14/s needs 3'. Closes #121
1 parent 278edb1 commit e2ff2ab

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

app/src/components/block/goal-card.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "#/components/ui/card.t
44
import { STOCK_WINDOW_DEFAULT } from "../../lib/goals";
55
import { Icon } from "../../lib/icons";
66
import { EditableRate } from "./editable-rate.tsx";
7-
import { ENERGY_PSEUDO } from "./format.ts";
7+
import { ENERGY_PSEUDO, num } from "./format.ts";
88
import { EditableStock } from "./editable-stock.tsx";
99
import { LogiTag } from "./logi-tag.tsx";
1010
import type { BlockDocStore } from "./doc-store.ts";
@@ -166,6 +166,23 @@ export function GoalCard({
166166
launch={logi.launchInfo(g, Math.abs(goal.rate))}
167167
/>
168168
)}
169+
{/* supply-push note (#121): a count pin on this goal's producer
170+
drives output — the goal rate no longer binds. Show what the
171+
pinned buildings make, and (if short) what the target needs. */}
172+
{(() => {
173+
const ss = res?.goalSuperseded?.find((x) => x.item === g);
174+
if (!ss) return null;
175+
const short = ss.actualRate < ss.goalRate - 1e-9;
176+
return (
177+
<span
178+
className="flex items-center gap-0.5 text-sm text-info"
179+
title={`This goal's producer is pinned to ${ss.pinnedCount} building${ss.pinnedCount === 1 ? "" : "s"}, so the count drives output and the ${num(ss.goalRate)}/s target no longer binds.${short ? ` Reaching ${num(ss.goalRate)}/s would take ${ss.buildingsForGoal} buildings.` : ""}`}
180+
>
181+
<Lock className="size-3" /> pinned {ss.pinnedCount}{num(ss.actualRate)}/s
182+
{short && ` · ${num(ss.goalRate)}/s needs ${ss.buildingsForGoal}`}
183+
</span>
184+
);
185+
})()}
169186
</div>
170187
);
171188
})}

app/src/server/block-compute.server.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,29 @@ export async function computeBlock(rawData: SolveInput) {
559559
// Explicit doc state wins; a legacy doc (no `made`) derives it from its old
560560
// dispositions via the migration mapping the parity report validated. The
561561
// derived set is echoed back on the result so the editor persists it.
562-
const goals = targets.map((t) => ({ name: t.name, rate: t.rate }));
562+
// Supply-push (#121): a COUNT pin on a recipe that PRODUCES a pinned goal good
563+
// re-states the block's size from that building count, so the goal's rate stops
564+
// forcing total output. Without this the two fight — 2 foundries make 0.133/s,
565+
// a 0.14/s goal needs 2.1, and an exact-count pin plus a ≥-floor can't both
566+
// hold → a spurious infeasibility over a rounding sliver. Only COUNT pins (exact
567+
// "I built N of these") relax the goal, and only on a producer OF that goal; a
568+
// CAP pin ("at most N") is a ceiling the goal may legitimately exceed, so its
569+
// shortfall must still flag, and a count pin on a mid-chain row stays a hard
570+
// constraint that can honestly conflict. The doc goal is untouched (naming,
571+
// rollups); only the solver floor is relaxed, and `goalSuperseded` reports the
572+
// gap so the UI can note "N buildings make X/s; your target needs M".
573+
const productsByRecipe = new Map(defs.map((d) => [d.name, d.products.map((p) => p.name)]));
574+
const supersededGoals = new Map<string, { recipe: string; count: number }>();
575+
for (const p of data.pins ?? []) {
576+
if (p.kind !== "count") continue;
577+
for (const prod of productsByRecipe.get(p.recipe) ?? [])
578+
if (targets.some((t) => t.name === prod && t.rate != null) && !supersededGoals.has(prod))
579+
supersededGoals.set(prod, { recipe: p.recipe, count: p.count });
580+
}
581+
const goals = targets.map((t) => ({
582+
name: t.name,
583+
rate: supersededGoals.has(t.name) ? 0 : t.rate,
584+
}));
563585
const made =
564586
data.made ??
565587
migrateToLpInput({ targets: goals, recipes: defs, dispositions: data.dispositions }).made ??
@@ -594,6 +616,26 @@ export async function computeBlock(rawData: SolveInput) {
594616
rate: p.count * perBuilding,
595617
});
596618
}
619+
// Gap report for each superseded goal: what the pinned buildings actually make
620+
// (exact, since a count pin fixes the rate) vs the original target, and how many
621+
// whole buildings the target WOULD need — the UI shows this as a soft note so
622+
// the relaxation isn't silent ("2 foundries make 0.13/s; 0.14/s needs 3").
623+
const goalSuperseded = [...supersededGoals].flatMap(([item, { recipe, count }]) => {
624+
const goalRate = targets.find((t) => t.name === item)?.rate ?? 0;
625+
const def = defs.find((d) => d.name === recipe);
626+
const perCraft = def?.products.filter((c) => c.name === item).reduce((s, c) => s + c.amount, 0);
627+
const perBuilding = (perCraft ?? 0) * (craftRate(recipe) ?? 0);
628+
if (perBuilding <= 0) return [];
629+
return [
630+
{
631+
item,
632+
goalRate,
633+
pinnedCount: count,
634+
actualRate: perBuilding * count,
635+
buildingsForGoal: Math.ceil(goalRate / perBuilding - 1e-9),
636+
},
637+
];
638+
});
597639
const defaultTemp = (f: string) => q.getFluid(f)?.defaultTemperature ?? null;
598640
// Sub-blocks v2 (#76): a COMPOSED group is solved as its own module and pulled
599641
// out of the parent solve, replaced by a synthetic recipe carrying only its
@@ -1184,6 +1226,8 @@ export async function computeBlock(rawData: SolveInput) {
11841226
made,
11851227
// temperature qualifier per unmade item (#110): "water" unmade at "≤101°"
11861228
unmadeTemp,
1229+
// goals whose rate a count pin superseded (#121) — the soft supply-push note
1230+
goalSuperseded,
11871231
diagnosis,
11881232
power,
11891233
fuelItems,

app/src/server/block-compute.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,3 +791,67 @@ describe("fluid-fuel supplier designation (#115)", () => {
791791
expect(res.imports.map((f) => f.name)).not.toContain("pyops-fluid-fuel");
792792
});
793793
});
794+
795+
describe("count pin supersedes a goal it produces (#121)", () => {
796+
let fx: TestDb;
797+
798+
beforeEach(async () => {
799+
fx = await makeTestDb();
800+
// steel from iron: 1 iron -> 1 steel, 1s craft, foundry at speed 1 → one
801+
// building makes exactly 1 steel/s. A second recipe makes iron, so a count
802+
// pin can be placed on a NON-goal producer too.
803+
fx.db.exec(`
804+
INSERT INTO recipes (name, kind, category, energy_required, allow_productivity, enabled, hidden) VALUES
805+
('mk-steel','real','smelting',1,0,1,0),
806+
('mk-iron','real','smelting',1,0,1,0);
807+
INSERT INTO recipe_ingredients (recipe, idx, kind, name, amount) VALUES ('mk-steel',0,'item','iron',1);
808+
INSERT INTO recipe_products (recipe, idx, kind, name, amount) VALUES
809+
('mk-steel',0,'item','steel',1),
810+
('mk-iron',0,'item','iron',1);
811+
INSERT INTO items (name, display) VALUES ('iron','Iron'),('steel','Steel');
812+
INSERT INTO crafting_machines (name, kind, crafting_speed, module_slots, energy_usage_w, energy_source)
813+
VALUES ('foundry','assembling-machine',1,0,100000,'electric');
814+
INSERT INTO machine_categories (machine, category) VALUES ('foundry','smelting'),('mk-iron','smelting');
815+
`);
816+
fx.db.close();
817+
switchDatabase(fx.file);
818+
});
819+
820+
afterEach(() => fx.cleanup());
821+
822+
it("relaxes the goal so a count pin drives output instead of fighting it", async () => {
823+
// one foundry = 1 steel/s; pin 2 → 2 steel/s. Goal 2.5/s would need 3.
824+
const res = await computeBlock({
825+
goals: [{ name: "steel", rate: 2.5 }],
826+
recipes: ["mk-steel", "mk-iron"],
827+
pins: [{ kind: "count", recipe: "mk-steel", count: 2 }],
828+
});
829+
expect(res.status).toBe("solved"); // NOT infeasible over the 0.5/s gap
830+
expect(res.rows.find((r) => r.recipe === "mk-steel")?.rate).toBeCloseTo(2);
831+
expect(res.goalSuperseded).toEqual([
832+
{ item: "steel", goalRate: 2.5, pinnedCount: 2, actualRate: 2, buildingsForGoal: 3 },
833+
]);
834+
});
835+
836+
it("a CAP pin does NOT supersede — the goal still binds and the shortfall flags", async () => {
837+
const res = await computeBlock({
838+
goals: [{ name: "steel", rate: 2.5 }],
839+
recipes: ["mk-steel", "mk-iron"],
840+
pins: [{ kind: "cap", recipe: "mk-steel", count: 2 }],
841+
});
842+
expect(res.status).toBe("infeasible"); // 2.5/s needs 3 buildings, cap says ≤ 2
843+
expect(res.goalSuperseded).toEqual([]);
844+
});
845+
846+
it("a count pin on a NON-goal producer leaves the goal binding", async () => {
847+
// pin iron production; steel goal is unrelated and must still be met exactly
848+
const res = await computeBlock({
849+
goals: [{ name: "steel", rate: 1.5 }],
850+
recipes: ["mk-steel", "mk-iron"],
851+
pins: [{ kind: "count", recipe: "mk-iron", count: 5 }],
852+
});
853+
expect(res.status).toBe("solved");
854+
expect(res.goalSuperseded).toEqual([]);
855+
expect(res.rows.find((r) => r.recipe === "mk-steel")?.rate).toBeCloseTo(1.5); // goal still drives
856+
});
857+
});

docs/solver.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ single-`target` shape. The item rules:
6565
applies it after count-pinned consumers' fixed intake). Counts convert to
6666
rates at solve time via the row's real per-building craft rate, so pins follow
6767
module/machine changes.
68+
- **Count pins supersede a goal they produce** (#121): a `count` pin on a recipe
69+
that makes a pinned goal good re-states the block's size from that building
70+
count, so the goal's rate floor stops binding — the pin drives output. Without
71+
this the two fight (2 foundries make 0.13/s, a 0.14/s goal needs 2.1, and
72+
exact-count + ≥-floor can't both hold → a spurious infeasible). Only `count`
73+
pins on a goal *producer* relax the goal; a `cap` there still lets the
74+
shortfall flag, and a count pin on a mid-chain row stays a hard constraint that
75+
can honestly conflict. The doc goal is untouched (naming, rollups) — only the
76+
solver floor relaxes, and `goalSuperseded` reports the gap so the goal card can
77+
note "pinned N → X/s; R/s needs M".
6878

6979
**Fluid temperatures are real identities** (#110, `temps.ts`): when any enabled
7080
consumer declares an accepted temperature range, that fluid expands — each

0 commit comments

Comments
 (0)