Skip to content

Commit 89ede05

Browse files
author
web3blind
committed
Fix deterministic crafting state flow
1 parent 5cbb534 commit 89ede05

8 files changed

Lines changed: 212 additions & 39 deletions

File tree

app/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
<script src="js/protocols/market-protocol.js"></script>
138138

139139
<!-- Game data -->
140-
<script src="js/data/creatures.js"></script>
140+
<script src="js/data/creatures.js?v=20260621i"></script>
141141
<script src="js/data/spells.js"></script>
142142
<script src="js/data/recipes.js"></script>
143143
<script src="js/data/regions.js"></script>
@@ -154,7 +154,7 @@
154154
<script src="js/engine/validator.js"></script>
155155
<script src="js/engine/guild.js?v=20260621d"></script>
156156
<script src="js/engine/territory.js"></script>
157-
<script src="js/engine/crafting.js"></script>
157+
<script src="js/engine/crafting.js?v=20260621i"></script>
158158
<script src="js/engine/enchanting.js"></script>
159159
<script src="js/engine/marketplace.js"></script>
160160
<script src="js/engine/checkpoint.js"></script>
@@ -164,7 +164,7 @@
164164
<script src="js/engine/quest-system.js?v=20260621e"></script>
165165
<script src="js/engine/loci.js"></script>
166166
<script src="js/engine/npc-framework.js"></script>
167-
<script src="js/engine/state-engine.js?v=20260621h"></script>
167+
<script src="js/engine/state-engine.js?v=20260621i"></script>
168168
<script src="js/engine/daily-leaderboard-storage.js"></script>
169169
<script src="js/engine/daily-leaderboard.js"></script>
170170

@@ -190,7 +190,7 @@
190190
<script src="js/ui/screens/guild.js?v=20260621d"></script>
191191
<script src="js/ui/screens/map.js?v=20260621h"></script>
192192
<script src="js/ui/screens/marketplace.js"></script>
193-
<script src="js/ui/screens/crafting.js"></script>
193+
<script src="js/ui/screens/crafting.js?v=20260621i"></script>
194194
<script src="js/ui/screens/quests.js?v=20260621e"></script>
195195
<script src="js/ui/screens/world-boss.js"></script>
196196
<script src="js/ui/screens/settings.js"></script>

