Wownerogue

A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
/**
* Database Connection Manager
* Handles PostgreSQL connection pool and migration management
*/
const { Pool } = require('pg');
const fs = require('fs').promises;
const path = require('path');
/**
* Query validator to detect potential SQL injection
*/
class QueryValidator {
isSafe(text, params) {
// Check if using parameterized query properly
// Count unique placeholders ($1, $2, etc.): PostgreSQL allows reusing $1 multiple times
const placeholders = text.match(/\$\d+/g) || [];
const uniquePlaceholders = new Set(placeholders);
const placeholderCount = uniquePlaceholders.size;
if (placeholderCount !== params.length) {
console.warn('⚠️ Parameter count mismatch in query');
return false;
}
return true;
}
validateQuery(query) {
if (typeof query === 'string') {
console.warn('⚠️ Raw string query detected - should use parameterized queries');
}
}
}
class DatabaseManager {
constructor() {
this.pool = null;
this.connected = false;
this.lastSuccessfulQueryAt = 0;
this.lastFailedQueryAt = 0;
this.queryValidator = new QueryValidator();
}
/**
* Initialize database connection and run migrations
*/
async initialize() {
const connected = await this.init();
if (connected) {
await this.runMigrations();
}
}
/**
* Initialize database connection
*/
async init() {
try {
this.pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'wownerogue',
user: process.env.DB_USER || 'wownerogue',
password: process.env.DB_PASSWORD,
max: 40, // Maximum number of clients in the pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Add query monitoring for security
this.pool.on('query', (query) => {
if (process.env.DEBUG === 'true') {
this.queryValidator.validateQuery(query);
}
});
// Test connection
const client = await this.pool.connect();
await client.query('SELECT NOW()');
client.release();
this.connected = true;
this.lastSuccessfulQueryAt = Date.now();
console.log('✅ Database connected successfully');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error.message);
this.connected = false;
return false;
}
}
/**
* Run database migrations
*/
async runMigrations() {
try {
// Simple migration ledger
await this.pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT NOW())`);
const migrationsPath = path.join(__dirname, '../migrations');
const files = await fs.readdir(migrationsPath);
const sqlFiles = files.filter(file => file.endsWith('.sql')).sort();
console.log(`📁 Found ${sqlFiles.length} migration files`);
for (const file of sqlFiles) {
// Skip if already applied
const existing = await this.pool.query(`SELECT 1 FROM schema_migrations WHERE filename = $1`, [file]);
if (existing.rowCount > 0) {
console.log(`⏩ Skipping already applied migration: ${file}`);
continue;
}
const filePath = path.join(migrationsPath, file);
const sql = await fs.readFile(filePath, 'utf8');
console.log(`🔄 Running migration: ${file}`);
// Apply the migration AND record it in the ledger atomically in one
// transaction, so a migration can never be applied-but-unrecorded (which
// would re-run a possibly-non-idempotent migration on the next boot).
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
await client.query('COMMIT');
} catch (migErr) {
await client.query('ROLLBACK').catch(() => {});
throw migErr;
} finally {
client.release();
}
console.log(`✅ Migration completed: ${file}`);
}
console.log('✅ Migration phase complete');
} catch (error) {
console.error('❌ Migration failed:', error.message);
throw error;
}
}
/**
* Execute a query with security validation
*/
async query(text, params = []) {
if (!this.connected) {
throw new Error('Database not connected');
}
// Validate query structure for security
if (!this.queryValidator.isSafe(text, params)) {
throw new Error('Potentially unsafe query detected');
}
try {
const start = Date.now();
const result = await this.pool.query(text, params);
this.lastSuccessfulQueryAt = Date.now();
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
console.log('📊 Query executed:', { text: text.substring(0, 100), duration, rows: result.rowCount });
}
return result;
} catch (error) {
this.lastFailedQueryAt = Date.now();
console.error('❌ Database query error:', error.message);
console.error('Query:', text);
throw error;
}
}
/**
* Helper method to ensure parameterized queries
*/
async safeQuery(template, values = {}) {
const { text, params } = this.buildParameterizedQuery(template, values);
return this.query(text, params);
}
/**
* Build parameterized query from template
*/
buildParameterizedQuery(template, values) {
let paramIndex = 1;
const params = [];
const text = template.replace(/:(\w+)/g, (match, key) => {
if (key in values) {
params.push(values[key]);
return `$${paramIndex++}`;
}
throw new Error(`Missing parameter: ${key}`);
});
return { text, params };
}
/**
* Get a client from the pool for transactions
*/
async getClient() {
if (!this.connected) {
throw new Error('Database not connected');
}
return await this.pool.connect();
}
/**
* Execute a callback within a database transaction
* Automatically handles BEGIN, COMMIT, and ROLLBACK
* @param {Function} callback - Async function that receives the client
* @returns {Promise<any>} - Result from the callback
*/
async withTransaction(callback) {
const client = await this.getClient();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Close database connection
*/
async close() {
if (this.pool) {
await this.pool.end();
this.connected = false;
console.log('📴 Database connection closed');
}
}
/**
* Get connection status
*/
isConnected() {
return this.connected;
}
isHealthy(maxStaleMs = Number(process.env.DB_HEALTH_MAX_STALE_MS) || 30000) {
return this.connected
&& this.lastSuccessfulQueryAt > 0
&& (Date.now() - this.lastSuccessfulQueryAt) <= maxStaleMs;
}
/**
* Health check
*/
async healthCheck() {
try {
const result = await this.query('SELECT 1 as status, NOW() as timestamp');
return {
status: 'healthy',
timestamp: result.rows[0].timestamp,
connected: this.connected
};
} catch (error) {
return {
status: 'unhealthy',
error: error.message,
connected: false
};
}
}
}
module.exports = DatabaseManager;
+99
View File
@@ -0,0 +1,99 @@
const Game = require('../game/game');
// Verbose logging is limited to debug and development environments.
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
class User {
constructor(id, address) {
this.id = id; // socket.id
this.address = address;
this.currentGame = null;
this.blockRec = 0; // Block height used for game entry timing
this.clientId = null; // socket.client.id, a distinct value from socket.id
this.stats = {
gamesPlayed: 0,
gamesWon: 0,
gamesLost: 0,
totalPlayTime: 0,
highestScore: 0,
treasuresFound: 0,
monstersDefeated: 0
};
// Registration happens in the constructor so every User is reachable by socket id.
userRegistry.set(id, this);
if (CONSOLE_LOGGING) {
console.log(`User created and registered: ${id}`);
}
}
/**
* Start tracking a new game for this user.
* @param {Game} gameInstance - The game instance this user is playing
*/
joinGame(gameInstance) {
this.currentGame = gameInstance;
this.stats.gamesPlayed++;
if (CONSOLE_LOGGING) {
console.log(`[User.joinGame] User ${this.id} joined game ${gameInstance.id}. Total games played: ${this.stats.gamesPlayed}`);
}
}
/**
* Record the end of a game and fold its results into the user statistics.
* Does nothing when the user has no current game.
* @param {string} result - 'won', 'lost', or 'abandoned'
* @param {number} score - Final score
* @param {object} gameStats - Additional game statistics
*/
endGame(result, score = 0, gameStats = {}) {
if (this.currentGame) {
this.currentGame = null;
if (result === 'won') {
this.stats.gamesWon++;
} else if (result === 'lost') {
this.stats.gamesLost++;
}
if (score > this.stats.highestScore) {
this.stats.highestScore = score;
}
if (gameStats.treasuresFound) {
this.stats.treasuresFound += gameStats.treasuresFound;
}
if (gameStats.monstersDefeated) {
this.stats.monstersDefeated += gameStats.monstersDefeated;
}
if (CONSOLE_LOGGING) {
console.log(`[User.endGame] User ${this.id} game ended: ${result}. Score: ${score}. Stats: ${JSON.stringify(this.stats)}`);
}
}
}
/**
* @returns {object} A copy of the stats, so callers cannot mutate the user's own counters.
*/
getStats() {
return { ...this.stats };
}
isInGame() {
return this.currentGame !== null;
}
}
// Maps socket.id to User. Declared after the class body; the constructor only reads it at call time,
// which is always after this binding is initialised.
const userRegistry = new Map();
module.exports = {
User,
getUserBySocketId: (socketId) => userRegistry.get(socketId),
removeUser: (socketId) => userRegistry.delete(socketId),
getAllUsers: () => Array.from(userRegistry.values())
};