@xforge-dev/forge-kit 0.17.1 → 0.18.1

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/dist/jobs.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ export declare const JOB_CATALOG_VERSION = 1;
2
+ export declare const JOB_CATALOG_URL = "https://cdn.xforge.dev/data/jobs.json";
3
+ export type JobBranch = 'warrior' | 'magician' | 'bowman' | 'thief' | 'pirate';
4
+ export type JobStat = 'STR' | 'DEX' | 'INT' | 'LUK' | 'HP' | 'ALL';
5
+ export type JobStatus = 'released' | 'upcoming' | 'retired';
6
+ export type LegionRank = 'B' | 'A' | 'S' | 'SS' | 'SSS';
7
+ export declare const JOB_LEGION_RANKS: readonly ["B", "A", "S", "SS", "SSS"];
8
+ export type RankValues<T> = [T, T, T, T, T];
9
+ /** Numeric keys BossForge models; others are display-only. STR..LUK are Final stats. */
10
+ export type LegionStatKey = 'STR' | 'DEX' | 'INT' | 'LUK' | 'hp' | 'hpPct' | 'mpPct' | 'critRate' | 'critDamage' | 'ied' | 'boss' | 'summonDuration' | 'buffDuration' | 'statusDamage' | 'cooldownPct' | 'mesos' | 'exp' | 'speed' | 'statusResist' | 'hpRecover' | 'mpRecover' | 'bonusDamage';
11
+ export type JobLegionEffect = {
12
+ stats: Partial<Record<LegionStatKey, RankValues<number>>>;
13
+ text: RankValues<string>;
14
+ };
15
+ /** A link shared by several jobs (Explorer branch links, Cygnus, Shine) has the same `key` on each. */
16
+ export type JobLink = {
17
+ key: string;
18
+ name: string;
19
+ icon: string;
20
+ max: number;
21
+ text: string[];
22
+ };
23
+ export type Job = {
24
+ name: string;
25
+ aliases: string[];
26
+ branches: JobBranch[];
27
+ faction: string;
28
+ mainStat: JobStat;
29
+ secondaryStats: JobStat[];
30
+ status: JobStatus;
31
+ icon: string | null;
32
+ nexon: {
33
+ jobId: number;
34
+ jobDetail: number;
35
+ } | null;
36
+ legionRankLevels?: RankValues<number>;
37
+ legion: JobLegionEffect | null;
38
+ link: JobLink | null;
39
+ };
40
+ export type JobCatalog = {
41
+ version: number;
42
+ updated: string;
43
+ jobs: Job[];
44
+ };
45
+ /** Validates the shape by hand (the kit has no schema library); throws an Error naming the first problem. */
46
+ export declare function parseJobCatalog(data: unknown): JobCatalog;
47
+ export declare function setJobCatalog(next: JobCatalog): void;
48
+ export declare function getJobCatalog(): JobCatalog;
49
+ /** Replaces the unknown-job reporter (default console.warn); each distinct trimmed name is reported once. */
50
+ export declare function onUnknownJob(fn: (name: string) => void): void;
51
+ /** Looks up a job by canonical name or alias, trimmed and case-insensitive. */
52
+ export declare function job(name: string | null | undefined): Job | null;
53
+ export declare function jobName(name: string | null | undefined): string | null;
54
+ /** Jobs sorted by name; retired ones are left out unless asked for. */
55
+ export declare function allJobs(opts?: {
56
+ includeRetired?: boolean;
57
+ }): readonly Job[];
58
+ /** Highest Legion rank a character of this level has reached; null below rank B's level. */
59
+ export declare function legionRankFor(j: Job, level: number): LegionRank | null;
package/dist/jobs.js ADDED
@@ -0,0 +1,127 @@
1
+ import { JOBS_SNAPSHOT } from './jobs-snapshot.js';
2
+ export const JOB_CATALOG_VERSION = 1;
3
+ export const JOB_CATALOG_URL = 'https://cdn.xforge.dev/data/jobs.json';
4
+ export const JOB_LEGION_RANKS = ['B', 'A', 'S', 'SS', 'SSS'];
5
+ /** Standard level needed for each rank (B..SSS); a job may override via `legionRankLevels`. */
6
+ const STANDARD_RANK_LEVELS = [60, 100, 140, 200, 250];
7
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
+ const isRank5 = (v, type) => Array.isArray(v) && v.length === 5 && v.every((x) => typeof x === type);
9
+ const BRANCHES = ['warrior', 'magician', 'bowman', 'thief', 'pirate'];
10
+ const STATS = ['STR', 'DEX', 'INT', 'LUK', 'HP', 'ALL'];
11
+ const isOneOf = (list, v) => list.includes(v);
12
+ const isListOf = (list, v) => Array.isArray(v) && v.every((x) => isOneOf(list, x));
13
+ /** Validates the shape by hand (the kit has no schema library); throws an Error naming the first problem. */
14
+ export function parseJobCatalog(data) {
15
+ if (!isObj(data))
16
+ throw new Error('job catalog: not an object');
17
+ if (data.version !== JOB_CATALOG_VERSION)
18
+ throw new Error(`job catalog: unsupported version ${String(data.version)}`);
19
+ if (typeof data.updated !== 'string')
20
+ throw new Error('job catalog: updated must be a string');
21
+ if (!Array.isArray(data.jobs))
22
+ throw new Error('job catalog: jobs must be an array');
23
+ const owner = new Map();
24
+ const claim = (key, name) => {
25
+ const k = key.trim().toLowerCase();
26
+ const prev = owner.get(k);
27
+ if (prev !== undefined)
28
+ throw new Error(`job "${name}": name or alias "${key}" already used by "${prev}"`);
29
+ owner.set(k, name);
30
+ };
31
+ for (const raw of data.jobs) {
32
+ if (!isObj(raw) || typeof raw.name !== 'string' || !raw.name.trim())
33
+ throw new Error('job catalog: a job has no name');
34
+ const name = raw.name;
35
+ const bad = (why) => new Error(`job "${name}": ${why}`);
36
+ if (!Array.isArray(raw.aliases) || !raw.aliases.every((a) => typeof a === 'string'))
37
+ throw bad('aliases must be strings');
38
+ if (raw.aliases.some((a) => !a.trim()))
39
+ throw bad('an alias is empty');
40
+ if (!isListOf(BRANCHES, raw.branches))
41
+ throw bad(`branches must be from ${BRANCHES.join(', ')}`);
42
+ if (typeof raw.faction !== 'string')
43
+ throw bad('faction must be a string');
44
+ if (!isOneOf(STATS, raw.mainStat))
45
+ throw bad(`mainStat must be one of ${STATS.join(', ')}`);
46
+ if (!isListOf(STATS, raw.secondaryStats))
47
+ throw bad(`secondaryStats must be from ${STATS.join(', ')}`);
48
+ if (raw.status !== 'released' && raw.status !== 'upcoming' && raw.status !== 'retired')
49
+ throw bad('bad status');
50
+ if (raw.icon !== null && typeof raw.icon !== 'string')
51
+ throw bad('icon must be a string or null');
52
+ if (raw.nexon !== null && !(isObj(raw.nexon) && typeof raw.nexon.jobId === 'number' && typeof raw.nexon.jobDetail === 'number')) {
53
+ throw bad('nexon must be { jobId, jobDetail } numbers or null');
54
+ }
55
+ if (raw.legionRankLevels !== undefined && !isRank5(raw.legionRankLevels, 'number'))
56
+ throw bad('legionRankLevels needs 5 numbers');
57
+ if (raw.legion !== null) {
58
+ if (!isObj(raw.legion) || !isObj(raw.legion.stats))
59
+ throw bad('legion.stats missing');
60
+ if (!isRank5(raw.legion.text, 'string'))
61
+ throw bad('legion.text needs 5 entries');
62
+ for (const [k, v] of Object.entries(raw.legion.stats))
63
+ if (!isRank5(v, 'number'))
64
+ throw bad(`legion.stats.${k} needs 5 numbers`);
65
+ }
66
+ if (raw.link !== null) {
67
+ const l = raw.link;
68
+ if (!isObj(l) || typeof l.key !== 'string' || typeof l.name !== 'string' || typeof l.icon !== 'string'
69
+ || typeof l.max !== 'number' || !Array.isArray(l.text))
70
+ throw bad('malformed link');
71
+ if (l.text.length !== l.max || !l.text.every((t) => typeof t === 'string'))
72
+ throw bad('link.text needs one string per level up to max');
73
+ }
74
+ claim(name, name);
75
+ for (const a of raw.aliases)
76
+ claim(a, name);
77
+ }
78
+ return data;
79
+ }
80
+ let catalog = JOBS_SNAPSHOT;
81
+ let byName = new Map();
82
+ const norm = (s) => s.trim().toLowerCase();
83
+ export function setJobCatalog(next) {
84
+ catalog = next;
85
+ byName = new Map();
86
+ for (const j of next.jobs) {
87
+ byName.set(norm(j.name), j);
88
+ for (const a of j.aliases)
89
+ byName.set(norm(a), j);
90
+ }
91
+ }
92
+ /** The registry starts as the built-in copy taken from data/jobs.json when the kit was built. */
93
+ setJobCatalog(JOBS_SNAPSHOT);
94
+ export function getJobCatalog() { return catalog; }
95
+ let report = (name) => console.warn(`[forge-kit] unknown job: ${name}`);
96
+ const reported = new Set();
97
+ /** Replaces the unknown-job reporter (default console.warn); each distinct trimmed name is reported once. */
98
+ export function onUnknownJob(fn) { report = fn; }
99
+ /** Looks up a job by canonical name or alias, trimmed and case-insensitive. */
100
+ export function job(name) {
101
+ const t = name?.trim();
102
+ if (!t)
103
+ return null;
104
+ const found = byName.get(t.toLowerCase());
105
+ if (found)
106
+ return found;
107
+ if (!reported.has(t)) {
108
+ reported.add(t);
109
+ report(t);
110
+ }
111
+ return null;
112
+ }
113
+ export function jobName(name) { return job(name)?.name ?? null; }
114
+ /** Jobs sorted by name; retired ones are left out unless asked for. */
115
+ export function allJobs(opts = {}) {
116
+ return catalog.jobs
117
+ .filter((j) => opts.includeRetired || j.status !== 'retired')
118
+ .sort((a, b) => a.name.localeCompare(b.name));
119
+ }
120
+ /** Highest Legion rank a character of this level has reached; null below rank B's level. */
121
+ export function legionRankFor(j, level) {
122
+ const levels = j.legionRankLevels ?? STANDARD_RANK_LEVELS;
123
+ let rank = null;
124
+ levels.forEach((min, i) => { if (level >= min)
125
+ rank = JOB_LEGION_RANKS[i]; });
126
+ return rank;
127
+ }
package/dist/server.d.ts CHANGED
@@ -24,3 +24,4 @@ export declare function updateForgeSession(request: NextRequest): Promise<{
24
24
  * receiving page; null everywhere else.
25
25
  */
26
26
  export declare function forgeLegacyResponse(request: NextRequest): NextResponse | null;
27
+ export { refreshJobCatalog } from './jobs-server.js';
package/dist/server.js CHANGED
@@ -76,3 +76,4 @@ export function forgeLegacyResponse(request) {
76
76
  },
77
77
  });
78
78
  }
79
+ export { refreshJobCatalog } from './jobs-server.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xforge-dev/forge-kit",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Shared auth, app registry and UI for xForge apps",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,8 +31,9 @@
31
31
  "./tokens.css": "./dist/tokens.css"
32
32
  },
33
33
  "scripts": {
34
- "build": "rm -rf dist && tsc -p tsconfig.json && cp src/tokens.css dist/tokens.css",
34
+ "build": "npm run snapshot-jobs && rm -rf dist && tsc -p tsconfig.json && cp src/tokens.css dist/tokens.css",
35
35
  "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "snapshot-jobs": "node scripts/snapshot-jobs.mjs",
36
37
  "test": "vitest run",
37
38
  "prepublishOnly": "npm run build"
38
39
  },