@xforge-dev/forge-kit 0.23.0 → 0.24.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
@@ -129,6 +129,15 @@ example `2026-10-29T18:30:00Z`). CI fails a content change that keeps the old st
129
129
  check a board with (`boardProblem`). A preset places characters by `legionNameKey(name)`, the md5 of
130
130
  the lowercased name without spaces, so a stored board never holds names.
131
131
 
132
+ ### Legion Points (GMS v.272)
133
+
134
+ v.272 removes the board. The points model sits beside it (the board stays for app versions still reading it):
135
+ `legionPoints` (the automatic raid's ranks, B 1 … SSS 5, as many members as the Legion rank holds), stat levels
136
+ (`LegionStats`, one point a level, `statCap` 15 for basic stats and up to 40 for expanded ones by Legion level),
137
+ `allocationProblem`, `statTotals`, `suggestStats`, the two highest event blocks (`appliedEventBlocks`), 10 named
138
+ presets (`SharedPoints`, stored under `points` in the shared row so an older board write leaves it alone) and the 5th
139
+ Champion slot (`championSlotsOpen`). Values not yet confirmed sit in one marked block in `src/legion.ts`.
140
+
132
141
  ## Exports
133
142
 
134
143
  `isForgeAuthConfigured` is exported from the root and `/client` entry points.
package/dist/legion.d.ts CHANGED
@@ -164,3 +164,110 @@ export declare function blocksFrom(raw: unknown): EventBlock[];
164
164
  export declare function sharedBoardFrom(raw: unknown): SharedBoard | null;
165
165
  /** Valid crystals: nine at most, levels 0–5, up to 3 known lines each, a line on at most 2 crystals. */
166
166
  export declare function crystalsFrom(raw: unknown): ArtifactCrystal[];
167
+ /** The Legion's 16 stats, keyed as the grid's areas were. */
168
+ export type LegionStatKey = GridKey;
169
+ /** Basic stats, open from the start (the patch notes' order). */
170
+ export declare const BASIC_STATS: readonly ["str", "dex", "int", "luk", "att", "matt", "hp", "mp"];
171
+ /** Expanded stats, opening together at Legion level 2,000 (the patch notes' order). */
172
+ export declare const EXPANDED_STATS: readonly ["critDamage", "boss", "ied", "critRate", "buffDuration", "normal", "exp", "statusResist"];
173
+ export type ExpandedStat = (typeof EXPANDED_STATS)[number];
174
+ export declare const LEGION_STATS: readonly GridKey[];
175
+ export declare const STAT_LABELS: Record<GridKey, string>;
176
+ /** Legion Points a member gives by rank (patch notes): B 1, A 2, S 3, SS 4, SSS 5. */
177
+ export declare const RANK_POINTS: Record<LegionRank, number>;
178
+ export declare const STAT_PRESETS = 10;
179
+ export declare const PRESET_NAME_MAX = 20;
180
+ export declare const defaultPresetName: (i: number) => string;
181
+ /** Legion ranks by Legion level: name, the level it starts at, its raid members (the wiki's table) and insignia. */
182
+ export type LegionLevelRank = {
183
+ name: string;
184
+ minLevel: number;
185
+ members: number;
186
+ image: string;
187
+ };
188
+ export declare const LEGION_LEVEL_RANKS: readonly LegionLevelRank[];
189
+ /** The Legion rank at this Legion level, or null below Nameless I (500). */
190
+ export declare function legionLevelRank(level: number): LegionLevelRank | null;
191
+ /** Members the automatic Legion Raid takes at this Legion level (Nameless I's 9 below it). */
192
+ export declare const raidMembers: (level: number) => number;
193
+ /** Each stat's highest level. */
194
+ export declare const STAT_MAX_LEVEL: Record<GridKey, number>;
195
+ /** The expanded stats' shared level cap by Legion level (the guide's 유니온 레벨별 확장 스탯 최대 레벨). */
196
+ export declare const EXPANDED_CAPS: readonly {
197
+ level: number;
198
+ cap: number;
199
+ }[];
200
+ /** The expanded stats' level cap at this Legion level: 0 below 2,000. */
201
+ export declare function expandedCap(legionLevel: number): number;
202
+ /** What one level of each stat gives. */
203
+ export declare const STAT_PER_LEVEL: Record<GridKey, number>;
204
+ /** Points an applied event block gives. */
205
+ export declare const eventBlockPoints: (_b: EventBlock) => number;
206
+ /** Stat levels, one per point: absent or 0 when nothing is spent. */
207
+ export type LegionStats = Partial<Record<GridKey, number>>;
208
+ export declare const isExpandedStat: (k: GridKey) => k is ExpandedStat;
209
+ /** A stat's level cap at this Legion level: basic stats 15, expanded stats as the Legion level allows. */
210
+ export declare const statCap: (k: GridKey, legionLevel: number) => number;
211
+ /** Whether a stat is open at this Legion level. */
212
+ export declare const statUnlocked: (k: GridKey, legionLevel: number) => boolean;
213
+ /** One member as the points model reads it: `rank` null below B (no points, no effect). */
214
+ export type PointsMember = {
215
+ level: number;
216
+ rank: LegionRank | null;
217
+ };
218
+ /**
219
+ * The members the automatic raid takes: the highest levels with a rank, as many as the Legion level's rank holds.
220
+ * The caller works out ranks (Zero's own levels) and Legion level.
221
+ */
222
+ export declare function raidComposition<M extends PointsMember>(members: readonly M[], legionLevel: number): M[];
223
+ /** The two event blocks that apply: the highest values among those owned. */
224
+ export declare function appliedEventBlocks(owned: readonly EventBlock[]): EventBlock[];
225
+ /** Legion Points to spend: the raid's ranks (and the applied event blocks', none as far as known). */
226
+ export declare function legionPoints(members: readonly PointsMember[], legionLevel: number, owned?: readonly EventBlock[]): number;
227
+ /** Points the stat levels spend. */
228
+ export declare const pointsSpent: (stats: LegionStats) => number;
229
+ /** What each stat gives at its level (only stats with a level). */
230
+ export declare function statTotals(stats: LegionStats): LegionStats;
231
+ /** What's wrong with an allocation, or null: no stat past its cap at the Legion level, no overspending. */
232
+ export declare function allocationProblem(a: {
233
+ stats: LegionStats;
234
+ points: number;
235
+ legionLevel: number;
236
+ }): string | null;
237
+ /**
238
+ * The best allocation for linear weights (value per stat level, e.g. damage per level): with every level costing one
239
+ * point, the open stats fill highest weight first, each to its cap. Stats without a positive weight get nothing.
240
+ */
241
+ export declare function suggestStats(weights: Partial<Record<GridKey, number>>, points: number, legionLevel: number): LegionStats;
242
+ /** Valid stat levels: known stats, whole numbers within each stat's cap, zeros dropped. */
243
+ export declare function statsFrom(raw: unknown): LegionStats;
244
+ /** Valid owned event blocks: known blocks at one of their values, each within its copies. */
245
+ export declare function ownedBlocksFrom(raw: unknown): EventBlock[];
246
+ /** A Legion Stat Preset: its name and stat levels. */
247
+ export type StatPreset = {
248
+ name: string;
249
+ stats: LegionStats;
250
+ };
251
+ /**
252
+ * The points part of a world's Legion, stored under `points` in the shared row so an older app's board write (which
253
+ * merges `presets`, `active` and `crystals`) leaves it alone: 10 presets, the one in use (0-based) and the event
254
+ * blocks owned.
255
+ */
256
+ export type SharedPoints = {
257
+ presets: StatPreset[];
258
+ active: number;
259
+ blocks: EventBlock[];
260
+ };
261
+ export declare const emptyStatPreset: (i: number) => StatPreset;
262
+ export declare const emptySharedPoints: () => SharedPoints;
263
+ /** A preset name trimmed to PRESET_NAME_MAX, or the default for its slot when blank. */
264
+ export declare function presetName(raw: unknown, i: number): string;
265
+ /** A stored points part as a valid one: always 10 presets, missing ones empty. Anything else (absent, older rows) is empty. */
266
+ export declare function sharedPointsFrom(raw: unknown): SharedPoints;
267
+ /** Whether a stored world Legion has the points part (a row written by an app that knows v.272). */
268
+ export declare const hasPoints: (legion: unknown) => boolean;
269
+ /** Legion Champion slots: 4, and a 5th once four Champions are rank S or higher. */
270
+ export declare const CHAMPION_SLOTS = 5;
271
+ export declare const CHAMPION_FIFTH_SLOT_RANK: LegionRank;
272
+ /** Open Champion slots for the Champions' ranks (C, B … SSS; anything below B counts as none). */
273
+ export declare function championSlotsOpen(ranks: readonly string[]): number;
package/dist/legion.js CHANGED
@@ -195,3 +195,185 @@ export function crystalsFrom(raw) {
195
195
  return { level, lines };
196
196
  });
