265 lines
9.3 KiB
JavaScript
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;
|