- lib/hive: frontend port of the hive domain (ops, validator, facts, layout, follow-ups, describe texts, reason texts) running the backend fixtures, API client - hive card on the colony page (same height as Stammdaten): SVG view, accessible outline tree, frame/part sheets with optimistic save, conflict sheet, history with past states, init from template or another colony, inspection draft mode - follow-up toasts prefilling feeding and treatment forms (treatment end only with an open treatment) - settings page "Beutensysteme" (hive-systems:manage) - NumberStepper decimal steps, reactive motion level hook, holo tokens - spec: owner design decision 2026-10-16 (glass hologram, focus mode) The SVG view is the fallback; the 3D scene follows in L3. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
import {
|
|
MAX_OPS,
|
|
type HiveApplyResult,
|
|
type HiveCatalog,
|
|
type HiveFrame,
|
|
type HiveOp,
|
|
type HivePart,
|
|
type HiveState,
|
|
} from "./types";
|
|
import { validateHiveState } from "./validate";
|
|
|
|
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
|
|
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
|
|
|
|
interface Location {
|
|
part: HivePart;
|
|
list: HivePart[];
|
|
index: number;
|
|
isInsert: boolean;
|
|
}
|
|
|
|
/** Result of one op on the working copy: a reason string, or null on success. */
|
|
type OpOutcome = string | null;
|
|
|
|
function locate(state: HiveState, id: unknown): Location | null {
|
|
for (let index = 0; index < state.stack.length; index++) {
|
|
const part = state.stack[index];
|
|
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
|
|
const inserts = part.inserts ?? [];
|
|
for (let i = 0; i < inserts.length; i++) {
|
|
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function missing(id: unknown): string {
|
|
return `MISSING_ID: no part with id ${String(id)}`;
|
|
}
|
|
|
|
function clamp(value: unknown, max: number): number {
|
|
if (typeof value !== "number" || Number.isNaN(value)) return max;
|
|
return Math.min(Math.max(Math.trunc(value), 0), max);
|
|
}
|
|
|
|
/** Catalog membership and active check for a part to be added (and its inserts). */
|
|
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
|
|
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
|
|
const type = catalog.partTypes.get(part.partTypeId);
|
|
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
|
|
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
|
|
for (const insert of part.inserts ?? []) {
|
|
const nested = checkAddable(insert, catalog);
|
|
if (nested) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Deep copy of an incoming part without empty `inserts` arrays. */
|
|
function normalisedCopy(part: HivePart): HivePart {
|
|
const copy = structuredClone(part);
|
|
if (Array.isArray(copy.inserts)) {
|
|
if (copy.inserts.length === 0) delete copy.inserts;
|
|
else copy.inserts = copy.inserts.map(normalisedCopy);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
|
|
|
|
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
|
|
const problem = checkAddable(op.part, catalog);
|
|
if (problem) return problem;
|
|
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
|
|
return null;
|
|
}
|
|
|
|
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
|
|
const host = locate(state, op.hostId);
|
|
if (!host || host.isInsert) return missing(op.hostId);
|
|
const problem = checkAddable(op.part, catalog);
|
|
if (problem) return problem;
|
|
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
|
|
return null;
|
|
}
|
|
|
|
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
|
|
const found = locate(state, op.id);
|
|
if (!found) return missing(op.id);
|
|
found.list.splice(found.index, 1);
|
|
if (found.isInsert) {
|
|
const host = state.stack.find((p) => p.inserts === found.list);
|
|
if (host && found.list.length === 0) delete host.inserts;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
|
|
const found = locate(state, op.id);
|
|
if (!found) return missing(op.id);
|
|
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
|
|
const [moved] = state.stack.splice(found.index, 1);
|
|
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
|
|
return null;
|
|
}
|
|
|
|
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
|
|
const found = locate(state, op.id);
|
|
if (!found) return missing(op.id);
|
|
found.part.state = { ...found.part.state, [op.key]: op.value };
|
|
return null;
|
|
}
|
|
|
|
function locateBox(state: HiveState, boxId: unknown): Location | string {
|
|
const found = locate(state, boxId);
|
|
if (!found) return missing(boxId);
|
|
if (found.isInsert || !Array.isArray(found.part.frames)) {
|
|
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
|
|
const box = locateBox(state, op.boxId);
|
|
if (typeof box === "string") return box;
|
|
const frames = box.part.frames as HiveFrame[];
|
|
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
|
|
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
|
|
}
|
|
// name: undefined keeps the current name, null removes it.
|
|
const current = frames[op.slot - 1];
|
|
const name = op.name === undefined ? current.name : (op.name ?? undefined);
|
|
frames[op.slot - 1] =
|
|
name === undefined
|
|
? { slot: op.slot, type: op.type }
|
|
: { slot: op.slot, type: op.type, name };
|
|
return null;
|
|
}
|
|
|
|
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
|
|
const box = locateBox(state, op.boxId);
|
|
if (typeof box === "string") return box;
|
|
box.part.frames = structuredClone(op.frames);
|
|
return null;
|
|
}
|
|
|
|
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
|
|
const found = locate(state, op.id);
|
|
if (!found) return missing(op.id);
|
|
if (op.label === null) delete found.part.label;
|
|
else found.part.label = op.label;
|
|
return null;
|
|
}
|
|
|
|
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
|
|
switch (op?.op) {
|
|
case "addPart":
|
|
return applyAddPart(state, op, catalog);
|
|
case "addInsert":
|
|
return applyAddInsert(state, op, catalog);
|
|
case "removePart":
|
|
return applyRemovePart(state, op);
|
|
case "movePart":
|
|
return applyMovePart(state, op);
|
|
case "setState":
|
|
return applySetState(state, op);
|
|
case "setFrame":
|
|
return applySetFrame(state, op);
|
|
case "setFrames":
|
|
return applySetFrames(state, op);
|
|
case "setLabel":
|
|
return applySetLabel(state, op);
|
|
default:
|
|
return "UNKNOWN_OP: unsupported operation";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies ops in order and validates the whole state after each one.
|
|
* Never mutates its inputs: it works on a private deep copy.
|
|
*/
|
|
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
|
|
if (ops.length > MAX_OPS) {
|
|
return {
|
|
ok: false,
|
|
opIndex: MAX_OPS,
|
|
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
|
|
};
|
|
}
|
|
const working = structuredClone(state);
|
|
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
|
|
const failure =
|
|
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
|
|
if (failure) return { ok: false, opIndex, reason: failure };
|
|
}
|
|
return { ok: true, state: working };
|
|
}
|