@provable-games/dm-engine 0.1.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.
Files changed (89) hide show
  1. package/dist/actions.d.ts +18 -0
  2. package/dist/actions.d.ts.map +1 -0
  3. package/dist/actions.js +88 -0
  4. package/dist/actions.js.map +1 -0
  5. package/dist/constants/beast.d.ts +28 -0
  6. package/dist/constants/beast.d.ts.map +1 -0
  7. package/dist/constants/beast.js +216 -0
  8. package/dist/constants/beast.js.map +1 -0
  9. package/dist/constants/game.d.ts +23 -0
  10. package/dist/constants/game.d.ts.map +1 -0
  11. package/dist/constants/game.js +26 -0
  12. package/dist/constants/game.js.map +1 -0
  13. package/dist/constants/loot.d.ts +341 -0
  14. package/dist/constants/loot.d.ts.map +1 -0
  15. package/dist/constants/loot.js +456 -0
  16. package/dist/constants/loot.js.map +1 -0
  17. package/dist/constants/obstacle.d.ts +4 -0
  18. package/dist/constants/obstacle.d.ts.map +1 -0
  19. package/dist/constants/obstacle.js +81 -0
  20. package/dist/constants/obstacle.js.map +1 -0
  21. package/dist/events.d.ts +50 -0
  22. package/dist/events.d.ts.map +1 -0
  23. package/dist/events.js +2 -0
  24. package/dist/events.js.map +1 -0
  25. package/dist/index.d.ts +30 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +34 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/packing.d.ts +6 -0
  30. package/dist/packing.d.ts.map +1 -0
  31. package/dist/packing.js +76 -0
  32. package/dist/packing.js.map +1 -0
  33. package/dist/protocol.d.ts +55 -0
  34. package/dist/protocol.d.ts.map +1 -0
  35. package/dist/protocol.js +250 -0
  36. package/dist/protocol.js.map +1 -0
  37. package/dist/replay.d.ts +35 -0
  38. package/dist/replay.d.ts.map +1 -0
  39. package/dist/replay.js +856 -0
  40. package/dist/replay.js.map +1 -0
  41. package/dist/rules/beast.d.ts +264 -0
  42. package/dist/rules/beast.d.ts.map +1 -0
  43. package/dist/rules/beast.js +535 -0
  44. package/dist/rules/beast.js.map +1 -0
  45. package/dist/rules/game.d.ts +35 -0
  46. package/dist/rules/game.d.ts.map +1 -0
  47. package/dist/rules/game.js +462 -0
  48. package/dist/rules/game.js.map +1 -0
  49. package/dist/rules/gameSimulation.d.ts +158 -0
  50. package/dist/rules/gameSimulation.d.ts.map +1 -0
  51. package/dist/rules/gameSimulation.js +845 -0
  52. package/dist/rules/gameSimulation.js.map +1 -0
  53. package/dist/rules/health.d.ts +3 -0
  54. package/dist/rules/health.d.ts.map +1 -0
  55. package/dist/rules/health.js +7 -0
  56. package/dist/rules/health.js.map +1 -0
  57. package/dist/rules/loot.d.ts +100 -0
  58. package/dist/rules/loot.d.ts.map +1 -0
  59. package/dist/rules/loot.js +626 -0
  60. package/dist/rules/loot.js.map +1 -0
  61. package/dist/rules/market.d.ts +28 -0
  62. package/dist/rules/market.d.ts.map +1 -0
  63. package/dist/rules/market.js +83 -0
  64. package/dist/rules/market.js.map +1 -0
  65. package/dist/rules/processFutures.d.ts +79 -0
  66. package/dist/rules/processFutures.d.ts.map +1 -0
  67. package/dist/rules/processFutures.js +429 -0
  68. package/dist/rules/processFutures.js.map +1 -0
  69. package/dist/rules/tokenId.d.ts +31 -0
  70. package/dist/rules/tokenId.d.ts.map +1 -0
  71. package/dist/rules/tokenId.js +102 -0
  72. package/dist/rules/tokenId.js.map +1 -0
  73. package/dist/staging.d.ts +18 -0
  74. package/dist/staging.d.ts.map +1 -0
  75. package/dist/staging.js +27 -0
  76. package/dist/staging.js.map +1 -0
  77. package/dist/transcript.d.ts +64 -0
  78. package/dist/transcript.d.ts.map +1 -0
  79. package/dist/transcript.js +92 -0
  80. package/dist/transcript.js.map +1 -0
  81. package/dist/types.d.ts +141 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +2 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/view.d.ts +4 -0
  86. package/dist/view.d.ts.map +1 -0
  87. package/dist/view.js +18 -0
  88. package/dist/view.js.map +1 -0
  89. package/package.json +51 -0
