@terrafirmagreg-team/recipe 0.0.0-stage → 1.2.0

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.
Files changed (221) hide show
  1. package/package.json +62 -3
  2. package/src/components/button-copy.tsx +43 -0
  3. package/src/components/cards/card/index.tsx +214 -0
  4. package/src/components/cards/card/scene.tsx +107 -0
  5. package/src/components/cards/card/strip.tsx +198 -0
  6. package/src/components/cards/card/text.tsx +46 -0
  7. package/src/components/cards/card/tips.tsx +63 -0
  8. package/src/components/cards/default-item.tsx +47 -0
  9. package/src/components/cards/glare-filter.tsx +29 -0
  10. package/src/components/cards/paged-card.tsx +222 -0
  11. package/src/components/cards/slot-hitbox.tsx +213 -0
  12. package/src/components/cards/strip-card/buttons.tsx +106 -0
  13. package/src/components/cards/strip-card/index.tsx +214 -0
  14. package/src/components/cards/strip-card/member-cell.tsx +167 -0
  15. package/src/components/cards/strip-card/sprite.tsx +36 -0
  16. package/src/components/embed-card.tsx +44 -0
  17. package/src/components/mdx/recipe.tsx +129 -0
  18. package/src/components/tool-list.tsx +49 -0
  19. package/src/components/tree.tsx +98 -0
  20. package/src/components/views/alloy/index.tsx +112 -0
  21. package/src/components/views/alloy/list.tsx +85 -0
  22. package/src/components/views/alloy/mix/index.tsx +35 -0
  23. package/src/components/views/alloy/mix/ratio.tsx +94 -0
  24. package/src/components/views/alloy/mix/rows.tsx +145 -0
  25. package/src/components/views/alloy/mix/status.tsx +122 -0
  26. package/src/components/views/alloy/mix/total.tsx +132 -0
  27. package/src/components/views/alloy/range-bar.tsx +65 -0
  28. package/src/components/views/alloy/stack-bar.tsx +88 -0
  29. package/src/components/views/browser/axis-switcher.tsx +54 -0
  30. package/src/components/views/browser/card-grid.tsx +275 -0
  31. package/src/components/views/browser/favorite-button.tsx +46 -0
  32. package/src/components/views/browser/frame.tsx +112 -0
  33. package/src/components/views/browser/index.tsx +114 -0
  34. package/src/components/views/browser/item-panel/index.tsx +229 -0
  35. package/src/components/views/browser/item-panel/skeleton.tsx +26 -0
  36. package/src/components/views/browser/item-panel-grid/cell.tsx +178 -0
  37. package/src/components/views/browser/item-panel-grid/favorite-cell.tsx +129 -0
  38. package/src/components/views/browser/item-panel-grid/index.tsx +105 -0
  39. package/src/components/views/browser/item-view/index.tsx +186 -0
  40. package/src/components/views/browser/item-view/tags.tsx +85 -0
  41. package/src/components/views/browser/item-view-header.tsx +131 -0
  42. package/src/components/views/browser/pane-tabs.tsx +61 -0
  43. package/src/components/views/browser/place-header.tsx +72 -0
  44. package/src/components/views/browser/query-controls.tsx +105 -0
  45. package/src/components/views/browser/recipe-item-header.tsx +60 -0
  46. package/src/components/views/browser/settings.tsx +52 -0
  47. package/src/components/views/browser/toolbar.tsx +54 -0
  48. package/src/components/views/browser/type-tabs.tsx +71 -0
  49. package/src/components/views/browser/type-view.tsx +184 -0
  50. package/src/components/views/browser/workstation-rail.tsx +43 -0
  51. package/src/components/views/calc/calculator/actions.tsx +47 -0
  52. package/src/components/views/calc/calculator/help.tsx +189 -0
  53. package/src/components/views/calc/calculator/index.tsx +172 -0
  54. package/src/components/views/calc/dot-overlay.tsx +80 -0
  55. package/src/components/views/calc/edge.tsx +152 -0
  56. package/src/components/views/calc/flow/index.tsx +305 -0
  57. package/src/components/views/calc/flow/picker-dialogs.tsx +146 -0
  58. package/src/components/views/calc/flow/toolbar.tsx +60 -0
  59. package/src/components/views/calc/flow/view-menu.tsx +117 -0
  60. package/src/components/views/calc/member-picker.tsx +120 -0
  61. package/src/components/views/calc/node-card/actions.tsx +90 -0
  62. package/src/components/views/calc/node-card/badges.tsx +59 -0
  63. package/src/components/views/calc/node-card/chips.tsx +187 -0
  64. package/src/components/views/calc/node-card/far.tsx +123 -0
  65. package/src/components/views/calc/node-card/full.tsx +332 -0
  66. package/src/components/views/calc/node-card/index.tsx +27 -0
  67. package/src/components/views/calc/node-card/menu.tsx +282 -0
  68. package/src/components/views/calc/node-card/port-cells.tsx +186 -0
  69. package/src/components/views/calc/node-card/port-rails.tsx +251 -0
  70. package/src/components/views/calc/node-card/ports.tsx +86 -0
  71. package/src/components/views/calc/recipe-picker/body.tsx +207 -0
  72. package/src/components/views/calc/recipe-picker/footer.tsx +131 -0
  73. package/src/components/views/calc/recipe-picker/index.tsx +52 -0
  74. package/src/components/views/calc/sidebar.tsx +171 -0
  75. package/src/components/views/calc/trees/icon-picker.tsx +97 -0
  76. package/src/components/views/calc/trees/index.tsx +134 -0
  77. package/src/components/views/calc/trees/label.tsx +67 -0
  78. package/src/components/views/calc/trees/rows.tsx +140 -0
  79. package/src/components/views/forge/gui.tsx +131 -0
  80. package/src/components/views/forge/index.tsx +112 -0
  81. package/src/components/views/forge/list.tsx +113 -0
  82. package/src/components/views/forge/workbench/controls.tsx +103 -0
  83. package/src/components/views/forge/workbench/index.tsx +85 -0
  84. package/src/components/views/forge/workbench/panel.tsx +228 -0
  85. package/src/components/views/forge/workbench/ruler.tsx +133 -0
  86. package/src/components/views/forge/workbench/status.tsx +55 -0
  87. package/src/components/views/forge/workbench/tape.tsx +70 -0
  88. package/src/hooks/alloy/use-fluid-colors.ts +125 -0
  89. package/src/hooks/alloy/use-mix-state.ts +240 -0
  90. package/src/hooks/calc/flow/use-engage.ts +129 -0
  91. package/src/hooks/calc/flow/use-handlers.ts +192 -0
  92. package/src/hooks/calc/flow/use-layout.ts +105 -0
  93. package/src/hooks/calc/flow/use-mount.ts +97 -0
  94. package/src/hooks/calc/flow/use-nodes.ts +260 -0
  95. package/src/hooks/calc/flow/use-pickers.ts +57 -0
  96. package/src/hooks/calc/flow/use-viewport.ts +141 -0
  97. package/src/hooks/calc/flow/use-window.ts +87 -0
  98. package/src/hooks/calc/use-actions.ts +355 -0
  99. package/src/hooks/calc/use-amounts.ts +64 -0
  100. package/src/hooks/calc/use-data.ts +203 -0
  101. package/src/hooks/calc/use-defaults.ts +40 -0
  102. package/src/hooks/calc/use-far-mode.ts +37 -0
  103. package/src/hooks/calc/use-focus.ts +31 -0
  104. package/src/hooks/calc/use-maps.ts +76 -0
  105. package/src/hooks/calc/use-reset.ts +15 -0
  106. package/src/hooks/calc/use-resolver.ts +70 -0
  107. package/src/hooks/calc/use-session.ts +13 -0
  108. package/src/hooks/calc/use-share.ts +64 -0
  109. package/src/hooks/calc/use-storage.ts +126 -0
  110. package/src/hooks/calc/use-tier.ts +13 -0
  111. package/src/hooks/calc/use-view-mode.ts +24 -0
  112. package/src/hooks/calc/use-worker.ts +204 -0
  113. package/src/hooks/forge/use-anvil-gui.ts +16 -0
  114. package/src/hooks/forge/use-forge-state.ts +135 -0
  115. package/src/hooks/forge/use-ruler.ts +100 -0
  116. package/src/hooks/use-add-to-calc.ts +51 -0
  117. package/src/hooks/use-categories-by-id.ts +18 -0
  118. package/src/hooks/use-favorite-hotkey.ts +39 -0
  119. package/src/hooks/use-favorites.ts +135 -0
  120. package/src/hooks/use-group-outlines.ts +65 -0
  121. package/src/hooks/use-hide-unused.ts +12 -0
  122. package/src/hooks/use-index.ts +72 -0
  123. package/src/hooks/use-item-feeds.ts +46 -0
  124. package/src/hooks/use-item-pane.ts +106 -0
  125. package/src/hooks/use-item-press.ts +89 -0
  126. package/src/hooks/use-item-tag-groups.ts +54 -0
  127. package/src/hooks/use-item-tooltips.ts +18 -0
  128. package/src/hooks/use-left-sidebar-tree.ts +34 -0
  129. package/src/hooks/use-mod-names.ts +17 -0
  130. package/src/hooks/use-mount-limit.ts +22 -0
  131. package/src/hooks/use-panel-cells.ts +117 -0
  132. package/src/hooks/use-panel-page.ts +54 -0
  133. package/src/hooks/use-panel-tabs.ts +43 -0
  134. package/src/hooks/use-query.ts +186 -0
  135. package/src/hooks/use-session.ts +88 -0
  136. package/src/hooks/use-sidebar-group.ts +16 -0
  137. package/src/hooks/use-tag-members.ts +16 -0
  138. package/src/hooks/use-tool-recipes.ts +26 -0
  139. package/src/hooks/use-type-filter.ts +73 -0
  140. package/src/lib/alloy/mix.ts +210 -0
  141. package/src/lib/alloy/parse.ts +37 -0
  142. package/src/lib/calc/defaults.ts +47 -0
  143. package/src/lib/calc/flow.ts +315 -0
  144. package/src/lib/calc/focus.ts +15 -0
  145. package/src/lib/calc/meta.ts +17 -0
  146. package/src/lib/calc/pack.ts +77 -0
  147. package/src/lib/calc/ports.ts +43 -0
  148. package/src/lib/calc/resolve.ts +198 -0
  149. package/src/lib/calc/route/buckets.ts +49 -0
  150. package/src/lib/calc/route/geometry.ts +51 -0
  151. package/src/lib/calc/route/index.ts +3 -0
  152. package/src/lib/calc/route/polyline.ts +103 -0
  153. package/src/lib/calc/route/router.ts +394 -0
  154. package/src/lib/calc/scheme.ts +226 -0
  155. package/src/lib/calc/storage/entry.ts +97 -0
  156. package/src/lib/calc/storage/index.ts +20 -0
  157. package/src/lib/calc/storage/keys.ts +38 -0
  158. package/src/lib/calc/storage/store.ts +198 -0
  159. package/src/lib/calc/tree/index.ts +4 -0
  160. package/src/lib/calc/tree/materials.ts +29 -0
  161. package/src/lib/calc/tree/recipe.ts +104 -0
  162. package/src/lib/calc/tree/share.ts +135 -0
  163. package/src/lib/calc/worker/api.ts +25 -0
  164. package/src/lib/calc/worker/fetch.ts +66 -0
  165. package/src/lib/calc/worker/flush.ts +39 -0
  166. package/src/lib/calc/worker/index.ts +8 -0
  167. package/src/lib/calc/worker/protocol.ts +35 -0
  168. package/src/lib/calc/worker/resolve.ts +49 -0
  169. package/src/lib/calc/worker/state.ts +48 -0
  170. package/src/lib/card-model.ts +65 -0
  171. package/src/lib/client/recipe-categories.ts +14 -0
  172. package/src/lib/client/recipe.ts +85 -0
  173. package/src/lib/client-index/catalog.ts +91 -0
  174. package/src/lib/client-index/chrome.ts +180 -0
  175. package/src/lib/client-index/index.ts +20 -0
  176. package/src/lib/client-index/registry.ts +121 -0
  177. package/src/lib/client-index/rows.ts +260 -0
  178. package/src/lib/client-index/tooltips.ts +74 -0
  179. package/src/lib/config.ts +18 -0
  180. package/src/lib/data/category/dto.ts +25 -0
  181. package/src/lib/data/category/load.ts +47 -0
  182. package/src/lib/data/category/parse.ts +71 -0
  183. package/src/lib/data/dto.ts +152 -0
  184. package/src/lib/data/grid.ts +40 -0
  185. package/src/lib/data/parse/entry.ts +85 -0
  186. package/src/lib/data/parse/index.ts +3 -0
  187. package/src/lib/data/parse/layout.ts +138 -0
  188. package/src/lib/data/parse/slots.ts +85 -0
  189. package/src/lib/data/parse/widgets.ts +81 -0
  190. package/src/lib/data/scene.ts +103 -0
  191. package/src/lib/data/slot-io.ts +44 -0
  192. package/src/lib/dump.ts +6 -0
  193. package/src/lib/embed.ts +22 -0
  194. package/src/lib/favorite-hover.ts +13 -0
  195. package/src/lib/fluid-outputs.ts +70 -0
  196. package/src/lib/forge/actions.ts +59 -0
  197. package/src/lib/forge/data.ts +51 -0
  198. package/src/lib/forge/solve.ts +80 -0
  199. package/src/lib/group.ts +23 -0
  200. package/src/lib/index-store.ts +70 -0
  201. package/src/lib/links.ts +131 -0
  202. package/src/lib/lookup.ts +119 -0
  203. package/src/lib/panel-outline.ts +86 -0
  204. package/src/lib/panel.ts +105 -0
  205. package/src/lib/preview.ts +32 -0
  206. package/src/lib/recipe-slots.ts +8 -0
  207. package/src/lib/search.ts +139 -0
  208. package/src/lib/sidebar.ts +47 -0
  209. package/src/lib/used-items.ts +25 -0
  210. package/src/locales/en_us.json +172 -0
  211. package/src/locales/ru_ru.json +172 -0
  212. package/src/locales.ts +6 -0
  213. package/src/types/alloy.ts +20 -0
  214. package/src/types/calc-flow-props.ts +44 -0
  215. package/src/types/calc.ts +92 -0
  216. package/src/types/forge.ts +21 -0
  217. package/src/types/index.ts +136 -0
  218. package/styles/calc.css +308 -0
  219. package/styles/strip.css +7 -0
  220. package/styles.css +2 -0
  221. package/README.md +0 -3
