@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.
- package/dist/actions.d.ts +18 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +88 -0
- package/dist/actions.js.map +1 -0
- package/dist/constants/beast.d.ts +28 -0
- package/dist/constants/beast.d.ts.map +1 -0
- package/dist/constants/beast.js +216 -0
- package/dist/constants/beast.js.map +1 -0
- package/dist/constants/game.d.ts +23 -0
- package/dist/constants/game.d.ts.map +1 -0
- package/dist/constants/game.js +26 -0
- package/dist/constants/game.js.map +1 -0
- package/dist/constants/loot.d.ts +341 -0
- package/dist/constants/loot.d.ts.map +1 -0
- package/dist/constants/loot.js +456 -0
- package/dist/constants/loot.js.map +1 -0
- package/dist/constants/obstacle.d.ts +4 -0
- package/dist/constants/obstacle.d.ts.map +1 -0
- package/dist/constants/obstacle.js +81 -0
- package/dist/constants/obstacle.js.map +1 -0
- package/dist/events.d.ts +50 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/packing.d.ts +6 -0
- package/dist/packing.d.ts.map +1 -0
- package/dist/packing.js +76 -0
- package/dist/packing.js.map +1 -0
- package/dist/protocol.d.ts +55 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +250 -0
- package/dist/protocol.js.map +1 -0
- package/dist/replay.d.ts +35 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +856 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/beast.d.ts +264 -0
- package/dist/rules/beast.d.ts.map +1 -0
- package/dist/rules/beast.js +535 -0
- package/dist/rules/beast.js.map +1 -0
- package/dist/rules/game.d.ts +35 -0
- package/dist/rules/game.d.ts.map +1 -0
- package/dist/rules/game.js +462 -0
- package/dist/rules/game.js.map +1 -0
- package/dist/rules/gameSimulation.d.ts +158 -0
- package/dist/rules/gameSimulation.d.ts.map +1 -0
- package/dist/rules/gameSimulation.js +845 -0
- package/dist/rules/gameSimulation.js.map +1 -0
- package/dist/rules/health.d.ts +3 -0
- package/dist/rules/health.d.ts.map +1 -0
- package/dist/rules/health.js +7 -0
- package/dist/rules/health.js.map +1 -0
- package/dist/rules/loot.d.ts +100 -0
- package/dist/rules/loot.d.ts.map +1 -0
- package/dist/rules/loot.js +626 -0
- package/dist/rules/loot.js.map +1 -0
- package/dist/rules/market.d.ts +28 -0
- package/dist/rules/market.d.ts.map +1 -0
- package/dist/rules/market.js +83 -0
- package/dist/rules/market.js.map +1 -0
- package/dist/rules/processFutures.d.ts +79 -0
- package/dist/rules/processFutures.d.ts.map +1 -0
- package/dist/rules/processFutures.js +429 -0
- package/dist/rules/processFutures.js.map +1 -0
- package/dist/rules/tokenId.d.ts +31 -0
- package/dist/rules/tokenId.d.ts.map +1 -0
- package/dist/rules/tokenId.js +102 -0
- package/dist/rules/tokenId.js.map +1 -0
- package/dist/staging.d.ts +18 -0
- package/dist/staging.d.ts.map +1 -0
- package/dist/staging.js +27 -0
- package/dist/staging.js.map +1 -0
- package/dist/transcript.d.ts +64 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +92 -0
- package/dist/transcript.js.map +1 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/view.d.ts +4 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +18 -0
- package/dist/view.js.map +1 -0
- 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
|