Compare commits
9 commits
954e6e2c44
...
d45790e6b7
| Author | SHA1 | Date | |
|---|---|---|---|
| d45790e6b7 | |||
| 4fdc887a13 | |||
| 4845084c1c | |||
| 64d92bf848 | |||
| 73cae3defd | |||
| affc899da0 | |||
| 5f7fd508a5 | |||
| 89482798f4 | |||
| 15f2d0c573 |
49 changed files with 76719 additions and 171 deletions
|
|
@ -62,6 +62,13 @@ APP_PORT=3000
|
||||||
# DISCORD_ATTENDANCE_POLL_MS=
|
# DISCORD_ATTENDANCE_POLL_MS=
|
||||||
# DISCORD_NOTIFICATION_POLL_MS=
|
# DISCORD_NOTIFICATION_POLL_MS=
|
||||||
|
|
||||||
|
# --- Usage analytics (Microsoft Clarity) ---
|
||||||
|
# Project ID from clarity.microsoft.com (Settings > Project ID). Optional;
|
||||||
|
# leave unset to disable analytics entirely. The script only loads in
|
||||||
|
# production builds (never in local dev) and covers the frontend app and the
|
||||||
|
# login page, not the admin panel.
|
||||||
|
# NEXT_PUBLIC_CLARITY_ID=
|
||||||
|
|
||||||
# --- Arma 3 bridge (Pythia HTTP integration) ---
|
# --- Arma 3 bridge (Pythia HTTP integration) ---
|
||||||
# Shared secret required by the /api/arma/v1/* routes. The Arma server sends
|
# Shared secret required by the /api/arma/v1/* routes. The Arma server sends
|
||||||
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
|
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
|
||||||
|
|
|
||||||
14
AGENTS.md
14
AGENTS.md
|
|
@ -46,7 +46,7 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
|
||||||
|
|
||||||
- **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
|
- **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
|
||||||
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
|
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
|
||||||
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. Override the binary with `E2E_BROWSER_PATH` (e.g. Playwright's bundled chromium) when the installed Vivaldi crashes under automation; the default stays Vivaldi. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). `global-setup` also seeds the shared e2e login: `dev` / `Test123` (developer legacy role + a superuser Roles doc for permission-gated UI + the "J. Fraley" rank so the dashboard heading matches). E2E runs serially (`workers: 1`, one webpack dev server) with generous `timeout`/`expect` timeouts because first navigation to a route triggers a cold webpack compile. Currently: tours smoke (auto-start, dismissal, replay), frontend, operations, reservations, supply boxes, OPORD recall, session banner. Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
|
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. Override the binary with `E2E_BROWSER_PATH` (e.g. Playwright's bundled chromium) when the installed Vivaldi crashes under automation; the default stays Vivaldi. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). `global-setup` also seeds the shared e2e login: `dev` / `Test123` (developer legacy role + a superuser Roles doc for permission-gated UI + the "J. Fraley" rank so the dashboard heading matches). E2E runs serially (`workers: 1`, one webpack dev server) with generous `timeout`/`expect` timeouts because first navigation to a route triggers a cold webpack compile. Currently: tours smoke (auto-start, dismissal, replay), frontend, operations, reservations, supply boxes, OPORD recall, session banner, tech tree. Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
|
||||||
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
||||||
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
||||||
|
|
||||||
|
|
@ -303,6 +303,18 @@ Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full
|
||||||
- **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons).
|
- **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons).
|
||||||
- To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`.
|
- To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`.
|
||||||
|
|
||||||
|
## Tech Tree (admin)
|
||||||
|
|
||||||
|
`/admin/tech-tree` — visual tech tree editor over `technologies`, built on `@xyflow/react` v12 following the narrative-flow patterns. Custom admin view (root view) + a "Tech Tree" nav entry injected under the Intelligence group; the Technologies list/edit views are untouched.
|
||||||
|
|
||||||
|
- **Schema**: `tech-categories` collection (`src/collections/intelligence/TechCategories.ts`: `name` unique, `position` number = timeline order, optional hex `color`) + `Technologies.category` relationship (FK `ON DELETE set null`, so deleting a category just unfiles its techs) + `Technologies.treePosition` group (`x`/`y` numbers; manual canvas position from dragging, empty = automatic). Permissions mirror technologies (`tech-categories:*` + `admin:tech-categories:manage`, registered in `src/permissions/index.ts`); `tech-categories` is in `DIVISION_ADMIN_QUALIFICATIONS`, so Intelligence division members get admin-panel access exactly like `technologies`. Migrations: `20260930_230747_add_tech_categories`, `20261001_000320_add_tech_tree_position`.
|
||||||
|
- **View wiring**: `payload.config.ts` `admin.components.views.techTree` (path `/tech-tree`, component `src/components/admin/tech-tree/TechTreeView.tsx`) and `admin.components.Nav` → `TechTreeNav.tsx`. Custom root views are public by default, so the view gates itself (logged-in + `technologies:read` OR Intelligence qualification). The nav replica injects the entry into the group containing `technologies` and reuses public exports (`NavWrapper`/`NavHamburger`/`DefaultNavClient` from `@payloadcms/next/client`, `groupNavItems` from `@payloadcms/ui/shared`); registered admin components must be **default exports** (importMap convention in this repo).
|
||||||
|
- **Layout** (`layout.ts`, pure): a **propagating left-to-right tree**. A technology WITH prerequisites is placed one column to the right of its parents, at their average row, so linking A -> B drops B immediately beside A and B's dependents continue further right; nothing is teleported to a distant column when its depth changes. Technologies in a category stay pinned to that category's column (the category contract); uncategorized roots fill a catalog grid whose slots come from each tech's rank among ALL uncategorized technologies (`MAX_ROWS_PER_COLUMN` = 10 rows, spilling into more columns) so promoting a tech into the tree leaves an empty slot rather than re-shuffling the grid; uncategorized dependents follow their parents inside the uncategorized zone. Collisions search rows outward from the desired row (dependents may also grow upward, and their columns are not height-capped, so a link from a full column still lands beside its parent rather than far right). Category bands are the bounding box of their members' final positions. Prerequisites placed to the RIGHT of their dependent surface as amber warnings (toolbar chip + node icon + preview panel section).
|
||||||
|
- **Edges**: AND prerequisites solid slate, OR prerequisites dashed cyan with an OR label. Connections are editable on the canvas: drag from a node's right handle onto another node (the target card OR its handle) to add it as an AND prerequisite; the drop-on-card case is resolved by an `onConnectEnd` fallback because React Flow only resolves drops onto a handle. Select an edge and press Backspace to remove the prerequisite. Duplicates, self-references, and cycles (direct or transitive) are rejected by the pure `prereqEdits.ts` helpers with an inline error in the toolbar.
|
||||||
|
- **Interactions**: nodes are draggable; dropping one PATCHes its `treePosition` (persisted, survives reload) and the toolbar shows an "Auto-arrange" button (technologies:update gated) that clears every manual position and restores the ordered layout. A **Save positions** button (technologies:update gated), next to Auto-arrange, commits the current on-screen position of every node to `treePosition` in one pass (matching what React Flow rendered, not just dragged/pinned cards) so the exact arrangement survives a reload; Auto-arrange then clears them. **Connecting does not move anything**: if the ordered layout would relocate the connected technology, the canvas pins it to its current spot (`treePosition` written in the same PATCH as the prerequisite) so the edge appears without the card jumping; the catalog grid also uses stable slots (each tech's rank among all uncategorized technologies, so a tech promoted into the tree leaves its slot empty instead of re-shuffling every other root). Pressing "Auto-arrange" clears those pins and moves everything, including the connected node, to its ordered position. Node click opens the side panel, which is a **lite editor** over the fields it shows (name, type, approval status, summary, category with inline create, prerequisites with AND/OR grouping and add/remove, research costs with resource rows and min/max durations): dirty tracking, Save/Reset, and an "Edit in full view" jump to the standard admin edit route. Final approval states stay superuser-only (`canSetFinalApproval` = `system:admin-access`), mirroring the field access. Quick-create dialog (name, type, summary, category, research durations) creates via REST; the category picker is a native input + suggestion list (existing values selectable, new values creatable inline) because Radix Popover inside the admin Dialog proved layer-unstable. New categories get `position = max + 1`. The canvas uses React Flow's `<Background variant="lines">` so the grid scales with zoom (a CSS-background grid does not, and looked broken at low zoom), and `fitView` is capped at `maxZoom: 0.85`. Panel and toolbar controls carry dark tactical chrome (the admin panel's light shadcn defaults would render as white slabs on the dark surface; bare `<button>` elements in the admin additionally inherit a global light background AND border, so list rows need explicit `bg-transparent` + `border-0`), and the panel's dropdowns use the in-house `PanelSelect` listbox rather than native `<select>`: a native popup is browser/OS chrome that ignores dark styling (only `color-scheme` influences it) and cannot be screenshotted, whereas `PanelSelect` renders its list through a portal as a fixed-position overlay above the panel (so it never pushes the panel's content down), flips upward when there is more room above, closes on scroll/resize so it cannot detach from its trigger, and offers a search field for long lists. Both it and the panel's category picker use a mono header strip, hairline row separators, a cyan left accent on the selected row, and the `.tech-tree-scroll` dark scrollbar rules from `custom.scss`.
|
||||||
|
- **Styling**: tactical dossier (dark `#05070a` canvas + 28px grid via inline styles, squared corners, classification strips, mono readouts); shadcn form controls in the dialog stay conventional. React Flow controls/attribution are darkened via `.tech-tree-canvas` rules in `src/app/(payload)/custom.scss`.
|
||||||
|
- **Gotchas**: React Flow in controlled mode needs `useNodesState` AND `useEdgesState` with change handlers that forward everything except `remove` (a no-op edge handler silently breaks edge selection, and a passed-through remove lets Backspace delete nodes that only exist in the data layer); category band nodes must be `pointerEvents: "none"` or they swallow edge/pane clicks; node handles must live OUTSIDE the card's `overflow-hidden` box or they become un-draggable; a second `next dev` for the same directory is impossible (Next 16 lock) so e2e verification either frees port 3000 or uses a copied repo (whose file watcher is unreliable: restart the copied server after every source change). technologies create requires `approvalStatus` + `researchCosts.minimumResearchDuration` (the field's `defaultValue: 0` violates its own `min: 1`, so creates must pass it explicitly); clearing `treePosition` means sending `{ x: null, y: null }` (a group cannot be nulled wholesale). The e2e seeds via cookie-authed REST (`page.request`) after frontend login, so it MUST run against the test DB: a dev server left on port 3000 is silently reused (`reuseExistingServer: true`) and its DEV database 403s every seed (the dev user has no create permission). Free the port first. Tests: `tests/int/tech-tree-layout.int.spec.ts` (pure layout: wrapping, overrides, band bboxes, warnings, cycles), `tests/int/tech-tree-prereq-edits.int.spec.ts` (pure add/remove/switch with cycle rejection), `tests/int/tech-categories.int.spec.ts` (CRUD, RBAC, division gating, FK set-null, position round-trip) + `tests/e2e/tech-tree.e2e.spec.ts` (nav, render, panel, edit nav, both quick-create paths, drag persistence + Auto-arrange, save positions + reload survival, connect/disconnect + lite-editor saves, wrapping at scale). Vivaldi crashes under automation in this suite; run with `E2E_BROWSER_PATH=<playwright chromium>`.
|
||||||
|
|
||||||
## Wiki
|
## Wiki
|
||||||
|
|
||||||
`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**.
|
`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**.
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -15,6 +15,7 @@
|
||||||
"@lexical/react": "0.41.0",
|
"@lexical/react": "0.41.0",
|
||||||
"@lexical/rich-text": "0.41.0",
|
"@lexical/rich-text": "0.41.0",
|
||||||
"@lexical/selection": "0.41.0",
|
"@lexical/selection": "0.41.0",
|
||||||
|
"@microsoft/clarity": "^1.0.2",
|
||||||
"@payloadcms/db-postgres": "3.88.0",
|
"@payloadcms/db-postgres": "3.88.0",
|
||||||
"@payloadcms/email-nodemailer": "3.88.0",
|
"@payloadcms/email-nodemailer": "3.88.0",
|
||||||
"@payloadcms/next": "3.88.0",
|
"@payloadcms/next": "3.88.0",
|
||||||
|
|
@ -541,6 +542,8 @@
|
||||||
|
|
||||||
"@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="],
|
"@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="],
|
||||||
|
|
||||||
|
"@microsoft/clarity": ["@microsoft/clarity@1.0.2", "", {}, "sha512-9EZYROFpJxEGmQpHvUFqvD3ZJ7QQSqnibYSWmS+1xusoZfG1QQ1/Al9yVBBc11DWMbJrs1pe1hLT273it/skJg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="],
|
||||||
|
|
||||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.6.1", "", { "dependencies": { "state-local": "1.0.7" } }, "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg=="],
|
"@monaco-editor/loader": ["@monaco-editor/loader@1.6.1", "", { "dependencies": { "state-local": "1.0.7" } }, "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg=="],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.58",
|
"version": "0.2.59",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"@lexical/react": "0.41.0",
|
"@lexical/react": "0.41.0",
|
||||||
"@lexical/rich-text": "0.41.0",
|
"@lexical/rich-text": "0.41.0",
|
||||||
"@lexical/selection": "0.41.0",
|
"@lexical/selection": "0.41.0",
|
||||||
|
"@microsoft/clarity": "^1.0.2",
|
||||||
"@payloadcms/db-postgres": "3.88.0",
|
"@payloadcms/db-postgres": "3.88.0",
|
||||||
"@payloadcms/email-nodemailer": "3.88.0",
|
"@payloadcms/email-nodemailer": "3.88.0",
|
||||||
"@payloadcms/next": "3.88.0",
|
"@payloadcms/next": "3.88.0",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { AnnouncementHost } from "@/components/frontend/announcements/Announceme
|
||||||
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
|
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
|
||||||
import { TourHost } from "@/components/frontend/tours/TourHost";
|
import { TourHost } from "@/components/frontend/tours/TourHost";
|
||||||
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
||||||
|
import { ClarityAnalytics } from "@/components/frontend/analytics/ClarityAnalytics";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
// The layout calls `getPayload()` at render time to resolve the current user,
|
||||||
|
|
@ -242,6 +243,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
||||||
|
<ClarityAnalytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,14 @@ import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/component
|
||||||
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
||||||
|
import { default as default_a6bf792526e9f56202161f190c57e1c6 } from '@/components/admin/tech-tree/TechTreeNav'
|
||||||
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
|
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
|
||||||
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
|
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
|
||||||
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
|
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
|
||||||
import { default as default_05548cb43495e62d80b80ec4d9f0f7e9 } from '@/components/static/BeforeDashboardText'
|
import { default as default_05548cb43495e62d80b80ec4d9f0f7e9 } from '@/components/static/BeforeDashboardText'
|
||||||
import { default as default_459d6b6f5ead73c736013da63a5ace3f } from '@/components/static/GoToFrontendLink'
|
import { default as default_459d6b6f5ead73c736013da63a5ace3f } from '@/components/static/GoToFrontendLink'
|
||||||
import { InstructionsProvider as InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { InstructionsProvider as InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
|
import { default as default_2b34d0df0cf91f4442ca2cd62ae7cd94 } from '@/components/admin/tech-tree/TechTreeView'
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
/** @type import('payload').ImportMap */
|
/** @type import('payload').ImportMap */
|
||||||
|
|
@ -84,11 +86,13 @@ export const importMap = {
|
||||||
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
||||||
|
"@/components/admin/tech-tree/TechTreeNav#default": default_a6bf792526e9f56202161f190c57e1c6,
|
||||||
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
|
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
|
||||||
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
|
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
|
||||||
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
|
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,
|
||||||
"@/components/static/BeforeDashboardText#default": default_05548cb43495e62d80b80ec4d9f0f7e9,
|
"@/components/static/BeforeDashboardText#default": default_05548cb43495e62d80b80ec4d9f0f7e9,
|
||||||
"@/components/static/GoToFrontendLink#default": default_459d6b6f5ead73c736013da63a5ace3f,
|
"@/components/static/GoToFrontendLink#default": default_459d6b6f5ead73c736013da63a5ace3f,
|
||||||
"@ai-stack/payloadcms/client#InstructionsProvider": InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#InstructionsProvider": InstructionsProvider_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
|
"@/components/admin/tech-tree/TechTreeView#default": default_2b34d0df0cf91f4442ca2cd62ae7cd94,
|
||||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,43 @@
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@import 'tailwindcss/utilities.css';
|
@import 'tailwindcss/utilities.css';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tech Tree canvas: dark tactical chrome for React Flow controls. */
|
||||||
|
.tech-tree-canvas .react-flow__controls {
|
||||||
|
button {
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 0;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
svg { fill: currentColor; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tech-tree-canvas .react-flow__attribution {
|
||||||
|
background: transparent;
|
||||||
|
a { color: rgba(255, 255, 255, 0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tech Tree: dark thin scrollbars for the panel and its dropdowns. */
|
||||||
|
.tech-tree-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
30
src/app/api/arma/v1/balances/route.ts
Normal file
30
src/app/api/arma/v1/balances/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||||
|
import { getBalancesBySteamId } from "@/lib/arma-bridge/money";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Arma 3 bridge (get_balances) when a player connects and on a
|
||||||
|
* ~60 s poll for online players. Returns personal bank account balances for
|
||||||
|
* the requested Steam IDs; unlinked Steam IDs are omitted so the game keeps
|
||||||
|
* its own stored balance for them.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const auth = await requireArmaServer(req);
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
const { payload } = auth;
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => null)) as { steamIds?: unknown } | null;
|
||||||
|
const steamIds = body?.steamIds;
|
||||||
|
if (!Array.isArray(steamIds) || steamIds.length > 200 || steamIds.some((id) => typeof id !== "string")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "steamIds must be an array of at most 200 strings." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await getBalancesBySteamId(payload, steamIds as string[]);
|
||||||
|
return NextResponse.json({ ok: true, balances });
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import "@/app/(frontend)/styles.css";
|
import "@/app/(frontend)/styles.css";
|
||||||
|
import { ClarityAnalytics } from "@/components/frontend/analytics/ClarityAnalytics";
|
||||||
|
|
||||||
export default function LoginLayout(props: { children: React.ReactNode }) {
|
export default function LoginLayout(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="bg-black leading-8 antialiased h-full dark">
|
<html lang="en" className="bg-black leading-8 antialiased h-full dark">
|
||||||
<body className="h-full font-sans m-0 text-white">{props.children}</body>
|
<body className="h-full font-sans m-0 text-white">
|
||||||
|
{props.children}
|
||||||
|
<ClarityAnalytics />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/collections/intelligence/TechCategories.ts
Normal file
58
src/collections/intelligence/TechCategories.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
import { requireIntelligencePermission } from "@/utils/access-control/divisionAccess";
|
||||||
|
|
||||||
|
/** Validates an optional hex color like the map feature styling (`src/lib/map/style.ts`). */
|
||||||
|
function validateHexColor(value: unknown): true | string {
|
||||||
|
if (!value) return true;
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!str) return true;
|
||||||
|
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(str)
|
||||||
|
? true
|
||||||
|
: "Enter a hex color like #22d3ee, or leave empty for the default.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TechCategories: CollectionConfig = {
|
||||||
|
slug: "tech-categories",
|
||||||
|
admin: {
|
||||||
|
group: "Intelligence",
|
||||||
|
useAsTitle: "name",
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
create: requireIntelligencePermission("tech-categories:create"),
|
||||||
|
update: requireIntelligencePermission("tech-categories:update"),
|
||||||
|
delete: requireIntelligencePermission("tech-categories:delete"),
|
||||||
|
read: requireIntelligencePermission("tech-categories:read"),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Category label shown as a column band on the Tech Tree (e.g. Tier 1, Tier 2).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "position",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
defaultValue: 1,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Timeline position: lower numbers sit further left on the Tech Tree. Categories with the same position are ordered alphabetically.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "color",
|
||||||
|
type: "text",
|
||||||
|
validate: validateHexColor,
|
||||||
|
admin: {
|
||||||
|
description: "Optional hex accent color for the band on the Tech Tree (e.g. #22d3ee).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -26,6 +26,34 @@ export const Technologies: CollectionConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "category",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "tech-categories",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Tech Tree column this technology belongs to. Uncategorized technologies are auto-placed on the Tech Tree by prerequisite depth.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "treePosition",
|
||||||
|
type: "group",
|
||||||
|
label: "Tech Tree Position",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Manual position on the Tech Tree canvas (set by dragging a node). Leave empty to use the automatic layout.",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "x",
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "y",
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "approvalStatus",
|
name: "approvalStatus",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
||||||
import { processBoxEvent } from "@/lib/arma-bridge/crates";
|
import { processBoxEvent } from "@/lib/arma-bridge/crates";
|
||||||
|
import { processMoneyEvent } from "@/lib/arma-bridge/money";
|
||||||
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
||||||
import { processOperationEvent } from "@/lib/operations/process";
|
import { processOperationEvent } from "@/lib/operations/process";
|
||||||
|
|
||||||
|
|
@ -33,6 +34,12 @@ export const ArmaSyncEvents: CollectionConfig = {
|
||||||
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
|
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (doc.type === "money.delta") {
|
||||||
|
// Awaited so that by the time the game's flush returns, the bank
|
||||||
|
// balance already reflects it (the game pulls balances right after
|
||||||
|
// flushing). processMoneyEvent never throws.
|
||||||
|
await processMoneyEvent(req.payload, doc);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
61
src/components/admin/tech-tree/CategoryBand.tsx
Normal file
61
src/components/admin/tech-tree/CategoryBand.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { CategoryBandLayout } from "./types";
|
||||||
|
|
||||||
|
export type CategoryBandData = {
|
||||||
|
band: CategoryBandLayout;
|
||||||
|
techCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryBandNode = Node<CategoryBandData, "techBand">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height column band behind a category's technologies. Non-interactive:
|
||||||
|
* it is a backdrop, not a node (no handles, no dragging, no selection).
|
||||||
|
*/
|
||||||
|
export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
|
||||||
|
const { band, techCount } = data;
|
||||||
|
const accent = band.color ?? "#22d3ee";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none flex h-full flex-col border bg-black/40"
|
||||||
|
style={{
|
||||||
|
borderColor: `${accent}2e`,
|
||||||
|
boxShadow: "0 0 24px rgba(0,0,0,0.35)",
|
||||||
|
}}
|
||||||
|
data-testid={`tech-band-${band.key}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b bg-black/50 px-3 py-2"
|
||||||
|
style={{ borderColor: `${accent}33` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] tracking-widest uppercase"
|
||||||
|
style={{ color: `${accent}cc` }}
|
||||||
|
>
|
||||||
|
{band.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/40 uppercase">
|
||||||
|
{band.position !== null ? `POS ${band.position}` : "AUTO"} · {techCount} TECH
|
||||||
|
{techCount === 1 ? "" : "S"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-px w-full"
|
||||||
|
style={{ background: `linear-gradient(90deg, transparent, ${accent}59, transparent)` }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="border-t border-white/10 bg-black/40 px-3 py-1">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
|
||||||
|
TIER BAND // {band.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Inert handles so React Flow never treats the band as connectable */}
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!hidden" isConnectable={false} />
|
||||||
|
<Handle type="target" position={Position.Top} className="!hidden" isConnectable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/admin/tech-tree/CategoryPicker.tsx
Normal file
170
src/components/admin/tech-tree/CategoryPicker.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Check, PlusIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { TreeCategory } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category picker that both selects existing values and creates a new one
|
||||||
|
* inline (enter a name that does not exist yet, then pick the Create entry).
|
||||||
|
* Deliberately a native input + suggestion list instead of a
|
||||||
|
* Popover/Command combo: the admin dialog's Radix layering closed the
|
||||||
|
* portal'd list mid-flow, making options unclickable.
|
||||||
|
*/
|
||||||
|
export function CategoryPicker({
|
||||||
|
categories,
|
||||||
|
canCreateCategories,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
testIdPrefix = "tech-category",
|
||||||
|
tone = "light",
|
||||||
|
}: {
|
||||||
|
categories: TreeCategory[];
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (name: string | null, isNew: boolean) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
/** "dark" matches the tactical panel surface; "light" matches shadcn dialogs. */
|
||||||
|
tone?: "light" | "dark";
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dark = tone === "dark";
|
||||||
|
|
||||||
|
const trimmed = search.trim();
|
||||||
|
const exactMatch = categories.some(
|
||||||
|
(c) => c.name.toLowerCase() === trimmed.toLowerCase(),
|
||||||
|
);
|
||||||
|
const showCreate = canCreateCategories && trimmed.length > 0 && !exactMatch;
|
||||||
|
const matches = trimmed
|
||||||
|
? categories.filter((c) => c.name.toLowerCase().includes(trimmed.toLowerCase()))
|
||||||
|
: categories;
|
||||||
|
const selected = value ? categories.find((c) => c.name === value) : undefined;
|
||||||
|
|
||||||
|
const commit = (name: string, isNew: boolean) => {
|
||||||
|
onChange(name, isNew);
|
||||||
|
setSearch("");
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickNew = () => commit(trimmed, true);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (showCreate) pickNew();
|
||||||
|
else if (matches.length === 1) commit(matches[0].name, false);
|
||||||
|
else if (exactMatch) {
|
||||||
|
const match = categories.find((c) => c.name.toLowerCase() === trimmed.toLowerCase())!;
|
||||||
|
commit(match.name, false);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={open ? search : value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setSearch("");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => setOpen(false), 120);
|
||||||
|
}}
|
||||||
|
placeholder="Select or enter category..."
|
||||||
|
data-testid={`${testIdPrefix}-input`}
|
||||||
|
autoComplete="off"
|
||||||
|
className={
|
||||||
|
dark
|
||||||
|
? "rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{value && !open ? (
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-xs ${dark ? "text-white/45" : "text-muted-foreground"}`}
|
||||||
|
data-testid={`${testIdPrefix}-value`}
|
||||||
|
>
|
||||||
|
{selected ? `${selected.name} (position ${selected.position})` : `${value} (new)`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className={`absolute top-full left-0 z-[1500] mt-1 w-full border shadow-2xl ${
|
||||||
|
dark ? "rounded-none border-white/20 bg-[#0b0f14]" : "bg-popover rounded-md"
|
||||||
|
}`}
|
||||||
|
data-testid={`${testIdPrefix}-options`}
|
||||||
|
>
|
||||||
|
<div className="tech-tree-scroll max-h-48 overflow-y-auto p-1">
|
||||||
|
{showCreate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 bg-transparent px-2 py-1.5 text-left text-sm outline-none ${
|
||||||
|
dark
|
||||||
|
? "rounded-none border-0 border-b border-b-white/[0.06] text-white/85 hover:bg-white/[0.06]"
|
||||||
|
: "rounded-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pickNew();
|
||||||
|
}}
|
||||||
|
data-testid={`${testIdPrefix}-create-option`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" aria-hidden />
|
||||||
|
<span>
|
||||||
|
Create "{trimmed}"
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{matches.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm outline-none ${
|
||||||
|
dark
|
||||||
|
? `rounded-none border-0 border-b border-b-white/[0.06] border-l-2 ${
|
||||||
|
value === category.name
|
||||||
|
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
|
||||||
|
: "border-l-transparent bg-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
|
||||||
|
}`
|
||||||
|
: "rounded-sm bg-transparent hover:bg-accent hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(category.name, false);
|
||||||
|
}}
|
||||||
|
data-testid={`${testIdPrefix}-option-${category.id}`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`size-4 shrink-0 ${value === category.name ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{category.name}</span>
|
||||||
|
<span className={`block truncate ${dark ? "font-mono text-[10px] tracking-wide text-white/40 uppercase" : "text-xs text-muted-foreground"}`}>
|
||||||
|
Position {category.position}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{matches.length === 0 && !showCreate ? (
|
||||||
|
<p className={`px-2 py-1.5 ${dark ? "font-mono text-xs tracking-wide text-white/40 uppercase" : "text-sm text-muted-foreground"}`}>
|
||||||
|
{trimmed.length > 0 ? `No category matches "${trimmed}".` : "No categories yet."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/components/admin/tech-tree/PanelSelect.tsx
Normal file
243
src/components/admin/tech-tree/PanelSelect.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export type PanelSelectOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PanelSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: PanelSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
testId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Show a search field when the option list is long. */
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Placement = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
maxHeight: number;
|
||||||
|
openUp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_MAX_HEIGHT = 280;
|
||||||
|
const MIN_LIST_HEIGHT = 140;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dark listbox for the tech tree panel. Deliberately NOT a native <select>:
|
||||||
|
* its popup is browser/OS chrome, ignores the panel's dark styling, and cannot
|
||||||
|
* be themed reliably.
|
||||||
|
*
|
||||||
|
* The option list is rendered through a portal as a `position: fixed` overlay
|
||||||
|
* (above the panel, so it never pushes the panel's content down) with its own
|
||||||
|
* coordinates taken from the trigger. It flips above the trigger when there is
|
||||||
|
* more room up there, and closes on scroll/resize so it can never detach from
|
||||||
|
* its trigger.
|
||||||
|
*/
|
||||||
|
export function PanelSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Select...",
|
||||||
|
testId,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
searchable = false,
|
||||||
|
}: PanelSelectProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [placement, setPlacement] = React.useState<Placement | null>(null);
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const measure = React.useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||||
|
const spaceAbove = rect.top - 8;
|
||||||
|
const openUp = spaceBelow < LIST_MAX_HEIGHT && spaceAbove > spaceBelow;
|
||||||
|
const available = openUp ? spaceAbove : spaceBelow;
|
||||||
|
setPlacement({
|
||||||
|
left: rect.left,
|
||||||
|
top: openUp ? rect.top - 4 : rect.bottom + 4,
|
||||||
|
width: rect.width,
|
||||||
|
maxHeight: Math.max(MIN_LIST_HEIGHT, Math.min(LIST_MAX_HEIGHT, available)),
|
||||||
|
openUp,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openList = () => {
|
||||||
|
measure();
|
||||||
|
setSearch("");
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rafRef = React.useRef<number>(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the overlay glued to its trigger. Scrolling INSIDE the option list is
|
||||||
|
* left alone; any other scroll (the panel body, the window) re-measures the
|
||||||
|
* trigger and repositions, closing only when the trigger has left the
|
||||||
|
* viewport. Closing on every scroll made the list vanish when people simply
|
||||||
|
* scrolled the list itself or the panel behind a short list.
|
||||||
|
*/
|
||||||
|
const handleViewportChange = React.useCallback(
|
||||||
|
(event?: Event) => {
|
||||||
|
const target = event?.target as Node | undefined;
|
||||||
|
if (target && listRef.current?.contains(target)) return;
|
||||||
|
if (rafRef.current) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = 0;
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[measure],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocumentDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (triggerRef.current?.contains(target)) return;
|
||||||
|
if (listRef.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocumentDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
// Capture phase catches scrolling inside the panel's own scroll container.
|
||||||
|
document.addEventListener("scroll", handleViewportChange, true);
|
||||||
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocumentDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("scroll", handleViewportChange, true);
|
||||||
|
window.removeEventListener("resize", handleViewportChange);
|
||||||
|
};
|
||||||
|
}, [open, handleViewportChange]);
|
||||||
|
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
const showSearch = searchable && options.length > 8;
|
||||||
|
const filtered = search
|
||||||
|
? options.filter((option) => option.label.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const list =
|
||||||
|
open && placement ? (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
data-testid={testId ? `${testId}-list` : undefined}
|
||||||
|
className="tech-tree-scroll rounded-none border border-white/15 bg-[#0b0f14] shadow-2xl"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: placement.left,
|
||||||
|
top: placement.top,
|
||||||
|
width: placement.width,
|
||||||
|
zIndex: 100,
|
||||||
|
transform: placement.openUp ? "translateY(-100%)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 bg-black/50 px-2.5 py-1">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/45 uppercase">
|
||||||
|
SELECT // {placeholder.replace(/\.\.\.$/, "")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/30 uppercase tabular-nums">
|
||||||
|
{filtered.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSearch ? (
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="SEARCH..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 border-b border-white/10 bg-black/50 px-2.5 py-1.5 font-mono text-xs tracking-wide text-white outline-none placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="tech-tree-scroll overflow-y-auto py-1"
|
||||||
|
style={{ maxHeight: placement.maxHeight - (showSearch ? 36 : 0) }}
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-2.5 py-1.5 font-mono text-xs tracking-wide text-white/40 uppercase">
|
||||||
|
NO MATCHES
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{filtered.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
data-testid={testId ? `${testId}-option-${option.value}` : undefined}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 border-0 border-b border-l-2 border-b-white/[0.06] bg-transparent px-2.5 py-1.5 text-left text-[14px] outline-none ${
|
||||||
|
option.value === value
|
||||||
|
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
|
||||||
|
: "border-l-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`size-4 shrink-0 ${option.value === value ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
{option.hint ? (
|
||||||
|
<span className="block truncate font-mono text-[10px] tracking-wide text-white/40 uppercase">{option.hint}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={() => (open ? setOpen(false) : openList())}
|
||||||
|
className="flex h-9 w-full items-center justify-between gap-2 rounded-none border border-white/20 bg-black/40 px-2.5 py-1.5 text-left text-[15px] text-white shadow-xs outline-none hover:border-white/35 focus-visible:border-[#22d3ee]/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className={`truncate ${selected ? "" : "text-white/45"}`}>
|
||||||
|
{selected ? selected.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{typeof document !== "undefined" && list ? createPortal(list, document.body) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/components/admin/tech-tree/QuickCreateDialog.tsx
Normal file
239
src/components/admin/tech-tree/QuickCreateDialog.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
import type { TreeCategory } from "./types";
|
||||||
|
|
||||||
|
export interface QuickCreateResult {
|
||||||
|
techId: number;
|
||||||
|
techName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickCreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
categories: TreeCategory[];
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
onCreated: (result: QuickCreateResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas quick-create: minimal fields (name + category) plus the collection's
|
||||||
|
* other required fields, creating inline categories as needed.
|
||||||
|
*/
|
||||||
|
export function QuickCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
categories,
|
||||||
|
canCreateCategories,
|
||||||
|
onCreated,
|
||||||
|
}: QuickCreateDialogProps) {
|
||||||
|
const [name, setName] = React.useState("");
|
||||||
|
const [type, setType] = React.useState<"asset" | "upgrade">("upgrade");
|
||||||
|
const [summary, setSummary] = React.useState("");
|
||||||
|
const [minDuration, setMinDuration] = React.useState(1);
|
||||||
|
const [maxDuration, setMaxDuration] = React.useState(10);
|
||||||
|
const [categoryName, setCategoryName] = React.useState<string | null>(null);
|
||||||
|
const [categoryIsNew, setCategoryIsNew] = React.useState(false);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName("");
|
||||||
|
setType("upgrade");
|
||||||
|
setSummary("");
|
||||||
|
setMinDuration(1);
|
||||||
|
setMaxDuration(10);
|
||||||
|
setCategoryName(null);
|
||||||
|
setCategoryIsNew(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!summary.trim()) {
|
||||||
|
setError("Summary is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minDuration) || minDuration < 1 || !Number.isFinite(maxDuration) || maxDuration < 1) {
|
||||||
|
setError("Research durations must be at least 1 tick.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
let categoryId: number | null = null;
|
||||||
|
if (categoryName && categoryIsNew) {
|
||||||
|
const res = await fetch("/api/tech-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: categoryName.trim(), position: maxPosition + 1 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(
|
||||||
|
body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
categoryId = body?.doc?.id ?? null;
|
||||||
|
} else if (categoryName) {
|
||||||
|
categoryId = categories.find((c) => c.name === categoryName)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/technologies", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
summary: summary.trim(),
|
||||||
|
category: categoryId,
|
||||||
|
researchCosts: {
|
||||||
|
minimumResearchDuration: Math.max(1, Math.round(minDuration)),
|
||||||
|
maximumResearchDuration: Math.max(1, Math.round(maxDuration)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(
|
||||||
|
body?.errors?.[0]?.message || `Could not create technology (HTTP ${res.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
const techId = body?.doc?.id;
|
||||||
|
if (!techId) throw new Error("Technology was created but no id came back.");
|
||||||
|
onCreated({ techId, techName: name.trim() });
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unknown error.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="rounded-none sm:max-w-[440px]" data-testid="tech-tree-create-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-mono text-xs tracking-widest text-white/70 uppercase">
|
||||||
|
New Technology // Quick Create
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Composite Armor"
|
||||||
|
data-testid="tech-create-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="tech-create-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as "asset" | "upgrade")}
|
||||||
|
data-testid="tech-create-type"
|
||||||
|
className="border-input bg-transparent flex h-9 w-full rounded-md border px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option value="upgrade">Upgrade</option>
|
||||||
|
<option value="asset">Asset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-summary">Summary</Label>
|
||||||
|
<Textarea
|
||||||
|
id="tech-create-summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
placeholder="One or two sentences."
|
||||||
|
rows={3}
|
||||||
|
data-testid="tech-create-summary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<CategoryPicker
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={canCreateCategories}
|
||||||
|
value={categoryName}
|
||||||
|
onChange={(nextName, isNew) => {
|
||||||
|
setCategoryName(nextName);
|
||||||
|
setCategoryIsNew(isNew);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!canCreateCategories ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
You can select existing categories; creating new ones needs the
|
||||||
|
tech-categories:create permission.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-min-duration">Min research (ticks)</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-min-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={minDuration}
|
||||||
|
onChange={(e) => setMinDuration(Number(e.target.value))}
|
||||||
|
data-testid="tech-create-min-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-max-duration">Max research (ticks)</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-max-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={maxDuration}
|
||||||
|
onChange={(e) => setMaxDuration(Number(e.target.value))}
|
||||||
|
data-testid="tech-create-max-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="border border-red-400/40 bg-red-400/10 px-2 py-1.5 text-xs text-red-300" data-testid="tech-create-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy} data-testid="tech-create-submit">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{busy ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/admin/tech-tree/TechNode.tsx
Normal file
128
src/components/admin/tech-tree/TechNode.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
import { TriangleAlertIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { TreeTech } from "./types";
|
||||||
|
|
||||||
|
export type TechNodeData = {
|
||||||
|
tech: TreeTech;
|
||||||
|
categoryName: string | null;
|
||||||
|
refLabel: string;
|
||||||
|
hasWarning: boolean;
|
||||||
|
connectable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechNodeModel = Node<TechNodeData, "techCard">;
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
in_progress: { label: "In Progress", className: "text-blue-400" },
|
||||||
|
ready_for_review: { label: "Ready for Review", className: "text-amber-400" },
|
||||||
|
approved: { label: "Approved", className: "text-emerald-400" },
|
||||||
|
rejected: { label: "Rejected", className: "text-red-400" },
|
||||||
|
revision_requested: { label: "Revision Requested", className: "text-orange-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function CornerBrackets() {
|
||||||
|
const base = "pointer-events-none absolute size-2 border-white/25";
|
||||||
|
return (
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-1">
|
||||||
|
<span className={`${base} top-0 left-0 border-t border-l`} />
|
||||||
|
<span className={`${base} top-0 right-0 border-t border-r`} />
|
||||||
|
<span className={`${base} bottom-0 left-0 border-b border-l`} />
|
||||||
|
<span className={`${base} right-0 bottom-0 border-r border-b`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technology card node: tactical dossier styling (squared corners,
|
||||||
|
* classification strip, corner brackets, mono readouts).
|
||||||
|
*/
|
||||||
|
export function TechNodeCard({ data, selected }: NodeProps<TechNodeModel>) {
|
||||||
|
const { tech, categoryName, refLabel, hasWarning, connectable } = data;
|
||||||
|
const status = STATUS_LABELS[tech.approvalStatus] ?? {
|
||||||
|
label: tech.approvalStatus,
|
||||||
|
className: "text-white/60",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`tech-node-${tech.id}`}
|
||||||
|
className="relative h-full w-full"
|
||||||
|
>
|
||||||
|
{/* Clipped card surface (the handles live outside this box) */}
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full flex-col overflow-hidden rounded-none border bg-black/60 backdrop-blur-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-[#22d3ee]/70 shadow-[0_0_18px_rgba(34,211,238,0.15)]"
|
||||||
|
: "border-white/15 hover:border-white/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CornerBrackets />
|
||||||
|
|
||||||
|
{/* Classification strip */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-white/15 bg-black/50 px-3.5 py-1.5">
|
||||||
|
<span className="truncate font-mono text-[8px] tracking-widest text-white/50 uppercase">
|
||||||
|
TECH // {categoryName ?? "UNFILED"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[8px] tracking-widest text-white/40 uppercase">
|
||||||
|
{refLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 px-3.5 py-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="line-clamp-2 text-sm leading-snug font-semibold text-white">
|
||||||
|
{tech.name}
|
||||||
|
</span>
|
||||||
|
{hasWarning ? (
|
||||||
|
<TriangleAlertIcon
|
||||||
|
className="mt-0.5 size-3.5 shrink-0 text-amber-400"
|
||||||
|
aria-label="Timeline warning"
|
||||||
|
data-testid={`tech-warning-${tech.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="rounded-sm border border-white/15 px-1 py-px font-mono text-[8px] tracking-widest text-white/60 uppercase">
|
||||||
|
{tech.type}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[9px] tracking-widest uppercase ${status.className}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer readout */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-t border-white/15 bg-black/60 px-3.5 py-1.5">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/50 uppercase tabular-nums">
|
||||||
|
{tech.type === "asset" ? "UNLOCK" : "UPGRADE"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
|
||||||
|
{tech.categoryId ? "BAND" : "ZONE"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!bg-white/45 hover:!bg-[#22d3ee]"
|
||||||
|
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
|
||||||
|
isConnectable={connectable}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!bg-white/45 hover:!bg-[#22d3ee]"
|
||||||
|
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
|
||||||
|
isConnectable={connectable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
677
src/components/admin/tech-tree/TechPreviewPanel.tsx
Normal file
677
src/components/admin/tech-tree/TechPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PencilIcon, PlusIcon, RotateCcwIcon, SaveIcon, XIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
import { PanelSelect } from "./PanelSelect";
|
||||||
|
import { buildPrereqRows } from "./prereqEdits";
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
/** Dark tactical form chrome for the panel (it sits on a dark surface). */
|
||||||
|
const PANEL_CONTROL =
|
||||||
|
"rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20";
|
||||||
|
const PANEL_BUTTON =
|
||||||
|
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
|
||||||
|
const PANEL_BUTTON_ACCENT =
|
||||||
|
"rounded-none border border-[#22d3ee]/50 bg-[#22d3ee]/10 font-mono text-[10px] tracking-widest uppercase text-[#67e8f9] hover:border-[#22d3ee]/80 hover:bg-[#22d3ee]/20 hover:text-[#a5f3fc] disabled:opacity-40";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "in_progress", label: "In Progress" },
|
||||||
|
{ value: "ready_for_review", label: "Ready for Review" },
|
||||||
|
{ value: "approved", label: "Approved" },
|
||||||
|
{ value: "rejected", label: "Rejected" },
|
||||||
|
{ value: "revision_requested", label: "Revision Requested" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EDITABLE_STATUS_VALUES = new Set(["in_progress", "ready_for_review"]);
|
||||||
|
|
||||||
|
type CostRow = { resource: number | ""; amount: number };
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
name: string;
|
||||||
|
type: "asset" | "upgrade";
|
||||||
|
approvalStatus: string;
|
||||||
|
summary: string;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIsNew: boolean;
|
||||||
|
prereqAnd: number[];
|
||||||
|
prereqOr: number[];
|
||||||
|
costs: CostRow[];
|
||||||
|
minDuration: number;
|
||||||
|
maxDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftFromTech(tech: TreeTech, categoryName: string | null): Draft {
|
||||||
|
return {
|
||||||
|
name: tech.name,
|
||||||
|
type: tech.type,
|
||||||
|
approvalStatus: tech.approvalStatus,
|
||||||
|
summary: tech.summary,
|
||||||
|
categoryName,
|
||||||
|
categoryIsNew: false,
|
||||||
|
prereqAnd: [...tech.prereqAnd],
|
||||||
|
prereqOr: [...tech.prereqOr],
|
||||||
|
costs: tech.resourceCosts.map((c) => ({ resource: c.resource, amount: c.amount })),
|
||||||
|
minDuration: 1,
|
||||||
|
maxDuration: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechPreviewPanelProps {
|
||||||
|
tech: TreeTech;
|
||||||
|
categories: TreeCategory[];
|
||||||
|
resources: { id: number; name: string }[];
|
||||||
|
allTechs: TreeTech[];
|
||||||
|
canUpdate: boolean;
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
canSetFinalApproval: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSaved: (updated: TechTechLike) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by the REST update (only the fields the tree reads). */
|
||||||
|
export type TechTechLike = Partial<TreeTech> & { id: number };
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="mb-1.5 font-mono text-[10px] tracking-widest text-white/45 uppercase">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side dossier panel for the selected technology: a "lite" editor over the
|
||||||
|
* fields it displays (name, type, approval status, summary, category,
|
||||||
|
* prerequisites, research costs) plus a jump to the full admin edit view.
|
||||||
|
*/
|
||||||
|
export function TechPreviewPanel({
|
||||||
|
tech,
|
||||||
|
categories,
|
||||||
|
resources,
|
||||||
|
allTechs,
|
||||||
|
canUpdate,
|
||||||
|
canCreateCategories,
|
||||||
|
canSetFinalApproval,
|
||||||
|
warnings,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onSaved,
|
||||||
|
}: TechPreviewPanelProps) {
|
||||||
|
const categoryName =
|
||||||
|
tech.categoryId ? categories.find((c) => c.id === tech.categoryId)?.name ?? null : null;
|
||||||
|
|
||||||
|
const [draft, setDraft] = React.useState<Draft>(() => draftFromTech(tech, categoryName));
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
// Re-seed the draft when a different technology is selected.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDraft(draftFromTech(tech, categoryName));
|
||||||
|
setError(null);
|
||||||
|
setSavedAt(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tech.id]);
|
||||||
|
|
||||||
|
const baseline = React.useMemo(() => draftFromTech(tech, categoryName), [tech, categoryName]);
|
||||||
|
const dirty = JSON.stringify(draft) !== JSON.stringify(baseline);
|
||||||
|
|
||||||
|
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
|
||||||
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const nameOf = (id: number) => allTechs.find((t) => t.id === id)?.name ?? `#${id}`;
|
||||||
|
const resourceName = (id: number) => resources.find((r) => r.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
const statusLocked =
|
||||||
|
!canSetFinalApproval && !EDITABLE_STATUS_VALUES.has(draft.approvalStatus);
|
||||||
|
|
||||||
|
const addableTechs = React.useMemo(
|
||||||
|
() =>
|
||||||
|
allTechs
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.id !== tech.id &&
|
||||||
|
!draft.prereqAnd.includes(t.id) &&
|
||||||
|
!draft.prereqOr.includes(t.id),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[allTechs, tech.id, draft.prereqAnd, draft.prereqOr],
|
||||||
|
);
|
||||||
|
const [prereqToAdd, setPrereqToAdd] = React.useState<string>("");
|
||||||
|
const [prereqKind, setPrereqKind] = React.useState<"and" | "or">("and");
|
||||||
|
|
||||||
|
const movePrereq = (id: number, kind: "and" | "or") => {
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prereqAnd:
|
||||||
|
kind === "and"
|
||||||
|
? [...new Set([...prev.prereqAnd, id])]
|
||||||
|
: prev.prereqAnd.filter((x) => x !== id),
|
||||||
|
prereqOr:
|
||||||
|
kind === "or"
|
||||||
|
? [...new Set([...prev.prereqOr, id])]
|
||||||
|
: prev.prereqOr.filter((x) => x !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePrereq = (id: number) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prereqAnd: prev.prereqAnd.filter((x) => x !== id),
|
||||||
|
prereqOr: prev.prereqOr.filter((x) => x !== id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
setError("Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draft.summary.trim()) {
|
||||||
|
setError("Summary is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.minDuration < 1 || draft.maxDuration < 1) {
|
||||||
|
setError("Research durations must be at least 1 tick.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const costRows = draft.costs.filter((c) => c.resource !== "");
|
||||||
|
if (costRows.some((c) => !Number.isFinite(c.amount) || c.amount < 0)) {
|
||||||
|
setError("Resource amounts must be zero or more.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
let categoryId: number | null = null;
|
||||||
|
if (draft.categoryName && draft.categoryIsNew) {
|
||||||
|
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
|
||||||
|
const res = await fetch("/api/tech-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: draft.categoryName.trim(), position: maxPosition + 1 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
categoryId = (await res.json())?.doc?.id ?? null;
|
||||||
|
} else if (draft.categoryName) {
|
||||||
|
categoryId = categories.find((c) => c.name === draft.categoryName)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
type: draft.type,
|
||||||
|
summary: draft.summary.trim(),
|
||||||
|
category: categoryId,
|
||||||
|
requirements: {
|
||||||
|
prerequisiteTechnologies: buildPrereqRows({
|
||||||
|
and: draft.prereqAnd,
|
||||||
|
or: draft.prereqOr,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
researchCosts: {
|
||||||
|
resources: costRows.map((c) => ({ resource: c.resource, amount: c.amount })),
|
||||||
|
minimumResearchDuration: Math.max(1, Math.round(draft.minDuration)),
|
||||||
|
maximumResearchDuration: Math.max(1, Math.round(draft.maxDuration)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!statusLocked) data.approvalStatus = draft.approvalStatus;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/technologies/${tech.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
setSavedAt(Date.now());
|
||||||
|
onSaved({ ...tech, id: tech.id });
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unknown error.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
data-testid="tech-preview-panel"
|
||||||
|
className="absolute top-4 right-4 bottom-4 z-20 flex w-[400px] flex-col rounded-none border border-white/15 bg-black/75 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{/* Classification strip */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-white/15 bg-black/50 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
|
||||||
|
TECH DOSSIER // {canUpdate ? "EDIT" : "READ"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/40 uppercase">
|
||||||
|
REF {tech.id.toString().padStart(3, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex shrink-0 items-start justify-between gap-2 border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-lg leading-tight font-semibold text-white">
|
||||||
|
{draft.name || "Untitled"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 font-mono text-[10px] tracking-widest text-white/45 uppercase">
|
||||||
|
{draft.categoryName ? `CAT: ${draft.categoryName}` : "CAT: UNFILED"}
|
||||||
|
{dirty ? " // UNSAVED" : ""}
|
||||||
|
{savedAt && !dirty ? " // SAVED" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close preview"
|
||||||
|
data-testid="tech-preview-close"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="tech-tree-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4">
|
||||||
|
{canUpdate ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Name</SectionLabel>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
data-testid="tech-panel-name"
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Type</SectionLabel>
|
||||||
|
<PanelSelect
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(value) => set("type", value as "asset" | "upgrade")}
|
||||||
|
options={[
|
||||||
|
{ value: "upgrade", label: "Upgrade" },
|
||||||
|
{ value: "asset", label: "Asset" },
|
||||||
|
]}
|
||||||
|
testId="tech-panel-type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Approval Status</SectionLabel>
|
||||||
|
{statusLocked ? (
|
||||||
|
<p className="text-[15px] text-white/85" data-testid="tech-panel-status-locked">
|
||||||
|
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
|
||||||
|
draft.approvalStatus}
|
||||||
|
<span className="mt-1 block text-xs text-white/45">
|
||||||
|
Final states are set by superusers.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<PanelSelect
|
||||||
|
value={draft.approvalStatus}
|
||||||
|
onChange={(value) => set("approvalStatus", value)}
|
||||||
|
options={STATUS_OPTIONS.filter(
|
||||||
|
(o) => canSetFinalApproval || EDITABLE_STATUS_VALUES.has(o.value),
|
||||||
|
).map((o) => ({ value: o.value, label: o.label }))}
|
||||||
|
testId="tech-panel-status"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Summary</SectionLabel>
|
||||||
|
<Textarea
|
||||||
|
value={draft.summary}
|
||||||
|
onChange={(e) => set("summary", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
data-testid="tech-panel-summary"
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Category</SectionLabel>
|
||||||
|
<CategoryPicker
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={canCreateCategories}
|
||||||
|
value={draft.categoryName}
|
||||||
|
onChange={(name, isNew) =>
|
||||||
|
setDraft((prev) => ({ ...prev, categoryName: name, categoryIsNew: isNew }))
|
||||||
|
}
|
||||||
|
testIdPrefix="tech-panel-category"
|
||||||
|
tone="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Type</SectionLabel>
|
||||||
|
<p className="text-[15px] text-white/85 capitalize">{draft.type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Approval Status</SectionLabel>
|
||||||
|
<p className="text-[15px] text-white/85">
|
||||||
|
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
|
||||||
|
draft.approvalStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Summary</SectionLabel>
|
||||||
|
<p className="text-[15px] leading-relaxed text-white/80">
|
||||||
|
{draft.summary || "None."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Prerequisites</SectionLabel>
|
||||||
|
{draft.prereqAnd.length === 0 && draft.prereqOr.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-white/50">No prerequisites.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["and", draft.prereqAnd],
|
||||||
|
["or", draft.prereqOr],
|
||||||
|
] as const
|
||||||
|
).map(([kind, ids]) =>
|
||||||
|
ids.length === 0 ? null : (
|
||||||
|
<div key={kind}>
|
||||||
|
<p className="mb-1 font-mono text-[10px] tracking-widest text-white/40 uppercase">
|
||||||
|
{kind.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{ids.map((id) => (
|
||||||
|
<li
|
||||||
|
key={`${kind}-${id}`}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 bg-black/40 px-2.5 py-1.5"
|
||||||
|
data-testid={`tech-panel-prereq-${kind.toLowerCase()}-${id}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-[15px] text-white/85">{nameOf(id)}</span>
|
||||||
|
{canUpdate ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border border-white/20 bg-black/40 px-1.5 py-0.5 font-mono text-[9px] tracking-widest text-white/60 uppercase hover:border-white/45 hover:text-white"
|
||||||
|
onClick={() => movePrereq(id, kind === "and" ? "or" : "and")}
|
||||||
|
title={`Move to ${kind === "and" ? "OR" : "AND"}`}
|
||||||
|
data-testid={`tech-panel-prereq-${id}-toggle`}
|
||||||
|
>
|
||||||
|
{kind === "and" ? "→OR" : "→AND"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-transparent text-white/50 hover:text-red-400"
|
||||||
|
onClick={() => removePrereq(id)}
|
||||||
|
aria-label={`Remove ${nameOf(id)}`}
|
||||||
|
data-testid={`tech-panel-prereq-${id}-remove`}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canUpdate && addableTechs.length > 0 ? (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5">
|
||||||
|
<PanelSelect
|
||||||
|
value={prereqToAdd}
|
||||||
|
onChange={setPrereqToAdd}
|
||||||
|
placeholder="Add prerequisite..."
|
||||||
|
options={addableTechs.map((t) => ({ value: String(t.id), label: t.name }))}
|
||||||
|
testId="tech-panel-add-prereq"
|
||||||
|
searchable
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<PanelSelect
|
||||||
|
value={prereqKind}
|
||||||
|
onChange={(value) => setPrereqKind(value as "and" | "or")}
|
||||||
|
options={[
|
||||||
|
{ value: "and", label: "AND" },
|
||||||
|
{ value: "or", label: "OR" },
|
||||||
|
]}
|
||||||
|
testId="tech-panel-add-prereq-kind"
|
||||||
|
className="w-24 shrink-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!prereqToAdd}
|
||||||
|
onClick={() => {
|
||||||
|
if (!prereqToAdd) return;
|
||||||
|
movePrereq(Number(prereqToAdd), prereqKind);
|
||||||
|
setPrereqToAdd("");
|
||||||
|
}}
|
||||||
|
data-testid="tech-panel-add-prereq-confirm"
|
||||||
|
className={`shrink-0 ${PANEL_BUTTON}`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Research costs */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Research Costs</SectionLabel>
|
||||||
|
{canUpdate ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{draft.costs.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
|
||||||
|
) : null}
|
||||||
|
{draft.costs.map((cost, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-1.5">
|
||||||
|
<PanelSelect
|
||||||
|
value={cost.resource === "" ? "" : String(cost.resource)}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.map((c, i) =>
|
||||||
|
i === index
|
||||||
|
? { ...c, resource: value === "" ? "" : Number(value) }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Select resource..."
|
||||||
|
options={resources.map((r) => ({ value: String(r.id), label: r.name }))}
|
||||||
|
testId={`tech-panel-cost-${index}-resource`}
|
||||||
|
searchable
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={cost.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.map((c, i) =>
|
||||||
|
i === index ? { ...c, amount: Number(e.target.value) } : c,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`${PANEL_CONTROL} w-24 shrink-0`}
|
||||||
|
data-testid={`tech-panel-cost-${index}-amount`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 bg-transparent text-white/50 hover:text-red-400"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.filter((_, i) => i !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
aria-label="Remove cost"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: [...prev.costs, { resource: "", amount: 1 }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
data-testid="tech-panel-cost-add"
|
||||||
|
className={PANEL_BUTTON}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add resource
|
||||||
|
</Button>
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Min duration (ticks)</SectionLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.minDuration}
|
||||||
|
onChange={(e) => set("minDuration", Number(e.target.value))}
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
data-testid="tech-panel-min-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Max duration (ticks)</SectionLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.maxDuration}
|
||||||
|
onChange={(e) => set("maxDuration", Number(e.target.value))}
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
data-testid="tech-panel-max-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : draft.costs.length > 0 ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{draft.costs.map((cost, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between border border-white/10 bg-black/40 px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="truncate text-[15px] text-white/80">
|
||||||
|
{cost.resource === "" ? "Unset" : resourceName(cost.resource)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 font-mono text-sm text-white/60 tabular-nums">
|
||||||
|
{cost.amount}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warnings.length > 0 ? (
|
||||||
|
<div className="border border-amber-400/40 bg-amber-400/10 px-2.5 py-2">
|
||||||
|
<SectionLabel>Timeline Warnings</SectionLabel>
|
||||||
|
<ul className="list-inside list-disc space-y-1">
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<li key={i} className="text-sm leading-relaxed text-amber-300">
|
||||||
|
{warning}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="border border-red-400/40 bg-red-400/10 px-2.5 py-1.5 text-sm text-red-300"
|
||||||
|
data-testid="tech-panel-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Readout footer */}
|
||||||
|
<div className="flex shrink-0 border-t border-white/15 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="flex-1 border-r border-white/15 px-4 py-2">
|
||||||
|
<p className="font-mono text-sm text-white/85 tabular-nums">
|
||||||
|
{draft.type.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">KIND</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-4 py-2">
|
||||||
|
<p className="font-mono text-sm text-white/85 tabular-nums">
|
||||||
|
{draft.prereqAnd.length + draft.prereqOr.length}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">PREREQS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-t border-white/15 p-3">
|
||||||
|
{canUpdate ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
className={`flex-1 ${PANEL_BUTTON_ACCENT}`}
|
||||||
|
data-testid="tech-panel-save"
|
||||||
|
>
|
||||||
|
<SaveIcon className="size-4" />
|
||||||
|
{busy ? "Saving..." : dirty ? "Save changes" : "Saved"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(draftFromTech(tech, categoryName));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
title="Discard changes"
|
||||||
|
data-testid="tech-panel-reset"
|
||||||
|
className={PANEL_BUTTON}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant={canUpdate ? "outline" : "default"}
|
||||||
|
onClick={onEdit}
|
||||||
|
className={`${canUpdate ? "flex-1" : "w-full"} ${PANEL_BUTTON}`}
|
||||||
|
data-testid="tech-preview-edit"
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Edit in full view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
694
src/components/admin/tech-tree/TechTreeCanvas.tsx
Normal file
694
src/components/admin/tech-tree/TechTreeCanvas.tsx
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Controls,
|
||||||
|
MarkerType,
|
||||||
|
ReactFlow,
|
||||||
|
useEdgesState,
|
||||||
|
useNodesState,
|
||||||
|
type Edge,
|
||||||
|
type EdgeChange,
|
||||||
|
type Node,
|
||||||
|
type NodeChange,
|
||||||
|
type NodeTypes,
|
||||||
|
ReactFlowProvider,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { LayoutGridIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import { CategoryBand, type CategoryBandData, type CategoryBandNode } from "./CategoryBand";
|
||||||
|
import { edgeVisuals, type EdgeRef } from "./edgeVisuals";
|
||||||
|
import { applyPins, pinsToFreeze, type CanvasPosition } from "./freeze";
|
||||||
|
import { computeLayout } from "./layout";
|
||||||
|
import { mapCategoryDoc, mapTechDoc } from "./mappers";
|
||||||
|
import { addPrerequisite, buildPrereqRows, removePrerequisite, withPrereqs } from "./prereqEdits";
|
||||||
|
import { QuickCreateDialog, type QuickCreateResult } from "./QuickCreateDialog";
|
||||||
|
import { TechNodeCard, type TechNodeData, type TechNodeModel } from "./TechNode";
|
||||||
|
import { TechPreviewPanel } from "./TechPreviewPanel";
|
||||||
|
import {
|
||||||
|
COLUMN_STEP,
|
||||||
|
NODE_HEIGHT,
|
||||||
|
NODE_WIDTH,
|
||||||
|
type TreeCategory,
|
||||||
|
type TreeTech,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type ResourceEntry = { id: number; name: string };
|
||||||
|
|
||||||
|
export interface TechTreeData {
|
||||||
|
techs: TreeTech[];
|
||||||
|
categories: TreeCategory[];
|
||||||
|
resources: ResourceEntry[];
|
||||||
|
canCreate: boolean;
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canSetFinalApproval: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tactical chrome for the canvas toolbar controls. */
|
||||||
|
const TACTICAL_BUTTON =
|
||||||
|
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
|
||||||
|
|
||||||
|
const nodeTypes: NodeTypes = {
|
||||||
|
techCard: TechNodeCard,
|
||||||
|
techBand: CategoryBand,
|
||||||
|
};
|
||||||
|
|
||||||
|
function refLabel(id: number): string {
|
||||||
|
return `REF ${id.toString().padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlowNodes(
|
||||||
|
techs: TreeTech[],
|
||||||
|
categories: TreeCategory[],
|
||||||
|
layout: ReturnType<typeof computeLayout>,
|
||||||
|
connectable: boolean,
|
||||||
|
): Node[] {
|
||||||
|
const warningsByTech = new Map<number, number>();
|
||||||
|
for (const warning of layout.warnings) {
|
||||||
|
warningsByTech.set(warning.techId, (warningsByTech.get(warning.techId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const categoryById = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const bandNodes: CategoryBandNode[] = layout.bands.map((band) => {
|
||||||
|
const techCount =
|
||||||
|
band.key === "uncategorized"
|
||||||
|
? techs.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)).length
|
||||||
|
: techs.filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))).length;
|
||||||
|
return {
|
||||||
|
id: band.key,
|
||||||
|
type: "techBand",
|
||||||
|
position: { x: band.x, y: band.y },
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
deletable: false,
|
||||||
|
zIndex: 0,
|
||||||
|
// Bands are pure backdrops: they must never swallow pointer events,
|
||||||
|
// otherwise edges under a band cannot be selected and pane clicks die.
|
||||||
|
style: { width: band.width, height: band.height, pointerEvents: "none" },
|
||||||
|
data: { band, techCount },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const techById = new Map(techs.map((t) => [t.id, t]));
|
||||||
|
const techNodes: TechNodeModel[] = layout.nodes.map((node) => {
|
||||||
|
const tech = techById.get(node.techId)!;
|
||||||
|
return {
|
||||||
|
id: String(tech.id),
|
||||||
|
type: "techCard",
|
||||||
|
position: { x: node.x, y: node.y },
|
||||||
|
zIndex: 1,
|
||||||
|
style: { width: NODE_WIDTH, height: NODE_HEIGHT },
|
||||||
|
data: {
|
||||||
|
tech,
|
||||||
|
categoryName: tech.categoryId ? categoryById.get(tech.categoryId)?.name ?? null : null,
|
||||||
|
refLabel: refLabel(tech.id),
|
||||||
|
hasWarning: warningsByTech.has(tech.id),
|
||||||
|
connectable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...bandNodes, ...techNodes];
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
|
const value = hex.replace("#", "");
|
||||||
|
const r = parseInt(value.slice(0, 2), 16);
|
||||||
|
const g = parseInt(value.slice(2, 4), 16);
|
||||||
|
const b = parseInt(value.slice(4, 6), 16);
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prerequisite edges. Each edge takes its progression path's color and its own
|
||||||
|
* lane (see edgeVisuals). With a technology selected, edges that do not touch
|
||||||
|
* it fade back so its connections stand out.
|
||||||
|
*/
|
||||||
|
function buildFlowEdges(
|
||||||
|
techs: TreeTech[],
|
||||||
|
layout: ReturnType<typeof computeLayout>,
|
||||||
|
selectedId: string | null,
|
||||||
|
): Edge[] {
|
||||||
|
const known = new Set(techs.map((t) => t.id));
|
||||||
|
const refs: EdgeRef[] = [];
|
||||||
|
const isOr = new Set<string>();
|
||||||
|
for (const tech of techs) {
|
||||||
|
for (const prereqId of tech.prereqAnd) {
|
||||||
|
if (prereqId === tech.id || !known.has(prereqId)) continue;
|
||||||
|
refs.push({ id: `and-${prereqId}-${tech.id}`, source: prereqId, target: tech.id });
|
||||||
|
}
|
||||||
|
for (const prereqId of tech.prereqOr) {
|
||||||
|
if (prereqId === tech.id || !known.has(prereqId)) continue;
|
||||||
|
const id = `or-${prereqId}-${tech.id}`;
|
||||||
|
if (refs.some((r) => r.id === id)) continue;
|
||||||
|
refs.push({ id, source: prereqId, target: tech.id });
|
||||||
|
isOr.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map(layout.nodes.map((n) => [n.techId, { x: n.x, y: n.y }]));
|
||||||
|
const visuals = edgeVisuals(techs, refs, positions, COLUMN_STEP);
|
||||||
|
const focus = selectedId ? Number(selectedId) : null;
|
||||||
|
|
||||||
|
return refs.map((ref) => {
|
||||||
|
const visual = visuals.get(ref.id)!;
|
||||||
|
const or = isOr.has(ref.id);
|
||||||
|
const related = focus === null || ref.source === focus || ref.target === focus;
|
||||||
|
const alpha = focus === null ? 0.8 : related ? 1 : 0.12;
|
||||||
|
const stroke = withAlpha(visual.color, alpha);
|
||||||
|
return {
|
||||||
|
id: ref.id,
|
||||||
|
source: String(ref.source),
|
||||||
|
target: String(ref.target),
|
||||||
|
type: "smoothstep",
|
||||||
|
pathOptions: { stepPosition: visual.stepPosition, borderRadius: 8 },
|
||||||
|
animated: or,
|
||||||
|
zIndex: focus !== null && related ? 2 : 0,
|
||||||
|
style: {
|
||||||
|
stroke,
|
||||||
|
strokeWidth: focus !== null && related ? 2.25 : 1.5,
|
||||||
|
...(or ? { strokeDasharray: "6 4" } : {}),
|
||||||
|
},
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: stroke },
|
||||||
|
...(or
|
||||||
|
? {
|
||||||
|
label: "OR",
|
||||||
|
labelStyle: { fill: stroke, fontSize: 9, fontFamily: "monospace" },
|
||||||
|
labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
data: { pathRoot: visual.pathRoot },
|
||||||
|
} as Edge;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollection<T>(slug: string): Promise<T[]> {
|
||||||
|
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = await res.json();
|
||||||
|
return body?.docs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [techs, setTechs] = React.useState(data.techs);
|
||||||
|
const [categories, setCategories] = React.useState(data.categories);
|
||||||
|
const [resources, setResources] = React.useState(data.resources);
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false);
|
||||||
|
const [arranging, setArranging] = React.useState(false);
|
||||||
|
|
||||||
|
const layout = React.useMemo(() => computeLayout(techs, categories), [techs, categories]);
|
||||||
|
const flowNodes = React.useMemo(
|
||||||
|
() => buildFlowNodes(techs, categories, layout, data.canUpdate),
|
||||||
|
[techs, categories, layout, data.canUpdate],
|
||||||
|
);
|
||||||
|
const flowEdges = React.useMemo(
|
||||||
|
() => buildFlowEdges(techs, layout, selectedId),
|
||||||
|
[techs, layout, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Controlled nodes/edges: React Flow needs the change handlers to apply
|
||||||
|
// drag and selection updates in controlled mode.
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNodes(flowNodes);
|
||||||
|
}, [flowNodes, setNodes]);
|
||||||
|
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setEdges(flowEdges);
|
||||||
|
}, [flowEdges, setEdges]);
|
||||||
|
|
||||||
|
// Latest rendered card positions, read by the freeze diff below.
|
||||||
|
const nodesRef = React.useRef<Node[]>([]);
|
||||||
|
nodesRef.current = nodes;
|
||||||
|
const currentPositions = React.useCallback((): Map<number, CanvasPosition> => {
|
||||||
|
const map = new Map<number, CanvasPosition>();
|
||||||
|
for (const node of nodesRef.current) {
|
||||||
|
if (node.type !== "techCard") continue;
|
||||||
|
map.set(Number(node.id), { x: node.position.x, y: node.position.y });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
|
||||||
|
const persistPins = React.useCallback(
|
||||||
|
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
|
||||||
|
await Promise.all(
|
||||||
|
[...pins]
|
||||||
|
.filter(([id]) => !skip.has(id))
|
||||||
|
.map(([id, pin]) =>
|
||||||
|
fetch(`/api/technologies/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: pin }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nodes are data-driven: block React Flow's own remove changes so Backspace
|
||||||
|
// can never blank a node out of the canvas (positions/selection still flow).
|
||||||
|
const handleNodesChange = React.useCallback(
|
||||||
|
(changes: NodeChange<Node>[]) => {
|
||||||
|
const allowed = changes.filter((change) => change.type !== "remove");
|
||||||
|
if (allowed.length > 0) onNodesChange(allowed);
|
||||||
|
},
|
||||||
|
[onNodesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edge removal is a data change (it drops a prerequisite), handled in
|
||||||
|
// onEdgesDelete; selection changes still flow so edges can be picked.
|
||||||
|
const handleEdgesChange = React.useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
const allowed = changes.filter((change) => change.type !== "remove");
|
||||||
|
if (allowed.length > 0) onEdgesChange(allowed);
|
||||||
|
},
|
||||||
|
[onEdgesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [connectError, setConnectError] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!connectError) return;
|
||||||
|
const timer = window.setTimeout(() => setConnectError(null), 6000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [connectError]);
|
||||||
|
|
||||||
|
const hasManualPositions = techs.some((t) => t.treePosition !== null);
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!saveStatus) return;
|
||||||
|
const timer = window.setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [saveStatus]);
|
||||||
|
|
||||||
|
const patchPrereqs = React.useCallback(
|
||||||
|
async (
|
||||||
|
targetId: number,
|
||||||
|
lists: { and: number[]; or: number[] },
|
||||||
|
pin?: { x: number; y: number },
|
||||||
|
) => {
|
||||||
|
const res = await fetch(`/api/technologies/${targetId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
requirements: { prerequisiteTechnologies: buildPrereqRows(lists) },
|
||||||
|
...(pin ? { treePosition: pin } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConnect = React.useCallback(
|
||||||
|
async (connection: { source: string | null; target: string | null }) => {
|
||||||
|
if (!connection.source || !connection.target) return;
|
||||||
|
const sourceId = Number(connection.source);
|
||||||
|
const targetId = Number(connection.target);
|
||||||
|
const result = addPrerequisite(techs, targetId, sourceId, "and");
|
||||||
|
if (!result.ok) {
|
||||||
|
setConnectError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectError(null);
|
||||||
|
|
||||||
|
// Stability: nothing on screen moves because of a connection. Every card
|
||||||
|
// the ordered layout would relocate (the target, its dependents, column
|
||||||
|
// neighbours) is pinned where it sits; "Auto-arrange" re-orders them.
|
||||||
|
const updated = techs.map((t) =>
|
||||||
|
t.id === targetId ? withPrereqs(t, result.lists) : t,
|
||||||
|
);
|
||||||
|
const pins = pinsToFreeze(currentPositions(), updated, categories);
|
||||||
|
setTechs(applyPins(updated, pins));
|
||||||
|
try {
|
||||||
|
await patchPrereqs(targetId, result.lists, pins.get(targetId));
|
||||||
|
await persistPins(pins, new Set([targetId]));
|
||||||
|
} catch (e) {
|
||||||
|
setConnectError(e instanceof Error ? e.message : "Could not save the connection.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[techs, categories, patchPrereqs, currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdgesDelete = React.useCallback(
|
||||||
|
async (deleted: Edge[]) => {
|
||||||
|
let next = techs;
|
||||||
|
const listsByTarget = new Map<number, { and: number[]; or: number[] }>();
|
||||||
|
for (const edge of deleted) {
|
||||||
|
const targetId = Number(edge.target);
|
||||||
|
const result = removePrerequisite(next, targetId, Number(edge.source));
|
||||||
|
if (!result.ok) continue;
|
||||||
|
listsByTarget.set(targetId, result.lists);
|
||||||
|
next = next.map((t) => (t.id === targetId ? withPrereqs(t, result.lists) : t));
|
||||||
|
}
|
||||||
|
if (listsByTarget.size === 0) return;
|
||||||
|
const pins = pinsToFreeze(currentPositions(), next, categories);
|
||||||
|
setTechs(applyPins(next, pins));
|
||||||
|
try {
|
||||||
|
for (const [targetId, lists] of listsByTarget) {
|
||||||
|
await patchPrereqs(targetId, lists, pins.get(targetId));
|
||||||
|
}
|
||||||
|
await persistPins(pins, new Set(listsByTarget.keys()));
|
||||||
|
} catch (e) {
|
||||||
|
setConnectError(e instanceof Error ? e.message : "Could not remove the prerequisite.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[techs, categories, patchPrereqs, currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistPosition = React.useCallback(async (techId: number, x: number, y: number) => {
|
||||||
|
setTechs((prev) =>
|
||||||
|
prev.map((t) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/technologies/${techId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: { x, y } }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Optimistic position stays locally; a reload restores the stored value.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSavePositions = React.useCallback(async () => {
|
||||||
|
// Commit the current on-screen position of every node to treePosition, so
|
||||||
|
// the exact arrangement survives a reload. Unlike a drag (one node) or a
|
||||||
|
// connection (only what would move), this pins the whole tree to whatever
|
||||||
|
// React Flow actually rendered.
|
||||||
|
const pins = new Map<number, CanvasPosition>();
|
||||||
|
const current = currentPositions();
|
||||||
|
for (const tech of techs) {
|
||||||
|
const pos = current.get(tech.id);
|
||||||
|
if (pos) {
|
||||||
|
pins.set(tech.id, { x: Math.round(pos.x), y: Math.round(pos.y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pins.size === 0) {
|
||||||
|
setSaveStatus("Nothing to save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTechs((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
const pin = pins.get(t.id);
|
||||||
|
return pin ? { ...t, treePosition: pin } : t;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await persistPins(pins);
|
||||||
|
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus("Could not save positions.");
|
||||||
|
}
|
||||||
|
}, [techs, currentPositions, persistPins]);
|
||||||
|
|
||||||
|
const handleAutoArrange = React.useCallback(async () => {
|
||||||
|
const withPositions = techs.filter((t) => t.treePosition !== null);
|
||||||
|
if (withPositions.length === 0) return;
|
||||||
|
setArranging(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
withPositions.map((t) =>
|
||||||
|
fetch(`/api/technologies/${t.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: { x: null, y: null } }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setTechs((prev) => prev.map((t) => ({ ...t, treePosition: null })));
|
||||||
|
} finally {
|
||||||
|
setArranging(false);
|
||||||
|
}
|
||||||
|
}, [techs]);
|
||||||
|
|
||||||
|
const techById = React.useMemo(
|
||||||
|
() => new Map(techs.map((t) => [String(t.id), t])),
|
||||||
|
[techs],
|
||||||
|
);
|
||||||
|
const selectedTech = selectedId ? techById.get(selectedId) ?? null : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the tree. With `freeze`, cards the fresh data would relocate are
|
||||||
|
* pinned at their current spot first (panel saves, quick-create), so the
|
||||||
|
* canvas stays still. Error recovery calls it without freeze to show truth.
|
||||||
|
*/
|
||||||
|
const refresh = React.useCallback(
|
||||||
|
async (options: { freeze?: boolean } = {}) => {
|
||||||
|
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
|
||||||
|
fetchCollection<Record<string, unknown>>("technologies"),
|
||||||
|
fetchCollection<Record<string, unknown>>("tech-categories"),
|
||||||
|
fetchCollection<Record<string, unknown>>("resources"),
|
||||||
|
]);
|
||||||
|
let nextTechs = techDocs.map(mapTechDoc);
|
||||||
|
const nextCategories = categoryDocs.map(mapCategoryDoc);
|
||||||
|
if (options.freeze) {
|
||||||
|
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
|
||||||
|
nextTechs = applyPins(nextTechs, pins);
|
||||||
|
void persistPins(pins);
|
||||||
|
}
|
||||||
|
setTechs(nextTechs);
|
||||||
|
setCategories(nextCategories);
|
||||||
|
setResources(resourceDocs.map((d) => ({ id: d.id as number, name: String(d.name ?? "") })));
|
||||||
|
},
|
||||||
|
[currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreated = React.useCallback(
|
||||||
|
async (result: QuickCreateResult) => {
|
||||||
|
await refresh({ freeze: true });
|
||||||
|
setSelectedId(String(result.techId));
|
||||||
|
router.push(`/admin/collections/technologies/${result.techId}`);
|
||||||
|
},
|
||||||
|
[refresh, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedWarnings = React.useMemo(
|
||||||
|
() =>
|
||||||
|
selectedTech
|
||||||
|
? layout.warnings.filter((w) => w.techId === selectedTech.id).map((w) => w.message)
|
||||||
|
: [],
|
||||||
|
[selectedTech, layout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative tech-tree-canvas"
|
||||||
|
style={{
|
||||||
|
height: "calc(100dvh - 56px)",
|
||||||
|
backgroundColor: "#05070a",
|
||||||
|
}}
|
||||||
|
data-testid="tech-tree-canvas"
|
||||||
|
>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
|
||||||
|
minZoom={0.08}
|
||||||
|
nodesConnectable={data.canUpdate}
|
||||||
|
onConnect={(connection) => {
|
||||||
|
void handleConnect(connection);
|
||||||
|
}}
|
||||||
|
onConnectEnd={(event, connectionState) => {
|
||||||
|
// React Flow only resolves drops onto a handle. If the pointer was
|
||||||
|
// released over a node card instead, resolve that card ourselves so
|
||||||
|
// "drag onto the technology" works the way people expect. Validation
|
||||||
|
// (duplicates, cycles) lives in handleConnect, which reports errors.
|
||||||
|
if (!data.canUpdate) return;
|
||||||
|
if (!("fromNode" in connectionState)) return;
|
||||||
|
if (connectionState.toHandle) return; // dropped on a handle: onConnect leads
|
||||||
|
const sourceId = connectionState.fromNode?.id;
|
||||||
|
if (!sourceId) return;
|
||||||
|
const point = event as MouseEvent;
|
||||||
|
const element = document.elementFromPoint(point.clientX ?? 0, point.clientY ?? 0);
|
||||||
|
const nodeElement = element?.closest?.(".react-flow__node") as HTMLElement | null;
|
||||||
|
const targetId = nodeElement?.getAttribute("data-id");
|
||||||
|
if (!targetId || targetId === sourceId) return;
|
||||||
|
void handleConnect({ source: sourceId, target: targetId });
|
||||||
|
}}
|
||||||
|
onEdgesDelete={(deleted) => {
|
||||||
|
void handleEdgesDelete(deleted);
|
||||||
|
}}
|
||||||
|
onNodeClick={(_, node) => {
|
||||||
|
if (node.type === "techCard") setSelectedId(node.id);
|
||||||
|
}}
|
||||||
|
onNodeDragStop={(_, node) => {
|
||||||
|
if (node.type !== "techCard") return;
|
||||||
|
void persistPosition(Number(node.id), Math.round(node.position.x), Math.round(node.position.y));
|
||||||
|
}}
|
||||||
|
onPaneClick={() => setSelectedId(null)}
|
||||||
|
proOptions={{ hideAttribution: false }}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Lines}
|
||||||
|
gap={28}
|
||||||
|
size={1}
|
||||||
|
color="rgba(255,255,255,0.055)"
|
||||||
|
/>
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="absolute top-4 left-4 z-20 flex items-center gap-3 rounded-none border border-white/15 bg-black/60 px-3 py-2 backdrop-blur-sm">
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
|
||||||
|
TECH TREE // COMMAND
|
||||||
|
</span>
|
||||||
|
{layout.warnings.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] tracking-widest text-amber-400 uppercase"
|
||||||
|
title={layout.warnings.map((w) => w.message).join("\n")}
|
||||||
|
data-testid="tech-tree-warnings"
|
||||||
|
>
|
||||||
|
<TriangleAlertIcon className="size-3" />
|
||||||
|
{layout.warnings.length} TIMELINE WARNING{layout.warnings.length === 1 ? "" : "S"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{connectError ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tracking-widest text-red-400 uppercase"
|
||||||
|
data-testid="tech-tree-connect-error"
|
||||||
|
>
|
||||||
|
{connectError}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{saveStatus ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
|
||||||
|
data-testid="tech-tree-save-status"
|
||||||
|
>
|
||||||
|
{saveStatus}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSavePositions}
|
||||||
|
title="Save the current positions of every node so the layout stays the same on your next visit"
|
||||||
|
data-testid="tech-tree-save-positions"
|
||||||
|
className={TACTICAL_BUTTON}
|
||||||
|
>
|
||||||
|
<SaveIcon className="size-3.5" />
|
||||||
|
Save positions
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate && hasManualPositions ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleAutoArrange}
|
||||||
|
disabled={arranging}
|
||||||
|
title="Clear manual node positions and restore the automatic layout"
|
||||||
|
data-testid="tech-tree-auto-arrange"
|
||||||
|
className={TACTICAL_BUTTON}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
{arranging ? "Arranging..." : "Auto-arrange"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canCreate ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
data-testid="tech-tree-create"
|
||||||
|
className={`${TACTICAL_BUTTON} border-[#22d3ee]/40 text-[#67e8f9] hover:border-[#22d3ee]/70 hover:bg-[#22d3ee]/10 hover:text-[#a5f3fc]`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
New Technology
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate ? (
|
||||||
|
<span className="hidden font-mono text-[9px] tracking-widest text-white/35 uppercase lg:inline">
|
||||||
|
DRAG NODE EDGE TO LINK A PREREQUISITE · SELECT AN EDGE + BACKSPACE TO REMOVE
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{techs.length === 0 ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="pointer-events-auto rounded-none border border-white/15 bg-black/70 px-6 py-5 text-center backdrop-blur-sm">
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/50 uppercase">
|
||||||
|
NO TECHNOLOGIES ON RECORD
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/70">
|
||||||
|
Create the first technology to start the tree.
|
||||||
|
</p>
|
||||||
|
{data.canCreate ? (
|
||||||
|
<Button
|
||||||
|
className={`${TACTICAL_BUTTON} mt-3`}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
New Technology
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Preview / edit panel */}
|
||||||
|
{selectedTech ? (
|
||||||
|
<TechPreviewPanel
|
||||||
|
tech={selectedTech}
|
||||||
|
categories={categories}
|
||||||
|
resources={resources}
|
||||||
|
allTechs={techs}
|
||||||
|
canUpdate={data.canUpdate}
|
||||||
|
canCreateCategories={data.canCreateCategories}
|
||||||
|
canSetFinalApproval={data.canSetFinalApproval}
|
||||||
|
warnings={selectedWarnings}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
onEdit={() => router.push(`/admin/collections/technologies/${selectedTech.id}`)}
|
||||||
|
onSaved={() => {
|
||||||
|
void refresh({ freeze: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<QuickCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={data.canCreateCategories}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TechTreeCanvas({ data }: { data: TechTreeData }) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<CanvasInner data={data} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/admin/tech-tree/TechTreeNav.tsx
Normal file
151
src/components/admin/tech-tree/TechTreeNav.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { NavHamburger, NavWrapper } from "@payloadcms/next/client";
|
||||||
|
import { Logout } from "@payloadcms/ui";
|
||||||
|
import { RenderServerComponent } from "@payloadcms/ui/elements/RenderServerComponent";
|
||||||
|
import { EntityType, groupNavItems, type EntityToGroup } from "@payloadcms/ui/shared";
|
||||||
|
import { PREFERENCE_KEYS } from "payload/shared";
|
||||||
|
import type { PayloadRequest, SanitizedPermissions, ServerProps } from "payload";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { TechTreeNavClient, type TechTreeNavGroup, type TechTreeNavEntity } from "./TechTreeNavClient";
|
||||||
|
|
||||||
|
const baseClass = "nav";
|
||||||
|
|
||||||
|
type NavProps = ServerProps & { req?: PayloadRequest };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the default admin Nav so a "Tech Tree" entry can be injected into
|
||||||
|
* the Intelligence group (the group containing the technologies collection).
|
||||||
|
* Everything else mirrors Payload's DefaultNav: groups from visible entities,
|
||||||
|
* nav preferences, before/after components, and the logout control. The
|
||||||
|
* Tech Tree entry rides along with the technologies collection's visibility,
|
||||||
|
* so users who cannot see Technologies never see the tree entry either.
|
||||||
|
*/
|
||||||
|
export const TechTreeNav = async ({
|
||||||
|
i18n,
|
||||||
|
locale,
|
||||||
|
params,
|
||||||
|
payload,
|
||||||
|
permissions,
|
||||||
|
req,
|
||||||
|
searchParams,
|
||||||
|
user,
|
||||||
|
visibleEntities,
|
||||||
|
}: NavProps) => {
|
||||||
|
if (!payload?.config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
admin: {
|
||||||
|
components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout },
|
||||||
|
},
|
||||||
|
collections,
|
||||||
|
globals,
|
||||||
|
} = payload.config;
|
||||||
|
|
||||||
|
const visibleCollections = visibleEntities?.collections ?? [];
|
||||||
|
const visibleGlobals = visibleEntities?.globals ?? [];
|
||||||
|
|
||||||
|
const groups: TechTreeNavGroup[] = groupNavItems(
|
||||||
|
[
|
||||||
|
...visibleCollections.map((slug) => {
|
||||||
|
const collection = collections.find((c) => c.slug === slug)!;
|
||||||
|
return { type: EntityType.collection, entity: collection } satisfies EntityToGroup;
|
||||||
|
}),
|
||||||
|
...visibleGlobals.map((slug) => {
|
||||||
|
const global = globals.find((g) => g.slug === slug)!;
|
||||||
|
return { type: EntityType.global, entity: global } satisfies EntityToGroup;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
permissions ?? ({} as SanitizedPermissions),
|
||||||
|
i18n,
|
||||||
|
).map((group): TechTreeNavGroup => {
|
||||||
|
const techIndex = group.entities.findIndex(
|
||||||
|
(entity) => entity.type === EntityType.collection && entity.slug === "technologies",
|
||||||
|
);
|
||||||
|
if (techIndex === -1) return group;
|
||||||
|
const entities: TechTreeNavEntity[] = [...group.entities];
|
||||||
|
entities.splice(techIndex + 1, 0, {
|
||||||
|
slug: "tech-tree",
|
||||||
|
label: "Tech Tree",
|
||||||
|
type: "custom",
|
||||||
|
href: "/tech-tree",
|
||||||
|
});
|
||||||
|
return { ...group, entities };
|
||||||
|
});
|
||||||
|
|
||||||
|
let navPreferences: { groups?: { [key: string]: { open: boolean } } } | null = null;
|
||||||
|
if (req?.user?.collection) {
|
||||||
|
const prefs = await req.payload.find({
|
||||||
|
collection: "payload-preferences",
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
req,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ key: { equals: PREFERENCE_KEYS.NAV } },
|
||||||
|
{ "user.relationTo": { equals: req.user.collection } },
|
||||||
|
{ "user.value": { equals: req?.user?.id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
navPreferences = (prefs?.docs?.[0]?.value as typeof navPreferences) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverProps = { i18n, locale, params, payload, permissions, searchParams, user };
|
||||||
|
const clientProps = { documentSubViewType: undefined, viewType: undefined };
|
||||||
|
|
||||||
|
const LogoutComponent = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: logout?.Button,
|
||||||
|
Fallback: Logout,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const RenderedBeforeNav = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: beforeNav,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedBeforeNavLinks = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: beforeNavLinks,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedAfterNavLinks = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: afterNavLinks,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedAfterNav = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: afterNav,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavWrapper baseClass={baseClass}>
|
||||||
|
{RenderedBeforeNav}
|
||||||
|
<nav className={`${baseClass}__wrap`}>
|
||||||
|
{RenderedBeforeNavLinks}
|
||||||
|
<TechTreeNavClient groups={groups} navPreferences={navPreferences} />
|
||||||
|
{RenderedAfterNavLinks}
|
||||||
|
<div className={`${baseClass}__controls`}>{LogoutComponent}</div>
|
||||||
|
</nav>
|
||||||
|
{RenderedAfterNav}
|
||||||
|
<div className={`${baseClass}__header`}>
|
||||||
|
<div className={`${baseClass}__header-content`}>
|
||||||
|
<NavHamburger baseClass={baseClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NavWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechTreeNav;
|
||||||
103
src/components/admin/tech-tree/TechTreeNavClient.tsx
Normal file
103
src/components/admin/tech-tree/TechTreeNavClient.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link, NavGroup, useConfig, useTranslation } from "@payloadcms/ui";
|
||||||
|
import { EntityType } from "@payloadcms/ui/shared";
|
||||||
|
import { usePathname } from "next/navigation.js";
|
||||||
|
import { formatAdminURL } from "payload/shared";
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A nav entity: either a standard Payload entity (collection/global) or a
|
||||||
|
* custom view entry injected by TechTreeNav (type "custom", href given).
|
||||||
|
*/
|
||||||
|
export type TechTreeNavEntity = {
|
||||||
|
href?: `/${string}`;
|
||||||
|
label: unknown;
|
||||||
|
slug: string;
|
||||||
|
type: EntityType | "custom";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechTreeNavGroup = {
|
||||||
|
entities: TechTreeNavEntity[];
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
groups: TechTreeNavGroup[];
|
||||||
|
navPreferences?: {
|
||||||
|
groups?: { [key: string]: { open: boolean } };
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClass = "nav";
|
||||||
|
|
||||||
|
export const TechTreeNavClient = ({ groups, navPreferences }: Props) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const {
|
||||||
|
config: { routes, admin: adminConfig },
|
||||||
|
} = useConfig();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
const adminRoute = routes.admin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{groups.map((group, groupKey) => {
|
||||||
|
const { entities, label } = group;
|
||||||
|
return (
|
||||||
|
<NavGroup
|
||||||
|
key={groupKey}
|
||||||
|
isOpen={navPreferences?.groups?.[label]?.open}
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
{entities.map((entity, i) => {
|
||||||
|
const { label: entityLabel, slug, type } = entity;
|
||||||
|
|
||||||
|
let href: string | undefined;
|
||||||
|
let id: string | undefined;
|
||||||
|
if (type === "custom" && entity.href) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: entity.href });
|
||||||
|
id = `nav-${slug}`;
|
||||||
|
} else if (type === EntityType.collection) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: `/collections/${slug}` });
|
||||||
|
id = `nav-${slug}`;
|
||||||
|
} else if (type === EntityType.global) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: `/globals/${slug}` });
|
||||||
|
id = `nav-global-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!href) return null;
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
pathname === href ||
|
||||||
|
(pathname.startsWith(href) && ["/", undefined].includes(pathname[href.length]));
|
||||||
|
|
||||||
|
const Label = (
|
||||||
|
<Fragment>
|
||||||
|
{isActive && <div className={`${baseClass}__link-indicator`} />}
|
||||||
|
<span className={`${baseClass}__link-label`}>
|
||||||
|
{typeof entityLabel === "string" ? entityLabel : String(entityLabel)}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<div className={`${baseClass}__link`} id={id} key={i}>
|
||||||
|
{Label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={`${baseClass}__link`} href={href} id={id} key={i} prefetch={false}>
|
||||||
|
{Label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NavGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/components/admin/tech-tree/TechTreeView.tsx
Normal file
104
src/components/admin/tech-tree/TechTreeView.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { DefaultTemplate } from "@payloadcms/next/templates";
|
||||||
|
import type { AdminViewServerProps } from "payload";
|
||||||
|
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
|
||||||
|
import { TechTreeCanvas, type TechTreeData } from "./TechTreeCanvas";
|
||||||
|
import { mapCategoryDoc, mapTechDoc } from "./mappers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root admin view at /admin/tech-tree. Custom views are public by default, so
|
||||||
|
* this gates access itself: logged-in users holding technologies:read (or the
|
||||||
|
* Intelligence division qualification, mirroring the technologies collection's
|
||||||
|
* scoped admin-panel access).
|
||||||
|
*/
|
||||||
|
export async function TechTreeView({
|
||||||
|
initPageResult,
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: AdminViewServerProps) {
|
||||||
|
const {
|
||||||
|
req: { payload, user },
|
||||||
|
} = initPageResult;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user ?? undefined}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-sm text-white/70">You must be logged in to view the Tech Tree.</p>
|
||||||
|
</div>
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRead = await hasPermission(payload, user, "technologies:read");
|
||||||
|
const intelQualified = await hasIntelligenceQualification(payload, user);
|
||||||
|
|
||||||
|
if (!hasRead && !intelQualified) {
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user ?? undefined}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
You do not have permission to view the Tech Tree.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
|
||||||
|
payload.find({ collection: "technologies", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
payload.find({ collection: "tech-categories", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
payload.find({ collection: "resources", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: TechTreeData = {
|
||||||
|
techs: techDocs.docs.map((doc) => mapTechDoc(doc as unknown as Record<string, unknown>)),
|
||||||
|
categories: categoryDocs.docs.map((doc) =>
|
||||||
|
mapCategoryDoc(doc as unknown as Record<string, unknown>),
|
||||||
|
),
|
||||||
|
resources: resourceDocs.docs.map((doc) => ({ id: doc.id, name: doc.name })),
|
||||||
|
canCreate:
|
||||||
|
(await hasPermission(payload, user, "technologies:create")) || intelQualified,
|
||||||
|
canCreateCategories:
|
||||||
|
(await hasPermission(payload, user, "tech-categories:create")) || intelQualified,
|
||||||
|
canUpdate: (await hasPermission(payload, user, "technologies:update")) || intelQualified,
|
||||||
|
canSetFinalApproval: await hasPermission(payload, user, "system:admin-access"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<TechTreeCanvas data={data} />
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TechTreeView;
|
||||||
99
src/components/admin/tech-tree/edgeVisuals.ts
Normal file
99
src/components/admin/tech-tree/edgeVisuals.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { TreeTech } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progression-path palette: high-contrast on the dark canvas, distinct from the
|
||||||
|
* amber warning and red error accents used elsewhere in the tree.
|
||||||
|
*/
|
||||||
|
export const PATH_PALETTE = [
|
||||||
|
"#22d3ee", // cyan
|
||||||
|
"#a78bfa", // violet
|
||||||
|
"#34d399", // emerald
|
||||||
|
"#f472b6", // pink
|
||||||
|
"#38bdf8", // sky
|
||||||
|
"#a3e635", // lime
|
||||||
|
"#fb923c", // orange
|
||||||
|
"#e879f9", // fuchsia
|
||||||
|
];
|
||||||
|
|
||||||
|
export type EdgeRef = { id: string; source: number; target: number };
|
||||||
|
export type EdgeVisual = { color: string; stepPosition: number; pathRoot: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual treatment for each prerequisite edge:
|
||||||
|
*
|
||||||
|
* - **color**: the edge inherits the color of the progression path it belongs
|
||||||
|
* to, identified by the root technology the chain grows from (the lowest-id
|
||||||
|
* root reachable through the source's prerequisites). Separate chains read as
|
||||||
|
* separate colors; one chain keeps one color end to end.
|
||||||
|
* - **stepPosition**: smoothstep edges turn at the midpoint by default, so
|
||||||
|
* edges from DIFFERENT sources crossing the same column gap stack their
|
||||||
|
* vertical runs on top of each other. Each source in a gap gets its own lane
|
||||||
|
* (spread across 20%..80% of the horizontal run). Edges from the SAME source
|
||||||
|
* share a lane on purpose: they read as one bus fanning out from a parent.
|
||||||
|
*/
|
||||||
|
export function edgeVisuals(
|
||||||
|
techs: TreeTech[],
|
||||||
|
edges: EdgeRef[],
|
||||||
|
positions: Map<number, { x: number; y: number }>,
|
||||||
|
columnStep: number,
|
||||||
|
): Map<string, EdgeVisual> {
|
||||||
|
const byId = new Map(techs.map((t) => [t.id, t]));
|
||||||
|
const parentsOf = (id: number): number[] => {
|
||||||
|
const tech = byId.get(id);
|
||||||
|
if (!tech) return [];
|
||||||
|
return [...new Set([...tech.prereqAnd, ...tech.prereqOr])].filter(
|
||||||
|
(p) => p !== id && byId.has(p),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Root of the progression path (cycle-safe, memoized).
|
||||||
|
const rootMemo = new Map<number, number>();
|
||||||
|
const rootOf = (id: number, stack: Set<number> = new Set()): number => {
|
||||||
|
const cached = rootMemo.get(id);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (stack.has(id)) return id;
|
||||||
|
stack.add(id);
|
||||||
|
const parents = parentsOf(id);
|
||||||
|
const root = parents.length === 0 ? id : Math.min(...parents.map((p) => rootOf(p, stack)));
|
||||||
|
stack.delete(id);
|
||||||
|
rootMemo.set(id, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = [...new Set(edges.map((e) => rootOf(e.source)))].sort((a, b) => a - b);
|
||||||
|
const colorOfRoot = new Map(roots.map((r, i) => [r, PATH_PALETTE[i % PATH_PALETTE.length]]));
|
||||||
|
|
||||||
|
// Lanes: group distinct sources by the column gap they leave from.
|
||||||
|
const sourcesByGap = new Map<number, number[]>();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const pos = positions.get(edge.source);
|
||||||
|
if (!pos) continue;
|
||||||
|
const gap = Math.round(pos.x / columnStep);
|
||||||
|
const list = sourcesByGap.get(gap) ?? [];
|
||||||
|
if (!list.includes(edge.source)) list.push(edge.source);
|
||||||
|
sourcesByGap.set(gap, list);
|
||||||
|
}
|
||||||
|
const laneOf = new Map<number, number>();
|
||||||
|
for (const sources of sourcesByGap.values()) {
|
||||||
|
sources.sort((a, b) => {
|
||||||
|
const pa = positions.get(a)!;
|
||||||
|
const pb = positions.get(b)!;
|
||||||
|
return pa.y - pb.y || a - b;
|
||||||
|
});
|
||||||
|
const n = sources.length;
|
||||||
|
sources.forEach((source, i) => {
|
||||||
|
laneOf.set(source, n === 1 ? 0.5 : 0.2 + (0.6 * i) / (n - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visuals = new Map<string, EdgeVisual>();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const pathRoot = rootOf(edge.source);
|
||||||
|
visuals.set(edge.id, {
|
||||||
|
color: colorOfRoot.get(pathRoot) ?? PATH_PALETTE[0],
|
||||||
|
stepPosition: laneOf.get(edge.source) ?? 0.5,
|
||||||
|
pathRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return visuals;
|
||||||
|
}
|
||||||
45
src/components/admin/tech-tree/freeze.ts
Normal file
45
src/components/admin/tech-tree/freeze.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { computeLayout } from "./layout";
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
export type CanvasPosition = { x: number; y: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout stability contract: a data change (new prerequisite, removed edge,
|
||||||
|
* panel save, new technology) must not move cards on screen. Only dragging and
|
||||||
|
* "Auto-arrange" move things.
|
||||||
|
*
|
||||||
|
* Given where every card currently sits and the technologies AFTER the change,
|
||||||
|
* this returns a pin for each technology the ordered layout would relocate, at
|
||||||
|
* its current on-screen position. Writing those pins as `treePosition` keeps
|
||||||
|
* the canvas visually still; Auto-arrange later clears them.
|
||||||
|
*
|
||||||
|
* Technologies with no current position (e.g. just created) are skipped: they
|
||||||
|
* take their ordered slot.
|
||||||
|
*/
|
||||||
|
export function pinsToFreeze(
|
||||||
|
current: Map<number, CanvasPosition>,
|
||||||
|
nextTechs: TreeTech[],
|
||||||
|
categories: TreeCategory[],
|
||||||
|
exclude: Set<number> = new Set(),
|
||||||
|
): Map<number, CanvasPosition> {
|
||||||
|
const next = computeLayout(nextTechs, categories);
|
||||||
|
const pins = new Map<number, CanvasPosition>();
|
||||||
|
for (const node of next.nodes) {
|
||||||
|
if (exclude.has(node.techId)) continue;
|
||||||
|
const before = current.get(node.techId);
|
||||||
|
if (!before) continue;
|
||||||
|
if (Math.abs(node.x - before.x) > 1 || Math.abs(node.y - before.y) > 1) {
|
||||||
|
pins.set(node.techId, { x: Math.round(before.x), y: Math.round(before.y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply pins to a technology list (returns new objects only where pinned). */
|
||||||
|
export function applyPins(techs: TreeTech[], pins: Map<number, CanvasPosition>): TreeTech[] {
|
||||||
|
if (pins.size === 0) return techs;
|
||||||
|
return techs.map((t) => {
|
||||||
|
const pin = pins.get(t.id);
|
||||||
|
return pin ? { ...t, treePosition: pin } : t;
|
||||||
|
});
|
||||||
|
}
|
||||||
262
src/components/admin/tech-tree/layout.ts
Normal file
262
src/components/admin/tech-tree/layout.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import {
|
||||||
|
BAND_PADDING_BOTTOM,
|
||||||
|
BAND_PADDING_TOP,
|
||||||
|
BAND_PADDING_X,
|
||||||
|
COLUMN_STEP,
|
||||||
|
NODE_HEIGHT,
|
||||||
|
NODE_WIDTH,
|
||||||
|
ROW_STEP,
|
||||||
|
type CategoryBandLayout,
|
||||||
|
type LayoutWarning,
|
||||||
|
type TechLayoutNode,
|
||||||
|
type TechTreeLayout,
|
||||||
|
type TreeCategory,
|
||||||
|
type TreeTech,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many rows a column holds before placement spills into the next column.
|
||||||
|
* Keeps a large root pool (or a huge category) readable instead of one
|
||||||
|
* 8000px-tall strip.
|
||||||
|
*/
|
||||||
|
export const MAX_ROWS_PER_COLUMN = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagating left-to-right tree layout.
|
||||||
|
*
|
||||||
|
* The rule that gives the tree its shape: a technology WITH prerequisites is
|
||||||
|
* placed one column to the right of its parents, at their average row. So
|
||||||
|
* linking A -> B makes B appear immediately to the right of A (short, local
|
||||||
|
* edge) and B's own dependents continue further right. Nothing is teleported
|
||||||
|
* to a distant column just because its depth changed.
|
||||||
|
*
|
||||||
|
* - Technologies in a category are pinned to that category's column (the
|
||||||
|
* category contract), stacked in depth/name order.
|
||||||
|
* - Uncategorized technologies with no prerequisites form a compact root grid
|
||||||
|
* starting after the category columns.
|
||||||
|
* - Uncategorized technologies with prerequisites follow their parents inside
|
||||||
|
* the uncategorized zone.
|
||||||
|
* - Collisions resolve by searching rows outward from the desired row, then
|
||||||
|
* spilling into the next column.
|
||||||
|
* - A manual `treePosition` always wins; category bands are the bounding box of
|
||||||
|
* their members' final positions.
|
||||||
|
*/
|
||||||
|
export function computeLayout(techs: TreeTech[], categories: TreeCategory[]): TechTreeLayout {
|
||||||
|
const byId = new Map<number, TreeTech>(techs.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
const parentsOf = (tech: TreeTech): number[] => {
|
||||||
|
const ids = [...tech.prereqAnd, ...tech.prereqOr];
|
||||||
|
return [...new Set(ids)].filter((id) => byId.has(id) && id !== tech.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Longest-path depth, used only for placement ORDER (parents before children)
|
||||||
|
// and for the forward-edge warnings. Cycle-safe.
|
||||||
|
const depthById = new Map<number, number>();
|
||||||
|
const inStack = new Set<number>();
|
||||||
|
const depthOf = (tech: TreeTech): number => {
|
||||||
|
const cached = depthById.get(tech.id);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (inStack.has(tech.id)) return 0;
|
||||||
|
inStack.add(tech.id);
|
||||||
|
const parents = parentsOf(tech);
|
||||||
|
const d = parents.length === 0 ? 0 : 1 + Math.max(...parents.map((id) => depthOf(byId.get(id)!)));
|
||||||
|
inStack.delete(tech.id);
|
||||||
|
depthById.set(tech.id, d);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
techs.forEach(depthOf);
|
||||||
|
|
||||||
|
const sortedCategories = [...categories].sort(
|
||||||
|
(a, b) => a.position - b.position || a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
const columnOfCategory = new Map<number, number>(sortedCategories.map((c, i) => [c.id, i]));
|
||||||
|
const zoneStart = sortedCategories.length;
|
||||||
|
|
||||||
|
const isCategorized = (tech: TreeTech): boolean =>
|
||||||
|
tech.categoryId !== null && columnOfCategory.has(tech.categoryId);
|
||||||
|
|
||||||
|
const MAX_ROWS = MAX_ROWS_PER_COLUMN;
|
||||||
|
const occupancy = new Map<string, number>();
|
||||||
|
const positions = new Map<number, { col: number; row: number }>();
|
||||||
|
const cellKey = (col: number, row: number) => `${col}:${row}`;
|
||||||
|
|
||||||
|
const unbounded = Number.POSITIVE_INFINITY;
|
||||||
|
const findFreeRow = (col: number, desiredRow: number, maxRows: number): number | null => {
|
||||||
|
// Unbounded placement (dependents) may also grow UPWARDS, so a technology
|
||||||
|
// linked from a full column still lands right beside its parent.
|
||||||
|
const allowNegative = maxRows === unbounded;
|
||||||
|
for (let offset = 0; offset < 500; offset += 1) {
|
||||||
|
const down = desiredRow + offset;
|
||||||
|
if (down < maxRows && !occupancy.has(cellKey(col, down))) return down;
|
||||||
|
const up = desiredRow - offset;
|
||||||
|
if ((allowNegative || up >= 0) && !occupancy.has(cellKey(col, up))) return up;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `maxRows` caps how tall a column may grow before placement spills right.
|
||||||
|
* Grid-style placement (roots, category members) is capped; dependents are
|
||||||
|
* NOT, so a technology linked from a full column still lands immediately
|
||||||
|
* beside its parent instead of being pushed to a distant column.
|
||||||
|
*/
|
||||||
|
const placeNear = (
|
||||||
|
tech: TreeTech,
|
||||||
|
desiredCol: number,
|
||||||
|
desiredRow: number,
|
||||||
|
maxRows: number = MAX_ROWS,
|
||||||
|
) => {
|
||||||
|
let col = Math.max(0, desiredCol);
|
||||||
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
const row = findFreeRow(col, Math.max(0, desiredRow), maxRows);
|
||||||
|
if (row !== null) {
|
||||||
|
occupancy.set(cellKey(col, row), tech.id);
|
||||||
|
positions.set(tech.id, { col, row });
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
col += 1; // column full: spill to the right
|
||||||
|
}
|
||||||
|
occupancy.set(cellKey(col, 0), tech.id);
|
||||||
|
positions.set(tech.id, { col, row: 0 });
|
||||||
|
return { col, row: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const byDepthThenName = (a: TreeTech, b: TreeTech) =>
|
||||||
|
depthOf(a) - depthOf(b) || a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
// 1) Categorized technologies are pinned to their category's column.
|
||||||
|
const nextRowByColumn = new Map<number, number>();
|
||||||
|
for (const tech of techs.filter(isCategorized).sort(byDepthThenName)) {
|
||||||
|
const col = columnOfCategory.get(tech.categoryId!)!;
|
||||||
|
const desired = nextRowByColumn.get(col) ?? 0;
|
||||||
|
const placed = placeNear(tech, col, desired);
|
||||||
|
nextRowByColumn.set(col, placed.row + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Uncategorized roots: a catalog grid starting after the categories.
|
||||||
|
//
|
||||||
|
// Slots come from each tech's rank among ALL uncategorized technologies, not
|
||||||
|
// among the current roots, so a tech that becomes a dependent leaves its
|
||||||
|
// catalog slot EMPTY instead of shifting every other root. That is what keeps
|
||||||
|
// connecting two nodes from re-shuffling the whole grid.
|
||||||
|
const catalogRank = new Map<number, number>();
|
||||||
|
[...techs]
|
||||||
|
.filter((tech) => !isCategorized(tech))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.forEach((tech, index) => catalogRank.set(tech.id, index));
|
||||||
|
for (const tech of techs) {
|
||||||
|
if (isCategorized(tech) || parentsOf(tech).length > 0) continue;
|
||||||
|
const slot = catalogRank.get(tech.id) ?? 0;
|
||||||
|
placeNear(tech, zoneStart + Math.floor(slot / MAX_ROWS), slot % MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Uncategorized dependents: one column right of their parents, at their row.
|
||||||
|
const dependents = techs
|
||||||
|
.filter((tech) => !isCategorized(tech) && parentsOf(tech).length > 0)
|
||||||
|
.sort(byDepthThenName);
|
||||||
|
for (const tech of dependents) {
|
||||||
|
const parentPositions = parentsOf(tech)
|
||||||
|
.map((id) => positions.get(id))
|
||||||
|
.filter((p): p is { col: number; row: number } => Boolean(p));
|
||||||
|
if (parentPositions.length === 0) {
|
||||||
|
// Unplaced parents (cycle): park it in the uncategorized zone.
|
||||||
|
placeNear(tech, zoneStart, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const desiredCol = Math.max(zoneStart, Math.max(...parentPositions.map((p) => p.col)) + 1);
|
||||||
|
const desiredRow = Math.round(
|
||||||
|
parentPositions.reduce((sum, p) => sum + p.row, 0) / parentPositions.length,
|
||||||
|
);
|
||||||
|
placeNear(tech, desiredCol, desiredRow, unbounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual overrides win over the automatic grid.
|
||||||
|
const finalPosition = (tech: TreeTech): { x: number; y: number } => {
|
||||||
|
const manual = tech.treePosition;
|
||||||
|
if (manual && Number.isFinite(manual.x) && Number.isFinite(manual.y)) {
|
||||||
|
return { x: manual.x, y: manual.y };
|
||||||
|
}
|
||||||
|
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
|
||||||
|
return { x: placed.col * COLUMN_STEP, y: placed.row * ROW_STEP };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes: TechLayoutNode[] = techs.map((tech) => {
|
||||||
|
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
|
||||||
|
const { x, y } = finalPosition(tech);
|
||||||
|
return { techId: tech.id, column: placed.col, row: placed.row, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward-edge warnings: a prerequisite placed to the RIGHT of its dependent.
|
||||||
|
const columnById = new Map(nodes.map((n) => [n.techId, n.column]));
|
||||||
|
const warnings: LayoutWarning[] = [];
|
||||||
|
for (const tech of techs) {
|
||||||
|
for (const parentId of parentsOf(tech)) {
|
||||||
|
const parent = byId.get(parentId)!;
|
||||||
|
if ((columnById.get(parentId) ?? 0) > (columnById.get(tech.id) ?? 0)) {
|
||||||
|
warnings.push({
|
||||||
|
techId: tech.id,
|
||||||
|
message: `Prerequisite "${parent.name}" sits to the right of "${tech.name}" on the timeline.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category bands: bounding box of each group's final node positions.
|
||||||
|
const bandFor = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
color: string | null,
|
||||||
|
position: number | null,
|
||||||
|
members: TreeTech[],
|
||||||
|
): CategoryBandLayout | null => {
|
||||||
|
if (members.length === 0) return null;
|
||||||
|
const coords = members.map((m) => finalPosition(m));
|
||||||
|
const minX = Math.min(...coords.map((c) => c.x));
|
||||||
|
const maxX = Math.max(...coords.map((c) => c.x));
|
||||||
|
const minY = Math.min(...coords.map((c) => c.y));
|
||||||
|
const maxY = Math.max(...coords.map((c) => c.y));
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
x: minX - BAND_PADDING_X,
|
||||||
|
y: minY - BAND_PADDING_TOP,
|
||||||
|
width: maxX + NODE_WIDTH - minX + BAND_PADDING_X * 2,
|
||||||
|
height: maxY + NODE_HEIGHT - minY + BAND_PADDING_TOP + BAND_PADDING_BOTTOM,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bands: CategoryBandLayout[] = [];
|
||||||
|
for (const category of sortedCategories) {
|
||||||
|
const members = techs.filter((t) => t.categoryId === category.id);
|
||||||
|
const band = bandFor(
|
||||||
|
`cat-${category.id}`,
|
||||||
|
category.name,
|
||||||
|
category.color ?? null,
|
||||||
|
category.position,
|
||||||
|
members,
|
||||||
|
);
|
||||||
|
if (band) bands.push(band);
|
||||||
|
}
|
||||||
|
const uncategorizedBand = bandFor(
|
||||||
|
"uncategorized",
|
||||||
|
"Uncategorized",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
techs.filter((t) => !isCategorized(t)),
|
||||||
|
);
|
||||||
|
if (uncategorizedBand) bands.push(uncategorizedBand);
|
||||||
|
|
||||||
|
const maxColumn = nodes.reduce((max, n) => Math.max(max, n.column), 0);
|
||||||
|
const maxRow = nodes.reduce((max, n) => Math.max(max, n.row), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
bands,
|
||||||
|
warnings,
|
||||||
|
columns: nodes.length === 0 ? 0 : maxColumn + 1,
|
||||||
|
rows: nodes.length === 0 ? 0 : maxRow + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/components/admin/tech-tree/mappers.ts
Normal file
67
src/components/admin/tech-tree/mappers.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a `technologies` REST/local API doc (depth 0: relationships are
|
||||||
|
* plain ids) into the lightweight TreeTech shape used by the tree.
|
||||||
|
*/
|
||||||
|
export function mapTechDoc(doc: Record<string, unknown>): TreeTech {
|
||||||
|
const requirements = doc.requirements as { prerequisiteTechnologies?: unknown[] } | null;
|
||||||
|
const rows = Array.isArray(requirements?.prerequisiteTechnologies)
|
||||||
|
? (requirements!.prerequisiteTechnologies as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
const prereqAnd: number[] = [];
|
||||||
|
const prereqOr: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const requiresAnd = row?.requiresAnd as { technology?: unknown[] } | undefined;
|
||||||
|
const requiresOr = row?.requiresOr as { technology?: unknown[] } | undefined;
|
||||||
|
for (const t of requiresAnd?.technology ?? []) {
|
||||||
|
if (typeof t === "number") prereqAnd.push(t);
|
||||||
|
}
|
||||||
|
for (const t of requiresOr?.technology ?? []) {
|
||||||
|
if (typeof t === "number") prereqOr.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const researchCosts = doc.researchCosts as { resources?: unknown[] } | null;
|
||||||
|
const costRows = Array.isArray(researchCosts?.resources)
|
||||||
|
? (researchCosts!.resources as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
const resourceCosts = costRows
|
||||||
|
.map((row) => ({
|
||||||
|
resource: row?.resource as number,
|
||||||
|
amount: typeof row?.amount === "number" ? row.amount : 0,
|
||||||
|
}))
|
||||||
|
.filter((row) => typeof row.resource === "number");
|
||||||
|
|
||||||
|
const manualPosition = doc.treePosition as { x?: unknown; y?: unknown } | null | undefined;
|
||||||
|
const treePosition =
|
||||||
|
manualPosition &&
|
||||||
|
typeof manualPosition.x === "number" &&
|
||||||
|
typeof manualPosition.y === "number" &&
|
||||||
|
Number.isFinite(manualPosition.x) &&
|
||||||
|
Number.isFinite(manualPosition.y)
|
||||||
|
? { x: manualPosition.x, y: manualPosition.y }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id as number,
|
||||||
|
name: String(doc.name ?? ""),
|
||||||
|
type: doc.type === "asset" ? "asset" : "upgrade",
|
||||||
|
approvalStatus: String(doc.approvalStatus ?? "in_progress"),
|
||||||
|
summary: String(doc.summary ?? ""),
|
||||||
|
categoryId: typeof doc.category === "number" ? doc.category : null,
|
||||||
|
prereqAnd: [...new Set(prereqAnd)],
|
||||||
|
prereqOr: [...new Set(prereqOr)].filter((id) => !prereqAnd.includes(id)),
|
||||||
|
resourceCosts,
|
||||||
|
treePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a `tech-categories` doc into the TreeCategory shape. */
|
||||||
|
export function mapCategoryDoc(doc: Record<string, unknown>): TreeCategory {
|
||||||
|
return {
|
||||||
|
id: doc.id as number,
|
||||||
|
name: String(doc.name ?? ""),
|
||||||
|
position: typeof doc.position === "number" ? doc.position : 1,
|
||||||
|
color: typeof doc.color === "string" && doc.color ? doc.color : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/components/admin/tech-tree/prereqEdits.ts
Normal file
114
src/components/admin/tech-tree/prereqEdits.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
74
src/components/admin/tech-tree/types.ts
Normal file
74
src/components/admin/tech-tree/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* Shared shapes for the Tech Tree admin view. These are lightweight
|
||||||
|
* serializable projections of the `technologies` and `tech-categories`
|
||||||
|
* documents, built server-side by TechTreeView and consumed by the canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TreeTech = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: "asset" | "upgrade";
|
||||||
|
approvalStatus: string;
|
||||||
|
summary: string;
|
||||||
|
categoryId: number | null;
|
||||||
|
/** Union of all AND-row prerequisite technology ids. */
|
||||||
|
prereqAnd: number[];
|
||||||
|
/** Union of all OR-row prerequisite technology ids. */
|
||||||
|
prereqOr: number[];
|
||||||
|
/** Resource costs: resource id + amount. */
|
||||||
|
resourceCosts: { resource: number; amount: number }[];
|
||||||
|
/** Manual canvas position (dragged node); null = automatic layout. */
|
||||||
|
treePosition: { x: number; y: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutWarning = {
|
||||||
|
techId: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechLayoutNode = {
|
||||||
|
techId: number;
|
||||||
|
column: number;
|
||||||
|
row: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryBandLayout = {
|
||||||
|
/** tech-categories id, or "uncategorized" for the trailing auto zone. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
position: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechTreeLayout = {
|
||||||
|
nodes: TechLayoutNode[];
|
||||||
|
bands: CategoryBandLayout[];
|
||||||
|
warnings: LayoutWarning[];
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Canvas geometry (px). */
|
||||||
|
export const NODE_WIDTH = 236;
|
||||||
|
export const NODE_HEIGHT = 148;
|
||||||
|
export const COLUMN_GAP = 132;
|
||||||
|
export const ROW_GAP = 36;
|
||||||
|
export const BAND_PADDING_X = 20;
|
||||||
|
export const BAND_PADDING_TOP = 64;
|
||||||
|
export const BAND_PADDING_BOTTOM = 28;
|
||||||
|
|
||||||
|
export const COLUMN_STEP = NODE_WIDTH + COLUMN_GAP;
|
||||||
|
export const ROW_STEP = NODE_HEIGHT + ROW_GAP;
|
||||||
34
src/components/frontend/analytics/ClarityAnalytics.tsx
Normal file
34
src/components/frontend/analytics/ClarityAnalytics.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Clarity from "@microsoft/clarity";
|
||||||
|
|
||||||
|
// NEXT_PUBLIC_ vars are statically inlined at build time, so an unset
|
||||||
|
// NEXT_PUBLIC_CLARITY_ID is baked in as undefined for that build.
|
||||||
|
const CLARITY_ID = process.env.NEXT_PUBLIC_CLARITY_ID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microsoft Clarity usage analytics (https://clarity.microsoft.com).
|
||||||
|
*
|
||||||
|
* Renders nothing. Initializes only when the build is a production build
|
||||||
|
* AND NEXT_PUBLIC_CLARITY_ID was set at build time, so local dev and
|
||||||
|
* ID-less builds stay untracked. Clarity records SPA route changes on its
|
||||||
|
* own, so no per-navigation wiring is needed.
|
||||||
|
*
|
||||||
|
* Mount once per root layout that should be tracked: the (frontend) layout
|
||||||
|
* and the standalone /login layout. The Payload admin panel is untracked.
|
||||||
|
*/
|
||||||
|
export function ClarityAnalytics() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "production" || !CLARITY_ID) return;
|
||||||
|
try {
|
||||||
|
Clarity.init(CLARITY_ID);
|
||||||
|
} catch (e) {
|
||||||
|
// Fire-and-forget: analytics must never break the app if the script
|
||||||
|
// is blocked (ad blockers) or Clarity is unreachable.
|
||||||
|
console.warn("[clarity] init failed:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
164
src/lib/arma-bridge/money.ts
Normal file
164
src/lib/arma-bridge/money.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { BankAccount } from "@/payload-types";
|
||||||
|
import { applyTransaction, ensurePersonalAccount, getTreasuryAccountId } from "@/lib/banking";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-game shop money <-> personal bank account sync (Arma bridge).
|
||||||
|
*
|
||||||
|
* The game queues a `money.delta` event for every balance change it makes;
|
||||||
|
* the events route dedupes by `serverId:eventId`, so a retried delivery never
|
||||||
|
* double-charges. Money flow (agreed economy rules):
|
||||||
|
* - purchase (negative) -> personal account pays the unit treasury
|
||||||
|
* - other negative (dialogue/Zeus) -> personal account pays the unit treasury
|
||||||
|
* - refund (positive) -> treasury pays the personal account back
|
||||||
|
* (created new if the treasury can't cover it)
|
||||||
|
* - reward / grant (positive) -> created new (plain deposit)
|
||||||
|
* Players whose Steam ID is not linked to a web user keep a game-only balance:
|
||||||
|
* their events are stored as evidence and skipped here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MoneyDelta {
|
||||||
|
steamId: string;
|
||||||
|
delta: number;
|
||||||
|
reason: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload shape from ptf_shop_fnc_setMoney: [steamId, delta, reason, detail]. */
|
||||||
|
export function parseMoneyDelta(payload: unknown): MoneyDelta | null {
|
||||||
|
if (!Array.isArray(payload) || payload.length < 3) return null;
|
||||||
|
const steamId = typeof payload[0] === "string" ? payload[0].trim() : "";
|
||||||
|
const rawDelta = payload[1];
|
||||||
|
const delta = typeof rawDelta === "number" && Number.isFinite(rawDelta) ? Math.round(rawDelta) : 0;
|
||||||
|
const reason = typeof payload[2] === "string" ? payload[2].trim().toLowerCase() : "";
|
||||||
|
const detail = typeof payload[3] === "string" ? payload[3].slice(0, 200) : "";
|
||||||
|
if (!steamId || steamId.length > 64 || delta === 0 || !reason) return null;
|
||||||
|
return { steamId, delta, reason, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUserIdBySteamId(payload: Payload, steamId: string): Promise<number | null> {
|
||||||
|
const users = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { steamId: { equals: steamId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return users.docs[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one `money.delta` event to the player's personal account. Never
|
||||||
|
* throws: a failure (unlinked player, insufficient funds, frozen account) is
|
||||||
|
* logged, and the game's next balance pull corrects its displayed value.
|
||||||
|
*/
|
||||||
|
export async function processMoneyEvent(
|
||||||
|
payload: Payload,
|
||||||
|
raw: { eventId: string; payload: unknown },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const money = parseMoneyDelta(raw.payload);
|
||||||
|
if (!money) {
|
||||||
|
payload.logger.warn(`[ShopMoney] Money event ${raw.eventId} has a malformed payload; skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await findUserIdBySteamId(payload, money.steamId);
|
||||||
|
if (!userId) {
|
||||||
|
payload.logger.info(
|
||||||
|
`[ShopMoney] Steam ID ${money.steamId} is not linked to a user; game-only balance, event ${raw.eventId} skipped`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await ensurePersonalAccount(payload, userId);
|
||||||
|
const treasuryId = await getTreasuryAccountId(payload);
|
||||||
|
const amount = Math.abs(money.delta);
|
||||||
|
const memo = `In-game ${money.reason}${money.detail ? `: ${money.detail}` : ""} [${raw.eventId}]`;
|
||||||
|
|
||||||
|
if (money.delta < 0) {
|
||||||
|
// Money leaving the player goes to the unit treasury (like NPC vendor
|
||||||
|
// sales in the web market); without a treasury it simply leaves.
|
||||||
|
await applyTransaction(
|
||||||
|
payload,
|
||||||
|
treasuryId
|
||||||
|
? { type: "payment", fromAccountId: account.id, toAccountId: treasuryId, amount, memo }
|
||||||
|
: { type: "withdrawal", fromAccountId: account.id, amount, memo },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (money.reason === "refund" && treasuryId) {
|
||||||
|
try {
|
||||||
|
await applyTransaction(payload, {
|
||||||
|
type: "transfer",
|
||||||
|
fromAccountId: treasuryId,
|
||||||
|
toAccountId: account.id,
|
||||||
|
amount,
|
||||||
|
memo,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// A refund must never be lost: fall through to a plain deposit.
|
||||||
|
payload.logger.warn(`[ShopMoney] Treasury could not cover refund ${raw.eventId} (${err}); depositing instead`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyTransaction(payload, { type: "deposit", toAccountId: account.id, amount, memo });
|
||||||
|
} catch (err) {
|
||||||
|
payload.logger.error(`[ShopMoney] Failed to apply money event ${raw.eventId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal account balances for the given Steam IDs. Only linked players are
|
||||||
|
* returned (a linked player without an account yet reads as 0); unlinked
|
||||||
|
* Steam IDs are omitted so the game falls back to its own stored value.
|
||||||
|
* Read-only: accounts are created lazily by the first money event.
|
||||||
|
*/
|
||||||
|
export async function getBalancesBySteamId(
|
||||||
|
payload: Payload,
|
||||||
|
steamIds: string[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const wanted = [...new Set(steamIds.map((id) => id.trim()).filter((id) => id.length > 0 && id.length <= 64))];
|
||||||
|
if (wanted.length === 0) return {};
|
||||||
|
|
||||||
|
const users = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { steamId: { in: wanted } },
|
||||||
|
limit: wanted.length * 2,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const userBySteamId = new Map<string, number>();
|
||||||
|
for (const user of users.docs) {
|
||||||
|
const steamId = user.steamId?.trim();
|
||||||
|
if (steamId && !userBySteamId.has(steamId)) userBySteamId.set(steamId, user.id);
|
||||||
|
}
|
||||||
|
if (userBySteamId.size === 0) return {};
|
||||||
|
|
||||||
|
const accounts = (await payload.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ accountType: { equals: "personal" } },
|
||||||
|
{ ownerUser: { in: [...userBySteamId.values()] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: userBySteamId.size * 4,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: BankAccount[] };
|
||||||
|
const balanceByUser = new Map<number, number>();
|
||||||
|
for (const acc of accounts.docs) {
|
||||||
|
const owner = typeof acc.ownerUser === "number" ? acc.ownerUser : acc.ownerUser?.id;
|
||||||
|
// First personal account wins, matching ensurePersonalAccount.
|
||||||
|
if (owner != null && !balanceByUser.has(owner)) balanceByUser.set(owner, acc.balance ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances: Record<string, number> = {};
|
||||||
|
for (const [steamId, userId] of userBySteamId) {
|
||||||
|
balances[steamId] = Math.round((balanceByUser.get(userId) ?? 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
440
src/lib/arma-import/catalog.ts
Normal file
440
src/lib/arma-import/catalog.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
/**
|
||||||
|
* Arma catalog import: planning + upsert logic for the in-game export catalog.
|
||||||
|
*
|
||||||
|
* Record contract (the in-game ptf_export addon emits a JSON array of these):
|
||||||
|
*
|
||||||
|
* { classname: string, type: AssetType | "vehicle", displayName: string,
|
||||||
|
* picture: string (.paa ref, may be ""), mod: string, description: string,
|
||||||
|
* mass: number, extra: number | string }
|
||||||
|
*
|
||||||
|
* - `type` is trusted when it is a known Assets.assetType value or "vehicle";
|
||||||
|
* unknown/missing types fall back to classname heuristics.
|
||||||
|
* - For "vehicle" records `extra` is the transport mode ("air" | "sea" |
|
||||||
|
* "ground"; anything else -> "ground").
|
||||||
|
* - For "magazine" records `extra` is the round count (magazine roundCapacity).
|
||||||
|
* - `mass` is accepted for contract completeness; no collection field maps to
|
||||||
|
* it yet, so it is not imported.
|
||||||
|
*
|
||||||
|
* Legacy field aliases (`class`, `category`, `name`) are still accepted.
|
||||||
|
*
|
||||||
|
* planRecord() is pure (safe to unit test); enrichPlanMedia() does the
|
||||||
|
* filesystem work (.paa lookup, hemtt PAA->PNG conversion, screenshot
|
||||||
|
* matching); upsertPlan() writes to Payload and is an idempotent upsert —
|
||||||
|
* assets keyed by `className`, vehicles by `classname`. Updates refresh only
|
||||||
|
* in-game data (name, assetType/transportMode, mod, description) and attach a
|
||||||
|
* new thumbnail/image only when a fresh media doc was produced, so
|
||||||
|
* approvalStatus and human-edited "Virtual Data" are never reset.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||||
|
|
||||||
|
/** Mirrors the Assets collection's assetType enum (src/collections/logistics/Assets.ts). */
|
||||||
|
export const ASSET_TYPES = [
|
||||||
|
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
||||||
|
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
||||||
|
"explosive", "skin", "miscellaneous",
|
||||||
|
] as const;
|
||||||
|
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||||
|
export type TransportMode = "ground" | "air" | "sea";
|
||||||
|
|
||||||
|
const ASSET_TYPE_SET = new Set<string>(ASSET_TYPES);
|
||||||
|
const TRANSPORT_MODES: readonly string[] = ["ground", "air", "sea"];
|
||||||
|
|
||||||
|
export interface CatalogRecord {
|
||||||
|
classname?: string;
|
||||||
|
/** Legacy alias for classname. */
|
||||||
|
class?: string;
|
||||||
|
type?: string;
|
||||||
|
/** Legacy alias for type. */
|
||||||
|
category?: string;
|
||||||
|
displayName?: string;
|
||||||
|
/** Legacy alias for displayName. */
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
mod?: string;
|
||||||
|
description?: string;
|
||||||
|
/** Accepted by the contract; no collection field maps to it (not imported). */
|
||||||
|
mass?: number;
|
||||||
|
/** Transport mode for vehicles, round count for magazines, otherwise "". */
|
||||||
|
extra?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanMedia {
|
||||||
|
/** Resolved .paa path under --paa-root (exists check only, no conversion). */
|
||||||
|
paaPath: string;
|
||||||
|
/** Converted PNG (only produced in approve mode by enrichPlanMedia). */
|
||||||
|
pngPath: string;
|
||||||
|
/** Matched --screenshots PNG/WebP/JPG preview for the classname. */
|
||||||
|
screenshotPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPlan {
|
||||||
|
kind: "asset" | "vehicle";
|
||||||
|
className: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
/** The Assets.assetType value; "vehicle" for vehicle plans. */
|
||||||
|
assetType: AssetType | "vehicle";
|
||||||
|
/** Vehicles only. */
|
||||||
|
transportMode: TransportMode;
|
||||||
|
/** Magazines only: round count from `extra`. */
|
||||||
|
roundCapacity?: number;
|
||||||
|
mod: string;
|
||||||
|
/** Icon-map ref or rec.picture, used to resolve the .paa thumbnail. */
|
||||||
|
pictureRef: string;
|
||||||
|
media: PlanMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLookups {
|
||||||
|
/** icon_map JSON: classname -> .paa ref (takes priority over rec.picture). */
|
||||||
|
icons?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupIcon(icons: Record<string, unknown> | undefined, className: string): string {
|
||||||
|
if (!icons || typeof icons !== "object") return "";
|
||||||
|
for (const [key, value] of Object.entries(icons)) {
|
||||||
|
if (key.toLowerCase() === className.toLowerCase() && typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an asset record. Known types win; unknown types fall back to
|
||||||
|
* classname heuristics, ending at "miscellaneous" (a valid enum value — the
|
||||||
|
* old "item" fallback was not).
|
||||||
|
*/
|
||||||
|
function classifyAssetType(className: string, type: string): AssetType {
|
||||||
|
const t = (type || "").toLowerCase();
|
||||||
|
if (ASSET_TYPE_SET.has(t)) return t as AssetType;
|
||||||
|
const c = className.toLowerCase();
|
||||||
|
if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine";
|
||||||
|
if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon";
|
||||||
|
if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine";
|
||||||
|
return "miscellaneous";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy vehicle heuristic, used only when `type` is not a known value. */
|
||||||
|
function looksLikeVehicle(className: string, type: string): boolean {
|
||||||
|
if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true;
|
||||||
|
const c = className.toLowerCase();
|
||||||
|
return (
|
||||||
|
/^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) &&
|
||||||
|
!/backpack|vest|helmet/.test(c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric `extra` (round count for magazines); tolerates numeric strings. */
|
||||||
|
function numericExtra(extra: number | string | undefined): number | undefined {
|
||||||
|
if (extra === undefined || extra === "") return undefined;
|
||||||
|
const n = typeof extra === "number" ? extra : Number(String(extra).trim());
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transport mode from a vehicle record's `extra`, defaulting to "ground". */
|
||||||
|
function transportModeFromExtra(extra: number | string | undefined): TransportMode {
|
||||||
|
if (typeof extra === "string" && TRANSPORT_MODES.includes(extra.trim().toLowerCase())) {
|
||||||
|
return extra.trim().toLowerCase() as TransportMode;
|
||||||
|
}
|
||||||
|
return "ground";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan one catalog record (pure — no filesystem or database access).
|
||||||
|
* Returns undefined for records without a usable classname.
|
||||||
|
*/
|
||||||
|
export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): ImportPlan | undefined {
|
||||||
|
const className = String(rec.classname ?? rec.class ?? "").trim();
|
||||||
|
if (!className) return undefined;
|
||||||
|
|
||||||
|
const rawType = String(rec.type ?? rec.category ?? "").trim().toLowerCase();
|
||||||
|
const displayName = String(rec.displayName ?? rec.name ?? "").trim() || className;
|
||||||
|
const description = String(rec.description ?? "").trim();
|
||||||
|
const mod = String(rec.mod ?? "").trim() || "Arma 3";
|
||||||
|
const pictureRef = lookupIcon(lookups.icons, className) || String(rec.picture ?? "").trim();
|
||||||
|
|
||||||
|
if (rawType === "vehicle" || (!ASSET_TYPE_SET.has(rawType) && looksLikeVehicle(className, rawType))) {
|
||||||
|
return {
|
||||||
|
kind: "vehicle",
|
||||||
|
className,
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
assetType: "vehicle",
|
||||||
|
transportMode: transportModeFromExtra(rec.extra),
|
||||||
|
mod,
|
||||||
|
pictureRef,
|
||||||
|
media: { paaPath: "", pngPath: "", screenshotPath: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetType = classifyAssetType(className, rawType);
|
||||||
|
const roundCapacity = assetType === "magazine" ? numericExtra(rec.extra) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "asset",
|
||||||
|
className,
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
assetType,
|
||||||
|
transportMode: "ground",
|
||||||
|
...(roundCapacity !== undefined ? { roundCapacity } : {}),
|
||||||
|
mod,
|
||||||
|
pictureRef,
|
||||||
|
media: { paaPath: "", pngPath: "", screenshotPath: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* Filesystem media resolution (CLI only — never runs in planRecord/tests)
|
||||||
|
* ----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function basename(p: string): string {
|
||||||
|
const seg = p.split(/[\\/]/);
|
||||||
|
return seg[seg.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePaaPath(ref: string, paaRoot: string): string {
|
||||||
|
if (!ref || !paaRoot) return "";
|
||||||
|
const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/");
|
||||||
|
const cand = join(paaRoot, clean);
|
||||||
|
return existsSync(cand) ? cand : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined {
|
||||||
|
const dest = join(outDir, basename(paaPath).replace(/\.paa$/i, "") + ".png");
|
||||||
|
if (existsSync(dest)) return dest;
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" });
|
||||||
|
return r.status === 0 && existsSync(dest) ? dest : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaEnv {
|
||||||
|
/** Root mirroring ref paths where extracted .paa files live. */
|
||||||
|
paaRoot: string;
|
||||||
|
/** PNG/WebP/JPG per-classname 3D-rendered previews ("" to disable). */
|
||||||
|
screenshots: string;
|
||||||
|
/** Destination for converted PNGs. */
|
||||||
|
outDir: string;
|
||||||
|
/** hemtt binary used for the PAA->PNG conversion. */
|
||||||
|
hemtt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve media for a plan on the filesystem: .paa ref -> existing file,
|
||||||
|
* converted PNG, and screenshot match. With `convert = false` (dry-run) this
|
||||||
|
* is read-only: the .paa existence check runs but no PNG is produced.
|
||||||
|
*/
|
||||||
|
export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true): void {
|
||||||
|
const paaPath = resolvePaaPath(plan.pictureRef, env.paaRoot);
|
||||||
|
plan.media.paaPath = paaPath;
|
||||||
|
if (paaPath && convert) {
|
||||||
|
plan.media.pngPath = convertPaaToPng(env.hemtt, paaPath, env.outDir) ?? "";
|
||||||
|
}
|
||||||
|
plan.media.screenshotPath = env.screenshots
|
||||||
|
? (["png", "webp", "jpg"]
|
||||||
|
.map((ext) => join(env.screenshots, plan.className + "." + ext))
|
||||||
|
.find(existsSync) ?? "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* Database writes (Payload local API)
|
||||||
|
* ----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Resolve the "Fuel" resource id required by Vehicles.fuel.fuelType. */
|
||||||
|
export async function resolveFuelConfig(payload: Payload): Promise<number> {
|
||||||
|
const r = await payload.find({
|
||||||
|
collection: "resources",
|
||||||
|
limit: 50,
|
||||||
|
where: { name: { equals: "Fuel" } },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const fuel = r.docs.find((d) => String(d.name).toLowerCase() === "fuel");
|
||||||
|
if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
|
||||||
|
return Number(fuel.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertResult {
|
||||||
|
action: "created" | "updated";
|
||||||
|
collection: "assets" | "vehicles";
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert one planned record into `assets` (keyed by `className`) or
|
||||||
|
* `vehicles` (keyed by `classname`), exact match. `fuelId` (from
|
||||||
|
* resolveFuelConfig) is required for vehicle plans.
|
||||||
|
*/
|
||||||
|
export async function upsertPlan(payload: Payload, plan: ImportPlan, fuelId?: number): Promise<UpsertResult> {
|
||||||
|
return plan.kind === "vehicle" ? upsertVehicle(payload, plan, fuelId) : upsertAsset(payload, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_MIMETYPES: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find-or-create a media doc for the file, deduped by filename so re-runs
|
||||||
|
* reuse the same media row instead of piling up duplicates. `created` tells
|
||||||
|
* callers whether this run actually produced a new image; only then is an
|
||||||
|
* existing doc's thumbnail/image refreshed (a human-chosen image is kept).
|
||||||
|
* Failures resolve to undefined — the record is still imported, just without
|
||||||
|
* an image.
|
||||||
|
*/
|
||||||
|
async function resolveMedia(payload: Payload, filePath: string): Promise<{ id: number; created: boolean } | undefined> {
|
||||||
|
try {
|
||||||
|
const fileName = basename(filePath);
|
||||||
|
const buffer = readFileSync(filePath);
|
||||||
|
const ext = (fileName.split(".").pop() ?? "").toLowerCase();
|
||||||
|
const mimetype = IMAGE_MIMETYPES[ext] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: "media",
|
||||||
|
where: { filename: { equals: fileName } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
if (existing.docs.length > 0) return { id: Number(existing.docs[0].id), created: false };
|
||||||
|
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: "media",
|
||||||
|
data: { alt: fileName },
|
||||||
|
file: { data: buffer, mimetype, name: fileName, size: buffer.length },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return { id: Number(created.id), created: true };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First available image path for the plan, or "" when none was produced. */
|
||||||
|
function planImagePath(plan: ImportPlan): string {
|
||||||
|
return plan.media.pngPath || plan.media.screenshotPath || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset metadata group written on every import (mod) — gameDescription only
|
||||||
|
* when the record carries a description, so an empty export can never wipe a
|
||||||
|
* human-entered description. thumbnail is appended by the caller when a new
|
||||||
|
* image exists.
|
||||||
|
*/
|
||||||
|
function assetMetadata(plan: ImportPlan): Record<string, unknown> {
|
||||||
|
const metadata: Record<string, unknown> = { mod: plan.mod };
|
||||||
|
if (plan.description) metadata.gameDescription = plan.description;
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertAsset(payload: Payload, plan: ImportPlan): Promise<UpsertResult> {
|
||||||
|
// planRecord guarantees a real Assets.assetType value for kind "asset".
|
||||||
|
const assetType = plan.assetType as AssetType;
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: "assets",
|
||||||
|
where: { className: { equals: plan.className } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.docs.length > 0) {
|
||||||
|
const id = Number(existing.docs[0].id);
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: plan.displayName,
|
||||||
|
assetType,
|
||||||
|
"Asset Metadata": assetMetadata(plan),
|
||||||
|
};
|
||||||
|
const imagePath = planImagePath(plan);
|
||||||
|
if (imagePath) {
|
||||||
|
const media = await resolveMedia(payload, imagePath);
|
||||||
|
// Only attach when this run produced a new media doc — never stomp a
|
||||||
|
// human-chosen thumbnail with a re-discovered one.
|
||||||
|
if (media?.created) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
|
||||||
|
}
|
||||||
|
await payload.update({ collection: "assets", id, data, overrideAccess: true });
|
||||||
|
return { action: "updated", collection: "assets", id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = planImagePath(plan);
|
||||||
|
const mediaId = imagePath ? (await resolveMedia(payload, imagePath))?.id : undefined;
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: plan.displayName,
|
||||||
|
className: plan.className,
|
||||||
|
assetType,
|
||||||
|
"Asset Metadata": {
|
||||||
|
...assetMetadata(plan),
|
||||||
|
...(mediaId !== undefined ? { thumbnail: mediaId } : {}),
|
||||||
|
},
|
||||||
|
...(assetType === "magazine" && plan.roundCapacity !== undefined
|
||||||
|
? { "Magazine Configuration": { roundCapacity: plan.roundCapacity } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: "assets",
|
||||||
|
// draft: false pins the non-draft create overload; `as never` satisfies
|
||||||
|
// the generated create type, which marks defaultValue-backed fields
|
||||||
|
// (approvalStatus, crafting, storageDimensions) required even though
|
||||||
|
// Payload fills them at runtime (repo precedent: reservationOptions.ts).
|
||||||
|
draft: false,
|
||||||
|
data: data as never,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return { action: "created", collection: "assets", id: Number(created.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number | undefined): Promise<UpsertResult> {
|
||||||
|
if (!fuelId) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: "vehicles",
|
||||||
|
where: { classname: { equals: plan.className } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.docs.length > 0) {
|
||||||
|
const id = Number(existing.docs[0].id);
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: plan.displayName,
|
||||||
|
transportMode: plan.transportMode,
|
||||||
|
};
|
||||||
|
if (plan.description) data.description = plaintextToLexical(plan.description);
|
||||||
|
const imagePath = planImagePath(plan);
|
||||||
|
if (imagePath) {
|
||||||
|
const media = await resolveMedia(payload, imagePath);
|
||||||
|
if (media?.created) data.image = media.id;
|
||||||
|
}
|
||||||
|
await payload.update({ collection: "vehicles", id, data, overrideAccess: true });
|
||||||
|
return { action: "updated", collection: "vehicles", id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = planImagePath(plan);
|
||||||
|
const mediaId = imagePath ? (await resolveMedia(payload, imagePath))?.id : undefined;
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: plan.displayName,
|
||||||
|
classname: plan.className,
|
||||||
|
fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 },
|
||||||
|
transportMode: plan.transportMode,
|
||||||
|
...(plan.description ? { description: plaintextToLexical(plan.description) } : {}),
|
||||||
|
...(mediaId !== undefined ? { image: mediaId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: "vehicles",
|
||||||
|
// draft: false pins the non-draft create overload; `as never` satisfies
|
||||||
|
// the generated create type (approvalStatus is marked required though
|
||||||
|
// Payload fills the default at runtime).
|
||||||
|
draft: false,
|
||||||
|
data: data as never,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return { action: "created", collection: "vehicles", id: Number(created.id) };
|
||||||
|
}
|
||||||
35267
src/migrations/20260930_230747_add_tech_categories.json
Normal file
35267
src/migrations/20260930_230747_add_tech_categories.json
Normal file
File diff suppressed because it is too large
Load diff
54
src/migrations/20260930_230747_add_tech_categories.ts
Normal file
54
src/migrations/20260930_230747_add_tech_categories.ts
Normal file
File diff suppressed because one or more lines are too long
35279
src/migrations/20261001_000320_add_tech_tree_position.json
Normal file
35279
src/migrations/20261001_000320_add_tech_tree_position.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20261001_000320_add_tech_tree_position.ts
Normal file
13
src/migrations/20261001_000320_add_tech_tree_position.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "technologies" ADD COLUMN "tree_position_x" numeric;
|
||||||
|
ALTER TABLE "technologies" ADD COLUMN "tree_position_y" numeric;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "technologies" DROP COLUMN "tree_position_x";
|
||||||
|
ALTER TABLE "technologies" DROP COLUMN "tree_position_y";`)
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,8 @@ import * as migration_20260926_030859_add_mission_role_description from './20260
|
||||||
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
|
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
|
||||||
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
|
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
|
||||||
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
|
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
|
||||||
|
import * as migration_20260930_230747_add_tech_categories from './20260930_230747_add_tech_categories';
|
||||||
|
import * as migration_20261001_000320_add_tech_tree_position from './20261001_000320_add_tech_tree_position';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -575,11 +577,21 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260928_021138_change_ai_model.up,
|
up: migration_20260928_021138_change_ai_model.up,
|
||||||
down: migration_20260928_021138_change_ai_model.down,
|
down: migration_20260928_021138_change_ai_model.down,
|
||||||
name: '20260928_021138_change_ai_model'
|
name: '20260928_021138_change_ai_model',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260930_162200_add_documentation_wiki_category.up,
|
up: migration_20260930_162200_add_documentation_wiki_category.up,
|
||||||
down: migration_20260930_162200_add_documentation_wiki_category.down,
|
down: migration_20260930_162200_add_documentation_wiki_category.down,
|
||||||
name: '20260930_162200_add_documentation_wiki_category'
|
name: '20260930_162200_add_documentation_wiki_category',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260930_230747_add_tech_categories.up,
|
||||||
|
down: migration_20260930_230747_add_tech_categories.down,
|
||||||
|
name: '20260930_230747_add_tech_categories',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20261001_000320_add_tech_tree_position.up,
|
||||||
|
down: migration_20261001_000320_add_tech_tree_position.down,
|
||||||
|
name: '20261001_000320_add_tech_tree_position'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,11 @@ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [
|
||||||
"technologies:update",
|
"technologies:update",
|
||||||
"technologies:delete",
|
"technologies:delete",
|
||||||
"admin:technologies:manage",
|
"admin:technologies:manage",
|
||||||
|
"tech-categories:create",
|
||||||
|
"tech-categories:read",
|
||||||
|
"tech-categories:update",
|
||||||
|
"tech-categories:delete",
|
||||||
|
"admin:tech-categories:manage",
|
||||||
"assets:create",
|
"assets:create",
|
||||||
"assets:read",
|
"assets:read",
|
||||||
"assets:update",
|
"assets:update",
|
||||||
|
|
@ -3317,6 +3322,11 @@ export const technologies = pgTable(
|
||||||
{
|
{
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
|
category: integer("category_id").references(() => tech_categories.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
treePosition_x: numeric("tree_position_x", { mode: "number" }),
|
||||||
|
treePosition_y: numeric("tree_position_y", { mode: "number" }),
|
||||||
approvalStatus: enum_technologies_approval_status("approval_status")
|
approvalStatus: enum_technologies_approval_status("approval_status")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("in_progress"),
|
.default("in_progress"),
|
||||||
|
|
@ -3339,6 +3349,7 @@ export const technologies = pgTable(
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(columns) => [
|
(columns) => [
|
||||||
|
index("technologies_category_idx").on(columns.category),
|
||||||
index("technologies_updated_at_idx").on(columns.updatedAt),
|
index("technologies_updated_at_idx").on(columns.updatedAt),
|
||||||
index("technologies_created_at_idx").on(columns.createdAt),
|
index("technologies_created_at_idx").on(columns.createdAt),
|
||||||
],
|
],
|
||||||
|
|
@ -3371,6 +3382,27 @@ export const technologies_rels = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const tech_categories = pgTable(
|
||||||
|
"tech_categories",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
position: numeric("position", { mode: "number" }).notNull().default(1),
|
||||||
|
color: varchar("color"),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(columns) => [
|
||||||
|
uniqueIndex("tech_categories_name_idx").on(columns.name),
|
||||||
|
index("tech_categories_updated_at_idx").on(columns.updatedAt),
|
||||||
|
index("tech_categories_created_at_idx").on(columns.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const assets_skin_configuration_applies_to_asset_types = pgTable(
|
export const assets_skin_configuration_applies_to_asset_types = pgTable(
|
||||||
"assets_skin_configuration_applies_to_asset_types",
|
"assets_skin_configuration_applies_to_asset_types",
|
||||||
{
|
{
|
||||||
|
|
@ -6856,6 +6888,10 @@ export const payload_mcp_api_keys = pgTable(
|
||||||
technologies_create: boolean("technologies_create").default(false),
|
technologies_create: boolean("technologies_create").default(false),
|
||||||
technologies_update: boolean("technologies_update").default(false),
|
technologies_update: boolean("technologies_update").default(false),
|
||||||
technologies_delete: boolean("technologies_delete").default(false),
|
technologies_delete: boolean("technologies_delete").default(false),
|
||||||
|
techCategories_find: boolean("tech_categories_find").default(false),
|
||||||
|
techCategories_create: boolean("tech_categories_create").default(false),
|
||||||
|
techCategories_update: boolean("tech_categories_update").default(false),
|
||||||
|
techCategories_delete: boolean("tech_categories_delete").default(false),
|
||||||
assets_find: boolean("assets_find").default(false),
|
assets_find: boolean("assets_find").default(false),
|
||||||
assets_create: boolean("assets_create").default(false),
|
assets_create: boolean("assets_create").default(false),
|
||||||
assets_update: boolean("assets_update").default(false),
|
assets_update: boolean("assets_update").default(false),
|
||||||
|
|
@ -7128,6 +7164,7 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
campaignsID: integer("campaigns_id"),
|
campaignsID: integer("campaigns_id"),
|
||||||
factionsID: integer("factions_id"),
|
factionsID: integer("factions_id"),
|
||||||
technologiesID: integer("technologies_id"),
|
technologiesID: integer("technologies_id"),
|
||||||
|
"tech-categoriesID": integer("tech_categories_id"),
|
||||||
assetsID: integer("assets_id"),
|
assetsID: integer("assets_id"),
|
||||||
resourcesID: integer("resources_id"),
|
resourcesID: integer("resources_id"),
|
||||||
vehiclesID: integer("vehicles_id"),
|
vehiclesID: integer("vehicles_id"),
|
||||||
|
|
@ -7226,6 +7263,7 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
index("payload_locked_documents_rels_campaigns_id_idx").on(columns.campaignsID),
|
index("payload_locked_documents_rels_campaigns_id_idx").on(columns.campaignsID),
|
||||||
index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID),
|
index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID),
|
||||||
index("payload_locked_documents_rels_technologies_id_idx").on(columns.technologiesID),
|
index("payload_locked_documents_rels_technologies_id_idx").on(columns.technologiesID),
|
||||||
|
index("payload_locked_documents_rels_tech_categories_id_idx").on(columns["tech-categoriesID"]),
|
||||||
index("payload_locked_documents_rels_assets_id_idx").on(columns.assetsID),
|
index("payload_locked_documents_rels_assets_id_idx").on(columns.assetsID),
|
||||||
index("payload_locked_documents_rels_resources_id_idx").on(columns.resourcesID),
|
index("payload_locked_documents_rels_resources_id_idx").on(columns.resourcesID),
|
||||||
index("payload_locked_documents_rels_vehicles_id_idx").on(columns.vehiclesID),
|
index("payload_locked_documents_rels_vehicles_id_idx").on(columns.vehiclesID),
|
||||||
|
|
@ -7454,6 +7492,11 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
foreignColumns: [technologies.id],
|
foreignColumns: [technologies.id],
|
||||||
name: "payload_locked_documents_rels_technologies_fk",
|
name: "payload_locked_documents_rels_technologies_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [columns["tech-categoriesID"]],
|
||||||
|
foreignColumns: [tech_categories.id],
|
||||||
|
name: "payload_locked_documents_rels_tech_categories_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [columns["assetsID"]],
|
columns: [columns["assetsID"]],
|
||||||
foreignColumns: [assets.id],
|
foreignColumns: [assets.id],
|
||||||
|
|
@ -8870,7 +8913,12 @@ export const relations_technologies_rels = relations(technologies_rels, ({ one }
|
||||||
relationName: "technologies",
|
relationName: "technologies",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export const relations_technologies = relations(technologies, ({ many }) => ({
|
export const relations_technologies = relations(technologies, ({ one, many }) => ({
|
||||||
|
category: one(tech_categories, {
|
||||||
|
fields: [technologies.category],
|
||||||
|
references: [tech_categories.id],
|
||||||
|
relationName: "category",
|
||||||
|
}),
|
||||||
requirements_prerequisiteTechnologies: many(technologies_requirements_prerequisite_technologies, {
|
requirements_prerequisiteTechnologies: many(technologies_requirements_prerequisite_technologies, {
|
||||||
relationName: "requirements_prerequisiteTechnologies",
|
relationName: "requirements_prerequisiteTechnologies",
|
||||||
}),
|
}),
|
||||||
|
|
@ -8881,6 +8929,7 @@ export const relations_technologies = relations(technologies, ({ many }) => ({
|
||||||
relationName: "_rels",
|
relationName: "_rels",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
export const relations_tech_categories = relations(tech_categories, () => ({}));
|
||||||
export const relations_assets_skin_configuration_applies_to_asset_types = relations(
|
export const relations_assets_skin_configuration_applies_to_asset_types = relations(
|
||||||
assets_skin_configuration_applies_to_asset_types,
|
assets_skin_configuration_applies_to_asset_types,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
|
|
@ -10516,6 +10565,11 @@ export const relations_payload_locked_documents_rels = relations(
|
||||||
references: [technologies.id],
|
references: [technologies.id],
|
||||||
relationName: "technologies",
|
relationName: "technologies",
|
||||||
}),
|
}),
|
||||||
|
"tech-categoriesID": one(tech_categories, {
|
||||||
|
fields: [payload_locked_documents_rels["tech-categoriesID"]],
|
||||||
|
references: [tech_categories.id],
|
||||||
|
relationName: "tech-categories",
|
||||||
|
}),
|
||||||
assetsID: one(assets, {
|
assetsID: one(assets, {
|
||||||
fields: [payload_locked_documents_rels.assetsID],
|
fields: [payload_locked_documents_rels.assetsID],
|
||||||
references: [assets.id],
|
references: [assets.id],
|
||||||
|
|
@ -11075,6 +11129,7 @@ type DatabaseSchema = {
|
||||||
technologies_research_costs_resources: typeof technologies_research_costs_resources;
|
technologies_research_costs_resources: typeof technologies_research_costs_resources;
|
||||||
technologies: typeof technologies;
|
technologies: typeof technologies;
|
||||||
technologies_rels: typeof technologies_rels;
|
technologies_rels: typeof technologies_rels;
|
||||||
|
tech_categories: typeof tech_categories;
|
||||||
assets_skin_configuration_applies_to_asset_types: typeof assets_skin_configuration_applies_to_asset_types;
|
assets_skin_configuration_applies_to_asset_types: typeof assets_skin_configuration_applies_to_asset_types;
|
||||||
assets_crafting_crafting_data_inputs: typeof assets_crafting_crafting_data_inputs;
|
assets_crafting_crafting_data_inputs: typeof assets_crafting_crafting_data_inputs;
|
||||||
assets_crafting_crafting_data_byproducts: typeof assets_crafting_crafting_data_byproducts;
|
assets_crafting_crafting_data_byproducts: typeof assets_crafting_crafting_data_byproducts;
|
||||||
|
|
@ -11264,6 +11319,7 @@ type DatabaseSchema = {
|
||||||
relations_technologies_research_costs_resources: typeof relations_technologies_research_costs_resources;
|
relations_technologies_research_costs_resources: typeof relations_technologies_research_costs_resources;
|
||||||
relations_technologies_rels: typeof relations_technologies_rels;
|
relations_technologies_rels: typeof relations_technologies_rels;
|
||||||
relations_technologies: typeof relations_technologies;
|
relations_technologies: typeof relations_technologies;
|
||||||
|
relations_tech_categories: typeof relations_tech_categories;
|
||||||
relations_assets_skin_configuration_applies_to_asset_types: typeof relations_assets_skin_configuration_applies_to_asset_types;
|
relations_assets_skin_configuration_applies_to_asset_types: typeof relations_assets_skin_configuration_applies_to_asset_types;
|
||||||
relations_assets_crafting_crafting_data_inputs: typeof relations_assets_crafting_crafting_data_inputs;
|
relations_assets_crafting_crafting_data_inputs: typeof relations_assets_crafting_crafting_data_inputs;
|
||||||
relations_assets_crafting_crafting_data_byproducts: typeof relations_assets_crafting_crafting_data_byproducts;
|
relations_assets_crafting_crafting_data_byproducts: typeof relations_assets_crafting_crafting_data_byproducts;
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export interface Config {
|
||||||
campaigns: Campaign;
|
campaigns: Campaign;
|
||||||
factions: Faction;
|
factions: Faction;
|
||||||
technologies: Technology;
|
technologies: Technology;
|
||||||
|
'tech-categories': TechCategory;
|
||||||
assets: Asset;
|
assets: Asset;
|
||||||
resources: Resource;
|
resources: Resource;
|
||||||
vehicles: Vehicle;
|
vehicles: Vehicle;
|
||||||
|
|
@ -188,6 +189,7 @@ export interface Config {
|
||||||
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
||||||
factions: FactionsSelect<false> | FactionsSelect<true>;
|
factions: FactionsSelect<false> | FactionsSelect<true>;
|
||||||
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
||||||
|
'tech-categories': TechCategoriesSelect<false> | TechCategoriesSelect<true>;
|
||||||
assets: AssetsSelect<false> | AssetsSelect<true>;
|
assets: AssetsSelect<false> | AssetsSelect<true>;
|
||||||
resources: ResourcesSelect<false> | ResourcesSelect<true>;
|
resources: ResourcesSelect<false> | ResourcesSelect<true>;
|
||||||
vehicles: VehiclesSelect<false> | VehiclesSelect<true>;
|
vehicles: VehiclesSelect<false> | VehiclesSelect<true>;
|
||||||
|
|
@ -656,6 +658,11 @@ export interface Role {
|
||||||
| 'technologies:update'
|
| 'technologies:update'
|
||||||
| 'technologies:delete'
|
| 'technologies:delete'
|
||||||
| 'admin:technologies:manage'
|
| 'admin:technologies:manage'
|
||||||
|
| 'tech-categories:create'
|
||||||
|
| 'tech-categories:read'
|
||||||
|
| 'tech-categories:update'
|
||||||
|
| 'tech-categories:delete'
|
||||||
|
| 'admin:tech-categories:manage'
|
||||||
| 'assets:create'
|
| 'assets:create'
|
||||||
| 'assets:read'
|
| 'assets:read'
|
||||||
| 'assets:update'
|
| 'assets:update'
|
||||||
|
|
@ -1538,6 +1545,17 @@ export interface Resource {
|
||||||
export interface Technology {
|
export interface Technology {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Tech Tree column this technology belongs to. Uncategorized technologies are auto-placed on the Tech Tree by prerequisite depth.
|
||||||
|
*/
|
||||||
|
category?: (number | null) | TechCategory;
|
||||||
|
/**
|
||||||
|
* Manual position on the Tech Tree canvas (set by dragging a node). Leave empty to use the automatic layout.
|
||||||
|
*/
|
||||||
|
treePosition?: {
|
||||||
|
x?: number | null;
|
||||||
|
y?: number | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Mark whether this technology is ready for developer approval and implementation into the live environment.
|
* Mark whether this technology is ready for developer approval and implementation into the live environment.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1598,6 +1616,27 @@ export interface Technology {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "tech-categories".
|
||||||
|
*/
|
||||||
|
export interface TechCategory {
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Category label shown as a column band on the Tech Tree (e.g. Tier 1, Tier 2).
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Timeline position: lower numbers sit further left on the Tech Tree. Categories with the same position are ordered alphabetically.
|
||||||
|
*/
|
||||||
|
position: number;
|
||||||
|
/**
|
||||||
|
* Optional hex accent color for the band on the Tech Tree (e.g. #22d3ee).
|
||||||
|
*/
|
||||||
|
color?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "labor-classifications".
|
* via the `definition` "labor-classifications".
|
||||||
|
|
@ -5732,6 +5771,24 @@ export interface PayloadMcpApiKey {
|
||||||
*/
|
*/
|
||||||
delete?: boolean | null;
|
delete?: boolean | null;
|
||||||
};
|
};
|
||||||
|
techCategories?: {
|
||||||
|
/**
|
||||||
|
* Allow clients to find tech-categories.
|
||||||
|
*/
|
||||||
|
find?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Allow clients to create tech-categories.
|
||||||
|
*/
|
||||||
|
create?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Allow clients to update tech-categories.
|
||||||
|
*/
|
||||||
|
update?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Allow clients to delete tech-categories.
|
||||||
|
*/
|
||||||
|
delete?: boolean | null;
|
||||||
|
};
|
||||||
assets?: {
|
assets?: {
|
||||||
/**
|
/**
|
||||||
* Allow clients to find assets.
|
* Allow clients to find assets.
|
||||||
|
|
@ -6735,6 +6792,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'technologies';
|
relationTo: 'technologies';
|
||||||
value: number | Technology;
|
value: number | Technology;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'tech-categories';
|
||||||
|
value: number | TechCategory;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'assets';
|
relationTo: 'assets';
|
||||||
value: number | Asset;
|
value: number | Asset;
|
||||||
|
|
@ -7836,6 +7897,13 @@ export interface FactionsSelect<T extends boolean = true> {
|
||||||
*/
|
*/
|
||||||
export interface TechnologiesSelect<T extends boolean = true> {
|
export interface TechnologiesSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
|
category?: T;
|
||||||
|
treePosition?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
x?: T;
|
||||||
|
y?: T;
|
||||||
|
};
|
||||||
approvalStatus?: T;
|
approvalStatus?: T;
|
||||||
type?: T;
|
type?: T;
|
||||||
summary?: T;
|
summary?: T;
|
||||||
|
|
@ -7875,6 +7943,17 @@ export interface TechnologiesSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "tech-categories_select".
|
||||||
|
*/
|
||||||
|
export interface TechCategoriesSelect<T extends boolean = true> {
|
||||||
|
name?: T;
|
||||||
|
position?: T;
|
||||||
|
color?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "assets_select".
|
* via the `definition` "assets_select".
|
||||||
|
|
@ -9513,6 +9592,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
||||||
update?: T;
|
update?: T;
|
||||||
delete?: T;
|
delete?: T;
|
||||||
};
|
};
|
||||||
|
techCategories?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
find?: T;
|
||||||
|
create?: T;
|
||||||
|
update?: T;
|
||||||
|
delete?: T;
|
||||||
|
};
|
||||||
assets?:
|
assets?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { Shipments } from "@/collections/logistics/Shipments";
|
||||||
import { Assignments } from "@/collections/users/Assignments";
|
import { Assignments } from "@/collections/users/Assignments";
|
||||||
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
|
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
|
||||||
import { Technologies } from "@/collections/intelligence/Technologies";
|
import { Technologies } from "@/collections/intelligence/Technologies";
|
||||||
|
import { TechCategories } from "@/collections/intelligence/TechCategories";
|
||||||
import { GameRules } from "@/collections/game/GameRules";
|
import { GameRules } from "@/collections/game/GameRules";
|
||||||
import { GameStructures } from "@/collections/game/GameStructures";
|
import { GameStructures } from "@/collections/game/GameStructures";
|
||||||
import { GameHardResources } from "@/collections/game/GameHardResources";
|
import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||||
|
|
@ -275,6 +276,7 @@ const collections = [
|
||||||
Campaigns,
|
Campaigns,
|
||||||
Factions,
|
Factions,
|
||||||
Technologies,
|
Technologies,
|
||||||
|
TechCategories,
|
||||||
|
|
||||||
// Logistics
|
// Logistics
|
||||||
Assets,
|
Assets,
|
||||||
|
|
@ -415,6 +417,13 @@ export default buildConfig({
|
||||||
Logo: "@/components/graphics/AppLogo",
|
Logo: "@/components/graphics/AppLogo",
|
||||||
Icon: "@/components/graphics/AppIcon",
|
Icon: "@/components/graphics/AppIcon",
|
||||||
},
|
},
|
||||||
|
Nav: "@/components/admin/tech-tree/TechTreeNav",
|
||||||
|
views: {
|
||||||
|
techTree: {
|
||||||
|
path: "/tech-tree",
|
||||||
|
Component: "@/components/admin/tech-tree/TechTreeView",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
globals: [GameRules, Shims],
|
globals: [GameRules, Shims],
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||||
{ value: "admin:technologies:manage", label: "Manage Admin Page" },
|
{ value: "admin:technologies:manage", label: "Manage Admin Page" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: "Tech Categories",
|
||||||
|
permissions: [
|
||||||
|
{ value: "tech-categories:create", label: "Create" },
|
||||||
|
{ value: "tech-categories:read", label: "Read" },
|
||||||
|
{ value: "tech-categories:update", label: "Update" },
|
||||||
|
{ value: "tech-categories:delete", label: "Delete" },
|
||||||
|
{ value: "admin:tech-categories:manage", label: "Manage Admin Page" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Logistics -----------------------------------------------------------
|
// ---- Logistics -----------------------------------------------------------
|
||||||
{
|
{
|
||||||
|
|
@ -814,6 +824,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
||||||
"technologies:update",
|
"technologies:update",
|
||||||
"technologies:delete",
|
"technologies:delete",
|
||||||
"admin:technologies:manage",
|
"admin:technologies:manage",
|
||||||
|
"tech-categories:create",
|
||||||
|
"tech-categories:read",
|
||||||
|
"tech-categories:update",
|
||||||
|
"tech-categories:delete",
|
||||||
|
"admin:tech-categories:manage",
|
||||||
"assets:create",
|
"assets:create",
|
||||||
"assets:read",
|
"assets:read",
|
||||||
"assets:update",
|
"assets:update",
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
/**
|
/**
|
||||||
* Import an Arma inventory export into the Polaris Task Force Payload CMS.
|
* Import an Arma inventory export into the Polaris Task Force Payload CMS.
|
||||||
*
|
*
|
||||||
* Consumes a catalog JSON produced by the in-game ptf_export addon (records
|
* Thin CLI wrapper around src/lib/arma-import/catalog.ts (record contract,
|
||||||
* carrying classname, display name, type, description, picture) plus an
|
* planning and upsert logic live there). Records are UPSERTED into the
|
||||||
* optional icon_map, and upserts rows into the `assets` / `vehicles`
|
* `assets` / `vehicles` collections: assets keyed by `className`, vehicles by
|
||||||
* collections. Thumbnails are materialized from .paa inventory icons found
|
* `classname`. Thumbnails are materialized from .paa inventory icons found
|
||||||
* under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via
|
* under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via
|
||||||
* `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied
|
* `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied
|
||||||
* via --screenshots (matched by classname).
|
* via --screenshots (matched by classname).
|
||||||
|
|
@ -22,10 +22,18 @@
|
||||||
* [--limit <n>] import at most n records
|
* [--limit <n>] import at most n records
|
||||||
*/
|
*/
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname } from "path";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
|
import {
|
||||||
|
enrichPlanMedia,
|
||||||
|
planRecord,
|
||||||
|
resolveFuelConfig,
|
||||||
|
upsertPlan,
|
||||||
|
type CatalogRecord,
|
||||||
|
type ImportPlan,
|
||||||
|
} from "@/lib/arma-import/catalog";
|
||||||
|
|
||||||
interface CliOpts {
|
interface CliOpts {
|
||||||
catalog?: string[];
|
catalog?: string[];
|
||||||
|
|
@ -59,150 +67,6 @@ function resolveHemtt(explicit?: string): string {
|
||||||
throw new Error("hemtt not found; pass --hemtt <path>");
|
throw new Error("hemtt not found; pass --hemtt <path>");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the Assets collection's assetType enum (see payload-types.ts).
|
|
||||||
// The in-game export (ptf_export addon) emits exactly these values.
|
|
||||||
const KNOWN_ASSET_TYPES = new Set([
|
|
||||||
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
|
||||||
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
|
||||||
"explosive", "skin", "miscellaneous",
|
|
||||||
]);
|
|
||||||
function classifyAssetType(clazz: string, type: string): string {
|
|
||||||
const t = (type || "").toLowerCase();
|
|
||||||
if (KNOWN_ASSET_TYPES.has(t)) return t;
|
|
||||||
const c = clazz.toLowerCase();
|
|
||||||
if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine";
|
|
||||||
if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon";
|
|
||||||
if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine";
|
|
||||||
return "item";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVehicle(clazz: string, type: string): boolean {
|
|
||||||
if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true;
|
|
||||||
const c = clazz.toLowerCase();
|
|
||||||
return /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) &&
|
|
||||||
!/backpack|vest|helmet/.test(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookupIcon(icons: Record<string, unknown>, clazz: string): string {
|
|
||||||
if (icons && typeof icons === "object") {
|
|
||||||
for (const [k, v] of Object.entries(icons)) {
|
|
||||||
if (k.toLowerCase() === clazz.toLowerCase() && typeof v === "string") return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePaaPath(ref: string, paaRoot: string): string {
|
|
||||||
if (!ref) return "";
|
|
||||||
const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/");
|
|
||||||
const cand = join(paaRoot, clean);
|
|
||||||
return existsSync(cand) ? cand : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined {
|
|
||||||
const base = basename(paaPath).replace(/\.paa$/i, "") + ".png";
|
|
||||||
const dest = join(outDir, base);
|
|
||||||
if (existsSync(dest)) return dest;
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" });
|
|
||||||
return r.status === 0 && existsSync(dest) ? dest : undefined;
|
|
||||||
}
|
|
||||||
function basename(p: string): string {
|
|
||||||
const seg = p.split(/[\\/]/);
|
|
||||||
return seg[seg.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportPlan {
|
|
||||||
kind: "asset" | "vehicle";
|
|
||||||
clazz: string;
|
|
||||||
displayName: string;
|
|
||||||
description: string;
|
|
||||||
assetType: string;
|
|
||||||
mod: string;
|
|
||||||
pictureRef: string;
|
|
||||||
paaPath: string;
|
|
||||||
pngPath: string;
|
|
||||||
screenshotPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function planImport(
|
|
||||||
rec: Record<string, unknown>,
|
|
||||||
lookups: { icons: Record<string, unknown>; paaRoot: string; screenshots: string; outDir: string; hemtt: string },
|
|
||||||
): ImportPlan | undefined {
|
|
||||||
const clazz = String(rec.classname ?? rec.class ?? "").trim();
|
|
||||||
const type = String(rec.type ?? rec.category ?? "").trim();
|
|
||||||
if (!clazz) return undefined;
|
|
||||||
const vehicle = isVehicle(clazz, type);
|
|
||||||
const assetType = vehicle ? "vehicle" : classifyAssetType(clazz, type);
|
|
||||||
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
|
|
||||||
const description = String(rec.description ?? "").trim();
|
|
||||||
const pictureRef = String(rec.picture ?? "").trim();
|
|
||||||
const mod = String(rec.mod ?? "").trim() || "Arma 3";
|
|
||||||
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
|
|
||||||
const ref = iconRef || pictureRef;
|
|
||||||
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
|
|
||||||
const pngPath = paaPath
|
|
||||||
? (convertPaaToPng(lookups.hemtt, paaPath, join(lookups.outDir)) ?? "")
|
|
||||||
: "";
|
|
||||||
const screenshotPath = lookups.screenshots
|
|
||||||
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
|
|
||||||
: "";
|
|
||||||
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, mod, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
|
|
||||||
const r = await payload.find({ collection: "resources", limit: 50, where: { name: { equals: "Fuel" } } });
|
|
||||||
const fuel = r.docs.find((d: any) => String(d.name).toLowerCase() === "fuel");
|
|
||||||
if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
|
|
||||||
return { id: fuel.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeEntry(
|
|
||||||
payload: any,
|
|
||||||
plan: ImportPlan,
|
|
||||||
opts: { outDir: string },
|
|
||||||
fuelId: number,
|
|
||||||
): Promise<string> {
|
|
||||||
const common = {
|
|
||||||
class_name: plan.clazz,
|
|
||||||
display_name: plan.displayName,
|
|
||||||
description: plan.description || null,
|
|
||||||
};
|
|
||||||
const mediaId = plan.pngPath || plan.screenshotPath ? await uploadMedia(payload, plan.pngPath || plan.screenshotPath).catch(() => undefined) : undefined;
|
|
||||||
|
|
||||||
if (plan.kind === "vehicle") {
|
|
||||||
return payload.create({
|
|
||||||
collection: "vehicles",
|
|
||||||
data: {
|
|
||||||
...common,
|
|
||||||
vehicleName: plan.displayName,
|
|
||||||
...(mediaId ? { vehicle_metadata_thumbnail: mediaId } : {}),
|
|
||||||
fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 },
|
|
||||||
},
|
|
||||||
}).then((d: any) => `vehicle:${d.id}`);
|
|
||||||
}
|
|
||||||
return payload.create({
|
|
||||||
collection: "assets",
|
|
||||||
data: {
|
|
||||||
...common,
|
|
||||||
assetType: plan.assetType,
|
|
||||||
...(plan.pngPath ? { "Asset Metadata": { mod: plan.mod, thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
|
||||||
},
|
|
||||||
}).then((d: any) => `asset:${d.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadMedia(payload: { create: (args: { collection: string; data: any; file: any }) => Promise<any> }, filePath: string): Promise<number> {
|
|
||||||
const { readFileSync } = await import("fs");
|
|
||||||
const fileName = basename(filePath);
|
|
||||||
const buffer = readFileSync(filePath);
|
|
||||||
const res = await payload.create({
|
|
||||||
collection: "media",
|
|
||||||
data: { alt: fileName, filename: fileName },
|
|
||||||
file: { data: buffer, mimetype: "image/png", name: fileName, size: buffer.length },
|
|
||||||
});
|
|
||||||
return Number(res.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const opts = parseCli(process.argv.slice(2));
|
const opts = parseCli(process.argv.slice(2));
|
||||||
const hemtt = resolveHemtt(opts.hemtt?.[0]);
|
const hemtt = resolveHemtt(opts.hemtt?.[0]);
|
||||||
|
|
@ -210,10 +74,10 @@ async function main() {
|
||||||
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist");
|
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist");
|
||||||
const dryRun = !!opts["dry-run"];
|
const dryRun = !!opts["dry-run"];
|
||||||
const approve = !!opts.approve || dryRun;
|
const approve = !!opts.approve || dryRun;
|
||||||
|
if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write");
|
||||||
const outDir = opts.out?.[0] ?? dirname(catalogPath);
|
const outDir = opts.out?.[0] ?? dirname(catalogPath);
|
||||||
const paaRoot = opts["paa-root"]?.[0] ?? "";
|
const paaRoot = opts["paa-root"]?.[0] ?? "";
|
||||||
const screenshots = opts.screenshots?.[0] ?? "";
|
const screenshots = opts.screenshots?.[0] ?? "";
|
||||||
if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write");
|
|
||||||
|
|
||||||
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||||
const records = Array.isArray(catalog) ? catalog : [catalog];
|
const records = Array.isArray(catalog) ? catalog : [catalog];
|
||||||
|
|
@ -222,34 +86,47 @@ async function main() {
|
||||||
icons = JSON.parse(readFileSync(opts.icons[0], "utf8"));
|
icons = JSON.parse(readFileSync(opts.icons[0], "utf8"));
|
||||||
}
|
}
|
||||||
const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length;
|
const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length;
|
||||||
const lookups = { icons, paaRoot, screenshots, outDir, hemtt };
|
|
||||||
|
|
||||||
console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`);
|
console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`);
|
||||||
const plans: ImportPlan[] = [];
|
const plans: ImportPlan[] = [];
|
||||||
for (const rec of records.slice(0, limit)) {
|
for (const rec of records.slice(0, limit)) {
|
||||||
const plan = planImport(rec, lookups);
|
const plan = planRecord(rec as CatalogRecord, { icons });
|
||||||
if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; }
|
if (!plan) {
|
||||||
|
console.log(` SKIP ${String((rec as CatalogRecord).classname ?? "")} (not an importable record)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Media resolution is filesystem work: read-only during dry-run (no
|
||||||
|
// PAA->PNG conversion), full conversion in approve mode.
|
||||||
|
enrichPlanMedia(plan, { paaRoot, screenshots, outDir, hemtt }, !dryRun);
|
||||||
plans.push(plan);
|
plans.push(plan);
|
||||||
const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]";
|
const img = plan.media.pngPath || plan.media.paaPath
|
||||||
console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`);
|
? "[img: item-png]"
|
||||||
|
: plan.media.screenshotPath ? "[img: screenshot]" : "[no img]";
|
||||||
|
console.log(` -> ${plan.kind}\t${plan.className}\t${plan.displayName}\t${img}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dryRun) { console.log(`\nDry run: ${plans.length} would import, 0 applied.`); return; }
|
if (dryRun) {
|
||||||
|
console.log(`\nDry run: ${plans.length} would import, 0 applied.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const fuel = await resolveFuelConfig(payload);
|
// Fuel is only needed for vehicle records; resolve lazily so asset-only
|
||||||
let ok = 0, err = 0;
|
// catalogs import even without a Fuel resource.
|
||||||
|
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload) : undefined;
|
||||||
|
|
||||||
|
let created = 0, updated = 0, errors = 0;
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
try {
|
try {
|
||||||
const id = await writeEntry(payload, plan, { outDir }, fuel.id);
|
const res = await upsertPlan(payload, plan, fuelId);
|
||||||
console.log(`Applied ${id}`);
|
console.log(`Applied ${res.action} ${res.collection}:${res.id}`);
|
||||||
ok++;
|
if (res.action === "created") created++; else updated++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`ERR ${plan.clazz}: ${(e as Error).message}`);
|
console.error(`ERR ${plan.className}: ${(e as Error).message}`);
|
||||||
err++;
|
errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Done: ${ok} ok, ${err} errors.`);
|
console.log(`Done: ${created} created, ${updated} updated, ${errors} errors.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const DIVISION_ADMIN_QUALIFICATIONS: Record<
|
||||||
(payload: Payload, user: { id: number | string }) => Promise<boolean>
|
(payload: Payload, user: { id: number | string }) => Promise<boolean>
|
||||||
> = {
|
> = {
|
||||||
technologies: hasIntelligenceQualification,
|
technologies: hasIntelligenceQualification,
|
||||||
|
"tech-categories": hasIntelligenceQualification,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
512
tests/e2e/tech-tree.e2e.spec.ts
Normal file
512
tests/e2e/tech-tree.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tech Tree admin view (/admin/tech-tree): custom React Flow view over the
|
||||||
|
* technologies collection. Covers the Goal's evidence list: nav entry under
|
||||||
|
* Intelligence, node cards + category bands + AND/OR prerequisite edges,
|
||||||
|
* preview side panel, edit in the full-screen admin edit view, and canvas
|
||||||
|
* quick-create (existing + inline-created category) with the new tech visible
|
||||||
|
* in the tree afterwards.
|
||||||
|
*/
|
||||||
|
test.describe("Tech Tree admin view", () => {
|
||||||
|
let cat1Id: number;
|
||||||
|
let cat2Id: number;
|
||||||
|
let alphaId: number;
|
||||||
|
let betaId: number;
|
||||||
|
let gammaId: number;
|
||||||
|
|
||||||
|
async function login(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Username").fill("dev");
|
||||||
|
await page.getByLabel("Password").fill("Test123");
|
||||||
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDoc(page: import("@playwright/test").Page, slug: string, data: Record<string, unknown>) {
|
||||||
|
const res = await page.request.post(`/api/${slug}`, { data });
|
||||||
|
expect(res.ok(), `${slug} create failed: ${res.status()} ${await res.text()}`).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
return body.doc as { id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoTree(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/admin/tech-tree");
|
||||||
|
await expect(page.getByTestId("tech-tree-canvas")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nav entry, tree render, preview panel, edit navigation, quick-create", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// Seed categories + technologies through the REST API (cookie-authed).
|
||||||
|
const cat1 = await createDoc(page, "tech-categories", {
|
||||||
|
name: "Tier 1 (E2E)",
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
cat1Id = cat1.id;
|
||||||
|
const cat2 = await createDoc(page, "tech-categories", {
|
||||||
|
name: "Tier 2 (E2E)",
|
||||||
|
position: 2,
|
||||||
|
});
|
||||||
|
cat2Id = cat2.id;
|
||||||
|
|
||||||
|
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||||
|
const alpha = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Seed Alpha",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Uncategorized seed technology.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
alphaId = alpha.id;
|
||||||
|
const beta = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Seed Beta",
|
||||||
|
type: "asset",
|
||||||
|
summary: "Tier 1 seed asset.",
|
||||||
|
category: cat1Id,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
betaId = beta.id;
|
||||||
|
const gamma = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Seed Gamma",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Tier 2 seed with an AND prerequisite.",
|
||||||
|
category: cat2Id,
|
||||||
|
requirements: {
|
||||||
|
prerequisiteTechnologies: [{ requiresAnd: { technology: [betaId] } }],
|
||||||
|
},
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
gammaId = gamma.id;
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
|
||||||
|
// Nav entry under Intelligence, next to the technologies collection link.
|
||||||
|
const techTreeNav = page.locator("#nav-tech-tree");
|
||||||
|
await expect(techTreeNav).toHaveText("Tech Tree");
|
||||||
|
// It sits inside the same group as the Technologies collection link.
|
||||||
|
await expect(techTreeNav.locator("xpath=ancestor::*[@class][1]//a[@id='nav-technologies']")).toBeVisible();
|
||||||
|
|
||||||
|
// Nodes, category bands, and the AND prerequisite edge render.
|
||||||
|
await expect(page.getByTestId(`tech-node-${alphaId}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`tech-node-${betaId}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`tech-node-${gammaId}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tech-band-uncategorized")).toContainText("Uncategorized");
|
||||||
|
await expect(page.getByTestId(`tech-band-cat-${cat1Id}`)).toContainText("Tier 1 (E2E)");
|
||||||
|
await expect(page.getByTestId(`tech-band-cat-${cat2Id}`)).toContainText("Tier 2 (E2E)");
|
||||||
|
await expect(
|
||||||
|
page.locator(`.react-flow__edge[data-id="and-${betaId}-${gammaId}"]`),
|
||||||
|
).toBeAttached();
|
||||||
|
|
||||||
|
// Preview panel: click the Gamma node.
|
||||||
|
await page.getByTestId(`tech-node-${gammaId}`).click();
|
||||||
|
const panel = page.getByTestId("tech-preview-panel");
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(panel).toContainText("Tech Tree Seed Gamma");
|
||||||
|
await expect(panel).toContainText("Tier 2 (E2E)");
|
||||||
|
await expect(panel).toContainText("Tree Seed Beta");
|
||||||
|
await expect(panel).toContainText("TECH DOSSIER");
|
||||||
|
|
||||||
|
// Edit opens the full-screen admin edit view for that document.
|
||||||
|
await page.getByTestId("tech-preview-edit").click();
|
||||||
|
await page.waitForURL(`**/admin/collections/technologies/${gammaId}`, { timeout: 60_000 });
|
||||||
|
await expect(page.locator("#field-name")).toHaveValue("Tech Tree Seed Gamma");
|
||||||
|
|
||||||
|
// Quick-create with an EXISTING category: node must appear in the tree.
|
||||||
|
await gotoTree(page);
|
||||||
|
await page.getByTestId("tech-tree-create").click();
|
||||||
|
const dialog = page.getByTestId("tech-tree-create-dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await page.getByTestId("tech-create-name").fill("Tech Tree Seed Delta");
|
||||||
|
await page.getByTestId("tech-create-summary").fill("Quick-created with an existing category.");
|
||||||
|
await page.getByTestId("tech-category-input").fill("Tier 1");
|
||||||
|
await page.getByRole("button", { name: /Tier 1 \(E2E\)/ }).click();
|
||||||
|
await expect(page.getByTestId("tech-category-value")).toContainText("Tier 1 (E2E)");
|
||||||
|
await page.getByTestId("tech-create-submit").click();
|
||||||
|
await page.waitForURL(/\/admin\/collections\/technologies\/\d+/, { timeout: 60_000 });
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
const deltaDocs = await page.request.get(
|
||||||
|
"/api/technologies?where[name][equals]=Tech Tree Seed Delta&limit=1",
|
||||||
|
);
|
||||||
|
const deltaBody = await deltaDocs.json();
|
||||||
|
const deltaId = deltaBody.docs?.[0]?.id as number;
|
||||||
|
expect(deltaId).toBeTruthy();
|
||||||
|
await expect(page.getByTestId(`tech-node-${deltaId}`)).toBeVisible();
|
||||||
|
|
||||||
|
// Quick-create with an INLINE-CREATED category ("enter values").
|
||||||
|
await page.getByTestId("tech-tree-create").click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await page.getByTestId("tech-create-name").fill("Tech Tree Seed Epsilon");
|
||||||
|
await page.getByTestId("tech-create-summary").fill("Quick-created with a new inline category.");
|
||||||
|
await page.getByTestId("tech-category-input").fill("Tier 3 (E2E)");
|
||||||
|
await page.getByTestId("tech-category-create-option").click();
|
||||||
|
await page.getByTestId("tech-create-submit").click();
|
||||||
|
await page.waitForURL(/\/admin\/collections\/technologies\/\d+/, { timeout: 60_000 });
|
||||||
|
|
||||||
|
// The new category exists and its band contains the new tech.
|
||||||
|
const newCatRes = await page.request.get(
|
||||||
|
"/api/tech-categories?where[name][equals]=Tier 3 (E2E)&limit=1",
|
||||||
|
);
|
||||||
|
const newCatBody = await newCatRes.json();
|
||||||
|
const newCatId = newCatBody.docs?.[0]?.id as number;
|
||||||
|
expect(newCatId).toBeTruthy();
|
||||||
|
await gotoTree(page);
|
||||||
|
await expect(page.getByTestId(`tech-band-cat-${newCatId}`)).toContainText("Tier 3 (E2E)");
|
||||||
|
const epsilonRes = await page.request.get(
|
||||||
|
"/api/technologies?where[name][equals]=Tech Tree Seed Epsilon&limit=1",
|
||||||
|
);
|
||||||
|
const epsilonBody = await epsilonRes.json();
|
||||||
|
const epsilonId = epsilonBody.docs?.[0]?.id as number;
|
||||||
|
expect(epsilonId).toBeTruthy();
|
||||||
|
await expect(page.getByTestId(`tech-node-${epsilonId}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nodes drag to a persisted position and Auto-arrange restores the layout", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||||
|
const draggable = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Drag Target",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Dragged around by the e2e spec.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
const node = page.getByTestId(`tech-node-${draggable.id}`);
|
||||||
|
await expect(node).toBeVisible();
|
||||||
|
|
||||||
|
// Drag the node with real pointer events.
|
||||||
|
const before = (await node.boundingBox())!;
|
||||||
|
await page.mouse.move(before.x + before.width / 2, before.y + before.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(before.x + before.width / 2 + 240, before.y + before.height / 2 + 160, {
|
||||||
|
steps: 12,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// The position is persisted to the document.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${draggable.id}`);
|
||||||
|
const body = await res.json();
|
||||||
|
return body?.treePosition?.x ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
const storedRes = await page.request.get(`/api/technologies/${draggable.id}`);
|
||||||
|
const stored = (await storedRes.json()).treePosition as { x: number; y: number };
|
||||||
|
expect(stored.y).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// A reload renders the node at the stored coordinates.
|
||||||
|
await gotoTree(page);
|
||||||
|
const wrapper = page.locator(`.react-flow__node[data-id="${draggable.id}"]`);
|
||||||
|
await expect(wrapper).toHaveAttribute(
|
||||||
|
"style",
|
||||||
|
new RegExp(`translate\\(${stored.x}px,\\s*${stored.y}px\\)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-arrange clears the manual position.
|
||||||
|
await page.getByTestId("tech-tree-auto-arrange").click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${draggable.id}`);
|
||||||
|
const body = await res.json();
|
||||||
|
return body?.treePosition?.x ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBeNull();
|
||||||
|
await expect(page.getByTestId("tech-tree-auto-arrange")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("save positions commits every node so the layout survives a reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||||
|
const a = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Save A",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Save target A.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
const b = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Save B",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Save target B.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
const c = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Save C",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Save target C.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
const ids = [a.id, b.id, c.id];
|
||||||
|
for (const id of ids) {
|
||||||
|
await expect(page.getByTestId(`tech-node-${id}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record where every node currently sits.
|
||||||
|
const transform = (id: number) =>
|
||||||
|
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
|
||||||
|
const m = (el as HTMLElement).style.transform.match(
|
||||||
|
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
|
||||||
|
);
|
||||||
|
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
|
||||||
|
});
|
||||||
|
const before = new Map<number, { x: number; y: number }>();
|
||||||
|
for (const id of ids) before.set(id, (await transform(id))!);
|
||||||
|
|
||||||
|
// Commit every node's current position.
|
||||||
|
await page.getByTestId("tech-tree-save-positions").click();
|
||||||
|
await expect(page.getByTestId("tech-tree-save-status")).toContainText("saved");
|
||||||
|
|
||||||
|
// Each node's treePosition is now persisted.
|
||||||
|
for (const id of ids) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${id}`);
|
||||||
|
return (await res.json())?.treePosition?.x ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reload keeps every node in its saved spot (nothing auto-resets).
|
||||||
|
await gotoTree(page);
|
||||||
|
for (const id of ids) {
|
||||||
|
await expect.poll(() => transform(id), { timeout: 15_000 }).toEqual(before.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positions were saved, so Auto-arrange is now offered.
|
||||||
|
await expect(page.getByTestId("tech-tree-auto-arrange")).toBeVisible();
|
||||||
|
|
||||||
|
// The e2e suite shares one database (only truncated at global setup), so
|
||||||
|
// this test cleans up its seeded nodes. Leaving them behind, with their
|
||||||
|
// pinned positions, makes the connections test that follows trip up.
|
||||||
|
for (const id of ids) {
|
||||||
|
const del = await page.request.delete(`/api/technologies/${id}`);
|
||||||
|
expect(del.ok(), `cleanup delete ${id} failed: ${del.status()}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("connections and the lite editor persist through the REST API", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||||
|
const alpha = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Link Alpha",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Link source.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
const beta = await createDoc(page, "technologies", {
|
||||||
|
name: "Tech Tree Link Beta",
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Link target.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
await expect(page.getByTestId(`tech-node-${alpha.id}`)).toBeVisible();
|
||||||
|
|
||||||
|
// Drag from Alpha's source handle onto Beta to create an AND prerequisite.
|
||||||
|
const sourceHandle = page.locator(
|
||||||
|
`.react-flow__node[data-id="${alpha.id}"] .react-flow__handle-right`,
|
||||||
|
);
|
||||||
|
const targetNode = page.getByTestId(`tech-node-${beta.id}`);
|
||||||
|
const targetTransformBeforeConnect = await page
|
||||||
|
.locator(`.react-flow__node[data-id="${beta.id}"]`)
|
||||||
|
.evaluate((el) => (el as HTMLElement).style.transform);
|
||||||
|
const handleBox = (await sourceHandle.boundingBox())!;
|
||||||
|
const targetBox = (await targetNode.boundingBox())!;
|
||||||
|
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
|
||||||
|
steps: 20,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const edgeId = `and-${alpha.id}-${beta.id}`;
|
||||||
|
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toBeAttached();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||||
|
const body = await res.json();
|
||||||
|
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
|
||||||
|
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
|
||||||
|
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
|
||||||
|
);
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toContain(alpha.id);
|
||||||
|
|
||||||
|
// UX contract: the connected node must NOT jump. Its on-canvas position is
|
||||||
|
// unchanged (pinned), and the pin is persisted so a reload keeps it.
|
||||||
|
const wrapperSel = `.react-flow__node[data-id="${beta.id}"]`;
|
||||||
|
const positionAfterConnect = await page
|
||||||
|
.locator(wrapperSel)
|
||||||
|
.evaluate((el) => (el as HTMLElement).style.transform);
|
||||||
|
expect(positionAfterConnect).toBe(targetTransformBeforeConnect);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||||
|
const body = await res.json();
|
||||||
|
return body?.treePosition?.x ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
// Auto-arrange clears the pin and moves it to the ordered position
|
||||||
|
// (one column right of its prerequisite).
|
||||||
|
await page.getByTestId("tech-tree-auto-arrange").click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||||
|
return (await res.json())?.treePosition?.x ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBeNull();
|
||||||
|
const parentX = await page
|
||||||
|
.locator(`.react-flow__node[data-id="${alpha.id}"]`)
|
||||||
|
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
page
|
||||||
|
.locator(wrapperSel)
|
||||||
|
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toBe(parentX);
|
||||||
|
|
||||||
|
// Removing the edge (select + Backspace) drops the prerequisite. React Flow
|
||||||
|
// hit-tests edges through its wide invisible interaction path.
|
||||||
|
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`);
|
||||||
|
const onPath = await page.evaluate((id) => {
|
||||||
|
const pathEl = document.querySelector(
|
||||||
|
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
|
||||||
|
) as SVGPathElement | null;
|
||||||
|
if (!pathEl) return null;
|
||||||
|
const local = pathEl.getPointAtLength(pathEl.getTotalLength() / 2);
|
||||||
|
const svg = pathEl.ownerSVGElement!;
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = local.x;
|
||||||
|
pt.y = local.y;
|
||||||
|
const screen = pt.matrixTransform(pathEl.getScreenCTM()!);
|
||||||
|
return { x: screen.x, y: screen.y };
|
||||||
|
}, edgeId);
|
||||||
|
expect(onPath).not.toBeNull();
|
||||||
|
await page.mouse.click(onPath!.x, onPath!.y);
|
||||||
|
await expect(edge).toHaveClass(/selected/);
|
||||||
|
await page.keyboard.press("Backspace");
|
||||||
|
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toHaveCount(0);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||||
|
const body = await res.json();
|
||||||
|
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
|
||||||
|
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
|
||||||
|
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
|
||||||
|
);
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toContain(alpha.id);
|
||||||
|
|
||||||
|
// The lite panel edits and saves fields.
|
||||||
|
await page.getByTestId(`tech-node-${beta.id}`).click();
|
||||||
|
const panel = page.getByTestId("tech-preview-panel");
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await page.getByTestId("tech-panel-summary").fill("Edited from the lite panel.");
|
||||||
|
await page.getByTestId("tech-panel-save").click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}`);
|
||||||
|
return (await res.json())?.summary ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe("Edited from the lite panel.");
|
||||||
|
|
||||||
|
// The panel also edits the name, reflected on the node card.
|
||||||
|
await page.getByTestId("tech-panel-name").fill("Tech Tree Link Beta Renamed");
|
||||||
|
await page.getByTestId("tech-panel-save").click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await page.request.get(`/api/technologies/${beta.id}`);
|
||||||
|
return (await res.json())?.name ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe("Tech Tree Link Beta Renamed");
|
||||||
|
await expect(page.getByTestId(`tech-node-${beta.id}`)).toContainText(
|
||||||
|
"Tech Tree Link Beta Renamed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a large uncategorized pool wraps into multiple columns instead of one strip", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
const doc = await createDoc(page, "technologies", {
|
||||||
|
name: `Tech Tree Wrap Seed ${String(i).padStart(2, "0")}`,
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Uncategorized wrapping regression seed.",
|
||||||
|
category: null,
|
||||||
|
researchCosts: costs,
|
||||||
|
});
|
||||||
|
ids.push(doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await gotoTree(page);
|
||||||
|
await expect(page.getByTestId(`tech-node-${ids[0]}`)).toBeVisible();
|
||||||
|
|
||||||
|
// 12 uncategorized techs exceed MAX_ROWS_PER_COLUMN (10), so at least two
|
||||||
|
// distinct x offsets must exist (previously: one 1700px-tall column).
|
||||||
|
const xs = new Set<string>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const style = (await page.locator(`.react-flow__node[data-id="${id}"]`).getAttribute("style")) ?? "";
|
||||||
|
const match = style.match(/translate\((-?[\d.]+)px/);
|
||||||
|
if (match) xs.add(match[1]);
|
||||||
|
}
|
||||||
|
expect(xs.size).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
286
tests/int/arma-catalog-import.int.spec.ts
Normal file
286
tests/int/arma-catalog-import.int.spec.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ASSET_TYPES,
|
||||||
|
planRecord,
|
||||||
|
upsertPlan,
|
||||||
|
type CatalogRecord,
|
||||||
|
} from "@/lib/arma-import/catalog";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
const RUN = `armacat-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
let fuelId: number;
|
||||||
|
let createdFuel = false;
|
||||||
|
const assetIds: number[] = [];
|
||||||
|
const vehicleIds: number[] = [];
|
||||||
|
|
||||||
|
describe("Arma catalog import", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
// Vehicles need a fuel resource for the fuel group; reuse one if a prior
|
||||||
|
// fixture left it, otherwise create it (and only then clean it up).
|
||||||
|
const existingFuel = await payload.find({
|
||||||
|
collection: "resources",
|
||||||
|
where: { name: { equals: "Fuel" } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
if (existingFuel.docs[0]) {
|
||||||
|
fuelId = existingFuel.docs[0].id;
|
||||||
|
} else {
|
||||||
|
const fuel = await payload.create({
|
||||||
|
collection: "resources",
|
||||||
|
data: {
|
||||||
|
name: "Fuel",
|
||||||
|
codeName: `res_fuel_${RUN}`,
|
||||||
|
unitOfMeasure: "liter",
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
type: "fluid",
|
||||||
|
baseValue: 1,
|
||||||
|
rarity: "common",
|
||||||
|
massPerUnit: 0,
|
||||||
|
gridWidth: 1,
|
||||||
|
gridHeight: 1,
|
||||||
|
tradeable: false,
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
fuelId = fuel.id;
|
||||||
|
createdFuel = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of vehicleIds) {
|
||||||
|
await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of assetIds) {
|
||||||
|
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (createdFuel && fuelId) {
|
||||||
|
await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("planRecord classification", () => {
|
||||||
|
it("trusts type for every contract asset type", () => {
|
||||||
|
for (const t of ASSET_TYPES) {
|
||||||
|
const rec: CatalogRecord = {
|
||||||
|
classname: `PTF_Test_Thing_${t}`,
|
||||||
|
type: t,
|
||||||
|
displayName: "Thing",
|
||||||
|
picture: "",
|
||||||
|
mod: "CBA_3den",
|
||||||
|
description: "",
|
||||||
|
mass: 1,
|
||||||
|
extra: "",
|
||||||
|
};
|
||||||
|
const plan = planRecord(rec);
|
||||||
|
expect(plan).toBeDefined();
|
||||||
|
expect(plan!.kind).toBe("asset");
|
||||||
|
expect(plan!.assetType).toBe(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes vehicle records to the vehicles collection with transportMode from extra", () => {
|
||||||
|
const air = planRecord({
|
||||||
|
classname: "B_Heli_Light_01_F",
|
||||||
|
type: "vehicle",
|
||||||
|
displayName: "Ghost Hawk",
|
||||||
|
picture: "",
|
||||||
|
mod: "Arma 3",
|
||||||
|
description: "",
|
||||||
|
mass: 3000,
|
||||||
|
extra: "air",
|
||||||
|
});
|
||||||
|
expect(air!.kind).toBe("vehicle");
|
||||||
|
expect(air!.transportMode).toBe("air");
|
||||||
|
|
||||||
|
const sea = planRecord({ classname: "C_Boat_Civil_01_F", type: "vehicle", extra: "sea" });
|
||||||
|
expect(sea!.transportMode).toBe("sea");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults transportMode to ground when extra is not a valid mode", () => {
|
||||||
|
const plan = planRecord({ classname: "B_MRAP_01_F", type: "vehicle", extra: "submarine" });
|
||||||
|
expect(plan!.kind).toBe("vehicle");
|
||||||
|
expect(plan!.transportMode).toBe("ground");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to heuristics for unknown types (miscellaneous, not item)", () => {
|
||||||
|
const plan = planRecord({ classname: "PTF_Test_Odd_Gadget", type: "qrd_mysterious_device" });
|
||||||
|
expect(plan!.kind).toBe("asset");
|
||||||
|
expect(plan!.assetType).toBe("miscellaneous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the legacy vehicle heuristic for unknown types", () => {
|
||||||
|
const plan = planRecord({ classname: "B_Truck_01_transport_F", type: "who_knows" });
|
||||||
|
expect(plan!.kind).toBe("vehicle");
|
||||||
|
expect(plan!.transportMode).toBe("ground");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures magazine round counts from extra", () => {
|
||||||
|
const plan = planRecord({ classname: "30rnd_556x45_Mag", type: "magazine", extra: 30 });
|
||||||
|
expect(plan!.kind).toBe("asset");
|
||||||
|
expect(plan!.roundCapacity).toBe(30);
|
||||||
|
expect(plan!.assetType).toBe("magazine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores numeric extra for non-magazine assets", () => {
|
||||||
|
const plan = planRecord({ classname: "PTF_Test_Rifle", type: "weapon", extra: 30 });
|
||||||
|
expect(plan!.assetType).toBe("weapon");
|
||||||
|
expect(plan!.roundCapacity).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports legacy class/category/name aliases and icon-map overrides", () => {
|
||||||
|
const plan = planRecord(
|
||||||
|
{ class: "Legacy_Thing", category: "optic", name: "Legacy Optic" },
|
||||||
|
{ icons: { legacy_thing: "\\a3\\data\\ui\\icon.paa" } },
|
||||||
|
);
|
||||||
|
expect(plan!.className).toBe("Legacy_Thing");
|
||||||
|
expect(plan!.assetType).toBe("optic");
|
||||||
|
expect(plan!.displayName).toBe("Legacy Optic");
|
||||||
|
expect(plan!.pictureRef).toBe("\\a3\\data\\ui\\icon.paa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults mod to Arma 3 and rejects records without a classname", () => {
|
||||||
|
const plan = planRecord({ classname: "PTF_Test_Thing", type: "weapon" });
|
||||||
|
expect(plan).toBeDefined();
|
||||||
|
expect(plan!.mod).toBe("Arma 3");
|
||||||
|
expect(planRecord({ type: "weapon" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertPlan assets", () => {
|
||||||
|
const assetClassName = `${RUN}_asset_rifle`;
|
||||||
|
|
||||||
|
it("creates an asset with correct className/assetType and Asset Metadata.mod", async () => {
|
||||||
|
const plan = planRecord({
|
||||||
|
classname: assetClassName,
|
||||||
|
type: "weapon",
|
||||||
|
displayName: `${RUN} Rifle`,
|
||||||
|
picture: "",
|
||||||
|
mod: "PTF_Test_Mod",
|
||||||
|
description: "An import test rifle.",
|
||||||
|
mass: 2,
|
||||||
|
extra: "",
|
||||||
|
})!;
|
||||||
|
|
||||||
|
const res = await upsertPlan(payload, plan);
|
||||||
|
expect(res.action).toBe("created");
|
||||||
|
expect(res.collection).toBe("assets");
|
||||||
|
assetIds.push(res.id);
|
||||||
|
|
||||||
|
const doc = await payload.findByID({ collection: "assets", id: res.id, depth: 0, overrideAccess: true });
|
||||||
|
expect(doc.className).toBe(assetClassName);
|
||||||
|
expect(doc.assetType).toBe("weapon");
|
||||||
|
expect(doc.name).toBe(`${RUN} Rifle`);
|
||||||
|
expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod");
|
||||||
|
expect(doc["Asset Metadata"]?.gameDescription).toBe("An import test rifle.");
|
||||||
|
expect(doc.approvalStatus).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates on re-run without duplicating and preserves approvalStatus and Virtual Data", async () => {
|
||||||
|
const assetId = assetIds[0];
|
||||||
|
// Simulate human edits before the second import run.
|
||||||
|
await payload.update({
|
||||||
|
collection: "assets",
|
||||||
|
id: assetId,
|
||||||
|
data: { approvalStatus: "approved", tradeable: true },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await upsertPlan(
|
||||||
|
payload,
|
||||||
|
planRecord({
|
||||||
|
classname: assetClassName,
|
||||||
|
type: "weapon",
|
||||||
|
displayName: `${RUN} Rifle MkII`,
|
||||||
|
picture: "",
|
||||||
|
mod: "PTF_Test_Mod_2",
|
||||||
|
description: "Updated in-game description.",
|
||||||
|
mass: 2,
|
||||||
|
extra: "",
|
||||||
|
})!,
|
||||||
|
undefined, // assets never need fuel
|
||||||
|
);
|
||||||
|
expect(res.action).toBe("updated");
|
||||||
|
expect(res.collection).toBe("assets");
|
||||||
|
expect(res.id).toBe(assetId);
|
||||||
|
|
||||||
|
const count = await payload.count({
|
||||||
|
collection: "assets",
|
||||||
|
where: { className: { equals: assetClassName } },
|
||||||
|
});
|
||||||
|
expect(count.totalDocs).toBe(1);
|
||||||
|
|
||||||
|
const doc = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||||
|
expect(doc.name).toBe(`${RUN} Rifle MkII`);
|
||||||
|
expect(doc["Asset Metadata"]?.mod).toBe("PTF_Test_Mod_2");
|
||||||
|
expect(doc["Asset Metadata"]?.gameDescription).toBe("Updated in-game description.");
|
||||||
|
expect(doc.approvalStatus).toBe("approved"); // never reset by re-import
|
||||||
|
expect(doc.tradeable).toBe(true); // Virtual Data untouched
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertPlan vehicles", () => {
|
||||||
|
const vehicleClassname = `${RUN}_heli_ghost`;
|
||||||
|
|
||||||
|
it("creates a vehicle with classname and transportMode air", async () => {
|
||||||
|
const plan = planRecord({
|
||||||
|
classname: vehicleClassname,
|
||||||
|
type: "vehicle",
|
||||||
|
displayName: `${RUN} Ghost Hawk`,
|
||||||
|
picture: "",
|
||||||
|
mod: "PTF_Test_Mod",
|
||||||
|
description: "An import test helicopter.",
|
||||||
|
mass: 3000,
|
||||||
|
extra: "air",
|
||||||
|
})!;
|
||||||
|
|
||||||
|
const res = await upsertPlan(payload, plan, fuelId);
|
||||||
|
expect(res.action).toBe("created");
|
||||||
|
expect(res.collection).toBe("vehicles");
|
||||||
|
vehicleIds.push(res.id);
|
||||||
|
|
||||||
|
const doc = await payload.findByID({ collection: "vehicles", id: res.id, depth: 0, overrideAccess: true });
|
||||||
|
expect(doc.classname).toBe(vehicleClassname);
|
||||||
|
expect(doc.name).toBe(`${RUN} Ghost Hawk`);
|
||||||
|
expect(doc.transportMode).toBe("air");
|
||||||
|
expect(doc.fuel.fuelType).toBe(fuelId);
|
||||||
|
expect(doc.fuel.fuelCapacity).toBe(100);
|
||||||
|
expect(doc.approvalStatus).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing vehicle without duplicating", async () => {
|
||||||
|
const vehicleId = vehicleIds[0];
|
||||||
|
const res = await upsertPlan(
|
||||||
|
payload,
|
||||||
|
planRecord({
|
||||||
|
classname: vehicleClassname,
|
||||||
|
type: "vehicle",
|
||||||
|
displayName: `${RUN} Ghost Hawk II`,
|
||||||
|
extra: "air",
|
||||||
|
})!,
|
||||||
|
fuelId,
|
||||||
|
);
|
||||||
|
expect(res.action).toBe("updated");
|
||||||
|
expect(res.id).toBe(vehicleId);
|
||||||
|
|
||||||
|
const count = await payload.count({
|
||||||
|
collection: "vehicles",
|
||||||
|
where: { classname: { equals: vehicleClassname } },
|
||||||
|
});
|
||||||
|
expect(count.totalDocs).toBe(1);
|
||||||
|
|
||||||
|
const doc = await payload.findByID({ collection: "vehicles", id: vehicleId, depth: 0, overrideAccess: true });
|
||||||
|
expect(doc.name).toBe(`${RUN} Ghost Hawk II`);
|
||||||
|
expect(doc.transportMode).toBe("air");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
139
tests/int/arma-shop-money.int.spec.ts
Normal file
139
tests/int/arma-shop-money.int.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { BankAccount } from "@/payload-types";
|
||||||
|
import { createAccount, ensurePersonalAccount, getTreasuryAccountId } from "@/lib/banking";
|
||||||
|
import { getBalancesBySteamId, parseMoneyDelta, processMoneyEvent } from "@/lib/arma-bridge/money";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
const RUN = `money-${Date.now().toString(36)}`;
|
||||||
|
const STEAM_ID = `${RUN}-steam-001`;
|
||||||
|
const UNLINKED = `${RUN}-steam-unlinked`;
|
||||||
|
|
||||||
|
async function balanceOf(id: number): Promise<number> {
|
||||||
|
const acc = (await payload.findByID({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as BankAccount;
|
||||||
|
return acc.balance ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSeq = 0;
|
||||||
|
function moneyEvent(delta: number, reason: string, detail = "") {
|
||||||
|
eventSeq += 1;
|
||||||
|
return { eventId: `${RUN}:${eventSeq}`, payload: [STEAM_ID, delta, reason, detail] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Arma shop money sync", () => {
|
||||||
|
let userId: number;
|
||||||
|
let accountId: number;
|
||||||
|
let treasuryId: number;
|
||||||
|
let createdTreasury = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
const user = await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-player`,
|
||||||
|
discordUsername: `${RUN}-player-discord`,
|
||||||
|
displayName: `${RUN} Player`,
|
||||||
|
steamId: STEAM_ID,
|
||||||
|
password: "Test1234",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
const existingTreasury = await getTreasuryAccountId(payload);
|
||||||
|
if (existingTreasury) {
|
||||||
|
treasuryId = existingTreasury;
|
||||||
|
} else {
|
||||||
|
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
|
||||||
|
treasuryId = treasury.id;
|
||||||
|
createdTreasury = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (accountId) {
|
||||||
|
await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (createdTreasury && treasuryId) {
|
||||||
|
await payload.delete({ collection: "bank-accounts", id: treasuryId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
const profiles = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: userId } },
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
for (const profile of profiles.docs) {
|
||||||
|
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseMoneyDelta", () => {
|
||||||
|
it("parses [steamId, delta, reason, detail] and rounds the delta", () => {
|
||||||
|
expect(parseMoneyDelta(["7656119", -250.4, "Purchase", "arifle_MX_F x1"])).toEqual({
|
||||||
|
steamId: "7656119",
|
||||||
|
delta: -250,
|
||||||
|
reason: "purchase",
|
||||||
|
detail: "arifle_MX_F x1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or zero payloads", () => {
|
||||||
|
expect(parseMoneyDelta(null)).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 100])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["", 100, "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 0, "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", "100", "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 100, ""])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates reward money in the personal account and reports it by Steam ID", async () => {
|
||||||
|
await processMoneyEvent(payload, moneyEvent(500, "reward", "Dialogue test"));
|
||||||
|
const account = await ensurePersonalAccount(payload, userId);
|
||||||
|
accountId = account.id;
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
|
||||||
|
const balances = await getBalancesBySteamId(payload, [STEAM_ID, UNLINKED]);
|
||||||
|
expect(balances[STEAM_ID]).toBe(500);
|
||||||
|
expect(balances).not.toHaveProperty(UNLINKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pays purchases to the treasury and refunds them from it", async () => {
|
||||||
|
const treasuryBefore = await balanceOf(treasuryId);
|
||||||
|
|
||||||
|
await processMoneyEvent(payload, moneyEvent(-200, "purchase", "arifle_MX_F x1"));
|
||||||
|
expect(await balanceOf(accountId)).toBe(300);
|
||||||
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore + 200);
|
||||||
|
|
||||||
|
await processMoneyEvent(payload, moneyEvent(200, "refund", "arifle_MX_F x1"));
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overdraw: a debit larger than the balance is skipped without throwing", async () => {
|
||||||
|
await expect(processMoneyEvent(payload, moneyEvent(-100000, "purchase"))).resolves.toBeUndefined();
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unlinked players and malformed events without throwing", async () => {
|
||||||
|
await expect(
|
||||||
|
processMoneyEvent(payload, { eventId: `${RUN}:unlinked`, payload: [UNLINKED, 100, "reward"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(processMoneyEvent(payload, { eventId: `${RUN}:bad`, payload: "nope" })).resolves.toBeUndefined();
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
400
tests/int/tech-categories.int.spec.ts
Normal file
400
tests/int/tech-categories.int.spec.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Role, TechCategory, Technology, User } from "@/payload-types";
|
||||||
|
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
||||||
|
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
|
||||||
|
const RUN = `techcat-${Date.now().toString(36)}`;
|
||||||
|
const TIMEOUT = 30_000;
|
||||||
|
|
||||||
|
describe("Tech Categories (collection + RBAC + tech tree data model)", () => {
|
||||||
|
const roleIds: number[] = [];
|
||||||
|
const userIds: number[] = [];
|
||||||
|
const categoryIds: number[] = [];
|
||||||
|
const technologyIds: number[] = [];
|
||||||
|
const createdQualificationIds: number[] = [];
|
||||||
|
|
||||||
|
let plainUser: User;
|
||||||
|
let intelUser: User;
|
||||||
|
let superUser: User;
|
||||||
|
let categoryManagerUser: User;
|
||||||
|
|
||||||
|
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
||||||
|
const role = (await payload.create({
|
||||||
|
collection: "roles",
|
||||||
|
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Role;
|
||||||
|
roleIds.push(role.id);
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeUser = async (label: string, roleId: number): Promise<User> => {
|
||||||
|
const user = (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-${label}`,
|
||||||
|
discordUsername: `${RUN}-${label}`,
|
||||||
|
displayName: label.toUpperCase(),
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roleDocs: [roleId],
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
userIds.push(user.id);
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrCreateQualification = async (name: string): Promise<number> => {
|
||||||
|
const found = (await payload.find({
|
||||||
|
collection: "qualifications",
|
||||||
|
where: { name: { equals: name } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: Array<{ id: number }> };
|
||||||
|
if (found.docs.length > 0) return found.docs[0].id;
|
||||||
|
const created = (await payload.create({
|
||||||
|
collection: "qualifications",
|
||||||
|
data: { name },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { id: number };
|
||||||
|
createdQualificationIds.push(created.id);
|
||||||
|
return created.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const grantQualification = async (user: User, qualificationId: number) => {
|
||||||
|
const profile = (await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: Array<{ id: number }> };
|
||||||
|
expect(profile.docs.length).toBeGreaterThan(0);
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.docs[0].id,
|
||||||
|
data: { progression: { qualifications: [qualificationId] } },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectAccessDenied = async (fn: () => Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
const name = (e as { name?: string })?.name ?? "";
|
||||||
|
const message = e instanceof Error ? e.message : "";
|
||||||
|
expect(
|
||||||
|
name === "AccessError" ||
|
||||||
|
name === "Forbidden" ||
|
||||||
|
/not permitted|not allowed|access denied/i.test(message),
|
||||||
|
).toBe(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Expected operation to be denied");
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
invalidatePermissionCache();
|
||||||
|
|
||||||
|
const bareRole = await makeRole("bare", { permissions: [] });
|
||||||
|
const superRole = await makeRole("super", { isSuperuser: true });
|
||||||
|
const managerRole = await makeRole("techcat-manager", {
|
||||||
|
permissions: [
|
||||||
|
"tech-categories:create",
|
||||||
|
"tech-categories:read",
|
||||||
|
"tech-categories:update",
|
||||||
|
"tech-categories:delete",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
plainUser = await makeUser("plain", bareRole.id);
|
||||||
|
intelUser = await makeUser("intel", bareRole.id);
|
||||||
|
superUser = await makeUser("super", superRole.id);
|
||||||
|
categoryManagerUser = await makeUser("manager", managerRole.id);
|
||||||
|
|
||||||
|
const intelligenceId = await findOrCreateQualification("Intelligence");
|
||||||
|
await grantQualification(intelUser, intelligenceId);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!payload) return;
|
||||||
|
for (const id of technologyIds) {
|
||||||
|
await payload.delete({ collection: "technologies", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of categoryIds) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "tech-categories", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of userIds) {
|
||||||
|
const profiles = await payload
|
||||||
|
.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const p of profiles?.docs ?? []) {
|
||||||
|
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of roleIds) {
|
||||||
|
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of createdQualificationIds) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "qualifications", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CRUD (overrideAccess sanity)", () => {
|
||||||
|
it("creates a category with name, position, and optional hex color", async () => {
|
||||||
|
const category = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-Tier 1`, position: 1, color: "#22d3ee" },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
expect(category.id).toBeGreaterThan(0);
|
||||||
|
expect(category.position).toBe(1);
|
||||||
|
expect(category.color).toBe("#22d3ee");
|
||||||
|
categoryIds.push(category.id);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("rejects duplicate names (unique)", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-Tier 1`, position: 2 },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("rejects non-hex color values", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-bad-color`, position: 3, color: "not-a-color" },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permission gating", () => {
|
||||||
|
it("plain users cannot create or read categories (no permission, no qualification)", async () => {
|
||||||
|
await expectAccessDenied(() =>
|
||||||
|
payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-plain`, position: 1 },
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expectAccessDenied(() =>
|
||||||
|
payload.find({
|
||||||
|
collection: "tech-categories",
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("intelligence-qualified users create and read via the division bypass", async () => {
|
||||||
|
const created = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-intel`, position: 2 },
|
||||||
|
user: intelUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
categoryIds.push(created.id);
|
||||||
|
expect(created.id).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const found = (await payload.find({
|
||||||
|
collection: "tech-categories",
|
||||||
|
user: intelUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as { docs: Array<{ id: number }> };
|
||||||
|
expect(found.docs.some((d) => d.id === created.id)).toBe(true);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("users holding explicit tech-categories permissions pass without a qualification", async () => {
|
||||||
|
const created = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-manager`, position: 3 },
|
||||||
|
user: categoryManagerUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
categoryIds.push(created.id);
|
||||||
|
expect(created.id).toBeGreaterThan(0);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("superusers always pass", async () => {
|
||||||
|
const created = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-super`, position: 4 },
|
||||||
|
user: superUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
categoryIds.push(created.id);
|
||||||
|
expect(created.id).toBeGreaterThan(0);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("the tech-categories admin page follows the intelligence division like technologies", async () => {
|
||||||
|
// scopedAdminPageAccess consults DIVISION_ADMIN_QUALIFICATIONS; assert
|
||||||
|
// the BEHAVIOR: an intelligence-qualified user passes an admin-panel
|
||||||
|
// request for tech-categories, a plain user does not, superusers pass.
|
||||||
|
const decision = async (user: User | null): Promise<boolean> => {
|
||||||
|
const fn = scopedAdminPageAccess("tech-categories");
|
||||||
|
return Boolean(
|
||||||
|
await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||||
|
req: { user, payload, pathname: "/admin/collections/tech-categories" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
expect(await decision(intelUser)).toBe(true);
|
||||||
|
expect(await decision(plainUser)).toBe(false);
|
||||||
|
expect(await decision(superUser)).toBe(true);
|
||||||
|
expect(await decision(null)).toBe(false);
|
||||||
|
}, TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("technologies category relationship", () => {
|
||||||
|
it("assigns a category to a technology and reads it back", async () => {
|
||||||
|
const category = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-Tier 2`, position: 2 },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
categoryIds.push(category.id);
|
||||||
|
|
||||||
|
const tech = (await payload.create({
|
||||||
|
collection: "technologies",
|
||||||
|
data: {
|
||||||
|
name: `${RUN}-tech`,
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Test technology for the tree.",
|
||||||
|
category: category.id,
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
technologyIds.push(tech.id);
|
||||||
|
|
||||||
|
const fetched = (await payload.findByID({
|
||||||
|
collection: "technologies",
|
||||||
|
id: tech.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
expect(fetched.category).toBe(category.id);
|
||||||
|
}, TIMEOUT);
|
||||||
|
|
||||||
|
it("deleting a category leaves the technology with no category (FK set null)", async () => {
|
||||||
|
const category = (await payload.create({
|
||||||
|
collection: "tech-categories",
|
||||||
|
data: { name: `${RUN}-Tier 9`, position: 9 },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as TechCategory;
|
||||||
|
|
||||||
|
const tech = (await payload.create({
|
||||||
|
collection: "technologies",
|
||||||
|
data: {
|
||||||
|
name: `${RUN}-tech-orphan`,
|
||||||
|
type: "asset",
|
||||||
|
summary: "Orphaned on category delete.",
|
||||||
|
category: category.id,
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
technologyIds.push(tech.id);
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: "tech-categories",
|
||||||
|
id: category.id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = (await payload.findByID({
|
||||||
|
collection: "technologies",
|
||||||
|
id: tech.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
expect(fetched.category === null || fetched.category === undefined).toBe(true);
|
||||||
|
}, TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tech tree manual positions", () => {
|
||||||
|
it("stores a dragged tree position and clears it again", async () => {
|
||||||
|
const tech = (await payload.create({
|
||||||
|
collection: "technologies",
|
||||||
|
data: {
|
||||||
|
name: `${RUN}-positioned`,
|
||||||
|
type: "upgrade",
|
||||||
|
summary: "Manual canvas position round-trip.",
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
||||||
|
treePosition: { x: 420, y: 260 },
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
technologyIds.push(tech.id);
|
||||||
|
|
||||||
|
const fetched = (await payload.findByID({
|
||||||
|
collection: "technologies",
|
||||||
|
id: tech.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
expect(fetched.treePosition?.x).toBe(420);
|
||||||
|
expect(fetched.treePosition?.y).toBe(260);
|
||||||
|
|
||||||
|
const cleared = (await payload.update({
|
||||||
|
collection: "technologies",
|
||||||
|
id: tech.id,
|
||||||
|
data: { treePosition: { x: null, y: null } },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Technology;
|
||||||
|
expect(cleared.treePosition?.x ?? null).toBeNull();
|
||||||
|
expect(cleared.treePosition?.y ?? null).toBeNull();
|
||||||
|
}, TIMEOUT);
|
||||||
|
});
|
||||||
|
});
|
||||||
245
tests/int/tech-tree-layout.int.spec.ts
Normal file
245
tests/int/tech-tree-layout.int.spec.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_ROWS_PER_COLUMN,
|
||||||
|
computeLayout,
|
||||||
|
} from "@/components/admin/tech-tree/layout";
|
||||||
|
import {
|
||||||
|
COLUMN_STEP,
|
||||||
|
NODE_WIDTH,
|
||||||
|
ROW_STEP,
|
||||||
|
type TreeCategory,
|
||||||
|
type TreeTech,
|
||||||
|
} from "@/components/admin/tech-tree/types";
|
||||||
|
|
||||||
|
const tech = (over: Partial<TreeTech> & { id: number }): TreeTech => ({
|
||||||
|
name: `Tech ${over.id}`,
|
||||||
|
type: "upgrade",
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
summary: "",
|
||||||
|
categoryId: null,
|
||||||
|
prereqAnd: [],
|
||||||
|
prereqOr: [],
|
||||||
|
resourceCosts: [],
|
||||||
|
treePosition: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cat = (id: number, name: string, position: number): TreeCategory => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
position,
|
||||||
|
color: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tech tree layout (pure)", () => {
|
||||||
|
it("wraps a long uncategorized column into multiple physical columns", () => {
|
||||||
|
const techs = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 }));
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
|
||||||
|
const xs = [...new Set(layout.nodes.map((n) => n.x))].sort((a, b) => a - b);
|
||||||
|
expect(xs).toEqual([0, COLUMN_STEP, COLUMN_STEP * 2]);
|
||||||
|
expect(layout.columns).toBe(3);
|
||||||
|
|
||||||
|
// First column holds MAX_ROWS_PER_COLUMN nodes, rows 0..N-1.
|
||||||
|
const firstColumnRows = layout.nodes.filter((n) => n.x === 0).map((n) => n.y / ROW_STEP);
|
||||||
|
expect(firstColumnRows.sort((a, b) => a - b)).toEqual(
|
||||||
|
Array.from({ length: MAX_ROWS_PER_COLUMN }, (_, i) => i),
|
||||||
|
);
|
||||||
|
// The final column holds the remainder.
|
||||||
|
const lastColumnCount = layout.nodes.filter((n) => n.x === COLUMN_STEP * 2).length;
|
||||||
|
expect(lastColumnCount).toBe(25 - MAX_ROWS_PER_COLUMN * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders category columns by explicit position, then name", () => {
|
||||||
|
const categories = [cat(10, "Tier 2", 2), cat(11, "Tier 1", 1), cat(12, "Alpha", 2)];
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, categoryId: 10 }),
|
||||||
|
tech({ id: 2, categoryId: 11 }),
|
||||||
|
tech({ id: 3, categoryId: 12 }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x;
|
||||||
|
|
||||||
|
// Tier 1 (position 1) is leftmost.
|
||||||
|
expect(xOf(2)).toBe(0);
|
||||||
|
// Position 2 ties break alphabetically: "Alpha" before "Tier 2".
|
||||||
|
expect(xOf(3)).toBe(COLUMN_STEP);
|
||||||
|
expect(xOf(1)).toBe(COLUMN_STEP * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places uncategorized techs in a trailing zone after all categories", () => {
|
||||||
|
const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)];
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, categoryId: 1 }),
|
||||||
|
tech({ id: 2, categoryId: 2 }),
|
||||||
|
tech({ id: 3 }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x;
|
||||||
|
expect(xOf(3)).toBe(COLUMN_STEP * 2); // after both category columns
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a dependent one column right of its parent, at the parent's row", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const parent = layout.nodes.find((n) => n.techId === 1)!;
|
||||||
|
const child = layout.nodes.find((n) => n.techId === 2)!;
|
||||||
|
expect(child.column).toBe(parent.column + 1);
|
||||||
|
expect(child.row).toBe(parent.row);
|
||||||
|
// Short, local edge: exactly one column apart.
|
||||||
|
expect(child.x - parent.x).toBe(COLUMN_STEP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the chain propagating rightwards for grandchildren", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
tech({ id: 3, prereqAnd: [2] }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const col = (id: number) => layout.nodes.find((n) => n.techId === id)!.column;
|
||||||
|
expect(col(2)).toBe(col(1) + 1);
|
||||||
|
expect(col(3)).toBe(col(2) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links stay beside their parent even when the root grid fills its columns", () => {
|
||||||
|
// 25 roots fill three 10-row columns; a dependent of the FIRST root must
|
||||||
|
// still land immediately to its right, not after the whole grid.
|
||||||
|
const roots = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 }));
|
||||||
|
const techs = [...roots, tech({ id: 100, prereqAnd: [1] })];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const parent = layout.nodes.find((n) => n.techId === 1)!;
|
||||||
|
const child = layout.nodes.find((n) => n.techId === 100)!;
|
||||||
|
// Adjacent column, and right next to the parent vertically (it may grow
|
||||||
|
// upwards into a free slot rather than being pushed to a distant column).
|
||||||
|
expect(child.column).toBe(parent.column + 1);
|
||||||
|
expect(Math.abs(child.row - parent.row)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the catalog grid stable when a root becomes a dependent", () => {
|
||||||
|
const roots = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"].map((name, i) =>
|
||||||
|
tech({ id: i + 1, name }),
|
||||||
|
);
|
||||||
|
const before = computeLayout(roots, []);
|
||||||
|
const posBefore = (layout: ReturnType<typeof computeLayout>, id: number) =>
|
||||||
|
layout.nodes.find((n) => n.techId === id)!;
|
||||||
|
|
||||||
|
// Foxtrot (id 6) becomes Charlie's prerequisite: Charlie (id 3) leaves the
|
||||||
|
// catalog and becomes a dependent.
|
||||||
|
const after = computeLayout(
|
||||||
|
roots.map((t) => (t.id === 3 ? { ...t, prereqAnd: [6] } : t)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every other root keeps its exact slot: no re-shuffling on connect.
|
||||||
|
for (const id of [1, 2, 4, 5, 6]) {
|
||||||
|
expect(after.nodes.find((n) => n.techId === id)!.x).toBe(posBefore(before, id).x);
|
||||||
|
expect(after.nodes.find((n) => n.techId === id)!.y).toBe(posBefore(before, id).y);
|
||||||
|
}
|
||||||
|
// Charlie's catalog slot is now empty, and it sits right of its prerequisite.
|
||||||
|
const foxtrot = after.nodes.find((n) => n.techId === 6)!;
|
||||||
|
const charlie = after.nodes.find((n) => n.techId === 3)!;
|
||||||
|
expect(charlie.column).toBe(foxtrot.column + 1);
|
||||||
|
expect(charlie.row).toBe(foxtrot.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pinned dependent exactly where it was placed", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1], treePosition: { x: 1234, y: 567 } }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const pinned = layout.nodes.find((n) => n.techId === 2)!;
|
||||||
|
expect(pinned.x).toBe(1234);
|
||||||
|
expect(pinned.y).toBe(567);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the average parent row for multi-parent dependents", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2 }),
|
||||||
|
tech({ id: 3 }),
|
||||||
|
tech({ id: 4 }),
|
||||||
|
tech({ id: 5 }),
|
||||||
|
tech({ id: 6, prereqAnd: [1, 5] }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const row = (id: number) => layout.nodes.find((n) => n.techId === id)!.row;
|
||||||
|
const child = layout.nodes.find((n) => n.techId === 6)!;
|
||||||
|
expect(child.row).toBe(Math.round((row(1) + row(5)) / 2));
|
||||||
|
expect(child.column).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks siblings without overlapping them", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
tech({ id: 3, prereqAnd: [1] }),
|
||||||
|
tech({ id: 4, prereqAnd: [1] }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
const siblings = layout.nodes.filter((n) => [2, 3, 4].includes(n.techId));
|
||||||
|
expect(new Set(siblings.map((n) => n.column)).size).toBe(1);
|
||||||
|
expect(new Set(siblings.map((n) => n.row)).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors manual treePosition overrides and grows the band to contain them", () => {
|
||||||
|
const categories = [cat(1, "Tier 1", 1)];
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, categoryId: 1 }),
|
||||||
|
tech({ id: 2, categoryId: 1, treePosition: { x: 900, y: 400 } }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
|
||||||
|
const manual = layout.nodes.find((n) => n.techId === 2)!;
|
||||||
|
expect(manual.x).toBe(900);
|
||||||
|
expect(manual.y).toBe(400);
|
||||||
|
|
||||||
|
const band = layout.bands.find((b) => b.key === "cat-1")!;
|
||||||
|
// Band spans from the auto-positioned node to the dragged node.
|
||||||
|
expect(band.x + band.width).toBeGreaterThanOrEqual(900 + NODE_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a prerequisite that sits in a later column", () => {
|
||||||
|
const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)];
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, categoryId: 1, prereqAnd: [2] }), // depends on a Tier 2 tech
|
||||||
|
tech({ id: 2, categoryId: 2 }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
expect(layout.warnings.map((w) => w.techId)).toContain(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag forward-pointing prerequisites", () => {
|
||||||
|
const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)];
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, categoryId: 1 }),
|
||||||
|
tech({ id: 2, categoryId: 2, prereqAnd: [1] }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
expect(layout.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives prerequisite cycles", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1, prereqAnd: [2] }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
];
|
||||||
|
const layout = computeLayout(techs, []);
|
||||||
|
expect(layout.nodes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits bands for categories with no members", () => {
|
||||||
|
const categories = [cat(1, "Tier 1", 1), cat(2, "Empty Tier", 2)];
|
||||||
|
const techs = [tech({ id: 1, categoryId: 1 })];
|
||||||
|
const layout = computeLayout(techs, categories);
|
||||||
|
expect(layout.bands.map((b) => b.key)).toEqual(["cat-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores dangling category references by treating them as uncategorized", () => {
|
||||||
|
const techs = [tech({ id: 1, categoryId: 999 })];
|
||||||
|
const layout = computeLayout(techs, [cat(1, "Tier 1", 1)]);
|
||||||
|
expect(layout.bands.map((b) => b.key)).toEqual(["uncategorized"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
tests/int/tech-tree-prereq-edits.int.spec.ts
Normal file
110
tests/int/tech-tree-prereq-edits.int.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addPrerequisite,
|
||||||
|
buildPrereqRows,
|
||||||
|
dependsOn,
|
||||||
|
removePrerequisite,
|
||||||
|
setPrerequisiteKind,
|
||||||
|
withPrereqs,
|
||||||
|
} from "@/components/admin/tech-tree/prereqEdits";
|
||||||
|
import type { TreeTech } from "@/components/admin/tech-tree/types";
|
||||||
|
|
||||||
|
const tech = (over: Partial<TreeTech> & { id: number }): TreeTech => ({
|
||||||
|
name: `Tech ${over.id}`,
|
||||||
|
type: "upgrade",
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
summary: "",
|
||||||
|
categoryId: null,
|
||||||
|
prereqAnd: [],
|
||||||
|
prereqOr: [],
|
||||||
|
resourceCosts: [],
|
||||||
|
treePosition: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tech tree prerequisite edits (pure)", () => {
|
||||||
|
it("adds an AND prerequisite", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2 })];
|
||||||
|
const result = addPrerequisite(techs, 2, 1, "and");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.lists).toEqual({ and: [1], or: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an OR prerequisite", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2 })];
|
||||||
|
const result = addPrerequisite(techs, 2, 1, "or");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.lists).toEqual({ and: [], or: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects self references", () => {
|
||||||
|
const result = addPrerequisite([tech({ id: 1 })], 1, 1, "and");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toMatch(/own prerequisite/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicates", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })];
|
||||||
|
const result = addPrerequisite(techs, 2, 1, "or");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toMatch(/already a prerequisite/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a direct cycle", () => {
|
||||||
|
const techs = [tech({ id: 1, prereqAnd: [2] }), tech({ id: 2 })];
|
||||||
|
// Adding 1 as a prerequisite of 2 would close 2 -> 1 -> 2.
|
||||||
|
const result = addPrerequisite(techs, 2, 1, "and");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toMatch(/circular/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a transitive cycle", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
tech({ id: 3, prereqAnd: [2] }),
|
||||||
|
];
|
||||||
|
// 3 depends on 1 transitively; making 3 a prerequisite of 1 closes a loop.
|
||||||
|
expect(dependsOn(techs, 3, 1)).toBe(true);
|
||||||
|
const result = addPrerequisite(techs, 1, 3, "and");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a forward chain", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3 })];
|
||||||
|
const result = addPrerequisite(techs, 3, 2, "and");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.lists).toEqual({ and: [2], or: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a prerequisite from both groups", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1], prereqOr: [1, 3] })];
|
||||||
|
const result = removePrerequisite(techs, 2, 1);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.lists).toEqual({ and: [], or: [3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a prerequisite between AND and OR", () => {
|
||||||
|
const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })];
|
||||||
|
const toOr = setPrerequisiteKind(techs, 2, 1, "or");
|
||||||
|
expect(toOr.ok).toBe(true);
|
||||||
|
if (toOr.ok) expect(toOr.lists).toEqual({ and: [], or: [1] });
|
||||||
|
|
||||||
|
const back = setPrerequisiteKind(techs, 2, 1, "and");
|
||||||
|
expect(back.ok).toBe(true);
|
||||||
|
if (back.ok) expect(back.lists).toEqual({ and: [1], or: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a single document-shaped prerequisite row", () => {
|
||||||
|
expect(buildPrereqRows({ and: [1, 2], or: [3] })).toEqual([
|
||||||
|
{ requiresAnd: { technology: [1, 2] }, requiresOr: { technology: [3] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withPrereqs returns a tech carrying the new lists", () => {
|
||||||
|
const updated = withPrereqs(tech({ id: 1 }), { and: [4], or: [5] });
|
||||||
|
expect(updated.prereqAnd).toEqual([4]);
|
||||||
|
expect(updated.prereqOr).toEqual([5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
tests/int/tech-tree-stability.int.spec.ts
Normal file
121
tests/int/tech-tree-stability.int.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { edgeVisuals, PATH_PALETTE } from "@/components/admin/tech-tree/edgeVisuals";
|
||||||
|
import { applyPins, pinsToFreeze } from "@/components/admin/tech-tree/freeze";
|
||||||
|
import { computeLayout } from "@/components/admin/tech-tree/layout";
|
||||||
|
import { COLUMN_STEP, type TreeTech } from "@/components/admin/tech-tree/types";
|
||||||
|
|
||||||
|
const tech = (over: Partial<TreeTech> & { id: number }): TreeTech => ({
|
||||||
|
name: `Tech ${String(over.id).padStart(2, "0")}`,
|
||||||
|
type: "upgrade",
|
||||||
|
approvalStatus: "in_progress",
|
||||||
|
summary: "",
|
||||||
|
categoryId: null,
|
||||||
|
prereqAnd: [],
|
||||||
|
prereqOr: [],
|
||||||
|
resourceCosts: [],
|
||||||
|
treePosition: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const positionsOf = (techs: TreeTech[]) =>
|
||||||
|
new Map(computeLayout(techs, []).nodes.map((n) => [n.techId, { x: n.x, y: n.y }]));
|
||||||
|
|
||||||
|
describe("tech tree stability (freeze on data change)", () => {
|
||||||
|
it("pins every card the new layout would move, including the target's dependents", () => {
|
||||||
|
// B (2) already has a dependent C (3). Linking A (1) -> B relocates B,
|
||||||
|
// and C follows B in the ordered layout: both must be pinned in place.
|
||||||
|
const before = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2 }),
|
||||||
|
tech({ id: 3, prereqAnd: [2] }),
|
||||||
|
tech({ id: 4 }),
|
||||||
|
];
|
||||||
|
const current = positionsOf(before);
|
||||||
|
const after = before.map((t) => (t.id === 2 ? { ...t, prereqAnd: [1] } : t));
|
||||||
|
|
||||||
|
const pins = pinsToFreeze(current, after, []);
|
||||||
|
expect(pins.has(2)).toBe(true);
|
||||||
|
expect(pins.has(3)).toBe(true);
|
||||||
|
|
||||||
|
// With the pins applied, NOTHING on screen moves.
|
||||||
|
const frozen = positionsOf(applyPins(after, pins));
|
||||||
|
for (const [id, pos] of current) {
|
||||||
|
expect(frozen.get(id)).toEqual(pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pin cards whose position is unchanged", () => {
|
||||||
|
const before = [tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3 })];
|
||||||
|
const current = positionsOf(before);
|
||||||
|
const pins = pinsToFreeze(current, before, []);
|
||||||
|
expect(pins.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips technologies that have no on-screen position yet (just created)", () => {
|
||||||
|
const before = [tech({ id: 1 }), tech({ id: 3 })];
|
||||||
|
const current = positionsOf(before);
|
||||||
|
// A new tech whose name sorts between the others would shift catalog ranks.
|
||||||
|
const after = [...before, tech({ id: 2 })];
|
||||||
|
const pins = pinsToFreeze(current, after, []);
|
||||||
|
expect(pins.has(2)).toBe(false);
|
||||||
|
const frozen = positionsOf(applyPins(after, pins));
|
||||||
|
expect(frozen.get(1)).toEqual(current.get(1));
|
||||||
|
expect(frozen.get(3)).toEqual(current.get(3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tech tree edge visuals (path colors + lanes)", () => {
|
||||||
|
it("colors separate progression paths differently and keeps one color per chain", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
tech({ id: 3, prereqAnd: [2] }),
|
||||||
|
tech({ id: 10 }),
|
||||||
|
tech({ id: 11, prereqAnd: [10] }),
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ id: "a", source: 1, target: 2 },
|
||||||
|
{ id: "b", source: 2, target: 3 },
|
||||||
|
{ id: "c", source: 10, target: 11 },
|
||||||
|
];
|
||||||
|
const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP);
|
||||||
|
expect(visuals.get("a")!.color).toBe(visuals.get("b")!.color);
|
||||||
|
expect(visuals.get("a")!.color).not.toBe(visuals.get("c")!.color);
|
||||||
|
expect(PATH_PALETTE).toContain(visuals.get("c")!.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives different sources crossing the same gap their own lanes", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2 }),
|
||||||
|
tech({ id: 3, prereqAnd: [1] }),
|
||||||
|
tech({ id: 4, prereqAnd: [2] }),
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ id: "a", source: 1, target: 3 },
|
||||||
|
{ id: "b", source: 2, target: 4 },
|
||||||
|
];
|
||||||
|
const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP);
|
||||||
|
expect(visuals.get("a")!.stepPosition).not.toBe(visuals.get("b")!.stepPosition);
|
||||||
|
for (const v of visuals.values()) {
|
||||||
|
expect(v.stepPosition).toBeGreaterThanOrEqual(0.2);
|
||||||
|
expect(v.stepPosition).toBeLessThanOrEqual(0.8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps edges from the same source in one lane (a fan-out bus)", () => {
|
||||||
|
const techs = [
|
||||||
|
tech({ id: 1 }),
|
||||||
|
tech({ id: 2, prereqAnd: [1] }),
|
||||||
|
tech({ id: 3, prereqAnd: [1] }),
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ id: "a", source: 1, target: 2 },
|
||||||
|
{ id: "b", source: 1, target: 3 },
|
||||||
|
];
|
||||||
|
const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP);
|
||||||
|
expect(visuals.get("a")!.stepPosition).toBe(0.5);
|
||||||
|
expect(visuals.get("b")!.stepPosition).toBe(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue