mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
add address modal and try to fix payout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user