@provable-games/dm-engine 0.1.0 → 0.3.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.
package/dist/replay.js CHANGED
@@ -1,49 +1,122 @@
1
- import { BEAST_NAMES, STARTER_BEAST_ATTACK_DAMAGE } from "./constants/beast.js";
1
+ import { actionSeed as deriveActionSeed } from "./protocol.js";
2
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
3
  import { calculateGoldReward, calculateLevel, getNewItemsEquipped } from "./rules/game.js";
5
4
  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;
5
+ import { getMaxHealth } from "./rules/health.js";
6
+ import { getLevelSeed } from "./rules/market.js";
7
+ import { felt_to_two_u64, generateStartingStatsFromSeed, get_simple_entropy, getBattleRandomness, getBeastEntropy, getBeastFromEntropy, getBeastFromSeed, getRandomness, } from "./rules/processFutures.js";
9
8
  const EMPTY_ITEM = { id: 0, xp: 0 };
10
9
  /**
11
- * Resolve which randomness mode this action runs in.
10
+ * The three randomness modes, mirroring `utils/randomness.cairo::RandomnessMode`.
12
11
  *
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
12
+ * A mode chooses where the NON-combat stream comes from. Combat is never on this dial: `attack`,
13
+ * `flee` and `equip` always roll from the block hash, in every mode.
14
+ */
15
+ export const RandomnessMode = {
16
+ /** Every action rolls from the hash of the block that resolves it. */
17
+ FULL: 0,
18
+ /** Non-combat rolls expand `game_seed`; the encounter table is fixed before the run starts. */
19
+ SEEDED: 1,
20
+ /** Non-combat rolls expand the current `level_salt`, itself re-rolled live on every level-up. */
21
+ PER_LEVEL: 2,
22
+ };
23
+ /** `ImplAdventurer::nonzero_salt` -- mask, keeping zero (the never-rolled sentinel) out. */
24
+ export function nonzeroSalt(value, mask) {
25
+ const masked = value & mask;
26
+ return Number(masked === 0n ? 1n : masked);
27
+ }
28
+ /**
29
+ * The `SEEDED` stream: an expansion of `game_seed`, blind to the chain.
30
+ *
31
+ * `adventurerId` is deliberately absent -- every adventurer on the settings entry walks the
32
+ * identical dungeon, which is what makes the mode useful for fixtures and tournaments.
33
+ */
34
+ function seededRolls(adventurer, gameSeed) {
35
+ if (gameSeed === 0)
36
+ return null;
37
+ const { u64_1, u64_2 } = felt_to_two_u64(get_simple_entropy(adventurer.xp, gameSeed));
38
+ return { actionSeed: u64_1, marketSeed: u64_2 };
39
+ }
40
+ /**
41
+ * The `PER_LEVEL` stream: an expansion of the current level's salt.
42
+ *
43
+ * `getLevelSeed` folds `adventurerId` in, so two adventurers holding the same 14-bit salt still
44
+ * run different levels.
45
+ */
46
+ function perLevelRolls(adventurerId, adventurer) {
47
+ const levelSeed = getLevelSeed(adventurer.level_salt, BigInt(adventurerId ?? "0"));
48
+ const { u64_1, u64_2 } = felt_to_two_u64(get_simple_entropy(adventurer.xp, levelSeed));
49
+ return { actionSeed: u64_1, marketSeed: u64_2 };
50
+ }
51
+ /**
52
+ * The always-live stream: the block hash mixed with the adventurer's own state.
17
53
  *
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.
54
+ * The only one of the three that mixes `health`. Neither seeded stream does, deliberately --
55
+ * health is a product of live combat, so folding it in would make a seeded or per-level encounter
56
+ * table follow the fight.
20
57
  */
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 };
58
+ function liveRolls(blockHash, gameId, adventurer) {
59
+ if (blockHash === null)
60
+ return null;
61
+ const seed = deriveActionSeed(blockHash, BigInt(gameId ?? "0"), adventurer.xp, adventurer.health, adventurer.beast_health > 0);
62
+ const { u64_1, u64_2 } = felt_to_two_u64(seed);
63
+ return { actionSeed: u64_1, marketSeed: u64_2 };
64
+ }
65
+ /**
66
+ * `randomness::get_action_seeds` -- one block per mode, matching the Cairo split.
67
+ *
68
+ * FULL combat: live / live explore: live / live
69
+ * SEEDED combat: live / game_seed explore: game_seed / game_seed
70
+ * PER_LEVEL combat: live / live explore: level_salt / live
71
+ *
72
+ * (`actionSeed` / `marketSeed` -- the second is what `level_salt` is re-rolled from on a level-up.)
73
+ *
74
+ * `inCombat` is the combat carve-out: `attack`, `flee` and `equip` pass true and take `actionSeed`
75
+ * from the block hash whatever the mode says. It is also what keeps repeated fights apart -- a
76
+ * bundle shares one block hash, so the state term is the only thing separating its actions, and
77
+ * only `liveRolls` mixes `health`. Combat is the one thing that can act twice at the same xp.
78
+ *
79
+ * Returning `null` means neither stream was available (no block hash, no usable seed), and every
80
+ * caller keeps its pre-existing unseeded branch for that case.
81
+ */
82
+ function resolveActionRolls(blockHash, gameId, adventurer, gameSeed, mode, inCombat) {
83
+ if (mode === RandomnessMode.SEEDED) {
84
+ // The reseed stays on the seeded stream even for combat, or a mid-fight level-up would make
85
+ // the market after it unreproducible from `game_seed`.
86
+ const seeded = seededRolls(adventurer, gameSeed);
87
+ if (!seeded)
88
+ return null;
89
+ if (!inCombat)
90
+ return seeded;
91
+ const live = liveRolls(blockHash, gameId, adventurer);
92
+ if (!live)
93
+ return null;
94
+ return { actionSeed: live.actionSeed, marketSeed: seeded.marketSeed };
25
95
  }
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 };
96
+ if (mode === RandomnessMode.PER_LEVEL) {
97
+ // The reseed is live on BOTH paths, and that live draw IS the mode: the level-up is the one
98
+ // unpredictable moment in a per-level run.
99
+ const live = liveRolls(blockHash, gameId, adventurer);
100
+ if (!live)
101
+ return null;
102
+ if (inCombat)
103
+ return live;
104
+ const level = perLevelRolls(gameId, adventurer);
105
+ return { actionSeed: level.actionSeed, marketSeed: live.marketSeed };
29
106
  }
