@xforge-dev/forge-kit 0.23.0 → 0.24.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/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, what a grid square was: same values, caps 15 inner and 40 outer),
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`).
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,101 @@ 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: the board's inner areas (the patch notes' order). */
170
+ export declare const BASIC_STATS: readonly ["str", "dex", "int", "luk", "att", "matt", "hp", "mp"];
171
+ /** Expanded stats: the board's outer areas (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
+ /** What one level of each stat gives: a grid square's value. */
194
+ export declare const STAT_PER_LEVEL: Record<GridKey, number>;
195
+ /** Each stat's highest level: its grid area's size. */
196
+ export declare const STAT_MAX_LEVEL: Record<GridKey, number>;
197
+ /** Stat levels, one per point: absent or 0 when nothing is spent. */
198
+ export type LegionStats = Partial<Record<GridKey, number>>;
199
+ export declare const isExpandedStat: (k: GridKey) => k is ExpandedStat;
200
+ /** A stat's level cap at this Legion level: its grid area's size at every Legion level, as the board had it. */
201
+ export declare const statCap: (k: GridKey, _legionLevel: number) => number;
202
+ /** Whether a stat is open at this Legion level. */
203
+ export declare const statUnlocked: (k: GridKey, legionLevel: number) => boolean;
204
+ /** One member as the points model reads it: `rank` null below B (no points, no effect). */
205
+ export type PointsMember = {
206
+ level: number;
207
+ rank: LegionRank | null;
208
+ };
209
+ /**
210
+ * The members the automatic raid takes: the highest levels with a rank, as many as the Legion level's rank holds.
211
+ * The caller works out ranks (Zero's own levels) and Legion level.
212
+ */
213
+ export declare function raidComposition<M extends PointsMember>(members: readonly M[], legionLevel: number): M[];
214
+ /** The two event blocks that apply: the highest values among those owned. */
215
+ export declare function appliedEventBlocks(owned: readonly EventBlock[]): EventBlock[];
216
+ /** Legion Points to spend: the ranks of the members the raid counts. Event blocks add ATT, not points. */
217
+ export declare function legionPoints(members: readonly PointsMember[], legionLevel: number): number;
218
+ /** Points the stat levels spend. */
219
+ export declare const pointsSpent: (stats: LegionStats) => number;
220
+ /** What each stat gives at its level (only stats with a level). */
221
+ export declare function statTotals(stats: LegionStats): LegionStats;
222
+ /** What's wrong with an allocation, or null: no stat past its cap at the Legion level, no overspending. */
223
+ export declare function allocationProblem(a: {
224
+ stats: LegionStats;
225
+ points: number;
226
+ legionLevel: number;
227
+ }): string | null;
228
+ /**
229
+ * The best allocation for linear weights (value per stat level, e.g. damage per level): with every level costing one
230
+ * point, the open stats fill highest weight first, each to its cap. Stats without a positive weight get nothing.
231
+ */
232
+ export declare function suggestStats(weights: Partial<Record<GridKey, number>>, points: number, legionLevel: number): LegionStats;
233
+ /** Valid stat levels: known stats, whole numbers within each stat's cap, zeros dropped. */
234
+ export declare function statsFrom(raw: unknown): LegionStats;
235
+ /** Valid owned event blocks: known blocks at one of their values, each within its copies. */
236
+ export declare function ownedBlocksFrom(raw: unknown): EventBlock[];
237
+ /** A Legion Stat Preset: its name and stat levels. */
238
+ export type StatPreset = {
239
+ name: string;
240
+ stats: LegionStats;
241
+ };
242
+ /**
243
+ * The points part of a world's Legion, stored under `points` in the shared row so an older app's board write (which
244
+ * merges `presets`, `active` and `crystals`) leaves it alone: 10 presets, the one in use (0-based) and the event
245
+ * blocks owned.
246
+ */
247
+ export type SharedPoints = {
248
+ presets: StatPreset[];
249
+ active: number;
250
+ blocks: EventBlock[];
251
+ };
252
+ export declare const emptyStatPreset: (i: number) => StatPreset;
253
+ export declare const emptySharedPoints: () => SharedPoints;
254
+ /** A preset name trimmed to PRESET_NAME_MAX, or the default for its slot when blank. */
255
+ export declare function presetName(raw: unknown, i: number): string;
256
+ /** A stored points part as a valid one: always 10 presets, missing ones empty. Anything else (absent, older rows) is empty. */
257
+ export declare function sharedPointsFrom(raw: unknown): SharedPoints;
258
+ /** Whether a stored world Legion has the points part (a row written by an app that knows v.272). */
259
+ export declare const hasPoints: (legion: unknown) => boolean;
260
+ /** Legion Champion slots: 4, and a 5th once four Champions are rank S or higher. */
261
+ export declare const CHAMPION_SLOTS = 5;
262
+ export declare const CHAMPION_FIFTH_SLOT_RANK: LegionRank;
263
+ /** Open Champion slots for the Champions' ranks (C, B … SSS; anything below B counts as none). */
264
+ export declare function championSlotsOpen(ranks: readonly string[]): number;
package/dist/legion.js CHANGED
@@ -195,3 +195,160 @@ export function crystalsFrom(raw) {
195
195
  return { level, lines };
196
196
  });
197
197
  }
198
+ /** Basic stats: the board's inner areas (the patch notes' order). */
199
+ export const BASIC_STATS = ['str', 'dex', 'int', 'luk', 'att', 'matt', 'hp', 'mp'];
200
+ /** Expanded stats: the board's outer areas (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
+ // The rules are the board's, without the board: a stat level is what a grid square was (same value per level, the
230
+ // same caps: inner stats 15, outer stats 40, open from the start as every area was), and only the members the raid
231
+ // counts (the top members by Legion level, as the board's slots were) give points and their effects.
232
+ /** What one level of each stat gives: a grid square's value. */
233
+ export const STAT_PER_LEVEL = { ...GRID_PER_SQUARE };
234
+ /** Each stat's highest level: its grid area's size. */
235
+ export const STAT_MAX_LEVEL = Object.fromEntries(GRID_KEYS.map((k) => [k, gridMax(k)]));
236
+ export const isExpandedStat = (k) => EXPANDED_STATS.includes(k);
237
+ /** A stat's level cap at this Legion level: its grid area's size at every Legion level, as the board had it. */
238
+ export const statCap = (k, _legionLevel) => STAT_MAX_LEVEL[k];
239
+ /** Whether a stat is open at this Legion level. */
240
+ export const statUnlocked = (k, legionLevel) => statCap(k, legionLevel) > 0;
241
+ /**
242
+ * The members the automatic raid takes: the highest levels with a rank, as many as the Legion level's rank holds.
243
+ * The caller works out ranks (Zero's own levels) and Legion level.
244
+ */
245
+ export function raidComposition(members, legionLevel) {
246
+ return members.filter((m) => m.rank).sort((a, b) => b.level - a.level).slice(0, raidMembers(legionLevel));
247
+ }
248
+ /** The two event blocks that apply: the highest values among those owned. */
249
+ export function appliedEventBlocks(owned) {
250
+ return [...owned].sort((a, b) => b.value - a.value).slice(0, MAX_EVENT_BLOCKS);
251
+ }
252
+ /** Legion Points to spend: the ranks of the members the raid counts. Event blocks add ATT, not points. */
253
+ export function legionPoints(members, legionLevel) {
254
+ return raidComposition(members, legionLevel).reduce((t, m) => t + RANK_POINTS[m.rank], 0);
255
+ }
256
+ /** Points the stat levels spend. */
257
+ export const pointsSpent = (stats) => Object.values(stats).reduce((t, n) => t + (n ?? 0), 0);
258
+ /** What each stat gives at its level (only stats with a level). */
259
+ export function statTotals(stats) {
260
+ const out = {};
261
+ for (const k of GRID_KEYS)
262
+ if (stats[k])
263
+ out[k] = stats[k] * STAT_PER_LEVEL[k];
264
+ return out;
265
+ }
266
+ /** What's wrong with an allocation, or null: no stat past its cap at the Legion level, no overspending. */
267
+ export function allocationProblem(a) {
268
+ for (const k of GRID_KEYS) {
269
+ const n = a.stats[k] ?? 0;
270
+ if (!n)
271
+ continue;
272
+ const cap = statCap(k, a.legionLevel);
273
+ if (n > cap)
274
+ return `${STAT_LABELS[k]} goes up to level ${cap}.`;
275
+ }
276
+ const spent = pointsSpent(a.stats);
277
+ if (spent > a.points)
278
+ return `${spent} points are spent, but the Legion has ${a.points}.`;
279
+ return null;
280
+ }
281
+ /**
282
+ * The best allocation for linear weights (value per stat level, e.g. damage per level): with every level costing one
283
+ * point, the open stats fill highest weight first, each to its cap. Stats without a positive weight get nothing.
284
+ */
285
+ export function suggestStats(weights, points, legionLevel) {
286
+ const out = {};
287
+ let left = Math.max(0, Math.trunc(points));
288
+ const order = GRID_KEYS.filter((k) => (weights[k] ?? 0) > 0 && statUnlocked(k, legionLevel)).sort((a, b) => weights[b] - weights[a]);
289
+ for (const k of order) {
290
+ if (!left)
291
+ break;
292
+ const n = Math.min(statCap(k, legionLevel), left);
293
+ out[k] = n;
294
+ left -= n;
295
+ }
296
+ return out;
297
+ }
298
+ /** Valid stat levels: known stats, whole numbers within each stat's cap, zeros dropped. */
299
+ export function statsFrom(raw) {
300
+ if (!isObject(raw))
301
+ return {};
302
+ const out = {};
303
+ for (const k of GRID_KEYS) {
304
+ const n = int(raw[k], 0, STAT_MAX_LEVEL[k]);
305
+ if (n)
306
+ out[k] = n;
307
+ }
308
+ return out;
309
+ }
310
+ /** Valid owned event blocks: known blocks at one of their values, each within its copies. */
311
+ export function ownedBlocksFrom(raw) {
312
+ if (!Array.isArray(raw))
313
+ return [];
314
+ const out = [];
315
+ for (const x of raw) {
316
+ if (!isObject(x))
317
+ continue;
318
+ const def = eventBlock(String(x.id));
319
+ if (!def || !def.values.includes(x.value))
320
+ continue;
321
+ if (out.filter((b) => b.id === def.id).length >= def.copies)
322
+ continue;
323
+ out.push({ id: def.id, value: x.value });
324
+ }
325
+ return out;
326
+ }
327
+ export const emptyStatPreset = (i) => ({ name: defaultPresetName(i), stats: {} });
328
+ export const emptySharedPoints = () => ({ presets: Array.from({ length: STAT_PRESETS }, (_, i) => emptyStatPreset(i)), active: 0, blocks: [] });
329
+ /** A preset name trimmed to PRESET_NAME_MAX, or the default for its slot when blank. */
330
+ export function presetName(raw, i) {
331
+ const s = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX) : '';
332
+ return s || defaultPresetName(i);
333
+ }
334
+ /** A stored points part as a valid one: always 10 presets, missing ones empty. Anything else (absent, older rows) is empty. */
335
+ export function sharedPointsFrom(raw) {
336
+ if (!isObject(raw))
337
+ return emptySharedPoints();
338
+ const list = Array.isArray(raw.presets) ? raw.presets : [];
339
+ const presets = Array.from({ length: STAT_PRESETS }, (_, i) => {
340
+ const p = list[i];
341
+ return isObject(p) ? { name: presetName(p.name, i), stats: statsFrom(p.stats) } : emptyStatPreset(i);
342
+ });
343
+ return { presets, active: int(raw.active, 0, STAT_PRESETS - 1) ?? 0, blocks: ownedBlocksFrom(raw.blocks) };
344
+ }
345
+ /** Whether a stored world Legion has the points part (a row written by an app that knows v.272). */
346
+ export const hasPoints = (legion) => isObject(legion) && isObject(legion.points);
347
+ /** Legion Champion slots: 4, and a 5th once four Champions are rank S or higher. */
348
+ export const CHAMPION_SLOTS = 5;
349
+ export const CHAMPION_FIFTH_SLOT_RANK = 'S';
350
+ /** Open Champion slots for the Champions' ranks (C, B … SSS; anything below B counts as none). */
351
+ export function championSlotsOpen(ranks) {
352
+ const at = LEGION_RANKS.indexOf(CHAMPION_FIFTH_SLOT_RANK);
353
+ return ranks.filter((r) => LEGION_RANKS.indexOf(r) >= at).length >= 4 ? CHAMPION_SLOTS : 4;
354
+ }
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.1",
4
4
  "description": "Shared auth, app registry and UI for xForge apps",
5
5
  "license": "MIT",
6
6
  "repository": {