Files
tamagotchi-service/api/server.js
T
khalil fbf5135dc8 feat: Initial Tamagotchi as a Service 🐣
- 3-tier architecture: Frontend (Nginx) → API (Node.js) → PostgreSQL
- Custom Prometheus metrics: hunger_level, happiness_score, energy_level
- Creature decay loop: stats degrade every 15 seconds
- Bilingual UI (EN/FR)
2026-07-01 16:36:48 +00:00

369 lines
14 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const { v4: uuidv4 } = require('uuid');
const client = require('prom-client');
const app = express();
app.use(cors());
app.use(express.json());
// ============================================================================
// Prometheus Metrics Setup
// ============================================================================
const register = new client.Registry();
client.collectDefaultMetrics({ register });
// Custom Tamagotchi metrics
const hungerGauge = new client.Gauge({
name: 'tamagotchi_hunger_level',
help: 'Current hunger level of each creature (0=full, 100=starving)',
labelNames: ['creature_id', 'creature_name', 'creature_type'],
registers: [register],
});
const happinessGauge = new client.Gauge({
name: 'tamagotchi_happiness_score',
help: 'Current happiness score of each creature (0=miserable, 100=ecstatic)',
labelNames: ['creature_id', 'creature_name', 'creature_type'],
registers: [register],
});
const energyGauge = new client.Gauge({
name: 'tamagotchi_energy_level',
help: 'Current energy level of each creature (0=exhausted, 100=hyperactive)',
labelNames: ['creature_id', 'creature_name', 'creature_type'],
registers: [register],
});
const aliveGauge = new client.Gauge({
name: 'tamagotchi_creatures_alive_total',
help: 'Total number of creatures currently alive',
registers: [register],
});
const deadGauge = new client.Gauge({
name: 'tamagotchi_creatures_dead_total',
help: 'Total number of creatures that have died',
registers: [register],
});
const feedCounter = new client.Counter({
name: 'tamagotchi_feed_actions_total',
help: 'Total number of feed actions performed',
labelNames: ['creature_type'],
registers: [register],
});
const playCounter = new client.Counter({
name: 'tamagotchi_play_actions_total',
help: 'Total number of play actions performed',
labelNames: ['creature_type'],
registers: [register],
});
const sleepCounter = new client.Counter({
name: 'tamagotchi_sleep_actions_total',
help: 'Total number of sleep actions performed',
labelNames: ['creature_type'],
registers: [register],
});
const httpRequestDuration = new client.Histogram({
name: 'tamagotchi_http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
const httpRequestsTotal = new client.Counter({
name: 'tamagotchi_http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [register],
});
// HTTP metrics middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route ? req.route.path : req.path;
httpRequestDuration.observe({ method: req.method, route, status_code: res.statusCode }, duration);
httpRequestsTotal.inc({ method: req.method, route, status_code: res.statusCode });
});
next();
});
// ============================================================================
// Database Setup
// ============================================================================
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'tamagotchi',
user: process.env.DB_USER || 'tamagotchi',
password: process.env.DB_PASSWORD || 'tamagotchi123',
});
async function initDB() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS creatures (
id UUID PRIMARY KEY,
name VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'egg',
hunger FLOAT NOT NULL DEFAULT 50,
happiness FLOAT NOT NULL DEFAULT 70,
energy FLOAT NOT NULL DEFAULT 80,
is_alive BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
last_interaction TIMESTAMP DEFAULT NOW()
)
`);
// Seed some default creatures if table is empty
const { rows } = await client.query('SELECT COUNT(*) FROM creatures');
if (parseInt(rows[0].count) === 0) {
const seedCreatures = [
{ name: 'Pixel', type: 'dragon' },
{ name: 'Nimbus', type: 'cat' },
{ name: 'Sprocket', type: 'robot' },
{ name: 'Blossom', type: 'plant' },
{ name: 'Byte', type: 'alien' },
];
for (const c of seedCreatures) {
await client.query(
'INSERT INTO creatures (id, name, type, hunger, happiness, energy) VALUES ($1, $2, $3, $4, $5, $6)',
[uuidv4(), c.name, c.type, 30 + Math.random() * 40, 50 + Math.random() * 40, 60 + Math.random() * 30]
);
}
console.log('🐣 Seeded 5 default creatures');
}
} finally {
client.release();
}
}
// ============================================================================
// Creature Decay Loop — Stats decrease over time!
// ============================================================================
const DECAY_INTERVAL_MS = parseInt(process.env.DECAY_INTERVAL_MS || '15000'); // every 15 seconds
const HUNGER_DECAY = parseFloat(process.env.HUNGER_DECAY || '2'); // hunger increases by 2
const HAPPINESS_DECAY = parseFloat(process.env.HAPPINESS_DECAY || '1.5'); // happiness decreases by 1.5
const ENERGY_DECAY = parseFloat(process.env.ENERGY_DECAY || '1'); // energy decreases by 1
setInterval(async () => {
try {
const client = await pool.connect();
try {
// Increase hunger, decrease happiness and energy for alive creatures
await client.query(`
UPDATE creatures
SET
hunger = LEAST(100, hunger + $1),
happiness = GREATEST(0, happiness - $2),
energy = GREATEST(0, energy - $3),
is_alive = CASE
WHEN hunger >= 100 AND happiness <= 0 THEN false
ELSE is_alive
END
WHERE is_alive = true
`, [HUNGER_DECAY, HAPPINESS_DECAY, ENERGY_DECAY]);
// Update Prometheus metrics
const { rows } = await client.query('SELECT * FROM creatures');
let alive = 0, dead = 0;
for (const c of rows) {
const labels = { creature_id: c.id, creature_name: c.name, creature_type: c.type };
hungerGauge.set(labels, c.hunger);
happinessGauge.set(labels, c.happiness);
energyGauge.set(labels, c.energy);
if (c.is_alive) alive++; else dead++;
}
aliveGauge.set(alive);
deadGauge.set(dead);
} finally {
client.release();
}
} catch (err) {
console.error('Decay loop error:', err.message);
}
}, DECAY_INTERVAL_MS);
// ============================================================================
// API Routes
// ============================================================================
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Prometheus metrics endpoint
app.get('/metrics', async (req, res) => {
try {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch (err) {
res.status(500).end(err.message);
}
});
// GET /creatures — List all creatures
app.get('/api/creatures', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures ORDER BY created_at ASC');
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /creatures/:id — Get a specific creature
app.get('/api/creatures/:id', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /creatures — Adopt a new creature
app.post('/api/creatures', async (req, res) => {
try {
const { name, type } = req.body;
if (!name || !type) return res.status(400).json({ error: 'name and type are required' });
const allowedTypes = ['dragon', 'cat', 'robot', 'plant', 'alien'];
if (!allowedTypes.includes(type)) {
return res.status(400).json({ error: `type must be one of: ${allowedTypes.join(', ')}` });
}
const id = uuidv4();
await pool.query(
'INSERT INTO creatures (id, name, type) VALUES ($1, $2, $3)',
[id, name, type]
);
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [id]);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /creatures/:id/feed — Feed a creature
app.post('/api/creatures/:id/feed', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' });
if (!rows[0].is_alive) return res.status(400).json({ error: 'Cannot feed a dead creature 💀' });
await pool.query(
'UPDATE creatures SET hunger = GREATEST(0, hunger - 25), happiness = LEAST(100, happiness + 5), last_interaction = NOW() WHERE id = $1',
[req.params.id]
);
feedCounter.inc({ creature_type: rows[0].type });
const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
res.json({ message: `${result.rows[0].name} has been fed! 🍖`, creature: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /creatures/:id/play — Play with a creature
app.post('/api/creatures/:id/play', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' });
if (!rows[0].is_alive) return res.status(400).json({ error: 'Cannot play with a dead creature 💀' });
await pool.query(
'UPDATE creatures SET happiness = LEAST(100, happiness + 20), energy = GREATEST(0, energy - 10), hunger = LEAST(100, hunger + 5), last_interaction = NOW() WHERE id = $1',
[req.params.id]
);
playCounter.inc({ creature_type: rows[0].type });
const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
res.json({ message: `${result.rows[0].name} had a great time! 🎮`, creature: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /creatures/:id/sleep — Put a creature to sleep
app.post('/api/creatures/:id/sleep', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' });
if (!rows[0].is_alive) return res.status(400).json({ error: 'This creature is already in eternal sleep 💀' });
await pool.query(
'UPDATE creatures SET energy = LEAST(100, energy + 30), hunger = LEAST(100, hunger + 5), last_interaction = NOW() WHERE id = $1',
[req.params.id]
);
sleepCounter.inc({ creature_type: rows[0].type });
const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
res.json({ message: `${result.rows[0].name} is well rested! 💤`, creature: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /creatures/:id/revive — Revive a dead creature (admin only concept)
app.post('/api/creatures/:id/revive', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' });
if (rows[0].is_alive) return res.status(400).json({ error: 'This creature is already alive!' });
await pool.query(
'UPDATE creatures SET is_alive = true, hunger = 50, happiness = 50, energy = 50, last_interaction = NOW() WHERE id = $1',
[req.params.id]
);
const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]);
res.json({ message: `${result.rows[0].name} has been revived! ✨`, creature: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /stats — Global statistics
app.get('/api/stats', async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE is_alive = true) as alive_count,
COUNT(*) FILTER (WHERE is_alive = false) as dead_count,
ROUND(AVG(hunger)::numeric, 1) FILTER (WHERE is_alive = true) as avg_hunger,
ROUND(AVG(happiness)::numeric, 1) FILTER (WHERE is_alive = true) as avg_happiness,
ROUND(AVG(energy)::numeric, 1) FILTER (WHERE is_alive = true) as avg_energy,
COUNT(*) FILTER (WHERE hunger > 80 AND is_alive = true) as starving_count,
COUNT(*) FILTER (WHERE happiness < 20 AND is_alive = true) as sad_count
FROM creatures
`);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============================================================================
// Start Server
// ============================================================================
const PORT = parseInt(process.env.PORT || '8080');
initDB()
.then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`🐣 Tamagotchi API running on port ${PORT}`);
console.log(`📊 Metrics available at /metrics`);
console.log(`⏱️ Decay interval: ${DECAY_INTERVAL_MS}ms`);
});
})
.catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});