@xforge-dev/forge-kit 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,29 @@ the sign-in cookie, so a third-party script on one can read every user's session
106
106
  apps add Vercel Web Analytics (`<Analytics />` from `@vercel/analytics/next` in the root layout);
107
107
  it loads from the app's own origin, so the policy needs no extra sources.
108
108
 
109
+ ## Job catalog
110
+
111
+ The kit ships a snapshot of `data/jobs.json` for sync lookups (`job()`, `allJobs()`, ...).
112
+ `refreshJobCatalog()` (from `/server`) fetches the published copy at
113
+ `https://cdn.xforge.dev/data/jobs.json`, caching it for an hour, and installs it. The root layout
114
+ awaits it and passes the result to `ForgeJobsProvider` (from `/client`), so the browser gets the same
115
+ catalog. Whenever `data/jobs.json` changes, set `updated` to a new full ISO-8601 UTC timestamp (for
116
+ example `2026-10-29T18:30:00Z`). CI fails a content change that keeps the old stamp.
117
+
118
+ - **New enum values need a kit release first.** A new `status`, branch or stat value has to ship in
119
+ a kit release, and every app has to bump to that release, before `data/jobs.json` can use the value.
120
+ An older parser rejects the whole file and keeps its snapshot, so no part of the edit reaches that app.
121
+ - **Route handlers and server actions refresh the catalog themselves.** They don't run the root layout,
122
+ so any route handler or server action that looks up jobs must call `await refreshJobCatalog()`
123
+ before its first lookup. Otherwise it only ever sees the snapshot.
124
+
125
+ ## Legion board
126
+
127
+ `/legion` holds the Legion board model BossForge and LegionForge share: the 5 presets of a world
128
+ (`SharedBoard`: slots, placed name keys, grid, event blocks), the Artifact, and the rules both apps
129
+ check a board with (`boardProblem`). A preset places characters by `legionNameKey(name)`, the md5 of
130
+ the lowercased name without spaces, so a stored board never holds names.
131
+
109
132
  ## Exports
110
133
 
111
134
  `isForgeAuthConfigured` is exported from the root and `/client` entry points.
@@ -3,7 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { getJobCatalog, setJobCatalog } from './jobs.js';
4
4
  /** Installs the server-fetched catalog before the children render, so client lookups agree with the server. */
5
5
  export function ForgeJobsProvider({ catalog, children }) {
6
- if (catalog.updated !== getJobCatalog().updated)
6
+ if (catalog !== getJobCatalog())
7
7
  setJobCatalog(catalog);
8
8
  return _jsx(_Fragment, { children: children });
9
9
  }