@@ -0,0 +1,845 @@
1
+ /**
2
+ * Client-only game simulation logic.
3
+ * Pure functions porting Cairo contract formulas for use in useGameCore.
4
+ */
5
+ import { BEAST_NAMES, BEAST_SPECIAL_NAME_LEVEL_UNLOCK } from "../constants/beast.js";
6
+ import { MAX_ADVENTURER_HEALTH, MAX_ADVENTURER_XP, MAX_GOLD, MIN_DAMAGE_FROM_BEASTS, MIN_DAMAGE_FROM_OBSTACLES, } from "../constants/game.js";
7
+ import { ItemId, SUFFIX_UNLOCK_GREATNESS } from "../constants/loot.js";
8
+ import { getBeastName, getBeastTier, getBeastType } from "./beast.js";
9
+ import { calculateAttackDamage, calculateBeastDamage, calculateLevel, elementalAdjustedDamage, } from "./game.js";
10
+ import { getMaxHealth } from "./health.js";
11
+ import { ItemUtils, Tier } from "./loot.js";
12
+ import { generateStartingStatsFromSeed } from "./processFutures.js";
13
+ // ─── Item progression constants (mirrors contracts/src/constants/adventurer.cairo) ───
14
+ const ITEM_MAX_GREATNESS = 20;
15
+ const MAX_GREATNESS_STAT_BONUS = 1;
16
+ const PREFIX_UNLOCK_GREATNESS = 19;
17
+ const VITALITY_INSTANT_HEALTH_BONUS = 15;
18
+ const MAX_ITEM_XP = 400;
19
+ const EQUIPMENT_SLOTS = [
20
+ "weapon",
21
+ "chest",
22
+ "head",
23
+ "waist",
24
+ "foot",
25
+ "hand",
26
+ "neck",
27
+ "ring",
28
+ ];
29
+ // ─── Random helpers ──────────────────────────────────────────────
30
+ export const randomU8 = () => Math.floor(Math.random() * 256);
31
+ export const randomU16 = () => Math.floor(Math.random() * 65536);
32
+ export const randomU32 = () => Math.floor(Math.random() * 0x100000000);
33
+ // ─── Tier item lists (from Cairo get_t*_items) ──────────────────
34
+ export const T5_ITEMS = [
35
+ ItemId.Book,
36
+ ItemId.Wand,
37
+ ItemId.Hood,
38
+ ItemId.Sash,
39
+ ItemId.Shirt,
40
+ ItemId.Shoes,
41
+ ItemId.Gloves,
42
+ ItemId.Club,
43
+ ItemId.Helm,
44
+ ItemId.RingMail,
45
+ ItemId.HeavyBoots,
46
+ ItemId.HeavyBelt,
47
+ ItemId.HeavyGloves,
48
+ ItemId.ShortSword,
49
+ ItemId.LeatherBelt,
50
+ ItemId.LeatherBoots,
51
+ ItemId.LeatherArmor,
52
+ ItemId.LeatherGloves,
53
+ ItemId.Cap,
54
+ ];
55
+ export const T4_ITEMS = [
56
+ ItemId.LongSword,
57
+ ItemId.HardLeatherBelt,
58
+ ItemId.HardLeatherBoots,
59
+ ItemId.HardLeatherArmor,
60
+ ItemId.LeatherCap,
61
+ ItemId.HardLeatherGloves,
62
+ ItemId.Mace,
63
+ ItemId.ChainMail,
64
+ ItemId.FullHelm,
65
+ ItemId.ChainBoots,
66
+ ItemId.ChainGloves,
67
+ ItemId.MeshBelt,
68
+ ItemId.Robe,
69
+ ItemId.LinenHood,
70
+ ItemId.LinenSash,
71
+ ItemId.LinenShoes,
72
+ ItemId.LinenGloves,
73
+ ];
74
+ export const T3_ITEMS = [
75
+ ItemId.Tome,
76
+ ItemId.BoneWand,
77
+ ItemId.LinenRobe,
78
+ ItemId.SilkHood,
79
+ ItemId.WoolSash,
80
+ ItemId.WoolShoes,
81
+ ItemId.WoolGloves,
82
+ ItemId.Scimitar,
83
+ ItemId.StuddedLeatherArmor,
84
+ ItemId.StuddedLeatherBoots,
85
+ ItemId.StuddedLeatherBelt,
86
+ ItemId.StuddedLeatherGloves,
87
+ ItemId.WarCap,
88
+ ItemId.Maul,
89
+ ItemId.Greaves,
90
+ ItemId.Gauntlets,
91
+ ItemId.PlateMail,
92
+ ItemId.GreatHelm,
93
+ ItemId.PlatedBelt,
94
+ ];
95
+ export const T2_ITEMS = [
96
+ ItemId.GraveWand,
97
+ ItemId.Chronicle,
98
+ ItemId.SilkRobe,
99
+ ItemId.DivineHood,
100
+ ItemId.SilkSash,
101
+ ItemId.SilkSlippers,
102
+ ItemId.SilkGloves,
103
+ ItemId.Falchion,
104
+ ItemId.DragonsCrown,
105
+ ItemId.DragonskinBelt,
106
+ ItemId.DragonskinBoots,
107
+ ItemId.DragonskinArmor,
108
+ ItemId.DragonskinGloves,
109
+ ItemId.Quarterstaff,
110
+ ItemId.OrnateChestplate,
111
+ ItemId.WarBelt,
112
+ ItemId.OrnateHelm,
113
+ ItemId.OrnateGreaves,
114
+ ItemId.OrnateGauntlets,
115
+ ];
116
+ export const T1_ITEMS = [
117
+ ItemId.GhostWand,
118
+ ItemId.Grimoire,
119
+ ItemId.DivineRobe,
120
+ ItemId.Crown,
121
+ ItemId.BrightsilkSash,
122
+ ItemId.DivineSlippers,
123
+ ItemId.DivineGloves,
124
+ ItemId.Katana,
125
+ ItemId.DemonHusk,
126
+ ItemId.DemonCrown,
127
+ ItemId.DemonhideBelt,
128
+ ItemId.DemonsHands,
129
+ ItemId.DemonhideBoots,
130
+ ItemId.Warhammer,
131
+ ItemId.HolyChestplate,
132
+ ItemId.AncientHelm,
133
+ ItemId.HolyGreaves,
134
+ ItemId.HolyGauntlets,
135
+ ItemId.OrnateBelt,
136
+ ];
137
+ // ─── Combat helpers ──────────────────────────────────────────────
138
+ export const getTierMultiplier = (tier) => {
139
+ return Math.max(1, 6 - tier); // T1=5, T2=4, T3=3, T4=2, T5=1
140
+ };
141
+ export const applyDamageReduction = (damage, reductionPercent) => {
142
+ if (damage <= 0 || reductionPercent <= 0)
143
+ return Math.max(0, damage);
144
+ const clamped = Math.min(100, Math.max(0, reductionPercent));
145
+ return Math.max(0, Math.floor((damage * (100 - clamped)) / 100));
146
+ };
147
+ /**
148
+ * Ability-based threat avoidance (used for ambush with wisdom, dodge with intelligence).
149
+ * Returns true if the threat is avoided.
150
+ */
151
+ export const abilityBasedAvoidThreat = (level, stat) => {
152
+ if (stat >= level)
153
+ return true;
154
+ const rnd = randomU8();
155
+ const scaledChance = Math.floor((level * rnd) / 255);
156
+ return stat > scaledChance;
157
+ };
158
+ export const abilityBasedAvoidThreatWithRoll = (level, stat, avoidRnd) => {
159
+ if (stat >= level)
160
+ return { avoided: true, rnd: 0 };
161
+ const rnd = avoidRnd ?? randomU8();
162
+ const scaledChance = Math.floor((level * rnd) / 255);
163
+ return { avoided: stat > scaledChance, rnd };
164
+ };
165
+ // ─── Encounter level/health generation ───────────────────────────
166
+ const getDifficultyOffset = (adventurerLevel) => {
167
+ if (adventurerLevel >= 50)
168
+ return 80;
169
+ if (adventurerLevel >= 40)
170
+ return 40;
171
+ if (adventurerLevel >= 30)
172
+ return 20;
173
+ if (adventurerLevel >= 20)
174
+ return 10;
175
+ return 0;
176
+ };
177
+ const getHealthOffset = (adventurerLevel) => {
178
+ if (adventurerLevel >= 50)
179
+ return 500;
180
+ if (adventurerLevel >= 40)
181
+ return 400;
182
+ if (adventurerLevel >= 30)
183
+ return 200;
184
+ if (adventurerLevel >= 20)
185
+ return 100;
186
+ return 10;
187
+ };
188
+ export const getEncounterLevel = (adventurerLevel, levelRnd) => {
189
+ const base = 1 + ((levelRnd ?? randomU16()) % Math.max(1, adventurerLevel * 3));
190
+ return base + getDifficultyOffset(adventurerLevel);
191
+ };
192
+ export const getEncounterHealth = (adventurerLevel, healthRnd) => {
193
+ const base = 1 + ((healthRnd ?? randomU16()) % Math.max(1, adventurerLevel * 20));
194
+ return Math.min(MAX_ADVENTURER_HEALTH, base + getHealthOffset(adventurerLevel));
195
+ };
196
+ // ─── Attack location ─────────────────────────────────────────────
197
+ const ATTACK_SLOTS = ["chest", "head", "waist", "foot", "hand"];
198
+ // Every roll below takes an OPTIONAL pre-drawn value. Omitted, they behave exactly as before
199
+ // (Math.random), which is what VRF dungeons want -- their optimistic render is corrected by the
200
+ // chain events that follow. Supplied, they make the simulation a deterministic function of a seed,
201
+ // which is what block-hash commit/reveal requires: there, nothing arrives later to correct a wrong
202
+ // guess, and acting on one permanently bricks the run.
203
+ export const getAttackLocation = (rnd) => {
204
+ return ATTACK_SLOTS[(rnd ?? randomU8()) % 5];
205
+ };
206
+ const SLOT_DISPLAY_NAMES = {
207
+ chest: "Chest",
208
+ head: "Head",
209
+ waist: "Waist",
210
+ foot: "Foot",
211
+ hand: "Hand",
212
+ };
213
+ export const getSlotDisplayName = (slot) => {
214
+ return SLOT_DISPLAY_NAMES[slot] || "None";
215
+ };
216
+ // ─── Obstacle type from ID ───────────────────────────────────────
217
+ const getObstacleType = (id) => {
218
+ if (id >= 1 && id < 26)
219
+ return "Magic";
220
+ if (id < 51)
221
+ return "Blade";
222
+ return "Bludgeon";
223
+ };
224
+ const getObstacleTier = (id) => {
225
+ const offset = (id - 1) % 25;
226
+ if (offset < 5)
227
+ return 1;
228
+ if (offset < 10)
229
+ return 2;
230
+ if (offset < 15)
231
+ return 3;
232
+ if (offset < 20)
233
+ return 4;
234
+ return 5;
235
+ };
236
+ // ─── Armor type helper ───────────────────────────────────────────
237
+ const getArmorType = (itemId) => {
238
+ if (itemId === 0)
239
+ return "None";
240
+ const type = ItemUtils.getItemType(itemId);
241
+ // Map item types to armor types for elemental calculation
242
+ if (type === "Cloth" || type === "Hide" || type === "Metal")
243
+ return type;
244
+ return "None";
245
+ };
246
+ export const generateBeast = (adventurerLevel) => {
247
+ const id = 1 + (randomU32() % 75);
248
+ const level = getEncounterLevel(adventurerLevel);
249
+ const health = getEncounterHealth(adventurerLevel);
250
+ const tier = getBeastTier(id);
251
+ const hasSpecials = level >= BEAST_SPECIAL_NAME_LEVEL_UNLOCK;
252
+ const special2 = hasSpecials ? 1 + (randomU8() % 69) : 0;
253
+ const special3 = hasSpecials ? 1 + (randomU8() % 18) : 0;
254
+ return { id, level, health, tier, special2, special3 };
255
+ };
256
+ export const beastToBeastEvent = (beast) => ({
257
+ id: beast.id,
258
+ baseName: BEAST_NAMES[beast.id],
259
+ name: getBeastName(beast.id, beast.level, beast.special2, beast.special3),
260
+ health: beast.health,
261
+ level: beast.level,
262
+ type: getBeastType(beast.id),
263
+ tier: beast.tier,
264
+ specialPrefix: beast.level >= BEAST_SPECIAL_NAME_LEVEL_UNLOCK ? String(beast.special2) : null,
265
+ specialSuffix: beast.level >= BEAST_SPECIAL_NAME_LEVEL_UNLOCK ? String(beast.special3) : null,
266
+ isCollectable: false,
267
+ traitSeed: BigInt(0),
268
+ });
269
+ export const generateObstacle = (adventurerLevel, rolls) => {
270
+ const id = 1 + ((rolls?.idRnd ?? randomU32()) % 75);
271
+ const level = getEncounterLevel(adventurerLevel, rolls?.levelRnd);
272
+ const tier = getObstacleTier(id);
273
+ const type = getObstacleType(id);
274
+ return { id, level, tier, type };
275
+ };
276
+ // ─── Obstacle damage ─────────────────────────────────────────────
277
+ export const calculateObstacleDamage = (obstacle, armor, adventurerLevel, baseDamageReduction, rolls) => {
278
+ const slot = getAttackLocation(rolls?.attackLocationRnd);
279
+ const attackPower = obstacle.level * getTierMultiplier(obstacle.tier);
280
+ const armorType = getArmorType(armor.id);
281
+ const armorLevel = armor.id ? calculateLevel(armor.xp) : 0;
282
+ const armorTier = armor.id ? ItemUtils.getItemTier(armor.id) : Tier.T5;
283
+ const armorPower = armor.id ? armorLevel * getTierMultiplier(armorTier) : 0;
284
+ let adjustedAttack = elementalAdjustedDamage(attackPower, obstacle.type, armorType);
285
+ // Critical hit check
286
+ const critChance = Math.min(adventurerLevel, 100);
287
+ const isCrit = isCriticalHit(critChance, rolls?.criticalHitRnd);
288
+ if (isCrit) {
289
+ adjustedAttack *= 2;
290
+ }
291
+ let damage = Math.max(MIN_DAMAGE_FROM_OBSTACLES, adjustedAttack - armorPower);
292
+ if (baseDamageReduction > 0) {
293
+ damage = Math.max(MIN_DAMAGE_FROM_OBSTACLES, applyDamageReduction(damage, baseDamageReduction));
294
+ }
295
+ return { damage, location: getSlotDisplayName(slot), critical_hit: isCrit };
296
+ };
297
+ // ─── XP reward calculation ───────────────────────────────────────
298
+ export const getBaseXpReward = (tier, entityLevel, adventurerLevel) => {
299
+ const levelDecay = Math.min(adventurerLevel * 2, 95);
300
+ const tierMult = getTierMultiplier(tier);
301
+ const reward = Math.floor((tierMult * entityLevel) / 2);
302
+ const decayed = Math.floor((reward * (100 - levelDecay)) / 100);
303
+ return Math.max(4, decayed); // MINIMUM_XP_REWARD = 4
304
+ };
305
+ export const getDiscovery = (adventurerLevel, rolls) => {
306
+ const typeRnd = rolls?.typeRnd ?? randomU8();
307
+ const scaledType = Math.floor((typeRnd * 100 + 127) / 255);
308
+ const amountRnd1 = rolls?.amountRnd1 ?? randomU8();
309
+ const amountRnd2 = rolls?.amountRnd2 ?? randomU8();
310
+ if (scaledType < 45) {
311
+ return { type: "Gold", amount: (amountRnd1 % Math.max(1, adventurerLevel)) + 1 };
312
+ }
313
+ if (scaledType < 90) {
314
+ return { type: "Health", amount: ((amountRnd1 % Math.max(1, adventurerLevel)) + 1) * 2 };
315
+ }
316
+ // Loot
317
+ const tierRnd = Math.floor((amountRnd1 * 100 + 127) / 255);
318
+ let items;
319
+ if (tierRnd < 50)
320
+ items = T5_ITEMS;
321
+ else if (tierRnd < 80)
322
+ items = T4_ITEMS;
323
+ else if (tierRnd < 92)
324
+ items = T3_ITEMS;
325
+ else if (tierRnd < 98)
326
+ items = T2_ITEMS;
327
+ else
328
+ items = T1_ITEMS;
329
+ const itemId = items[amountRnd2 % items.length];
330
+ return { type: "Loot", amount: itemId, itemId };
331
+ };
332
+ // ─── Adventurer state helpers ────────────────────────────────────
333
+ export const increaseHealth = (adventurer, amount) => {
334
+ adventurer.health = Math.min(getMaxHealth(adventurer.stats.vitality), adventurer.health + amount);
335
+ };
336
+ export const decreaseHealth = (adventurer, amount) => {
337
+ adventurer.health = Math.max(0, adventurer.health - amount);
338
+ };
339
+ export const increaseGold = (adventurer, amount) => {
340
+ adventurer.gold = Math.min(MAX_GOLD, adventurer.gold + amount);
341
+ };
342
+ export const increaseXp = (adventurer, amount) => {
343
+ const previousLevel = calculateLevel(adventurer.xp);
344
+ adventurer.xp = Math.min(MAX_ADVENTURER_XP, adventurer.xp + amount);
345
+ const newLevel = calculateLevel(adventurer.xp);
346
+ if (newLevel > previousLevel) {
347
+ adventurer.stat_upgrades_available += newLevel - previousLevel;
348
+ }
349
+ };
350
+ export const canExplore = (adventurer) => {
351
+ return (adventurer.health > 0 &&
352
+ adventurer.beast_health === 0 &&
353
+ adventurer.stat_upgrades_available === 0);
354
+ };
355
+ // ─── Loot discovery helpers ──────────────────────────────────────
356
+ /**
357
+ * Get the armor Item at a given attack slot.
358
+ */
359
+ export const getArmorAtSlot = (adventurer, slot) => {
360
+ return adventurer.equipment[slot] || { id: 0, xp: 0 };
361
+ };
362
+ /**
363
+ * Mirrors `_process_discovery` in the exploration contract: a discovered item
364
+ * is replaced with gold (T1=20, T2=16, T3=12, T4=8, T5=4) when the adventurer
365
+ * already owns it (equipped or in bag) or has nowhere to put it (bag full and
366
+ * equipment slot occupied). Returns null when the item would be kept.
367
+ */
368
+ export const getLootDiscoveryGoldConversion = (adventurer, bag, itemId) => {
369
+ const equipment = adventurer.equipment;
370
+ const equipped = [
371
+ equipment.weapon,
372
+ equipment.chest,
373
+ equipment.head,
374
+ equipment.waist,
375
+ equipment.foot,
376
+ equipment.hand,
377
+ equipment.neck,
378
+ equipment.ring,
379
+ ].some((item) => item.id === itemId);
380
+ const inBag = bag.some((item) => item.id === itemId);
381
+ const slot = ItemUtils.getItemSlot(itemId);
382
+ const slotKey = slot.toLowerCase();
383
+ const slotFree = equipment[slotKey]?.id === 0;
384
+ const bagFull = bag.length >= 15;
385
+ let reason;
386
+ if (equipped || inBag) {
387
+ reason = "owned";
388
+ }
389
+ else if (bagFull && !slotFree) {
390
+ reason = "inventory_full";
391
+ }
392
+ else {
393
+ return null;
394
+ }
395
+ const tier = ItemUtils.getItemTier(itemId);
396
+ switch (tier) {
397
+ case 1:
398
+ return { gold: 20, reason };
399
+ case 2:
400
+ return { gold: 16, reason };
401
+ case 3:
402
+ return { gold: 12, reason };
403
+ case 4:
404
+ return { gold: 8, reason };
405
+ default:
406
+ return { gold: 4, reason };
407
+ }
408
+ };
409
+ /**
410
+ * Try to add a discovered loot item to equipment or bag.
411
+ * Returns the gold conversion if the item couldn't be added (already owned or
412
+ * inventory full), or null if the item was equipped/bagged.
413
+ */
414
+ export const processLootDiscovery = (adventurer, bag, itemId) => {
415
+ const conversion = getLootDiscoveryGoldConversion(adventurer, bag, itemId);
416
+ if (conversion) {
417
+ return conversion;
418
+ }
419
+ const equipment = adventurer.equipment;
420
+ const slotKey = ItemUtils.getItemSlot(itemId).toLowerCase();
421
+ const slotFree = equipment[slotKey]?.id === 0;
422
+ const newItem = { id: itemId, xp: 0 };
423
+ if (slotFree) {
424
+ // Equip directly
425
+ equipment[slotKey].id = newItem.id;
426
+ equipment[slotKey].xp = newItem.xp;
427
+ }
428
+ else {
429
+ // Add to bag
430
+ bag.push(newItem);
431
+ }
432
+ return null;
433
+ };
434
+ // ─── Combat: Adventurer attack ───────────────────────────────────
435
+ /**
436
+ * Item greatness (level) from XP, capped at 20.
437
+ */
438
+ export const getItemGreatness = (item) => {
439
+ if (item.xp === 0)
440
+ return 1;
441
+ return Math.min(20, Math.floor(Math.sqrt(item.xp)));
442
+ };
443
+ /**
444
+ * Luck-based critical hit check (used for adventurer attacks).
445
+ */
446
+ /** `ImplCombat::is_critical_hit(chance, rnd)`: `chance * 256 > 100 * rnd`. */
447
+ export const isCriticalHit = (luck, criticalHitRnd) => {
448
+ const rnd = criticalHitRnd ?? randomU8();
449
+ return luck * 256 > 100 * rnd;
450
+ };
451
+ /**
452
+ * Precompute adventurer attack damage (normal and critical) against current beast.
453
+ * Delegates to calculateAttackDamage from game.ts which includes weapon specials,
454
+ * ring name-match bonus, and ring critical hit bonus.
455
+ */
456
+ export const calculateAdventurerAttackDamage = (adventurer, beast) => {
457
+ const { baseDamage, criticalDamage } = calculateAttackDamage(adventurer.equipment.weapon, adventurer, beast);
458
+ return { normal: baseDamage, crit: criticalDamage };
459
+ };
460
+ export const calculateBeastCounterAttack = (beast, adventurer, baseDamageReduction, isAmbush = false, rolls) => {
461
+ const slot = getAttackLocation(rolls?.attackLocationRnd);
462
+ const armor = getArmorAtSlot(adventurer, slot);
463
+ const beastDmg = calculateBeastDamage(beast, adventurer, armor);
464
+ // Level-based crit check for beasts
465
+ const adventurerLevel = calculateLevel(adventurer.xp);
466
+ const critChance = Math.min(adventurerLevel, 100);
467
+ const isCrit = isCriticalHit(critChance, rolls?.criticalHitRnd);
468
+ let damage = isCrit ? beastDmg.criticalDamage : beastDmg.baseDamage;
469
+ // Apply damage reduction for ambushes only
470
+ if (isAmbush && baseDamageReduction > 0) {
471
+ damage = Math.max(MIN_DAMAGE_FROM_BEASTS, applyDamageReduction(damage, baseDamageReduction));
472
+ }
473
+ return { damage, location: getSlotDisplayName(slot), critical_hit: isCrit, slot };
474
+ };
475
+ /**
476
+ * Generate starting stats when adventurer levels from 1 to 2.
477
+ * 12 random point allocations across 6 stats.
478
+ */
479
+ // ─── Inventory: equip / drop ─────────────────────────────────────
480
+ /** Get suffix name for an item if its greatness unlocks specials. */
481
+ const getItemSuffix = (item, salt) => {
482
+ if (item.id === 0)
483
+ return null;
484
+ const greatness = getItemGreatness(item);
485
+ if (greatness < SUFFIX_UNLOCK_GREATNESS)
486
+ return null;
487
+ const specials = ItemUtils.getSpecials(item.id, greatness, salt);
488
+ return specials.special1;
489
+ };
490
+ /** Apply suffix boost to stats (matches contract apply_suffix_boost). */
491
+ const applySuffixBoost = (stats, suffix) => {
492
+ if (suffix === "of Power") {
493
+ stats.strength += 3;
494
+ }
495
+ else if (suffix === "of Titans") {
496
+ stats.strength += 2;
497
+ }
498
+ else if (suffix === "of Skill") {
499
+ stats.dexterity += 3;
500
+ }
501
+ else if (suffix === "of Perfection") {
502
+ stats.strength += 1;
503
+ stats.dexterity += 1;
504
+ }
505
+ else if (suffix === "of Brilliance") {
506
+ stats.intelligence += 3;
507
+ }
508
+ else if (suffix === "of Enlightenment") {
509
+ stats.wisdom += 3;
510
+ }
511
+ else if (suffix === "of Protection") {
512
+ stats.dexterity += 1;
513
+ }
514
+ else if (suffix === "of Anger") {
515
+ stats.strength += 2;
516
+ stats.dexterity += 1;
517
+ }
518
+ else if (suffix === "of Rage") {
519
+ stats.strength += 1;
520
+ stats.wisdom += 1;
521
+ }
522
+ else if (suffix === "of Fury") {
523
+ stats.intelligence += 1;
524
+ }
525
+ else if (suffix === "of Vitriol") {
526
+ stats.intelligence += 2;
527
+ stats.wisdom += 1;
528
+ }
529
+ else if (suffix === "of the Fox") {
530
+ stats.dexterity += 2;
531
+ }
532
+ else if (suffix === "of Detection") {
533
+ stats.wisdom += 2;
534
+ stats.dexterity += 1;
535
+ }
536
+ else if (suffix === "of Reflection") {
537
+ stats.intelligence += 1;
538
+ stats.wisdom += 2;
539
+ }
540
+ };
541
+ /** Remove suffix boost from stats (matches contract remove_suffix_boost). */
542
+ const removeSuffixBoost = (stats, suffix) => {
543
+ if (suffix === "of Power") {
544
+ stats.strength -= 3;
545
+ }
546
+ else if (suffix === "of Titans") {
547
+ stats.strength -= 2;
548
+ }
549
+ else if (suffix === "of Skill") {
550
+ stats.dexterity -= 3;
551
+ }
552
+ else if (suffix === "of Perfection") {
553
+ stats.strength -= 1;
554
+ stats.dexterity -= 1;
555
+ }
556
+ else if (suffix === "of Brilliance") {
557
+ stats.intelligence -= 3;
558
+ }
559
+ else if (suffix === "of Enlightenment") {
560
+ stats.wisdom -= 3;
561
+ }
562
+ else if (suffix === "of Protection") {
563
+ stats.dexterity -= 1;
564
+ }
565
+ else if (suffix === "of Anger") {
566
+ stats.strength -= 2;
567
+ stats.dexterity -= 1;
568
+ }
569
+ else if (suffix === "of Rage") {
570
+ stats.strength -= 1;
571
+ stats.wisdom -= 1;
572
+ }
573
+ else if (suffix === "of Fury") {
574
+ stats.intelligence -= 1;
575
+ }
576
+ else if (suffix === "of Vitriol") {
577
+ stats.intelligence -= 2;
578
+ stats.wisdom -= 1;
579
+ }
580
+ else if (suffix === "of the Fox") {
581
+ stats.dexterity -= 2;
582
+ }
583
+ else if (suffix === "of Detection") {
584
+ stats.wisdom -= 2;
585
+ stats.dexterity -= 1;
586
+ }
587
+ else if (suffix === "of Reflection") {
588
+ stats.intelligence -= 1;
589
+ stats.wisdom -= 2;
590
+ }
591
+ };
592
+ /** Remove bag boost from stats (matches contract remove_bag_boost). */
593
+ /** Mirror of `apply_bag_boost`; `removeBagBoost` below is its inverse. */
594
+ const applyBagBoost = (stats, suffix) => {
595
+ if (suffix === "of Giant") {
596
+ stats.vitality += 3;
597
+ }
598
+ else if (suffix === "of Titans") {
599
+ stats.charisma += 1;
600
+ }
601
+ else if (suffix === "of Perfection") {
602
+ stats.vitality += 1;
603
+ }
604
+ else if (suffix === "of Protection") {
605
+ stats.vitality += 2;
606
+ }
607
+ else if (suffix === "of Rage") {
608
+ stats.charisma += 1;
609
+ }
610
+ else if (suffix === "of Fury") {
611
+ stats.vitality += 1;
612
+ stats.charisma += 1;
613
+ }
614
+ else if (suffix === "of the Fox") {
615
+ stats.charisma += 1;
616
+ }
617
+ else if (suffix === "of the Twins") {
618
+ stats.charisma += 3;
619
+ }
620
+ };
621
+ /** `ImplAdventurer::get_vitality_item_boost` -- vitality granted by an item suffix. */
622
+ const vitalityItemBoost = (suffix) => {
623
+ if (suffix === "of Giant")
624
+ return 3;
625
+ if (suffix === "of Perfection")
626
+ return 1;
627
+ if (suffix === "of Protection")
628
+ return 2;
629
+ if (suffix === "of Fury")
630
+ return 1;
631
+ return 0;
632
+ };
633
+ /** `ImplAdventurer::unlocked_specials(previous_level, new_level)`. */
634
+ const unlockedSpecials = (previousLevel, newLevel) => {
635
+ if (previousLevel < SUFFIX_UNLOCK_GREATNESS && newLevel >= PREFIX_UNLOCK_GREATNESS) {
636
+ return { suffixUnlocked: true, prefixesUnlocked: true };
637
+ }
638
+ if (previousLevel < SUFFIX_UNLOCK_GREATNESS && newLevel >= SUFFIX_UNLOCK_GREATNESS) {
639
+ return { suffixUnlocked: true, prefixesUnlocked: false };
640
+ }
641
+ if (previousLevel < PREFIX_UNLOCK_GREATNESS && newLevel >= PREFIX_UNLOCK_GREATNESS) {
642
+ return { suffixUnlocked: false, prefixesUnlocked: true };
643
+ }
644
+ return { suffixUnlocked: false, prefixesUnlocked: false };
645
+ };
646
+ /**
647
+ * `ImplItem::grant_xp_to_equipped_items` -- item XP plus everything a level-up drags along.
648
+ *
649
+ * The naive "just add xp, capped at 400" loop this replaces silently skipped three things that all
650
+ * live in the packed adventurer, and so all show up as settlement mismatches:
651
+ * - `item_specials_salt`, written the FIRST time any item unlocks specials and never again,
652
+ * - a bonus stat upgrade point when an item reaches max greatness,
653
+ * - the suffix/bag stat boosts (and one-time vitality health) applied at the unlock itself.
654
+ *
655
+ * `itemSpecialsRnd` is the roll the salt is seeded from -- `item_specials_seed` on a beast kill,
656
+ * the explore `rnd3` on an obstacle. Pass `undefined` to keep the old xp-only behaviour.
657
+ */
658
+ export const grantXpToEquippedItems = (adventurer, xpAmount, adventurerId, itemSpecialsRnd) => {
659
+ for (const slotKey of EQUIPMENT_SLOTS) {
660
+ const item = adventurer.equipment[slotKey];
661
+ if (item.id === 0)
662
+ continue;
663
+ const previousLevel = getItemGreatness(item);
664
+ item.xp = Math.min(MAX_ITEM_XP, item.xp + xpAmount);
665
+ const newLevel = getItemGreatness(item);
666
+ if (newLevel <= previousLevel || itemSpecialsRnd === undefined)
667
+ continue;
668
+ if (newLevel === ITEM_MAX_GREATNESS) {
669
+ adventurer.stat_upgrades_available += MAX_GREATNESS_STAT_BONUS;
670
+ }
671
+ const { suffixUnlocked, prefixesUnlocked } = unlockedSpecials(previousLevel, newLevel);
672
+ if (!suffixUnlocked && !prefixesUnlocked)
673
+ continue;
674
+ // The salt is per-adventurer and write-once. If it is already set, the specials were fixed
675
+ // long ago and this level-up only reveals more of them -- nothing new is rolled.
676
+ const existingSeed = ItemUtils.getItemSpecialsSeed(adventurer.item_specials_salt, adventurerId);
677
+ if (existingSeed !== 0) {
678
+ if (suffixUnlocked) {
679
+ const specials = ItemUtils.getSpecials(item.id, newLevel, existingSeed);
680
+ applyUnlockedSuffix(adventurer, specials.special1);
681
+ }
682
+ continue;
683
+ }
684
+ const masked = itemSpecialsRnd & 0x3fff;
685
+ adventurer.item_specials_salt = masked === 0 ? 1 : masked;
686
+ if (suffixUnlocked) {
687
+ const newSeed = ItemUtils.getItemSpecialsSeed(adventurer.item_specials_salt, adventurerId);
688
+ const specials = ItemUtils.getSpecials(item.id, newLevel, newSeed);
689
+ applyUnlockedSuffix(adventurer, specials.special1);
690
+ }
691
+ }
692
+ };
693
+ const applyUnlockedSuffix = (adventurer, suffix) => {
694
+ if (!suffix)
695
+ return;
696
+ applySuffixBoost(adventurer.stats, suffix);
697
+ applyBagBoost(adventurer.stats, suffix);
698
+ const vitality = vitalityItemBoost(suffix);
699
+ if (vitality !== 0) {
700
+ increaseHealth(adventurer, vitality * VITALITY_INSTANT_HEALTH_BONUS);
701
+ }
702
+ };
703
+ const removeBagBoost = (stats, suffix) => {
704
+ if (suffix === "of Giant") {
705
+ stats.vitality -= 3;
706
+ }
707
+ else if (suffix === "of Titans") {
708
+ stats.charisma -= 1;
709
+ }
710
+ else if (suffix === "of Perfection") {
711
+ stats.vitality -= 1;
712
+ }
713
+ else if (suffix === "of Protection") {
714
+ stats.vitality -= 2;
715
+ }
716
+ else if (suffix === "of Rage") {
717
+ stats.charisma -= 1;
718
+ }
719
+ else if (suffix === "of Fury") {
720
+ stats.vitality -= 1;
721
+ stats.charisma -= 1;
722
+ }
723
+ else if (suffix === "of the Fox") {
724
+ stats.charisma -= 1;
725
+ }
726
+ else if (suffix === "of the Twins") {
727
+ stats.charisma -= 3;
728
+ }
729
+ };
730
+ /**
731
+ * Equip a single item from bag into the correct equipment slot.
732
+ * If the slot is occupied, the displaced item moves to the bag.
733
+ */
734
+ export const equipItem = (adventurer, bag, itemId) => {
735
+ const salt = adventurer.item_specials_salt;
736
+ // Find and remove item from bag (preserve XP)
737
+ const bagIndex = bag.findIndex((i) => i.id === itemId);
738
+ let item;
739
+ if (bagIndex >= 0) {
740
+ item = bag.splice(bagIndex, 1)[0];
741
+ }
742
+ else {
743
+ item = { id: itemId, xp: 0 };
744
+ }
745
+ // Determine target slot
746
+ const slotName = ItemUtils.getItemSlot(itemId);
747
+ const slotKey = slotName.toLowerCase();
748
+ const current = adventurer.equipment[slotKey];
749
+ // Displace current item to bag if it exists
750
+ if (current && current.id !== 0) {
751
+ // Remove suffix boost for displaced item (contract: remove_suffix_boost)
752
+ const displacedSuffix = getItemSuffix(current, salt);
753
+ if (displacedSuffix) {
754
+ removeSuffixBoost(adventurer.stats, displacedSuffix);
755
+ }
756
+ bag.push({ ...current });
757
+ }
758
+ // Equip new item
759
+ adventurer.equipment[slotKey].id = item.id;
760
+ adventurer.equipment[slotKey].xp = item.xp;
761
+ // Apply suffix boost for newly equipped item (contract: apply_suffix_boost)
762
+ const newSuffix = getItemSuffix(item, salt);
763
+ if (newSuffix) {
764
+ applySuffixBoost(adventurer.stats, newSuffix);
765
+ }
766
+ };
767
+ /**
768
+ * Drop a single item from equipment or bag.
769
+ */
770
+ export const dropItem = (adventurer, bag, itemId) => {
771
+ const salt = adventurer.item_specials_salt;
772
+ // Check equipment first
773
+ for (const slotKey of [
774
+ "weapon",
775
+ "chest",
776
+ "head",
777
+ "waist",
778
+ "foot",
779
+ "hand",
780
+ "neck",
781
+ "ring",
782
+ ]) {
783
+ const item = adventurer.equipment[slotKey];
784
+ if (item.id === itemId) {
785
+ // Remove suffix boost for equipped item (contract: remove_suffix_boost)
786
+ const suffix = getItemSuffix(item, salt);
787
+ if (suffix) {
788
+ removeSuffixBoost(adventurer.stats, suffix);
789
+ }
790
+ item.id = 0;
791
+ item.xp = 0;
792
+ // Clamp health to max (stat boosts may have changed)
793
+ const maxHp = getMaxHealth(adventurer.stats.vitality);
794
+ if (adventurer.health > maxHp)
795
+ adventurer.health = maxHp;
796
+ return;
797
+ }
798
+ }
799
+ // Check bag
800
+ const bagIndex = bag.findIndex((i) => i.id === itemId);
801
+ if (bagIndex >= 0) {
802
+ const bagItem = bag[bagIndex];
803
+ // Remove bag boost for bag item (contract: remove_bag_boost)
804
+ const suffix = getItemSuffix(bagItem, salt);
805
+ if (suffix) {
806
+ removeBagBoost(adventurer.stats, suffix);
807
+ }
808
+ bag.splice(bagIndex, 1);
809
+ }
810
+ // Clamp health
811
+ const maxHp = getMaxHealth(adventurer.stats.vitality);
812
+ if (adventurer.health > maxHp)
813
+ adventurer.health = maxHp;
814
+ };
815
+ // ─── Combat: Starting stats ─────────────────────────────────────
816
+ export const generateStartingStats = (levelSeed) => {
817
+ // Seeded, this is `ImplStats::generate_starting_stats`: the same twelve points, dealt by
818
+ // repeated `divmod 6` over the level seed instead of by twelve independent random draws.
819
+ if (levelSeed !== undefined) {
820
+ return generateStartingStatsFromSeed(levelSeed);
821
+ }
822
+ const stats = {
823
+ strength: 0,
824
+ dexterity: 0,
825
+ vitality: 0,
826
+ intelligence: 0,
827
+ wisdom: 0,
828
+ charisma: 0,
829
+ luck: 0,
830
+ };
831
+ const statKeys = [
832
+ "strength",
833
+ "dexterity",
834
+ "vitality",
835
+ "intelligence",
836
+ "wisdom",
837
+ "charisma",
838
+ ];
839
+ for (let i = 0; i < 12; i++) {
840
+ const idx = Math.floor(Math.random() * 6);
841
+ stats[statKeys[idx]]++;
842
+ }
843
+ return stats;
844
+ };
845
+ //# sourceMappingURL=gameSimulation.js.map