Files
rltn/economy.js

265 lines
9.3 KiB
JavaScript

const { readFileSync } = require('fs');
const path = require('path');
const fetch = require('node-fetch');
class Economy {
constructor(io, db) {
this.io = io;
this.db = db;
const cfgPath = path.join(__dirname, 'economy', 'config.json');
this.config = JSON.parse(readFileSync(cfgPath, 'utf8'));
this.batch = new Map();
this.initTables().catch(err => this.log('error', 'initTables', err));
this.ensureTreasuryRows().catch(err => this.log('error', 'ensureTreasury', err));
this.registerSocketHandlers();
this.interval = setInterval(() => this.flushBatch(), this.config.batchIntervalMinutes * 60 * 1000);
}
async initTables() {
await this.db.query(`CREATE TABLE IF NOT EXISTS treasury (
id SERIAL PRIMARY KEY,
country_code TEXT UNIQUE,
balance NUMERIC DEFAULT 0
);`);
await this.db.query(`CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
currency TEXT,
balance NUMERIC
);`);
await this.db.query(`CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
from_account INTEGER,
to_account INTEGER,
amount NUMERIC,
currency TEXT,
type TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);`);
await this.db.query(`CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
key TEXT UNIQUE,
name TEXT NOT NULL,
type TEXT,
weight NUMERIC DEFAULT 1,
hunger_gain NUMERIC DEFAULT 0,
thirst_gain NUMERIC DEFAULT 0,
stackable BOOLEAN DEFAULT true,
functions JSONB DEFAULT '{}'::jsonb
);`);
await this.db.query(`CREATE TABLE IF NOT EXISTS inventory (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
item_id INTEGER,
name TEXT,
quantity INTEGER,
stackable BOOLEAN,
weight NUMERIC
);`);
await this.db.query(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_user_item ON inventory(user_id, item_id)'
);
await this.db.query(
'ALTER TABLE users ADD COLUMN IF NOT EXISTS satiety NUMERIC DEFAULT 100'
);
await this.db.query(
'ALTER TABLE users ADD COLUMN IF NOT EXISTS thirst NUMERIC DEFAULT 100'
);
}
async ensureTreasuryRows() {
try {
// ищем подходящее поле для "кода страны"
const cand = await this.db.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'countries'
AND column_name IN ('code','iso_code','alpha2','alpha3')
LIMIT 1
`);
const col = cand.rows[0]?.column_name || 'name';
const { rows } = await this.db.query(`SELECT ${col} AS code FROM countries`);
for (const r of rows) {
await this.db.query(
'INSERT INTO treasury(country_code) VALUES($1) ON CONFLICT (country_code) DO NOTHING',
[String(r.code)]
);
}
} catch (e) {
this.log('error', 'ensureTreasuryRows failed', e);
}
}
async createAccount(userId, currency) {
const res = await this.db.query('SELECT balance FROM users WHERE id=$1', [userId]);
const initial = res.rows[0] ? parseFloat(res.rows[0].balance) : this.config.startBalance;
if (!res.rows.length) return;
if (res.rows[0].balance == null) {
await this.db.query('UPDATE users SET balance=$2 WHERE id=$1', [userId, initial]);
}
await this.db.query(
'INSERT INTO accounts(user_id, currency, balance) VALUES($1,$2,$3) ON CONFLICT (user_id, currency) DO NOTHING',
[userId, currency, initial]
);
this.log('info', `Account ensured for user ${userId} with balance ${initial}`);
}
async getBalance(userId, currency) {
this.log('info', 'getBalance', { userId, currency });
const { rows } = await this.db.query('SELECT balance FROM users WHERE id=$1', [userId]);
return rows[0] ? parseFloat(rows[0].balance) : 0;
}
async transfer(fromUser, toUser, amount, currency, type) {
this.log('info', 'transfer begin', { fromUser, toUser, amount, currency, type });
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const fromRes = await client.query(
'UPDATE users SET balance = balance - $1 WHERE id=$2 RETURNING balance',
[amount, fromUser]
);
const toRes = await client.query(
'UPDATE users SET balance = balance + $1 WHERE id=$2 RETURNING balance',
[amount, toUser]
);
await client.query(
'INSERT INTO transactions(from_account,to_account,amount,currency,type) VALUES($1,$2,$3,$4,$5)',
[fromUser, toUser, amount, currency, type]
);
await client.query('COMMIT');
this.io.emit('economy:balanceChanged', { userId: fromUser, currency, newBalance: fromRes.rows[0].balance });
this.io.emit('economy:balanceChanged', { userId: toUser, currency, newBalance: toRes.rows[0].balance });
this.io.emit('economy:transactionRecorded', { fromUser, toUser, amount, currency, type });
} catch (e) {
await client.query('ROLLBACK');
this.log('error', 'transfer failed', e);
throw e;
} finally {
client.release();
}
}
convert(amount, fromCurrency, toCurrency) {
this.log('info', 'convert request', { amount, fromCurrency, toCurrency });
this.io.emit('economy:exchangeRateRequested', { fromCurrency, toCurrency });
const rates = this.config.exchangeRates;
const usd = amount / rates[fromCurrency];
const result = usd * rates[toCurrency];
this.io.emit('economy:exchangePerformed', { amount, fromCurrency, toCurrency, result });
return result;
}
async addItem(userId, item) {
await this.db.query(
`INSERT INTO inventory(user_id, item_id, name, quantity, stackable, weight)
VALUES($1,$2,$3,$4,$5,$6)
ON CONFLICT (user_id, item_id) DO UPDATE SET quantity = inventory.quantity + EXCLUDED.quantity`,
[userId, item.item_id, item.name, item.quantity, item.stackable, item.weight]
);
this.log('info', 'addItem', { userId, item });
}
async removeItem(userId, itemId, quantity) {
await this.db.query(
`UPDATE inventory SET quantity = GREATEST(quantity - $3,0) WHERE user_id=$1 AND item_id=$2`,
[userId, itemId, quantity]
);
await this.db.query('DELETE FROM inventory WHERE user_id=$1 AND item_id=$2 AND quantity<=0', [userId, itemId]);
this.log('info', 'removeItem', { userId, itemId, quantity });
}
async getInventory(userId) {
const { rows } = await this.db.query('SELECT * FROM inventory WHERE user_id=$1', [userId]);
return rows;
}
queueUpdate(update) {
const existing = this.batch.get(update.userId) || {};
this.batch.set(update.userId, { ...existing, ...update });
}
async flushBatch() {
for (const upd of this.batch.values()) {
try {
await this.db.query(
'UPDATE users SET health_level = COALESCE($2, health_level), satiety = COALESCE($3, satiety), thirst = COALESCE($4, thirst) WHERE id=$1',
[upd.userId, upd.health, upd.satiety, upd.thirst]
);
} catch (e) {
this.log('error', 'flushBatch error', e);
}
}
this.batch.clear();
}
registerSocketHandlers() {
this.io.on('connection', socket => {
socket.on('economy:getBalance', async ({ userId }) => {
const effectiveUserId = userId ?? socket.userId;
const bal = await this.getBalance(effectiveUserId);
socket.emit('economy:balanceChanged', { userId: effectiveUserId, newBalance: bal });
});
socket.on('economy:transfer', async data => {
try {
await this.transfer(data.fromUser, data.toUser, data.amount, data.currency, data.type);
} catch (e) {
socket.emit('economy:error', { message: 'transfer failed' });
}
});
socket.on('economy:buyItem', async ({ userId, item }) => {
await this.addItem(userId, item);
});
socket.on('economy:getInventory', async ({ userId }) => {
socket.emit('economy:inventory', await this.getInventory(userId));
});
socket.on('economy:removeItem', async ({ userId, itemId, quantity }) => {
await this.removeItem(userId, itemId, quantity);
socket.emit('economy:inventory', await this.getInventory(userId));
});
socket.on('economy:updateStats', data => {
this.queueUpdate({ userId: socket.userId, ...data });
});
socket.on('economy:exchange', ({ amount, fromCurrency, toCurrency }) => {
const result = this.convert(amount, fromCurrency, toCurrency);
socket.emit('economy:exchangeResult', { result });
});
});
}
async log(level, message, meta) {
const entry = { level, message, meta, timestamp: new Date().toISOString() };
console[level === 'error' ? 'error' : 'log'](`[Economy] ${message}`, meta || '');
const url = this.config.monitoringEndpoint;
// отключаем внешний вызов, если endpoint пустой или примерный
if (!url || /(^|\.)example\.com$/.test(new URL(url).hostname)) return;
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
} catch (e) {
// глушим сетевую ошибку, чтобы не засорять логи
}
}
}
module.exports = Economy;