@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
package/dist/replay.js ADDED
@@ -0,0 +1,856 @@
1
+ import { BEAST_NAMES, STARTER_BEAST_ATTACK_DAMAGE } from "./constants/beast.js";
2
+ import { HEALTH_INCREASE_PER_VITALITY, ITEM_XP_MULTIPLIER_BEASTS, ITEM_XP_MULTIPLIER_OBSTACLES, POTION_HEALTH_AMOUNT, STARTER_WEAPONS, STARTING_GOLD, STARTING_HEALTH, XP_FOR_DISCOVERIES, } from "./constants/game.js";
3
+ import { getBeastName, getBeastTier, getBeastType } from "./rules/beast.js";
4
+ import { calculateGoldReward, calculateLevel, getNewItemsEquipped } from "./rules/game.js";
5
+ import { abilityBasedAvoidThreatWithRoll, beastToBeastEvent, calculateAdventurerAttackDamage, calculateBeastCounterAttack, calculateObstacleDamage, canExplore, decreaseHealth, dropItem, equipItem, generateBeast, generateObstacle, generateStartingStats, getArmorAtSlot, getAttackLocation, getBaseXpReward, getDiscovery, getSlotDisplayName, grantXpToEquippedItems, increaseGold, increaseHealth, increaseXp, isCriticalHit, processLootDiscovery, } from "./rules/gameSimulation.js";
6
+ import { ItemUtils } from "./rules/loot.js";
7
+ import { felt_to_two_u64, get_simple_entropy, getBattleRandomness, getBeastEntropy, getBeastFromEntropy, getBeastFromSeed, getRandomness, } from "./rules/processFutures.js";
8
+ const STARTER_BEAST_HEALTH = 3;
9
+ const EMPTY_ITEM = { id: 0, xp: 0 };
10
+ /**
11
+ * Resolve which randomness mode this action runs in.
12
+ *
13
+ * Three modes, in priority order:
14
+ * 1. injected entropy (block-hash commit/reveal) -- deterministic against a revealed block hash
15
+ * 2. settings `game_seed` -- deterministic against a fixed seed
16
+ * 3. neither -- Math.random, corrected later by chain events
17
+ *
18
+ * Returning `null` means mode 3, and every caller keeps its pre-existing unseeded branch for that
19
+ * case, so nothing about VRF dungeons or trials changes.
20
+ */
21
+ function resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, xp) {
22
+ if (injectedEntropy !== null) {
23
+ const { u64_1, u64_2 } = felt_to_two_u64(injectedEntropy);
24
+ return { actionSeed: u64_1, marketSeed: u64_2 };
25
+ }
26
+ if (gameSeed !== 0 && (gameSeedUntilXp === 0 || gameSeedUntilXp > xp)) {
27
+ const { u64_1, u64_2 } = felt_to_two_u64(get_simple_entropy(xp, gameSeed));
28
+ return { actionSeed: u64_1, marketSeed: u64_2 };
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * `level_salt` after a level-up.
34
+ *
35
+ * Injected mode uses the action's own market seed, matching `replay.cairo`. Settings-seed mode
36
+ * deliberately keeps re-deriving from the adventurer's NEW xp -- that is what shipped, and changing
37
+ * it would silently reroll every existing `game_seed` dungeon's market.
38
+ */
39
+ function levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, newXp) {
40
+ if (injectedEntropy !== null && rolls) {
41
+ return Number((rolls.marketSeed & 0x3fffn) | 1n);
42
+ }
43
+ if (gameSeed !== 0 && (gameSeedUntilXp === 0 || gameSeedUntilXp > newXp)) {
44
+ const felt = get_simple_entropy(newXp, gameSeed);
45
+ return Number((felt_to_two_u64(felt).u64_2 & 0x3fffn) | 1n);
46
+ }
47
+ return Math.floor(Math.random() * 0x3fff) | 1;
48
+ }
49
+ /** The four `get_battle_randomness` bytes for one round, as `beast_attack` consumes them. */
50
+ function battleRolls(xp, battleCount, seed) {
51
+ const rnd = getBattleRandomness(xp, battleCount, seed);
52
+ return {
53
+ fleeRnd: Number(rnd.rnd1),
54
+ adventurerCritRnd: Number(rnd.rnd2),
55
+ beastCritRnd: Number(rnd.rnd3),
56
+ attackLocationRnd: Number(rnd.rnd4),
57
+ };
58
+ }
59
+ function getStarterBeastId(weaponId) {
60
+ if (ItemUtils.isMagicOrCloth(weaponId))
61
+ return 71; // Troll
62
+ if (ItemUtils.isBladeOrHide(weaponId))
63
+ return 21; // Fairy
64
+ if (ItemUtils.isBludgeonOrMetal(weaponId))
65
+ return 46; // Bear
66
+ return 71; // fallback
67
+ }
68
+ function cloneAdventurer(adv) {
69
+ return {
70
+ ...adv,
71
+ stats: { ...adv.stats },
72
+ equipment: {
73
+ weapon: { ...adv.equipment.weapon },
74
+ chest: { ...adv.equipment.chest },
75
+ head: { ...adv.equipment.head },
76
+ waist: { ...adv.equipment.waist },
77
+ foot: { ...adv.equipment.foot },
78
+ hand: { ...adv.equipment.hand },
79
+ neck: { ...adv.equipment.neck },
80
+ ring: { ...adv.equipment.ring },
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * Identity, deliberately.
86
+ *
87
+ * The client re-presents a generated beast under the dungeon's theme here. The engine must
88
+ * NOT: it emits the same raw ids the contract did, because the indexer stores raw ids and the
89
+ * client themes on read (`transformApiEvent`). Keeping the call sites lets this file stay a
90
+ * byte-for-byte port of `useGameCore`'s handlers.
91
+ */
92
+ function applyTheme(beast, _dungeonId) {
93
+ return beast;
94
+ }
95
+ /**
96
+ * Apply one action to `state` and return the events it produced.
97
+ *
98
+ * This is `useGameCore`'s body verbatim, with the store reads replaced by `state` and the
99
+ * `actionEntropy` reads replaced by `entropy`. It is pure: no store, no RPC, no `Date`, and
100
+ * -- when `entropy` is non-null -- no `Math.random()`. That last property is what lets the
101
+ * client and the off-chain replayer agree, and it is asserted by the entropy test suite.
102
+ *
103
+ * `entropy` is `action_entropy(rolling_i, blockhash)` for a commit-reveal game, or `null` for
104
+ * a legacy dungeon, where the handlers fall back to a settings `game_seed` or `Math.random()`.
105
+ */
106
+ export function applyAction(state, action, entropy) {
107
+ const clientGameAction = (action) => {
108
+ switch (action.type) {
109
+ case "start_game":
110
+ return startGame(action);
111
+ case "explore":
112
+ return explore(action);
113
+ case "attack":
114
+ return attack(action);
115
+ case "flee":
116
+ return flee(action);
117
+ case "buy_items":
118
+ return buyItemsAction(action);
119
+ case "select_stat_upgrades":
120
+ return selectStatUpgrades(action);
121
+ case "equip":
122
+ return equip(action);
123
+ case "drop":
124
+ return drop(action);
125
+ case "surrender":
126
+ return surrender();
127
+ case "surrender_and_claim":
128
+ case "surrender_and_claim_tickets":
129
+ return surrender();
130
+ default:
131
+ return [];
132
+ }
133
+ };
134
+ const startGame = (action) => {
135
+ const settings = action.settings;
136
+ const events = [];
137
+ if (!settings || settings.adventurer.xp === 0) {
138
+ // `action.weapon` is set by block-hash mode, where the same value is encoded into the
139
+ // committed START. START consumes no entropy on chain -- it is fully determined by the
140
+ // weapon -- so pinning that one number is all this branch needs to be exact.
141
+ const weapon = action.weapon ?? STARTER_WEAPONS[Math.floor(Math.random() * STARTER_WEAPONS.length)];
142
+ const beastId = getStarterBeastId(weapon);
143
+ events.push({
144
+ type: "beast",
145
+ // Themed too: the starter beast is the first thing a player sees, so it
146
+ // has to look like the dungeon it opened.
147
+ beast: applyTheme({
148
+ id: beastId,
149
+ baseName: BEAST_NAMES[beastId],
150
+ name: getBeastName(beastId, 1, 0, 0),
151
+ health: STARTER_BEAST_HEALTH,
152
+ level: 1,
153
+ type: getBeastType(beastId),
154
+ tier: getBeastTier(beastId),
155
+ specialPrefix: null,
156
+ specialSuffix: null,
157
+ isCollectable: false,
158
+ traitSeed: BigInt(0),
159
+ }, null),
160
+ });
161
+ events.push({
162
+ type: "adventurer",
163
+ adventurer: {
164
+ health: STARTING_HEALTH - STARTER_BEAST_ATTACK_DAMAGE,
165
+ xp: 0,
166
+ gold: STARTING_GOLD,
167
+ beast_health: STARTER_BEAST_HEALTH,
168
+ stat_upgrades_available: 0,
169
+ stats: {
170
+ strength: 0,
171
+ dexterity: 0,
172
+ vitality: 0,
173
+ intelligence: 0,
174
+ wisdom: 0,
175
+ charisma: 0,
176
+ luck: 0,
177
+ },
178
+ equipment: {
179
+ weapon: { id: weapon, xp: 0 },
180
+ chest: EMPTY_ITEM,
181
+ head: EMPTY_ITEM,
182
+ waist: EMPTY_ITEM,
183
+ foot: EMPTY_ITEM,
184
+ hand: EMPTY_ITEM,
185
+ neck: EMPTY_ITEM,
186
+ ring: EMPTY_ITEM,
187
+ },
188
+ item_specials_salt: 0,
189
+ beast_salt: 0,
190
+ level_salt: 0,
191
+ },
192
+ });
193
+ }
194
+ else {
195
+ // Initialize from editor/trial settings with existing adventurer state
196
+ const adventurer = { ...settings.adventurer, stats: { ...settings.adventurer.stats } };
197
+ const adventurerLevel = calculateLevel(adventurer.xp);
198
+ // Derive seeds like the contract: poseidon(xp, game_seed) → felt_to_two_u64 → (beast_seed, market_seed)
199
+ if (settings.game_seed !== 0) {
200
+ const felt = get_simple_entropy(adventurer.xp, settings.game_seed);
201
+ const { u64_1, u64_2 } = felt_to_two_u64(felt);
202
+ adventurer.beast_salt = Number((u64_1 & 0x1ffffn) | 1n);
203
+ adventurer.level_salt = Number((u64_2 & 0x3fffn) | 1n);
204
+ }
205
+ else {
206
+ adventurer.beast_salt = Math.floor(Math.random() * 0x1ffff) | 1;
207
+ adventurer.level_salt = Math.floor(Math.random() * 0x3fff) | 1;
208
+ }
209
+ events.push({ type: "level_up", level: adventurerLevel });
210
+ if (settings.in_battle) {
211
+ const dungeonId = null;
212
+ if (settings.game_seed !== 0) {
213
+ const beast = applyTheme(getBeastFromSeed(adventurer.xp, settings.game_seed), dungeonId);
214
+ adventurer.beast_health = beast.health;
215
+ events.push({ type: "beast", beast });
216
+ }
217
+ else {
218
+ const generatedBeast = generateBeast(adventurerLevel);
219
+ const beastEvent = applyTheme(beastToBeastEvent(generatedBeast), dungeonId);
220
+ adventurer.beast_health = generatedBeast.health;
221
+ events.push({ type: "beast", beast: beastEvent });
222
+ }
223
+ }
224
+ events.push({ type: "adventurer", adventurer });
225
+ if (settings.bag && settings.bag.length > 0) {
226
+ events.push({ type: "bag", bag: settings.bag });
227
+ }
228
+ }
229
+ return events;
230
+ };
231
+ const explore = (action) => {
232
+ const storeState = state;
233
+ if (!storeState.adventurer)
234
+ return [];
235
+ const adventurer = cloneAdventurer(storeState.adventurer);
236
+ const bag = storeState.bag.map((item) => ({ ...item }));
237
+ const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
238
+ const gameSeed = storeState.gameSettings?.game_seed ?? 0;
239
+ const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
240
+ // Validate
241
+ if (!canExplore(adventurer))
242
+ return [];
243
+ // Derive explore seed like the contract: felt_to_two_u64(entropy) → (explore_seed, market_seed)
244
+ const injectedEntropy = entropy;
245
+ const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
246
+ const useSeeded = rolls !== null;
247
+ const exploreSeed = rolls?.actionSeed ?? 0n;
248
+ const events = [];
249
+ const previousLevel = calculateLevel(adventurer.xp);
250
+ const tillDamage = action.tillDamage ?? 0;
251
+ const tillGold = action.tillGold ?? false;
252
+ let damageTaken = 0;
253
+ let goldFound = false;
254
+ let bagMutated = false;
255
+ // Exploration loop
256
+ while (true) {
257
+ // Determine encounter type: seeded or random
258
+ let exploreResult;
259
+ let rnd = null;
260
+ if (useSeeded && (gameSeedUntilXp === 0 || gameSeedUntilXp > adventurer.xp)) {
261
+ rnd = getRandomness(adventurer.xp, exploreSeed);
262
+ exploreResult = Number(rnd.rnd8 % 3n);
263
+ }
264
+ else {
265
+ exploreResult = Math.floor(Math.random() * 3);
266
+ }
267
+ if (exploreResult === 0) {
268
+ // ── Beast encounter ──
269
+ const adventurerLevel = calculateLevel(adventurer.xp);
270
+ let beastEvent;
271
+ if (rnd && injectedEntropy !== null) {
272
+ // replay.cairo: write beast_salt from the explore seed FIRST, then hash it with the
273
+ // adventurer id and xp to get the beast entropy. The salt is stored state, so getting
274
+ // this order wrong desyncs every later action that re-derives the beast from it (attack,
275
+ // flee, in-battle equip all do).
276
+ adventurer.beast_salt = Number((exploreSeed & 0x1ffffn) | 1n);
277
+ const beastEntropy = getBeastEntropy(adventurer.beast_salt, storeState.gameId ?? "0", adventurer.xp);
278
+ const beast = getBeastFromEntropy(adventurer.xp, beastEntropy);
279
+ adventurer.beast_health = beast.health;
280
+ beastEvent = applyTheme(beast, null);
281
+ }
282
+ else if (rnd) {
283
+ // Seeded beast from explore randomness
284
+ const beast = getBeastFromSeed(adventurer.xp, gameSeed);
285
+ adventurer.beast_health = beast.health;
286
+ beastEvent = applyTheme(beast, null);
287
+ }
288
+ else {
289
+ const generatedBeast = generateBeast(adventurerLevel);
290
+ beastEvent = applyTheme(beastToBeastEvent(generatedBeast), null);
291
+ adventurer.beast_health = generatedBeast.health;
292
+ }
293
+ // Ambush check (wisdom avoids) — capture roll for visualization
294
+ let isAmbushed;
295
+ let ambushRnd;
296
+ if (rnd) {
297
+ ambushRnd = Number(rnd.rnd7);
298
+ const scaledChance = Math.floor((adventurerLevel * ambushRnd) / 255);
299
+ isAmbushed = !(adventurer.stats.wisdom >= adventurerLevel || adventurer.stats.wisdom > scaledChance);
300
+ }
301
+ else {
302
+ const rollResult = abilityBasedAvoidThreatWithRoll(adventurerLevel, adventurer.stats.wisdom);
303
+ isAmbushed = !rollResult.avoided;
304
+ ambushRnd = rollResult.rnd;
305
+ }
306
+ const ambushRollData = {
307
+ roll: ambushRnd,
308
+ threshold: adventurer.stats.wisdom,
309
+ max: adventurerLevel,
310
+ type: "ambush",
311
+ };
312
+ events.push({
313
+ type: "beast",
314
+ beast: beastEvent,
315
+ rollData: ambushRollData,
316
+ });
317
+ if (isAmbushed) {
318
+ // replay.cairo's beast_encounter passes the outer explore rnd5/rnd6 as the ambush's
319
+ // damage location and crit rolls (the beast IDENTITY rnds come from the beast entropy;
320
+ // these two do not).
321
+ const ambushResult = calculateBeastCounterAttack(beastEvent, adventurer, baseDamageReduction, true, rnd && injectedEntropy !== null
322
+ ? { attackLocationRnd: Number(rnd.rnd5), criticalHitRnd: Number(rnd.rnd6) }
323
+ : undefined);
324
+ if (ambushResult.damage > 0) {
325
+ decreaseHealth(adventurer, ambushResult.damage);
326
+ events.push({
327
+ type: "ambush",
328
+ attack: {
329
+ damage: ambushResult.damage,
330
+ location: ambushResult.location,
331
+ critical_hit: ambushResult.critical_hit,
332
+ },
333
+ rollData: ambushRollData,
334
+ });
335
+ }
336
+ }
337
+ // Beast encounter ends the explore loop (adventurer is now in battle)
338
+ }
339
+ else if (exploreResult === 1) {
340
+ // ── Obstacle encounter ──
341
+ const adventurerLevel = calculateLevel(adventurer.xp);
342
+ // replay.cairo: obstacle_encounter(seed = rnd1, level_rnd = rnd4, damage_location = rnd5,
343
+ // crit = rnd6, dodge = rnd7, item_specials = rnd3).
344
+ const seededObstacle = rnd !== null && injectedEntropy !== null;
345
+ const obstacle = generateObstacle(adventurerLevel, seededObstacle
346
+ ? { idRnd: Number(rnd.rnd1), levelRnd: Number(rnd.rnd4) }
347
+ : undefined);
348
+ // Dodge check (intelligence avoids) — capture roll for visualization
349
+ let dodged;
350
+ let dodgeRnd;
351
+ if (rnd) {
352
+ dodgeRnd = Number(rnd.rnd7);
353
+ const scaledChance = Math.floor((adventurerLevel * dodgeRnd) / 255);
354
+ dodged =
355
+ adventurer.stats.intelligence >= adventurerLevel ||
356
+ adventurer.stats.intelligence > scaledChance;
357
+ }
358
+ else {
359
+ const rollResult = abilityBasedAvoidThreatWithRoll(adventurerLevel, adventurer.stats.intelligence);
360
+ dodged = rollResult.avoided;
361
+ dodgeRnd = rollResult.rnd;
362
+ }
363
+ const obstacleRolls = seededObstacle
364
+ ? { attackLocationRnd: Number(rnd.rnd5), criticalHitRnd: Number(rnd.rnd6) }
365
+ : undefined;
366
+ const slot = getAttackLocation(obstacleRolls?.attackLocationRnd);
367
+ const armor = getArmorAtSlot(adventurer, slot);
368
+ const obstacleResult = calculateObstacleDamage(obstacle, armor, adventurerLevel, baseDamageReduction, obstacleRolls);
369
+ const damage = dodged ? 0 : obstacleResult.damage;
370
+ if (!dodged && damage > 0) {
371
+ decreaseHealth(adventurer, damage);
372
+ damageTaken += damage;
373
+ }
374
+ const xpReward = getBaseXpReward(obstacle.tier, obstacle.level, adventurerLevel);
375
+ events.push({
376
+ type: "obstacle",
377
+ xp_reward: xpReward,
378
+ obstacle: {
379
+ id: obstacle.id,
380
+ dodged,
381
+ damage: dodged ? 0 : obstacleResult.damage,
382
+ location: getSlotDisplayName(slot),
383
+ critical_hit: obstacleResult.critical_hit,
384
+ },
385
+ rollData: {
386
+ roll: dodgeRnd,
387
+ threshold: adventurer.stats.intelligence,
388
+ max: adventurerLevel,
389
+ type: "dodge",
390
+ },
391
+ });
392
+ // Grant XP if alive
393
+ if (adventurer.health > 0) {
394
+ increaseXp(adventurer, xpReward);
395
+ // Grant item XP to equipped items. replay.cairo's obstacle_encounter passes the explore
396
+ // rnd3 as the item-specials roll.
397
+ const itemXp = xpReward * ITEM_XP_MULTIPLIER_OBSTACLES;
398
+ grantXpToEquippedItems(adventurer, itemXp, storeState.gameId ?? "0", seededObstacle ? Number(rnd.rnd3) : undefined);
399
+ }
400
+ }
401
+ else {
402
+ // ── Discovery ──
403
+ const adventurerLevel = calculateLevel(adventurer.xp);
404
+ // replay.cairo: get_discovery(level, rnd5, rnd6, rnd7).
405
+ const discovery = getDiscovery(adventurerLevel, rnd && injectedEntropy !== null
406
+ ? {
407
+ typeRnd: Number(rnd.rnd5),
408
+ amountRnd1: Number(rnd.rnd6),
409
+ amountRnd2: Number(rnd.rnd7),
410
+ }
411
+ : undefined);
412
+ let lootConversion = null;
413
+ if (discovery.type === "Gold") {
414
+ increaseGold(adventurer, discovery.amount);
415
+ goldFound = true;
416
+ }
417
+ else if (discovery.type === "Health") {
418
+ increaseHealth(adventurer, discovery.amount);
419
+ }
420
+ else if (discovery.type === "Loot") {
421
+ lootConversion = processLootDiscovery(adventurer, bag, discovery.itemId);
422
+ if (lootConversion) {
423
+ increaseGold(adventurer, lootConversion.gold);
424
+ goldFound = true;
425
+ }
426
+ else {
427
+ bagMutated = true;
428
+ }
429
+ }
430
+ // Discoveries grant 1 XP
431
+ increaseXp(adventurer, XP_FOR_DISCOVERIES);
432
+ events.push({
433
+ type: "discovery",
434
+ discovery: {
435
+ type: discovery.type,
436
+ amount: discovery.type === "Loot" ? discovery.itemId : discovery.amount,
437
+ ...(lootConversion ? { goldConversion: lootConversion } : {}),
438
+ },
439
+ xp_reward: XP_FOR_DISCOVERIES,
440
+ });
441
+ }
442
+ // Check loop exit conditions
443
+ if (!canExplore(adventurer))
444
+ break;
445
+ if (tillDamage === 0 && !tillGold)
446
+ break; // single explore
447
+ if (tillDamage > 0 && damageTaken >= tillDamage)
448
+ break;
449
+ if (tillGold && goldFound)
450
+ break;
451
+ }
452
+ // Level up check
453
+ const newLevel = calculateLevel(adventurer.xp);
454
+ if (newLevel > previousLevel) {
455
+ adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
456
+ events.push({ type: "level_up", level: newLevel });
457
+ }
458
+ // Bag event if mutated
459
+ if (bagMutated) {
460
+ events.push({ type: "bag", bag });
461
+ }
462
+ // Always emit final adventurer state
463
+ events.push({ type: "adventurer", adventurer });
464
+ return events;
465
+ };
466
+ const attack = (action) => {
467
+ const storeState = state;
468
+ if (!storeState.adventurer || !storeState.beast)
469
+ return [];
470
+ const adventurer = cloneAdventurer(storeState.adventurer);
471
+ const beast = storeState.beast;
472
+ const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
473
+ const gameSeed = storeState.gameSettings?.game_seed ?? 0;
474
+ const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
475
+ // Validate
476
+ if (adventurer.health === 0)
477
+ return [];
478
+ if (adventurer.beast_health <= 0)
479
+ return [];
480
+ const injectedEntropy = entropy;
481
+ const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
482
+ // Battle rolls are only ever seeded from injected entropy. A settings `game_seed` game leaves
483
+ // combat random exactly as it does today -- its outcomes get corrected by the chain events the
484
+ // action returns, so there is nothing here to fix and plenty to break.
485
+ const battleSeed = injectedEntropy !== null ? (rolls?.actionSeed ?? null) : null;
486
+ const events = [];
487
+ const previousLevel = calculateLevel(adventurer.xp);
488
+ const tillHealth = action.tillHealth ?? 0;
489
+ const tillBeastHealth = action.tillBeastHealth ?? 0;
490
+ // Precompute adventurer damage
491
+ const { normal: normalDamage, crit: critDamage } = calculateAdventurerAttackDamage(adventurer, beast);
492
+ // Battle loop
493
+ let battleCount = 0;
494
+ while (true) {
495
+ battleCount += 1;
496
+ // replay.cairo: one get_battle_randomness draw per round, keyed on the CURRENT xp and the
497
+ // round number. xp only moves when the beast dies (which ends the loop), so in practice the
498
+ // round number is what advances it -- but it is re-derived every round regardless, because
499
+ // that is what the contract does.
500
+ const round = battleSeed !== null ? battleRolls(adventurer.xp, battleCount, battleSeed) : null;
501
+ const isCrit = isCriticalHit(adventurer.stats.luck, round?.adventurerCritRnd);
502
+ const adventurerDamage = isCrit ? critDamage : normalDamage;
503
+ events.push({
504
+ type: "attack",
505
+ attack: {
506
+ damage: adventurerDamage,
507
+ location: "None",
508
+ critical_hit: isCrit,
509
+ },
510
+ });
511
+ if (adventurerDamage >= adventurer.beast_health) {
512
+ // Beast dies
513
+ adventurer.beast_health = 0;
514
+ const goldReward = calculateGoldReward(beast, adventurer.equipment.ring);
515
+ increaseGold(adventurer, goldReward);
516
+ const adventurerLevel = calculateLevel(adventurer.xp);
517
+ const xpReward = getBaseXpReward(beast.tier, beast.level, adventurerLevel);
518
+ const prevLevel = calculateLevel(adventurer.xp);
519
+ increaseXp(adventurer, xpReward);
520
+ // Item XP. process_beast_death seeds any first-time specials unlock from the adventurer's
521
+ // OWN item_specials_seed, not from a fresh roll.
522
+ const itemXp = xpReward * ITEM_XP_MULTIPLIER_BEASTS;
523
+ grantXpToEquippedItems(adventurer, itemXp, storeState.gameId ?? "0", injectedEntropy !== null
524
+ ? ItemUtils.getItemSpecialsSeed(adventurer.item_specials_salt, storeState.gameId ?? "0")
525
+ : undefined);
526
+ // Level 1→2 transition: generate starting stats — only for games that
527
+ // genuinely started at xp 0 (fresh starter-beast run). Settings that seed
528
+ // the adventurer at xp >= 1 already have their starting stats applied.
529
+ const startedFresh = (storeState.gameSettings?.adventurer?.xp ?? 0) === 0;
530
+ const newLevel = calculateLevel(adventurer.xp);
531
+ if (startedFresh && prevLevel === 1 && newLevel >= 2) {
532
+ // process_beast_death seeds this from the BATTLE seed (not the market seed).
533
+ const startingStats = generateStartingStats(battleSeed ?? undefined);
534
+ adventurer.stats.strength += startingStats.strength;
535
+ adventurer.stats.dexterity += startingStats.dexterity;
536
+ adventurer.stats.vitality += startingStats.vitality;
537
+ adventurer.stats.intelligence += startingStats.intelligence;
538
+ adventurer.stats.wisdom += startingStats.wisdom;
539
+ adventurer.stats.charisma += startingStats.charisma;
540
+ adventurer.stats.luck = Math.max(2, Math.min(100, adventurer.stats.luck));
541
+ // Health boost from new vitality
542
+ increaseHealth(adventurer, HEALTH_INCREASE_PER_VITALITY * startingStats.vitality);
543
+ }
544
+ events.push({
545
+ type: "defeated_beast",
546
+ beast_id: beast.id,
547
+ gold_reward: goldReward,
548
+ xp_reward: xpReward,
549
+ });
550
+ break;
551
+ }
552
+ else {
553
+ // Beast survives, deduct damage
554
+ adventurer.beast_health -= adventurerDamage;
555
+ // Beast counter-attack
556
+ const counterAttack = calculateBeastCounterAttack(beast, adventurer, baseDamageReduction, false, round
557
+ ? {
558
+ attackLocationRnd: round.attackLocationRnd,
559
+ criticalHitRnd: round.beastCritRnd,
560
+ }
561
+ : undefined);
562
+ decreaseHealth(adventurer, counterAttack.damage);
563
+ events.push({
564
+ type: "beast_attack",
565
+ attack: {
566
+ damage: counterAttack.damage,
567
+ location: counterAttack.location,
568
+ critical_hit: counterAttack.critical_hit,
569
+ },
570
+ });
571
+ // Check break conditions
572
+ if (adventurer.health === 0)
573
+ break;
574
+ if (adventurer.health <= tillHealth || adventurer.beast_health <= tillBeastHealth)
575
+ break;
576
+ }
577
+ }
578
+ // Level up check
579
+ const newLevel = calculateLevel(adventurer.xp);
580
+ if (newLevel > previousLevel) {
581
+ adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
582
+ events.push({ type: "level_up", level: newLevel });
583
+ }
584
+ // Always emit final adventurer state
585
+ events.push({ type: "adventurer", adventurer });
586
+ return events;
587
+ };
588
+ const flee = (action) => {
589
+ const storeState = state;
590
+ if (!storeState.adventurer || !storeState.beast)
591
+ return [];
592
+ const adventurer = cloneAdventurer(storeState.adventurer);
593
+ const beast = storeState.beast;
594
+ const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
595
+ const gameSeed = storeState.gameSettings?.game_seed ?? 0;
596
+ const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
597
+ // Validate
598
+ if (adventurer.health === 0)
599
+ return [];
600
+ if (adventurer.beast_health <= 0)
601
+ return [];
602
+ const adventurerLevel = calculateLevel(adventurer.xp);
603
+ if (adventurerLevel <= 1)
604
+ return []; // Can't flee starter beast
605
+ if (adventurer.stats.dexterity === 0)
606
+ return [];
607
+ const injectedEntropy = entropy;
608
+ const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
609
+ const fleeSeed = injectedEntropy !== null ? (rolls?.actionSeed ?? null) : null;
610
+ const updateLevelSalt = () => {
611
+ adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
612
+ };
613
+ const events = [];
614
+ const previousLevel = adventurerLevel;
615
+ const tillHealth = action.tillHealth ?? 0;
616
+ // Guaranteed flee if dexterity >= level
617
+ if (adventurer.stats.dexterity >= adventurerLevel) {
618
+ adventurer.beast_health = 0;
619
+ increaseXp(adventurer, 1);
620
+ events.push({ type: "flee", success: true });
621
+ events.push({ type: "fled_beast", beast_id: beast.id, xp_reward: 1 });
622
+ const newLevel = calculateLevel(adventurer.xp);
623
+ if (newLevel > previousLevel) {
624
+ updateLevelSalt();
625
+ events.push({ type: "level_up", level: newLevel });
626
+ }
627
+ events.push({ type: "adventurer", adventurer });
628
+ return events;
629
+ }
630
+ // Flee loop
631
+ let battleCount = 0;
632
+ while (true) {
633
+ battleCount += 1;
634
+ // replay.cairo's flee loop reads the same get_battle_randomness draw as attack, but takes
635
+ // rnd1 as the flee roll instead of rnd2 as the adventurer crit roll.
636
+ const round = fleeSeed !== null ? battleRolls(adventurer.xp, battleCount, fleeSeed) : null;
637
+ const fleeResult = abilityBasedAvoidThreatWithRoll(adventurerLevel, adventurer.stats.dexterity, round?.fleeRnd);
638
+ const fleeRollData = {
639
+ roll: fleeResult.rnd,
640
+ threshold: adventurer.stats.dexterity,
641
+ max: adventurerLevel,
642
+ type: "flee",
643
+ };
644
+ if (fleeResult.avoided) {
645
+ adventurer.beast_health = 0;
646
+ increaseXp(adventurer, 1);
647
+ events.push({ type: "flee", success: true, rollData: fleeRollData });
648
+ events.push({ type: "fled_beast", beast_id: beast.id, xp_reward: 1 });
649
+ break;
650
+ }
651
+ else {
652
+ // Failed flee — beast counter-attacks
653
+ const counterAttack = calculateBeastCounterAttack(beast, adventurer, baseDamageReduction, false, round
654
+ ? {
655
+ attackLocationRnd: round.attackLocationRnd,
656
+ criticalHitRnd: round.beastCritRnd,
657
+ }
658
+ : undefined);
659
+ decreaseHealth(adventurer, counterAttack.damage);
660
+ events.push({ type: "flee", success: false, rollData: fleeRollData });
661
+ events.push({
662
+ type: "beast_attack",
663
+ attack: {
664
+ damage: counterAttack.damage,
665
+ location: counterAttack.location,
666
+ critical_hit: counterAttack.critical_hit,
667
+ },
668
+ });
669
+ if (adventurer.health === 0)
670
+ break;
671
+ if (adventurer.health <= tillHealth)
672
+ break;
673
+ }
674
+ }
675
+ // Level up check
676
+ const newLevel = calculateLevel(adventurer.xp);
677
+ if (newLevel > previousLevel) {
678
+ updateLevelSalt();
679
+ events.push({ type: "level_up", level: newLevel });
680
+ }
681
+ // Always emit final adventurer state
682
+ events.push({ type: "adventurer", adventurer });
683
+ return events;
684
+ };
685
+ const buyItemsAction = (action) => {
686
+ const storeState = state;
687
+ if (!storeState.adventurer)
688
+ return [];
689
+ const adventurer = cloneAdventurer(storeState.adventurer);
690
+ const bag = storeState.bag.map((item) => ({ ...item }));
691
+ const itemPurchases = action.itemPurchases ?? [];
692
+ const potions = action.potions ?? 0;
693
+ // Validate
694
+ if (adventurer.health === 0)
695
+ return [];
696
+ if (adventurer.beast_health > 0)
697
+ return [];
698
+ if (adventurer.stat_upgrades_available > 0)
699
+ return [];
700
+ const events = [];
701
+ // Gold is pre-calculated by the caller
702
+ if (action.remainingGold !== undefined) {
703
+ adventurer.gold = action.remainingGold;
704
+ }
705
+ // Add non-equipped items to bag
706
+ for (const purchase of itemPurchases) {
707
+ if (!purchase.equip) {
708
+ bag.push({ id: purchase.item_id, xp: 0 });
709
+ }
710
+ }
711
+ // Equip purchased items (handles displaced items → bag + stat boosts)
712
+ for (const purchase of itemPurchases) {
713
+ if (purchase.equip) {
714
+ equipItem(adventurer, bag, purchase.item_id);
715
+ }
716
+ }
717
+ // Buy potions
718
+ if (potions > 0) {
719
+ increaseHealth(adventurer, POTION_HEALTH_AMOUNT * potions);
720
+ }
721
+ events.push({ type: "buy_items", potions, items_purchased: itemPurchases });
722
+ events.push({ type: "bag", bag });
723
+ events.push({ type: "adventurer", adventurer });
724
+ return events;
725
+ };
726
+ const selectStatUpgrades = (action) => {
727
+ const storeState = state;
728
+ if (!storeState.adventurer)
729
+ return [];
730
+ const adventurer = cloneAdventurer(storeState.adventurer);
731
+ const stats = action.statUpgrades;
732
+ // Validate
733
+ if (adventurer.health === 0)
734
+ return [];
735
+ if (adventurer.stat_upgrades_available === 0)
736
+ return [];
737
+ const events = [];
738
+ // Apply stats
739
+ adventurer.stats.strength += stats.strength;
740
+ adventurer.stats.dexterity += stats.dexterity;
741
+ adventurer.stats.vitality += stats.vitality;
742
+ adventurer.stats.intelligence += stats.intelligence;
743
+ adventurer.stats.wisdom += stats.wisdom;
744
+ adventurer.stats.charisma += stats.charisma;
745
+ // Vitality health boost (contract: apply_vitality_health_boost)
746
+ if (stats.vitality > 0) {
747
+ increaseHealth(adventurer, HEALTH_INCREASE_PER_VITALITY * stats.vitality);
748
+ }
749
+ adventurer.stat_upgrades_available = 0;
750
+ events.push({ type: "stat_upgrade", stats });
751
+ events.push({ type: "adventurer", adventurer });
752
+ return events;
753
+ };
754
+ const surrender = () => {
755
+ const storeState = state;
756
+ if (!storeState.adventurer)
757
+ return [];
758
+ const adventurer = cloneAdventurer(storeState.adventurer);
759
+ // Validate
760
+ if (adventurer.health === 0)
761
+ return [];
762
+ if (adventurer.beast_health > 0)
763
+ return [];
764
+ if (adventurer.xp === 0)
765
+ return [];
766
+ adventurer.health = 0;
767
+ adventurer.beast_salt = 0;
768
+ adventurer.level_salt = 0;
769
+ return [{ type: "adventurer", adventurer }];
770
+ };
771
+ const equip = (action) => {
772
+ const storeState = state;
773
+ if (!storeState.adventurer || !storeState.adventurerState)
774
+ return [];
775
+ // Derive items from store state if not explicitly passed (matches GameDirector on-chain path)
776
+ const items = action.items ??
777
+ getNewItemsEquipped(storeState.adventurer.equipment, storeState.adventurerState.equipment).map((item) => item.id);
778
+ // The client's store applies the equip optimistically before this runs, so its
779
+ // `adventurer.equipment` and `bag` are already correct. The off-chain replayer holds the
780
+ // pre-action state instead and must do the move itself -- see `equipmentApplied`.
781
+ const adventurer = cloneAdventurer(storeState.adventurer);
782
+ const bag = storeState.bag.map((item) => ({ ...item }));
783
+ const beast = storeState.beast;
784
+ const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
785
+ // Validate
786
+ if (adventurer.health === 0)
787
+ return [];
788
+ if (items.length === 0 || items.length > 8)
789
+ return [];
790
+ const events = [];
791
+ // Equip BEFORE the counter-attack: `Inventory::equip` calls `equip_items` first and only
792
+ // then lets the beast swing, so the beast hits the NEW armour. The client gets this for
793
+ // free because its store already moved the items; the replayer has to move them here, and
794
+ // moving them after the counter-attack would quietly under-armour every in-battle equip.
795
+ if (storeState.equipmentApplied === false) {
796
+ for (const itemId of items) {
797
+ equipItem(adventurer, bag, itemId);
798
+ }
799
+ }
800
+ // If in battle, beast counter-attacks (mimics contract: randomness → beast_attack → equip)
801
+ if (adventurer.beast_health > 0 && beast) {
802
+ // replay_equip draws get_battle_randomness(xp, 0, equip_seed) -- battle count ZERO, unlike
803
+ // attack and flee which start at 1 -- and takes rnd3/rnd4 as the beast crit and location.
804
+ const injectedEntropy = entropy;
805
+ const equipSeed = injectedEntropy !== null ? felt_to_two_u64(injectedEntropy).u64_1 : null;
806
+ const round = equipSeed !== null ? battleRolls(adventurer.xp, 0, equipSeed) : null;
807
+ const counterAttack = calculateBeastCounterAttack(beast, adventurer, baseDamageReduction, false, round
808
+ ? {
809
+ attackLocationRnd: round.attackLocationRnd,
810
+ criticalHitRnd: round.beastCritRnd,
811
+ }
812
+ : undefined);
813
+ decreaseHealth(adventurer, counterAttack.damage);
814
+ events.push({
815
+ type: "beast_attack",
816
+ attack: {
817
+ damage: counterAttack.damage,
818
+ location: counterAttack.location,
819
+ critical_hit: counterAttack.critical_hit,
820
+ },
821
+ });
822
+ }
823
+ events.push({ type: "equip", items });
824
+ events.push({ type: "bag", bag });
825
+ events.push({ type: "adventurer", adventurer });
826
+ return events;
827
+ };
828
+ const drop = (action) => {
829
+ const storeState = state;
830
+ if (!storeState.adventurer)
831
+ return [];
832
+ const adventurer = cloneAdventurer(storeState.adventurer);
833
+ const bag = storeState.bag.map((item) => ({ ...item }));
834
+ const items = action.items ?? [];
835
+ // Validate
836
+ if (adventurer.health === 0)
837
+ return [];
838
+ if (adventurer.beast_health > 0)
839
+ return []; // Can't drop in battle
840
+ if (calculateLevel(adventurer.xp) <= 1)
841
+ return []; // Can't drop during starter beast
842
+ if (items.length === 0)
843
+ return [];
844
+ const events = [];
845
+ // Drop each item
846
+ for (const itemId of items) {
847
+ dropItem(adventurer, bag, itemId);
848
+ }
849
+ events.push({ type: "drop", items });
850
+ events.push({ type: "bag", bag });
851
+ events.push({ type: "adventurer", adventurer });
852
+ return events;
853
+ };
854
+ return clientGameAction(action);
855
+ }
856
+ //# sourceMappingURL=replay.js.map