30
- return null;
107
+ // FULL, and the fallback for any value the contract's validation would have rejected. There is
108
+ // no second stream, so `inCombat` cannot change the answer.
109
+ return liveRolls(blockHash, gameId, adventurer);
31
110
  }
32
111
  /**
33
112
  * `level_salt` after a level-up.
34
113
  *
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.
114
+ * `rolls.marketSeed` already carries the mode's reseed decision (see `resolveActionRolls`), so
115
+ * this only has to truncate it. The `Math.random` tail is the pre-existing unseeded fallback.
38
116
  */
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
- }
117
+ function levelSaltAfterLevelUp(rolls) {
118
+ if (rolls)
119
+ return nonzeroSalt(rolls.marketSeed, 0x3fffn);
47
120
  return Math.floor(Math.random() * 0x3fff) | 1;
48
121
  }
49
122
  /** The four `get_battle_randomness` bytes for one round, as `beast_attack` consumes them. */
@@ -56,15 +129,6 @@ function battleRolls(xp, battleCount, seed) {
56
129
  attackLocationRnd: Number(rnd.rnd4),
57
130
  };
58
131
  }
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
132
  function cloneAdventurer(adv) {
69
133
  return {
70
134
  ...adv,
@@ -96,12 +160,14 @@ function applyTheme(beast, _dungeonId) {
96
160
  * Apply one action to `state` and return the events it produced.
97
161
  *
98
162
  * 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
163
+ * VRF reads replaced by `entropy`. It is pure: no store, no RPC, no `Date`, and
100
164
  * -- when `entropy` is non-null -- no `Math.random()`. That last property is what lets the
101
165
  * client and the off-chain replayer agree, and it is asserted by the entropy test suite.
102
166
  *
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()`.
167
+ * `entropy` is the RAW block hash that resolves this action for a commit-reveal game, or `null`
168
+ * for a legacy dungeon, where the handlers fall back to a settings `game_seed` or `Math.random()`.
169
+ * It is not the seed: `resolveActionRolls` mixes it with the adventurer's own state, which is what
170
+ * separates two actions that were committed in the same block and therefore share a hash.
105
171
  */
106
172
  export function applyAction(state, action, entropy) {
107
173
  const clientGameAction = (action) => {
@@ -131,101 +197,108 @@ export function applyAction(state, action, entropy) {
131
197
  return [];
132
198
  }
133
199
  };
200
+ /**
201
+ * `Settlement::start` -- an adventurer initializer, and nothing else.
202
+ *
203
+ * The settings entry IS the starting state. There is no starter beast and no
204
+ * default-versus-custom branch: whatever `settings.adventurer` holds is what the run begins
205
+ * with, `settings.in_battle` decides whether a beast is already waiting, and
206
+ * `settings.random_starting_stats` deals points on top of the baseline.
207
+ *
208
+ * The one thing the player still chooses is the weapon, and only when the settings entry
209
+ * leaves the slot open.
210
+ */
134
211
  const startGame = (action) => {
135
- const settings = action.settings;
212
+ // The contract reads the settings entry from storage, not from the action. `action.settings`
213
+ // is a client-side convenience for previews; the replay path carries them on the state.
214
+ const settings = action.settings ?? state.gameSettings;
136
215
  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,
216
+ const base = settings?.adventurer;
217
+ const adventurer = base
218
+ ? cloneAdventurer(base)
219
+ : {
220
+ health: STARTING_HEALTH,
221
+ xp: 0,
222
+ gold: STARTING_GOLD,
223
+ beast_health: 0,
224
+ stat_upgrades_available: 0,
225
+ stats: {
226
+ strength: 0,
227
+ dexterity: 0,
228
+ vitality: 0,
229
+ intelligence: 0,
230
+ wisdom: 0,
231
+ charisma: 0,
232
+ luck: 0,
191
233
  },
192
- });
234
+ equipment: {
235
+ weapon: EMPTY_ITEM,
236
+ chest: EMPTY_ITEM,
237
+ head: EMPTY_ITEM,
238
+ waist: EMPTY_ITEM,
239
+ foot: EMPTY_ITEM,
240
+ hand: EMPTY_ITEM,
241
+ neck: EMPTY_ITEM,
242
+ ring: EMPTY_ITEM,
243
+ },
244
+ item_specials_salt: 0,
245
+ beast_salt: 0,
246
+ level_salt: 0,
247
+ };
248
+ // An empty weapon slot is the settings entry deferring to the player.
249
+ if (!adventurer.equipment.weapon.id) {
250
+ const weapon = action.weapon ?? STARTER_WEAPONS[Math.floor(Math.random() * STARTER_WEAPONS.length)];
251
+ adventurer.equipment.weapon = { id: weapon, xp: 0 };
252
+ }
253
+ // The contract splits the raw START block hash: the low half deals the random stats and the
254
+ // beast salt, the high half becomes `level_salt`. Without an injected hash this is a client
255
+ // preview, so it falls back to Math.random and gets corrected by the chain events later.
256
+ const seeded = entropy !== null;
257
+ const { u64_1: beastSeed, u64_2: marketSeed } = seeded
258
+ ? felt_to_two_u64(entropy)
259
+ : { u64_1: 0n, u64_2: 0n };
260
+ const randomStats = settings?.random_starting_stats ?? 0;
261
+ if (randomStats > 0) {
262
+ const healthBefore = getMaxHealth(adventurer.stats.vitality);
263
+ const dealt = seeded
264
+ ? generateStartingStatsFromSeed(beastSeed, randomStats)
265
+ : generateStartingStats(undefined, randomStats);
266
+ adventurer.stats.strength += dealt.strength;
267
+ adventurer.stats.dexterity += dealt.dexterity;
268
+ adventurer.stats.vitality += dealt.vitality;
269
+ adventurer.stats.intelligence += dealt.intelligence;
270
+ adventurer.stats.wisdom += dealt.wisdom;
271
+ adventurer.stats.charisma += dealt.charisma;
272
+ // Vitality among the points raises max health, and the adventurer is handed it rather than
273
+ // starting the run already wounded.
274
+ const gained = getMaxHealth(adventurer.stats.vitality) - healthBefore;
275
+ adventurer.health = Math.min(adventurer.health + gained, getMaxHealth(adventurer.stats.vitality));
193
276
  }
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);
277
+ // A zero `level_salt` makes `getLevelSeed` return 0, which under PER_LEVEL would give every
278
+ // adventurer an identical level 1.
279
+ adventurer.level_salt = seeded
280
+ ? nonzeroSalt(marketSeed, 0x3fffn)
281
+ : Math.floor(Math.random() * 0x3fff) | 1;
282
+ events.push({ type: "level_up", level: calculateLevel(adventurer.xp) });
283
+ if (settings?.in_battle) {
284
+ if (seeded) {
285
+ adventurer.beast_salt = nonzeroSalt(beastSeed, 0x1ffffn);
286
+ const beastEntropy = getBeastEntropy(adventurer.beast_salt, BigInt(state.gameId ?? "0"), adventurer.xp);
287
+ const beast = applyTheme(getBeastFromEntropy(adventurer.xp, beastEntropy), null);
288
+ adventurer.beast_health = beast.health;
289
+ events.push({ type: "beast", beast });
204
290
  }
205
291
  else {
206
292
  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 });
293
+ const generated = generateBeast(calculateLevel(adventurer.xp));
294
+ adventurer.beast_health = generated.health;
295
+ events.push({ type: "beast", beast: applyTheme(beastToBeastEvent(generated), null) });
227
296
  }
228
297
  }
298
+ events.push({ type: "adventurer", adventurer });
299
+ if (settings?.bag && settings.bag.length > 0) {
300
+ events.push({ type: "bag", bag: settings.bag });
301
+ }
229
302
  return events;
230
303
  };
231
304
  const explore = (action) => {
@@ -236,13 +309,16 @@ export function applyAction(state, action, entropy) {
236
309
  const bag = storeState.bag.map((item) => ({ ...item }));
237
310
  const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
238
311
  const gameSeed = storeState.gameSettings?.game_seed ?? 0;
239
- const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
312
+ const randomnessMode = storeState.gameSettings?.randomness_mode ?? RandomnessMode.FULL;
240
313
  // Validate
241
314
  if (!canExplore(adventurer))
242
315
  return [];
243
- // Derive explore seed like the contract: felt_to_two_u64(entropy) (explore_seed, market_seed)
316
+ // Derive the explore seed like the contract: mix the block hash with the adventurer's own
317
+ // state, then felt_to_two_u64 → (explore_seed, market_seed).
244
318
  const injectedEntropy = entropy;
245
- const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
319
+ // Explore follows the mode: the encounter at each xp is what a seeded or per-level run makes
320
+ // predictable.
321
+ const rolls = resolveActionRolls(injectedEntropy, storeState.gameId, adventurer, gameSeed, randomnessMode, false);
246
322
  const useSeeded = rolls !== null;
247
323
  const exploreSeed = rolls?.actionSeed ?? 0n;
248
324
  const events = [];
@@ -257,7 +333,7 @@ export function applyAction(state, action, entropy) {
257
333
  // Determine encounter type: seeded or random
258
334
  let exploreResult;
259
335
  let rnd = null;
260
- if (useSeeded && (gameSeedUntilXp === 0 || gameSeedUntilXp > adventurer.xp)) {
336
+ if (useSeeded) {
261
337
  rnd = getRandomness(adventurer.xp, exploreSeed);
262
338
  exploreResult = Number(rnd.rnd8 % 3n);
263
339
  }
@@ -273,7 +349,7 @@ export function applyAction(state, action, entropy) {
273
349
  // adventurer id and xp to get the beast entropy. The salt is stored state, so getting
274
350
  // this order wrong desyncs every later action that re-derives the beast from it (attack,
275
351
  // flee, in-battle equip all do).
276
- adventurer.beast_salt = Number((exploreSeed & 0x1ffffn) | 1n);
352
+ adventurer.beast_salt = nonzeroSalt(exploreSeed, 0x1ffffn);
277
353
  const beastEntropy = getBeastEntropy(adventurer.beast_salt, storeState.gameId ?? "0", adventurer.xp);
278
354
  const beast = getBeastFromEntropy(adventurer.xp, beastEntropy);
279
355
  adventurer.beast_health = beast.health;
@@ -452,7 +528,7 @@ export function applyAction(state, action, entropy) {
452
528
  // Level up check
453
529
  const newLevel = calculateLevel(adventurer.xp);
454
530
  if (newLevel > previousLevel) {
455
- adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
531
+ adventurer.level_salt = levelSaltAfterLevelUp(rolls);
456
532
  events.push({ type: "level_up", level: newLevel });
457
533
  }
458
534
  // Bag event if mutated
@@ -471,14 +547,15 @@ export function applyAction(state, action, entropy) {
471
547
  const beast = storeState.beast;
472
548
  const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
473
549
  const gameSeed = storeState.gameSettings?.game_seed ?? 0;
474
- const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
550
+ const randomnessMode = storeState.gameSettings?.randomness_mode ?? RandomnessMode.FULL;
475
551
  // Validate
476
552
  if (adventurer.health === 0)
477
553
  return [];
478
554
  if (adventurer.beast_health <= 0)
479
555
  return [];
480
556
  const injectedEntropy = entropy;
481
- const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
557
+ // Combat always rolls live, whatever the mode says.
558
+ const rolls = resolveActionRolls(injectedEntropy, storeState.gameId, adventurer, gameSeed, randomnessMode, true);
482
559
  // Battle rolls are only ever seeded from injected entropy. A settings `game_seed` game leaves
483
560
  // combat random exactly as it does today -- its outcomes get corrected by the chain events the
484
561
  // action returns, so there is nothing here to fix and plenty to break.
@@ -517,30 +594,12 @@ export function applyAction(state, action, entropy) {
517
594
  const xpReward = getBaseXpReward(beast.tier, beast.level, adventurerLevel);
518
595
  const prevLevel = calculateLevel(adventurer.xp);
519
596
  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.
597
+ // Item XP. A first-time specials unlock draws from the action's OWN seed. This used to
598
+ // pass `getItemSpecialsSeed(item_specials_salt, ..)`, which is 0 for exactly as long as
599
+ // the salt is unset -- so the unlock always wrote the constant 1 and collapsed every item
600
+ // special in the run. Mirrors the same fix in `combat.cairo`.
522
601
  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
- }
602
+ grantXpToEquippedItems(adventurer, itemXp, storeState.gameId ?? "0", rolls ? Number(rolls.actionSeed & 0xffffn) : undefined);
544
603
  events.push({
545
604
  type: "defeated_beast",
546
605
  beast_id: beast.id,
@@ -578,7 +637,7 @@ export function applyAction(state, action, entropy) {
578
637
  // Level up check
579
638
  const newLevel = calculateLevel(adventurer.xp);
580
639
  if (newLevel > previousLevel) {
581
- adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
640
+ adventurer.level_salt = levelSaltAfterLevelUp(rolls);
582
641
  events.push({ type: "level_up", level: newLevel });
583
642
  }
584
643
  // Always emit final adventurer state
@@ -593,22 +652,21 @@ export function applyAction(state, action, entropy) {
593
652
  const beast = storeState.beast;
594
653
  const baseDamageReduction = storeState.gameSettings?.base_damage_reduction ?? 0;
595
654
  const gameSeed = storeState.gameSettings?.game_seed ?? 0;
596
- const gameSeedUntilXp = storeState.gameSettings?.game_seed_until_xp ?? 0;
655
+ const randomnessMode = storeState.gameSettings?.randomness_mode ?? RandomnessMode.FULL;
597
656
  // Validate
598
657
  if (adventurer.health === 0)
599
658
  return [];
600
659
  if (adventurer.beast_health <= 0)
601
660
  return [];
602
661
  const adventurerLevel = calculateLevel(adventurer.xp);
603
- if (adventurerLevel <= 1)
604
- return []; // Can't flee starter beast
605
662
  if (adventurer.stats.dexterity === 0)
606
663
  return [];
607
664
  const injectedEntropy = entropy;
608
- const rolls = resolveActionRolls(injectedEntropy, gameSeed, gameSeedUntilXp, adventurer.xp);
665
+ // Combat always rolls live, whatever the mode says.
666
+ const rolls = resolveActionRolls(injectedEntropy, storeState.gameId, adventurer, gameSeed, randomnessMode, true);
609
667
  const fleeSeed = injectedEntropy !== null ? (rolls?.actionSeed ?? null) : null;
610
668
  const updateLevelSalt = () => {
611
- adventurer.level_salt = levelSaltAfterLevelUp(injectedEntropy, rolls, gameSeed, gameSeedUntilXp, adventurer.xp);
669
+ adventurer.level_salt = levelSaltAfterLevelUp(rolls);
612
670
  };
613
671
  const events = [];
614
672
  const previousLevel = adventurerLevel;
@@ -802,7 +860,11 @@ export function applyAction(state, action, entropy) {
802
860
  // replay_equip draws get_battle_randomness(xp, 0, equip_seed) -- battle count ZERO, unlike
803
861
  // attack and flee which start at 1 -- and takes rnd3/rnd4 as the beast crit and location.
804
862
  const injectedEntropy = entropy;
805
- const equipSeed = injectedEntropy !== null ? felt_to_two_u64(injectedEntropy).u64_1 : null;
863
+ // The contract calls get_random_seed here too, so the block hash gets the same state mix.
864
+ // `adventurer` is post-equip and still in battle, which is the state the contract holds.
865
+ const equipSeed = injectedEntropy !== null
866
+ ? felt_to_two_u64(deriveActionSeed(injectedEntropy, BigInt(storeState.gameId ?? "0"), adventurer.xp, adventurer.health, adventurer.beast_health > 0)).u64_1
867
+ : null;
806
868
  const round = equipSeed !== null ? battleRolls(adventurer.xp, 0, equipSeed) : null;
807
869
  const counterAttack = calculateBeastCounterAttack(beast, adventurer, baseDamageReduction, false, round
808
870
  ? {
@@ -837,8 +899,6 @@ export function applyAction(state, action, entropy) {
837
899
  return [];
838
900
  if (adventurer.beast_health > 0)
839
901
  return []; // Can't drop in battle
840
- if (calculateLevel(adventurer.xp) <= 1)
841
- return []; // Can't drop during starter beast
842
902
  if (items.length === 0)
843
903
  return [];
844
904
  const events = [];