diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..a43399a --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --production +COPY server.js ./ +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..b4ad166 --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "tamagotchi-api", + "version": "1.0.0", + "description": "Tamagotchi as a Service โ Backend API with Prometheus metrics", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.21.0", + "pg": "^8.13.0", + "prom-client": "^15.1.0", + "cors": "^2.8.5", + "uuid": "^10.0.0" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..22c0ad7 --- /dev/null +++ b/api/server.js @@ -0,0 +1,368 @@ +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); + }); diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1669669 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ +EXPOSE 80 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..c00dd36 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,295 @@ +// ============================================================================ +// Tamagotchi as a Service โ Frontend Application +// ============================================================================ + +const API_BASE = window.TAMAGOTCHI_API_URL || '/api'; +const REFRESH_INTERVAL = 5000; // Refresh creature stats every 5 seconds + +// Creature type emoji map +const TYPE_EMOJI = { + dragon: '๐', + cat: '๐ฑ', + robot: '๐ค', + plant: '๐ฑ', + alien: '๐ฝ', +}; + +// i18n translations +const i18n = { + en: { + adopt_title: 'Adopt a Creature', + name_label: 'Name', + type_label: 'Type', + cancel: 'Cancel', + adopt_btn: 'Adopt! ๐', + hunger: 'Hunger', + happiness: 'Happiness', + energy: 'Energy', + feed: '๐ Feed', + play: '๐ฎ Play', + sleep: '๐ค Sleep', + revive: 'โจ Revive', + alive: 'Alive', + dead: 'Dead', + empty_title: 'No creatures yet!', + empty_subtitle: 'Click the + button to adopt your first creature.', + }, + fr: { + adopt_title: 'Adopter une Crรฉature', + name_label: 'Nom', + type_label: 'Type', + cancel: 'Annuler', + adopt_btn: 'Adopter ! ๐', + hunger: 'Faim', + happiness: 'Bonheur', + energy: 'รnergie', + feed: '๐ Nourrir', + play: '๐ฎ Jouer', + sleep: '๐ค Dormir', + revive: 'โจ Ressusciter', + alive: 'Vivant', + dead: 'Mort', + empty_title: 'Pas encore de crรฉatures !', + empty_subtitle: 'Cliquez sur le bouton + pour adopter votre premiรจre crรฉature.', + }, +}; + +let currentLang = 'en'; +let creatures = []; + +// ---- i18n ---- +function t(key) { + return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key; +} + +function setLang(lang) { + currentLang = lang; + document.querySelectorAll('.lang-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.lang === lang); + }); + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + renderCreatures(); +} + +// ---- API Calls ---- +async function fetchCreatures() { + try { + const res = await fetch(`${API_BASE}/creatures`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + creatures = await res.json(); + renderCreatures(); + updateGlobalStats(); + } catch (err) { + console.error('Failed to fetch creatures:', err); + } +} + +async function fetchGlobalStats() { + try { + const res = await fetch(`${API_BASE}/stats`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const stats = await res.json(); + document.getElementById('aliveCount').textContent = stats.alive_count || 0; + document.getElementById('deadCount').textContent = stats.dead_count || 0; + document.getElementById('starvingCount').textContent = stats.starving_count || 0; + } catch (err) { + console.error('Failed to fetch stats:', err); + } +} + +function updateGlobalStats() { + const alive = creatures.filter(c => c.is_alive).length; + const dead = creatures.filter(c => !c.is_alive).length; + const starving = creatures.filter(c => c.is_alive && c.hunger > 80).length; + document.getElementById('aliveCount').textContent = alive; + document.getElementById('deadCount').textContent = dead; + document.getElementById('starvingCount').textContent = starving; +} + +async function adoptCreature(name, type) { + try { + const res = await fetch(`${API_BASE}/creatures`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const creature = await res.json(); + showToast(`${creature.name} ${TYPE_EMOJI[creature.type]} has been adopted!`); + await fetchCreatures(); + } catch (err) { + showToast(`Failed to adopt creature: ${err.message}`); + } +} + +async function creatureAction(id, action) { + try { + const res = await fetch(`${API_BASE}/creatures/${id}/${action}`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + showToast(data.error || `Action failed`); + return; + } + const data = await res.json(); + showToast(data.message); + await fetchCreatures(); + } catch (err) { + showToast(`Action failed: ${err.message}`); + } +} + +// ---- Rendering ---- +function getStatColor(stat, value) { + if (stat === 'hunger') { + if (value > 80) return 'var(--accent-red)'; + if (value > 50) return 'var(--accent-orange)'; + return 'var(--accent-green)'; + } + if (value < 20) return 'var(--accent-red)'; + if (value < 50) return 'var(--accent-yellow)'; + return stat === 'happiness' ? 'var(--accent-purple)' : 'var(--accent-cyan)'; +} + +function renderCreatures() { + const grid = document.getElementById('creaturesGrid'); + + if (creatures.length === 0) { + grid.innerHTML = ` +
${t('empty_subtitle')}
+