pf2e-sage-stats 0.1.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/src/app.ts ADDED
@@ -0,0 +1,623 @@
1
+ import zod from 'zod';
2
+ import { TSV } from 'tsv';
3
+ import { capitalize, entries, startCase, toLower, kebabCase } from 'lodash';
4
+ import dedent from 'dedent-js';
5
+ import abbreviate from 'abbreviate';
6
+ import pluralize from 'pluralize';
7
+
8
+ const schema = zod.object({
9
+ name: zod.string(),
10
+ alias: zod.string().optional(),
11
+ level: zod.coerce.number().default(0),
12
+ maxhp: zod.coerce.number().default(0),
13
+ attributes: zod.object({
14
+ strength: zod.coerce.number().default(0),
15
+ dexterity: zod.coerce.number().default(0),
16
+ constitution: zod.coerce.number().default(0),
17
+ intelligence: zod.coerce.number().default(0),
18
+ wisdom: zod.coerce.number().default(0),
19
+ charisma: zod.coerce.number().default(0),
20
+ }).default({}),
21
+ saves: zod.object({
22
+ fortitude: zod.coerce.number().default(0),
23
+ reflex: zod.coerce.number().default(0),
24
+ will: zod.coerce.number().default(0),
25
+ }).default({}),
26
+ ac: zod.coerce.number().default(10),
27
+ perception: zod.coerce.number().default(0),
28
+ skills: zod.object({
29
+ acrobatics: zod.coerce.number().optional(),
30
+ arcana: zod.coerce.number().optional(),
31
+ athletics: zod.coerce.number().optional(),
32
+ crafting: zod.coerce.number().optional(),
33
+ deception: zod.coerce.number().optional(),
34
+ diplomacy: zod.coerce.number().optional(),
35
+ intimidation: zod.coerce.number().optional(),
36
+ medicine: zod.coerce.number().optional(),
37
+ nature: zod.coerce.number().optional(),
38
+ occultism: zod.coerce.number().optional(),
39
+ perfomance: zod.coerce.number().optional(),
40
+ religion: zod.coerce.number().optional(),
41
+ society: zod.coerce.number().optional(),
42
+ stealth: zod.coerce.number().optional(),
43
+ survival: zod.coerce.number().optional(),
44
+ thievery: zod.coerce.number().optional(),
45
+ }).default({}),
46
+ lores: zod.record(zod.string(), zod.object({
47
+ mod: zod.coerce.number().default(0),
48
+ name: zod.string(),
49
+ })).default({}),
50
+ melee: zod.record(zod.string(), zod.object({
51
+ mod: zod.coerce.number().default(0),
52
+ desc: zod.string(),
53
+ damage: zod.string(),
54
+ })).default({}),
55
+ ranged: zod.record(zod.string(), zod.object({
56
+ mod: zod.coerce.number().default(0),
57
+ desc: zod.string(),
58
+ damage: zod.string(),
59
+ })).default({}),
60
+ spells: zod.object({
61
+ attack: zod.coerce.number().optional(),
62
+ dc: zod.coerce.number().optional(),
63
+ }).default({}),
64
+ extra: zod.record(zod.string(), zod.coerce.number()).default({}),
65
+ extraDCs: zod.record(zod.string(), zod.coerce.number()).default({}),
66
+ extraDice: zod.record(zod.string(), zod.coerce.string()).default({}),
67
+ improvisation: zod.boolean().default(false),
68
+ })
69
+
70
+ export type Schema = zod.infer<typeof schema>;
71
+
72
+ export const stub: Required<Schema> = {
73
+ name: 'name',
74
+ alias: 'nm',
75
+ level: 0,
76
+ maxhp: 0,
77
+ attributes: {
78
+ strength: 0,
79
+ dexterity: 0,
80
+ constitution: 0,
81
+ intelligence: 0,
82
+ wisdom: 0,
83
+ charisma: 0,
84
+ },
85
+ saves: {
86
+ fortitude: 0,
87
+ reflex: 0,
88
+ will: 0,
89
+ },
90
+ ac: 10,
91
+ perception: 0,
92
+ skills: {
93
+ acrobatics: 0,
94
+ arcana: 0,
95
+ athletics: 0,
96
+ crafting: 0,
97
+ deception: 0,
98
+ diplomacy: 0,
99
+ intimidation: 0,
100
+ medicine: 0,
101
+ nature: 0,
102
+ occultism: 0,
103
+ perfomance: 0,
104
+ religion: 0,
105
+ society: 0,
106
+ stealth: 0,
107
+ survival: 0,
108
+ thievery: 0,
109
+ },
110
+ lores: {},
111
+ melee: {
112
+ fist: { mod: 7, desc: 'fist (agile, finesse)', damage: '1d4 bludgeoning' }
113
+ },
114
+ ranged: {},
115
+ spells: {},
116
+ extra: {},
117
+ extraDCs: {},
118
+ extraDice: {},
119
+ improvisation: false,
120
+ };
121
+
122
+ const formatMap: Record<string, string> = {
123
+ xxs: 'xxs',
124
+ xs: 'xs',
125
+ s: 's',
126
+ m: 'm',
127
+ l: 'l',
128
+ xl: 'xl',
129
+ xxl: 'xxl',
130
+ };
131
+
132
+ export const prediceateMap: Record<string, string> = {
133
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
134
+ [`${v}`, `d20 >= ${v} flat;`]
135
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
136
+ c: 'd20 >= 5 flat;',
137
+ concealed: 'd20 >= 5 flat;',
138
+ h: 'd20 >= 11 flat;',
139
+ hidden: 'd20 >= 11 flat;',
140
+ none: '',
141
+ };
142
+
143
+ export const flatMap = Object.entries(prediceateMap).flatMap(([k, v]) => Object.entries(formatMap).map(([fk, fv]): [string, string] => (
144
+ [`${fk}_${k}`, `${fv} ${v}`]
145
+ ))).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {});
146
+
147
+ export const valueNameMap: (tag: string | null) => Record<string, string> = (tag: string | null) => ({
148
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
149
+ [`+${v}`, tag ? `­ ⟮+${v} ${tag}⟯` : `­ ⟮+${v}⟯`]
150
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
151
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
152
+ [`${v}`, tag ? `­ ⟮+${v} ${tag}⟯` : `­ ⟮+${v}⟯`]
153
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
154
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
155
+ [`-${v}`, tag ? `­ ⟮-${v} ${tag}⟯` : `­ ⟮-${v}⟯`]
156
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
157
+ '+0': '­',
158
+ '0': '­',
159
+ });
160
+
161
+ export const valueMap: () => Record<string, string> = () => ({
162
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
163
+ [`+${v}`, `+${v}`]
164
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
165
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
166
+ [`${v}`, `+${v}`]
167
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
168
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
169
+ [`-${v}`, `-${v}`]
170
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
171
+ '+0': '+0',
172
+ '0': '+0',
173
+ });
174
+
175
+ export const diceNameMap: () => Record<string, string> = () => ({
176
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
177
+ [`(${v})`, `­ ⟮take ${v}⟯`]
178
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
179
+ '1': '­',
180
+ '+1': '­',
181
+ '-1': '­',
182
+ '2': '­ ⟮fortune⟯',
183
+ '+2': '­ ⟮fortune⟯',
184
+ '-2': '­ ⟮misfortune⟯',
185
+ 'a': '­ ⟮fortune⟯',
186
+ 'adv': '­ ⟮fortune⟯',
187
+ 'advantage': '­ ⟮fortune⟯',
188
+ 'd': '­ ⟮misfortune⟯',
189
+ 'dis': '­ ⟮misfortune⟯',
190
+ 'disadvantage': '­ ⟮misfortune⟯',
191
+ 'f': '­ ⟮fortune⟯',
192
+ 'for': '­ ⟮fortune⟯',
193
+ 'fortune': '­ ⟮fortune⟯',
194
+ 'm': '­ ⟮misfortune⟯',
195
+ 'mis': '­ ⟮misfortune⟯',
196
+ 'misfortune': '­ ⟮misfortune⟯',
197
+ });
198
+
199
+ export const diceMap: () => Record<string, string> = () => ({
200
+ ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((v): [string, string] => (
201
+ [`(${v})`, `(${v})`]
202
+ )).reduce<Record<string, string>>((p, [k, v]) => { p[k] = v; return p }, {})),
203
+ '1': '1',
204
+ '+1': '1',
205
+ '-1': '1',
206
+ '2': '+2',
207
+ '+2': '+2',
208
+ '-2': '-2',
209
+ 'a': '+2',
210
+ 'adv': '+2',
211
+ 'advantage': '+2',
212
+ 'd': '-2',
213
+ 'dis': '-2',
214
+ 'disadvantage': '-2',
215
+ 'f': '+2',
216
+ 'for': '+2',
217
+ 'fortune': '+2',
218
+ 'm': '-2',
219
+ 'mis': '-2',
220
+ 'misfortune': '-2',
221
+ });
222
+
223
+ export const adjustmentMap: () => Record<string, string> = () => ({
224
+ 'incredibly-easy': '­ ⟮Incredibly Easy⟯',
225
+ 'very-easy': '­ ⟮Very Easy⟯',
226
+ 'easy': '­ ⟮Easy⟯',
227
+ 'default': '­',
228
+ 'hard': '­ ⟮Hard⟯',
229
+ 'very-hard': '­ ⟮Very Easy⟯',
230
+ 'incredibly-hard': '­ ⟮Incredibly Hard⟯',
231
+ });
232
+
233
+ export const fromatMap = (name: string, map: Record<string, string>) => {
234
+ return TSV.stringify([{
235
+ name,
236
+ ...map,
237
+ }]);
238
+ };
239
+
240
+ const dcByLevel = [13, 14, 15, 16, 18, 19, 20, 22, 23, 24, 26, 27, 28, 30, 31, 32, 34, 35, 36, 38, 39, 40, 42, 44, 46, 48, 50]
241
+
242
+ export const formatJSON = (stats: Schema): string => JSON.stringify(stats, undefined, 2);
243
+ export const parseJSON = (json: string): Schema => schema.parse(JSON.parse(json));
244
+
245
+ const locateName = (statblock: string): { name: string; level: number } | null => {
246
+ const regex = /^([^()]+?)(\s+\(\d+\))?\s+\S+\s+(-?\d+)$/m;
247
+
248
+ const match = statblock.match(regex);
249
+
250
+ if (match && match[1] && match[3]) {
251
+ return { name: pluralize(startCase(toLower(match[1].replace(/\s+/, ' ').trim())), 1), level: parseInt(match[3]) };
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ const locateInt = <T extends string>(name: T, statblock: string, section: string | null = null, signed: boolean = true, alias?: string): { [k in T]: number } | null => {
258
+ const match = statblock.match(new RegExp(`${section ? `(${section}|,)\\s+` : '()'}${alias ?? capitalize(name)}\\s+(${signed ? '[+-]' : '-?'}\\d+)`))
259
+
260
+ if (match && match[2]) {
261
+ return { [name]: parseInt(match[2]) } as { [k in T]: number };
262
+ }
263
+
264
+ return null;
265
+ }
266
+
267
+ const locateInts = (statblock: string, section: string, alias: string, signed: boolean = true): Record<string, { mod: number; name: string; }> => {
268
+ const regex = new RegExp(`(${section}|,)([^,]*?)${alias}\\s+(${signed ? '[+-]' : '-?'}\\d+)`, 'g');
269
+
270
+ let matches: RegExpExecArray | null = null;
271
+ const output: [string, string, number][] = [];
272
+ // eslint-disable-next-line no-cond-assign
273
+ while (matches = regex.exec(statblock)) {
274
+ const [,, name, mod] = matches;
275
+
276
+ if (name && mod) {
277
+ output.push([
278
+ name.toLowerCase().trim().replace(/\s+/g, '-'),
279
+ name.trim(),
280
+ parseInt(mod),
281
+ ]);
282
+ }
283
+ }
284
+
285
+ return output.reduce<Record<string, { mod: number; name: string; }>>((p, [key, name, value]) => { p[key] = { mod: value, name: name }; return p; }, {});
286
+ }
287
+
288
+ const locateIntsAfter = (statblock: string, alias: string, signed: boolean = true): Record<string, { mod: number; name: string; }> => {
289
+ const regex = new RegExp(`${alias}([\\s\\S]*?)(${signed ? '[+-]' : '-?'}\\d+)`, 'g');
290
+
291
+ let matches: RegExpExecArray | null = null;
292
+ const output: [string, string, number][] = [];
293
+ // eslint-disable-next-line no-cond-assign
294
+ while (matches = regex.exec(statblock)) {
295
+ const [,name, mod] = matches;
296
+
297
+ if (name && mod) {
298
+ output.push([
299
+ name.toLowerCase().trim().replace(/\s+/g, '-'),
300
+ name.trim(),
301
+ parseInt(mod),
302
+ ]);
303
+ }
304
+ }
305
+
306
+ return output.reduce<Record<string, { mod: number; name: string; }>>((p, [key, name, value]) => { p[key] = { mod: value, name: name }; return p; }, {});
307
+ }
308
+
309
+ const locateStrikes = (statblock: string, alias: string): Record<string, Schema['melee'][string]> => {
310
+ const regex = new RegExp(`${alias}(\\s+\\[.+\\])?\\s+(.+?)\\s+(\\(x\\d+\\)\\s+)?([+-]\\d+)\\s*([\\S\\s]*?),\\s+(Damage|Effect)\\s+(\\S+\\s+\\S+.*?)(?=$|\\s+Melee|\\s+Ranged)`, 'gm');
311
+
312
+ let matches: RegExpExecArray | null = null;
313
+ const output: [string, number, string, string][] = [];
314
+ // eslint-disable-next-line no-cond-assign
315
+ while (matches = regex.exec(statblock)) {
316
+ const [, , name, , attack, traits, , dice] = matches;
317
+
318
+ if (name && attack && dice) {
319
+ const _name = name.toLowerCase()
320
+ .replace(/\s+/g, ' ')
321
+ .replace(/\+\d/g, '')
322
+ .replace(/Weapon\s+Striking(\s+\((Greater|Major)\))?/gi, '')
323
+ .replace(/\(Agile\)/gi, '')
324
+ .replace(/\(\+\)/gi, '')
325
+ .trim();
326
+
327
+ const bps = /(?<=[^\w']|^)[BbPpSs](?=[^\w]|$)/g;
328
+
329
+ let _traits = traits && traits.toLowerCase()
330
+ .replace(/\s+/g, ' ')
331
+ .replace(bps, (m) => m.toUpperCase())
332
+ .replace(/1d/, 'd')
333
+ .trim();
334
+
335
+ const _dice = dice
336
+ .replace(/\s+/g, ' ')
337
+ .replace(bps, (m) => ({ b: 'bludgeoning', p: 'piercing', s: 'slashing' }[m.toLowerCase()] ?? m))
338
+ .trim();
339
+
340
+ const twoHand = _traits.match(/(two-hand|two-handed)\s+(d\d+)/);
341
+
342
+ if (twoHand && twoHand[2]) {
343
+ _traits = _traits.replace(/(two-hand|two-handed)\s+d\d+/g, 'two-hand');
344
+
345
+ const dice2h = _dice.replace(/(?<=\d)d\d+/, twoHand[2]);
346
+
347
+ output.push([
348
+ _name.replace(/\s+/g, '-') + '-2h',
349
+ parseInt(attack),
350
+ `2h ${_traits ? `${_name} ${_traits}` : _name}`,
351
+ dice2h,
352
+ ], [
353
+ _name.replace(/\s+/g, '-') + '-1h',
354
+ parseInt(attack),
355
+ `1h ${_traits ? `${_name} ${_traits}` : _name}`,
356
+ _dice,
357
+ ]);
358
+ } else {
359
+ output.push([
360
+ _name.replace(/\s+/g, '-'),
361
+ parseInt(attack),
362
+ _traits ? `${_name} ${_traits}` : _name,
363
+ _dice,
364
+ ]);
365
+ }
366
+ }
367
+ }
368
+
369
+ return output.reduce<Record<string, Schema['melee'][string]>>((p, [key, m, t, d]) => { p[key] = {
370
+ mod: m,
371
+ desc: t,
372
+ damage: d,
373
+ }; return p; }, {});
374
+ }
375
+
376
+ const locateSpells = (statblock: string): Schema['spells'] | null => {
377
+ const match = statblock.match(new RegExp(/Spells\s+DC\s+(\d+)(,\s+[Aa]ttack\s+([+-]\d+))?/));
378
+
379
+ if (match && match[1]) {
380
+ return {
381
+ dc: parseInt(match[1]),
382
+ ...(match[3] ? { attack: parseInt(match[3]) } : null)
383
+ };
384
+ }
385
+
386
+ return null;
387
+ }
388
+
389
+ export const parseStatblock = (name: string | null, _statblock: string, alias: string | null): Schema => {
390
+ const defaults = schema.parse({ name: name ?? 'Unknown' });
391
+
392
+ const statblock = _statblock
393
+ .replace(/^Items.*$/m, '')
394
+ .replace(/–/g, '-') + '\n';
395
+
396
+ const improvisation = !!statblock.match(/Untrained\s+Improvisation/);
397
+
398
+ const basic = locateName(statblock);
399
+
400
+ return {
401
+ ...defaults,
402
+ ...basic,
403
+ ...(name && { name: name }),
404
+ ...{ alias: alias ?? abbreviate(name ?? basic?.name ?? defaults.name, { length: 3 }).toLowerCase() },
405
+ ...locateInt('perception', statblock),
406
+ ...locateInt('ac', statblock, null, false, 'AC'),
407
+ ...locateInt('maxhp', statblock, null, false, 'HP'),
408
+ attributes: {
409
+ ...defaults.attributes,
410
+ ...locateInt('strength', statblock, null, true, 'Str'),
411
+ ...locateInt('dexterity', statblock, null, true, 'Dex'),
412
+ ...locateInt('constitution', statblock, null, true, 'Con'),
413
+ ...locateInt('intelligence', statblock, null, true, 'Int'),
414
+ ...locateInt('wisdom', statblock, null, true, 'Wis'),
415
+ ...locateInt('charisma', statblock, null, true, 'Cha'),
416
+ },
417
+ saves: {
418
+ ...defaults.saves,
419
+ ...locateInt('fortitude', statblock, null, true, 'Fortitude'),
420
+ ...locateInt('reflex', statblock, null, true, 'Reflex'),
421
+ ...locateInt('fortitude', statblock, null, true, 'Fort'),
422
+ ...locateInt('reflex', statblock, null, true, 'Ref'),
423
+ ...locateInt('will', statblock, null, true, 'Will'),
424
+ },
425
+ skills: {
426
+ ...defaults.skills,
427
+ ...locateInt('acrobatics', statblock, 'Skills'),
428
+ ...locateInt('arcana', statblock, 'Skills'),
429
+ ...locateInt('athletics', statblock, 'Skills'),
430
+ ...locateInt('crafting', statblock, 'Skills'),
431
+ ...locateInt('deception', statblock, 'Skills'),
432
+ ...locateInt('diplomacy', statblock, 'Skills'),
433
+ ...locateInt('intimidation', statblock, 'Skills'),
434
+ ...locateInt('medicine', statblock, 'Skills'),
435
+ ...locateInt('nature', statblock, 'Skills'),
436
+ ...locateInt('occultism', statblock, 'Skills'),
437
+ ...locateInt('perfomance', statblock, 'Skills'),
438
+ ...locateInt('religion', statblock, 'Skills'),
439
+ ...locateInt('society', statblock, 'Skills'),
440
+ ...locateInt('stealth', statblock, 'Skills'),
441
+ ...locateInt('survival', statblock, 'Skills'),
442
+ ...locateInt('thievery', statblock, 'Skills'),
443
+ },
444
+ lores: {
445
+ ...locateInts(statblock, 'Skills', 'Lore'),
446
+ ...locateIntsAfter(statblock, 'Lore:')
447
+ },
448
+ melee: {
449
+ ...locateStrikes(statblock, 'Melee'),
450
+ },
451
+ ranged: {
452
+ ...locateStrikes(statblock, 'Ranged'),
453
+ },
454
+ spells: {
455
+ ...locateSpells(statblock),
456
+ },
457
+ extra: {},
458
+ extraDCs: {
459
+ ...locateInt('stealth', statblock, null, false, 'Stealth DC'),
460
+ },
461
+ extraDice: {},
462
+ improvisation,
463
+ };
464
+ }
465
+
466
+ const dc2DC = (secret: boolean) => ([key, value]: [string | number, number]): [string, string] => [`dc.${key}`, secret ? `||${value}||` : `${value}`];
467
+ const mod2DC = (secret: boolean) => ([key, value]: [string | number, number]): [string, string] => [`dc.${key}`, secret ? `||${10 + value}||` : `${10 + value}`];
468
+ const toLore = ([key, value]: [string | number, { mod: number; name: string; }]): [string, number| string][] => [[`lore.${key}`, value.mod], [`lore.${key}.name`, value.name]];
469
+ const toMelee = ([key, value]: [string, Schema['melee'][string]]): [string, number | string][] => [
470
+ [`melee.${key}`, value.mod],
471
+ [`melee.${key}.desc`, value.desc],
472
+ [`melee.${key}.damage`, value.damage],
473
+ ];
474
+
475
+ const toRanged = ([key, value]: [string, Schema['ranged'][string]]): [string, number | string][] => [
476
+ [`ranged.${key}`, value.mod],
477
+ [`ranged.${key}.desc`, value.desc],
478
+ [`ranged.${key}.damage`, value.damage],
479
+ ];
480
+
481
+ const recallDCs = (level: number, secret: boolean): [string, string][] => ((value) => [
482
+ ['dc.recall.incredibly-easy', secret ? `||${value - 10}||` : `${value - 10}`],
483
+ ['dc.recall.very-easy', secret ? `||${value - 5}||` : `${value - 5}`],
484
+ ['dc.recall.easy', secret ? `||${value - 2}||` : `${value - 2}`],
485
+ ['dc.recall.default', secret ? `||${value}||` : `${value}`],
486
+ ['dc.recall', secret ? `||${value}||` : `${value}`],
487
+ ['dc.recall.hard', secret ? `||${value + 2}||` : `${value + 2}`],
488
+ ['dc.recall.very-hard', secret ? `||${value + 5}||` : `${value + 5}`],
489
+ ['dc.recall.incredibly-hard', secret ? `||${value + 10}||` : `${value + 10}`],
490
+ ])(dcByLevel[Math.max(Math.min(level + 1, dcByLevel.length - 1), 0)]);
491
+
492
+ export const flatten = (stats: Schema, secretDC: boolean = false, defaultSkills: boolean = false, recallDC: boolean = false, desc: boolean): Record<string, string | number> => {
493
+ const untrained = stats.improvisation ? (stats.level >= 7 ? stats.level : stats.level >= 5 ? stats.level - 1 : stats.level - 2) : 0;
494
+ const skills = defaultSkills ? {
495
+ acrobatics: untrained + stats.attributes.dexterity,
496
+ arcana: untrained + stats.attributes.intelligence,
497
+ athletics: untrained + stats.attributes.strength,
498
+ crafting: untrained + stats.attributes.intelligence,
499
+ deception: untrained + stats.attributes.charisma,
500
+ diplomacy: untrained + stats.attributes.charisma,
501
+ intimidation: untrained + stats.attributes.charisma,
502
+ medicine: untrained + stats.attributes.wisdom,
503
+ nature: untrained + stats.attributes.wisdom,
504
+ occultism: untrained + stats.attributes.intelligence,
505
+ perfomance: untrained + stats.attributes.charisma,
506
+ religion: untrained + stats.attributes.wisdom,
507
+ society: untrained + stats.attributes.intelligence,
508
+ stealth: untrained + stats.attributes.dexterity,
509
+ survival: untrained + stats.attributes.wisdom,
510
+ thievery: untrained + stats.attributes.dexterity,
511
+ ...stats.skills,
512
+ } : stats.skills;
513
+
514
+ let melee = entries(stats.melee);
515
+ if (melee.length > 0) {
516
+ melee = [
517
+ ['default', melee[0][1]],
518
+ ...melee,
519
+ ];
520
+ }
521
+
522
+ let ranged = entries(stats.ranged);
523
+ if (ranged.length > 0) {
524
+ ranged = [
525
+ ['default', ranged[0][1]],
526
+ ...ranged,
527
+ ];
528
+ }
529
+
530
+ return [
531
+ ['name', stats.name],
532
+ ...(stats.alias ? [['alias', stats.alias]] : []),
533
+ ['gamesystem', 'pf2e'],
534
+ ['level', stats.level],
535
+ ['lvl', stats.level],
536
+ ['maxhp', stats.maxhp],
537
+ ...entries(stats.attributes),
538
+ ...entries(stats.attributes).map(([key, value]) => [key.substring(0,3), value]),
539
+ ['ac', secretDC ? `||${stats.ac}||` : stats.ac],
540
+ ...entries(stats.saves),
541
+ ['fort', stats.saves.fortitude],
542
+ ['ref', stats.saves.reflex],
543
+ ['perception', stats.perception],
544
+ ...entries(skills),
545
+ ...entries(stats.lores).flatMap(toLore),
546
+ ...(desc ? [
547
+ ...entries(stats.saves).map(([key]) => [`${key}.desc`, `${capitalize(key)} Save`]),
548
+ ['fort.desc', 'Fortitude Save'],
549
+ ['ref.desc', 'Reflex Save'],
550
+ ['perception.desc', 'Perception Check'],
551
+ ...entries(skills).map(([key]) => [`${key}.desc`, `${capitalize(key)} Check`]),
552
+ ...entries(stats.lores).map(([key, value]) => [`lore.${key}.desc`, `${value.name} Lore Check`]),
553
+ ...(stats.spells.attack ? [['spells.desc', 'Spell Attack']] : []),
554
+ ]: []),
555
+ ...melee.flatMap(toMelee),
556
+ ...ranged.flatMap(toRanged),
557
+ ...entries(stats.extra),
558
+ ...(stats.spells.attack ? [['spells', stats.spells.attack]] : []),
559
+ ...(stats.spells.dc ? [dc2DC(secretDC)(['spells', stats.spells.dc])] : []),
560
+ ...entries(stats.extraDCs).map(dc2DC(secretDC)),
561
+ ...entries(stats.extraDice),
562
+ ...(secretDC ? [
563
+ ...entries(stats.saves).map(mod2DC(true)),
564
+ mod2DC(true)(['perception', stats.perception]),
565
+ mod2DC(true)(['fort', stats.saves.fortitude]),
566
+ mod2DC(true)(['ref', stats.saves.reflex]),
567
+ ...entries(skills).map(mod2DC(true)),
568
+ ...entries(stats.extra).map(mod2DC(true)),
569
+ ] : []),
570
+ ...(recallDC ? [
571
+ ...recallDCs(stats.level, secretDC),
572
+ ] : []),
573
+ ].reduce<Record<string | number, string | number>>((p, [key, value]) => { p[key] = value; return p }, {});
574
+ }
575
+
576
+ export const formatTSV = (stats: Schema, secretDC: boolean = false, defaultSkills: boolean = false, recallDC: boolean = false, desc: boolean = false): string => (
577
+ TSV.stringify([flatten(stats, secretDC, defaultSkills, recallDC, desc)])
578
+ );
579
+
580
+ export const formatCommand = (stats: Schema, secretDC: boolean = false, defaultSkills: boolean = false, recallDC: boolean = false, desc: boolean = false): string => (
581
+ Object.entries(flatten(stats, secretDC, defaultSkills, recallDC, desc)).map(([k, v]) => `${k}="${v}"`).join(' ')
582
+ );
583
+
584
+ export const formatTable = (table: number, season: number, scenario: number, _name: string, gm: string, players: string) => {
585
+ const name = _name.replace(/[\s-_]/g, ' ').replace(/[^a-zA-Z0-9 ]/g, '');
586
+
587
+ const gameName = `PF2 T${table} S${season} ${String(scenario).padStart(2, '0')} ${name}`;
588
+ const tableName = `pf2-t${table}-s${season}${String(scenario).padStart(2, '0')}-${kebabCase(name)}`;
589
+
590
+ return dedent`
591
+ \`\`\`
592
+ @TableBot create "${gameName}" --gm ${gm} --players ${players} --table-name ${tableName} --ooc-table-name ooc-${tableName} --category Game Tables
593
+ \`\`\`
594
+ \`\`\`
595
+ sage! game create name="${gameName}" gameSystem="pf2e" ic=" #${tableName} " ooc=" #ooc-${tableName} " gms=" ${gm} " players=" @${gameName} " dialogPost="post" diceSecret="gm" diceCrit="timestwo" diceOutput=M gmCharName="Хронист" diceSecret="gm"
596
+ \`\`\`
597
+ `;
598
+ };
599
+
600
+ const _formatHP = (hp: number, maxhp: number) => {
601
+ const base32 = Math.ceil(hp * 32 / maxhp);
602
+
603
+ const slabs = Math.floor(base32 / 8);
604
+ const tail = base32 % 8;
605
+
606
+ const tails = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
607
+
608
+ return `▕${new Array(slabs).fill(0).map(() => '█').join('')}${[tails[tail], ...new Array(3).fill(0).map(() => ' ')].slice(0, 4 - slabs).join('')}▏`;
609
+ }
610
+
611
+ export const formatHP = (arg: string) => {
612
+ const match = arg.match(/^(m)?(\d+)\/(\d+)$/);
613
+
614
+ if (!match) {
615
+ throw new Error('No match');
616
+ }
617
+
618
+ const negative = !!match[1];
619
+ const hp = parseInt(match[2]);
620
+ const maxhp = parseInt(match[3]);
621
+
622
+ return _formatHP(negative ? maxhp - hp : hp, maxhp);
623
+ };