Skip to content

Commit 4fc485d

Browse files
web3blindclaude
andcommitted
Fix item duplication, guild listing rejection, and empty arena player list
- Hunt: skip _handleHunt replay when processHuntResult already ran (lastHuntBlock guard) — prevents doubled loot and XP - Guild listing: remove strict membership check in _handleGuildListing — listings are discovery messages and must be accepted even when the receiving client lacks guild data - Arena: _renderKnownPlayers now also reads state.social.knownAccounts so discovered players appear even without full character hydration - Add Puppeteer smoke test (tests/smoke.js) covering idempotency and listing acceptance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dffd406 commit 4fc485d

4 files changed

Lines changed: 304 additions & 5 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ test-artifacts/
44
.claude/*
55
.superpowers/brainstorm/*
66
docs/superpowers/specs/*
7+
node_modules/
8+
package.json
9+
package-lock.json

app/js/engine/state-engine.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ var StateEngine = (function() {
412412
var character = worldState.characters[sender];
413413
if (!character) return [];
414414

415+
// Skip if already processed by optimistic processHuntResult()
416+
if (character.lastHuntBlock === blockNum) return [];
417+
415418
// Get creature and spell definitions
416419
var creature = GameCreatures.getCreature(data.creature);
417420
var spell = GameSpells.getSpell(data.spell);
@@ -765,9 +768,10 @@ var StateEngine = (function() {
765768

766769
function _handleGuildListing(sender, data, blockNum) {
767770
if (!data.guild_id || !data.created_block) return [];
768-
// Verify sender is a member of the guild (if guild is in state)
769-
var guild = worldState.guilds[data.guild_id];
770-
if (guild && !guild.members[sender]) return [];
771+
// Accept listing even if guild is not yet in local state or is a placeholder.
772+
// The listing's purpose is discovery — other clients need it to learn
773+
// about guilds they haven't synced yet. Membership validation is too
774+
// strict here because the receiving client may not have the guild data.
771775

772776
if (!worldState.guildListings) worldState.guildListings = [];
773777
// Deduplicate: keep only the latest listing per guild

app/js/ui/screens/arena.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,17 +346,32 @@ var ArenaScreen = (function() {
346346
lbAccounts[leaderboard[li].account] = true;
347347
}
348348

349+
var seen = {};
349350
var players = [];
351+
352+
// Source 1: fully hydrated characters from state-engine
350353
if (state.characters) {
351354
for (var account in state.characters) {
352355
if (!state.characters.hasOwnProperty(account)) continue;
353356
if (account === user || lbAccounts[account]) continue;
354357
var ch = state.characters[account];
355358
if (!ch || !ch.name) continue;
359+
seen[account] = true;
356360
players.push({ account: account, name: ch.name, level: ch.level || 1, className: ch.className });
357361
}
358362
}
359363

364+
// Source 2: known accounts discovered during sync (guild members, action targets, etc.)
365+
// Shows accounts without full character data — at least the account name is visible
366+
if (state.social && state.social.knownAccounts) {
367+
for (var ki = 0; ki < state.social.knownAccounts.length; ki++) {
368+
var knownAcct = state.social.knownAccounts[ki];
369+
if (!knownAcct || knownAcct === user || lbAccounts[knownAcct] || seen[knownAcct]) continue;
370+
seen[knownAcct] = true;
371+
players.push({ account: knownAcct, name: knownAcct, level: 0, className: '' });
372+
}
373+
}
374+
360375
if (players.length === 0) return '';
361376

362377
// Sort by level descending
@@ -366,11 +381,13 @@ var ArenaScreen = (function() {
366381
html += '<div class="arena-players-list" role="list">';
367382
for (var i = 0; i < Math.min(players.length, 50); i++) {
368383
var p = players[i];
384+
var levelText = p.level > 0 ? ' <span class="arena-player-level">Lv ' + p.level + '</span>' : '';
385+
var classIconHtml = p.className ? '<span aria-hidden="true">' + Helpers.classIcon(p.className) + '</span> ' : '';
369386
html += '<div class="arena-player-card" role="listitem">' +
370387
'<span class="arena-player-info">' +
371-
'<span aria-hidden="true">' + Helpers.classIcon(p.className) + '</span> ' +
388+
classIconHtml +
372389
Helpers.escapeHtml(p.name) +
373-
' <span class="arena-player-level">Lv ' + p.level + '</span>' +
390+
levelText +
374391
'</span>' +
375392
'<button class="btn btn-secondary btn-sm arena-challenge-btn" ' +
376393
'data-account="' + p.account + '" ' +

tests/smoke.js

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* Smoke test — opens the app in headless Chromium via Puppeteer,
3+
* checks for JS errors, verifies key screens render.
4+
*
5+
* Usage: node tests/smoke.js
6+
* Starts its own HTTP server on port 8199, tears it down when done.
7+
*/
8+
9+
var http = require('http');
10+
var fs = require('fs');
11+
var path = require('path');
12+
var puppeteer = require('puppeteer');
13+
14+
// ---------- tiny static server ----------
15+
var APP_DIR = path.join(__dirname, '..', 'app');
16+
var PORT = 8199;
17+
var MIME = {
18+
'.html': 'text/html',
19+
'.js': 'application/javascript',
20+
'.css': 'text/css',
21+
'.json': 'application/json',
22+
'.png': 'image/png',
23+
'.svg': 'image/svg+xml'
24+
};
25+
26+
function startServer() {
27+
return new Promise(function (resolve) {
28+
var srv = http.createServer(function (req, res) {
29+
var url = req.url.split('?')[0];
30+
if (url === '/') url = '/index.html';
31+
var filePath = path.join(APP_DIR, url);
32+
var ext = path.extname(filePath);
33+
fs.readFile(filePath, function (err, data) {
34+
if (err) {
35+
res.writeHead(404);
36+
res.end('Not found');
37+
return;
38+
}
39+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
40+
res.end(data);
41+
});
42+
});
43+
srv.listen(PORT, function () {
44+
console.log('Server listening on port ' + PORT);
45+
resolve(srv);
46+
});
47+
});
48+
}
49+
50+
// ---------- test runner ----------
51+
var errors = [];
52+
var warnings = [];
53+
var passed = 0;
54+
var failed = 0;
55+
56+
function assert(label, ok, detail) {
57+
if (ok) {
58+
passed++;
59+
console.log(' PASS: ' + label);
60+
} else {
61+
failed++;
62+
console.log(' FAIL: ' + label + (detail ? ' — ' + detail : ''));
63+
}
64+
}
65+
66+
async function runTests() {
67+
var srv = await startServer();
68+
var browser;
69+
try {
70+
browser = await puppeteer.launch({
71+
headless: 'new',
72+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']
73+
});
74+
var page = await browser.newPage();
75+
76+
// Collect console errors
77+
page.on('console', function (msg) {
78+
if (msg.type() === 'error') {
79+
errors.push(msg.text());
80+
} else if (msg.type() === 'warning') {
81+
warnings.push(msg.text());
82+
}
83+
});
84+
85+
// Collect uncaught exceptions
86+
page.on('pageerror', function (err) {
87+
errors.push('PAGE ERROR: ' + err.message);
88+
});
89+
90+
// ---------- 1. Page loads without crash ----------
91+
console.log('\n--- Test: page load ---');
92+
await page.goto('http://localhost:' + PORT + '/', {
93+
waitUntil: 'networkidle0',
94+
timeout: 15000
95+
});
96+
assert('Page loaded', true);
97+
98+
// ---------- 2. Key globals exist ----------
99+
console.log('\n--- Test: global modules ---');
100+
var globals = [
101+
'VizMagicConfig', 'Helpers', 'StateEngine', 'BlockProcessor',
102+
'CombatSystem', 'ItemSystem', 'CharacterSystem', 'CraftingSystem',
103+
'GuildSystem', 'DuelStateManager', 'VMProtocol', 'GameCreatures',
104+
'GameSpells', 'GameRecipes', 'GameRegions', 'WorldBoss'
105+
];
106+
for (var i = 0; i < globals.length; i++) {
107+
var exists = await page.evaluate('typeof ' + globals[i] + ' !== "undefined"');
108+
assert(globals[i] + ' defined', exists);
109+
}
110+
111+
// ---------- 3. Screen sections exist in DOM ----------
112+
console.log('\n--- Test: screen sections ---');
113+
var screens = [
114+
'screen-landing', 'screen-login', 'screen-home',
115+
'screen-hunt', 'screen-character', 'screen-inventory',
116+
'screen-crafting', 'screen-arena', 'screen-guild',
117+
'screen-chronicle', 'screen-map', 'screen-world-boss'
118+
];
119+
for (var si = 0; si < screens.length; si++) {
120+
var found = await page.evaluate(
121+
'!!document.getElementById("' + screens[si] + '")'
122+
);
123+
assert('Section #' + screens[si], found);
124+
}
125+
126+
// ---------- 4. StateEngine initializes ----------
127+
console.log('\n--- Test: StateEngine ---');
128+
var seState = await page.evaluate(function () {
129+
if (typeof StateEngine === 'undefined') return null;
130+
var s = StateEngine.getState();
131+
return {
132+
hasCharacters: typeof s.characters === 'object',
133+
hasInventories: typeof s.inventories === 'object',
134+
hasGuilds: typeof s.guilds === 'object',
135+
hasGuildListings: Array.isArray(s.guildListings),
136+
hasSocial: typeof s.social === 'object'
137+
};
138+
});
139+
if (seState) {
140+
assert('state.characters is object', seState.hasCharacters);
141+
assert('state.inventories is object', seState.hasInventories);
142+
assert('state.guilds is object', seState.hasGuilds);
143+
assert('state.guildListings is array', seState.hasGuildListings);
144+
assert('state.social is object', seState.hasSocial);
145+
} else {
146+
assert('StateEngine.getState() returned', false, 'null');
147+
}
148+
149+
// ---------- 5. ItemSystem idempotency (the fix we made) ----------
150+
console.log('\n--- Test: hunt idempotency ---');
151+
var idempotencyOk = await page.evaluate(function () {
152+
// Create a fake character and run two hunts on same block
153+
var state = StateEngine.getState();
154+
var testAcct = '__smoke_test_account__';
155+
state.characters[testAcct] = CharacterSystem.createCharacter(testAcct, 'TestMage', 'embercaster');
156+
state.inventories[testAcct] = [];
157+
158+
// Simulate processHuntResult (optimistic path)
159+
var allCreatures = GameCreatures.getAll();
160+
var creatureId = Object.keys(allCreatures)[0];
161+
var creature = allCreatures[creatureId];
162+
var spells = GameSpells.getSpellsForClass('embercaster');
163+
var spell = spells[0];
164+
if (!creature || !spell) return { error: 'no creature/spell data' };
165+
166+
var result = StateEngine.processHuntResult(
167+
testAcct, creature.id, spell.id,
168+
'aabbccdd', 99999, 10000
169+
);
170+
171+
var countAfterOptimistic = state.inventories[testAcct].length;
172+
173+
// Now simulate block replay — _handleHunt via processBlock
174+
var fakeBlock = {
175+
blockNum: 99999,
176+
blockHash: 'aabbccdd',
177+
vmActions: [{
178+
sender: testAcct,
179+
action: { type: 'hunt', data: { creature: creature.id, spell: spell.id } }
180+
}],
181+
voicePosts: [],
182+
awards: []
183+
};
184+
StateEngine.processBlock(fakeBlock);
185+
186+
var countAfterReplay = state.inventories[testAcct].length;
187+
188+
// Cleanup
189+
delete state.characters[testAcct];
190+
delete state.inventories[testAcct];
191+
192+
return {
193+
countAfterOptimistic: countAfterOptimistic,
194+
countAfterReplay: countAfterReplay,
195+
noDuplication: countAfterOptimistic === countAfterReplay
196+
};
197+
});
198+
if (idempotencyOk && !idempotencyOk.error) {
199+
assert('Hunt loot not duplicated on replay',
200+
idempotencyOk.noDuplication,
201+
'after optimistic: ' + idempotencyOk.countAfterOptimistic +
202+
', after replay: ' + idempotencyOk.countAfterReplay);
203+
} else {
204+
assert('Hunt idempotency test ran', false, idempotencyOk ? idempotencyOk.error : 'null');
205+
}
206+
207+
// ---------- 6. Guild listing accepted without membership ----------
208+
console.log('\n--- Test: guild listing acceptance ---');
209+
var guildListingOk = await page.evaluate(function () {
210+
var state = StateEngine.getState();
211+
var before = (state.guildListings || []).length;
212+
213+
// Broadcast a guild.listing from an unknown sender for an unknown guild
214+
var fakeBlock = {
215+
blockNum: 88888,
216+
blockHash: 'deadbeef',
217+
vmActions: [{
218+
sender: '__unknown_sender__',
219+
action: {
220+
type: 'guild.listing',
221+
data: { guild_id: '__test_guild__', created_block: 10000 }
222+
}
223+
}],
224+
voicePosts: [],
225+
awards: []
226+
};
227+
StateEngine.processBlock(fakeBlock);
228+
229+
var after = (state.guildListings || []).length;
230+
231+
// Cleanup: remove test listing
232+
for (var i = state.guildListings.length - 1; i >= 0; i--) {
233+
if (state.guildListings[i].guild_id === '__test_guild__') {
234+
state.guildListings.splice(i, 1);
235+
}
236+
}
237+
238+
return { before: before, after: after, accepted: after === before + 1 };
239+
});
240+
assert('Guild listing accepted without membership check',
241+
guildListingOk && guildListingOk.accepted,
242+
guildListingOk ? 'before: ' + guildListingOk.before + ', after: ' + guildListingOk.after : 'null');
243+
244+
// ---------- 7. No JS errors ----------
245+
console.log('\n--- Test: console errors ---');
246+
// Filter out expected errors (blockchain connection fails in test env)
247+
var criticalErrors = errors.filter(function (e) {
248+
return e.indexOf('WebSocket') === -1 &&
249+
e.indexOf('ERR_CONNECTION_REFUSED') === -1 &&
250+
e.indexOf('net::') === -1 &&
251+
e.indexOf('Failed to fetch') === -1;
252+
});
253+
assert('No critical JS errors', criticalErrors.length === 0,
254+
criticalErrors.length + ' errors: ' + criticalErrors.slice(0, 3).join('; '));
255+
256+
if (warnings.length > 0) {
257+
console.log(' (Warnings: ' + warnings.length + ')');
258+
}
259+
260+
} catch (e) {
261+
console.log('FATAL: ' + e.message);
262+
failed++;
263+
} finally {
264+
if (browser) await browser.close();
265+
srv.close();
266+
}
267+
268+
// ---------- Summary ----------
269+
console.log('\n========================================');
270+
console.log(' Passed: ' + passed + ' Failed: ' + failed);
271+
console.log('========================================\n');
272+
process.exit(failed > 0 ? 1 : 0);
273+
}
274+
275+
runTests();

0 commit comments

Comments
 (0)