@@ -0,0 +1,210 @@
1
+ import type { AlloyComponent, AlloyMixRow } from '@terrafirmagreg-team/recipe/types/alloy';
2
+
3
+ export const INGOT_MB = 144;
4
+
5
+ const MAX_INGOT_BATCH = 128;
6
+
7
+ function gcd(a: number, b: number): number {
8
+ return b ? gcd(b, a % b) : a;
9
+ }
10
+
11
+ export function ingotRatioMix(
12
+ components: readonly AlloyComponent[],
13
+ ): { counts: number[]; total: number } | null {
14
+ const scaled = components.map(({ slot }) => Math.round(slot.amount * 100));
15
+ if (!scaled.length || scaled.some((value) => value <= 0)) return null;
16
+ const divisor = scaled.reduce(gcd);
17
+ const counts = scaled.map((value) => value / divisor);
18
+ const total = counts.reduce((sum, value) => sum + value, 0);
19
+ return total <= MAX_INGOT_BATCH ? { counts, total } : null;
20
+ }
21
+
22
+ function componentsOk(
23
+ components: readonly AlloyComponent[],
24
+ ): components is readonly (AlloyComponent & { slot: { amount: number } })[] {
25
+ return (
26
+ components.length > 0 &&
27
+ components.every(
28
+ ({ slot }) => Number.isFinite(slot.amount) && slot.amount > 0,
29
+ )
30
+ );
31
+ }
32
+
33
+ function distributeUnits(shares: readonly number[], total: number): number[] {
34
+ const units = Math.max(1, Math.round(total));
35
+ const floors = shares.map((share) => Math.floor(share));
36
+ const leftover = units - floors.reduce((sum, value) => sum + value, 0);
37
+ const order = shares
38
+ .map((share, i) => [share - floors[i], i] as const)
39
+ .sort((a, b) => b[0] - a[0]);
40
+ for (let i = 0; i < leftover && order.length; i++) {
41
+ floors[order[i % order.length][1]]++;
42
+ }
43
+ return floors;
44
+ }
45
+
46
+ export function suggestAmounts(
47
+ components: readonly AlloyComponent[],
48
+ total: number,
49
+ ): number[] | null {
50
+ if (!componentsOk(components) || !Number.isFinite(total) || total <= 0) {
51
+ return null;
52
+ }
53
+ const sum = components.reduce((value, { slot }) => value + slot.amount, 0);
54
+ if (!Number.isFinite(sum) || sum <= 0) return null;
55
+ const units = Math.round(total);
56
+ if (units <= 0) return null;
57
+ return distributeUnits(
58
+ components.map(({ slot }) => (units * slot.amount) / sum),
59
+ units,
60
+ );
61
+ }
62
+
63
+ export function scaleAmounts(
64
+ amounts: readonly number[],
65
+ total: number,
66
+ ): number[] | null {
67
+ const sum = amounts.reduce((value, amount) => value + Math.max(0, amount), 0);
68
+ if (!Number.isFinite(total) || total <= 0 || sum <= 0) return null;
69
+ const units = Math.round(total);
70
+ if (units <= 0) return null;
71
+ return distributeUnits(
72
+ amounts.map((amount) => (Math.max(0, amount) / sum) * units),
73
+ units,
74
+ );
75
+ }
76
+
77
+ export function checkMix(
78
+ components: readonly AlloyComponent[],
79
+ amounts: readonly number[],
80
+ ): { total: number; output: number; rows: AlloyMixRow[]; valid: boolean } {
81
+ const total = amounts.reduce((sum, value) => sum + Math.max(0, value), 0);
82
+ const rows = components.map((component, i) => {
83
+ const amount = Math.max(0, amounts[i] ?? 0);
84
+ const percent = total > 0 ? (amount / total) * 100 : 0;
85
+ const { range } = component;
86
+ const valid =
87
+ !range ||
88
+ (percent >= range.min - 1e-8 && percent <= range.max + 1e-8);
89
+ return { ...component, amount, percent, valid };
90
+ });
91
+ return {
92
+ total,
93
+ output: total,
94
+ rows,
95
+ valid: total > 0 && rows.every((row) => row.valid),
96
+ };
97
+ }
98
+
99
+ export function snapToIngots(
100
+ components: readonly AlloyComponent[],
101
+ amounts: readonly number[],
102
+ ): number[] | null {
103
+ const count = components.length;
104
+ if (!count) return null;
105
+ const lows = components.map((_, i) =>
106
+ Math.max(0, Math.round((amounts[i] ?? 0) / INGOT_MB) - 1),
107
+ );
108
+ let best: number[] | null = null;
109
+ let bestDiff = Infinity;
110
+ for (let code = 0, limit = 3 ** count; code < limit; code++) {
111
+ let rest = code;
112
+ const snapped = new Array<number>(count);
113
+ for (let i = 0; i < count; i++) {
114
+ snapped[i] = (lows[i] + (rest % 3)) * INGOT_MB;
115
+ rest = Math.floor(rest / 3);
116
+ }
117
+ if (!checkMix(components, snapped).valid) continue;
118
+ const diff = snapped.reduce(
119
+ (sum, value, i) => sum + Math.abs(value - (amounts[i] ?? 0)),
120
+ 0,
121
+ );
122
+ if (diff < bestDiff) {
123
+ bestDiff = diff;
124
+ best = snapped;
125
+ }
126
+ }
127
+ return best;
128
+ }
129
+
130
+ export function minShareMix(
131
+ components: readonly AlloyComponent[],
132
+ index: number,
133
+ total: number,
134
+ ): number[] | null {
135
+ const range = components[index]?.range;
136
+ if (
137
+ !range ||
138
+ !componentsOk(components) ||
139
+ !Number.isFinite(total) ||
140
+ total <= 0
141
+ ) {
142
+ return null;
143
+ }
144
+ const units = Math.round(total);
145
+
146
+ const pinned = Math.max(1, Math.ceil((units * range.min) / 100));
147
+ const rest = units - pinned;
148
+ if (rest <= 0) return null;
149
+ const restSum = components.reduce(
150
+ (sum, { slot }, i) => sum + (i === index ? 0 : slot.amount),
151
+ 0,
152
+ );
153
+ const shares = components.map(({ slot }, i) =>
154
+ i === index ? 0 : (rest * slot.amount) / restSum,
155
+ );
156
+ const dist = distributeUnits(shares, rest);
157
+ dist[index] = pinned;
158
+ return dist;
159
+ }
160
+
161
+ export function minimalAdditions(
162
+ components: readonly AlloyComponent[],
163
+ amounts: readonly number[],
164
+ ): number[] | null {
165
+ if (!componentsOk(components)) return null;
166
+ const next = components.map((_, i) => Math.max(0, amounts[i] ?? 0));
167
+ for (let pass = 0; pass < 256; pass++) {
168
+ const { rows, valid } = checkMix(components, next);
169
+ if (valid) return next.map((value, i) => value - Math.max(0, amounts[i] ?? 0));
170
+ const over = rows.filter((row) => row.range && row.percent > row.range.max);
171
+ const under = rows.filter((row) => row.range && row.percent < row.range.min);
172
+ const total = rows.reduce((sum, row) => sum + row.amount, 0);
173
+
174
+ if (over.length && !under.length) {
175
+
176
+ const extra = over[0];
177
+ const neededTotal = (next[rows.indexOf(extra)] * 100) / extra.range!.max;
178
+ const room = rows.filter(
179
+ (row) => !over.includes(row) && (!row.range || row.percent < row.range.max),
180
+ );
181
+ if (!room.length) return null;
182
+ const target = room.sort(
183
+ (a, b) =>
184
+ (b.range?.max ?? Infinity) - b.percent -
185
+ ((a.range?.max ?? Infinity) - a.percent),
186
+ )[0];
187
+ const index = rows.indexOf(target);
188
+ if (!target.range) {
189
+ next[index] += neededTotal - total;
190
+ continue;
191
+ }
192
+ const addNeed = neededTotal - total;
193
+ const addMax =
194
+ (target.range.max * total - 100 * next[index]) / (100 - target.range.max);
195
+ next[index] += Math.max(Math.ceil(Math.min(addNeed, addMax)), 1);
196
+ continue;
197
+ }
198
+
199
+ const target = under.sort(
200
+ (a, b) => a.percent - a.range!.min - (b.percent - b.range!.min),
201
+ )[0];
202
+ if (!target) return null;
203
+ const index = rows.indexOf(target);
204
+ const add =
205
+ (target.range!.min * total - 100 * next[index]) / (100 - target.range!.min);
206
+ next[index] += Math.max(Math.ceil(add), 1);
207
+ }
208
+ return null;
209
+ }
210
+
@@ -0,0 +1,37 @@
1
+ import type { Recipe } from '@terrafirmagreg-team/recipe/lib/data/dto';
2
+ import type { AlloyComponent, AlloyRecipe } from '@terrafirmagreg-team/recipe/types/alloy';
3
+ import { cleanForgeText } from '@terrafirmagreg-team/recipe/lib/forge/actions';
4
+
5
+ export function parseAlloyPercent(
6
+ text: string,
7
+ ): { min: number; max: number } | undefined {
8
+ const match = /^(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)\s*%$/.exec(
9
+ cleanForgeText(text),
10
+ );
11
+ if (!match) return undefined;
12
+ const min = Number(match[1]);
13
+ const max = Number(match[2]);
14
+ return min >= 0 && min <= max && max <= 100 ? { min, max } : undefined;
15
+ }
16
+
17
+ export function toAlloyRecipe(recipe: Recipe): AlloyRecipe {
18
+ const inputs = Object.entries(recipe.slots)
19
+ .filter(([, slot]) => slot.type === 'input')
20
+ .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
21
+ .map(([, slot]) => slot);
22
+ const ranges = recipe.widgets
23
+ .filter((widget) => widget.type === 'text')
24
+ .map((widget) => ({
25
+ x: widget.x,
26
+ y: widget.y,
27
+ range: parseAlloyPercent(widget.text ?? ''),
28
+ }))
29
+ .filter((widget) => widget.range !== undefined)
30
+ .sort((a, b) => a.y - b.y || a.x - b.x);
31
+ const components: AlloyComponent[] = inputs.map((slot, i) => ({
32
+ slot,
33
+ ...(ranges.length === inputs.length ? { range: ranges[i].range } : {}),
34
+ }));
35
+ return { recipe, components };
36
+ }
37
+
@@ -0,0 +1,47 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ const KEY_PREFIX = 'calc:recipe-defaults:';
3
+
4
+ export function readRecipeDefaults(dataset: DatasetRef): Record<string, string> {
5
+ if (typeof window === 'undefined') return {};
6
+ try {
7
+ const raw = window.localStorage.getItem(`${KEY_PREFIX}${dataset.id}/${dataset.version ?? ''}`);
8
+ if (!raw) return {};
9
+ const parsed = JSON.parse(raw);
10
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
11
+ return {};
12
+ return Object.fromEntries(
13
+ Object.entries(parsed as Record<string, unknown>).filter(
14
+ ([key, value]) =>
15
+ typeof key === 'string' && typeof value === 'string',
16
+ ) as [string, string][],
17
+ );
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ const EVENT = 'tfg:recipe-defaults';
24
+
25
+ export function subscribeRecipeDefaults(listener: () => void): () => void {
26
+ if (typeof window === 'undefined') return () => {};
27
+ window.addEventListener(EVENT, listener);
28
+ return () => window.removeEventListener(EVENT, listener);
29
+ }
30
+
31
+ export function writeRecipeDefault(
32
+ dataset: DatasetRef,
33
+ materialId: string,
34
+ recipeId: string | null,
35
+ ): Record<string, string> {
36
+ const next = readRecipeDefaults(dataset);
37
+ if (recipeId === null) delete next[materialId];
38
+ else next[materialId] = recipeId;
39
+ try {
40
+ window.localStorage.setItem(`${KEY_PREFIX}${dataset.id}/${dataset.version ?? ''}`, JSON.stringify(next));
41
+ } catch {
42
+
43
+ }
44
+ window.dispatchEvent(new Event(EVENT));
45
+ return next;
46
+ }
47
+
@@ -0,0 +1,315 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import type { Edge, Node } from '@terrafirmagreg-team/ui/lib/canvas/xyflow';
3
+ import type { Recipe } from '@terrafirmagreg-team/recipe/lib/data/dto';
4
+ import { PORT_IN } from '@terrafirmagreg-team/recipe/lib/calc/ports';
5
+ import type {
6
+ CalcKind,
7
+ CalcNode,
8
+ CalcRecipeSummary,
9
+ CalcWire,
10
+ } from '@terrafirmagreg-team/recipe/types/calc';
11
+ import type { FlowPoint } from '@terrafirmagreg-team/ui/types/canvas';
12
+
13
+ export const CALC_NODE_W = 232;
14
+ export const CALC_NODE_H = 76;
15
+
16
+ export const CALC_LOD_FAR = 0.45;
17
+
18
+ export const CALC_LOD_DOT = 0.15;
19
+ const CALC_MINI_W = 96;
20
+ const CALC_MINI_TAG_H = 84;
21
+
22
+ export type CalcViewMode = 'mini' | 'normal' | 'detailed';
23
+ export type CalcFarMode = 'full' | 'preview' | 'hidden';
24
+
25
+ export const GRID_GAPS = { x: 14, y: 24, goal: 48 };
26
+
27
+ export type CalcTier = 'dot' | 'far' | 'near';
28
+
29
+ export type CalcFlowData = {
30
+ node: CalcNode;
31
+ dataset: DatasetRef;
32
+ mode: CalcViewMode;
33
+ recipeBody: (recipeId: string) => Recipe | undefined;
34
+ onSelectRecipe: (cardId: string, recipeId: string | null) => void;
35
+ onSelectTagMember: (tagId: string, memberId: string) => void;
36
+ onRequestPicker: (node: CalcNode) => void;
37
+ onRequestMemberPicker: (node: CalcNode) => void;
38
+ onRequestSupply: (node: CalcNode, input: number) => void;
39
+ onSupplyInput: (
40
+ cardId: string,
41
+ inputIndex: number,
42
+ recipeId: string | null,
43
+ outputAmount?: number,
44
+ ) => string | null;
45
+ getRecipesForItem: (
46
+ materialId: string,
47
+ kind: CalcKind,
48
+ ) => CalcRecipeSummary[];
49
+ ensureMaterial: (materialId: string) => void;
50
+ onAddCard: (
51
+ materialId: string,
52
+ kind: CalcKind,
53
+ pos?: { x: number; y: number },
54
+ ) => string;
55
+ onOpenItem: (materialId: string, kind: CalcKind) => void;
56
+ onRemoveCard: (cardId: string) => void;
57
+ onMoveCard: (cardId: string, pos: { x: number; y: number }) => void;
58
+ onCrafts: (cardId: string, crafts: number) => void;
59
+ onWire: (from: string, to: string, input: number, out?: number) => void;
60
+ onUnwire: (
61
+ from: string,
62
+ to: string,
63
+ input: number,
64
+ out?: number,
65
+ ) => void;
66
+ onRewire: (
67
+ from: string,
68
+ to: string,
69
+ input: number,
70
+ next: { from: string; to: string; input: number; out?: number },
71
+ ) => void;
72
+ onWireAmount: (
73
+ from: string,
74
+ to: string,
75
+ input: number,
76
+ amount: number | null,
77
+ ) => void;
78
+ recipeDefaults: Record<string, string>;
79
+ onSetRecipeDefault: (materialId: string, recipeId: string | null) => void;
80
+ };
81
+
82
+ export type CalcFlowNode = Node<CalcFlowData, 'calcMaterial'>;
83
+ export type AnyCalcFlowNode = CalcFlowNode;
84
+
85
+ export function nodeSize(
86
+ calc: CalcNode,
87
+ ctx: { mode: CalcViewMode; recipeBody: (id: string) => Recipe | undefined },
88
+ ): { w: number; h: number } {
89
+ const margin =
90
+ (calc.inputs.length ? PORT_IN : 0) + (calc.outputs.length ? PORT_IN : 0);
91
+ if (ctx.mode === 'mini') {
92
+ return { w: CALC_MINI_W + margin, h: CALC_MINI_TAG_H };
93
+ }
94
+ const body = calc.recipe ? ctx.recipeBody(calc.recipe.recipeId) : undefined;
95
+ if (ctx.mode === 'detailed' && body?.w && body.h) {
96
+ return {
97
+ w: Math.max(CALC_NODE_W, body.w + 16) + margin,
98
+ h: CALC_NODE_H + body.h + 6,
99
+ };
100
+ }
101
+ return { w: CALC_NODE_W + margin, h: CALC_NODE_H };
102
+ }
103
+
104
+ export function cardsToFlow(
105
+ nodes: CalcNode[],
106
+ wires: readonly CalcWire[],
107
+ data: Omit<CalcFlowData, 'node'>,
108
+ flows?: readonly (number | undefined)[],
109
+ ): { nodes: AnyCalcFlowNode[]; edges: Edge[] } {
110
+ const flowNodes: AnyCalcFlowNode[] = nodes.map((node) => {
111
+ const { w, h } = nodeSize(node, {
112
+ mode: data.mode,
113
+ recipeBody: data.recipeBody,
114
+ });
115
+ return {
116
+ id: node.id,
117
+ type: 'calcMaterial',
118
+ position: { x: 0, y: 0 },
119
+ width: w,
120
+ height: h,
121
+ data: { ...data, node },
122
+ };
123
+ });
124
+ const byId = new Map(nodes.map((node) => [node.id, node]));
125
+ const edges: Edge[] = wires.flatMap((wire, index) => {
126
+ const src = byId.get(wire.from);
127
+ const dst = byId.get(wire.to);
128
+ const input = dst?.inputs[wire.input];
129
+ if (!src || !dst || !input || !input.wired) return [];
130
+ const atIndex = src.outputs[wire.out ?? 0];
131
+ const outIndex =
132
+ atIndex && atIndex.id === input.id && atIndex.kind === input.kind
133
+ ? (wire.out ?? 0)
134
+ : src.outputs.findIndex(
135
+ (o) => o.id === input.id && o.kind === input.kind,
136
+ );
137
+ if (outIndex < 0) return [];
138
+ const mat = src.outputs[outIndex];
139
+ return [
140
+ {
141
+ id: `wire:${wire.from}->${wire.to}:${wire.input}:${index}`,
142
+ source: wire.from,
143
+ sourceHandle: outIndex > 0 ? `out-${outIndex}` : 'out',
144
+ target: wire.to,
145
+ targetHandle: `in-${wire.input}`,
146
+ type: 'calcEdge',
147
+ className: 'calc-edge--wire',
148
+ deletable: true,
149
+ data: {
150
+ wire,
151
+ flow: flows?.[index],
152
+ matId: mat?.id ?? input.id,
153
+ matKind: mat?.kind ?? input.kind,
154
+ dataset: data.dataset,
155
+ onWireAmount: data.onWireAmount,
156
+ onUnwire: data.onUnwire,
157
+ },
158
+ },
159
+ ];
160
+ });
161
+ return { nodes: flowNodes, edges };
162
+ }
163
+
164
+ function sameCalcNode(a: CalcNode, b: CalcNode): boolean {
165
+ return (
166
+ a.id === b.id &&
167
+ a.materialId === b.materialId &&
168
+ a.kind === b.kind &&
169
+ a.crafts === b.crafts &&
170
+ a.produced === b.produced &&
171
+ a.allocated === b.allocated &&
172
+ a.remaining === b.remaining &&
173
+ a.outputs.length === b.outputs.length &&
174
+ a.outputs.every(
175
+ (out, index) =>
176
+ out.id === b.outputs[index]?.id &&
177
+ out.kind === b.outputs[index]?.kind &&
178
+ out.amount === b.outputs[index]?.amount &&
179
+ out.allocated === b.outputs[index]?.allocated &&
180
+ out.remaining === b.outputs[index]?.remaining,
181
+ ) &&
182
+ a.cycle === b.cycle &&
183
+ a.products === b.products &&
184
+ a.recipe?.recipeId === b.recipe?.recipeId &&
185
+ a.recipe?.category === b.recipe?.category &&
186
+ a.tagId === b.tagId &&
187
+ a.loading === b.loading &&
188
+ a.inputs.length === b.inputs.length &&
189
+ a.inputs.every(
190
+ (inp, index) =>
191
+ inp.id === b.inputs[index]?.id &&
192
+ inp.amount === b.inputs[index]?.amount &&
193
+ inp.covered === b.inputs[index]?.covered &&
194
+ inp.wired === b.inputs[index]?.wired &&
195
+ inp.catalyst === b.inputs[index]?.catalyst,
196
+ ) &&
197
+ (a.members?.join() ?? '') === (b.members?.join() ?? '') &&
198
+ a.availableRecipes.length === b.availableRecipes.length &&
199
+ a.availableRecipes.every(
200
+ (row, index) =>
201
+ row.recipeId === b.availableRecipes[index]?.recipeId &&
202
+ row.outputAmount === b.availableRecipes[index]?.outputAmount &&
203
+ row.category === b.availableRecipes[index]?.category,
204
+ )
205
+ );
206
+ }
207
+
208
+ function sameFlowData(a: CalcFlowData, b: CalcFlowData): boolean {
209
+ return (
210
+ (a.node === b.node || sameCalcNode(a.node, b.node)) &&
211
+ a.dataset === b.dataset &&
212
+ a.mode === b.mode &&
213
+ a.recipeBody === b.recipeBody &&
214
+ a.onSelectRecipe === b.onSelectRecipe &&
215
+ a.onSelectTagMember === b.onSelectTagMember &&
216
+ a.onRequestPicker === b.onRequestPicker &&
217
+ a.onRequestMemberPicker === b.onRequestMemberPicker &&
218
+ a.onRequestSupply === b.onRequestSupply &&
219
+ a.onSupplyInput === b.onSupplyInput &&
220
+ a.getRecipesForItem === b.getRecipesForItem &&
221
+ a.ensureMaterial === b.ensureMaterial &&
222
+ a.onAddCard === b.onAddCard &&
223
+ a.onOpenItem === b.onOpenItem &&
224
+ a.onRemoveCard === b.onRemoveCard &&
225
+ a.onMoveCard === b.onMoveCard &&
226
+ a.onCrafts === b.onCrafts &&
227
+ a.onWire === b.onWire &&
228
+ a.onUnwire === b.onUnwire &&
229
+ a.onRewire === b.onRewire &&
230
+ a.onWireAmount === b.onWireAmount &&
231
+ a.recipeDefaults === b.recipeDefaults &&
232
+ a.onSetRecipeDefault === b.onSetRecipeDefault
233
+ );
234
+ }
235
+
236
+ export function reconcileCalcNodes(
237
+ prev: AnyCalcFlowNode[],
238
+ next: AnyCalcFlowNode[],
239
+ ): AnyCalcFlowNode[] {
240
+ const withDelay = (
241
+ node: AnyCalcFlowNode,
242
+ order: number,
243
+ ): AnyCalcFlowNode => ({
244
+ ...node,
245
+ style: {
246
+ ...node.style,
247
+ '--calc-delay': `${Math.min(order * 14, 300)}ms`,
248
+ } as CalcFlowNode['style'],
249
+ });
250
+
251
+ if (!prev.length) return next.map((node, i) => withDelay(node, i + 1));
252
+ const byId = new Map(prev.map((node) => [node.id, node]));
253
+ let changed = next.length !== prev.length;
254
+ let enters = 0;
255
+ const out = next.map((node) => {
256
+ const kept = byId.get(node.id);
257
+ if (
258
+ kept &&
259
+ kept.position.x === node.position.x &&
260
+ kept.position.y === node.position.y &&
261
+ kept.width === node.width &&
262
+ kept.height === node.height &&
263
+ kept.className === node.className &&
264
+ sameFlowData(kept.data, node.data)
265
+ ) {
266
+ return kept;
267
+ }
268
+ changed = true;
269
+ if (!kept) {
270
+ enters += 1;
271
+ return withDelay(node, enters);
272
+ }
273
+ return node;
274
+ });
275
+ return changed ? out : prev;
276
+ }
277
+
278
+ export function reconcileCalcEdges(
279
+ prev: Edge[],
280
+ next: Edge[],
281
+ ): Edge[] {
282
+ if (!prev.length) return next;
283
+ const byId = new Map(prev.map((edge) => [edge.id, edge]));
284
+ let changed = next.length !== prev.length;
285
+ const out = next.map((edge) => {
286
+ const kept = byId.get(edge.id);
287
+
288
+ if (
289
+ kept &&
290
+ kept.source === edge.source &&
291
+ kept.target === edge.target &&
292
+ kept.sourceHandle === edge.sourceHandle &&
293
+ kept.targetHandle === edge.targetHandle &&
294
+ kept.className === edge.className &&
295
+ kept.data?.flow === edge.data?.flow &&
296
+ (kept.data?.wire as CalcWire | undefined)?.amount ===
297
+ (edge.data?.wire as CalcWire | undefined)?.amount &&
298
+ ((kept.data?.points as FlowPoint[] | undefined)?.length ?? 0) ===
299
+ ((edge.data?.points as FlowPoint[] | undefined)?.length ?? 0) &&
300
+ (kept.data?.points as FlowPoint[] | undefined)?.every(
301
+ (point, i) =>
302
+ point.x ===
303
+ (edge.data?.points as FlowPoint[] | undefined)?.[i]?.x &&
304
+ point.y ===
305
+ (edge.data?.points as FlowPoint[] | undefined)?.[i]?.y,
306
+ ) !== false
307
+ ) {
308
+ return kept;
309
+ }
310
+ changed = true;
311
+ return edge;
312
+ });
313
+ return changed ? out : prev;
314
+ }
315
+
@@ -0,0 +1,15 @@
1
+ type Listener = (materialId: string) => void;
2
+
3
+ const listeners = new Set<Listener>();
4
+
5
+ export function requestCalcFocus(materialId: string): void {
6
+ for (const listener of listeners) listener(materialId);
7
+ }
8
+
9
+ export function onCalcFocusRequest(listener: Listener): () => void {
10
+ listeners.add(listener);
11
+ return () => {
12
+ listeners.delete(listener);
13
+ };
14
+ }
15
+
@@ -0,0 +1,17 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import type { CalcKind } from '@terrafirmagreg-team/recipe/types/calc';
3
+
4
+ export type IconItem = { id: string; kind: CalcKind };
5
+
6
+ export type EntryMeta = {
7
+ dataset: DatasetRef;
8
+ lang: string;
9
+ langMap?: Record<string, string>;
10
+ iconItems?: IconItem[];
11
+ };
12
+
13
+ export const rowClass =
14
+ 'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors';
15
+ export const rowIdle =
16
+ 'text-muted-foreground hover:bg-accent hover:text-accent-foreground';
17
+