add address modal and try to fix payout

This commit is contained in:
jwinterm
2025-09-29 17:40:59 -04:00
parent 95711b9d26
commit 4e7a773496
15 changed files with 806 additions and 85 deletions
+20
View File
@@ -39,6 +39,7 @@
<!-- Other modules -->
<script src="js/ui/ui.js"></script>
<script src="js/ui/audioAlerts.js"></script>
<script src="js/ui/addressModal.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/input/inputHandler.js"></script>
</head>
@@ -65,6 +66,7 @@
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
</label>
<button id="manageAddressButton" style="margin-left:auto; background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;">⚙️ Manage Payout Address</button>
</div>
</div>
@@ -133,6 +135,24 @@
</div>
</div>
<!-- Address management modal -->
<div id="addressModal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="addressModalTitle">
<div class="modal-content">
<button type="button" class="modal-close" id="addressModalClose" aria-label="Close">✕</button>
<h2 id="addressModalTitle">Manage Payout Address</h2>
<p class="modal-description">Enter your Wownero (WOW) or Monero (XMR) address below. This will be used for payouts when you escape.</p>
<form id="addressModalForm">
<label for="addressModalInput" class="modal-label">Payout Address</label>
<textarea id="addressModalInput" autocomplete="off" spellcheck="false" rows="3" placeholder="Paste your payout address here"></textarea>
<div class="modal-actions">
<button type="submit" id="addressModalSave">Save Address</button>
<button type="button" id="addressModalCancel">Cancel</button>
</div>
<div id="addressModalFeedback" class="modal-feedback" aria-live="polite"></div>
</form>
</div>
</div>
<!-- Browser info panel -->
<div id="browser-info" style="position: fixed; right: 10px; top: 40px; background: rgba(0,0,0,0.7); color: #0f0; padding: 5px; font-family: monospace; font-size: 12px; z-index: 1000; display: none;">
<div>Browser: <span id="browser-name">checking...</span></div>
+11
View File
@@ -176,6 +176,17 @@ const InputHandler = {
$('#messages').append($('<li style="color: #aa0;">').text(`🎬 Animation ${status}`));
UI.scrollChat();
});
// Address management button
$('#manageAddressButton').click(function(e) {
e.preventDefault();
if (window.socket) {
socket.emit('address:prompt');
}
if (typeof AddressModal !== 'undefined') {
AddressModal.show();
}
});
},
setupModeToggle: function() {
+25 -2
View File
@@ -49,8 +49,10 @@ const SocketHandlers = {
socket.on('queue_cancelled', this.onQueueCancelled);
// Payment/Address handlers
socket.on('address_detected', this.onAddressDetected);
socket.on('address_confirmed', this.onAddressConfirmed);
socket.on('address_detected', this.onAddressDetected);
socket.on('address_confirmed', this.onAddressConfirmed);
socket.on('address_update_error', this.onAddressUpdateError);
socket.on('address_prompt', this.onAddressPrompt);
socket.on('payment_created', this.onPaymentCreated);
socket.on('payment_confirmed', this.onPaymentConfirmed);
socket.on('payment_detected', this.onPaymentDetected);
@@ -100,6 +102,9 @@ const SocketHandlers = {
if (data && data.payoutAddress) {
$('#messages').append($('<li class="address-confirmed" style="color:#0f0;">').text('Payout address restored.'));
UI.scrollChat();
if (typeof AddressModal !== 'undefined') {
AddressModal.setCurrentAddress(data.payoutAddress);
}
}
},
@@ -298,6 +303,24 @@ const SocketHandlers = {
console.log('Address confirmed:', data);
$('#messages').append($('<li class="address-confirmed" style="color: #0f0; white-space: pre-line;">').text(data.message));
UI.scrollChat();
if (typeof AddressModal !== 'undefined') {
AddressModal.onConfirmed(data);
}
},
onAddressPrompt: function(data) {
if (typeof AddressModal !== 'undefined') {
AddressModal.show({
existingAddress: data?.existingAddress || null,
message: data?.message || null
});
}
},
onAddressUpdateError: function(data) {
if (typeof AddressModal !== 'undefined') {
AddressModal.handleError(data?.message || 'Failed to update address.');
}
},
onPaymentCreated: function(data) {
+178
View File
@@ -0,0 +1,178 @@
(function(window, $) {
const AddressModal = {
_initialized: false,
_isVisible: false,
_pending: false,
_currentAddress: null,
_elements: {},
init() {
if (this._initialized) {
return;
}
this._elements.overlay = $('#addressModal');
this._elements.input = $('#addressModalInput');
this._elements.form = $('#addressModalForm');
this._elements.close = $('#addressModalClose');
this._elements.cancel = $('#addressModalCancel');
this._elements.feedback = $('#addressModalFeedback');
this._elements.saveButton = $('#addressModalSave');
if (!this._elements.overlay.length) {
console.warn('AddressModal: modal markup not found.');
return;
}
this._elements.close.on('click', (evt) => {
evt.preventDefault();
this.hide();
});
this._elements.cancel.on('click', (evt) => {
evt.preventDefault();
this.hide();
});
this._elements.overlay.on('click', (evt) => {
if (evt.target === this._elements.overlay[0]) {
this.hide();
}
});
this._elements.form.on('submit', (evt) => {
evt.preventDefault();
this.handleSubmit();
});
this._initialized = true;
},
ensureInit() {
if (!this._initialized) {
this.init();
}
},
show({ existingAddress = null, message = null } = {}) {
this.ensureInit();
if (!this._elements.overlay.length) {
return;
}
if (typeof existingAddress === 'string' && existingAddress.trim().length > 0) {
this._currentAddress = existingAddress.trim();
}
const value = this._currentAddress || existingAddress || '';
this._elements.input.val(value);
this._elements.input.trigger('focus');
if (message) {
this.setFeedback(message, 'info');
} else {
this.clearFeedback();
}
this.setPending(false);
this._elements.overlay.removeClass('hidden');
this._isVisible = true;
},
hide() {
if (!this._isVisible) {
return;
}
this._elements.overlay.addClass('hidden');
this._isVisible = false;
this.setPending(false);
this.clearFeedback();
},
setFeedback(text, type = 'info') {
if (!this._elements.feedback.length) return;
this._elements.feedback.text(text || '');
this._elements.feedback.removeClass('error success info');
this._elements.feedback.addClass(type);
},
clearFeedback() {
if (!this._elements.feedback.length) return;
this._elements.feedback.text('');
this._elements.feedback.removeClass('error success info');
},
setCurrentAddress(address) {
if (typeof address === 'string' && address.trim()) {
this._currentAddress = address.trim();
if (this._isVisible && !this._pending) {
this._elements.input.val(this._currentAddress);
}
}
},
setPending(state) {
this._pending = Boolean(state);
if (!this._elements.saveButton?.length) {
return;
}
if (!this._pending && this._isVisible) {
this._elements.saveButton.prop('disabled', false).text('Save Address');
} else if (this._pending) {
this._elements.saveButton.prop('disabled', true).text('Saving…');
} else {
this._elements.saveButton.prop('disabled', false);
}
},
handleError(message) {
this.setPending(false);
if (message) {
this.setFeedback(message, 'error');
}
},
onConfirmed({ address, message }) {
if (address) {
this.setCurrentAddress(address);
}
if (this._isVisible) {
if (message) {
this.setFeedback(message, 'success');
}
this.setPending(false);
this.hide();
}
},
handleSubmit() {
if (this._pending) {
return;
}
const value = (this._elements.input.val() || '').trim();
if (!value) {
this.setFeedback('Please enter a payout address.', 'error');
this._elements.input.focus();
return;
}
if (value.length < 80 || value.length > 128) {
this.setFeedback('That address length looks off. Please double-check and try again.', 'error');
return;
}
if (!window.socket) {
this.setFeedback('Socket connection not ready. Try again shortly.', 'error');
return;
}
this.setPending(true);
this.setFeedback('Saving address…', 'info');
window.socket.emit('address:update', { address: value });
}
};
$(function() {
AddressModal.init();
});
window.AddressModal = AddressModal;
})(window, jQuery);
+40 -13
View File
@@ -13,6 +13,8 @@ const AudioAlerts = {
_lastPlay: 0,
_cooldownMs: 1500,
_audioTags: {}, // cache HTMLAudioElements
_gameStartHoldUntil: 0,
_pendingGameStartTimer: null,
_fileMap: {
request_coin: 'audio/Pleasesendcoin.m4a',
payment_detected: 'audio/Paymentdetected.m4a',
@@ -58,7 +60,7 @@ const AudioAlerts = {
const origQueueJoined = SocketHandlers.onQueueJoined;
SocketHandlers.onPaymentConfirmed = function(data) {
if (origPaymentConfirmed) origPaymentConfirmed.call(SocketHandlers, data);
AudioAlerts.playFile('payment_confirmed');
AudioAlerts.playPaymentConfirmed();
};
SocketHandlers.onPaymentDetected = function(data) {
if (origPaymentDetected) origPaymentDetected.call(SocketHandlers, data);
@@ -66,14 +68,7 @@ const AudioAlerts = {
};
SocketHandlers.onGameStart = function(data) {
if (origGameStart) origGameStart.call(SocketHandlers, data);
// Add delay to prevent overlap with payment_confirmed audio
const lastPaymentConfirmed = AudioAlerts._audioTagTimes && AudioAlerts._audioTagTimes['payment_confirmed'];
if (lastPaymentConfirmed && (Date.now() - lastPaymentConfirmed < 2000)) {
// Delay game_start audio if payment_confirmed played recently
setTimeout(() => AudioAlerts.playFile('game_start'), 1500);
} else {
AudioAlerts.playFile('game_start');
}
AudioAlerts.playGameStart();
};
SocketHandlers.onGameOver = function(data) {
if (origGameOver) origGameOver.call(SocketHandlers, data);
@@ -140,19 +135,51 @@ const AudioAlerts = {
return this._audioTags[tag];
},
playFile(tag) {
if (!this._enabled) return;
if (!this._enabled) return null;
const now = Date.now();
// Per-tag cooldown so different event types can fire back-to-back (e.g. payment_confirmed -> game_start)
if (!this._audioTagTimes) this._audioTagTimes = {}; // tag -> last play timestamp
const lastForTag = this._audioTagTimes[tag] || 0;
const elapsed = now - lastForTag;
const allow = elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start');
if (!allow) return;
if (!allow) return null;
this._audioTagTimes[tag] = now;
this._lastTag = tag;
const el = this._getAudio(tag);
if (!el) return;
try { el.currentTime = 0; el.play().catch(()=>{}); } catch(_) {}
if (!el) return null;
try {
el.currentTime = 0;
el.play().catch(()=>{});
} catch(_) {}
return el;
},
playPaymentConfirmed() {
if (!this._enabled) return;
const el = this.playFile('payment_confirmed');
const fallback = 2200;
let holdMs = fallback;
if (el && isFinite(el.duration) && el.duration > 0.2) {
holdMs = Math.ceil(el.duration * 1000);
}
this._gameStartHoldUntil = Date.now() + holdMs;
},
playGameStart() {
if (!this._enabled) return;
const now = Date.now();
if (this._pendingGameStartTimer) {
clearTimeout(this._pendingGameStartTimer);
this._pendingGameStartTimer = null;
}
const holdUntil = this._gameStartHoldUntil || 0;
if (holdUntil && now < holdUntil) {
const delay = Math.max(0, holdUntil - now);
this._pendingGameStartTimer = setTimeout(() => {
this._pendingGameStartTimer = null;
this.playFile('game_start');
}, delay);
return;
}
this.playFile('game_start');
},
playGameOverSound(gameOverData) {
if (!this._enabled) return;
+119
View File
@@ -206,3 +206,122 @@ li.debug {
border-left: 2px solid #0ff;
}
/* Utility */
.hidden {
display: none !important;
}
/* Modal styling */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
padding: 20px;
}
.modal-content {
background: #0a0a0f;
border: 2px solid #0ff;
border-radius: 6px;
width: min(480px, 90vw);
padding: 20px;
color: #0ff;
font-family: 'PixelOperator', monospace;
position: relative;
box-shadow: 0 0 18px rgba(0, 255, 255, 0.2);
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 8px;
font-size: 20px;
}
.modal-description {
margin-top: 0;
margin-bottom: 16px;
font-size: 12px;
color: #9cf;
}
.modal-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#addressModalInput {
width: 100%;
background: #020205;
border: 1px solid #0ff;
border-radius: 4px;
color: #0ff;
padding: 10px;
font-family: monospace;
resize: vertical;
min-height: 100px;
box-shadow: inset 0 0 6px rgba(0, 255, 255, 0.15);
}
.modal-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.modal-actions button {
padding: 8px 16px;
border-radius: 4px;
border: 1px solid #0ff;
background: #031c28;
color: #0ff;
cursor: pointer;
font-family: inherit;
}
.modal-actions button#addressModalSave {
background: #013d2a;
border-color: #0f7;
color: #0f7;
}
.modal-actions button:hover {
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
}
.modal-close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: none;
color: #0ff;
font-size: 18px;
cursor: pointer;
}
.modal-feedback {
margin-top: 12px;
font-size: 12px;
min-height: 16px;
}
.modal-feedback.error {
color: #ff6b6b;
}
.modal-feedback.success {
color: #5dff9c;
}
.modal-feedback.info {
color: #9cf6ff;
}
+29 -4
View File
@@ -22,6 +22,8 @@ class Game {
this.players = {};
this.monsters = {};
this.dungeon = null;
this.moveCount = 0;
this.startedAt = Date.now();
// Get default configuration from DungeonGenerator
const dungeonConfig = DungeonGenerator.getConfig();
@@ -140,7 +142,8 @@ class Game {
this.dungeon.map[newY][newX] !== undefined &&
(this.dungeon.map[newY][newX] === primaryFloor || this.dungeon.map[newY][newX] === secondaryFloor)) {
this.player.moveTo(newX, newY);
this.player.moveTo(newX, newY);
this.moveCount += 1;
if (CONSOLE_LOGGING) {
console.log(`Player moved to ${newX},${newY} in game for socket ${this.socketId}`);
}
@@ -151,7 +154,7 @@ class Game {
// Check for game events like finding treasure or exit
if (this.dungeon.exit && this.player.isAt(this.dungeon.exit[0], this.dungeon.exit[1])) {
this.gameState = 'won';
return { status: 'moved', event: 'escaped', player: this.player.getState(), visibleTiles: this.visibleTiles };
return { status: 'moved', event: 'escaped', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
}
if (this.dungeon.treasure && this.player.isAt(this.dungeon.treasure[0], this.dungeon.treasure[1]) && !this.player.hasTreasure) {
this.player.hasTreasure = true;
@@ -163,10 +166,10 @@ class Game {
console.log(`🗑️ TREASURE REMOVED: dungeon.treasure is now null for player ${this.socketId}`);
console.log(`Player ${this.socketId} collected treasure! hasTreasure: ${this.player.hasTreasure}`);
}
return { status: 'moved', event: 'treasure_found', player: this.player.getState(), visibleTiles: this.visibleTiles };
return { status: 'moved', event: 'treasure_found', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
}
return { status: 'moved', player: this.player.getState(), visibleTiles: this.visibleTiles };
return { status: 'moved', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
}
if (CONSOLE_LOGGING) {
@@ -176,7 +179,27 @@ class Game {
}
moveMonster() {
if (!this.monster || !this.player) {
return { status: 'idle' };
}
this.monster.moveTowardPlayer(this.player, this.dungeon);
if (this.monster.hasCaughtPlayer(this.player)) {
this.gameState = 'lost';
return {
status: 'monster_caught',
event: 'monster_caught',
player: this.player.getState(),
monster: this.monster.getState()
};
}
return {
status: 'moved',
player: this.player.getState(),
monster: this.monster.getState()
};
}
getState() {
@@ -189,6 +212,8 @@ class Game {
entrance: this.dungeon ? this.dungeon.entrance : null,
exit: this.dungeon ? this.dungeon.exit : null,
treasure: this.dungeon ? this.dungeon.treasure : null,
moves: this.moveCount,
startedAt: this.startedAt
};
if (CONSOLE_LOGGING) {
+39 -13
View File
@@ -54,12 +54,17 @@ class GameManager {
async handleGameOver(socket, game, status, reason, message, score = 0) {
try {
const socketId = socket.id || socket;
game.gameState = status;
const gameStats = {
score: score,
const moves = game.moveCount || 0;
const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null;
const finalScore = score > 0 ? score : this._calculateScore(game, status, reason);
const gameStats = {
score: finalScore,
reason: reason,
treasuresFound: game.player.hasTreasure ? 1 : 0
treasuresFound: game.player.hasTreasure ? 1 : 0,
moves,
durationSeconds
};
game.endGame(status, gameStats);
@@ -71,7 +76,8 @@ class GameManager {
socketId,
game.id,
status === 'won',
game.player.hasTreasure || false
game.player.hasTreasure || false,
{ moves, durationSeconds, score: finalScore }
);
if (this.debugManager.CONSOLE_LOGGING && payoutInfo) {
@@ -87,13 +93,15 @@ class GameManager {
status: status,
reason: reason,
message: message,
score: score,
score: finalScore,
moves,
durationSeconds,
payout: payoutInfo,
treasure: game.player.hasTreasure || false
});
// Persist completion details if DB available
await this._updateGameRecord(game, socketId, status, reason);
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
// Clean up game from active games
this.activeGames.delete(socketId);
@@ -199,6 +207,24 @@ class GameManager {
// Private helper methods
_calculateScore(game, status, reason) {
if (!game) return 0;
const moves = game.moveCount || 0;
let score = 0;
if (status === 'won' && reason === 'escaped') {
score += 100; // base win
if (game.player?.hasTreasure) {
score += 100; // treasure bonus
}
const moveBonus = Math.max(0, 100 - Math.max(moves - 40, 0) * 2);
score += moveBonus;
} else if (game.player?.hasTreasure) {
// Reached treasure but did not escape
score += 50;
}
return Math.max(0, Math.round(score));
}
/**
* Insert a database record for the game
* @param {Object} game - Game instance
@@ -213,8 +239,8 @@ class GameManager {
db.query(`
INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at)
VALUES ((SELECT id FROM users WHERE socket_id = $1), $1, $2, 'active', $3, $4, NOW())
`, [socketId, gameMode, blockHeight, game.id])
VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW())
`, [socketId, socketId, gameMode, blockHeight, game.id])
.catch(err => console.error('Game insert failed:', err.message));
}
}
@@ -226,16 +252,16 @@ class GameManager {
* @param {string} status - Final game status
* @param {string} reason - Reason for ending
*/
async _updateGameRecord(game, socketId, status, reason) {
async _updateGameRecord(game, socketId, status, reason, moves = 0, durationSeconds = null) {
if (this.gameModeManager && this.gameModeManager.db) {
const db = this.gameModeManager.db;
const outcome = reason === 'escaped' ? 'escaped' : (reason === 'monster' ? 'caught_by_monster' : reason);
try {
await db.query(`
UPDATE games SET status = $1, outcome = $2, treasure_found = $3, completed_at = NOW()
WHERE dungeon_seed = $4 AND socket_id = $5
`, [status, outcome, game.player.hasTreasure, game.id, socketId]);
UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4, duration_seconds = $5, completed_at = NOW()
WHERE dungeon_seed = $6 AND socket_id = $7
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId]);
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`✅ Updated game record for ${socketId}: ${status} (${outcome})`);
+31 -19
View File
@@ -425,12 +425,14 @@ class GameModeManager {
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
if (payoutAmount > 0) {
await this.walletService.processPayout(
game.user_id,
await this.walletService.processPayout({
userId: game.user_id,
gameId,
game.payout_address,
payoutAmount
);
address: game.payout_address,
amount: payoutAmount,
multiplier,
description: `Game ${gameId} payout`
});
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
}
@@ -644,10 +646,10 @@ class GameModeManager {
* @param {boolean} treasureFound - Whether treasure was found
* @returns {object} payout / completion info
*/
async completeGame(socketId, gameId, won, treasureFound) {
async completeGame(socketId, gameId, won, treasureFound, metrics = {}) {
// For FREE mode we just return basic info
if (this.gameMode === 'FREE') {
return { success: true, mode: 'FREE', payout: null };
return { success: true, mode: 'FREE', payout: null, score: metrics.score ?? null };
}
try {
@@ -655,9 +657,16 @@ class GameModeManager {
// If a games table exists with at least id & user reference, mark completion.
try {
await this.db.query(`
UPDATE games SET status = $1, treasure_found = $2, completed_at = NOW()
WHERE id = $3
`, [won ? 'won' : 'lost', treasureFound, gameId]);
UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW()
WHERE dungeon_seed = $5 AND socket_id = $6
`, [
won ? 'won' : 'lost',
treasureFound,
metrics.moves ?? null,
metrics.durationSeconds ?? null,
gameId,
socketId
]);
} catch (e) {
// Non-fatal if games table differs during early dev.
if (process.env.NODE_ENV === 'development') {
@@ -675,12 +684,14 @@ class GameModeManager {
const userRow = userResult.rows[0];
if (userRow && userRow.payout_address) {
try {
await this.walletService.processPayout(
userRow.id,
await this.walletService.processPayout({
userId: userRow.id,
gameId,
userRow.payout_address,
payoutAmount
);
address: userRow.payout_address,
amount: payoutAmount,
multiplier,
description: `Game ${gameId} payout`
});
return {
success: true,
mode: this.gameMode,
@@ -688,19 +699,20 @@ class GameModeManager {
amount: payoutAmount,
multiplier,
treasure: treasureFound
}
},
score: metrics.score ?? null
};
} catch (payoutErr) {
const normalizedPayout = normalizeError(payoutErr, 'Failed to send payout');
console.error('❌ Error creating payout:', normalizedPayout.message);
return { success: true, mode: this.gameMode, payout: null, payoutError: normalizedPayout.safeMessage };
return { success: true, mode: this.gameMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null };
}
}
return { success: true, mode: this.gameMode, payout: null, reason: 'No payout address' };
return { success: true, mode: this.gameMode, payout: null, reason: 'No payout address', score: metrics.score ?? null };
}
// Credits mode: decrement nothing here (already handled start). Optionally could award stats.
return { success: true, mode: this.gameMode, payout: null };
return { success: true, mode: this.gameMode, payout: null, score: metrics.score ?? null };
} catch (err) {
const normalized = normalizeError(err, 'Failed to complete game');
console.error('❌ completeGame error:', normalized.message);
+17
View File
@@ -64,6 +64,23 @@ class MovementManager {
this.io.to(socketId).emit('game_event', { event: moveResult.event });
}
}
emitGameUpdate(socketId) {
const game = this.activeGames.get(socketId);
if (!game) {
return;
}
let state;
if (typeof game.getState === 'function') {
state = game.getState();
} else {
state = { player: game.player };
}
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
state.blockHeight = this.debugManager.getCurrentBlockHeight();
}
this.io.to(socketId).emit('game_update', state);
}
}
module.exports = MovementManager;
+44 -7
View File
@@ -3,6 +3,10 @@
* Handles detection, pending confirmation, validation, and persistence of payout addresses.
* Keeps socketHandlers slimmer by encapsulating this concern.
*/
const { AppError, normalizeError } = require('../utils/errors');
const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/;
class AddressManager {
constructor({ gameModeManager, broadcastManager, io, debugManager, onConfirmed = null }) {
this.gameModeManager = gameModeManager;
@@ -15,11 +19,18 @@ class AddressManager {
detectInText(text) {
if (!text || typeof text !== 'string') return null;
const re = /((?:4|8)[0-9A-Za-z]{90,110}|Wo[0-9A-Za-z]{88,110})/; // Monero / Wownero style
const m = text.match(re);
if (!m) return null;
if (m[1].length < 80) return null;
return m[1];
const match = text.match(ADDRESS_REGEX);
if (!match) return null;
const candidate = match[1];
return this.isValidAddress(candidate) ? candidate : null;
}
isValidAddress(address) {
const value = typeof address === 'string' ? address.trim() : '';
if (!value) return false;
if (!ADDRESS_REGEX.test(value)) return false;
// Basic length guard for sanity (allowing integrated addresses)
return value.length >= 80 && value.length <= 120;
}
async handleDetection(socketId, address) {
@@ -27,7 +38,9 @@ class AddressManager {
if (this.gameModeManager) {
const userRow = await this.gameModeManager.getOrCreateUser(socketId);
if (userRow && userRow.payout_address === address) {
this.io.to(socketId).emit('address_confirmed', { address, message: 'Payout address already set.' });
const shortAddr = address.slice(0, 10) + '…' + address.slice(-6);
this.io.to(socketId).emit('address_confirmed', { address, message: `Payout address already set to ${shortAddr}. You can update it via the address manager.` });
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address already on file. Use the address button/command to replace it.');
return;
}
}
@@ -40,6 +53,29 @@ class AddressManager {
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address detected. Type confirm or cancel.');
}
async saveAddress(socketId, address, { autoConfirm = true } = {}) {
const trimmed = typeof address === 'string' ? address.trim() : '';
if (!trimmed) {
throw new AppError('No payout address supplied', {
safeMessage: 'Please enter a payout address.'
});
}
if (!this.isValidAddress(trimmed)) {
throw new AppError('Invalid payout address format', {
safeMessage: 'That does not look like a valid XMR/WOW address.'
});
}
this.pending.set(socketId, trimmed);
if (autoConfirm) {
await this.confirm(socketId, true);
}
return trimmed;
}
async confirm(socketId, accept) {
const pending = this.pending.get(socketId);
if (!pending) {
@@ -61,7 +97,8 @@ class AddressManager {
this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address saved.');
this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' });
} catch (e) {
this.broadcastManager?.sendStatusUpdate(socketId, 'error', 'Failed to save address. Try again.');
const normalized = normalizeError?.(e, 'Failed to save address') || e;
this.broadcastManager?.sendStatusUpdate(socketId, 'error', normalized.safeMessage || 'Failed to save address. Try again.');
return;
} finally {
this.pending.delete(socketId);
+30 -2
View File
@@ -102,6 +102,10 @@ class ChatHandler {
// Private helper methods
async promptAddress(socket) {
await this._promptAddress(socket);
}
async _handleAddressDetection(socket, detected) {
// Rate limiting for address setting
const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'address:set');
@@ -150,8 +154,7 @@ class ChatHandler {
case 'address':
case 'payout':
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
'Please paste your XMR/WOW address directly in chat. The system will automatically detect and confirm it.');
await this._promptAddress(socket);
return true;
case 'payment':
@@ -217,6 +220,31 @@ class ChatHandler {
console.log(`🧹 ChatHandler cleanup: removed ${cleanedCount} old timestamps`);
}
}
async _promptAddress(socket) {
let existing = null;
try {
if (this.gameModeManager) {
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
existing = userRow?.payout_address || null;
}
} catch (e) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn('Address prompt lookup failed:', e.message);
}
}
socket.emit('address_prompt', {
existingAddress: existing,
canUpdate: true
});
if (existing) {
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Update your payout address using the address manager.');
} else {
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Add your payout address to receive rewards.');
}
}
}
module.exports = ChatHandler;
+86 -12
View File
@@ -15,6 +15,7 @@ const ConnectionHandler = require('./connectionHandler');
const ChatHandler = require('./chatHandler');
const QueueHandler = require('./queueHandler');
const MemoryManager = require('../utils/memoryManager');
const { normalizeError } = require('../utils/errors');
class SocketHandlers {
constructor(io, activeGames, broadcastManager, debugManager, gameModeManager = null, walletService = null) {
@@ -149,7 +150,7 @@ class SocketHandlers {
io: this.io,
debugManager: this.debugManager,
moveCooldown: this.MOVE_COOLDOWN,
postMoveHook: ({ socketId, game }) => this.afterPlayerMove(socketId, game)
postMoveHook: ({ socketId, game, moveResult }) => this.afterPlayerMove(socketId, game, moveResult)
});
// Register memory cleanup functions
@@ -245,6 +246,10 @@ class SocketHandlers {
}
}
if (typeof game.moveMonster === 'function') {
game.moveMonster();
}
// Build updated state
let state;
if (typeof game.getState === 'function') {
@@ -269,24 +274,35 @@ class SocketHandlers {
* Called after a successful player move (before update is emitted) via MovementManager postMoveHook.
* Handles monster chasing logic and immediate game over if monster catches player.
*/
afterPlayerMove(socketId, game) {
afterPlayerMove(socketId, game, moveResult) {
let monsterResult = null;
// Move monster one step toward player each player action
if (game && typeof game.moveMonster === 'function') {
try {
game.moveMonster();
monsterResult = game.moveMonster();
} catch (e) {
console.error('Monster move error:', e);
}
}
// Check if monster catches player
try {
if (game && game.monster && game.player && game.monster.x === game.player.x && game.monster.y === game.player.y) {
const fakeSocket = { id: socketId };
this.handleGameOver(fakeSocket, game, 'lost', 'monster', 'You were caught by the monster!', 0);
}
} catch (e) {
console.error('Monster catch check error:', e);
// If the monster caught the player, end the game immediately
if (monsterResult && monsterResult.event === 'monster_caught') {
const fakeSocket = { id: socketId };
this.handleGameOver(fakeSocket, game, 'lost', 'monster', 'You were caught by the monster!', 0);
return;
}
// If player escaped or triggered another event, handle via game manager
if (moveResult && moveResult.event === 'escaped') {
const fakeSocket = { id: socketId };
this.handleGameOver(fakeSocket, game, 'won', 'escaped', 'You escaped the dungeon!', 0);
return;
}
// Re-emit state if treasure collected without auto game_update (fallback)
if (moveResult && moveResult.event === 'treasure_found') {
this.movementManager.emitGameUpdate(socketId);
}
}
@@ -303,16 +319,18 @@ class SocketHandlers {
handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket),
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
}));
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
socket.on('disconnect', () => this.handleDisconnect(socket));
socket.on('debug_ping', (data) => this.handleDebugPing(socket, data));
socket.on('register_client', (data) => this.connectionHandler.handleRegisterClient(socket, data));
socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
// Payment system handlers
socket.on('request_payment', (data) => this.paymentHandlers.handlePaymentRequest(socket, data));
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
}
/**
@@ -408,6 +426,62 @@ class SocketHandlers {
}
}
async handleAddressPrompt(socket) {
try {
if (this.chatHandler && typeof this.chatHandler.promptAddress === 'function') {
await this.chatHandler.promptAddress(socket);
return;
}
let existingAddress = null;
if (this.gameModeManager) {
try {
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
existingAddress = userRow?.payout_address || null;
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn('Address prompt lookup failed:', err.message);
}
}
}
this.io.to(socket.id).emit('address_prompt', {
existingAddress,
message: existingAddress ? 'Update your payout address anytime.' : 'Add a payout address to receive rewards.'
});
} catch (error) {
const normalized = normalizeError(error, 'Unable to open address manager');
this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.safeMessage || 'Address manager unavailable.');
}
}
async handleAddressUpdate(socket, data) {
try {
const address = typeof data?.address === 'string' ? data.address.trim() : '';
if (!address) {
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please enter a payout address before saving.');
this.io.to(socket.id).emit('address_update_error', { message: 'Please enter a payout address before saving.' });
return;
}
const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'address:set');
if (!rateLimitResult.allowed) {
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`Address changes are rate limited. Try again in ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`);
this.io.to(socket.id).emit('address_update_error', { message: 'Address changes are temporarily rate limited.' });
return;
}
await this.rateLimiter.recordAttempt(socket.id, 'address:set');
await this.addressManager.saveAddress(socket.id, address);
} catch (err) {
const normalized = normalizeError?.(err, 'Failed to update payout address') || err;
this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.safeMessage || 'Failed to update payout address.');
this.io.to(socket.id).emit('address_update_error', { message: normalized.safeMessage || 'Failed to update payout address.' });
}
}
/** Return the active socket instance by id (io.sockets.sockets Map) */
_getLiveSocket(socketId) {
try {
+87 -13
View File
@@ -282,24 +282,93 @@ class WalletRPCService {
return { processed: 0 };
}
async processPayout(recipientAddress, amount, description) {
normalizeAtomicAmount(value) {
if (value === undefined || value === null) {
throw new AppError('Payout amount was not provided', {
safeMessage: 'Invalid payout amount supplied.'
});
}
if (typeof value === 'bigint') {
if (value <= 0n) {
throw new AppError('Payout amount must be greater than zero', {
safeMessage: 'Invalid payout amount supplied.'
});
}
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
return value.toString();
}
return Number(value);
}
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
throw new AppError('Payout amount must be a positive number', {
safeMessage: 'Invalid payout amount supplied.'
});
}
const truncated = Math.trunc(num);
if (truncated <= 0) {
throw new AppError('Payout amount rounded below minimum atomic unit', {
safeMessage: 'Invalid payout amount supplied.'
});
}
return truncated;
}
async processPayout({
address,
amount,
userId = null,
gameId = null,
description = null,
multiplier = null,
subaddressIndex = null
} = {}) {
try {
// Send payment to user
const response = await this.rpcCall('transfer', {
if (!address || typeof address !== 'string') {
throw new AppError('Payout address was not provided or invalid', {
safeMessage: 'Invalid payout address supplied.'
});
}
const normalizedAmount = this.normalizeAtomicAmount(amount);
const transferParams = {
destinations: [{
amount: amount,
address: recipientAddress
amount: normalizedAmount,
address
}],
account_index: this.accountIndex,
priority: 1, // Normal priority
priority: 1,
get_tx_key: true
});
};
if (Number.isInteger(subaddressIndex) && subaddressIndex >= 0) {
transferParams.subaddr_indices = [subaddressIndex];
}
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`💸 Payout sent:`, {
to: recipientAddress.substring(0, 10) + '...',
amount: amount,
txHash: response.result.tx_hash
console.log('🚀 Initiating payout transfer', {
to: address.substring(0, 10) + '...',
amount: normalizedAmount,
userId,
gameId,
multiplier,
description
});
}
const response = await this.rpcCall('transfer', transferParams);
if (this.debugManager?.CONSOLE_LOGGING) {
console.log('💸 Payout sent successfully', {
to: address.substring(0, 10) + '...',
amount: normalizedAmount,
txHash: response.result.tx_hash,
fee: response.result.fee
});
}
@@ -307,14 +376,19 @@ class WalletRPCService {
success: true,
txHash: response.result.tx_hash,
txKey: response.result.tx_key,
fee: response.result.fee
fee: response.result.fee,
userId,
gameId,
amount: normalizedAmount
};
} catch (error) {
const wrapped = error instanceof AppError ? error : new ExternalServiceError('Failed to process payout', {
safeMessage: 'Unable to send payout at this time.',
cause: error
});
console.error('❌ Failed to process payout:', wrapped.message);
const meta = { userId, gameId, address: address?.substring?.(0, 10) + '...', amount };
console.error('❌ Failed to process payout:', wrapped.message, meta);
throw wrapped;
}
}
+50
View File
@@ -0,0 +1,50 @@
const AddressManager = require('../src/network/addressManager');
const { AppError } = require('../src/utils/errors');
describe('AddressManager', () => {
const sampleAddress = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
function createManager({ existingAddress = null } = {}) {
const emitMock = jest.fn();
const toMock = jest.fn().mockReturnValue({ emit: emitMock });
const broadcastMock = { sendStatusUpdate: jest.fn() };
const gameModeManager = {
setUserPayoutAddress: jest.fn().mockResolvedValue(true),
getOrCreateUser: jest.fn().mockResolvedValue({ payout_address: existingAddress })
};
const manager = new AddressManager({
gameModeManager,
broadcastManager: broadcastMock,
io: { to: toMock },
debugManager: { CONSOLE_LOGGING: false },
onConfirmed: jest.fn()
});
return { manager, emitMock, toMock, broadcastMock, gameModeManager };
}
test('saveAddress persists and emits confirmation', async () => {
const { manager, emitMock, gameModeManager } = createManager();
await manager.saveAddress('socket-1', sampleAddress);
expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-1', sampleAddress);
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ address: sampleAddress }));
});
test('saveAddress rejects invalid address input', async () => {
const { manager } = createManager();
await expect(manager.saveAddress('socket-2', 'not-an-address')).rejects.toBeInstanceOf(AppError);
});
test('handleDetection skips confirmation when address already set', async () => {
const { manager, emitMock, gameModeManager } = createManager({ existingAddress: sampleAddress });
await manager.handleDetection('socket-3', sampleAddress);
expect(gameModeManager.getOrCreateUser).toHaveBeenCalled();
expect(manager.pending.has('socket-3')).toBe(false);
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ message: expect.stringContaining('already set') }));
});
});