1
0
Fork 0
polaris-task-force/src/components/admin/tech-tree/prereqEdits.ts
Z8MB1E 89482798f4 feat(tech-tree): add admin tech tree editor view
Add a custom admin view over the technologies collection so staff can model
prerequisites as a left-to-right tree instead of a flat list.

- React Flow v12 canvas with category bands, OR-prerequisite edges, a
  lite side-panel editor, and a quick-create dialog.
- Propagating layout: a technology with prerequisites lands one column right
  of its parents at their average row; uncategorized roots fill a catalog
  grid keyed by each tech's rank among all uncategorized technologies, so
  promoting one into the tree leaves an empty slot instead of re-shuffling
  the grid.
- Stability contract: connecting or removing edges never moves a card (the
  connected node is pinned to its current spot), and a "Save positions"
  control commits every on-screen node so the arrangement survives reload;
  "Auto-arrange" clears manual positions and restores the ordered layout.
- Progression-path edge colors and per-source lanes keep overlapping edges
  readable. Wire the view and its nav entry into payload.config.ts and add
  dark React Flow / scrollbar styling.
2026-10-01 10:03:58 -04:00

114 lines
4 KiB
TypeScript

import type { TreeTech } from "./types";
/**
* Pure helpers for editing a technology's prerequisites from the tree canvas
* and the side panel. The tree flattens the document's prerequisite rows into
* two unions (AND / OR), and edits are written back as a single row, which
* preserves the meaning of the rows the tree renders.
*/
export type PrereqLists = { and: number[]; or: number[] };
export type PrereqEditResult =
| { ok: true; lists: PrereqLists }
| { ok: false; error: string };
/** Does `fromId` (transitively) depend on `toId`? Cycle probe over prereq edges. */
export function dependsOn(techs: TreeTech[], fromId: number, toId: number): boolean {
const byId = new Map(techs.map((t) => [t.id, t]));
const seen = new Set<number>();
const stack = [fromId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toId) return true;
if (seen.has(current)) continue;
seen.add(current);
const tech = byId.get(current);
if (!tech) continue;
stack.push(...tech.prereqAnd, ...tech.prereqOr);
}
return false;
}
/** The document-shaped prerequisite rows for a pair of id lists. */
export function buildPrereqRows(lists: PrereqLists) {
return [
{
requiresAnd: { technology: lists.and },
requiresOr: { technology: lists.or },
},
];
}
/**
* Add `sourceId` as a prerequisite of `targetId`. Rejects self-references,
* duplicates, and connections that would create a circular dependency.
*/
export function addPrerequisite(
techs: TreeTech[],
targetId: number,
sourceId: number,
kind: "and" | "or",
): PrereqEditResult {
const target = techs.find((t) => t.id === targetId);
if (!target) return { ok: false, error: "Technology not found." };
if (targetId === sourceId) {
return { ok: false, error: "A technology cannot be its own prerequisite." };
}
if (target.prereqAnd.includes(sourceId) || target.prereqOr.includes(sourceId)) {
return { ok: false, error: "That technology is already a prerequisite." };
}
// Adding source -> target means target depends on source. If source already
// depends on target, this closes a loop.
if (dependsOn(techs, sourceId, targetId)) {
return { ok: false, error: "That would create a circular dependency." };
}
const lists: PrereqLists = {
and: kind === "and" ? [...target.prereqAnd, sourceId] : [...target.prereqAnd],
or: kind === "or" ? [...target.prereqOr, sourceId] : [...target.prereqOr],
};
return { ok: true, lists };
}
/** Remove `sourceId` from both prerequisite lists of `targetId`. */
export function removePrerequisite(
techs: TreeTech[],
targetId: number,
sourceId: number,
): PrereqEditResult {
const target = techs.find((t) => t.id === targetId);
if (!target) return { ok: false, error: "Technology not found." };
const lists: PrereqLists = {
and: target.prereqAnd.filter((id) => id !== sourceId),
or: target.prereqOr.filter((id) => id !== sourceId),
};
return { ok: true, lists };
}
/** Move an existing prerequisite between the AND and OR groups. */
export function setPrerequisiteKind(
techs: TreeTech[],
targetId: number,
sourceId: number,
kind: "and" | "or",
): PrereqEditResult {
const target = techs.find((t) => t.id === targetId);
if (!target) return { ok: false, error: "Technology not found." };
if (!target.prereqAnd.includes(sourceId) && !target.prereqOr.includes(sourceId)) {
return { ok: false, error: "That technology is not a prerequisite yet." };
}
const lists: PrereqLists = {
and: kind === "and"
? [...new Set([...target.prereqAnd, sourceId])]
: target.prereqAnd.filter((id) => id !== sourceId),
or: kind === "or"
? [...new Set([...target.prereqOr, sourceId])]
: target.prereqOr.filter((id) => id !== sourceId),
};
return { ok: true, lists };
}
/** Apply a set of prerequisite lists to a TreeTech-shaped object. */
export function withPrereqs(tech: TreeTech, lists: PrereqLists): TreeTech {
return { ...tech, prereqAnd: lists.and, prereqOr: lists.or };
}