197
197
  }
198
+ /** Basic stats, open from the start (the patch notes' order). */
199
+ export const BASIC_STATS = ['str', 'dex', 'int', 'luk', 'att', 'matt', 'hp', 'mp'];
200
+ /** Expanded stats, opening together at Legion level 2,000 (the patch notes' order). */
201
+ export const EXPANDED_STATS = [
202
+ 'critDamage', 'boss', 'ied', 'critRate', 'buffDuration', 'normal', 'exp', 'statusResist',
203
+ ];
204
+ export const LEGION_STATS = [...BASIC_STATS, ...EXPANDED_STATS];
205
+ export const STAT_LABELS = { ...GRID_LABELS, att: 'Attack Power', ied: 'Ignore Defense', normal: 'Normal Enemy Damage', exp: 'EXP Obtained' };
206
+ /** Legion Points a member gives by rank (patch notes): B 1, A 2, S 3, SS 4, SSS 5. */
207
+ export const RANK_POINTS = { B: 1, A: 2, S: 3, SS: 4, SSS: 5 };
208
+ export const STAT_PRESETS = 10;
209
+ export const PRESET_NAME_MAX = 20;
210
+ export const defaultPresetName = (i) => `Preset ${i + 1}`;
211
+ const RANK_TIERS = [['Nameless', 9], ['Renowned', 18], ['Heroic', 27], ['Legendary', 36], ['Supreme', 41]];
212
+ const ROMAN = ['I', 'II', 'III', 'IV', 'V'];
213
+ export const LEGION_LEVEL_RANKS = RANK_TIERS.flatMap(([tier, first], t) => ROMAN.map((roman, r) => ({
214
+ name: `${tier} Legion Rank ${roman}`,
215
+ minLevel: 500 * (5 * t + r + 1),
216
+ members: first + r,
217
+ image: `${CDN}/legion/ranks/${tier}_Legion_Rank_${r + 1}.png`,
218
+ })));
219
+ /** The Legion rank at this Legion level, or null below Nameless I (500). */
220
+ export function legionLevelRank(level) {
221
+ let out = null;
222
+ for (const r of LEGION_LEVEL_RANKS)
223
+ if (level >= r.minLevel)
224
+ out = r;
225
+ return out;
226
+ }
227
+ /** Members the automatic Legion Raid takes at this Legion level (Nameless I's 9 below it). */
228
+ export const raidMembers = (level) => (legionLevelRank(level) ?? LEGION_LEVEL_RANKS[0]).members;
229
+ // Confirmed from KMS 1.2.416 (the same revamp, 2026-06-18; maplestory.nexon.com News/Update/806 and the official game
230
+ // guide, Guide/N23GameInformation/Articles/407): basic stats go to level 15, expanded stats to 40; the 8 expanded stats
231
+ // open together, their cap rising with Legion level (below); points are only the raid's ranks (a KMST screenshot:
232
+ // Supreme I, 41 members, 202 points).
233
+ /** Each stat's highest level. */
234
+ export const STAT_MAX_LEVEL = Object.fromEntries(GRID_KEYS.map((k) => [k, gridMax(k)]));
235
+ /** The expanded stats' shared level cap by Legion level (the guide's 유니온 레벨별 확장 스탯 최대 레벨). */
236
+ export const EXPANDED_CAPS = [
237
+ { level: 2000, cap: 5 }, { level: 3000, cap: 10 }, { level: 4000, cap: 20 }, { level: 5000, cap: 30 }, { level: 6000, cap: 40 },
238
+ ];
239
+ /** The expanded stats' level cap at this Legion level: 0 below 2,000. */
240
+ export function expandedCap(legionLevel) {
241
+ let cap = 0;
242
+ for (const c of EXPANDED_CAPS)
243
+ if (legionLevel >= c.level)
244
+ cap = c.cap;
245
+ return cap;
246
+ }
247
+ // ┌─ ASSUMED VALUES ────────────────────────────────────────────────────────────────────────────────────────────────┐
248
+ // │ Not in the v.272 notes nor any KMS source found: what a stat level gives. Each is taken as one old grid square │
249
+ // │ (KMS says the stats are "the same types as the old occupation effects", and the caps and 440 total match the │
250
+ // │ board). Nor whether event blocks give points: taken as none (the notes only say they apply automatically and │
251
+ // │ don't count as members). Change them here only; everything below reads them. │
252
+ // └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
253
+ /** What one level of each stat gives. */
254
+ export const STAT_PER_LEVEL = { ...GRID_PER_SQUARE };
255
+ /** Points an applied event block gives. */
256
+ export const eventBlockPoints = (_b) => 0;
257
+ export const isExpandedStat = (k) => EXPANDED_STATS.includes(k);
258
+ /** A stat's level cap at this Legion level: basic stats 15, expanded stats as the Legion level allows. */
259
+ export const statCap = (k, legionLevel) => (isExpandedStat(k) ? Math.min(expandedCap(legionLevel), STAT_MAX_LEVEL[k]) : STAT_MAX_LEVEL[k]);
260
+ /** Whether a stat is open at this Legion level. */
261
+ export const statUnlocked = (k, legionLevel) => statCap(k, legionLevel) > 0;
262
+ /**
263
+ * The members the automatic raid takes: the highest levels with a rank, as many as the Legion level's rank holds.
264
+ * The caller works out ranks (Zero's own levels) and Legion level.
265
+ */
266
+ export function raidComposition(members, legionLevel) {
267
+ return members.filter((m) => m.rank).sort((a, b) => b.level - a.level).slice(0, raidMembers(legionLevel));
268
+ }
269
+ /** The two event blocks that apply: the highest values among those owned. */
270
+ export function appliedEventBlocks(owned) {
271
+ return [...owned].sort((a, b) => b.value - a.value).slice(0, MAX_EVENT_BLOCKS);
272
+ }
273
+ /** Legion Points to spend: the raid's ranks (and the applied event blocks', none as far as known). */
274
+ export function legionPoints(members, legionLevel, owned = []) {
275
+ return raidComposition(members, legionLevel).reduce((t, m) => t + RANK_POINTS[m.rank], 0)
276
+ + appliedEventBlocks(owned).reduce((t, b) => t + eventBlockPoints(b), 0);
277
+ }
278
+ /** Points the stat levels spend. */
279
+ export const pointsSpent = (stats) => Object.values(stats).reduce((t, n) => t + (n ?? 0), 0);
280
+ /** What each stat gives at its level (only stats with a level). */
281
+ export function statTotals(stats) {
282
+ const out = {};
283
+ for (const k of GRID_KEYS)
284
+ if (stats[k])
285
+ out[k] = stats[k] * STAT_PER_LEVEL[k];
286
+ return out;
287
+ }
288
+ /** What's wrong with an allocation, or null: no stat past its cap at the Legion level, no overspending. */
289
+ export function allocationProblem(a) {
290
+ for (const k of GRID_KEYS) {
291
+ const n = a.stats[k] ?? 0;
292
+ if (!n)
293
+ continue;
294
+ const cap = statCap(k, a.legionLevel);
295
+ if (n > cap) {
296
+ if (!cap)
297
+ return `${STAT_LABELS[k]} opens at Legion level ${EXPANDED_CAPS[0].level.toLocaleString('en-US')}.`;
298
+ return `${STAT_LABELS[k]} goes up to level ${cap}${cap < STAT_MAX_LEVEL[k] ? ' at this Legion level' : ''}.`;
299
+ }
300
+ }
301
+ const spent = pointsSpent(a.stats);
302
+ if (spent > a.points)
303
+ return `${spent} points are spent, but the Legion has ${a.points}.`;
304
+ return null;
305
+ }
306
+ /**
307
+ * The best allocation for linear weights (value per stat level, e.g. damage per level): with every level costing one
308
+ * point, the open stats fill highest weight first, each to its cap. Stats without a positive weight get nothing.
309
+ */
310
+ export function suggestStats(weights, points, legionLevel) {
311
+ const out = {};
312
+ let left = Math.max(0, Math.trunc(points));
313
+ const order = GRID_KEYS.filter((k) => (weights[k] ?? 0) > 0 && statUnlocked(k, legionLevel)).sort((a, b) => weights[b] - weights[a]);
314
+ for (const k of order) {
315
+ if (!left)
316
+ break;
317
+ const n = Math.min(statCap(k, legionLevel), left);
318
+ out[k] = n;
319
+ left -= n;
320
+ }
321
+ return out;
322
+ }
323
+ /** Valid stat levels: known stats, whole numbers within each stat's cap, zeros dropped. */
324
+ export function statsFrom(raw) {
325
+ if (!isObject(raw))
326
+ return {};
327
+ const out = {};
328
+ for (const k of GRID_KEYS) {
329
+ const n = int(raw[k], 0, STAT_MAX_LEVEL[k]);
330
+ if (n)
331
+ out[k] = n;
332
+ }
333
+ return out;
334
+ }
335
+ /** Valid owned event blocks: known blocks at one of their values, each within its copies. */
336
+ export function ownedBlocksFrom(raw) {
337
+ if (!Array.isArray(raw))
338
+ return [];
339
+ const out = [];
340
+ for (const x of raw) {
341
+ if (!isObject(x))
342
+ continue;
343
+ const def = eventBlock(String(x.id));
344
+ if (!def || !def.values.includes(x.value))
345
+ continue;
346
+ if (out.filter((b) => b.id === def.id).length >= def.copies)
347
+ continue;
348
+ out.push({ id: def.id, value: x.value });
349
+ }
350
+ return out;
351
+ }
352
+ export const emptyStatPreset = (i) => ({ name: defaultPresetName(i), stats: {} });
353
+ export const emptySharedPoints = () => ({ presets: Array.from({ length: STAT_PRESETS }, (_, i) => emptyStatPreset(i)), active: 0, blocks: [] });
354
+ /** A preset name trimmed to PRESET_NAME_MAX, or the default for its slot when blank. */
355
+ export function presetName(raw, i) {
356
+ const s = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX) : '';
357
+ return s || defaultPresetName(i);
358
+ }
359
+ /** A stored points part as a valid one: always 10 presets, missing ones empty. Anything else (absent, older rows) is empty. */
360
+ export function sharedPointsFrom(raw) {
361
+ if (!isObject(raw))
362
+ return emptySharedPoints();
363
+ const list = Array.isArray(raw.presets) ? raw.presets : [];
364
+ const presets = Array.from({ length: STAT_PRESETS }, (_, i) => {
365
+ const p = list[i];
366
+ return isObject(p) ? { name: presetName(p.name, i), stats: statsFrom(p.stats) } : emptyStatPreset(i);
367
+ });
368
+ return { presets, active: int(raw.active, 0, STAT_PRESETS - 1) ?? 0, blocks: ownedBlocksFrom(raw.blocks) };
369
+ }
370
+ /** Whether a stored world Legion has the points part (a row written by an app that knows v.272). */
371
+ export const hasPoints = (legion) => isObject(legion) && isObject(legion.points);
372
+ /** Legion Champion slots: 4, and a 5th once four Champions are rank S or higher. */
373
+ export const CHAMPION_SLOTS = 5;
374
+ export const CHAMPION_FIFTH_SLOT_RANK = 'S';
375
+ /** Open Champion slots for the Champions' ranks (C, B … SSS; anything below B counts as none). */
376
+ export function championSlotsOpen(ranks) {
377
+ const at = LEGION_RANKS.indexOf(CHAMPION_FIFTH_SLOT_RANK);
378
+ return ranks.filter((r) => LEGION_RANKS.indexOf(r) >= at).length >= 4 ? CHAMPION_SLOTS : 4;
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xforge-dev/forge-kit",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Shared auth, app registry and UI for xForge apps",
5
5
  "license": "MIT",
6
6
  "repository": {