app/js/data/creatures.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ var GameCreatures = (function() {
2727
zone: 'commons_first_light',
2828
lootTable: [
2929
{ itemType: 'fire_dust', name: 'Fire Dust', dropRate: 400 },
30+
{ itemType: 'sparkdust', name: 'Sparkdust', dropRate: 250 },
31+
{ itemType: 'sealwax', name: 'Sealwax', dropRate: 90 },
3032
{ itemType: 'health_scroll', name: 'Health Scroll', dropRate: 150 }
3133
]
3234
},
@@ -45,6 +47,7 @@ var GameCreatures = (function() {
4547
zone: 'commons_first_light',
4648
lootTable: [
4749
{ itemType: 'shadow_shard', name: 'Shadow Shard', dropRate: 350 },
50+
{ itemType: 'chronicle_ink', name: 'Chronicle Ink', dropRate: 120 },
4851
{ itemType: 'health_scroll', name: 'Health Scroll', dropRate: 200 },
4952
{ itemType: 'lucky_charm', name: 'Lucky Charm', dropRate: 50 },
5053
{ itemType: 'echo_shards', name: 'Echo Shards', dropRate: 120 }
@@ -65,6 +68,7 @@ var GameCreatures = (function() {
6568
zone: 'commons_first_light',
6669
lootTable: [
6770
{ itemType: 'thorn_essence', name: 'Thorn Essence', dropRate: 300 },
71+
{ itemType: 'veilstone', name: 'Veilstone', dropRate: 140 },
6872
{ itemType: 'cloth_robe', name: 'Cloth Robe', dropRate: 100 },
6973
{ itemType: 'oak_wand', name: 'Oak Wand', dropRate: 80 },
7074
{ itemType: 'armageddon_stone', name: 'Armageddon Stone', dropRate: 5 }
@@ -86,6 +90,7 @@ var GameCreatures = (function() {
8690
lootTable: [
8791
{ itemType: 'ancient_shard', name: 'Ancient Echo Shard', dropRate: 300 },
8892
{ itemType: 'stone_tablet', name: 'Weathered Stone Tablet', dropRate: 140 },
93+
{ itemType: 'aether_ore', name: 'Aether Ore', dropRate: 80 },
8994
{ itemType: 'spirit_tunic', name: 'Spirit Tunic', dropRate: 70 }
9095
]
9196
},

app/js/engine/crafting.js

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -159,24 +159,63 @@ var CraftingSystem = (function() {
159159
};
160160
}
161161

162-
/**
163-
* Execute a craft action
164-
* @param {string} recipeId - recipe identifier
165-
* @param {Object} character - CharacterState (will be mutated)
166-
* @param {Array} inventory - inventory array (will be mutated)
167-
* @param {string} location - current zone
168-
* @param {string} blockHash - block hash for quality roll
169-
* @param {number} blockNum - block number
170-
* @param {string} account - crafter account
171-
* @returns {Object} {success, item, quality, error, consumedIds}
172-
*/
173-
function craft(recipeId, character, inventory, location, blockHash, blockNum, account) {
174-
// Validate recipe
162+
function _validateRecipeWithMaterialIds(recipeId, character, inventory, location, materialIds) {
175163
var validation = validateRecipe(recipeId, character, inventory, location);
176164
if (!validation.valid) {
177-
return { success: false, error: validation.error, missing: validation.missing };
165+
return validation;
166+
}
167+
168+
var recipe = validation.recipe;
169+
var required = {};
170+
var remaining = 0;
171+
for (var r = 0; r < recipe.materials.length; r++) {
172+
required[recipe.materials[r].type] = (required[recipe.materials[r].type] || 0) + recipe.materials[r].quantity;
173+
remaining += recipe.materials[r].quantity;
174+
}
175+
176+
if (!materialIds || materialIds.length !== remaining) {
177+
return { valid: false, error: 'missing_materials', recipe: recipe };
178+
}
179+
180+
var matchedMaterials = [];
181+
var used = {};
182+
for (var i = 0; i < materialIds.length; i++) {
183+
var id = materialIds[i];
184+
if (used[id]) {
185+
return { valid: false, error: 'missing_materials', recipe: recipe };
186+
}
187+
used[id] = true;
188+
189+
var item = null;
190+
for (var j = 0; j < inventory.length; j++) {
191+
if (inventory[j].id === id) {
192+
item = inventory[j];
193+
break;
194+
}
195+
}
196+
197+
if (!item || item.consumed || item.equipped || !required[item.type]) {
198+
return { valid: false, error: 'missing_materials', recipe: recipe };
199+
}
200+
201+
required[item.type]--;
202+
matchedMaterials.push(item);
203+
}
204+
205+
for (var type in required) {
206+
if (required.hasOwnProperty(type) && required[type] !== 0) {
207+
return { valid: false, error: 'missing_materials', recipe: recipe };
208+
}
178209
}
179210

211+
return {
212+
valid: true,
213+
recipe: recipe,
214+
matchedMaterials: matchedMaterials
215+
};
216+
}
217+
218+
function _craftFromValidation(validation, character, inventory, blockHash, blockNum, account) {
180219
var recipe = validation.recipe;
181220
var materials = validation.matchedMaterials;
182221

@@ -220,6 +259,38 @@ var CraftingSystem = (function() {
220259
};
221260
}
222261

262+
/**
263+
* Execute a craft action using any matching materials.
264+
* @param {string} recipeId - recipe identifier
265+
* @param {Object} character - CharacterState (will be mutated)
266+
* @param {Array} inventory - inventory array (will be mutated)
267+
* @param {string} location - current zone
268+
* @param {string} blockHash - block hash for quality roll
269+
* @param {number} blockNum - block number
270+
* @param {string} account - crafter account
271+
* @returns {Object} {success, item, quality, error, consumedIds}
272+
*/
273+
function craft(recipeId, character, inventory, location, blockHash, blockNum, account) {
274+
var validation = validateRecipe(recipeId, character, inventory, location);
275+
if (!validation.valid) {
276+
return { success: false, error: validation.error, missing: validation.missing };
277+
}
278+
return _craftFromValidation(validation, character, inventory, blockHash, blockNum, account);
279+
}
280+
281+
/**
282+
* Execute a craft action using the exact material item IDs recorded on-chain.
283+
* This keeps replay deterministic and prevents a later replay from consuming a
284+
* second set of matching materials after the live UI already applied the craft.
285+
*/
286+
function craftWithMaterialIds(recipeId, character, inventory, location, blockHash, blockNum, account, materialIds) {
287+
var validation = _validateRecipeWithMaterialIds(recipeId, character, inventory, location, materialIds || []);
288+
if (!validation.valid) {
289+
return { success: false, error: validation.error, missing: validation.missing };
290+
}
291+
return _craftFromValidation(validation, character, inventory, blockHash, blockNum, account);
292+
}
293+
223294
/**
224295
* Get available recipes for a character (filtered by class, level, materials)
225296
* @param {Object} character - CharacterState
@@ -284,6 +355,7 @@ var CraftingSystem = (function() {
284355
calculateQuality: calculateQuality,
285356
validateRecipe: validateRecipe,
286357
craft: craft,
358+
craftWithMaterialIds: craftWithMaterialIds,
287359
getAvailableRecipes: getAvailableRecipes,
288360
countMaterial: countMaterial
289361
};

app/js/engine/state-engine.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,10 +848,19 @@ var StateEngine = (function() {
848848
var inventory = worldState.inventories[sender];
849849
if (!character || !inventory) return [];
850850

851-
var result = CraftingSystem.craft(
852-
data.recipe, character, inventory,
853-
data.location || '', blockHash, blockNum, sender
854-
);
851+
var result;
852+
if (typeof CraftingSystem.craftWithMaterialIds === 'function') {
853+
result = CraftingSystem.craftWithMaterialIds(
854+
data.recipe, character, inventory,
855+
data.location || '', blockHash, blockNum, sender,
856+
data.materials || []
857+
);
858+
} else {
859+
result = CraftingSystem.craft(
860+
data.recipe, character, inventory,
861+
data.location || '', blockHash, blockNum, sender
862+
);
863+
}
855864

856865
if (!result.success) {
857866
console.log('StateEngine: Craft failed for', sender, ':', result.error);
@@ -1226,6 +1235,27 @@ var StateEngine = (function() {
12261235
return events[0];
12271236
}
12281237

1238+
/**
1239+
* Process crafting for live UI — same authoritative mutation path as replay.
1240+
* @param {string} account
1241+
* @param {string} recipeId
1242+
* @param {Array} materialIds
1243+
* @param {string} location
1244+
* @param {string} blockHash
1245+
* @param {number} blockNum
1246+
* @returns {Object|null}
1247+
*/
1248+
function processCraftResult(account, recipeId, materialIds, location, blockHash, blockNum) {
1249+
var data = {
1250+
recipe: recipeId,
1251+
materials: materialIds || [],
1252+
location: location || ''
1253+
};
1254+
var events = _handleCraft(account, data, blockNum || 0, blockHash || '');
1255+
if (!events.length) return null;
1256+
return events[0];
1257+
}
1258+
12291259
/**
12301260
* Process Armageddon for live UI — same logic as _handleHuntArmageddon.
12311261
* @param {string} account
@@ -1262,6 +1292,7 @@ var StateEngine = (function() {
12621292
getInventory: getInventory,
12631293
processHuntResult: processHuntResult,
12641294
processMoveResult: processMoveResult,
1295+
processCraftResult: processCraftResult,
12651296
processArmageddonResult: processArmageddonResult,
12661297
reset: reset
12671298
};

app/js/ui/screens/crafting.js

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -497,22 +497,40 @@ var CraftingScreen = (function() {
497497
return;
498498
}
499499

500-
// Simulate local craft result (actual state updated from block processing)
500+
// Apply local craft result through StateEngine so live play and replay share one mutation path.
501501
var worldState = StateEngine.getState();
502-
var blockHash = 'sim_' + Date.now().toString(16);
503-
var blockNum = worldState.headBlock + 1;
504-
505-
var craftRes = CraftingSystem.craft(
506-
selectedRecipe, character, inventory,
507-
character.currentZone || '', blockHash, blockNum, user
508-
);
509-
510-
if (craftRes.success) {
511-
craftResult = craftRes;
502+
var blockNum = (result && result.block_num) || (result && result.action && result.action.block_num) || (worldState.headBlock + 1);
503+
var blockHash = (result && (result.block_id || result.id || result.transaction_id)) ||
504+
(result && result.action && (result.action.block_id || result.action.id || result.action.transaction_id)) ||
505+
('sim_' + Date.now().toString(16));
506+
507+
var craftEvent = StateEngine.processCraftResult(user, selectedRecipe, materialIds, character.currentZone || '', blockHash, blockNum);
508+
509+
if (craftEvent) {
510+
craftResult = {
511+
item: {
512+
id: craftEvent.itemId,
513+
type: craftEvent.itemType,
514+
rarity: craftEvent.rarity,
515+
stats: (function() {
516+
var inv = StateEngine.getInventory(user);
517+
for (var ii = 0; ii < inv.length; ii++) {
518+
if (inv[ii].id === craftEvent.itemId) return inv[ii].stats || null;
519+
}
520+
return null;
521+
})()
522+
},
523+
quality: {
524+
rarity: craftEvent.rarity,
525+
rarityName: craftEvent.rarityName
526+
},
527+
consumedIds: craftEvent.consumedIds || []
528+
};
529+
StateEngine.saveCheckpoint(function() {});
512530
SoundManager.play('victory');
513531
SoundManager.vibrate('loot');
514532
} else {
515-
Toast.error(t('craft_error_' + (craftRes.error || 'unknown')));
533+
Toast.error(t('craft_error_unknown'));
516534
SoundManager.play('error');
517535
}
518536

app/sw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Viz Magic — Service Worker
2-
var CACHE_NAME = 'viz-magic-v21';
2+
var CACHE_NAME = 'viz-magic-v22';
33
var ASSETS = [
44
'/',
55
'/index.html',

plan.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Viz Magic — remaining QA and hardening plan
22

3-
Updated: 2026-06-21 06:48 UTC
3+
Updated: 2026-06-21 07:32 UTC
44

55
This file is the canonical handoff plan for the next Hermes session. It intentionally replaces the old `plan.md` contents.
66

@@ -107,7 +107,14 @@ PY
107107

108108
## Remaining work item 1 — Crafting E2E
109109

110-
Status: not fully covered E2E.
110+
Status: locally covered with regression tests and browser fixture smoke; live VIZ broadcast not executed.
111+
112+
2026-06-21 update:
113+
114+
- Live crafting no longer mutates inventory directly in `CraftingScreen`; after successful `MarketProtocol.broadcastCraft(...)` it routes through `StateEngine.processCraftResult(...)` and saves a checkpoint.
115+
- Replay crafting now consumes the exact material item IDs from action data via `CraftingSystem.craftWithMaterialIds(...)`, preventing different matching materials from being consumed during later replay.
116+
- Recipe output templates and recipe material obtainability are covered by `tests/player-bug-regressions.test.js`.
117+
- Browser fixture smoke crafted one `mana_potion`, marked the three selected material IDs consumed, added exactly one crafted item, and confirmed checkpoint save was called. This was a local stubbed broadcast smoke, not a live-chain spend.
111118

112119
Goal: verify crafting from UI through state-engine, inventory, material consumption, messages, and checkpoint persistence.
113120

@@ -419,7 +426,7 @@ Suggested prompt for a new Hermes session:
419426

420427
Mark items here as the next session completes them:
421428

422-
- [ ] Crafting E2E
429+
- [x] Crafting E2E — local fixture + regression coverage; live-chain broadcast intentionally not spent
423430
- [ ] Marketplace E2E
424431
- [ ] Two-browser Guild smoke
425432
- [ ] Two-browser Arena smoke

0 commit comments

Comments
 (0)