@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/README.md +69 -0
- package/dist/constants/beast.d.ts +0 -2
- package/dist/constants/beast.d.ts.map +1 -1
- package/dist/constants/beast.js +0 -2
- package/dist/constants/beast.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +23 -6
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +73 -47
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +21 -3
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +229 -169
- package/dist/replay.js.map +1 -1
- package/dist/rules/gameSimulation.d.ts +1 -1
- package/dist/rules/gameSimulation.d.ts.map +1 -1
- package/dist/rules/gameSimulation.js +5 -5
- package/dist/rules/gameSimulation.js.map +1 -1
- package/dist/rules/processFutures.d.ts +7 -6
- package/dist/rules/processFutures.d.ts.map +1 -1
- package/dist/rules/processFutures.js +8 -7
- package/dist/rules/processFutures.js.map +1 -1
- package/dist/transcript.d.ts +10 -6
- package/dist/transcript.d.ts.map +1 -1
- package/dist/transcript.js +4 -9
- package/dist/transcript.js.map +1 -1
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/replay.js
CHANGED
|
@@ -1,49 +1,122 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
*
|
|
10
|
+
* The three randomness modes, mirroring `utils/randomness.cairo::RandomnessMode`.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
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 (
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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(
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
adventurer.beast_salt =
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
521
|
-
//
|
|
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",
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 = [];
|