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)
This commit is contained in:
2026-07-01 16:36:48 +00:00
parent a7220a9a5b
commit fbf5135dc8
9 changed files with 1496 additions and 0 deletions
+7
View File
@@ -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"]
+17
View File
@@ -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"
}
}
+368
View File
@@ -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);
});
+6
View File
@@ -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
+295
View File
@@ -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 = `
<div class="empty-state">
<div class="empty-state__emoji">🥚</div>
<h2 class="empty-state__title">${t('empty_title')}</h2>
<p>${t('empty_subtitle')}</p>
</div>
`;
return;
}
grid.innerHTML = creatures.map(c => {
const emoji = TYPE_EMOJI[c.type] || '❓';
const statusClass = c.is_alive ? 'alive' : 'dead';
const statusText = c.is_alive ? t('alive') : t('dead');
const hungerDisplay = c.is_alive ? Math.round(c.hunger) : 0;
const happinessDisplay = c.is_alive ? Math.round(c.happiness) : 0;
const energyDisplay = c.is_alive ? Math.round(c.energy) : 0;
return `
<div class="creature-card ${c.is_alive ? '' : 'dead'}">
<div class="creature-card__header">
<div style="display:flex;align-items:center;gap:1rem;">
<div class="creature-card__avatar">${c.is_alive ? emoji : '💀'}</div>
<div class="creature-card__info">
<h3>${escapeHtml(c.name)}</h3>
<span class="creature-card__type">${escapeHtml(c.type)}</span>
</div>
</div>
<span class="creature-card__status ${statusClass}">${statusText}</span>
</div>
<div class="creature-card__stats">
<div class="stat-bar">
<div class="stat-bar__label">
<span>🍖 ${t('hunger')}</span>
<span>${hungerDisplay}%</span>
</div>
<div class="stat-bar__track">
<div class="stat-bar__fill hunger" style="width:${hungerDisplay}%"></div>
</div>
</div>
<div class="stat-bar">
<div class="stat-bar__label">
<span>😊 ${t('happiness')}</span>
<span>${happinessDisplay}%</span>
</div>
<div class="stat-bar__track">
<div class="stat-bar__fill happiness" style="width:${happinessDisplay}%"></div>
</div>
</div>
<div class="stat-bar">
<div class="stat-bar__label">
<span>⚡ ${t('energy')}</span>
<span>${energyDisplay}%</span>
</div>
<div class="stat-bar__track">
<div class="stat-bar__fill energy" style="width:${energyDisplay}%"></div>
</div>
</div>
</div>
<div class="creature-card__actions">
${c.is_alive ? `
<button class="action-btn" onclick="creatureAction('${c.id}', 'feed')">${t('feed')}</button>
<button class="action-btn" onclick="creatureAction('${c.id}', 'play')">${t('play')}</button>
<button class="action-btn" onclick="creatureAction('${c.id}', 'sleep')">${t('sleep')}</button>
` : `
<button class="action-btn revive" onclick="creatureAction('${c.id}', 'revive')">${t('revive')}</button>
`}
</div>
</div>
`;
}).join('');
}
// ---- Utilities ----
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// ---- Modal ----
const adoptModal = document.getElementById('adoptModal');
const adoptForm = document.getElementById('adoptForm');
let selectedType = 'dragon';
document.getElementById('adoptBtn').addEventListener('click', () => {
adoptModal.classList.add('active');
});
document.getElementById('cancelAdopt').addEventListener('click', () => {
adoptModal.classList.remove('active');
});
adoptModal.addEventListener('click', (e) => {
if (e.target === adoptModal) adoptModal.classList.remove('active');
});
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedType = btn.dataset.type;
});
});
adoptForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('creatureName').value.trim();
if (!name) return;
await adoptCreature(name, selectedType);
adoptModal.classList.remove('active');
adoptForm.reset();
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('selected'));
document.querySelector('.type-btn[data-type="dragon"]').classList.add('selected');
selectedType = 'dragon';
});
// ---- Language ----
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => setLang(btn.dataset.lang));
});
// ---- Init ----
fetchCreatures();
setInterval(fetchCreatures, REFRESH_INTERVAL);
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐣 Tamagotchi as a Service</title>
<meta name="description" content="Adopt and care for virtual creatures running as Kubernetes pods. A fun DevOps demo!">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<!-- Header -->
<header class="header">
<div class="header__logo">
<span class="header__emoji">🐣</span>
<h1>Tamagotchi <span class="header__accent">as a Service</span></h1>
</div>
<div class="header__stats" id="globalStats">
<div class="stat-chip alive"><span id="aliveCount">-</span> Alive</div>
<div class="stat-chip dead"><span id="deadCount">-</span> Dead</div>
<div class="stat-chip starving"><span id="starvingCount">-</span> Starving</div>
</div>
<div class="header__lang">
<button class="lang-btn active" data-lang="en">EN</button>
<button class="lang-btn" data-lang="fr">FR</button>
</div>
</header>
<!-- Creature Grid -->
<main class="creatures-grid" id="creaturesGrid">
<!-- Creatures will be rendered here by JS -->
</main>
<!-- Adopt Modal -->
<div class="modal-overlay" id="adoptModal">
<div class="modal">
<h2 class="modal__title">🥚 <span data-i18n="adopt_title">Adopt a Creature</span></h2>
<form id="adoptForm">
<div class="form-group">
<label data-i18n="name_label">Name</label>
<input type="text" id="creatureName" placeholder="Pixel, Nimbus, Byte..." required maxlength="50">
</div>
<div class="form-group">
<label data-i18n="type_label">Type</label>
<div class="type-selector" id="typeSelector">
<button type="button" class="type-btn selected" data-type="dragon">🐉 Dragon</button>
<button type="button" class="type-btn" data-type="cat">🐱 Cat</button>
<button type="button" class="type-btn" data-type="robot">🤖 Robot</button>
<button type="button" class="type-btn" data-type="plant">🌱 Plant</button>
<button type="button" class="type-btn" data-type="alien">👽 Alien</button>
</div>
</div>
<div class="modal__actions">
<button type="button" class="btn btn--ghost" id="cancelAdopt" data-i18n="cancel">Cancel</button>
<button type="submit" class="btn btn--primary" data-i18n="adopt_btn">Adopt! 🎉</button>
</div>
</form>
</div>
</div>
<!-- Floating Adopt Button -->
<button class="fab" id="adoptBtn" title="Adopt a new creature">
<span>+</span>
</button>
<!-- Toast Notifications -->
<div class="toast-container" id="toastContainer"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://tamagotchi-api:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+489
View File
@@ -0,0 +1,489 @@
/* ============================================================================
Tamagotchi as a Service — Stylesheet
Premium dark theme with neon accents and glassmorphism
============================================================================ */
:root {
--bg-primary: #0a0a1a;
--bg-card: rgba(20, 20, 45, 0.7);
--bg-card-hover: rgba(30, 30, 60, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--text-primary: #e8e8f0;
--text-secondary: #8888aa;
--text-muted: #555577;
--accent-cyan: #00e5ff;
--accent-magenta: #ff00e5;
--accent-purple: #a855f7;
--accent-green: #00ff88;
--accent-red: #ff4466;
--accent-yellow: #ffbb00;
--accent-orange: #ff8800;
--gradient-primary: linear-gradient(135deg, #a855f7 0%, #00e5ff 100%);
--gradient-danger: linear-gradient(135deg, #ff4466 0%, #ff8800 100%);
--shadow-glow: 0 0 20px rgba(168, 85, 247, 0.3);
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 24px;
--font: 'Space Grotesk', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 20% 50%, rgba(168, 85, 247, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(0, 229, 255, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 50% 80%, rgba(255, 0, 229, 0.05) 0%, transparent 50%);
z-index: -1;
animation: bgShift 20s ease-in-out infinite alternate;
}
@keyframes bgShift {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(-5%, 3%) rotate(3deg); }
}
/* ---- Header ---- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem 2rem;
backdrop-filter: blur(20px);
background: rgba(10, 10, 26, 0.8);
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.header__logo {
display: flex;
align-items: center;
gap: 0.7rem;
}
.header__emoji {
font-size: 2rem;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.header__logo h1 {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.header__accent {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header__stats {
display: flex;
gap: 0.5rem;
}
.stat-chip {
padding: 0.35rem 0.8rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
}
.stat-chip.alive { background: rgba(0, 255, 136, 0.1); color: var(--accent-green); border-color: rgba(0, 255, 136, 0.2); }
.stat-chip.dead { background: rgba(255, 68, 102, 0.1); color: var(--accent-red); border-color: rgba(255, 68, 102, 0.2); }
.stat-chip.starving { background: rgba(255, 187, 0, 0.1); color: var(--accent-yellow); border-color: rgba(255, 187, 0, 0.2); }
.header__lang { display: flex; gap: 0.3rem; }
.lang-btn {
padding: 0.3rem 0.6rem;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--glass-border);
cursor: pointer;
font-family: var(--font);
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s;
}
.lang-btn.active, .lang-btn:hover {
background: var(--accent-purple);
color: white;
border-color: var(--accent-purple);
}
/* ---- Creatures Grid ---- */
.creatures-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
/* ---- Creature Card ---- */
.creature-card {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
backdrop-filter: blur(20px);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.creature-card:hover {
background: var(--bg-card-hover);
transform: translateY(-4px);
box-shadow: var(--shadow-glow);
border-color: rgba(168, 85, 247, 0.3);
}
.creature-card.dead {
opacity: 0.5;
filter: grayscale(0.6);
}
.creature-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.creature-card__avatar {
font-size: 3rem;
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(168, 85, 247, 0.1);
border: 2px solid rgba(168, 85, 247, 0.2);
animation: pulse 3s ease-in-out infinite;
}
.creature-card.dead .creature-card__avatar {
animation: none;
border-color: rgba(255, 68, 102, 0.3);
background: rgba(255, 68, 102, 0.05);
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.2); }
50% { box-shadow: 0 0 20px 5px rgba(168, 85, 247, 0.15); }
}
.creature-card__info h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
.creature-card__type {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
.creature-card__status {
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
}
.creature-card__status.alive { background: rgba(0, 255, 136, 0.15); color: var(--accent-green); }
.creature-card__status.dead { background: rgba(255, 68, 102, 0.15); color: var(--accent-red); }
/* ---- Stats Bars ---- */
.creature-card__stats { margin: 1rem 0; }
.stat-bar {
margin-bottom: 0.7rem;
}
.stat-bar__label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.3rem;
}
.stat-bar__track {
height: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
overflow: hidden;
}
.stat-bar__fill {
height: 100%;
border-radius: 999px;
transition: width 0.8s ease, background 0.5s ease;
}
.stat-bar__fill.hunger { background: var(--gradient-danger); }
.stat-bar__fill.happiness { background: var(--gradient-primary); }
.stat-bar__fill.energy { background: linear-gradient(135deg, #00ff88 0%, #00e5ff 100%); }
/* ---- Action Buttons ---- */
.creature-card__actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.action-btn {
flex: 1;
padding: 0.6rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
cursor: pointer;
font-family: var(--font);
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
}
.action-btn:hover {
background: rgba(168, 85, 247, 0.15);
border-color: rgba(168, 85, 247, 0.3);
transform: scale(1.03);
}
.action-btn:active {
transform: scale(0.97);
}
.action-btn.revive {
background: rgba(0, 255, 136, 0.1);
border-color: rgba(0, 255, 136, 0.2);
color: var(--accent-green);
}
/* ---- FAB ---- */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--gradient-primary);
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
box-shadow: 0 4px 20px rgba(168, 85, 247, 0.4);
transition: all 0.3s;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
transform: scale(1.1) rotate(90deg);
box-shadow: 0 6px 30px rgba(168, 85, 247, 0.6);
}
/* ---- Modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 2rem;
width: 90%;
max-width: 480px;
backdrop-filter: blur(30px);
animation: modalIn 0.3s ease;
}
@keyframes modalIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal__title {
font-size: 1.3rem;
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1.2rem;
}
.form-group label {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group input {
width: 100%;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
font-family: var(--font);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: var(--accent-purple);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.15);
}
.type-selector {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.type-btn {
padding: 0.5rem 0.8rem;
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
cursor: pointer;
font-family: var(--font);
font-size: 0.85rem;
transition: all 0.2s;
}
.type-btn.selected, .type-btn:hover {
background: rgba(168, 85, 247, 0.15);
border-color: var(--accent-purple);
}
.modal__actions {
display: flex;
gap: 0.8rem;
margin-top: 1.5rem;
}
.btn {
flex: 1;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-family: var(--font);
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s;
}
.btn--primary {
background: var(--gradient-primary);
color: white;
}
.btn--primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(168, 85, 247, 0.4); }
.btn--ghost {
background: transparent;
border: 1px solid var(--glass-border);
color: var(--text-secondary);
}
.btn--ghost:hover { background: rgba(255, 255, 255, 0.05); }
/* ---- Toasts ---- */
.toast-container {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.8rem 1.5rem;
border-radius: var(--radius-md);
background: var(--bg-card);
border: 1px solid var(--glass-border);
backdrop-filter: blur(20px);
color: var(--text-primary);
font-size: 0.9rem;
animation: toastIn 0.4s ease, toastOut 0.4s ease 2.6s forwards;
white-space: nowrap;
}
@keyframes toastIn { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(-10px); } }
/* ---- Empty State ---- */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 4rem 2rem;
color: var(--text-muted);
}
.empty-state__emoji { font-size: 4rem; margin-bottom: 1rem; }
.empty-state__title { font-size: 1.3rem; color: var(--text-secondary); margin-bottom: 0.5rem; }
/* ---- Responsive ---- */
@media (max-width: 768px) {
.header { flex-wrap: wrap; gap: 0.8rem; padding: 1rem; }
.header__stats { order: 3; width: 100%; justify-content: center; }
.creatures-grid { padding: 1rem; gap: 1rem; grid-template-columns: 1fr; }
.creature-card__actions { flex-wrap: wrap; }
}
+224
View File
@@ -0,0 +1,224 @@
---
# Namespace
apiVersion: v1
kind: Namespace
metadata:
name: tamagotchi
---
# PostgreSQL PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: tamagotchi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
# PostgreSQL Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: tamagotchi
labels:
app: postgres
tier: database
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
tier: database
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: tamagotchi
- name: POSTGRES_USER
value: tamagotchi
- name: POSTGRES_PASSWORD
value: tamagotchi123
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 300m
memory: 256Mi
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
---
# PostgreSQL Service
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: tamagotchi
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
# Tamagotchi API Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: tamagotchi-api
namespace: tamagotchi
labels:
app: tamagotchi-api
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: tamagotchi-api
template:
metadata:
labels:
app: tamagotchi-api
tier: backend
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
containers:
- name: api
image: tamagotchi-api:latest
imagePullPolicy: Never
ports:
- containerPort: 8080
env:
- name: DB_HOST
value: postgres
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: tamagotchi
- name: DB_USER
value: tamagotchi
- name: DB_PASSWORD
value: tamagotchi123
- name: DECAY_INTERVAL_MS
value: "15000"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
---
# Tamagotchi API Service
apiVersion: v1
kind: Service
metadata:
name: tamagotchi-api
namespace: tamagotchi
labels:
app: tamagotchi-api
spec:
selector:
app: tamagotchi-api
ports:
- port: 8080
targetPort: 8080
---
# Tamagotchi Frontend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: tamagotchi-frontend
namespace: tamagotchi
labels:
app: tamagotchi-frontend
tier: frontend
spec:
replicas: 1
selector:
matchLabels:
app: tamagotchi-frontend
template:
metadata:
labels:
app: tamagotchi-frontend
tier: frontend
spec:
containers:
- name: frontend
image: tamagotchi-frontend:latest
imagePullPolicy: Never
ports:
- containerPort: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
---
# Tamagotchi Frontend Service
apiVersion: v1
kind: Service
metadata:
name: tamagotchi-frontend
namespace: tamagotchi
spec:
selector:
app: tamagotchi-frontend
ports:
- port: 80
targetPort: 80
---
# Prometheus ServiceMonitor for Tamagotchi API
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: tamagotchi-api-monitor
namespace: tamagotchi
labels:
release: kube-prometheus
spec:
selector:
matchLabels:
app: tamagotchi-api
endpoints:
- port: "8080"
path: /metrics
interval: 15s
namespaceSelector:
matchNames:
- tamagotchi