@@ -1,6 +1,6 @@
1
1
  export const JOBS_SNAPSHOT = {
2
2
  "version": 1,
3
- "updated": "2026-10-29",
3
+ "updated": "2026-10-29T18:30:00Z",
4
4
  "jobs": [
5
5
  {
6
6
  "name": "Hero",
package/dist/jobs.js CHANGED
@@ -115,7 +115,7 @@ export function jobName(name) { return job(name)?.name ?? null; }
115
115
  export function allJobs(opts = {}) {
116
116
  return catalog.jobs
117
117
  .filter((j) => opts.includeRetired || j.status !== 'retired')
118
- .sort((a, b) => a.name.localeCompare(b.name));
118
+ .sort((a, b) => a.name.localeCompare(b.name, 'en'));
119
119
  }
120
120
  /** Highest Legion rank a character of this level has reached; null below rank B's level. */
121
121
  export function legionRankFor(j, level) {
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The Legion board both BossForge and LegionForge edit: one per account and world (BossForge's legion_worlds), with
3
+ * 5 presets of placement, grid squares and event blocks, and one Artifact. Values from the MapleStory Wiki (Legion,
4
+ * Legion Artifact), read 2026-10-26.
5
+ */
6
+ export declare const LEGION_RANKS: readonly ["B", "A", "S", "SS", "SSS"];
7
+ export type LegionRank = (typeof LEGION_RANKS)[number];
8
+ /** Squares a member's block covers on the board: 1 at rank B up to 5 at SSS. */
9
+ export declare const blockSize: (rank: LegionRank) => number;
10
+ /**
11
+ * Inner grid squares (Stat % applies) and outer grid squares, with each square's value. The full 22 × 20 board's
12
+ * inner 12 × 10 splits into 8 areas of 15 squares; the other 320 split into 8 outer areas of 40.
13
+ */
14
+ export declare const GRID_KEYS: readonly ["str", "dex", "int", "luk", "hp", "mp", "att", "matt", "critRate", "boss", "normal", "buffDuration", "ied", "critDamage", "statusResist", "exp"];
15
+ export type GridKey = (typeof GRID_KEYS)[number];
16
+ export type LegionGrid = Partial<Record<GridKey, number>>;
17
+ export declare const OUTER_GRID_KEYS: readonly GridKey[];
18
+ export declare const GRID_MAX_OUTER = 40;
19
+ export declare const GRID_MAX_INNER = 15;
20
+ export declare const gridMax: (k: GridKey) => 40 | 15;
21
+ export declare const GRID_PER_SQUARE: Record<GridKey, number>;
22
+ export declare const GRID_LABELS: Record<GridKey, string>;
23
+ /** Squares filled across the grid's areas. */
24
+ export declare const gridTotal: (grid: LegionGrid) => number;
25
+ /**
26
+ * Artifact lines: each of the 9 crystals (level 0–5) carries up to 3 different lines; a line's level is the sum of the
27
+ * levels of the crystals that carry it, capped at 10, and its value is level × max / 10. A line sits on at most 2
28
+ * crystals, which is all a Lv 10 line needs.
29
+ */
30
+ export declare const ARTIFACT_KEYS: readonly ["allStat", "hpMp", "att", "damage", "boss", "ied", "buffDuration", "cooldownSkip", "critRate", "critDamage", "summonDuration", "finalAttack", "meso", "drop", "expTarget", "statusResist"];
31
+ export type ArtifactKey = (typeof ARTIFACT_KEYS)[number];
32
+ export declare const ARTIFACT_MAX_LEVEL = 10;
33
+ export declare const ARTIFACT_CRYSTALS: readonly ["Orange Mushroom", "Slime", "Horny Mushroom", "Stump", "Stone Golem", "Balrog", "Zakum", "Pink Bean", "Papulatus"];
34
+ export declare const CRYSTAL_MAX_LEVEL = 5;
35
+ export declare const CRYSTAL_LINES = 3;
36
+ export declare const ARTIFACT_LINE_MAX_CRYSTALS = 2;
37
+ /** A crystal's icon on the CDN (legion/artifact, from the MapleStory Wiki). */
38
+ export declare const crystalImageUrl: (i: number) => string;
39
+ export declare const ARTIFACT_MAX: Record<ArtifactKey, number>;
40
+ export declare const ARTIFACT_LABELS: Record<ArtifactKey, string>;
41
+ export type ArtifactCrystal = {
42
+ level: number;
43
+ lines: ArtifactKey[];
44
+ };
45
+ /** Each line's level from the crystals: summed over the crystals carrying it, capped at 10. */
46
+ export declare function artifactLevels(crystals: readonly ArtifactCrystal[]): Partial<Record<ArtifactKey, number>>;
47
+ /** How many crystals carry each line. */
48
+ export declare function artifactLineUses(crystals: readonly ArtifactCrystal[]): Partial<Record<ArtifactKey, number>>;
49
+ export declare function artifactValue(key: ArtifactKey, level: number): number;
50
+ /**
51
+ * Event Legion blocks (the wiki's Legion § Event Legion Blocks, GMS): ATT and Magic ATT only, flat (Stat % applies),
52
+ * in the board's two special slots, outside the member slots. Each covers squares like a member block of its rank
53
+ * (B 1 … SSS 5), so it also adds to what the grid can hold. Lab Server and Abyssal Expedition (Minar Forest, GMS's
54
+ * one run; its rank was how far the event went) blocks level by rank (B…SSS); Ride or Die came in several seasons,
55
+ * so `copies: 2` lets it fill both slots. Blocks without ranks count as SSS.
56
+ */
57
+ export declare const EVENT_BLOCKS: readonly [{
58
+ readonly id: "labServer";
59
+ readonly name: "Lab Server Legion Block";
60
+ readonly values: readonly [5, 10, 15, 20, 25];
61
+ readonly ranked: true;
62
+ readonly copies: 1;
63
+ }, {
64
+ readonly id: "enhancedLabServer";
65
+ readonly name: "Enhanced Lab Server Legion Block";
66
+ readonly values: readonly [7, 14, 21, 28, 35];
67
+ readonly ranked: true;
68
+ readonly copies: 1;
69
+ }, {
70
+ readonly id: "abyssalExpedition";
71
+ readonly name: "Abyssal Expedition Legion Block";
72
+ readonly values: readonly [7, 14, 21, 28, 35];
73
+ readonly ranked: true;
74
+ readonly copies: 1;
75
+ }, {
76
+ readonly id: "rideOrDie";
77
+ readonly name: "Ride or Die Legion Block";
78
+ readonly values: readonly [35];
79
+ readonly ranked: false;
80
+ readonly copies: 2;
81
+ }];
82
+ export type EventBlockId = (typeof EVENT_BLOCKS)[number]['id'];
83
+ export type EventBlock = {
84
+ id: EventBlockId;
85
+ value: number;
86
+ };
87
+ export declare const MAX_EVENT_BLOCKS = 2;
88
+ export declare const eventBlock: (id: string) => {
89
+ readonly id: "labServer";
90
+ readonly name: "Lab Server Legion Block";
91
+ readonly values: readonly [5, 10, 15, 20, 25];
92
+ readonly ranked: true;
93
+ readonly copies: 1;
94
+ } | {
95
+ readonly id: "enhancedLabServer";
96
+ readonly name: "Enhanced Lab Server Legion Block";
97
+ readonly values: readonly [7, 14, 21, 28, 35];
98
+ readonly ranked: true;
99
+ readonly copies: 1;
100
+ } | {
101
+ readonly id: "abyssalExpedition";
102
+ readonly name: "Abyssal Expedition Legion Block";
103
+ readonly values: readonly [7, 14, 21, 28, 35];
104
+ readonly ranked: true;
105
+ readonly copies: 1;
106
+ } | {
107
+ readonly id: "rideOrDie";
108
+ readonly name: "Ride or Die Legion Block";
109
+ readonly values: readonly [35];
110
+ readonly ranked: false;
111
+ readonly copies: 2;
112
+ } | undefined;
113
+ /** Squares an event block covers: its rank's block size, or SSS's for a block without ranks. */
114
+ export declare function eventBlockSquares(b: EventBlock): number;
115
+ export declare const eventBlockUrl: (id: EventBlockId) => string;
116
+ export declare const MAX_LEGION_SLOTS = 50;
117
+ export declare const MAX_LEGION_MEMBERS = 60;
118
+ export declare const LEGION_PRESETS = 5;
119
+ /**
120
+ * A character's key on the board: md5 of its name, lowercased, with surrounding spaces stripped. It is what BossForge's
121
+ * character_legion returns for names (md5(lower(btrim(name)))), so a stored placement reveals no names.
122
+ */
123
+ export declare const legionNameKey: (name: string) => string;
124
+ /** One roster member as the board check sees it: `job` canonical in the caller's naming, `rank` null below B. */
125
+ export type BoardMember = {
126
+ key?: string;
127
+ job: string;
128
+ level: number;
129
+ rank: LegionRank | null;
130
+ placed: boolean;
131
+ };
132
+ /**
133
+ * What's wrong with a preset's board, or null. With members entered: no more slots than the Legion level allows
134
+ * (`cap`, from Legion level `level`), no more placed members than slots, one member per job, and no more grid squares than the placed members
135
+ * (the highest `slots` with a rank) and event blocks cover.
136
+ */
137
+ export declare function boardProblem(b: {
138
+ slots: number;
139
+ cap: number;
140
+ level?: number;
141
+ members: readonly BoardMember[];
142
+ grid: LegionGrid;
143
+ blocks?: readonly EventBlock[];
144
+ }): string | null;
145
+ /** A preset as stored for a world both apps share: who's placed (name keys), its slots, grid and event blocks. */
146
+ export type SharedBoard = {
147
+ slots: number;
148
+ placed: string[];
149
+ grid: LegionGrid;
150
+ blocks?: EventBlock[];
151
+ };
152
+ /** The shared part of a world's Legion: 5 presets, the one in use (0-based) and the Artifact. */
153
+ export type SharedLegion = {
154
+ presets: SharedBoard[];
155
+ active: number;
156
+ crystals: ArtifactCrystal[];
157
+ };
158
+ export declare const emptySharedBoard: () => SharedBoard;
159
+ /** Valid grid squares: known areas, whole numbers within each area's size, empty areas dropped. */
160
+ export declare function gridFrom(raw: unknown): LegionGrid;
161
+ /** Valid event blocks: known blocks at one of their values, each within its copies, two at most. */
162
+ export declare function blocksFrom(raw: unknown): EventBlock[];
163
+ /** A stored shared preset as a valid one, or null when it isn't one (no `placed`: an older, member-shaped preset). */
164
+ export declare function sharedBoardFrom(raw: unknown): SharedBoard | null;
165
+ /** Valid crystals: nine at most, levels 0–5, up to 3 known lines each, a line on at most 2 crystals. */
166
+ export declare function crystalsFrom(raw: unknown): ArtifactCrystal[];
package/dist/legion.js ADDED
@@ -0,0 +1,197 @@
1
+ import { md5 } from './md5.js';
2
+ /**
3
+ * The Legion board both BossForge and LegionForge edit: one per account and world (BossForge's legion_worlds), with
4
+ * 5 presets of placement, grid squares and event blocks, and one Artifact. Values from the MapleStory Wiki (Legion,
5
+ * Legion Artifact), read 2026-10-26.
6
+ */
7
+ export const LEGION_RANKS = ['B', 'A', 'S', 'SS', 'SSS'];
8
+ /** Squares a member's block covers on the board: 1 at rank B up to 5 at SSS. */
9
+ export const blockSize = (rank) => LEGION_RANKS.indexOf(rank) + 1;
10
+ /**
11
+ * Inner grid squares (Stat % applies) and outer grid squares, with each square's value. The full 22 × 20 board's
12
+ * inner 12 × 10 splits into 8 areas of 15 squares; the other 320 split into 8 outer areas of 40.
13
+ */
14
+ export const GRID_KEYS = [
15
+ 'str', 'dex', 'int', 'luk', 'hp', 'mp', 'att', 'matt',
16
+ 'critRate', 'boss', 'normal', 'buffDuration', 'ied', 'critDamage', 'statusResist', 'exp',
17
+ ];
18
+ export const OUTER_GRID_KEYS = ['critRate', 'boss', 'normal', 'buffDuration', 'ied', 'critDamage', 'statusResist', 'exp'];
19
+ export const GRID_MAX_OUTER = 40;
20
+ export const GRID_MAX_INNER = 15;
21
+ export const gridMax = (k) => (OUTER_GRID_KEYS.includes(k) ? GRID_MAX_OUTER : GRID_MAX_INNER);
22
+ export const GRID_PER_SQUARE = {
23
+ str: 5, dex: 5, int: 5, luk: 5, hp: 250, mp: 250, att: 1, matt: 1,
24
+ critRate: 1, boss: 1, normal: 1, buffDuration: 1, ied: 1, critDamage: 0.5, statusResist: 1, exp: 0.25,
25
+ };
26
+ export const GRID_LABELS = {
27
+ str: 'STR', dex: 'DEX', int: 'INT', luk: 'LUK', hp: 'Max HP', mp: 'Max MP', att: 'ATT', matt: 'Magic ATT',
28
+ critRate: 'Critical Rate', boss: 'Boss Damage', normal: 'Normal Monster Damage', buffDuration: 'Buff Duration',
29
+ ied: 'Ignore Enemy DEF', critDamage: 'Critical Damage', statusResist: 'Status Resistance', exp: 'EXP',
30
+ };
31
+ /** Squares filled across the grid's areas. */
32
+ export const gridTotal = (grid) => Object.values(grid).reduce((t, n) => t + (n ?? 0), 0);
33
+ /**
34
+ * Artifact lines: each of the 9 crystals (level 0–5) carries up to 3 different lines; a line's level is the sum of the
35
+ * levels of the crystals that carry it, capped at 10, and its value is level × max / 10. A line sits on at most 2
36
+ * crystals, which is all a Lv 10 line needs.
37
+ */
38
+ export const ARTIFACT_KEYS = [
39
+ 'allStat', 'hpMp', 'att', 'damage', 'boss', 'ied', 'buffDuration', 'cooldownSkip', 'critRate', 'critDamage',
40
+ 'summonDuration', 'finalAttack', 'meso', 'drop', 'expTarget', 'statusResist',
41
+ ];
42
+ export const ARTIFACT_MAX_LEVEL = 10;
43
+ export const ARTIFACT_CRYSTALS = ['Orange Mushroom', 'Slime', 'Horny Mushroom', 'Stump', 'Stone Golem', 'Balrog', 'Zakum', 'Pink Bean', 'Papulatus'];
44
+ export const CRYSTAL_MAX_LEVEL = 5;
45
+ export const CRYSTAL_LINES = 3;
46
+ export const ARTIFACT_LINE_MAX_CRYSTALS = 2;
47
+ const CDN = 'https://cdn.xforge.dev';
48
+ /** A crystal's icon on the CDN (legion/artifact, from the MapleStory Wiki). */
49
+ export const crystalImageUrl = (i) => `${CDN}/legion/artifact/${ARTIFACT_CRYSTALS[i].toLowerCase().replace(/ /g, '-')}.png`;
50
+ export const ARTIFACT_MAX = {
51
+ allStat: 150, hpMp: 7500, att: 30, damage: 15, boss: 15, ied: 20, buffDuration: 20, cooldownSkip: 7.5,
52
+ critRate: 20, critDamage: 4, summonDuration: 20, finalAttack: 30, meso: 12, drop: 12, expTarget: 12, statusResist: 12,
53
+ };
54
+ export const ARTIFACT_LABELS = {
55
+ allStat: 'All Stats', hpMp: 'Max HP/MP', att: 'ATT / Magic ATT', damage: 'Damage', boss: 'Boss Damage',
56
+ ied: 'Ignore Enemy DEF', buffDuration: 'Buff Duration', cooldownSkip: 'Cooldown Skip Chance', critRate: 'Critical Rate',
57
+ critDamage: 'Critical Damage', summonDuration: 'Summon Duration', finalAttack: 'Final Attack Damage', meso: 'Mesos Obtained',
58
+ drop: 'Item Drop Rate', expTarget: 'EXP and +1 Target', statusResist: 'Status Resistance',
59
+ };
60
+ /** Each line's level from the crystals: summed over the crystals carrying it, capped at 10. */
61
+ export function artifactLevels(crystals) {
62
+ const out = {};
63
+ for (const c of crystals)
64
+ for (const k of c.lines)
65
+ out[k] = Math.min((out[k] ?? 0) + c.level, ARTIFACT_MAX_LEVEL);
66
+ return out;
67
+ }
68
+ /** How many crystals carry each line. */
69
+ export function artifactLineUses(crystals) {
70
+ const out = {};
71
+ for (const c of crystals)
72
+ for (const k of c.lines)
73
+ out[k] = (out[k] ?? 0) + 1;
74
+ return out;
75
+ }
76
+ export function artifactValue(key, level) {
77
+ return (Math.min(Math.max(level, 0), ARTIFACT_MAX_LEVEL) * ARTIFACT_MAX[key]) / ARTIFACT_MAX_LEVEL;
78
+ }
79
+ /**
80
+ * Event Legion blocks (the wiki's Legion § Event Legion Blocks, GMS): ATT and Magic ATT only, flat (Stat % applies),
81
+ * in the board's two special slots, outside the member slots. Each covers squares like a member block of its rank
82
+ * (B 1 … SSS 5), so it also adds to what the grid can hold. Lab Server and Abyssal Expedition (Minar Forest, GMS's
83
+ * one run; its rank was how far the event went) blocks level by rank (B…SSS); Ride or Die came in several seasons,
84
+ * so `copies: 2` lets it fill both slots. Blocks without ranks count as SSS.
85
+ */
86
+ export const EVENT_BLOCKS = [
87
+ { id: 'labServer', name: 'Lab Server Legion Block', values: [5, 10, 15, 20, 25], ranked: true, copies: 1 },
88
+ { id: 'enhancedLabServer', name: 'Enhanced Lab Server Legion Block', values: [7, 14, 21, 28, 35], ranked: true, copies: 1 },
89
+ { id: 'abyssalExpedition', name: 'Abyssal Expedition Legion Block', values: [7, 14, 21, 28, 35], ranked: true, copies: 1 },
90
+ { id: 'rideOrDie', name: 'Ride or Die Legion Block', values: [35], ranked: false, copies: 2 },
91
+ ];
92
+ export const MAX_EVENT_BLOCKS = 2;
93
+ export const eventBlock = (id) => EVENT_BLOCKS.find((b) => b.id === id);
94
+ /** Squares an event block covers: its rank's block size, or SSS's for a block without ranks. */
95
+ export function eventBlockSquares(b) {
96
+ const def = eventBlock(b.id);
97
+ if (!def?.ranked)
98
+ return blockSize('SSS');
99
+ return def.values.indexOf(b.value) + 1;
100
+ }
101
+ const BLOCK_ART = {
102
+ labServer: 'lab-server', enhancedLabServer: 'enhanced-lab-server', abyssalExpedition: 'abyssal-expedition', rideOrDie: 'ride-or-die',
103
+ };
104
+ export const eventBlockUrl = (id) => `${CDN}/legion/blocks/${BLOCK_ART[id]}.png`;
105
+ export const MAX_LEGION_SLOTS = 50;
106
+ export const MAX_LEGION_MEMBERS = 60;
107
+ export const LEGION_PRESETS = 5;
108
+ /**
109
+ * A character's key on the board: md5 of its name, lowercased, with surrounding spaces stripped. It is what BossForge's
110
+ * character_legion returns for names (md5(lower(btrim(name)))), so a stored placement reveals no names.
111
+ */
112
+ export const legionNameKey = (name) => md5(name.replace(/^ +| +$/g, '').toLowerCase());
113
+ /**
114
+ * What's wrong with a preset's board, or null. With members entered: no more slots than the Legion level allows
115
+ * (`cap`, from Legion level `level`), no more placed members than slots, one member per job, and no more grid squares than the placed members
116
+ * (the highest `slots` with a rank) and event blocks cover.
117
+ */
118
+ export function boardProblem(b) {
119
+ if (!b.members.length)
120
+ return null;
121
+ if (b.slots > b.cap)
122
+ return `Legion level ${b.level !== undefined ? `${b.level.toLocaleString('en-US')} ` : ''}allows ${b.cap} members on the board.`;
123
+ const placed = b.members.filter((m) => m.placed);
124
+ if (placed.length > b.slots)
125
+ return `The board holds ${b.slots} members.`;
126
+ const jobs = new Set();
127
+ for (const m of placed) {
128
+ if (jobs.has(m.job))
129
+ return `Only one ${m.job} can be on the board.`;
130
+ jobs.add(m.job);
131
+ }
132
+ const squares = placed.filter((m) => m.rank).sort((x, y) => y.level - x.level).slice(0, b.slots)
133
+ .reduce((t, m) => t + blockSize(m.rank), 0) + (b.blocks ?? []).reduce((t, e) => t + eventBlockSquares(e), 0);
134
+ const used = gridTotal(b.grid);
135
+ if (used > squares)
136
+ return `The grid has ${used} squares filled, but your placed members and event blocks cover ${squares}.`;
137
+ return null;
138
+ }
139
+ export const emptySharedBoard = () => ({ slots: MAX_LEGION_SLOTS, placed: [], grid: {} });
140
+ const isObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
141
+ const int = (x, min, max) => (typeof x === 'number' && Number.isFinite(x) ? Math.min(Math.max(Math.trunc(x), min), max) : null);
142
+ /** Valid grid squares: known areas, whole numbers within each area's size, empty areas dropped. */
143
+ export function gridFrom(raw) {
144
+ if (!isObject(raw))
145
+ return {};
146
+ const out = {};
147
+ for (const k of GRID_KEYS) {
148
+ const n = int(raw[k], 0, gridMax(k));
149
+ if (n)
150
+ out[k] = n;
151
+ }
152
+ return out;
153
+ }
154
+ /** Valid event blocks: known blocks at one of their values, each within its copies, two at most. */
155
+ export function blocksFrom(raw) {
156
+ if (!Array.isArray(raw))
157
+ return [];
158
+ const out = [];
159
+ for (const x of raw) {
160
+ if (!isObject(x) || out.length >= MAX_EVENT_BLOCKS)
161
+ continue;
162
+ const def = eventBlock(String(x.id));
163
+ if (!def || !def.values.includes(x.value))
164
+ continue;
165
+ if (out.filter((b) => b.id === def.id).length >= def.copies)
166
+ continue;
167
+ out.push({ id: def.id, value: x.value });
168
+ }
169
+ return out;
170
+ }
171
+ /** A stored shared preset as a valid one, or null when it isn't one (no `placed`: an older, member-shaped preset). */
172
+ export function sharedBoardFrom(raw) {
173
+ if (!isObject(raw) || !Array.isArray(raw.placed))
174
+ return null;
175
+ const placed = [...new Set(raw.placed.filter((k) => typeof k === 'string' && k.length > 0))].slice(0, MAX_LEGION_MEMBERS);
176
+ const blocks = blocksFrom(raw.blocks);
177
+ return { slots: int(raw.slots, 0, MAX_LEGION_SLOTS) ?? MAX_LEGION_SLOTS, placed, grid: gridFrom(raw.grid), ...(blocks.length ? { blocks } : {}) };
178
+ }
179
+ /** Valid crystals: nine at most, levels 0–5, up to 3 known lines each, a line on at most 2 crystals. */
180
+ export function crystalsFrom(raw) {
181
+ if (!Array.isArray(raw))
182
+ return [];
183
+ const uses = {};
184
+ return raw.slice(0, ARTIFACT_CRYSTALS.length).map((c) => {
185
+ const level = isObject(c) ? int(c.level, 0, CRYSTAL_MAX_LEVEL) ?? 0 : 0;
186
+ const lines = [];
187
+ for (const k of isObject(c) && Array.isArray(c.lines) ? c.lines : []) {
188
+ if (!ARTIFACT_KEYS.includes(k) || lines.includes(k) || lines.length >= CRYSTAL_LINES)
189
+ continue;
190
+ if ((uses[k] ?? 0) >= ARTIFACT_LINE_MAX_CRYSTALS)
191
+ continue;
192
+ uses[k] = (uses[k] ?? 0) + 1;
193
+ lines.push(k);
194
+ }
195
+ return { level, lines };
196
+ });
197
+ }
package/dist/md5.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** MD5 (RFC 1321) of a string's UTF-8 bytes, as lowercase hex: the same value as Postgres md5(). */
2
+ export declare function md5(input: string): string;
package/dist/md5.js ADDED
@@ -0,0 +1,48 @@
1
+ /** MD5 (RFC 1321) of a string's UTF-8 bytes, as lowercase hex: the same value as Postgres md5(). */
2
+ const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
3
+ const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0);
4
+ export function md5(input) {
5
+ const bytes = new TextEncoder().encode(input);
6
+ const words = new Uint32Array((((bytes.length + 8) >>> 6) + 1) * 16);
7
+ for (let i = 0; i < bytes.length; i++)
8
+ words[i >>> 2] |= bytes[i] << ((i % 4) * 8);
9
+ words[bytes.length >>> 2] |= 0x80 << ((bytes.length % 4) * 8);
10
+ const bits = bytes.length * 8;
11
+ words[words.length - 2] = bits >>> 0;
12
+ words[words.length - 1] = Math.floor(bits / 2 ** 32);
13
+ let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
14
+ for (let off = 0; off < words.length; off += 16) {
15
+ let a = a0, b = b0, c = c0, d = d0;
16
+ for (let i = 0; i < 64; i++) {
17
+ let f, g;
18
+ if (i < 16) {
19
+ f = (b & c) | (~b & d);
20
+ g = i;
21
+ }
22
+ else if (i < 32) {
23
+ f = (d & b) | (~d & c);
24
+ g = (5 * i + 1) % 16;
25
+ }
26
+ else if (i < 48) {
27
+ f = b ^ c ^ d;
28
+ g = (3 * i + 5) % 16;
29
+ }
30
+ else {
31
+ f = c ^ (b | ~d);
32
+ g = (7 * i) % 16;
33
+ }
34
+ const t = d;
35
+ d = c;
36
+ c = b;
37
+ const x = (a + f + K[i] + words[off + g]) >>> 0;
38
+ const s = S[(i >>> 4) * 4 + (i % 4)];
39
+ b = (b + ((x << s) | (x >>> (32 - s)))) >>> 0;
40
+ a = t;
41
+ }
42
+ a0 = (a0 + a) >>> 0;
43
+ b0 = (b0 + b) >>> 0;
44
+ c0 = (c0 + c) >>> 0;
45
+ d0 = (d0 + d) >>> 0;
46
+ }
47
+ return [a0, b0, c0, d0].map((w) => Array.from({ length: 4 }, (_, i) => ((w >>> (i * 8)) & 0xff).toString(16).padStart(2, '0')).join('')).join('');
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xforge-dev/forge-kit",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Shared auth, app registry and UI for xForge apps",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,10 @@
24
24
  "types": "./dist/client.d.ts",
25
25
  "default": "./dist/client.js"
26
26
  },
27
+ "./legion": {
28
+ "types": "./dist/legion.d.ts",
29
+ "default": "./dist/legion.js"
30
+ },
27
31
  "./server": {
28
32
  "types": "./dist/server.d.ts",
29
33
  "default": "./dist/server.js"