@dreadwood-types/types 0.1.385 → 0.1.387

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.
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Skilling Giants (James, 2026-10-28: "an equivalent of the stone bosses in a new design for skilling ... a new random spawn each time
3
+ * the user isn't expecting ... a HUGE tree spawning which doesn't attack, but must have its health reduced to 0 by all the players over
4
+ * a 5-10 minute window ... every interval it might give a bunch of xp, some logs in a loot box ... when it dies, loot boxes appear on the
5
+ * floor ... afkable ... but with some sort of click needed every now and then").
6
+ *
7
+ * A Giant is one enormous skilling node with a health bar, one per gathering skill: the Great Tree (Woodwork), the Great Shoal (Fishing),
8
+ * the Fallen Star (Mining), the Great Bloom (Foraging), the Great Cauldron (Potion Making). It appears without warning somewhere in a
9
+ * level band and never attacks. Every hit takes health and gives nothing; every PULSE (each tenth of its health, or every minute) pays
10
+ * every worker a lump of XP and drops one crate; every minute or two a CHORE lands on each worker (a sapling, a snagged line, a loose
11
+ * rock, a bramble, a boil-over) and their hits do nothing until they clear it; at 0 health it dies: crates and satchels scatter for a
12
+ * race, and everyone who really worked gets a satchel straight to the bag.
13
+ *
14
+ * Spec with the reasons: `dreadwoods-backend/SKILLING_GIANTS_SPEC.md`. This file is the pure half (bands, kinds, the pool rule, pulses,
15
+ * chores, scatter, satchels, chat lines, the view, the save), the way `windfall.ts` is for Windfall; the backend's `skillingGiantEvent.ts`
16
+ * runs it. Every number is a placeholder until the balance pass; the shapes (random spawn, nothing per hit, the chore rule, the caps)
17
+ * are what the tests pin.
18
+ */
19
+ const SECOND = 1000;
20
+ const MINUTE = 60 * SECOND;
21
+ export const GIANT_KINDS = ['tree', 'shoal', 'star', 'bloom', 'cauldron'];
22
+ export function isGiantKind(k) {
23
+ return typeof k === 'string' && GIANT_KINDS.includes(k);
24
+ }
25
+ export const GIANT_BAND_IDS = [1, 2, 3, 4];
26
+ export const GIANT_KIND_INFO = {
27
+ tree: {
28
+ kind: 'tree', title: 'the Great Tree', skill: 'woodwork', skillName: 'Woodwork', tool: 'a hatchet or a saw', chore: 'sapling',
29
+ choreName: 'a sapling', choreFix: 'Chop the sapling at your feet', lands: 'has risen', pulse: 'shudders and drops a log crate', dies: 'has fallen', withers: 'has withered away',
30
+ // Windfall's great tree's scale: a poplar is 6.5 tall, so this one is about 16 (its crown fades over the players under it).
31
+ hitMs: 2400, footprint: 1, scale: 2.4,
32
+ },
33
+ shoal: {
34
+ kind: 'shoal', title: 'the Great Shoal', skill: 'fishing', skillName: 'Fishing', tool: 'a rod or a net', chore: 'snag',
35
+ choreName: 'a snagged line', choreFix: 'Tug your line free', lands: 'is churning', pulse: 'boils and a fish barrel washes up', dies: 'has sunk', withers: 'has slipped away',
36
+ hitMs: 3000, footprint: 1, scale: 2.6,
37
+ },
38
+ star: {
39
+ kind: 'star', title: 'the Fallen Star', skill: 'mining', skillName: 'Mining', tool: 'a pickaxe', chore: 'rock',
40
+ choreName: 'a loose rock', choreFix: 'Break the loose rock at your feet', lands: 'has struck the ground', pulse: 'cracks and sheds an ore crate', dies: 'has broken apart', withers: 'has cooled to dead stone',
41
+ hitMs: 4200, footprint: 1, scale: 3.0,
42
+ },
43
+ bloom: {
44
+ kind: 'bloom', title: 'the Great Bloom', skill: 'foraging', skillName: 'Foraging', tool: 'your hands', chore: 'bramble',
45
+ choreName: 'a bramble', choreFix: 'Cut the bramble round your boots', lands: 'has opened', pulse: 'sheds a basket of herbs', dies: 'has wilted', withers: 'has closed and gone to seed',
46
+ hitMs: 2000, footprint: 1, scale: 3.5,
47
+ },
48
+ cauldron: {
49
+ kind: 'cauldron', title: 'the Great Cauldron', skill: 'potionmaking', skillName: 'Potion Making', tool: 'a pestle and mortar', chore: 'boilover',
50
+ choreName: 'a boil-over', choreFix: 'Skim the boil-over off the brew', lands: 'is on the boil', pulse: 'bubbles over and a potion rack appears', dies: 'has boiled dry', withers: 'has gone cold',
51
+ hitMs: 3000, footprint: 1, scale: 3.0,
52
+ },
53
+ };
54
+ export const GIANT_BANDS = {
55
+ 1: {
56
+ id: 1, band: 'levels 1-10', gate: 1,
57
+ names: { tree: 'Elderbough', shoal: 'the Oldwater Shoal', star: 'Ironfall', bloom: 'Briarcrown', cauldron: 'the Wildbrew Cauldron' },
58
+ resource: {
59
+ tree: { kind: 'poplarwood', unitXp: 8, rare: 'pinewood' },
60
+ shoal: { kind: 'rawrudd', unitXp: 10, rare: 'rawperch' },
61
+ star: { kind: 'copper', unitXp: 10, rare: 'iron' },
62
+ bloom: { kind: 'wildthyme', unitXp: 8, rare: 'watermint' },
63
+ cauldron: { kind: 'minicookingpotion', unitXp: 15, rare: 'miniexplorerpotion' },
64
+ },
65
+ styles: { tree: 'poplar', shoal: 'default', star: 'copper', bloom: 'default', cauldron: 'default' },
66
+ },
67
+ 2: {
68
+ id: 2, band: 'levels 11-20', gate: 11,
69
+ names: { tree: 'Oakfather', shoal: 'the Silverrun Shoal', star: 'Emberfall', bloom: 'Thornqueen', cauldron: 'the Mossbrew Cauldron' },
70
+ resource: {
71
+ tree: { kind: 'sprucewood', unitXp: 22, rare: 'hazelwood' },
72
+ shoal: { kind: 'rawperch', unitXp: 25, rare: 'rawtench' },
73
+ star: { kind: 'iron', unitXp: 48, rare: 'coal' },
74
+ bloom: { kind: 'watermint', unitXp: 20, rare: 'bloodroot' },
75
+ cauldron: { kind: 'miniwoodworkpotion', unitXp: 24, rare: 'minienergypotion' },
76
+ },
77
+ styles: { tree: 'spruce', shoal: 'default', star: 'iron', bloom: 'default', cauldron: 'default' },
78
+ },
79
+ 3: {
80
+ id: 3, band: 'levels 21-30', gate: 21,
81
+ names: { tree: 'Ironbark', shoal: 'the Blackmere Shoal', star: 'Blackfall', bloom: 'Marshcrown', cauldron: 'the Bogbrew Cauldron' },
82
+ resource: {
83
+ tree: { kind: 'alderwood', unitXp: 38, rare: 'ashwood' },
84
+ shoal: { kind: 'rawgrayling', unitXp: 70, rare: 'rawchar' },
85
+ star: { kind: 'coal', unitXp: 36, rare: 'gold' },
86
+ bloom: { kind: 'bloodroot', unitXp: 32, rare: 'ironwort' },
87
+ cauldron: { kind: 'minibountyhunterpotion', unitXp: 34, rare: 'cookingpotion' },
88
+ },
89
+ styles: { tree: 'alder', shoal: 'default', star: 'coal', bloom: 'default', cauldron: 'default' },
90
+ },
91
+ 4: {
92
+ id: 4, band: 'levels 31-40', gate: 31,
93
+ names: { tree: 'Hoarbough', shoal: 'the Icewake Shoal', star: 'Frostfall', bloom: 'Frostcrown', cauldron: 'the Rimebrew Cauldron' },
94
+ resource: {
95
+ tree: { kind: 'beechwood', unitXp: 53, rare: 'oakwood' },
96
+ shoal: { kind: 'rawchar', unitXp: 105, rare: 'rawsalmon' },
97
+ star: { kind: 'silver', unitXp: 90, rare: 'gold' },
98
+ bloom: { kind: 'ironwort', unitXp: 45, rare: 'samphire' },
99
+ cauldron: { kind: 'fishingpotion', unitXp: 44, rare: 'strengthpotion' },
100
+ },
101
+ styles: { tree: 'beech', shoal: 'default', star: 'silver', bloom: 'default', cauldron: 'default' },
102
+ },
103
+ };
104
+ export function giantBand(id) {
105
+ return GIANT_BANDS[(id >= 1 && id <= 4 ? Math.floor(id) : 1)];
106
+ }
107
+ /** "Elderbough, the Great Tree". */
108
+ export function giantName(kind, band) {
109
+ return `${GIANT_BANDS[band].names[kind]}, ${GIANT_KIND_INFO[kind].title}`;
110
+ }
111
+ /** The skill level a Giant asks. Null when `level` may work it, else the line the player reads. */
112
+ export function giantGate(band) {
113
+ return GIANT_BANDS[band].gate;
114
+ }
115
+ /** The first band is open to everyone; the others ask the band's level. */
116
+ export function giantCanWork(band, level) {
117
+ const gate = giantGate(band);
118
+ return gate <= 1 || Math.floor(level) >= gate;
119
+ }
120
+ export function giantGateLine(kind, band, level) {
121
+ if (giantCanWork(band, level))
122
+ return null;
123
+ return `You need ${GIANT_KIND_INFO[kind].skillName} ${giantGate(band)} to work this.`;
124
+ }
125
+ // ---- the clock: random, unannounced --------------------------------------------------------------------------------------
126
+ /** A Giant lands this long after the last one ended (uniform between the two): no clock to learn. */
127
+ export const GIANT_SPAWN_MIN_MS = 60 * MINUTE;
128
+ export const GIANT_SPAWN_MAX_MS = 150 * MINUTE;
129
+ /** The hard cap from landing: then it withers and only what was paid stands. */
130
+ export const GIANT_CAP_MS = 12 * MINUTE;
131
+ /** With five workers at the band's level it dies in about this long. */
132
+ export const GIANT_TARGET_MS = 8 * MINUTE;
133
+ /** After the death (or the withering) the remains stay and the view says so this long. */
134
+ export const GIANT_SPENT_MS = 3 * MINUTE;
135
+ /** The world-timer key the backend saves under. */
136
+ export const GIANT_TIMER_KEY = 'giants';
137
+ /** The one world entity id of the live Giant; a chore entity is `giant-chore-<character key>`. */
138
+ export const GIANT_NODE_ID = 'giant-node';
139
+ export const GIANT_CHORE_PREFIX = 'giant-chore-';
140
+ export function giantChoreId(characterKey) {
141
+ return `${GIANT_CHORE_PREFIX}${characterKey}`;
142
+ }
143
+ export function isGiantEntityId(id) {
144
+ return id === GIANT_NODE_ID || id.startsWith(GIANT_CHORE_PREFIX);
145
+ }
146
+ /** The world entity kinds: the Giant itself (style `giantNodeStyle`) and a worker's chore (style the chore kind). */
147
+ export const GIANT_NODE_KIND = 'skillinggiant';
148
+ export const GIANT_CHORE_KIND = 'giantchore';
149
+ /** The Giant entity's style, `tree:1`: what it is and its band, so the client draws the right look without the view. */
150
+ export function giantNodeStyle(kind, band) {
151
+ return `${kind}:${band}`;
152
+ }
153
+ export function parseGiantNodeStyle(style) {
154
+ if (!style)
155
+ return null;
156
+ const [k, b] = style.split(':');
157
+ const band = Number(b);
158
+ if (!isGiantKind(k) || !(band === 1 || band === 2 || band === 3 || band === 4))
159
+ return null;
160
+ return { kind: k, band: band };
161
+ }
162
+ /** The hover and menu name of a Giant entity: "Elderbough, the Great Tree" (or the plain title for a style it cannot read). */
163
+ export function giantEntityName(style) {
164
+ const p = parseGiantNodeStyle(style);
165
+ return p ? giantName(p.kind, p.band) : 'a Skilling Giant';
166
+ }
167
+ /** The hover name of a chore: "a sapling". */
168
+ export function giantChoreEntityName(style) {
169
+ const info = GIANT_KINDS.map((k) => GIANT_KIND_INFO[k]).find((i) => i.chore === style);
170
+ return info ? info.choreName.replace(/^an? /, '').replace(/^\w/, (c) => c.toUpperCase()) : 'A chore';
171
+ }
172
+ /** How far the Giant has been worked, as a look: 0 whole, 1 a quarter gone, 2 half, 3 three quarters (the node is drawn a little smaller each step). */
173
+ export function giantNodeLook(progress) {
174
+ return Math.max(0, Math.min(3, Math.floor(progress * 4 + 1e-9)));
175
+ }
176
+ /** Pick the next landing time after `endedAt`. */
177
+ export function giantNextSpawnAt(endedAt, random = Math.random) {
178
+ return endedAt + GIANT_SPAWN_MIN_MS + Math.floor(random() * (GIANT_SPAWN_MAX_MS - GIANT_SPAWN_MIN_MS));
179
+ }
180
+ // ---- the pool: nothing per hit, health by crowd ---------------------------------------------------------------------------
181
+ /** A worker is active with a hit inside this window (the pulse pays them; the pool sizes by them). */
182
+ export const GIANT_ACTIVE_WINDOW_MS = 60 * SECOND;
183
+ /** The pool is sized for at least this many workers (a lone worker reaches about 40 percent before the cap). */
184
+ export const GIANT_MIN_ACTIVE = 3;
185
+ export const GIANT_ACTIVE_CAP = 30;
186
+ /** How the crowd scales the health: twice the workers, 2^0.7 times the hits (so each head's share falls, and a crowd is faster). */
187
+ export const GIANT_CROWD_EXPONENT = 0.7;
188
+ /** The crowd the target time is set for. */
189
+ export const GIANT_REF_WORKERS = 5;
190
+ /** Hits a minute a steady worker lands on this kind. */
191
+ export function giantRefRate(kind) {
192
+ return MINUTE / GIANT_KIND_INFO[kind].hitMs;
193
+ }
194
+ /** The hits that kill a Giant of this kind with `active` workers. */
195
+ export function giantHitsToKill(kind, active) {
196
+ const a = Math.min(GIANT_ACTIVE_CAP, Math.max(GIANT_MIN_ACTIVE, Math.floor(active) || 0));
197
+ const ref = giantRefRate(kind) * (GIANT_TARGET_MS / MINUTE) * GIANT_REF_WORKERS;
198
+ return ref * Math.pow(a / GIANT_REF_WORKERS, GIANT_CROWD_EXPONENT);
199
+ }
200
+ /** How much health one hit takes with `active` workers (0 to 1). */
201
+ export function giantStep(kind, active) {
202
+ return 1 / giantHitsToKill(kind, active);
203
+ }
204
+ export function giantActiveCount(node, now) {
205
+ let n = 0;
206
+ for (const w of node.workers)
207
+ if (now - w.lastAt <= GIANT_ACTIVE_WINDOW_MS)
208
+ n += 1;
209
+ return Math.min(GIANT_ACTIVE_CAP, n);
210
+ }
211
+ /** A pulse fires at each of these shares of the health, and at least every GIANT_PULSE_EVERY_MS while anyone works. */
212
+ export const GIANT_PULSE_SHARES = 10;
213
+ export const GIANT_PULSE_EVERY_MS = 60 * SECOND;
214
+ /**
215
+ * A worker's hit at `now` (the chore rule is the backend's: it does not call this while the worker's chore stands). Pure: returns the
216
+ * next node. The first hit also counts the worker as active for this very step.
217
+ */
218
+ export function giantApplyHit(node, who, now) {
219
+ if (node.end)
220
+ return { node, counted: false, pulse: false, killed: false };
221
+ const workers = node.workers.map((w) => ({ ...w }));
222
+ let w = workers.find((x) => x.key === who.key);
223
+ if (!w) {
224
+ w = { key: who.key, hits: 0, lastAt: now, firstAt: now };
225
+ if (who.account)
226
+ w.account = who.account;
227
+ workers.push(w);
228
+ }
229
+ w.hits += 1;
230
+ w.lastAt = now;
231
+ const next = { ...node, workers };
232
+ const before = Math.floor(node.progress * GIANT_PULSE_SHARES + 1e-9);
233
+ next.progress = Math.min(1, next.progress + giantStep(node.kind, giantActiveCount(next, now)));
234
+ const after = Math.floor(next.progress * GIANT_PULSE_SHARES + 1e-9);
235
+ let killed = false;
236
+ if (next.progress >= 1) {
237
+ next.progress = 1;
238
+ next.end = { at: now, how: 'killed' };
239
+ killed = true;
240
+ }
241
+ // The death is its own event (the scatter), not a pulse.
242
+ const pulse = !killed && after > before;
243
+ if (pulse) {
244
+ next.pulses += 1;
245
+ next.lastPulseAt = now;
246
+ }
247
+ return { node: next, counted: true, pulse, killed };
248
+ }
249
+ /** Is a timed pulse due (a minute since the last, someone active, and the Giant standing)? */
250
+ export function giantTimedPulseDue(node, now) {
251
+ if (node.end)
252
+ return false;
253
+ if (giantActiveCount(node, now) === 0)
254
+ return false;
255
+ return now - Math.max(node.lastPulseAt, node.landedAt) >= GIANT_PULSE_EVERY_MS;
256
+ }
257
+ export function giantMarkTimedPulse(node, now) {
258
+ return { ...node, pulses: node.pulses + 1, lastPulseAt: now };
259
+ }
260
+ /** The hard cap: a Giant still standing GIANT_CAP_MS after it landed withers. Idempotent. */
261
+ export function giantExpire(node, now) {
262
+ if (node.end)
263
+ return { node, withered: false };
264
+ if (now - node.landedAt < GIANT_CAP_MS)
265
+ return { node, withered: false };
266
+ return { node: { ...node, end: { at: node.landedAt + GIANT_CAP_MS, how: 'withered' } }, withered: true };
267
+ }
268
+ // ---- chores: the click that stops a pure AFK -------------------------------------------------------------------------------
269
+ /** A worker's first chore lands no sooner than this after their first hit; later ones this long after the last was cleared. */
270
+ export const GIANT_CHORE_FIRST_MS = 45 * SECOND;
271
+ export const GIANT_CHORE_MIN_MS = 60 * SECOND;
272
+ export const GIANT_CHORE_MAX_MS = 120 * SECOND;
273
+ /** A chore nobody clears is taken away with the Giant; a cleared one goes at once. */
274
+ export const GIANT_CHORE_LINE = 'Your hits do nothing until it is cleared.';
275
+ export function giantNextChoreDelay(first, random = Math.random) {
276
+ if (first)
277
+ return GIANT_CHORE_FIRST_MS + Math.floor(random() * (GIANT_CHORE_MIN_MS - GIANT_CHORE_FIRST_MS));
278
+ return GIANT_CHORE_MIN_MS + Math.floor(random() * (GIANT_CHORE_MAX_MS - GIANT_CHORE_MIN_MS));
279
+ }
280
+ /** How the chore's stand-in is drawn: an entity kind, a style and a scale (the Blender props come later: upgrades.json). */
281
+ export function giantChoreLook(chore) {
282
+ if (chore === 'sapling')
283
+ return { kind: 'tree', style: 'birch', scale: 0.45 };
284
+ if (chore === 'snag')
285
+ return { kind: 'reeds', scale: 0.9 };
286
+ if (chore === 'rock')
287
+ return { kind: 'rock', scale: 0.7 };
288
+ if (chore === 'bramble')
289
+ return { kind: 'bush', scale: 0.7 };
290
+ return { kind: 'bucket', scale: 1.2 };
291
+ }
292
+ /** What the player reads when the chore lands and when it is cleared. */
293
+ export function giantChoreLandLine(kind) {
294
+ const info = GIANT_KIND_INFO[kind];
295
+ return `${info.choreFix}! ${GIANT_CHORE_LINE}`;
296
+ }
297
+ export function giantChoreClearLine(kind) {
298
+ const c = GIANT_KIND_INFO[kind].chore;
299
+ if (c === 'sapling')
300
+ return 'The sapling falls. Back to the tree!';
301
+ if (c === 'snag')
302
+ return 'The line comes free. Cast again!';
303
+ if (c === 'rock')
304
+ return 'The rock breaks. Back to the star!';
305
+ if (c === 'bramble')
306
+ return 'The bramble is cut. Back to the bloom!';
307
+ return 'The brew settles. Keep stirring!';
308
+ }
309
+ /** What a hit on the Giant says while the chore stands. */
310
+ export function giantChoreBlockLine(kind) {
311
+ return `Clear ${GIANT_KIND_INFO[kind].choreName} first.`;
312
+ }
313
+ // ---- rewards: pulses, the death, the floor ---------------------------------------------------------------------------------
314
+ /** A pulse pays each active worker this many XP units; the death this many to a real worker (half to a light one). */
315
+ export const GIANT_PULSE_XP_UNITS = 4;
316
+ export const GIANT_FINAL_XP_UNITS = 40;
317
+ /** A real worker (the full death XP and a small satchel) landed this many hits; a large satchel from this many. */
318
+ export const GIANT_REAL_HITS = 20;
319
+ export const GIANT_LIGHT_HITS = 5;
320
+ export const GIANT_LARGE_SATCHEL_HITS = 60;
321
+ export function giantPulseXp(kind, band) {
322
+ return GIANT_PULSE_XP_UNITS * GIANT_BANDS[band].resource[kind].unitXp;
323
+ }
324
+ export function giantFinalXp(kind, band, hits) {
325
+ const unit = GIANT_BANDS[band].resource[kind].unitXp;
326
+ if (hits >= GIANT_REAL_HITS)
327
+ return GIANT_FINAL_XP_UNITS * unit;
328
+ if (hits >= GIANT_LIGHT_HITS)
329
+ return Math.round((GIANT_FINAL_XP_UNITS * unit) / 2);
330
+ return 0;
331
+ }
332
+ /** The full death XP also asks the hits be spread over this long (spec section 6: a late rush of hits gets the light share). */
333
+ export const GIANT_REAL_SPAN_MS = 3 * MINUTE;
334
+ /** The death XP one worker gets: the full share for GIANT_REAL_HITS over GIANT_REAL_SPAN_MS, half for a lighter hand, nothing under GIANT_LIGHT_HITS. */
335
+ export function giantDeathXp(kind, band, worker) {
336
+ if (worker.hits >= GIANT_REAL_HITS && worker.lastAt - worker.firstAt < GIANT_REAL_SPAN_MS)
337
+ return giantFinalXp(kind, band, GIANT_LIGHT_HITS);
338
+ return giantFinalXp(kind, band, worker.hits);
339
+ }
340
+ export function giantContribution(hits) {
341
+ if (hits >= GIANT_LARGE_SATCHEL_HITS)
342
+ return 'large';
343
+ if (hits >= GIANT_REAL_HITS)
344
+ return 'small';
345
+ return 'none';
346
+ }
347
+ export function giantEntitlements(node) {
348
+ const out = [];
349
+ for (const w of node.workers) {
350
+ const c = giantContribution(w.hits);
351
+ if (c !== 'none')
352
+ out.push({ key: w.key, large: c === 'large' });
353
+ }
354
+ return out;
355
+ }
356
+ /** A pulse crate holds this many of the resource; it lies this long; one each a pulse. */
357
+ export const GIANT_CRATE_MIN = 5;
358
+ export const GIANT_CRATE_MAX = 15;
359
+ export const GIANT_CRATE_MS = 90 * SECOND;
360
+ export const GIANT_PULSE_PICKUP_CAP = 1;
361
+ /** The death scatter lies this long; a character takes at most this many pieces of it. */
362
+ export const GIANT_SCATTER_MS = 120 * SECOND;
363
+ export const GIANT_SCATTER_PICKUP_CAP = 3;
364
+ /** Pieces land this far from the footprint edge; the work ring is this far. */
365
+ export const GIANT_SCATTER_MIN = 2;
366
+ export const GIANT_SCATTER_MAX = 6;
367
+ export const GIANT_WORK_RANGE = 2;
368
+ export function giantReach(kind) {
369
+ return GIANT_KIND_INFO[kind].footprint + GIANT_WORK_RANGE + (kind === 'shoal' ? 1 : 0);
370
+ }
371
+ /** The panel shows within this many tiles of the Giant; the pulse line is told within it too. */
372
+ export const GIANT_PANEL_RANGE = 40;
373
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
374
+ const rollBetween = (min, max, random) => min + (Math.floor(random() * (max - min + 1)) % (max - min + 1));
375
+ /** What the death throws for `active` workers at that moment (Windfall's fall table). A withering throws nothing. */
376
+ export function giantScatterCounts(active) {
377
+ const a = Math.max(0, Math.floor(active) || 0);
378
+ return {
379
+ crates: clamp(4 + 2 * a, 8, 24),
380
+ smallSatchels: clamp(Math.ceil(a / 2), 2, 10),
381
+ largeSatchels: clamp(Math.floor(a / 5), 1, 4),
382
+ rare: 1 + Math.floor(a / 10),
383
+ };
384
+ }
385
+ export function giantCrateQty(random = Math.random) {
386
+ return rollBetween(GIANT_CRATE_MIN, GIANT_CRATE_MAX, random);
387
+ }
388
+ /** A satchel's coins by band: [small min, small max, large min, large max] (Windfall's table). */
389
+ export const GIANT_SATCHEL_COINS = {
390
+ 1: [30, 70, 80, 170],
391
+ 2: [60, 140, 150, 350],
392
+ 3: [100, 220, 250, 550],
393
+ 4: [160, 320, 400, 800],
394
+ };
395
+ /** What opening a satchel gives: coins, the Giant's resource, and (large) its rare piece. No food, no shop goods. */
396
+ export function giantSatchelContents(kind, band, large, random = Math.random) {
397
+ const [sMin, sMax, lMin, lMax] = GIANT_SATCHEL_COINS[band];
398
+ const r = GIANT_BANDS[band].resource[kind];
399
+ const items = [{ kind: 'coins', qty: large ? rollBetween(lMin, lMax, random) : rollBetween(sMin, sMax, random) }];
400
+ items.push({ kind: r.kind, qty: large ? rollBetween(8, 14, random) : rollBetween(5, 10, random) });
401
+ if (large)
402
+ items.push({ kind: r.rare, qty: rollBetween(1, 3, random) });
403
+ return items;
404
+ }
405
+ /** The first site list (spec section 3: four to six a band come with the balance pass; these are the anchors the data gives). */
406
+ export const GIANT_SITES = [
407
+ // Band 1: Hope Town and its surroundings.
408
+ { id: 'g1-tree-hopewood', kind: 'tree', band: 1, where: 'in Hopewood Common', anchor: { x: 125, z: 900 }, searchRadius: 25 },
409
+ { id: 'g1-tree-copse', kind: 'tree', band: 1, where: 'at Hopewood Copse', anchor: { x: 500, z: 884 }, searchRadius: 30 },
410
+ { id: 'g1-shoal-henson', kind: 'shoal', band: 1, where: 'in Henson Pond', anchor: { x: 300, z: 835 }, searchRadius: 25 },
411
+ { id: 'g1-star-hollin', kind: 'star', band: 1, where: 'on the Hollin Brook shore', anchor: { x: 350, z: 938 }, searchRadius: 25 },
412
+ { id: 'g1-bloom-common', kind: 'bloom', band: 1, where: 'in Hopewood Common', anchor: { x: 150, z: 880 }, searchRadius: 30 },
413
+ { id: 'g1-cauldron-hope', kind: 'cauldron', band: 1, where: 'outside Hope Town', anchor: { x: 330, z: 905 }, searchRadius: 30 },
414
+ // Band 2: Calverton, the fields and the Western line's first stops.
415
+ { id: 'g2-tree-brindle', kind: 'tree', band: 2, where: 'in the Brindlecross pines', anchor: { x: 1850, z: 1090 }, searchRadius: 25 },
416
+ { id: 'g2-shoal-riversend', kind: 'shoal', band: 2, where: 'at Riversend', anchor: { x: 1851, z: 1001 }, searchRadius: 30 },
417
+ { id: 'g2-star-riversend', kind: 'star', band: 2, where: 'on the Riversend gravel bar', anchor: { x: 1851, z: 1001 }, searchRadius: 25 },
418
+ { id: 'g2-bloom-calverton', kind: 'bloom', band: 2, where: 'outside Calverton', anchor: { x: 772, z: 660 }, searchRadius: 30 },
419
+ { id: 'g2-cauldron-calverton', kind: 'cauldron', band: 2, where: 'outside Calverton', anchor: { x: 760, z: 670 }, searchRadius: 30 },
420
+ // Band 3: the Dreadwoods' edge, the Greenwood, the mine.
421
+ { id: 'g3-tree-rangers', kind: 'tree', band: 3, where: "by Ranger's Rest", anchor: { x: 1735, z: 1230 }, searchRadius: 25 },
422
+ { id: 'g3-shoal-lake', kind: 'shoal', band: 3, where: 'on the battlefield lake', anchor: { x: 600, z: 700 }, searchRadius: 40 },
423
+ // The mine yard is inside the PvP woods, where no Giant lands: band 3's star falls at the Emberdeep diggings instead.
424
+ { id: 'g3-star-emberdeep', kind: 'star', band: 3, where: 'at the Emberdeep diggings', anchor: { x: 1760, z: 862 }, searchRadius: 25 },
425
+ { id: 'g3-bloom-lake', kind: 'bloom', band: 3, where: 'by the battlefield lake', anchor: { x: 580, z: 722 }, searchRadius: 30 },
426
+ { id: 'g3-cauldron-rangers', kind: 'cauldron', band: 3, where: "by Ranger's Rest", anchor: { x: 1745, z: 1220 }, searchRadius: 25 },
427
+ // Band 4: the frozen north and the Highfold road.
428
+ { id: 'g4-tree-highfold', kind: 'tree', band: 4, where: 'on the Highfold road', anchor: { x: 2040, z: 360 }, searchRadius: 25 },
429
+ { id: 'g4-shoal-thaw', kind: 'shoal', band: 4, where: 'in the Thaw gorge', anchor: { x: 237, z: 150 }, searchRadius: 30 },
430
+ { id: 'g4-star-gorge', kind: 'star', band: 4, where: 'in the Crystal Gorge', anchor: { x: 30, z: 226 }, searchRadius: 25 },
431
+ { id: 'g4-bloom-highfold', kind: 'bloom', band: 4, where: 'on the Highfold road', anchor: { x: 2050, z: 370 }, searchRadius: 25 },
432
+ { id: 'g4-cauldron-highfold', kind: 'cauldron', band: 4, where: 'on the Highfold road', anchor: { x: 2030, z: 350 }, searchRadius: 25 },
433
+ ];
434
+ export function giantSiteById(id) {
435
+ return GIANT_SITES.find((s) => s.id === id);
436
+ }
437
+ /**
438
+ * Pick the next Giant: a random band, then a random kind of that band's sites, never the site that just ran. The bands are weighted so
439
+ * the starter band sees the most (a newcomer meets one soon).
440
+ */
441
+ export const GIANT_BAND_WEIGHTS = { 1: 4, 2: 3, 3: 2, 4: 1 };
442
+ export function giantPickSite(lastSite, random = Math.random, sites = GIANT_SITES) {
443
+ const pool = sites.filter((s) => s.id !== lastSite);
444
+ if (pool.length === 0)
445
+ return null;
446
+ const total = pool.reduce((n, s) => n + GIANT_BAND_WEIGHTS[s.band], 0);
447
+ let roll = random() * total;
448
+ for (const s of pool) {
449
+ roll -= GIANT_BAND_WEIGHTS[s.band];
450
+ if (roll < 0)
451
+ return s;
452
+ }
453
+ return pool[pool.length - 1];
454
+ }
455
+ /** The world-chat lines (one place, so the backend and the tests read the same words). */
456
+ export function giantLine(kind, node, where) {
457
+ const info = GIANT_KIND_INFO[node.kind];
458
+ const band = GIANT_BANDS[node.band];
459
+ const name = giantName(node.kind, node.band);
460
+ if (kind === 'lands')
461
+ return `World event: ${name}, ${info.lands} ${where}! Bring ${info.tool}, ${band.band}.`;
462
+ if (kind === 'pulse')
463
+ return `${band.names[node.kind]} ${info.pulse} ${where}.`;
464
+ if (kind === 'dies')
465
+ return `World event: ${name}, ${info.dies} ${where}! Crates and satchels on the ground for ${GIANT_SCATTER_MS / MINUTE} minutes.`;
466
+ return `World event: ${band.names[node.kind]} ${where} ${info.withers}.`;
467
+ }
468
+ /** One line for the "Today in Dreadwoods" panel and the login while a Giant stands (none otherwise: it is never announced ahead). */
469
+ export function giantTodayLine(node, where) {
470
+ if (!node || node.end || !where)
471
+ return null;
472
+ return `Skilling Giant: ${giantName(node.kind, node.band)} is up ${where} (${GIANT_BANDS[node.band].band})`;
473
+ }
474
+ export function giantView(input) {
475
+ const { node, where, tile, now } = input;
476
+ const info = GIANT_KIND_INFO[node.kind];
477
+ const worker = input.you ? node.workers.find((w) => w.key === input.you.key) : undefined;
478
+ return {
479
+ id: node.id,
480
+ kind: node.kind,
481
+ band: node.band,
482
+ name: GIANT_BANDS[node.band].names[node.kind],
483
+ title: info.title,
484
+ skill: info.skillName,
485
+ gate: giantGate(node.band),
486
+ where,
487
+ x: tile.x,
488
+ z: tile.z,
489
+ health: Math.max(0, Math.min(1, 1 - node.progress)),
490
+ msToCap: node.end ? 0 : Math.max(0, node.landedAt + GIANT_CAP_MS - now),
491
+ pulses: node.pulses,
492
+ active: giantActiveCount(node, now),
493
+ ended: node.end ? node.end.how : null,
494
+ msSinceEnd: node.end ? Math.max(0, now - node.end.at) : 0,
495
+ you: worker || input.you?.choreStanding
496
+ ? { hits: worker?.hits ?? 0, satchel: giantContribution(worker?.hits ?? 0), chore: input.you?.choreStanding ? { name: info.choreName, fix: info.choreFix } : null }
497
+ : null,
498
+ canWork: input.you ? giantCanWork(node.band, input.you.level) : true,
499
+ tool: info.tool,
500
+ };
501
+ }
502
+ /** The paper map marks a Giant from the moment it lands until its remains are gone. */
503
+ export function giantOnMap(view) {
504
+ return !!view && (view.ended === null || view.msSinceEnd < GIANT_SPENT_MS);
505
+ }
506
+ export const GIANT_PENDING_PER_CHARACTER = 8;
507
+ export const GIANT_PENDING_CHARACTERS = 5000;
508
+ export const GIANT_COLLECTED_MAX = 600;
509
+ export function emptyGiantSave() {
510
+ return { v: 1, node: null, nextAt: null, lastSite: null, collected: [], pending: {} };
511
+ }
512
+ function num(v, lo, hi) {
513
+ return typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null;
514
+ }
515
+ function bandOf(v) {
516
+ return v === 1 || v === 2 || v === 3 || v === 4 ? v : null;
517
+ }
518
+ function parseWorker(raw) {
519
+ if (!raw || typeof raw !== 'object')
520
+ return null;
521
+ const r = raw;
522
+ if (typeof r.key !== 'string' || r.key.length === 0 || r.key.length > 80)
523
+ return null;
524
+ const hits = num(r.hits, 0, 1e6);
525
+ const lastAt = num(r.lastAt, 0, 1e15);
526
+ const firstAt = num(r.firstAt, 0, 1e15);
527
+ if (hits === null || lastAt === null || firstAt === null)
528
+ return null;
529
+ const w = { key: r.key, hits: Math.floor(hits), lastAt, firstAt };
530
+ if (typeof r.account === 'string' && r.account.length > 0 && r.account.length <= 80)
531
+ w.account = r.account;
532
+ return w;
533
+ }
534
+ export function parseGiantNode(raw) {
535
+ if (!raw || typeof raw !== 'object')
536
+ return null;
537
+ const r = raw;
538
+ const band = bandOf(r.band);
539
+ const progress = num(r.progress, 0, 1);
540
+ const pulses = num(r.pulses, 0, 1e4);
541
+ const lastPulseAt = num(r.lastPulseAt, 0, 1e15);
542
+ const landedAt = num(r.landedAt, 0, 1e15);
543
+ if (!isGiantKind(r.kind) || band === null || progress === null || pulses === null || lastPulseAt === null || landedAt === null)
544
+ return null;
545
+ if (typeof r.id !== 'string' || r.id.length === 0 || r.id.length > 60 || typeof r.site !== 'string' || r.site.length > 60)
546
+ return null;
547
+ const seen = new Set();
548
+ const workers = (Array.isArray(r.workers) ? r.workers.slice(0, 5000) : [])
549
+ .map(parseWorker)
550
+ .filter((w) => w !== null && (seen.has(w.key) ? false : (seen.add(w.key), true)));
551
+ let end = null;
552
+ const e = r.end;
553
+ if (e && typeof e === 'object') {
554
+ const at = num(e.at, 0, 1e15);
555
+ if (at !== null && (e.how === 'killed' || e.how === 'withered'))
556
+ end = { at, how: e.how };
557
+ }
558
+ return { v: 1, id: r.id, kind: r.kind, band, site: r.site, landedAt, progress, pulses: Math.floor(pulses), lastPulseAt, workers, end };
559
+ }
560
+ /** Defensive parse of the saved state (a corrupt save is an empty one, never a crash). */
561
+ export function parseGiantSave(raw) {
562
+ if (!raw || typeof raw !== 'object')
563
+ return emptyGiantSave();
564
+ const r = raw;
565
+ if (r.v !== 1)
566
+ return emptyGiantSave();
567
+ const nextAt = num(r.nextAt, 0, 1e15);
568
+ const lastSite = typeof r.lastSite === 'string' && r.lastSite.length <= 60 ? r.lastSite : null;
569
+ const collected = (Array.isArray(r.collected) ? r.collected : []).filter((c) => typeof c === 'string' && c.length <= 160).slice(-GIANT_COLLECTED_MAX);
570
+ const pending = {};
571
+ if (r.pending && typeof r.pending === 'object') {
572
+ for (const [key, list] of Object.entries(r.pending).slice(0, GIANT_PENDING_CHARACTERS)) {
573
+ if (!Array.isArray(list) || key.length === 0 || key.length > 80)
574
+ continue;
575
+ const owed = list
576
+ .map((p) => {
577
+ const q = p;
578
+ if (!q || typeof q !== 'object')
579
+ return null;
580
+ const band = bandOf(q.band);
581
+ if (typeof q.id !== 'string' || q.id.length > 60 || !isGiantKind(q.kind) || band === null)
582
+ return null;
583
+ return { id: q.id, kind: q.kind, band, large: q.large === true };
584
+ })
585
+ .filter((p) => p !== null)
586
+ .slice(-GIANT_PENDING_PER_CHARACTER);
587
+ if (owed.length > 0)
588
+ pending[key] = owed;
589
+ }
590
+ }
591
+ return { v: 1, node: parseGiantNode(r.node), nextAt, lastSite, collected, pending };
592
+ }
593
+ /** The key a collected contribution satchel is saved under: one per character per Giant. */
594
+ export function giantCollectedKey(id, characterKey) {
595
+ return `${id}:${characterKey}`;
596
+ }
597
+ //# sourceMappingURL=skillingGiants.js.map