Compare commits

...

25 Commits

Author SHA1 Message Date
consultoria-as
ea142501fa docs: add SM64, N64 Netplay, and Dolphin Traversal server documentation
Some checks failed
Deploy / deploy (push) Has been cancelled
Updated game-servers.md with full setup guides for:
- SM64 Coop DX (headless, UDP 7777, build patches, mods)
- N64 Netplay / gopher64 (Mario Party 1-3, relay server, client setup)
- Dolphin Traversal (GC/Wii NAT hole-punching, all Dolphin games)

Updated README.md service table and game listings.
Updated deployment.md with new ports and connection info.
Fixed MapleStory 2 ports (20002→20003) to match INSTANCED_CONTENT fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:34:37 +00:00
consultoria-as
a3bd1ac2e6 feat: add Dolphin traversal server for GameCube/Wii netplay
Some checks failed
Deploy / deploy (push) Has been cancelled
Self-hosted NAT hole-punching relay for Dolphin emulator netplay.
Enables online play for ALL GameCube and Wii games without players
needing to open ports (Mario Party 4-7, MKDD, Smash Melee, etc).

Multi-stage Docker build compiles only the traversal_server target.
UDP ports 6262 (primary) and 6226 (NAT probe).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:26:49 +00:00
consultoria-as
9436bb2faf feat: add gopher64 N64 netplay server for Mario Party
Lightweight Go relay server (k4rian/gopher64-netplay-server) for N64
netplay. Supports Mario Party 1-3 with up to 4 players per room.
Players connect via gopher64/RMG emulator with their own ROM.

Ports: TCP+UDP 45000-45004 (lobby + 4 concurrent game sessions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:36:46 +00:00
consultoria-as
d4d22e987b chore: increase OpenFusion log verbosity for debugging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:56 +00:00
consultoria-as
1cc3baf58b feat: add sm64coopdx headless dedicated server
Multi-stage Docker build compiles sm64coopdx from source with HEADLESS=1.
Includes patches for GCC 11 float.h and upstream platform.c fallback bug.
Server runs on UDP 7777, supports 16 players, comes with bundled mods.

Also hardcodes OpenFusion SHARD_IP (was using PUBLIC_HOST variable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:51 +00:00
consultoria-as
ad8fcae10c fix: MapleStory 2 channel config — INSTANCED_CONTENT and port mapping
INSTANCED_CONTENT: "true" caused the game channel to register as
instanced (channelId=0), but FirstChannel() only returns non-instanced
channels, resulting in "server not found" on character select.

Changed to INSTANCED_CONTENT: "false" so channel registers as id=1.
Updated port mapping from 20002/21002 to 20003/21003 (base + channelId).
Added GRPC_LOGIN_IP env var to world service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:44 +00:00
consultoria-as
a76d513659 feat: add AFC Store with MercadoPago purchases and prize redemption
Some checks failed
Deploy / deploy (push) Has been cancelled
Players can now buy AfterCoin with real money (MercadoPago Checkout Pro,
$15 MXN/AFC) and redeem AFC for gift cards or cash withdrawals. Admin
fulfills redemptions manually.

- Bridge: payments + redemptions tables, CRUD routes, PATCH auth
- Next.js API: verify-disk, balance, create-preference, webhook (idempotent
  minting with HMAC signature verification), redeem, payment/redemption history
- Frontend: hub, buy flow (4 packages + custom), redeem flow (gift cards +
  cash out), success/failure/pending pages, history with tabs, 8 components
- i18n: full English + Spanish translations
- Infra: nginx /api/afc/ → Next.js, docker-compose env vars, .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:26:13 +00:00
consultoria-as
7dc1d2e0e5 docs: add AfterCoin documentation and MetaMask guides
Some checks failed
Deploy / deploy (push) Has been cancelled
Comprehensive docs covering architecture, all components, Docker
services, environment variables, MetaMask connection (desktop + mobile),
administration commands, and troubleshooting.

Also adds Lua scripts to repo for version control, including the
periodic chain sync loop (every 30s) in the mainframe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:48:24 +00:00
consultoria-as
eac2671529 feat: add HTTPS RPC proxy for MetaMask mobile support
Some checks failed
Deploy / deploy (push) Has been cancelled
Nginx SSL reverse proxy (port 8443) in front of Geth using Let's
Encrypt cert via Cloudflare DNS challenge. MetaMask mobile requires
HTTPS for custom RPC URLs.

Also adds AFC token icon served from bridge API static files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:34:12 +00:00
consultoria-as
14279a878c feat: add AfterCoin (AFC) private blockchain for Minecraft casino
Some checks failed
Deploy / deploy (push) Has been cancelled
Private Ethereum chain (Clique PoA, chain ID 8888) with ERC-20 token
(0 decimals, 1 AFC = 1 diamond) bridging casino balances on-chain so
players can view tokens in MetaMask.

- Geth v1.13.15 node with 5s block time, zero gas cost
- AfterCoin ERC-20 contract with owner-gated mint/burn/bridgeTransfer
- Bridge API (Express + ethers.js + SQLite) with register, deposit,
  withdraw, balance, and wallet endpoints
- Nonce queue for serial transaction safety
- Auto-deploys contract on first boot
- Updated mainframe Lua with diff-based on-chain sync (pcall fallback)
- Updated card generator Lua with wallet info display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:48:22 +00:00
consultoria-as
e65260c69b feat: add external access via Cloudflare DDNS
Some checks failed
Deploy / deploy (push) Has been cancelled
Add cloudflare-ddns container for automatic DNS updates, update game
server connection strings to use play.consultoria-as.com, and document
port forwarding and external access setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:14:22 +00:00
consultoria-as
81e978947e feat: switch Minecraft from FTB Infinity Evolved to FTB Evolution
Some checks failed
Deploy / deploy (push) Has been cancelled
Minecraft 1.21.1 + NeoForge 21.1.218 with 200+ mods.
Added MAX_TICK_TIME=-1 to prevent watchdog crashes on startup.
Updated CMS entries, README, and all docs to reflect new modpack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:14:55 +00:00
consultoria-as
e4404b209d docs: add comprehensive project documentation
Some checks failed
Deploy / deploy (push) Has been cancelled
- README.md: project overview, server status, quick start guide,
  architecture diagram, tech stack, and content inventory
- docs/architecture.md: technical architecture, service diagram,
  component details, and design decisions
- docs/game-servers.md: setup and operation guide for OpenFusion,
  MapleStory 2, and Minecraft FTB Infinity Evolved
- docs/cms-content.md: Strapi content model, i18n strategy,
  documentary structure, and API endpoints
- docs/deployment.md: local dev, production deploy, CI/CD,
  SSL setup, backup procedures, and monitoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:23:53 +00:00
consultoria-as
aea2283d8f feat: add game server infrastructure and documentary improvements
Some checks failed
Deploy / deploy (push) Has been cancelled
- Add Docker Compose for OpenFusion (FusionFall), MapleStory 2, and
  Minecraft FTB Infinity Evolved game servers
- Add MapleStory 2 multi-service compose (MySQL, World, Login, Web, Game)
- Add OpenFusion Dockerfile and configuration files
- Fix CMS Dockerfile, web Dockerfile, and documentary components
- Add root layout, globals.css, not-found page, and text formatting utils
- Update .gitignore to exclude large game server repos and data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:11:12 +00:00
consultoria-as
0df69b38d5 docs: add social media launch posts for Reddit and Threads
Some checks failed
Deploy / deploy (push) Has been cancelled
HTML page with copy buttons containing all launch posts for
FusionFall and Drift City in English and Spanish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:11:19 +00:00
consultoria-as
a167c6643b feat: add SEO metadata and Open Graph helpers
Some checks failed
Deploy / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:12:16 +00:00
consultoria-as
8e9f45b38b feat: add GitHub Actions CI/CD deploy workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:11:30 +00:00
consultoria-as
e95b9a61c9 feat: add Docker Compose setup with Nginx, PostgreSQL, MinIO
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:10:38 +00:00
consultoria-as
7571ea3bab feat: add About and Donate pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:09:15 +00:00
consultoria-as
279ab5e822 feat: build interactive documentary page with audio player and chapter navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:07:22 +00:00
consultoria-as
e7e58bba29 feat: build game detail page with header, info panel, and screenshot gallery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:03:41 +00:00
consultoria-as
70a603274b feat: build game catalog page with filters and grid
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:01:50 +00:00
consultoria-as
eabc858f9a feat: build landing page with hero, latest games, and donation CTA
Add GameCard shared component, HeroSection with framer-motion animations,
LatestGames grid section, and DonationCTA banner. Wire up the home page
to fetch games from Strapi and render all landing page sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:00:00 +00:00
consultoria-as
dfda08085b feat: add Navbar, Footer, and LanguageSwitcher layout components
Install framer-motion and create shared layout components with i18n
support. Update locale layout to include fixed navbar, flex-col body,
and footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:58:03 +00:00
consultoria-as
bd222376bd feat: add Strapi API client and data fetching functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:56:02 +00:00
123 changed files with 13092 additions and 1168 deletions

26
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/project-afterlife
git pull origin main
cd docker
docker compose build
docker compose up -d
docker compose exec web npm run build
docker compose restart web

6
.gitignore vendored
View File

@@ -9,3 +9,9 @@ dist/
.DS_Store
.tmp/
build/
# Game servers (cloned repos / large data)
servers/maple2/
servers/openfusion/fusion
servers/openfusion/tdata/
servers/openfusion/data/

220
README.md Normal file
View File

@@ -0,0 +1,220 @@
# Project Afterlife
Plataforma de preservacion de videojuegos con documentales interactivos. Servidores privados de juegos que ya no existen, acompanados de documentales narrativos que cuentan su historia.
## Estado Actual
| Servicio | Estado | Puerto | RAM |
|----------|--------|--------|-----|
| **Next.js 15** (frontend) | En linea | 3000 | ~111 MB |
| **Strapi 5** (CMS) | En linea | 1337 | ~179 MB |
| **PostgreSQL 16** | En linea | 5432 | ~57 MB |
| **MinIO** (almacenamiento) | En linea | 9000/9001 | ~144 MB |
| **OpenFusion** (FusionFall) | En linea | 23000-23001 | ~254 MB |
| **MapleStory 2 - World** | En linea | 21001 | ~126 MB |
| **MapleStory 2 - Login** | En linea | 20001 | ~100 MB |
| **MapleStory 2 - Web** | En linea | 4000 | ~70 MB |
| **MapleStory 2 - Game Ch0** | En linea | 20003/21003 | ~341 MB |
| **MapleStory 2 - MySQL** | En linea | 3307 | ~733 MB |
| **Minecraft FTB Evolution** | En linea | 25565 | ~3.5 GB |
| **SM64 Coop DX** | En linea | 7777/udp | ~45 MB |
| **N64 Netplay** (Mario Party) | En linea | 45000-45004 | <1 MB |
| **Dolphin Traversal** (GC/Wii) | En linea | 6262/udp, 6226/udp | <10 MB |
**Total**: ~6 GB RAM / 40 GB disponibles | 35 GB disco / 96 GB disponibles
## Juegos Preservados
### FusionFall (Cartoon Network Universe)
- **Emulador**: [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) (C++)
- **Conexion**: `play.consultoria-as.com:23000` (o `192.168.10.234:23000` en LAN)
- **Cliente**: [FusionFall Retro Client](https://github.com/OpenFusionProject)
- **Documental**: "FusionFall: El Mundo Que No Queriamos Perder" (7 capitulos)
### MapleStory 2
- **Emulador**: [Maple2](https://github.com/MS2Community/Maple2) (C# .NET 8)
- **Conexion**: `play.consultoria-as.com:20001` (o `192.168.10.234:20001` en LAN)
- **Cliente**: MapleStory 2 Global Client + XML Patches
- **Documental**: "MapleStory 2: El Mundo Que Construimos Juntos" (7 capitulos)
### Minecraft: FTB Evolution
- **Servidor**: [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) (Java 21)
- **Conexion**: `play.consultoria-as.com:25565` (o `192.168.10.234:25565` en LAN)
- **Cliente**: FTB App o launcher compatible con FTB Evolution v1.29.1
- **Modpack**: 200+ mods, Minecraft 1.21.1 + NeoForge 21.1.218
### Super Mario 64 Coop
- **Servidor**: [sm64coopdx](https://github.com/coop-deluxe/sm64coopdx) (C, headless)
- **Conexion**: `play.consultoria-as.com:7777` (o `192.168.10.234:7777` en LAN)
- **Cliente**: sm64coopdx (compilado con la misma ROM)
- **Jugadores**: Hasta 16, con mods incluidos (star-road, arena, character-select)
### Mario Party 1-3 (N64 Netplay)
- **Servidor**: [gopher64-netplay-server](https://github.com/gopher64/gopher64-netplay-server) (Go)
- **Conexion**: `play.consultoria-as.com:45000` (o `192.168.10.234:45000` en LAN)
- **Cliente**: [gopher64](https://github.com/gopher64/gopher64) o RMG + ROM de Mario Party
- **Jugadores**: 4 por sala, 4 salas concurrentes
### GameCube / Wii (Dolphin Netplay)
- **Servidor**: Dolphin Traversal Server (NAT hole-punching)
- **Config en Dolphin**: Traversal Server = `play.consultoria-as.com`, Port = `6262`
- **Juegos**: Mario Party 4-7, MKDD, Smash Melee, F-Zero GX, y cualquier juego de GC/Wii
## Arquitectura
```
project-afterlife/
├── apps/
│ ├── cms/ # Strapi 5 CMS (React 18)
│ └── web/ # Next.js 15 frontend (React 19)
├── packages/
│ └── shared/ # Tipos TypeScript compartidos
├── servers/
│ ├── openfusion/ # Servidor FusionFall (C++)
│ ├── maple2/ # Servidor MapleStory 2 (C# .NET 8)
│ ├── sm64coopdx/ # Super Mario 64 Coop (C, headless)
│ └── dolphin-traversal/ # Dolphin Traversal Server (C++)
├── docker/
│ ├── docker-compose.dev.yml # Stack local (web + CMS + juegos)
│ ├── docker-compose.maple2.yml # MapleStory 2 (separado)
│ ├── docker-compose.yml # Produccion (con Nginx + SSL)
│ └── nginx/ # Configuracion Nginx
├── docs/ # Documentacion del proyecto
└── .github/workflows/ # CI/CD deployment
```
### Stack Tecnologico
| Componente | Tecnologia | Version |
|-----------|-----------|---------|
| Frontend | Next.js + TypeScript | 15.x |
| UI | Tailwind CSS | v4 |
| i18n | next-intl | 4.8.3 |
| CMS | Strapi | 5.36.0 |
| Base de datos (CMS) | PostgreSQL | 16 |
| Base de datos (MS2) | MySQL | 8.0 |
| Almacenamiento | MinIO (S3) | Latest |
| Audio | Howler.js | 2.2.4 |
| Animaciones | Framer Motion | 12.x |
| Monorepo | npm workspaces + Turborepo | - |
| CI/CD | GitHub Actions | - |
| Reverse Proxy | Nginx | Alpine |
## Inicio Rapido
### Requisitos
- Docker y Docker Compose v2+
- 8 GB RAM minimo (16 GB recomendado con todos los servidores)
- 50 GB disco libre
### 1. Clonar y configurar
```bash
git clone https://git.consultoria-as.com/consultoria-as/project-afterlife.git
cd project-afterlife
```
### 2. Crear archivo de entorno
```bash
cp docker/.env.example docker/.env
# Editar docker/.env con las claves necesarias
```
Variables requeridas en `docker/.env`:
```env
# Base de datos
DATABASE_NAME=afterlife
DATABASE_USERNAME=afterlife
DATABASE_PASSWORD=afterlife
# MinIO
MINIO_ROOT_USER=afterlife
MINIO_ROOT_PASSWORD=afterlife123
# Strapi (generar con openssl rand -base64 32)
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
TRANSFER_TOKEN_SALT=
JWT_SECRET=
# API Token (crear en Strapi Admin > Settings > API Tokens)
STRAPI_API_TOKEN=
# Strapi URL publica
PUBLIC_STRAPI_URL=http://localhost:1337
# OpenFusion
OPENFUSION_SHARD_IP=192.168.10.234
```
### 3. Levantar servicios base
```bash
cd docker
# Stack principal (CMS + Web + OpenFusion + Minecraft FTB)
docker compose -f docker-compose.dev.yml up -d
# MapleStory 2 (requiere setup previo, ver docs/game-servers.md)
docker compose -f docker-compose.maple2.yml up -d
```
### 4. Setup inicial de Strapi
1. Abrir http://localhost:1337/admin
2. Crear usuario administrador
3. Ir a Settings > API Tokens > Create new API Token
4. Tipo: Full access, copiar el token a `STRAPI_API_TOKEN` en `.env`
5. Reiniciar el servicio web: `docker compose -f docker-compose.dev.yml restart web`
### 5. Verificar
- **Frontend**: http://localhost:3000
- **CMS Admin**: http://localhost:1337/admin
- **MinIO Console**: http://localhost:9001
## Documentacion Completa
| Documento | Descripcion |
|-----------|------------|
| [README.md](README.md) | Este archivo — vision general y estado |
| [docs/architecture.md](docs/architecture.md) | Arquitectura tecnica detallada |
| [docs/game-servers.md](docs/game-servers.md) | Setup y operacion de servidores de juegos |
| [docs/cms-content.md](docs/cms-content.md) | Modelo de contenido CMS y documentales |
| [docs/deployment.md](docs/deployment.md) | Guia de despliegue a produccion |
| [docs/plans/](docs/plans/) | Documentos de diseno e implementacion |
## Rutas de la Web
| Ruta | Descripcion |
|------|------------|
| `/es` o `/en` | Pagina principal con hero y ultimos juegos |
| `/es/catalog` | Catalogo de juegos con filtros |
| `/es/about` | Sobre el proyecto |
| `/es/donate` | Pagina de donaciones |
| `/es/games/[slug]` | Pagina individual de juego |
| `/es/games/[slug]/documentary` | Documental interactivo |
## Contenido en Base de Datos
### Juegos
| Slug | Titulo | Estado | Documental |
|------|--------|--------|------------|
| `fusionfall` | FusionFall | Online | 7 capitulos |
| `maplestory2` | MapleStory 2 | Online | 7 capitulos |
| `minecraft-ftb-evolution` | Minecraft: FTB Evolution | Online | Pendiente |
### Documentales
| Juego | Titulo | Capitulos |
|-------|--------|-----------|
| FusionFall | "El Mundo Que No Queriamos Perder" | 7 |
| MapleStory 2 | "El Mundo Que Construimos Juntos" | 7 |
Cada documental tiene sus 7 capitulos publicados en ambos idiomas (ES/EN).
## Licencia
Proyecto privado. Todos los derechos reservados.
Los emuladores de juegos utilizados son proyectos open-source independientes con sus propias licencias.

16
apps/cms/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build && \
find src/api -name "schema.json" | while read f; do \
mkdir -p "dist/$(dirname "$f")" && cp "$f" "dist/$f"; \
done
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=base /app ./
EXPOSE 1337
CMD ["npm", "run", "start"]

View File

@@ -15,10 +15,15 @@
"@strapi/plugin-cloud": "^5.36.0",
"@strapi/plugin-users-permissions": "^5.36.0",
"pg": "^8.13.0",
"better-sqlite3": "^11.0.0"
"better-sqlite3": "^11.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.0.0",
"styled-components": "^6.0.0"
},
"devDependencies": {
"typescript": "^5.3.0"
"typescript": "^5.3.0",
"esbuild": "^0.25.0"
},
"engines": {
"node": ">=20.0.0 <=24.x.x",

View File

@@ -40,9 +40,6 @@
"relation": "oneToMany",
"target": "api::chapter.chapter",
"mappedBy": "documentary"
},
"publishedAt": {
"type": "datetime"
}
}
}

3
apps/web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-here
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337

27
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci
COPY packages/shared/ ./packages/shared/
COPY apps/web/ ./apps/web/
WORKDIR /app/apps/web
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=base /app/package.json ./
COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/packages ./packages
COPY --from=base /app/apps/web/.next ./apps/web/.next
COPY --from=base /app/apps/web/package.json ./apps/web/
COPY --from=base /app/apps/web/next.config.ts ./apps/web/
WORKDIR /app/apps/web
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -2,6 +2,10 @@ import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig = {};
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
};
export default withNextIntl(nextConfig);

View File

@@ -10,13 +10,18 @@
},
"dependencies": {
"@afterlife/shared": "*",
"framer-motion": "^12.34.3",
"howler": "^2.2.4",
"mercadopago": "^2.12.0",
"next": "^15",
"next-intl": "^4.8.3",
"react": "^19",
"react-dom": "^19"
"react-dom": "^19",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/howler": "^2.2.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -0,0 +1,42 @@
import { useTranslations } from "next-intl";
export default function AboutPage() {
const t = useTranslations("about");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-12">{t("title")}</h1>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
<div className="prose prose-invert prose-lg max-w-none">
<p>
Project Afterlife nace de la convicción de que los juegos online que marcaron a
generaciones de jugadores no deberían desaparecer cuando sus servidores se apagan.
Somos un equipo dedicado a preservar estos mundos virtuales, restaurando sus servidores
y documentando su historia para que nunca sean olvidados.
</p>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
<p className="text-gray-500 text-sm">Team members coming soon.</p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">{t("contribute")}</h2>
<div className="prose prose-invert prose-lg max-w-none">
<p>
Si tienes experiencia con servidores de juegos, desarrollo web, narración, o
simplemente quieres ayudar, contacta con nosotros.
</p>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
export default function BuyFailurePage() {
const t = useTranslations("afc");
const locale = useLocale();
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-500/15 border-2 border-red-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_failure_title")}</h1>
<p className="text-gray-400 mb-8">{t("payment_failure_description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/buy`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("try_again")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { createPreference } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { AfcPackageCard } from "@/components/afc/AfcPackageCard";
const PRICE_PER_AFC = 15;
const PACKAGES = [
{ amount: 10, popular: false },
{ amount: 25, popular: true },
{ amount: 50, popular: false },
{ amount: 100, popular: false },
];
export default function BuyAfcPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [customAmount, setCustomAmount] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleBuy(amount: number) {
if (!disk.verified || !disk.diskId) return;
setLoading(true);
setError(null);
try {
const data = await createPreference(disk.diskId, amount);
// Redirect to MercadoPago checkout
window.location.href = data.initPoint;
} catch (e: any) {
setError(e.message);
setLoading(false);
}
}
return (
<div className="max-w-3xl mx-auto px-4 py-12">
{/* Back */}
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("buy_title")}</h1>
<p className="text-gray-400 mb-8">{t("buy_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
{/* Balance */}
<div className="mb-8">
<BalanceDisplay balance={disk.balance} compact />
</div>
{/* Packages */}
<div className="space-y-3 mb-8">
<h2 className="text-lg font-semibold text-white mb-4">{t("select_package")}</h2>
{PACKAGES.map((pkg) => (
<AfcPackageCard
key={pkg.amount}
amount={pkg.amount}
priceMxn={pkg.amount * PRICE_PER_AFC}
popular={pkg.popular}
loading={loading}
onSelect={() => handleBuy(pkg.amount)}
/>
))}
</div>
{/* Custom amount */}
<div className="bg-gray-900 border border-white/5 rounded-2xl p-6">
<h3 className="text-sm font-medium text-gray-400 mb-3">{t("custom_amount")}</h3>
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type="number"
min="1"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="AFC"
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 transition-all"
/>
{customAmount && Number(customAmount) > 0 && (
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
= ${Number(customAmount) * PRICE_PER_AFC} MXN
</span>
)}
</div>
<button
onClick={() => {
const amt = Number(customAmount);
if (amt >= 1) handleBuy(amt);
}}
disabled={loading || !customAmount || Number(customAmount) < 1}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
>
{t("buy")}
</button>
</div>
</div>
{error && (
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Payment info */}
<p className="mt-6 text-xs text-gray-600 text-center">
{t("payment_info")}
</p>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
export default function BuyPendingPage() {
const t = useTranslations("afc");
const locale = useLocale();
const searchParams = useSearchParams();
const paymentId = searchParams.get("payment_id");
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-yellow-500/15 border-2 border-yellow-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_pending_title")}</h1>
<p className="text-gray-400 mb-2">{t("payment_pending_description")}</p>
{paymentId && (
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
export default function BuySuccessPage() {
const t = useTranslations("afc");
const locale = useLocale();
const searchParams = useSearchParams();
const paymentId = searchParams.get("payment_id");
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3 text-white">{t("payment_success_title")}</h1>
<p className="text-gray-400 mb-2">{t("payment_success_description")}</p>
{paymentId && (
<p className="text-xs text-gray-600 mb-8 font-mono">ID: {paymentId}</p>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { getPaymentHistory, getRedemptionHistory } from "@/lib/afc";
import type { Payment, Redemption } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { PaymentHistoryTable } from "@/components/afc/PaymentHistoryTable";
import { RedemptionHistoryTable } from "@/components/afc/RedemptionHistoryTable";
type Tab = "payments" | "redemptions";
export default function HistoryPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [tab, setTab] = useState<Tab>("payments");
const [payments, setPayments] = useState<Payment[]>([]);
const [redemptions, setRedemptions] = useState<Redemption[]>([]);
const [loadingData, setLoadingData] = useState(false);
useEffect(() => {
if (!disk.verified || !disk.diskId) return;
setLoadingData(true);
Promise.all([
getPaymentHistory(disk.diskId).then((d) => setPayments(d.payments || [])).catch(() => {}),
getRedemptionHistory(disk.diskId).then((d) => setRedemptions(d.redemptions || [])).catch(() => {}),
]).finally(() => setLoadingData(false));
}, [disk.verified, disk.diskId]);
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("history_title")}</h1>
<p className="text-gray-400 mb-8">{t("history_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
<div className="mb-8">
<BalanceDisplay balance={disk.balance} compact />
</div>
{/* Tabs */}
<div className="flex gap-1 bg-gray-900 rounded-xl p-1 mb-6">
<button
onClick={() => setTab("payments")}
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
tab === "payments"
? "bg-amber-500 text-black"
: "text-gray-400 hover:text-white"
}`}
>
{t("purchases")} ({payments.length})
</button>
<button
onClick={() => setTab("redemptions")}
className={`flex-1 py-2.5 text-sm font-medium rounded-lg transition-colors ${
tab === "redemptions"
? "bg-amber-500 text-black"
: "text-gray-400 hover:text-white"
}`}
>
{t("redemptions")} ({redemptions.length})
</button>
</div>
{loadingData ? (
<div className="text-center py-12 text-gray-500">{t("loading")}</div>
) : tab === "payments" ? (
<PaymentHistoryTable payments={payments} />
) : (
<RedemptionHistoryTable redemptions={redemptions} />
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
export default function AfcHubPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
return (
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Header */}
<div className="text-center mb-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/20">
<span className="text-3xl font-bold text-black">A</span>
</div>
<h1 className="text-4xl font-bold mb-3">{t("store_title")}</h1>
<p className="text-gray-400 text-lg max-w-xl mx-auto">
{t("store_subtitle")}
</p>
</div>
{/* Disk ID */}
<div className="mb-10">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{/* Balance */}
{disk.verified && (
<div className="mb-10">
<BalanceDisplay balance={disk.balance} />
</div>
)}
{/* Action cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href={`/${locale}/afc/buy`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-green-500/10 flex items-center justify-center mb-4 group-hover:bg-green-500/20 transition-colors">
<span className="text-2xl">+</span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("buy_title")}</h2>
<p className="text-gray-500 text-sm">{t("buy_description")}</p>
</Link>
<Link
href={`/${locale}/afc/redeem`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-4 group-hover:bg-amber-500/20 transition-colors">
<span className="text-2xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("redeem_title")}</h2>
<p className="text-gray-500 text-sm">{t("redeem_description")}</p>
</Link>
<Link
href={`/${locale}/afc/history`}
className="group block bg-gray-900 rounded-2xl p-8 border border-white/5 hover:border-amber-500/40 transition-all duration-200 hover:shadow-lg hover:shadow-amber-500/5"
>
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4 group-hover:bg-blue-500/20 transition-colors">
<span className="text-2xl"></span>
</div>
<h2 className="text-xl font-bold mb-2 text-white">{t("history_title")}</h2>
<p className="text-gray-500 text-sm">{t("history_description")}</p>
</Link>
</div>
{/* Info */}
<div className="mt-12 bg-gray-900/50 border border-white/5 rounded-2xl p-6 text-sm text-gray-500">
<p>{t("store_info")}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useDiskId } from "@/hooks/useDiskId";
import { redeemAfc } from "@/lib/afc";
import { DiskIdInput } from "@/components/afc/DiskIdInput";
import { BalanceDisplay } from "@/components/afc/BalanceDisplay";
import { PrizeCard } from "@/components/afc/PrizeCard";
import { RedeemForm } from "@/components/afc/RedeemForm";
interface Prize {
icon: string;
brand: string;
label: string;
costAfc: number;
valueMxn: number;
prizeType: string;
prizeDetail: string;
}
const GIFT_CARDS: Prize[] = [
{ icon: "🎮", brand: "Steam", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Steam $200 MXN" },
{ icon: "🎮", brand: "Steam", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Steam $500 MXN" },
{ icon: "🎮", brand: "Steam", label: "$1,000 MXN", costAfc: 67, valueMxn: 1000, prizeType: "gift_card", prizeDetail: "Steam $1,000 MXN" },
{ icon: "🟢", brand: "Xbox", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Xbox $200 MXN" },
{ icon: "🟢", brand: "Xbox", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Xbox $500 MXN" },
{ icon: "🔵", brand: "PlayStation", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "PlayStation $200 MXN" },
{ icon: "🔵", brand: "PlayStation", label: "$500 MXN", costAfc: 14, valueMxn: 500, prizeType: "gift_card", prizeDetail: "PlayStation $500 MXN" },
{ icon: "📦", brand: "Amazon", label: "$200 MXN", costAfc: 14, valueMxn: 200, prizeType: "gift_card", prizeDetail: "Amazon $200 MXN" },
{ icon: "📦", brand: "Amazon", label: "$500 MXN", costAfc: 34, valueMxn: 500, prizeType: "gift_card", prizeDetail: "Amazon $500 MXN" },
];
const CASH_OUT: Prize[] = [
{ icon: "🏦", brand: "Banco (CLABE)", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "bank_transfer", prizeDetail: "Transferencia bancaria $750 MXN" },
{ icon: "💳", brand: "MercadoPago", label: "$750+ MXN", costAfc: 50, valueMxn: 750, prizeType: "mercadopago", prizeDetail: "Retiro MercadoPago $750 MXN" },
];
export default function RedeemPage() {
const t = useTranslations("afc");
const locale = useLocale();
const disk = useDiskId();
const [selected, setSelected] = useState<Prize | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleRedeem(deliveryInfo: string) {
if (!selected || !disk.diskId) return;
setLoading(true);
setError(null);
try {
await redeemAfc({
diskId: disk.diskId,
amountAfc: selected.costAfc,
prizeType: selected.prizeType,
prizeDetail: selected.prizeDetail,
deliveryInfo,
});
setSuccess(true);
disk.refreshBalance();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}
if (success) {
return (
<div className="max-w-lg mx-auto px-4 py-20 text-center">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/15 border-2 border-green-500/30 flex items-center justify-center">
<span className="text-4xl"></span>
</div>
<h1 className="text-3xl font-bold mb-3">{t("redeem_success_title")}</h1>
<p className="text-gray-400 mb-8">{t("redeem_success_description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href={`/${locale}/afc/history`}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-xl transition-colors"
>
{t("view_history")}
</Link>
<Link
href={`/${locale}/afc`}
className="px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl transition-colors"
>
{t("back_to_store")}
</Link>
</div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<Link
href={`/${locale}/afc`}
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-300 transition-colors mb-8"
>
{t("back_to_store")}
</Link>
<h1 className="text-3xl font-bold mb-2">{t("redeem_title")}</h1>
<p className="text-gray-400 mb-8">{t("redeem_subtitle")}</p>
{/* Disk ID */}
<div className="mb-8">
<DiskIdInput
diskId={disk.diskId}
onChange={disk.setDiskId}
onVerify={() => disk.verify(disk.diskId)}
loading={disk.loading}
verified={disk.verified}
playerName={disk.playerName}
error={disk.error}
onClear={disk.clear}
/>
</div>
{disk.verified && (
<>
<div className="mb-8">
<BalanceDisplay balance={disk.balance} />
</div>
{selected ? (
<RedeemForm
prizeType={selected.prizeType}
prizeDetail={selected.prizeDetail}
costAfc={selected.costAfc}
onSubmit={handleRedeem}
onCancel={() => setSelected(null)}
loading={loading}
/>
) : (
<>
{/* Gift Cards */}
<h2 className="text-lg font-semibold text-white mb-4">{t("gift_cards")}</h2>
<div className="space-y-3 mb-8">
{GIFT_CARDS.map((prize, i) => (
<PrizeCard
key={i}
icon={prize.icon}
brand={prize.brand}
label={prize.label}
costAfc={prize.costAfc}
valueMxn={prize.valueMxn}
disabled={disk.balance !== null && disk.balance < prize.costAfc}
onSelect={() => setSelected(prize)}
/>
))}
</div>
{/* Cash Out */}
<h2 className="text-lg font-semibold text-white mb-4">{t("cash_out")}</h2>
<div className="space-y-3">
{CASH_OUT.map((prize, i) => (
<PrizeCard
key={i}
icon={prize.icon}
brand={prize.brand}
label={prize.label}
costAfc={prize.costAfc}
valueMxn={prize.valueMxn}
disabled={disk.balance !== null && disk.balance < prize.costAfc}
onSelect={() => setSelected(prize)}
/>
))}
</div>
</>
)}
{error && (
<div className="mt-4 bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">
{error}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Suspense } from "react";
import { getGames } from "@/lib/api";
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
export default async function CatalogPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
let games: any[] = [];
try {
const res = await getGames(locale);
games = res.data;
} catch {
// Strapi not running
}
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
</h1>
<Suspense>
<CatalogFilters />
</Suspense>
<CatalogGrid games={games} locale={locale} />
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useTranslations } from "next-intl";
export default function DonatePage() {
const t = useTranslations("donate");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
<a
href="https://patreon.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-orange-500/50 transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-orange-400">Patreon</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones recurrentes mensuales</p>
<span className="inline-block px-6 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors">
{t("patreon")}
</span>
</a>
<a
href="https://ko-fi.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-sky-500/50 transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-sky-400">Ko-fi</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones puntuales</p>
<span className="inline-block px-6 py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-colors">
{t("kofi")}
</span>
</a>
</div>
<section>
<h2 className="text-2xl font-semibold mb-4">{t("transparency")}</h2>
<div className="prose prose-invert max-w-none">
<p>
Cada donación se destina al mantenimiento de servidores, costes de hosting,
y equipamiento para la grabación de los audiolibros narrativos. Publicamos
un desglose mensual de gastos.
</p>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { notFound } from "next/navigation";
import { getDocumentaryByGameSlug } from "@/lib/api";
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
export default async function DocumentaryPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
let documentary;
try {
documentary = await getDocumentaryByGameSlug(slug, locale);
} catch {
notFound();
}
if (!documentary || !documentary.chapters?.length) {
notFound();
}
return <DocumentaryLayout documentary={documentary} />;
}

View File

@@ -0,0 +1,35 @@
import { notFound } from "next/navigation";
import { getGameBySlug } from "@/lib/api";
import { GameHeader } from "@/components/game/GameHeader";
import { GameInfo } from "@/components/game/GameInfo";
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
export default async function GamePage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
let game;
try {
const res = await getGameBySlug(slug, locale);
game = Array.isArray(res.data) ? res.data[0] : res.data;
} catch {
notFound();
}
if (!game) notFound();
return (
<>
<GameHeader game={game} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<GameInfo game={game} locale={locale} />
{game.screenshots && (
<ScreenshotGallery screenshots={game.screenshots} />
)}
</div>
</>
);
}

View File

@@ -1,13 +1,37 @@
import type { Metadata } from "next";
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import "./globals.css";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair",
display: "swap",
});
const sourceSerif = Source_Serif_4({
subsets: ["latin", "latin-ext"],
variable: "--font-source-serif",
display: "swap",
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
display: "swap",
});
export const metadata: Metadata = {
title: "Project Afterlife",
title: {
default: "Project Afterlife",
template: "%s | Project Afterlife",
},
description: "Preserving online games that deserve a second life",
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev"),
};
export default async function LocaleLayout({
@@ -24,10 +48,15 @@ export default async function LocaleLayout({
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<html
lang={locale}
className={`${playfair.variable} ${sourceSerif.variable} ${dmSans.variable}`}
>
<body className="bg-gray-950 text-white antialiased min-h-screen flex flex-col font-sans">
<NextIntlClientProvider messages={messages}>
{children}
<Navbar />
<main className="flex-1 pt-16">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -1,7 +1,28 @@
export default function HomePage() {
import { getGames } from "@/lib/api";
import { HeroSection } from "@/components/home/HeroSection";
import { LatestGames } from "@/components/home/LatestGames";
import { DonationCTA } from "@/components/home/DonationCTA";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
let games: any[] = [];
try {
const res = await getGames(locale);
games = res.data;
} catch {
// Strapi not running yet — render page without games
}
return (
<main>
<h1>Project Afterlife</h1>
</main>
<>
<HeroSection />
<LatestGames games={games} locale={locale} />
<DonationCTA />
</>
);
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/balance/${diskId}`);
return NextResponse.json({ balance: data.balance });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { preferenceClient } from "../lib/mercadopago";
import { bridgePost, bridgePatch } from "../lib/bridge";
const PRICE_MXN = Number(process.env.AFC_PRICE_MXN) || 15;
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export async function POST(req: NextRequest) {
try {
const { diskId, amountAfc } = await req.json();
if (!diskId || !amountAfc || amountAfc < 1) {
return NextResponse.json(
{ error: "diskId and amountAfc (>=1) required" },
{ status: 400 }
);
}
const amountMxn = amountAfc * PRICE_MXN;
const paymentId = randomUUID();
// Create payment record in bridge
await bridgePost("/api/payments", {
id: paymentId,
diskId,
amountAfc,
amountMxn,
});
// Create MercadoPago preference
const preference = await preferenceClient.create({
body: {
items: [
{
id: paymentId,
title: `${amountAfc} AfterCoin (AFC)`,
quantity: 1,
unit_price: amountMxn,
currency_id: "MXN",
},
],
external_reference: paymentId,
back_urls: {
success: `${BASE_URL}/afc/buy/success?payment_id=${paymentId}`,
failure: `${BASE_URL}/afc/buy/failure?payment_id=${paymentId}`,
pending: `${BASE_URL}/afc/buy/pending?payment_id=${paymentId}`,
},
auto_return: "approved",
notification_url:
process.env.MERCADOPAGO_WEBHOOK_URL ||
`${BASE_URL}/api/afc/webhook`,
},
});
// Store the MP preference ID
await bridgePatch(`/api/payments/${paymentId}`, {
mp_preference_id: preference.id,
});
return NextResponse.json({
paymentId,
initPoint: preference.init_point,
sandboxInitPoint: preference.sandbox_init_point,
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("create-preference error:", e);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,50 @@
const BRIDGE_URL = process.env.AFC_BRIDGE_URL || "http://afc-bridge:3001";
const BRIDGE_SECRET = process.env.AFC_BRIDGE_SECRET || "";
export async function bridgeGet(path: string) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
cache: "no-store",
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Bridge error: ${res.status}`);
}
return res.json();
}
export async function bridgePost(path: string, body: Record<string, unknown>) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-bridge-secret": BRIDGE_SECRET,
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Bridge error: ${res.status}`);
}
return res.json();
}
export async function bridgePatch(
path: string,
body: Record<string, unknown>
) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-bridge-secret": BRIDGE_SECRET,
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Bridge error: ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1,9 @@
import { MercadoPagoConfig, Preference, Payment } from "mercadopago";
const ACCESS_TOKEN = process.env.MERCADOPAGO_ACCESS_TOKEN || "";
const client = new MercadoPagoConfig({ accessToken: ACCESS_TOKEN });
export const preferenceClient = new Preference(client);
export const paymentClient = new Payment(client);
export { client as mpClient };

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/payments/history/${diskId}`);
return NextResponse.json(data);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { bridgePost } from "../lib/bridge";
export async function POST(req: NextRequest) {
try {
const { diskId, amountAfc, prizeType, prizeDetail, deliveryInfo } =
await req.json();
if (!diskId || !amountAfc || !prizeType || !prizeDetail) {
return NextResponse.json(
{
error:
"diskId, amountAfc, prizeType, and prizeDetail are required",
},
{ status: 400 }
);
}
// Burn the AFC via withdraw (burn) endpoint
const burnResult = await bridgePost("/api/withdraw", {
diskId,
amount: amountAfc,
});
const redemptionId = randomUUID();
// Create redemption record
await bridgePost("/api/redemptions", {
id: redemptionId,
diskId,
amountAfc,
prizeType,
prizeDetail,
deliveryInfo: deliveryInfo || "",
burnTxHash: burnResult.txHash,
});
return NextResponse.json({
redemptionId,
burnTxHash: burnResult.txHash,
balance: burnResult.balance,
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("redeem error:", e);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/redemptions/history/${diskId}`);
return NextResponse.json(data);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet } from "../lib/bridge";
export async function GET(req: NextRequest) {
const diskId = req.nextUrl.searchParams.get("diskId");
if (!diskId) {
return NextResponse.json({ error: "diskId is required" }, { status: 400 });
}
try {
const data = await bridgeGet(`/api/wallet/${diskId}`);
return NextResponse.json({ valid: true, name: data.name || null });
} catch {
return NextResponse.json({ valid: false, name: null });
}
}

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
import { paymentClient } from "../lib/mercadopago";
import { bridgeGet, bridgePost, bridgePatch } from "../lib/bridge";
const WEBHOOK_SECRET = process.env.MERCADOPAGO_WEBHOOK_SECRET || "";
function verifySignature(req: NextRequest): boolean {
if (!WEBHOOK_SECRET) return true; // Skip in dev if no secret configured
const xSignature = req.headers.get("x-signature") || "";
const xRequestId = req.headers.get("x-request-id") || "";
// MercadoPago v2 signature: ts=xxx,v1=xxx
const parts = Object.fromEntries(
xSignature.split(",").map((p) => {
const [k, ...v] = p.trim().split("=");
return [k, v.join("=")];
})
);
const dataId = new URL(req.url).searchParams.get("data.id") || "";
const manifest = `id:${dataId};request-id:${xRequestId};ts:${parts.ts};`;
const hmac = createHmac("sha256", WEBHOOK_SECRET)
.update(manifest)
.digest("hex");
return hmac === parts.v1;
}
export async function POST(req: NextRequest) {
try {
const body = await req.text();
if (!verifySignature(req)) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
const data = JSON.parse(body);
// Only process payment notifications
if (data.type !== "payment") {
return NextResponse.json({ ok: true });
}
const mpPaymentId = String(data.data?.id);
if (!mpPaymentId) {
return NextResponse.json({ ok: true });
}
// Fetch payment details from MercadoPago
const mpPayment = await paymentClient.get({ id: mpPaymentId });
if (mpPayment.status !== "approved") {
// Update our record status but don't mint
const externalRef = mpPayment.external_reference;
if (externalRef) {
await bridgePatch(`/api/payments/${externalRef}`, {
status: mpPayment.status,
mp_payment_id: mpPaymentId,
});
}
return NextResponse.json({ ok: true });
}
const paymentId = mpPayment.external_reference;
if (!paymentId) {
console.error("webhook: no external_reference in MP payment");
return NextResponse.json({ ok: true });
}
// Get our payment record
let payment;
try {
payment = (await bridgeGet(`/api/payments/${paymentId}`)).payment;
} catch {
console.error("webhook: payment not found:", paymentId);
return NextResponse.json({ ok: true });
}
// Idempotency: if already minted, skip
if (payment.status === "completed" && payment.tx_hash) {
return NextResponse.json({ ok: true, already_processed: true });
}
// Mint AFC via bridge deposit endpoint
const mintResult = await bridgePost("/api/deposit", {
diskId: payment.disk_id,
amount: payment.amount_afc,
});
// Update payment record as completed
await bridgePatch(`/api/payments/${paymentId}`, {
status: "completed",
mp_payment_id: mpPaymentId,
tx_hash: mintResult.txHash,
});
console.log(
`webhook: minted ${payment.amount_afc} AFC for disk ${payment.disk_id}, tx: ${mintResult.txHash}`
);
return NextResponse.json({ ok: true, minted: true });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error("webhook error:", e);
// Always return 200 to MP so it doesn't retry endlessly
return NextResponse.json({ ok: true, error: message });
}
}

View File

@@ -0,0 +1,54 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-dm-sans), system-ui, sans-serif;
--font-display: var(--font-playfair), Georgia, serif;
--font-body: var(--font-source-serif), Georgia, serif;
}
/* ── Editorial prose — game descriptions ────────────────── */
.prose-editorial p {
font-family: var(--font-body);
font-size: 1.125rem;
line-height: 1.85;
color: #d1d5db;
margin-bottom: 1.5em;
}
.prose-editorial p:last-child {
margin-bottom: 0;
}
/* ── Chapter reading experience ─────────────────────────── */
.chapter-prose p {
font-family: var(--font-body);
font-size: 1.1875rem;
line-height: 1.9;
color: #e5e7eb;
margin-bottom: 1.75em;
letter-spacing: 0.005em;
}
.chapter-prose > p:first-of-type::first-letter {
float: left;
font-family: var(--font-display);
font-size: 3.5rem;
line-height: 1;
padding-right: 0.5rem;
margin-top: 0.1rem;
font-weight: 700;
color: #f59e0b;
}
.chapter-prose p:last-child {
margin-bottom: 0;
}
/* ── Em-dash and quotation styling inside prose ─────────── */
.chapter-prose p em {
font-style: italic;
color: #fbbf24;
}

View File

@@ -0,0 +1,9 @@
import "./globals.css";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}

View File

@@ -0,0 +1,13 @@
export default function RootNotFound() {
return (
<html lang="es">
<body style={{ backgroundColor: "#030712", color: "#fff", fontFamily: "system-ui", display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", margin: 0 }}>
<div style={{ textAlign: "center" }}>
<h1 style={{ fontSize: "3rem", marginBottom: "1rem" }}>404</h1>
<p style={{ color: "#9ca3af" }}>Page not found</p>
<a href="/es" style={{ color: "#60a5fa", marginTop: "1rem", display: "inline-block" }}>Go home</a>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
interface AfcPackageCardProps {
amount: number;
priceMxn: number;
popular?: boolean;
loading?: boolean;
onSelect: () => void;
}
export function AfcPackageCard({
amount,
priceMxn,
popular,
loading,
onSelect,
}: AfcPackageCardProps) {
return (
<button
onClick={onSelect}
disabled={loading}
className={`relative group block w-full text-left bg-gray-900 rounded-2xl p-6 border transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 ${
popular
? "border-amber-500/50 shadow-lg shadow-amber-500/10"
: "border-white/5 hover:border-amber-500/30"
}`}
>
{popular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-amber-500 text-black text-xs font-bold px-3 py-1 rounded-full">
POPULAR
</span>
)}
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center shrink-0 group-hover:bg-amber-500/20 transition-colors">
<span className="text-xl font-bold text-amber-400">{amount}</span>
</div>
<div className="flex-1">
<p className="text-white font-semibold text-lg">{amount} AFC</p>
<p className="text-gray-500 text-sm">AfterCoin</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-white">${priceMxn}</p>
<p className="text-xs text-gray-500">MXN</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useTranslations } from "next-intl";
interface BalanceDisplayProps {
balance: number | null;
compact?: boolean;
}
export function BalanceDisplay({ balance, compact }: BalanceDisplayProps) {
const t = useTranslations("afc");
if (balance === null) return null;
if (compact) {
return (
<span className="inline-flex items-center gap-1.5 text-amber-400 font-semibold">
<span className="w-4 h-4 rounded-full bg-amber-500/20 border border-amber-500/40 inline-flex items-center justify-center text-[10px]">
A
</span>
{balance} AFC
</span>
);
}
return (
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 border border-amber-500/20 rounded-2xl p-6 text-center">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-amber-500/15 border-2 border-amber-500/30 flex items-center justify-center">
<span className="text-2xl font-bold text-amber-400">A</span>
</div>
<p className="text-sm text-gray-400 mb-1">{t("your_balance")}</p>
<p className="text-4xl font-bold text-white">{balance}</p>
<p className="text-sm text-amber-400 mt-1">AfterCoin</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslations } from "next-intl";
interface DiskIdInputProps {
diskId: string;
onChange: (value: string) => void;
onVerify: () => void;
loading: boolean;
verified: boolean;
playerName: string | null;
error: string | null;
onClear?: () => void;
}
export function DiskIdInput({
diskId,
onChange,
onVerify,
loading,
verified,
playerName,
error,
onClear,
}: DiskIdInputProps) {
const t = useTranslations("afc");
if (verified && playerName) {
return (
<div className="flex items-center gap-4 bg-amber-500/10 border border-amber-500/30 rounded-xl px-5 py-4">
<div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center text-amber-400 font-bold text-lg shrink-0">
{playerName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-amber-400/70">{t("disk_id")}: {diskId}</p>
<p className="text-white font-semibold truncate">{playerName}</p>
</div>
{onClear && (
<button
onClick={onClear}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{t("change")}
</button>
)}
</div>
);
}
return (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-400">
{t("enter_disk_id")}
</label>
<div className="flex gap-3">
<input
type="text"
value={diskId}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onVerify()}
placeholder={t("disk_id_placeholder")}
className="flex-1 bg-gray-900 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
/>
<button
onClick={onVerify}
disabled={loading || !diskId.trim()}
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-semibold rounded-xl transition-colors"
>
{loading ? "..." : t("verify")}
</button>
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { StatusBadge } from "./StatusBadge";
import type { Payment } from "@/lib/afc";
interface PaymentHistoryTableProps {
payments: Payment[];
}
export function PaymentHistoryTable({ payments }: PaymentHistoryTableProps) {
const t = useTranslations("afc");
if (payments.length === 0) {
return (
<p className="text-center text-gray-500 py-8">{t("no_payments")}</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-gray-400">
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
<th className="text-right py-3 px-2 font-medium">AFC</th>
<th className="text-right py-3 px-2 font-medium">MXN</th>
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b border-white/5 hover:bg-white/[0.02]">
<td className="py-3 px-2 text-gray-300">
{new Date(p.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-2 text-right text-amber-400 font-medium">
+{p.amount_afc}
</td>
<td className="py-3 px-2 text-right text-gray-400">
${p.amount_mxn}
</td>
<td className="py-3 px-2 text-center">
<StatusBadge status={p.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
interface PrizeCardProps {
icon: string;
brand: string;
label: string;
costAfc: number;
valueMxn: number;
disabled?: boolean;
onSelect: () => void;
}
export function PrizeCard({
icon,
brand,
label,
costAfc,
valueMxn,
disabled,
onSelect,
}: PrizeCardProps) {
return (
<button
onClick={onSelect}
disabled={disabled}
className="group block w-full text-left bg-gray-900 rounded-2xl p-5 border border-white/5 hover:border-amber-500/30 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-40 disabled:pointer-events-none"
>
<div className="flex items-center gap-4">
<span className="text-3xl">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold">{brand}</p>
<p className="text-gray-500 text-sm truncate">{label}</p>
</div>
<div className="text-right shrink-0">
<p className="text-lg font-bold text-amber-400">{costAfc} AFC</p>
<p className="text-xs text-gray-500">${valueMxn} MXN</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface RedeemFormProps {
prizeType: string;
prizeDetail: string;
costAfc: number;
onSubmit: (deliveryInfo: string) => void;
onCancel: () => void;
loading: boolean;
}
export function RedeemForm({
prizeType,
prizeDetail,
costAfc,
onSubmit,
onCancel,
loading,
}: RedeemFormProps) {
const t = useTranslations("afc");
const [deliveryInfo, setDeliveryInfo] = useState("");
const isBankTransfer = prizeType === "bank_transfer";
const isMercadoPago = prizeType === "mercadopago";
const placeholder = isBankTransfer
? t("clabe_placeholder")
: isMercadoPago
? t("mp_account_placeholder")
: t("delivery_placeholder");
const label = isBankTransfer
? t("clabe_label")
: isMercadoPago
? t("mp_account_label")
: t("delivery_label");
return (
<div className="bg-gray-900 border border-white/10 rounded-2xl p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{prizeDetail}</h3>
<p className="text-sm text-amber-400">{costAfc} AFC</p>
</div>
<button
onClick={onCancel}
className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
>
{t("cancel")}
</button>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-400">
{label}
</label>
<input
type="text"
value={deliveryInfo}
onChange={(e) => setDeliveryInfo(e.target.value)}
placeholder={placeholder}
className="w-full bg-gray-800 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-600 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/25 transition-all"
/>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-sm text-amber-300/80">
{t("redeem_warning")}
</div>
<button
onClick={() => onSubmit(deliveryInfo)}
disabled={loading || !deliveryInfo.trim()}
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-700 disabled:text-gray-500 text-black font-bold rounded-xl transition-colors"
>
{loading ? t("processing") : t("confirm_redeem")}
</button>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { StatusBadge } from "./StatusBadge";
import type { Redemption } from "@/lib/afc";
interface RedemptionHistoryTableProps {
redemptions: Redemption[];
}
export function RedemptionHistoryTable({ redemptions }: RedemptionHistoryTableProps) {
const t = useTranslations("afc");
if (redemptions.length === 0) {
return (
<p className="text-center text-gray-500 py-8">{t("no_redemptions")}</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-gray-400">
<th className="text-left py-3 px-2 font-medium">{t("date")}</th>
<th className="text-left py-3 px-2 font-medium">{t("prize")}</th>
<th className="text-right py-3 px-2 font-medium">AFC</th>
<th className="text-center py-3 px-2 font-medium">{t("status")}</th>
</tr>
</thead>
<tbody>
{redemptions.map((r) => (
<tr key={r.id} className="border-b border-white/5 hover:bg-white/[0.02]">
<td className="py-3 px-2 text-gray-300">
{new Date(r.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-2 text-white">
{r.prize_detail}
</td>
<td className="py-3 px-2 text-right text-red-400 font-medium">
-{r.amount_afc}
</td>
<td className="py-3 px-2 text-center">
<StatusBadge status={r.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
interface StatusBadgeProps {
status: string;
}
const STATUS_STYLES: Record<string, string> = {
pending: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
completed: "bg-green-500/15 text-green-400 border-green-500/30",
approved: "bg-green-500/15 text-green-400 border-green-500/30",
fulfilled: "bg-green-500/15 text-green-400 border-green-500/30",
rejected: "bg-red-500/15 text-red-400 border-red-500/30",
failed: "bg-red-500/15 text-red-400 border-red-500/30",
};
const DEFAULT_STYLE = "bg-gray-500/15 text-gray-400 border-gray-500/30";
export function StatusBadge({ status }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || DEFAULT_STYLE;
return (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{status}
</span>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import type { Genre, ServerStatus } from "@afterlife/shared";
const GENRES: Genre[] = ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"];
const STATUSES: ServerStatus[] = ["online", "maintenance", "coming_soon"];
export function CatalogFilters() {
const t = useTranslations("catalog");
const tGame = useTranslations("game");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const currentGenre = searchParams.get("genre") || "";
const currentStatus = searchParams.get("status") || "";
function setFilter(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
}
return (
<div className="flex flex-wrap gap-4 mb-8">
<select
value={currentGenre}
onChange={(e) => setFilter("genre", e.target.value)}
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
>
<option value="">{t("filter_genre")}: {t("all")}</option>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
<select
value={currentStatus}
onChange={(e) => setFilter("status", e.target.value)}
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
>
<option value="">{t("filter_status")}: {t("all")}</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{tGame(`status_${s}`)}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { useTranslations } from "next-intl";
import type { Game } from "@afterlife/shared";
import { GameCard } from "../shared/GameCard";
interface CatalogGridProps {
games: Game[];
locale: string;
}
export function CatalogGrid({ games, locale }: CatalogGridProps) {
const t = useTranslations("catalog");
if (games.length === 0) {
return (
<div className="text-center py-20">
<p className="text-gray-500 text-lg">{t("no_results")}</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.map((game) => (
<GameCard key={game.id} game={game} locale={locale} />
))}
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useTranslations } from "next-intl";
interface AudioPlayerProps {
trackTitle: string | null;
isPlaying: boolean;
progress: number;
duration: number;
playbackRate: number;
continuousMode: boolean;
onToggle: () => void;
onSeek: (seconds: number) => void;
onChangeRate: (rate: number) => void;
onToggleContinuous: () => void;
}
const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function AudioPlayer({
trackTitle,
isPlaying,
progress,
duration,
playbackRate,
continuousMode,
onToggle,
onSeek,
onChangeRate,
onToggleContinuous,
}: AudioPlayerProps) {
const t = useTranslations("audio");
if (!trackTitle) return null;
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900/95 backdrop-blur-sm border-t border-white/10">
<div className="max-w-7xl mx-auto px-4 py-3">
{/* Progress bar */}
<div
className="w-full h-1 bg-gray-700 rounded-full mb-3 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
onSeek(ratio * duration);
}}
>
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={onToggle}
className="w-10 h-10 flex items-center justify-center bg-white rounded-full text-black hover:bg-gray-200 transition-colors"
aria-label={isPlaying ? t("pause") : t("play")}
>
{isPlaying ? "\u23F8" : "\u25B6"}
</button>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{trackTitle}</p>
<p className="text-xs text-gray-500">
{formatTime(progress)} / {formatTime(duration)}
</p>
</div>
{/* Speed selector */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t("speed")}:</span>
<select
value={playbackRate}
onChange={(e) => onChangeRate(Number(e.target.value))}
className="bg-gray-800 border border-white/10 rounded px-2 py-1 text-xs text-white"
>
{RATES.map((r) => (
<option key={r} value={r}>
{r}x
</option>
))}
</select>
</div>
{/* Continuous mode toggle */}
<button
onClick={onToggleContinuous}
className={`text-xs px-3 py-1 rounded border transition-colors ${
continuousMode
? "border-blue-500 text-blue-400"
: "border-white/10 text-gray-500 hover:text-white"
}`}
>
{continuousMode ? t("continuous_mode") : t("chapter_mode")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import Image from "next/image";
import type { Chapter } from "@afterlife/shared";
import { formatTextToHtml } from "@/lib/format";
interface ChapterContentProps {
chapter: Chapter;
}
export function ChapterContent({ chapter }: ChapterContentProps) {
return (
<article className="max-w-2xl mx-auto">
{/* Chapter indicator */}
<div className="mb-10">
<div className="flex items-center gap-4 mb-4">
<span className="text-amber-500/80 font-display text-sm tracking-[0.2em]">
{String(chapter.order).padStart(2, "0")}
</span>
<div className="h-px flex-1 bg-white/10" />
</div>
<h2 className="text-3xl sm:text-4xl font-display font-bold leading-tight tracking-tight">
{chapter.title}
</h2>
</div>
{chapter.coverImage && (
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
<Image
src={chapter.coverImage.url}
alt={chapter.coverImage.alternativeText || chapter.title}
fill
className="object-cover"
/>
</div>
)}
<div
className="chapter-prose"
dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
/>
</article>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import type { Chapter } from "@afterlife/shared";
import { useTranslations } from "next-intl";
interface ChapterNavProps {
chapters: Chapter[];
activeChapterId: number;
onSelectChapter: (id: number, index: number) => void;
}
export function ChapterNav({
chapters,
activeChapterId,
onSelectChapter,
}: ChapterNavProps) {
const t = useTranslations("documentary");
return (
<nav className="w-72 flex-shrink-0 hidden lg:block">
<div className="sticky top-24">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-[0.15em] mb-5">
{t("chapters")}
</h3>
<ol className="space-y-0.5">
{chapters.map((chapter, index) => (
<li key={chapter.id}>
<button
onClick={() => onSelectChapter(chapter.id, index)}
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
chapter.id === activeChapterId
? "bg-amber-500/10 text-amber-400 font-medium border-l-2 border-amber-500 rounded-l-none"
: "text-gray-400 hover:text-gray-200 hover:bg-white/[0.03]"
}`}
>
<span
className={`text-xs mr-2 tabular-nums ${
chapter.id === activeChapterId
? "text-amber-500/70"
: "text-gray-600"
}`}
>
{String(index + 1).padStart(2, "0")}
</span>
{chapter.title}
</button>
</li>
))}
</ol>
</div>
</nav>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import type { Documentary, Chapter } from "@afterlife/shared";
import { ChapterNav } from "./ChapterNav";
import { ChapterContent } from "./ChapterContent";
import { AudioPlayer } from "./AudioPlayer";
import { ReadingProgress } from "./ReadingProgress";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
interface DocumentaryLayoutProps {
documentary: Documentary;
}
export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
const chapters = [...documentary.chapters].sort((a, b) => a.order - b.order);
const [activeChapter, setActiveChapter] = useState<Chapter>(chapters[0]);
const audio = useAudioPlayer();
useEffect(() => {
const audioTracks = chapters
.filter((ch) => ch.audioFile)
.map((ch) => ({
id: ch.id,
title: ch.title,
url: ch.audioFile!.url,
duration: ch.audioDuration ?? 0,
}));
audio.setTracks(audioTracks);
if (audioTracks.length > 0) {
audio.loadTrack(0);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSelectChapter(chapterId: number, index: number) {
const chapter = chapters.find((c) => c.id === chapterId);
if (chapter) {
setActiveChapter(chapter);
window.scrollTo({ top: 0, behavior: "smooth" });
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
if (trackIndex !== -1) {
audio.goToTrack(trackIndex);
}
}
}
return (
<>
<ReadingProgress />
{/* Documentary header */}
<header className="border-b border-white/[0.06]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-8">
<h1 className="text-3xl sm:text-4xl font-display font-bold tracking-tight">
{documentary.title}
</h1>
{documentary.description && (
<p className="mt-3 text-gray-400 font-body text-lg max-w-3xl leading-relaxed">
{documentary.description}
</p>
)}
</div>
</header>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12">
<ChapterNav
chapters={chapters}
activeChapterId={activeChapter.id}
onSelectChapter={handleSelectChapter}
/>
<div className="flex-1 min-w-0 pb-24">
<ChapterContent chapter={activeChapter} />
</div>
</div>
<AudioPlayer
trackTitle={audio.currentTrack?.title ?? null}
isPlaying={audio.isPlaying}
progress={audio.progress}
duration={audio.duration}
playbackRate={audio.playbackRate}
continuousMode={audio.continuousMode}
onToggle={audio.toggle}
onSeek={audio.seek}
onChangeRate={audio.changeRate}
onToggleContinuous={() =>
audio.setContinuousMode(!audio.continuousMode)
}
/>
</>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
export function ReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="fixed top-16 left-0 right-0 z-40 h-0.5 bg-gray-800">
<div
className="h-full bg-blue-500 transition-all duration-150"
style={{ width: `${progress}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import Image from "next/image";
import type { Game } from "@afterlife/shared";
interface GameHeaderProps {
game: Game;
}
export function GameHeader({ game }: GameHeaderProps) {
return (
<div className="relative h-[50vh] overflow-hidden">
{game.coverImage && (
<Image
src={game.coverImage.url}
alt={game.title}
fill
className="object-cover"
priority
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 via-gray-950/60 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 max-w-7xl mx-auto">
<h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
{game.title}
</h1>
<p className="text-gray-400 text-lg font-body">
{game.developer} · {game.releaseYear}{game.shutdownYear}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useTranslations } from "next-intl";
import Link from "next/link";
import type { Game } from "@afterlife/shared";
import { formatTextToHtml } from "@/lib/format";
interface GameInfoProps {
game: Game;
locale: string;
}
export function GameInfo({ game, locale }: GameInfoProps) {
const t = useTranslations("game");
const statusColors = {
online: "text-green-400",
maintenance: "text-yellow-400",
coming_soon: "text-blue-400",
};
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div className="md:col-span-2">
<div
className="prose-editorial"
dangerouslySetInnerHTML={{ __html: formatTextToHtml(game.description) }}
/>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-b from-gray-900 to-gray-900/50 rounded-xl p-6 border border-white/[0.07]">
<dl className="divide-y divide-white/5 text-sm">
<div className="pb-3">
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("developer")}
</dt>
<dd className="text-gray-100 font-medium">{game.developer}</dd>
</div>
{game.publisher && (
<div className="py-3">
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("publisher")}
</dt>
<dd className="text-gray-100 font-medium">{game.publisher}</dd>
</div>
)}
<div className="py-3">
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("released")}
</dt>
<dd className="text-gray-100 font-medium">{game.releaseYear}</dd>
</div>
<div className="py-3">
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("shutdown")}
</dt>
<dd className="text-gray-100 font-medium">{game.shutdownYear}</dd>
</div>
<div className="pt-3">
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("server_status")}
</dt>
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
{t(`status_${game.serverStatus}`)}
</dd>
</div>
</dl>
<div className="mt-6 space-y-3">
{game.serverLink && game.serverStatus === "online" && (
<a
href={game.serverLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full text-center px-4 py-2.5 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors font-medium text-sm"
>
{t("play_now")}
</a>
)}
{game.documentary && (
<Link
href={`/${locale}/games/${game.slug}/documentary`}
className="block w-full text-center px-4 py-2.5 bg-amber-600 hover:bg-amber-500 text-white rounded-lg transition-colors font-medium text-sm"
>
{t("view_documentary")}
</Link>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import type { StrapiMedia } from "@afterlife/shared";
interface ScreenshotGalleryProps {
screenshots: StrapiMedia[];
}
export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) {
const [selected, setSelected] = useState(0);
if (screenshots.length === 0) return null;
return (
<div className="mt-12">
<div className="relative aspect-video rounded-lg overflow-hidden mb-4">
<Image
src={screenshots[selected].url}
alt={screenshots[selected].alternativeText || "Screenshot"}
fill
className="object-cover"
/>
</div>
{screenshots.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{screenshots.map((ss, i) => (
<button
key={ss.id}
onClick={() => setSelected(i)}
className={`relative w-24 h-16 rounded overflow-hidden flex-shrink-0 border-2 transition-colors ${
i === selected ? "border-blue-500" : "border-transparent"
}`}
>
<Image
src={ss.url}
alt={ss.alternativeText || `Screenshot ${i + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useLocale } from "next-intl";
export function DonationCTA() {
const t = useTranslations("donate");
const locale = useLocale();
return (
<section className="bg-gradient-to-r from-blue-950/50 to-purple-950/50 py-20">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-4">{t("title")}</h2>
<p className="text-gray-400 mb-8">{t("description")}</p>
<Link
href={`/${locale}/donate`}
className="inline-block px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
>
{t("patreon")}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import Link from "next/link";
import { useLocale } from "next-intl";
export function HeroSection() {
const t = useTranslations("home");
const locale = useLocale();
return (
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-blue-950/20 via-gray-950 to-gray-950" />
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="text-5xl md:text-7xl font-bold tracking-tight mb-6"
>
{t("hero_title")}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-xl md:text-2xl text-gray-400 mb-10"
>
{t("hero_subtitle")}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="flex gap-4 justify-center"
>
<Link
href={`/${locale}/catalog`}
className="px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
>
{t("view_all")}
</Link>
<Link
href={`/${locale}/donate`}
className="px-8 py-3 border border-white/20 text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
>
{t("donate_cta")}
</Link>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslations } from "next-intl";
import Link from "next/link";
import type { Game } from "@afterlife/shared";
import { GameCard } from "../shared/GameCard";
interface LatestGamesProps {
games: Game[];
locale: string;
}
export function LatestGames({ games, locale }: LatestGamesProps) {
const t = useTranslations("home");
if (games.length === 0) return null;
return (
<section className="max-w-7xl mx-auto px-4 py-20">
<div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold">{t("latest_games")}</h2>
<Link
href={`/${locale}/catalog`}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
{t("view_all")}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.slice(0, 6).map((game) => (
<GameCard key={game.id} game={game} locale={locale} />
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,13 @@
import { useTranslations } from "next-intl";
export function Footer() {
const t = useTranslations("footer");
return (
<footer className="bg-black border-t border-white/10 py-8">
<div className="max-w-7xl mx-auto px-4 text-center">
<p className="text-sm text-gray-500">{t("rights")}</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("footer");
function switchLocale(newLocale: string) {
const segments = pathname.split("/");
segments[1] = newLocale;
router.push(segments.join("/"));
}
return (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">{t("language")}:</span>
<button
onClick={() => switchLocale("es")}
className={`text-sm ${locale === "es" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
>
ES
</button>
<span className="text-gray-600">|</span>
<button
onClick={() => switchLocale("en")}
className={`text-sm ${locale === "en" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
>
EN
</button>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { LanguageSwitcher } from "./LanguageSwitcher";
export function Navbar() {
const t = useTranslations("nav");
const locale = useLocale();
const links = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/catalog`, label: t("catalog") },
{ href: `/${locale}/afc`, label: t("afc") },
{ href: `/${locale}/about`, label: t("about") },
{ href: `/${locale}/donate`, label: t("donate") },
];
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<Link href={`/${locale}`} className="text-xl font-bold text-white tracking-tight">
Project Afterlife
</Link>
<div className="flex items-center gap-6">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-300 hover:text-white transition-colors"
>
{link.label}
</Link>
))}
<LanguageSwitcher />
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import Image from "next/image";
import type { Game } from "@afterlife/shared";
interface GameCardProps {
game: Game;
locale: string;
}
export function GameCard({ game, locale }: GameCardProps) {
const statusColors = {
online: "bg-green-500",
maintenance: "bg-yellow-500",
coming_soon: "bg-blue-500",
};
return (
<Link href={`/${locale}/games/${game.slug}`} className="group block">
<div className="relative overflow-hidden rounded-lg bg-gray-900 border border-white/5 hover:border-white/20 transition-all">
{game.coverImage && (
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={game.coverImage.url}
alt={game.coverImage.alternativeText || game.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent" />
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2 h-2 rounded-full ${statusColors[game.serverStatus]}`} />
<span className="text-xs text-gray-400 uppercase tracking-wider">{game.genre}</span>
</div>
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{game.title}
</h3>
<p className="text-sm text-gray-500 mt-1">
{game.releaseYear} {game.shutdownYear}
</p>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Howl } from "howler";
interface AudioTrack {
id: number;
title: string;
url: string;
duration: number;
}
export function useAudioPlayer() {
const [tracks, setTracks] = useState<AudioTrack[]>([]);
const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [playbackRate, setPlaybackRate] = useState(1);
const [continuousMode, setContinuousMode] = useState(false);
const howlRef = useRef<Howl | null>(null);
const animFrameRef = useRef<number>(0);
const currentTrack = tracks[currentTrackIndex] ?? null;
const destroyHowl = useCallback(() => {
if (howlRef.current) {
howlRef.current.unload();
howlRef.current = null;
}
cancelAnimationFrame(animFrameRef.current);
}, []);
const loadTrack = useCallback(
(index: number) => {
if (!tracks[index]) return;
destroyHowl();
const howl = new Howl({
src: [tracks[index].url],
html5: true,
rate: playbackRate,
onplay: () => {
setIsPlaying(true);
const updateProgress = () => {
if (howl.playing()) {
setProgress(howl.seek() as number);
animFrameRef.current = requestAnimationFrame(updateProgress);
}
};
animFrameRef.current = requestAnimationFrame(updateProgress);
},
onpause: () => setIsPlaying(false),
onstop: () => setIsPlaying(false),
onend: () => {
setIsPlaying(false);
if (continuousMode && index < tracks.length - 1) {
setCurrentTrackIndex(index + 1);
}
},
onload: () => {
setDuration(howl.duration());
},
});
howlRef.current = howl;
setCurrentTrackIndex(index);
setProgress(0);
},
[tracks, playbackRate, continuousMode, destroyHowl]
);
const play = useCallback(() => howlRef.current?.play(), []);
const pause = useCallback(() => howlRef.current?.pause(), []);
const toggle = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const seek = useCallback((seconds: number) => {
howlRef.current?.seek(seconds);
setProgress(seconds);
}, []);
const changeRate = useCallback(
(rate: number) => {
setPlaybackRate(rate);
howlRef.current?.rate(rate);
},
[]
);
const goToTrack = useCallback(
(index: number) => {
loadTrack(index);
setTimeout(() => howlRef.current?.play(), 100);
},
[loadTrack]
);
useEffect(() => {
return () => destroyHowl();
}, [destroyHowl]);
return {
tracks,
setTracks,
currentTrack,
currentTrackIndex,
isPlaying,
progress,
duration,
playbackRate,
continuousMode,
setContinuousMode,
loadTrack,
play,
pause,
toggle,
seek,
changeRate,
goToTrack,
};
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { verifyDiskId, getBalance } from "@/lib/afc";
const STORAGE_KEY = "afc_disk_id";
export function useDiskId() {
const [diskId, setDiskId] = useState("");
const [playerName, setPlayerName] = useState<string | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Restore from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
setDiskId(stored);
}
}, []);
const verify = useCallback(async (id: string) => {
if (!id.trim()) return;
setLoading(true);
setError(null);
try {
const result = await verifyDiskId(id);
if (result.valid) {
setVerified(true);
setPlayerName(result.name);
localStorage.setItem(STORAGE_KEY, id);
const balData = await getBalance(id);
setBalance(balData.balance ?? null);
} else {
setVerified(false);
setPlayerName(null);
setBalance(null);
setError("Disk ID not found");
}
} catch {
setError("Connection error");
setVerified(false);
} finally {
setLoading(false);
}
}, []);
const refreshBalance = useCallback(async () => {
if (!diskId || !verified) return;
try {
const data = await getBalance(diskId);
setBalance(data.balance ?? null);
} catch {
// silent
}
}, [diskId, verified]);
const clear = useCallback(() => {
setDiskId("");
setPlayerName(null);
setBalance(null);
setVerified(false);
setError(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
return {
diskId,
setDiskId,
playerName,
balance,
verified,
loading,
error,
verify,
refreshBalance,
clear,
};
}

88
apps/web/src/lib/afc.ts Normal file
View File

@@ -0,0 +1,88 @@
/** Client-side fetch wrappers for AFC Store API routes */
export async function verifyDiskId(diskId: string) {
const res = await fetch(`/api/afc/verify-disk?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ valid: boolean; name: string | null }>;
}
export async function getBalance(diskId: string) {
const res = await fetch(`/api/afc/balance?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ balance: number; error?: string }>;
}
export async function createPreference(diskId: string, amountAfc: number) {
const res = await fetch("/api/afc/create-preference", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ diskId, amountAfc }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to create payment");
}
return res.json() as Promise<{
paymentId: string;
initPoint: string;
sandboxInitPoint: string;
}>;
}
export async function redeemAfc(params: {
diskId: string;
amountAfc: number;
prizeType: string;
prizeDetail: string;
deliveryInfo: string;
}) {
const res = await fetch("/api/afc/redeem", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to redeem");
}
return res.json() as Promise<{
redemptionId: string;
burnTxHash: string;
balance: number;
}>;
}
export async function getPaymentHistory(diskId: string) {
const res = await fetch(`/api/afc/payments?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ payments: Payment[] }>;
}
export async function getRedemptionHistory(diskId: string) {
const res = await fetch(`/api/afc/redemptions?diskId=${encodeURIComponent(diskId)}`);
return res.json() as Promise<{ redemptions: Redemption[] }>;
}
export interface Payment {
id: string;
disk_id: string;
amount_afc: number;
amount_mxn: number;
status: string;
mp_preference_id: string | null;
mp_payment_id: string | null;
tx_hash: string | null;
created_at: string;
updated_at: string;
}
export interface Redemption {
id: string;
disk_id: string;
amount_afc: number;
prize_type: string;
prize_detail: string;
delivery_info: string | null;
status: string;
burn_tx_hash: string | null;
admin_notes: string | null;
created_at: string;
updated_at: string;
}

72
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,72 @@
import type {
Game,
Documentary,
Chapter,
StrapiListResponse,
StrapiResponse,
} from "@afterlife/shared";
import { strapiGet } from "./strapi";
export async function getGames(locale: string): Promise<StrapiListResponse<Game>> {
return strapiGet({
path: "/games",
locale,
params: {
"populate[coverImage][fields][0]": "url",
"populate[coverImage][fields][1]": "alternativeText",
"populate[documentary][fields][0]": "title",
"sort": "createdAt:desc",
},
});
}
export async function getGameBySlug(slug: string, locale: string): Promise<StrapiResponse<Game>> {
return strapiGet({
path: "/games",
locale,
params: {
"filters[slug][$eq]": slug,
"populate[coverImage][fields][0]": "url",
"populate[coverImage][fields][1]": "alternativeText",
"populate[coverImage][fields][2]": "width",
"populate[coverImage][fields][3]": "height",
"populate[screenshots][fields][0]": "url",
"populate[screenshots][fields][1]": "alternativeText",
"populate[documentary][populate][chapters][fields][0]": "title",
"populate[documentary][populate][chapters][fields][1]": "content",
"populate[documentary][populate][chapters][fields][2]": "order",
"populate[documentary][populate][chapters][fields][3]": "audioDuration",
"populate[documentary][populate][chapters][populate][audioFile][fields][0]": "url",
"populate[documentary][populate][chapters][populate][coverImage][fields][0]": "url",
"populate[documentary][populate][chapters][populate][coverImage][fields][1]": "alternativeText",
},
});
}
export async function getDocumentaryByGameSlug(
slug: string,
locale: string
): Promise<Documentary | null> {
const gameRes = await getGameBySlug(slug, locale);
const game = Array.isArray(gameRes.data) ? gameRes.data[0] : gameRes.data;
return game?.documentary ?? null;
}
export async function getChapter(
chapterId: number,
locale: string
): Promise<StrapiResponse<Chapter>> {
return strapiGet({
path: `/chapters/${chapterId}`,
locale,
params: {
"populate[audioFile][fields][0]": "url",
"populate[audioFile][fields][1]": "name",
"populate[audioFile][fields][2]": "mime",
"populate[coverImage][fields][0]": "url",
"populate[coverImage][fields][1]": "alternativeText",
"populate[coverImage][fields][2]": "width",
"populate[coverImage][fields][3]": "height",
},
});
}

View File

@@ -0,0 +1,18 @@
/**
* Converts plain text with newline separators into HTML paragraphs.
* If the text already contains HTML block elements, returns as-is.
*/
export function formatTextToHtml(text: string): string {
if (!text) return "";
// If already contains HTML block elements, return as-is
if (/<(?:p|div|h[1-6]|ul|ol|blockquote)\b/i.test(text)) {
return text;
}
return text
.split(/\n\n+/)
.filter((p) => p.trim())
.map((p) => `<p>${p.trim().replace(/\n/g, "<br>")}</p>`)
.join("");
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev";
export function createMetadata({
title,
description,
image,
path = "",
}: {
title: string;
description: string;
image?: string;
path?: string;
}): Metadata {
const url = `${BASE_URL}${path}`;
return {
title: `${title} | Project Afterlife`,
description,
openGraph: {
title,
description,
url,
siteName: "Project Afterlife",
images: image ? [{ url: image, width: 1200, height: 630 }] : [],
type: "website",
},
twitter: {
card: "summary_large_image",
title,
description,
images: image ? [image] : [],
},
};
}

View File

@@ -0,0 +1,35 @@
const STRAPI_URL = process.env.STRAPI_URL || "http://localhost:1337";
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
interface FetchOptions {
path: string;
params?: Record<string, string>;
locale?: string;
}
export async function strapiGet<T>({ path, params, locale }: FetchOptions): Promise<T> {
const url = new URL(`/api${path}`, STRAPI_URL);
if (locale) url.searchParams.set("locale", locale);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
const headers: HeadersInit = { "Content-Type": "application/json" };
if (STRAPI_TOKEN) {
headers.Authorization = `Bearer ${STRAPI_TOKEN}`;
}
const res = await fetch(url.toString(), {
headers,
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error(`Strapi error: ${res.status} ${res.statusText}`);
}
return res.json();
}

View File

@@ -3,7 +3,8 @@
"home": "Home",
"catalog": "Catalog",
"about": "About Us",
"donate": "Donations"
"donate": "Donations",
"afc": "AFC Store"
},
"home": {
"hero_title": "Project Afterlife",
@@ -59,5 +60,60 @@
"footer": {
"rights": "Project Afterlife. Preserving gaming history.",
"language": "Language"
},
"afc": {
"store_title": "AFC Store",
"store_subtitle": "Buy AfterCoin with real money or redeem your coins for prizes",
"store_info": "AfterCoin (AFC) is earned in the Minecraft casino. You can also buy AFC here with MercadoPago, or redeem your AFC for gift cards and cash. All redemptions are fulfilled manually by an admin within 24-48 hours.",
"disk_id": "Disk ID",
"enter_disk_id": "Enter your Disk ID to get started",
"disk_id_placeholder": "e.g. 7",
"verify": "Verify",
"change": "Change",
"your_balance": "Your balance",
"buy_title": "Buy AFC",
"buy_description": "Purchase AfterCoin with MercadoPago",
"buy_subtitle": "Select a package or enter a custom amount. Payment via MercadoPago.",
"redeem_title": "Redeem Prizes",
"redeem_description": "Exchange your AFC for gift cards or cash",
"redeem_subtitle": "Choose a prize to redeem with your AfterCoin.",
"history_title": "History",
"history_description": "View your purchase and redemption history",
"history_subtitle": "Track all your AFC transactions.",
"select_package": "Select a package",
"custom_amount": "Custom amount",
"buy": "Buy",
"back_to_store": "Back to AFC Store",
"payment_info": "Payments processed securely via MercadoPago. Supports credit/debit cards, OXXO, and bank transfers.",
"payment_success_title": "Payment Successful!",
"payment_success_description": "Your AfterCoin will be credited to your account within a few minutes.",
"payment_failure_title": "Payment Failed",
"payment_failure_description": "Something went wrong with your payment. No charges were made.",
"payment_pending_title": "Payment Pending",
"payment_pending_description": "Your payment is being processed. AFC will be credited once confirmed.",
"try_again": "Try Again",
"view_history": "View History",
"gift_cards": "Gift Cards",
"cash_out": "Cash Withdrawal",
"clabe_label": "CLABE (18 digits)",
"clabe_placeholder": "Enter your 18-digit CLABE",
"mp_account_label": "MercadoPago email or phone",
"mp_account_placeholder": "Email or phone number",
"delivery_label": "Delivery details",
"delivery_placeholder": "Email for gift card delivery",
"redeem_warning": "This action is irreversible. Your AFC will be burned immediately. The prize will be delivered by an admin within 24-48 hours.",
"confirm_redeem": "Confirm Redemption",
"cancel": "Cancel",
"processing": "Processing...",
"redeem_success_title": "Redemption Submitted!",
"redeem_success_description": "Your AFC has been burned. An admin will fulfill your prize within 24-48 hours.",
"purchases": "Purchases",
"redemptions": "Redemptions",
"loading": "Loading...",
"date": "Date",
"prize": "Prize",
"status": "Status",
"no_payments": "No purchases yet",
"no_redemptions": "No redemptions yet"
}
}

View File

@@ -3,7 +3,8 @@
"home": "Inicio",
"catalog": "Catálogo",
"about": "Sobre Nosotros",
"donate": "Donaciones"
"donate": "Donaciones",
"afc": "Tienda AFC"
},
"home": {
"hero_title": "Project Afterlife",
@@ -59,5 +60,60 @@
"footer": {
"rights": "Project Afterlife. Preservando la historia del gaming.",
"language": "Idioma"
},
"afc": {
"store_title": "Tienda AFC",
"store_subtitle": "Compra AfterCoin con dinero real o canjea tus monedas por premios",
"store_info": "AfterCoin (AFC) se gana en el casino de Minecraft. También puedes comprar AFC aquí con MercadoPago, o canjear tus AFC por tarjetas de regalo y efectivo. Todos los canjeos son cumplidos manualmente por un admin en 24-48 horas.",
"disk_id": "Disk ID",
"enter_disk_id": "Ingresa tu Disk ID para comenzar",
"disk_id_placeholder": "ej. 7",
"verify": "Verificar",
"change": "Cambiar",
"your_balance": "Tu saldo",
"buy_title": "Comprar AFC",
"buy_description": "Compra AfterCoin con MercadoPago",
"buy_subtitle": "Selecciona un paquete o ingresa una cantidad personalizada. Pago vía MercadoPago.",
"redeem_title": "Canjear Premios",
"redeem_description": "Cambia tus AFC por tarjetas de regalo o efectivo",
"redeem_subtitle": "Elige un premio para canjear con tus AfterCoin.",
"history_title": "Historial",
"history_description": "Consulta tu historial de compras y canjeos",
"history_subtitle": "Revisa todas tus transacciones de AFC.",
"select_package": "Selecciona un paquete",
"custom_amount": "Cantidad personalizada",
"buy": "Comprar",
"back_to_store": "Volver a Tienda AFC",
"payment_info": "Pagos procesados de forma segura vía MercadoPago. Acepta tarjetas de crédito/débito, OXXO y transferencias bancarias.",
"payment_success_title": "¡Pago Exitoso!",
"payment_success_description": "Tus AfterCoin serán acreditados a tu cuenta en unos minutos.",
"payment_failure_title": "Pago Fallido",
"payment_failure_description": "Algo salió mal con tu pago. No se realizó ningún cargo.",
"payment_pending_title": "Pago Pendiente",
"payment_pending_description": "Tu pago está siendo procesado. Los AFC serán acreditados una vez confirmado.",
"try_again": "Intentar de Nuevo",
"view_history": "Ver Historial",
"gift_cards": "Tarjetas de Regalo",
"cash_out": "Retiro de Efectivo",
"clabe_label": "CLABE (18 dígitos)",
"clabe_placeholder": "Ingresa tu CLABE de 18 dígitos",
"mp_account_label": "Email o teléfono de MercadoPago",
"mp_account_placeholder": "Email o número de teléfono",
"delivery_label": "Datos de entrega",
"delivery_placeholder": "Email para entrega de tarjeta de regalo",
"redeem_warning": "Esta acción es irreversible. Tus AFC serán quemados inmediatamente. El premio será entregado por un admin en 24-48 horas.",
"confirm_redeem": "Confirmar Canjeo",
"cancel": "Cancelar",
"processing": "Procesando...",
"redeem_success_title": "¡Canjeo Enviado!",
"redeem_success_description": "Tus AFC han sido quemados. Un admin cumplirá tu premio en 24-48 horas.",
"purchases": "Compras",
"redemptions": "Canjeos",
"loading": "Cargando...",
"date": "Fecha",
"prize": "Premio",
"status": "Estado",
"no_payments": "Sin compras aún",
"no_redemptions": "Sin canjeos aún"
}
}

12
blockchain/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM ethereum/client-go:v1.13.15
COPY genesis.json /app/genesis.json
COPY init-geth.sh /app/init-geth.sh
RUN chmod +x /app/init-geth.sh
EXPOSE 8545 8546
WORKDIR /app
ENTRYPOINT ["/app/init-geth.sh"]

View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title AfterCoin (AFC)
* @notice ERC-20 token for the Afterlife Project game preservation platform.
* 1 AFC = 1 diamond. Zero decimals — integer-only balances.
* @dev Self-contained implementation (no OpenZeppelin). Owner-gated mint,
* burn-from, and bridge-transfer helpers for the off-chain bridge service.
*/
contract AfterCoin {
// ──────────────────────────── ERC-20 metadata ────────────────────────────
string private constant _name = "AfterCoin";
string private constant _symbol = "AFC";
uint8 private constant _decimals = 0; // 1 token = 1 diamond
// ──────────────────────────── State ──────────────────────────────────────
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
address public owner;
// ──────────────────────────── Events (ERC-20) ────────────────────────────
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// ──────────────────────────── Errors ─────────────────────────────────────
error NotOwner();
error ZeroAddress();
error InsufficientBalance(address account, uint256 required, uint256 available);
error InsufficientAllowance(address spender, uint256 required, uint256 available);
// ──────────────────────────── Modifier ───────────────────────────────────
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
// ──────────────────────────── Constructor ────────────────────────────────
constructor() {
owner = msg.sender;
// Initial supply is 0 — tokens are minted on demand by the bridge.
}
// ──────────────────────────── ERC-20 view functions ──────────────────────
function name() external pure returns (string memory) {
return _name;
}
function symbol() external pure returns (string memory) {
return _symbol;
}
function decimals() external pure returns (uint8) {
return _decimals;
}
function totalSupply() external view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function allowance(address tokenOwner, address spender) external view returns (uint256) {
return _allowances[tokenOwner][spender];
}
// ──────────────────────────── ERC-20 mutative functions ──────────────────
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
uint256 currentAllowance = _allowances[from][msg.sender];
if (currentAllowance != type(uint256).max) {
if (currentAllowance < amount) {
revert InsufficientAllowance(msg.sender, amount, currentAllowance);
}
unchecked {
_approve(from, msg.sender, currentAllowance - amount);
}
}
_transfer(from, to, amount);
return true;
}
// ──────────────────────────── Owner-only functions ───────────────────────
/**
* @notice Mint new tokens to `to`. Only callable by the contract owner.
* @param to Recipient address.
* @param amount Number of tokens to create.
*/
function mint(address to, uint256 amount) external onlyOwner {
if (to == address(0)) revert ZeroAddress();
_totalSupply += amount;
_balances[to] += amount;
emit Transfer(address(0), to, amount);
}
/**
* @notice Burn tokens from `from`. Only callable by the contract owner.
* Does NOT require an allowance — the owner is the bridge operator.
* @param from Address whose tokens are burned.
* @param amount Number of tokens to destroy.
*/
function burnFrom(address from, uint256 amount) external onlyOwner {
if (from == address(0)) revert ZeroAddress();
uint256 bal = _balances[from];
if (bal < amount) {
revert InsufficientBalance(from, amount, bal);
}
unchecked {
_balances[from] = bal - amount;
}
_totalSupply -= amount;
emit Transfer(from, address(0), amount);
}
/**
* @notice Transfer tokens between two addresses on behalf of the bridge.
* Only callable by the contract owner.
* @param from Source address.
* @param to Destination address.
* @param amount Number of tokens to move.
*/
function bridgeTransfer(
address from,
address to,
uint256 amount
) external onlyOwner {
_transfer(from, to, amount);
}
// ──────────────────────────── Internal helpers ───────────────────────────
function _transfer(address from, address to, uint256 amount) internal {
if (from == address(0)) revert ZeroAddress();
if (to == address(0)) revert ZeroAddress();
uint256 fromBal = _balances[from];
if (fromBal < amount) {
revert InsufficientBalance(from, amount, fromBal);
}
unchecked {
_balances[from] = fromBal - amount;
}
_balances[to] += amount;
emit Transfer(from, to, amount);
}
function _approve(address tokenOwner, address spender, uint256 amount) internal {
if (tokenOwner == address(0)) revert ZeroAddress();
if (spender == address(0)) revert ZeroAddress();
_allowances[tokenOwner][spender] = amount;
emit Approval(tokenOwner, spender, amount);
}
}

27
blockchain/genesis.json Normal file
View File

@@ -0,0 +1,27 @@
{
"config": {
"chainId": 8888,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"clique": {
"period": 5,
"epoch": 0
}
},
"difficulty": "0x1",
"gasLimit": "0x1C9C380",
"extradata": "0x0000000000000000000000000000000000000000000000000000000000000000751c6F0Efd9B97A004969cfF9ACfA32230bdC4c40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"alloc": {
"0x751c6F0Efd9B97A004969cfF9ACfA32230bdC4c4": {
"balance": "0xffffffffffffffff"
}
}
}

50
blockchain/init-geth.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/sh
set -e
GENESIS_FILE="/app/genesis.json"
DATADIR="/data"
# Initialize geth datadir if not already done
if [ ! -d "$DATADIR/geth/chaindata" ]; then
echo "Initializing geth datadir with genesis block..."
geth init --datadir "$DATADIR" "$GENESIS_FILE"
fi
# Import admin private key if provided and no accounts exist yet
if [ -n "$ADMIN_PRIVATE_KEY" ]; then
EXISTING_ACCOUNTS=$(geth account list --datadir "$DATADIR" 2>/dev/null || true)
if [ -z "$EXISTING_ACCOUNTS" ]; then
echo "Importing admin private key..."
TMPKEY=$(mktemp)
echo "$ADMIN_PRIVATE_KEY" > "$TMPKEY"
geth account import --datadir "$DATADIR" --password /dev/null --lightkdf "$TMPKEY"
rm -f "$TMPKEY"
else
echo "Account(s) already exist, skipping import."
fi
fi
echo "Starting geth node..."
exec geth \
--datadir "$DATADIR" \
--networkid 8888 \
--http \
--http.addr 0.0.0.0 \
--http.port 8545 \
--http.api eth,net,web3,personal,txpool \
--http.corsdomain "*" \
--http.vhosts "*" \
--ws \
--ws.addr 0.0.0.0 \
--ws.port 8546 \
--ws.api eth,net,web3 \
--ws.origins "*" \
--mine \
--miner.etherbase "$ADMIN_ADDRESS" \
--unlock "$ADMIN_ADDRESS" \
--password /dev/null \
--allow-insecure-unlock \
--nodiscover \
--maxpeers 0 \
--syncmode full \
--gcmode archive

39
docker/.env.example Normal file
View File

@@ -0,0 +1,39 @@
# Database
DATABASE_NAME=afterlife
DATABASE_USERNAME=afterlife
DATABASE_PASSWORD=change_me_in_production
# Strapi
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=change_me
ADMIN_JWT_SECRET=change_me
TRANSFER_TOKEN_SALT=change_me
JWT_SECRET=change_me
STRAPI_API_TOKEN=your_api_token_after_first_boot
# MinIO
MINIO_ROOT_USER=afterlife
MINIO_ROOT_PASSWORD=change_me_in_production
# Public URL (for frontend image/media URLs)
PUBLIC_STRAPI_URL=http://yourdomain.com
# Public hostname for game servers (DDNS)
PUBLIC_HOST=play.yourdomain.com
# Cloudflare API Token (create at https://dash.cloudflare.com/profile/api-tokens)
# Permissions needed: Zone > DNS > Edit
CF_API_TOKEN=your_cloudflare_api_token
# AfterCoin Blockchain (private Ethereum chain for casino tokens)
# Generate with: node -e "const {ethers}=require('ethers');const w=ethers.Wallet.createRandom();console.log(w.address,w.privateKey)"
AFC_ADMIN_ADDRESS=0xYOUR_ADMIN_ADDRESS
AFC_ADMIN_PRIVATE_KEY=your_private_key_without_0x_prefix
AFC_BRIDGE_SECRET=change_me_in_production
# AFC Store (MercadoPago integration)
MERCADOPAGO_ACCESS_TOKEN=your_mp_access_token
MERCADOPAGO_WEBHOOK_SECRET=your_mp_webhook_secret
MERCADOPAGO_WEBHOOK_URL=https://yourdomain.com/api/afc/webhook
AFC_PRICE_MXN=15
NEXT_PUBLIC_SITE_URL=http://localhost:3000

View File

@@ -0,0 +1,243 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DATABASE_NAME:-afterlife}
POSTGRES_USER: ${DATABASE_USERNAME:-afterlife}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME:-afterlife}"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-afterlife}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-afterlife123}
ports:
- "9000:9000"
- "9001:9001"
cms:
build:
context: ../apps/cms
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
HOST: 0.0.0.0
PORT: 1337
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: ${DATABASE_NAME:-afterlife}
DATABASE_USERNAME: ${DATABASE_USERNAME:-afterlife}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
APP_KEYS: ${APP_KEYS}
API_TOKEN_SALT: ${API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
JWT_SECRET: ${JWT_SECRET}
ports:
- "1337:1337"
web:
build:
context: ../
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
- cms
- afc-bridge
environment:
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN:-}
NEXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL:-http://localhost:1337}
AFC_BRIDGE_URL: http://afc-bridge:3001
AFC_BRIDGE_SECRET: ${AFC_BRIDGE_SECRET}
MERCADOPAGO_ACCESS_TOKEN: ${MERCADOPAGO_ACCESS_TOKEN:-}
MERCADOPAGO_WEBHOOK_SECRET: ${MERCADOPAGO_WEBHOOK_SECRET:-}
MERCADOPAGO_WEBHOOK_URL: ${MERCADOPAGO_WEBHOOK_URL:-}
AFC_PRICE_MXN: ${AFC_PRICE_MXN:-15}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
ports:
- "3000:3000"
cloudflare-ddns:
image: favonia/cloudflare-ddns:latest
restart: unless-stopped
environment:
CF_API_TOKEN: ${CF_API_TOKEN}
DOMAINS: ${PUBLIC_HOST:-play.consultoria-as.com}
PROXIED: "false"
IP6_PROVIDER: none
openfusion:
build:
context: ../servers/openfusion
dockerfile: Dockerfile
restart: unless-stopped
environment:
SHARD_IP: 192.168.10.234
MOTD: ${OPENFUSION_MOTD:-Bienvenido a Project Afterlife - FusionFall Academy}
ports:
- "23000:23000"
- "23001:23001"
volumes:
- openfusion_data:/usr/src/app/data
minecraft-ftb:
image: itzg/minecraft-server:java21
restart: unless-stopped
container_name: minecraft-ftb
environment:
EULA: "TRUE"
TYPE: FTBA
FTB_MODPACK_ID: 125
FTB_MODPACK_VERSION_ID: 100181
MEMORY: 6G
MAX_MEMORY: 6G
MOTD: "Project Afterlife - FTB Evolution"
DIFFICULTY: normal
MAX_PLAYERS: 20
VIEW_DISTANCE: 10
ENABLE_COMMAND_BLOCK: "true"
MAX_TICK_TIME: -1
ports:
- "25565:25565"
volumes:
- minecraft_ftb_data:/data
deploy:
resources:
limits:
memory: 8G
geth:
build:
context: ../blockchain
dockerfile: Dockerfile
restart: unless-stopped
environment:
ADMIN_PRIVATE_KEY: ${AFC_ADMIN_PRIVATE_KEY}
ADMIN_ADDRESS: ${AFC_ADMIN_ADDRESS}
ports:
- "8545:8545"
- "8546:8546"
volumes:
- geth_data:/data
deploy:
resources:
limits:
memory: 1G
rpc-ssl:
image: nginx:alpine
restart: unless-stopped
depends_on:
- geth
volumes:
- ./nginx/rpc-ssl.conf:/etc/nginx/nginx.conf:ro
- certbot_etc:/etc/letsencrypt:ro
ports:
- "8443:8443"
afc-bridge:
build:
context: ../services/afc-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- geth
environment:
GETH_RPC_URL: http://geth:8545
ADMIN_PRIVATE_KEY: ${AFC_ADMIN_PRIVATE_KEY}
BRIDGE_SECRET: ${AFC_BRIDGE_SECRET}
PORT: 3001
DB_PATH: /data/bridge.db
GAS_FUND_AMOUNT: "0.01"
ports:
- "3001:3001"
volumes:
- afc_bridge_data:/data
sm64coopdx:
build:
context: ../servers/sm64coopdx
dockerfile: Dockerfile
restart: unless-stopped
container_name: sm64coopdx
environment:
SM64_PORT: ${SM64_PORT:-7777}
SM64_PLAYERS: ${SM64_PLAYERS:-16}
ports:
- "7777:7777/udp"
volumes:
- sm64_save:/server/save
- sm64_mods:/server/mods
deploy:
resources:
limits:
memory: 2G
n64-netplay:
image: k4rian/gopher64-netplay-server:latest
container_name: n64-netplay
restart: unless-stopped
environment:
G64NS_NAME: "Afterlife N64 - Mario Party"
G64NS_PORT: 45000
G64NS_MAXGAMES: 4
G64NS_MOTD: "Bienvenido a Project Afterlife - Mario Party N64"
G64NS_LOGPATH: "gopher64-server.log"
G64NS_DISABLEBROADCAST: "false"
G64NS_ENABLEAUTH: "false"
ports:
- "45000-45004:45000-45004/tcp"
- "45000-45004:45000-45004/udp"
volumes:
- n64_netplay_data:/home/gopher64
- /etc/localtime:/etc/localtime:ro
deploy:
resources:
limits:
memory: 128M
dolphin-traversal:
build:
context: ../servers/dolphin-traversal
dockerfile: Dockerfile
container_name: dolphin-traversal
restart: unless-stopped
ports:
- "6262:6262/udp"
- "6226:6226/udp"
deploy:
resources:
limits:
memory: 64M
volumes:
postgres_data:
minio_data:
openfusion_data:
minecraft_ftb_data:
geth_data:
afc_bridge_data:
sm64_save:
sm64_mods:
n64_netplay_data:
certbot_etc:
external: true
name: docker_certbot_etc

View File

@@ -0,0 +1,120 @@
services:
maple2-mysql:
image: mysql:8.0
restart: unless-stopped
container_name: maple2-db
environment:
MYSQL_ROOT_PASSWORD: ${MAPLE2_DB_PASSWORD:-maplestory}
volumes:
- maple2_mysql:/var/lib/mysql
ports:
- "3307:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MAPLE2_DB_PASSWORD:-maplestory}"]
interval: 10s
timeout: 5s
retries: 10
maple2-file-ingest:
container_name: maple2-file-ingest
image: mcr.microsoft.com/dotnet/sdk:8.0
working_dir: /app/Maple2.File.Ingest
entrypoint: ["dotnet", "run"]
depends_on:
maple2-mysql:
condition: service_healthy
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
MS2_DATA_FOLDER: /ClientData
volumes:
- ../servers/maple2:/app
- ${MAPLE2_DATA_FOLDER:-../servers/maple2/client-data/Data}:/ClientData
- maple2_dotnet_tools:/root/.dotnet/tools
profiles:
- ingest
maple2-world:
build:
context: ../servers/maple2
dockerfile: ./Maple2.Server.World/Dockerfile
container_name: maple2-world
image: maple2/world
command: dotnet Maple2.Server.World.dll
restart: unless-stopped
depends_on:
maple2-mysql:
condition: service_healthy
ports:
- "21001:21001"
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
GRPC_LOGIN_IP: maple2-login
maple2-login:
build:
context: ../servers/maple2
dockerfile: ./Maple2.Server.Login/Dockerfile
container_name: maple2-login
image: maple2/login
command: dotnet Maple2.Server.Login.dll
restart: unless-stopped
depends_on:
maple2-mysql:
condition: service_healthy
maple2-world:
condition: service_started
ports:
- "20001:20001"
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
GRPC_WORLD_IP: maple2-world
maple2-web:
build:
context: ../servers/maple2
dockerfile: ./Maple2.Server.Web/Dockerfile
container_name: maple2-web
image: maple2/web
command: dotnet Maple2.Server.Web.dll
restart: unless-stopped
depends_on:
maple2-mysql:
condition: service_healthy
ports:
- "4000:4000"
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
maple2-game-ch0:
build:
context: ../servers/maple2
dockerfile: ./Maple2.Server.Game/Dockerfile
image: maple2/game
restart: unless-stopped
depends_on:
maple2-mysql:
condition: service_healthy
maple2-world:
condition: service_started
ports:
- "20003:20003"
- "21003:21003"
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
GRPC_GAME_IP: maple2-game-ch0
GRPC_WORLD_IP: maple2-world
INSTANCED_CONTENT: "false"
volumes:
maple2_mysql:
maple2_dotnet_tools:

89
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,89 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DATABASE_NAME:-afterlife}
POSTGRES_USER: ${DATABASE_USERNAME:-afterlife}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
ports:
- "5432:5432"
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-afterlife}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-afterlife123}
ports:
- "9000:9000"
- "9001:9001"
cms:
build:
context: ../apps/cms
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- postgres
- minio
environment:
HOST: 0.0.0.0
PORT: 1337
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: ${DATABASE_NAME:-afterlife}
DATABASE_USERNAME: ${DATABASE_USERNAME:-afterlife}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
APP_KEYS: ${APP_KEYS}
API_TOKEN_SALT: ${API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
JWT_SECRET: ${JWT_SECRET}
ports:
- "1337:1337"
web:
build:
context: ../
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
- cms
environment:
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
NEXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL:-http://localhost:1337}
ports:
- "3000:3000"
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on:
- web
- cms
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_certs:/etc/letsencrypt:ro
- certbot_www:/var/www/certbot:ro
certbot:
image: certbot/certbot
volumes:
- certbot_certs:/etc/letsencrypt
- certbot_www:/var/www/certbot
volumes:
postgres_data:
minio_data:
certbot_certs:
certbot_www:

68
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,68 @@
events {
worker_connections 1024;
}
http {
upstream web {
server web:3000;
}
upstream cms {
server cms:1337;
}
server {
listen 80;
server_name _;
client_max_body_size 100M;
location / {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# AFC Store API — route to Next.js (before Strapi catch-all)
location /api/afc/ {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://cms;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin {
proxy_pass http://cms;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://cms;
proxy_set_header Host $host;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
}

32
docker/nginx/rpc-ssl.conf Normal file
View File

@@ -0,0 +1,32 @@
events {
worker_connections 256;
}
http {
server {
listen 8443 ssl;
server_name play.consultoria-as.com;
ssl_certificate /etc/letsencrypt/live/play.consultoria-as.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/play.consultoria-as.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Geth JSON-RPC proxy
location / {
proxy_pass http://geth:8545;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Content-Type application/json;
# CORS for MetaMask
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
if ($request_method = OPTIONS) {
return 204;
}
}
}
}

483
docs/aftercoin.md Normal file
View File

@@ -0,0 +1,483 @@
# AfterCoin (AFC) - Blockchain Privada para el Casino de Minecraft
## Tabla de Contenidos
- [Resumen General](#resumen-general)
- [Arquitectura](#arquitectura)
- [Componentes](#componentes)
- [1. Nodo Geth (blockchain/)](#1-nodo-geth-blockchain)
- [2. Contrato AfterCoin (blockchain/contracts/AfterCoin.sol)](#2-contrato-aftercoin-blockchaincontractsaftercoinsol)
- [3. Bridge API (services/afc-bridge/)](#3-bridge-api-servicesafc-bridge)
- [4. Proxy SSL para RPC (docker/nginx/rpc-ssl.conf)](#4-proxy-ssl-para-rpc-dockernginxrpc-sslconf)
- [5. Mainframe Lua (Computer 7)](#5-mainframe-lua-computer-7)
- [6. Generador de Tarjetas Lua (Computer 4)](#6-generador-de-tarjetas-lua-computer-4)
- [Servicios Docker](#servicios-docker)
- [Variables de Entorno](#variables-de-entorno)
- [Guia de Conexion con MetaMask](#guia-de-conexion-con-metamask)
- [Escritorio (Extension)](#escritorio-extension)
- [Movil (App)](#movil-app)
- [Vincular una Wallet Personal](#vincular-una-wallet-personal)
- [Administracion](#administracion)
- [Comandos de Verificacion](#comandos-de-verificacion)
- [Operaciones Directas con Tokens](#operaciones-directas-con-tokens)
- [Renovacion de Certificado SSL](#renovacion-de-certificado-ssl)
- [Whitelist HTTP de CC:Tweaked](#whitelist-http-de-cctweaked)
- [Despliegue de Scripts Lua](#despliegue-de-scripts-lua)
- [Detalles del Contrato](#detalles-del-contrato)
- [Solucion de Problemas](#solucion-de-problemas)
---
## Resumen General
**AfterCoin (AFC)** es un token ERC-20 desplegado en una blockchain privada de Ethereum que utiliza el mecanismo de consenso **Clique PoA** (Proof of Authority) con chain ID **8888**.
Principios fundamentales:
- **1 AFC = 1 diamante** en el casino de Minecraft
- **0 decimales** (solo valores enteros, sin fracciones)
- Los jugadores pueden ver su saldo del casino en **MetaMask** como tokens reales en la blockchain
- El **mainframe del casino** sincroniza los saldos on-chain a traves de un **Bridge API**
Esto permite que los jugadores tengan una experiencia tangible de sus fondos del casino: pueden abrir MetaMask en su telefono o navegador y ver exactamente cuantos diamantes tienen, con la seguridad y transparencia de una blockchain real.
---
## Arquitectura
```
+---------------------+ +-----------------------------+
| | HTTPS | |
| MetaMask |--------->| Nginx SSL Proxy |
| (Escritorio/Movil) | :8443 | (rpc-ssl) |
| | +-------------+---------------+
+--------+------------+ |
| | HTTP :8545
| HTTP :8545 v
| (solo escritorio) +--------+---------------+
+--------------------------->| |
| Geth Node |
| (Clique PoA, ID 8888) |
| |
+--------+---------------+
^
| ethers.js (HTTP RPC)
|
+--------+---------------+
| |
| Bridge API |
| (Node.js, :3001) |
| |
| +------------------+ |
| | SQLite DB | |
| | (wallets, config)| |
| +------------------+ |
+--------+---------------+
^
| HTTP (red Docker interna)
|
+----------------+------------------+
| |
| CC:Tweaked Mainframe |
| (Computer 7, Minecraft) |
| |
+---+----------+----------+---------+
| | |
rednet rednet rednet
| | |
+---+--+ +---+---+ +---+---+
| Slots| | BJ | | Poker |
+------+ +-------+ +-------+
(Juegos del casino)
```
**Flujo de datos:**
1. **MetaMask** se conecta al nodo Geth via HTTPS (puerto 8443, movil) o HTTP (puerto 8545, escritorio)
2. El **Bridge API** (puerto 3001) se comunica con Geth mediante ethers.js y mantiene una base de datos SQLite con las wallets de los jugadores
3. El **mainframe CC:Tweaked** (Computer 7) se comunica con el Bridge API via HTTP dentro de la red Docker interna
4. El **mainframe** se comunica con los juegos del casino (slots, blackjack, poker, etc.) via **rednet** (protocolo de red inalambrica de CC:Tweaked)
---
## Componentes
### 1. Nodo Geth (blockchain/)
El nodo Geth ejecuta la blockchain privada donde vive el token AfterCoin.
| Parametro | Valor |
|---|---|
| Consenso | Clique PoA (Proof of Authority) |
| Chain ID | 8888 |
| Tiempo de bloque | 5 segundos |
| Version de Geth | v1.13.15 (ultima version con soporte para Clique PoA) |
| Limite de memoria | 1 GB |
| Puerto HTTP RPC | 8545 |
| Puerto WebSocket | 8546 |
**Archivos clave:**
- `genesis.json` -- Configuracion genesis de la cadena (Clique PoA, chain ID 8888, periodo de bloque de 5s)
- `init-geth.sh` -- Script de inicializacion que importa la cuenta admin y arranca Geth
- `Dockerfile` -- Imagen basada en `ethereum/client-go:v1.13.15`
**Wallet de administrador:** Actua como sellador (sealer) de bloques y propietario (owner) del contrato inteligente. Es la unica autoridad en la cadena PoA.
### 2. Contrato AfterCoin (blockchain/contracts/AfterCoin.sol)
Contrato inteligente ERC-20 autocontenido (sin dependencias de OpenZeppelin).
**Caracteristicas principales:**
- `decimals()` retorna `0` -- los tokens son enteros, 1 token = 1 diamante
- Funciones restringidas al owner (wallet admin):
- `mint(address to, uint256 amount)` -- Crea nuevos tokens y los asigna a una direccion
- `burnFrom(address from, uint256 amount)` -- Quema tokens de una direccion especifica
- `bridgeTransfer(address from, address to, uint256 amount)` -- Transfiere tokens entre wallets via el bridge
- Cumple con el estandar ERC-20 completo (transfer, approve, transferFrom, allowance, etc.)
**Compilacion:**
- Compilado con `solcjs` version 0.8.34
- **Target: Paris EVM** -- No usa el opcode `PUSH0` (introducido en Shanghai EVM), ya que Geth v1.13 no lo soporta en cadenas privadas
- El contrato se despliega automaticamente por el Bridge API en el primer arranque
### 3. Bridge API (services/afc-bridge/)
Servicio backend que actua como puente entre el mundo de Minecraft y la blockchain.
**Stack tecnologico:** Node.js + Express + ethers.js v6 + better-sqlite3
**Funcionalidades clave:**
- **Auto-despliegue del contrato:** En el primer arranque, si no existe un contrato desplegado, lo despliega automaticamente y guarda la direccion en la base de datos SQLite
- **Cola de nonces:** Sistema de cola para transacciones que previene colisiones cuando multiples operaciones ocurren simultaneamente
**Endpoints:**
| Metodo | Ruta | Descripcion | Autenticacion |
|---|---|---|---|
| POST | `/api/register` | Crea una wallet custodial para un jugador | `x-bridge-secret` |
| POST | `/api/deposit` | Acuna (mint) AFC a la wallet del jugador | `x-bridge-secret` |
| POST | `/api/withdraw` | Quema (burn) AFC de la wallet del jugador | `x-bridge-secret` |
| GET | `/api/balance/:diskId` | Lee el saldo on-chain del jugador | Ninguna |
| GET | `/api/wallet/:diskId` | Retorna direccion de wallet + clave privada para importar en MetaMask | Ninguna |
**Seguridad:**
- Los endpoints POST requieren el header `x-bridge-secret` con el secreto configurado en las variables de entorno
- Los endpoints GET son publicos para facilitar la consulta de saldos y datos de wallet
**Archivo estatico:**
- Sirve el icono del token en `/afc-icon.svg` para que MetaMask pueda mostrar el logo del token
### 4. Proxy SSL para RPC (docker/nginx/rpc-ssl.conf)
Proxy inverso Nginx que proporciona acceso HTTPS al nodo Geth.
| Parametro | Valor |
|---|---|
| Puerto externo | 8443 (HTTPS) |
| Puerto interno | 8545 (HTTP hacia Geth) |
| Certificado | Let's Encrypt via Cloudflare DNS challenge |
**Por que es necesario:** MetaMask en dispositivos moviles **rechaza conexiones HTTP** para endpoints RPC. El proxy SSL permite que los jugadores conecten sus wallets desde el telefono usando HTTPS.
### 5. Mainframe Lua (Computer 7)
El mainframe es el computador central del casino dentro de Minecraft (CC:Tweaked). Coordina todos los juegos y gestiona los saldos de los jugadores.
**Funciones principales:**
| Funcion | Descripcion |
|---|---|
| `addPlayer(diskId, name)` | Registra una nueva wallet en el bridge para el jugador |
| `getPlayerBalance(diskId)` | Sincroniza el saldo desde la blockchain (detecta transferencias hechas desde MetaMask) |
| `setPlayerBalance(diskId, amount)` | Calcula la diferencia con el saldo actual y ejecuta mint o burn segun corresponda |
**Caracteristicas tecnicas:**
- Helpers HTTP con `pcall` como fallback para manejar errores de red
- **Bucle de sincronizacion periodica** cada 30 segundos usando `parallel.waitForAll`
- Los juegos del casino (slots, blackjack, poker) **no necesitan modificaciones** -- se comunican con el mainframe via rednet y este se encarga de toda la logica blockchain
### 6. Generador de Tarjetas Lua (Computer 4)
Computador auxiliar que genera las tarjetas de jugador del casino.
Despues de crear una tarjeta, muestra en pantalla:
- La **direccion de wallet** del jugador
- Instrucciones para **conectar MetaMask** y ver el saldo de AFC
---
## Servicios Docker
| Servicio | Puertos | depends_on | Volumenes | Limite de Memoria |
|---|---|---|---|---|
| `geth` | 8545:8545, 8546:8546 | -- | `geth_data:/root/.ethereum` | 1 GB |
| `afc-bridge` | 3001:3001 | `geth` | `afc_bridge_data:/app/data` | -- |
| `rpc-ssl` | 8443:8443 | `geth` | `certbot_etc:/etc/letsencrypt:ro` | -- |
Todos los servicios forman parte de la red Docker compartida con el servidor de Minecraft, permitiendo comunicacion interna por nombre de servicio (ej: `http://afc-bridge:3001`).
---
## Variables de Entorno
Todas las variables relacionadas con AfterCoin usan el prefijo `AFC_` y se definen en el archivo `.env` del directorio `docker/`.
| Variable | Descripcion | Ejemplo |
|---|---|---|
| `AFC_ADMIN_PRIVATE_KEY` | Clave privada de la wallet administradora (sellador + owner del contrato) | `0xabc123...` |
| `AFC_ADMIN_ADDRESS` | Direccion publica de la wallet administradora | `0x742d35Cc...` |
| `AFC_BRIDGE_SECRET` | Secreto compartido entre el mainframe y el Bridge API | `mi-secreto-seguro` |
| `AFC_CHAIN_ID` | ID de la cadena (debe coincidir con genesis.json) | `8888` |
| `AFC_RPC_URL` | URL interna del nodo Geth (dentro de Docker) | `http://geth:8545` |
| `AFC_CONTRACT_ADDRESS` | Direccion del contrato desplegado (se genera automaticamente en el primer arranque) | `0x5458...918C` |
---
## Guia de Conexion con MetaMask
### Escritorio (Extension)
1. Abrir MetaMask en el navegador
2. Ir a **Settings** (Configuracion) > **Networks** (Redes) > **Add Network** (Agregar red)
3. Rellenar los campos:
| Campo | Valor |
|---|---|
| Network Name | `AfterLife` |
| RPC URL | `http://play.consultoria-as.com:8545` |
| Chain ID | `8888` |
| Currency Symbol | `ETH` |
4. MetaMask mostrara una advertencia sobre el chain ID desconocido -- esto es **normal para cadenas privadas**, proceder de todas formas
5. **Importar la wallet del juego:**
- Menu de cuentas > **Import Account** (Importar cuenta)
- Pegar la clave privada obtenida del endpoint `/api/wallet/:diskId` o de la terminal del generador de tarjetas (Computer 4)
6. **Agregar el token AFC:**
- Click en **Import Tokens** (Importar tokens) > **Custom Token** (Token personalizado)
- Pegar la direccion del contrato: `0x54583A08C29556d16BA626cbA66101816D79918C`
- Simbolo: `AFC`
- Decimales: `0`
### Movil (App)
1. Abrir la app de MetaMask
2. Ir al **menu hamburguesa** > **Settings** > **Networks** > **Add Network**
3. Rellenar los campos:
| Campo | Valor |
|---|---|
| Network Name | `AfterLife` |
| RPC URL | `https://play.consultoria-as.com:8443` |
| Chain ID | `8888` |
| Currency Symbol | `ETH` |
> **IMPORTANTE:** En movil se DEBE usar la URL HTTPS (puerto 8443), no HTTP. MetaMask movil rechaza conexiones HTTP para endpoints RPC.
4. **Importar la wallet del juego:**
- Icono de cuenta > **Add account or hardware wallet** > **Import account**
- Pegar la clave privada
5. **Agregar el token AFC:**
- En la pantalla principal, hacer scroll hacia abajo
- **Import Tokens** > **Custom Token**
- Pegar la direccion del contrato: `0x54583A08C29556d16BA626cbA66101816D79918C`
### Vincular una Wallet Personal
Por defecto, el bridge crea **wallets custodiales** para cada jugador (el bridge genera y almacena las claves privadas). Si un jugador quiere vincular su propia wallet de MetaMask:
1. Actualizar la direccion en la base de datos del bridge:
```bash
docker exec docker-afc-bridge-1 node -e "
const db = require('./src/db');
db.db.prepare('UPDATE wallets SET address = ? WHERE disk_id = ?').run('0xDIRECCION_DEL_JUGADOR', 'DISK_ID');
"
```
2. Acunar (mint) el saldo actual del jugador a la nueva direccion para sincronizar:
```bash
curl -s localhost:3001/api/deposit \
-H "Content-Type: application/json" \
-H "x-bridge-secret: TU_SECRETO" \
-d '{"diskId":"DISK_ID","amount":SALDO_ACTUAL}'
```
> **Nota:** Al vincular una wallet personal, el jugador tendra control total sobre sus tokens y podra transferirlos libremente. Esto puede tener implicaciones en la economia del casino.
---
## Administracion
### Comandos de Verificacion
```bash
# Verificar el chain ID (debe retornar 0x22b8 = 8888)
curl -s -X POST localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
# Registrar un jugador de prueba
curl -s localhost:3001/api/register \
-H "Content-Type: application/json" \
-H "x-bridge-secret: SECRET" \
-d '{"diskId":"99","name":"Test"}'
# Depositar 50 AFC (= 50 diamantes) al jugador
curl -s localhost:3001/api/deposit \
-H "Content-Type: application/json" \
-H "x-bridge-secret: SECRET" \
-d '{"diskId":"99","amount":50}'
# Consultar saldo de un jugador
curl -s localhost:3001/api/balance/99
# Obtener informacion de wallet (direccion + clave privada)
curl -s localhost:3001/api/wallet/99
# Listar todas las wallets registradas
docker exec docker-afc-bridge-1 node -e \
"const db=require('./src/db');db.getAllWallets().forEach(w=>console.log(w.disk_id,w.name,w.address))"
```
### Operaciones Directas con Tokens
Para operaciones que requieren interaccion directa con el contrato inteligente (sin pasar por el Bridge API):
```bash
# Acunar tokens directamente a cualquier direccion
cd /tmp && node -e "
const {ethers}=require('ethers');
const artifact=require('/home/AfterlifeProject/services/afc-bridge/contracts/AfterCoin.json');
async function main(){
const provider=new ethers.JsonRpcProvider('http://localhost:8545');
const admin=new ethers.Wallet('ADMIN_PRIVATE_KEY',provider);
const contract=new ethers.Contract('CONTRACT_ADDRESS',artifact.abi,admin);
await (await contract.mint('DIRECCION_DESTINO',CANTIDAD)).wait();
console.log('Saldo:',Number(await contract.balanceOf('DIRECCION_DESTINO')));
}
main();
"
```
> **Nota:** Reemplazar `ADMIN_PRIVATE_KEY`, `CONTRACT_ADDRESS`, `DIRECCION_DESTINO` y `CANTIDAD` con los valores reales.
### Renovacion de Certificado SSL
El certificado de Let's Encrypt expira cada 90 dias. Para renovarlo:
```bash
# Renovar el certificado
docker run --rm \
-v docker_certbot_etc:/etc/letsencrypt \
-v /tmp/certbot/cloudflare.ini:/run/secrets/cloudflare.ini:ro \
certbot/dns-cloudflare:latest renew
# Reiniciar el proxy SSL para cargar el nuevo certificado
docker compose -f docker-compose.dev.yml restart rpc-ssl
```
### Whitelist HTTP de CC:Tweaked
La regla de whitelist en `computercraft-server.toml` (dentro del volumen de datos de Minecraft) permite que el mainframe se comunique con el Bridge API. Si Minecraft se reinstala, la regla se pierde y debe recrearse:
```bash
# Agregar la regla de whitelist para afc-bridge
docker exec minecraft-ftb sed -i '/\$private/i \\t[[http.rules]]\n\t\t host = "afc-bridge"\n\t\t action = "allow"\n' /data/config/computercraft-server.toml
# Reiniciar el servidor de Minecraft para aplicar cambios
docker restart minecraft-ftb
```
> **IMPORTANTE:** La regla `allow` para `afc-bridge` debe aparecer **ANTES** de la regla `deny` para `$private` en el archivo de configuracion. De lo contrario, la conexion sera bloqueada por la regla de denegacion general.
### Despliegue de Scripts Lua
Para actualizar los scripts de los computadores CC:Tweaked dentro de Minecraft:
```bash
# Copiar el script del mainframe (Computer 7)
docker cp /tmp/mainframe_startup.lua minecraft-ftb:/data/world/computercraft/computer/7/startup.lua
# Copiar el script del generador de tarjetas (Computer 4)
docker cp /tmp/cardgen_startup.lua minecraft-ftb:/data/world/computercraft/computer/4/startup.lua
```
Luego, reiniciar los computadores dentro del juego presionando **Ctrl+R** en cada terminal.
---
## Detalles del Contrato
| Campo | Valor |
|---|---|
| Direccion | `0x54583A08C29556d16BA626cbA66101816D79918C` |
| ABI | `services/afc-bridge/contracts/AfterCoin.json` |
| Codigo fuente | `blockchain/contracts/AfterCoin.sol` |
| Compilador | solcjs 0.8.34 |
| Target EVM | Paris (sin opcode PUSH0) |
| Desplegado en | Primer arranque del Bridge API |
| Almacenamiento de direccion | Base de datos SQLite del Bridge API |
---
## Solucion de Problemas
### "invalid opcode: PUSH0"
**Causa:** El contrato fue compilado para Shanghai EVM pero la cadena ejecuta Paris EVM. El opcode `PUSH0` fue introducido en Shanghai y no esta disponible en Geth v1.13 para cadenas privadas.
**Solucion:** Recompilar el contrato con la opcion `--evm-version paris`:
```bash
solcjs --bin --abi --evm-version paris AfterCoin.sol
```
### Geth termina por OOM (Out of Memory)
**Causa:** El nodo Geth supera el limite de memoria asignado (actualmente 1 GB).
**Solucion:** Incrementar el limite de memoria en el docker-compose. La flag `--lightkdf` ya esta habilitada para reducir el uso de memoria durante la importacion de claves.
### Bridge no puede conectar con Geth
**Causa:** El nodo Geth aun no ha terminado de inicializar cuando el bridge intenta conectarse.
**Solucion:** Verificar que `depends_on` esta configurado correctamente en docker-compose. El bridge incluye logica de reintentos (`waitForGeth()`) que espera a que Geth este disponible antes de continuar.
### CC:Tweaked bloquea peticiones HTTP
**Causa:** El archivo `computercraft-server.toml` no tiene la regla de whitelist para `afc-bridge`, o la regla esta ubicada despues de la regla de denegacion `$private`.
**Solucion:** Verificar que la regla `allow` para `afc-bridge` existe y esta posicionada **antes** de la regla `deny` para `$private`. Ver la seccion [Whitelist HTTP de CC:Tweaked](#whitelist-http-de-cctweaked) para los comandos de correccion.
### MetaMask movil no puede conectar
**Causa:** Se esta usando la URL HTTP (puerto 8545) en lugar de HTTPS (puerto 8443). MetaMask en dispositivos moviles requiere conexiones HTTPS para endpoints RPC.
**Solucion:** Cambiar la URL de la red en MetaMask a `https://play.consultoria-as.com:8443`.
### Transacciones fallan con "nonce too low"
**Causa:** Multiples transacciones se enviaron simultaneamente y los nonces colisionaron.
**Solucion:** El Bridge API incluye una cola de nonces que deberia prevenir esto. Si ocurre, reiniciar el servicio del bridge:
```bash
docker compose -f docker-compose.dev.yml restart afc-bridge
```
### El saldo en MetaMask no coincide con el casino
**Causa:** El jugador realizo una transferencia desde MetaMask que aun no ha sido sincronizada por el mainframe.
**Solucion:** El mainframe ejecuta un bucle de sincronizacion cada 30 segundos. Esperar a que se complete el siguiente ciclo, o forzar la sincronizacion reiniciando el Computer 7 con Ctrl+R en el juego.

161
docs/architecture.md Normal file
View File

@@ -0,0 +1,161 @@
# Arquitectura Tecnica
## Vision General
Project Afterlife es un monorepo que combina una plataforma web de preservacion de videojuegos con los servidores de los juegos preservados. La infraestructura se gestiona completamente con Docker Compose.
## Diagrama de Servicios
```
┌─────────────────────────────────────┐
│ USUARIO / CLIENTE │
└───────────┬───────────┬─────────────┘
│ │
┌───────────▼───┐ ┌─────▼─────────────┐
│ Next.js :3000│ │ Clientes de juegos │
└───────┬───────┘ └──┬──────┬─────┬───┘
│ │ │ │
┌───────▼───────┐ │ │ │
│ Strapi :1337 │ │ │ │
└───┬───────┬───┘ │ │ │
│ │ │ │ │
┌───────▼──┐ ┌──▼────┐ │ │ │
│ PG :5432 │ │ MinIO │ │ │ │
│ │ │ :9000 │ │ │ │
└──────────┘ └───────┘ │ │ │
│ │ │
┌──────────────────────────────┘ │ │
│ │ │
┌───────▼──────────┐ ┌──────────────────┐ │ │
│ OpenFusion │ │ MapleStory 2 │ │ │
│ :23000-23001 │ │ Login :20001 │ │ │
└──────────────────┘ │ World :21001 │ │ │
│ Game :20002 │ │ │
│ Web :4000 │ │ │
│ MySQL :3307 │ │ │
└──────────────────┘ │ │
│ │
┌──────────────────────▼─┐ │
│ Minecraft FTB :25565 │ │
└────────────────────────┘ │
(Juegos futuros...) ◄──┘
```
## Componentes
### Frontend (Next.js 15)
**Ubicacion**: `apps/web/`
- **Framework**: Next.js 15 con App Router
- **React**: 19 (forzado via `overrides` en root `package.json`)
- **Estilos**: Tailwind CSS v4 con `@tailwindcss/postcss`
- **i18n**: next-intl con prefijo de ruta (`/es/`, `/en/`)
- **Audio**: Howler.js para reproductor de documentales
- **Animaciones**: Framer Motion
**Estructura de rutas**:
```
src/app/
├── layout.tsx # Pass-through (return children)
├── globals.css # Tailwind v4 imports
├── not-found.tsx # 404 page
└── [locale]/
├── layout.tsx # <html>, <body>, providers
├── page.tsx # Home
├── about/page.tsx # About
├── catalog/page.tsx # Game catalog
├── donate/page.tsx # Donations
└── games/
└── [slug]/
├── page.tsx # Game detail
└── documentary/page.tsx # Interactive documentary
```
**Patron de layout**: El root `layout.tsx` es un pass-through que solo retorna `children`. El layout real con `<html>`, `<body>`, y `NextIntlClientProvider` esta en `[locale]/layout.tsx`. Esto es necesario para que next-intl funcione correctamente con el App Router.
### CMS (Strapi 5)
**Ubicacion**: `apps/cms/`
- **Version**: Strapi 5.36.0
- **Base de datos**: PostgreSQL 16
- **Almacenamiento**: MinIO (compatible con S3)
- **React**: 18 (admin panel, separado del frontend)
- **i18n**: Plugin nativo con locales ES/EN
**Content Types**:
- `Game` — Entrada de juego con metadata, screenshots, estado del servidor
- `Documentary` — Documental con titulo, descripcion, relacion 1:1 con Game
- `Chapter` — Capitulo con contenido rich text, audio opcional, orden
**Nota sobre schemas**: Los archivos `schema.json` de Strapi 5 deben copiarse manualmente al directorio `dist/` durante el build. El Dockerfile del CMS incluye este fix.
### Tipos Compartidos
**Ubicacion**: `packages/shared/`
Paquete TypeScript puro (`@afterlife/shared`) con las interfaces compartidas entre web y CMS:
```typescript
// Game, Documentary, Chapter, StrapiMedia, StrapiResponse, etc.
```
### Base de Datos
**PostgreSQL 16** para el CMS:
- 44 tablas (contenido + sistema Strapi)
- Modelo i18n: cada contenido tiene filas separadas por locale (en/es) y estado (draft/published)
- Relaciones via tablas `_lnk` (e.g., `games_documentary_lnk`)
**MySQL 8.0** para MapleStory 2:
- Dos databases: `maple-data` (datos del juego, read-only) y `game-server` (datos de jugadores)
- Puerto 3307 para evitar conflicto con PostgreSQL 5432
### Almacenamiento (MinIO)
MinIO corre como servicio S3-compatible para almacenar:
- Imagenes de portada de juegos
- Screenshots
- Archivos de audio para documentales
- Cualquier otro media subido al CMS
**Puertos**: 9000 (API), 9001 (consola web)
## Docker Compose: Tres Archivos
| Archivo | Proposito | Servicios |
|---------|----------|-----------|
| `docker-compose.dev.yml` | Desarrollo local | PG, MinIO, CMS, Web, OpenFusion, Minecraft FTB Evolution |
| `docker-compose.maple2.yml` | MapleStory 2 | MySQL, World, Login, Game, Web, File-Ingest |
| `docker-compose.yml` | Produccion | PG, MinIO, CMS, Web, Nginx, Certbot |
MapleStory 2 tiene su propio compose porque son 6 servicios (demasiados para mezclar con el stack principal) y requiere su propia base de datos MySQL.
## Red Docker
Todos los servicios del mismo archivo compose comparten una red Docker implicita. Los servicios se referencian entre si por nombre de servicio (e.g., `cms` desde `web`, `maple2-mysql` desde `maple2-world`).
Las variables de entorno como `DB_IP`, `GRPC_WORLD_IP` se configuran en cada servicio para apuntar al nombre de contenedor correcto dentro de la red Docker.
## CI/CD
**GitHub Actions** (`.github/workflows/deploy.yml`):
1. Push a `main` dispara el deploy
2. SSH al VPS
3. Pull, build, restart de servicios Docker
## Decisiones Arquitectonicas
### React 19 vs 18
El monorepo tiene dos versiones de React: 19 para Next.js (web) y 18 para Strapi (admin). Resuelto con `overrides` en el root `package.json` que solo afecta al workspace de web. El CMS corre en su propio contenedor Docker con sus propias dependencias.
### Tailwind v4
Usa la nueva sintaxis `@import "tailwindcss"` en `globals.css` con el plugin `@tailwindcss/postcss`. No hay `tailwind.config.js`.
### i18n con Strapi 5
Strapi 5 maneja i18n con filas separadas por locale y un `document_id` compartido. Cada documento tiene 4 filas: draft EN, draft ES, published EN, published ES. Las relaciones (`_lnk`) conectan las filas del mismo estado/locale.
### Servidores de juegos separados
Los servidores de juegos no son parte del build del monorepo. Son proyectos externos (OpenFusion en C++, Maple2 en C#) que se clonan en `servers/` y se ejecutan via Docker. El `.gitignore` excluye `servers/maple2/` (14 GB de datos de cliente) y los binarios de OpenFusion.

145
docs/cms-content.md Normal file
View File

@@ -0,0 +1,145 @@
# Modelo de Contenido CMS
## Strapi 5 — Content Types
### Game
Entrada principal de cada juego preservado.
| Campo | Tipo | Requerido | Localizado | Descripcion |
|-------|------|-----------|------------|-------------|
| title | string | si | si | Nombre del juego |
| slug | uid | si | no | URL-friendly, auto-generado desde title |
| description | richtext | no | si | Descripcion larga del juego |
| genre | enum | si | no | MMORPG, FPS, Casual, Strategy, Sports, Other |
| releaseYear | integer | si | no | Ano de lanzamiento original |
| shutdownYear | integer | si | no | Ano de cierre de servidores |
| developer | string | si | no | Estudio desarrollador |
| publisher | string | no | no | Publisher/distribuidor |
| screenshots | media[] | no | no | Capturas de pantalla (solo imagenes) |
| coverImage | media | si | no | Imagen de portada principal |
| serverStatus | enum | no | no | online, maintenance, coming_soon (default) |
| serverLink | string | no | no | IP:puerto para conectarse |
| documentary | relation | no | no | oneToOne con Documentary |
### Documentary
Documental interactivo asociado a un juego.
| Campo | Tipo | Requerido | Localizado | Descripcion |
|-------|------|-----------|------------|-------------|
| title | string | si | si | Titulo del documental |
| description | text | no | si | Descripcion/subtitulo |
| game | relation | no | no | oneToOne con Game |
| chapters | relation | no | no | oneToMany con Chapter (ordenados) |
### Chapter
Capitulo individual de un documental.
| Campo | Tipo | Requerido | Localizado | Descripcion |
|-------|------|-----------|------------|-------------|
| title | string | si | si | Titulo del capitulo |
| content | richtext | si | si | Contenido narrativo completo |
| audioFile | media | no | no | Archivo de audio (narracion) |
| audioDuration | integer | no | no | Duracion en segundos |
| order | integer | si | no | Orden de aparicion (1, 2, 3...) |
| coverImage | media | no | no | Imagen de portada del capitulo |
| documentary | relation | no | no | manyToOne con Documentary |
## Modelo i18n de Strapi 5
Strapi 5 maneja la internacionalizacion con **filas separadas por locale**. Cada documento tiene un `document_id` compartido y multiples filas:
```
document_id: "abc123"
├── id: 1 (locale: en, draft)
├── id: 2 (locale: en, published)
├── id: 3 (locale: es, draft)
└── id: 4 (locale: es, published)
```
Las **relaciones** (`_lnk` tables) conectan las filas del mismo estado. Un juego publicado en ES se conecta al documental publicado en ES, no al draft ni al EN.
### Tablas de enlace
- `games_documentary_lnk` — game_id ↔ documentary_id
- `chapters_documentary_lnk` — chapter_id ↔ documentary_id + chapter_ord
## Contenido Actual
### Juegos (3)
| Slug | document_id | Titulo | Genre | Release | Shutdown | Server |
|------|-------------|--------|-------|---------|----------|--------|
| fusionfall | sx17hshy2d... | FusionFall | MMORPG | 2009 | 2013 | online |
| maplestory2 | ms2maple2d... | MapleStory 2 | MMORPG | 2015 | 2020 | online |
| minecraft-ftb-evolution | mcftbevol... | Minecraft: FTB Evolution | Sandbox | 2011 | - | online |
### Documentales (2)
#### FusionFall: "El Mundo Que No Queriamos Perder"
| # | Titulo | Contenido |
|---|--------|-----------|
| 1 | El Sueno Imposible | Origenes, Cartoon Network, Grigon Entertainment |
| 2 | Cuando los Mundos Colisionaron | Desarrollo, motor Unity, estilo anime de Midori Foo |
| 3 | Bienvenido al Futuro | Lanzamiento, viajes en el tiempo, sistema de Nanos |
| 4 | La Caida de Grigon | Quiebra del estudio, CN asume el desarrollo |
| 5 | La Academia | Free-to-play, The Academy, Adventure Time |
| 6 | Seis Dias | Cierre con 6 dias de aviso, agosto 2013 |
| 7 | Afterlife | Comunidad, FusionFall Retro/Legacy, OpenFusion |
#### MapleStory 2: "El Mundo Que Construimos Juntos"
| # | Titulo | Contenido |
|---|--------|-----------|
| 1 | El Siguiente Nivel | De MapleStory 1 a la vision 3D de NSquare |
| 2 | Un Mundo de Cubos y Color | Arte voxel, UGC, musica, housing |
| 3 | El Amanecer Coreano | Lanzamiento Korea julio 2015, primeros problemas |
| 4 | La Conquista Global | Lanzamiento global octubre 2018, hype de Twitch |
| 5 | La Tormenta Perfecta | Declive: RNG, limites semanales, exodo |
| 6 | El Ultimo Despertar | Expansion Awakening, demasiado tarde |
| 7 | Afterlife | Cierre mayo 2020, emulador MS2Community |
## Crear Contenido Nuevo
### Via Strapi Admin (recomendado)
1. Ir a http://localhost:1337/admin
2. Content Manager > Game / Documentary / Chapter
3. Crear en un idioma, luego usar "Localization" para traducir
### Via PostgreSQL (insercion directa)
Cuando la API de Strapi tiene restricciones de permisos, se puede insertar directamente en PostgreSQL. Cada contenido necesita 4 filas:
```sql
-- Draft EN
INSERT INTO games (document_id, title, slug, ..., locale)
VALUES ('unique_doc_id', 'Title', 'slug', ..., 'en');
-- Draft ES
INSERT INTO games (document_id, title, slug, ..., locale)
VALUES ('unique_doc_id', 'Titulo', 'slug', ..., 'es');
-- Published EN (con published_at)
INSERT INTO games (document_id, title, slug, ..., published_at, locale)
VALUES ('unique_doc_id', 'Title', 'slug', ..., NOW(), 'en');
-- Published ES (con published_at)
INSERT INTO games (document_id, title, slug, ..., published_at, locale)
VALUES ('unique_doc_id', 'Titulo', 'slug', ..., NOW(), 'es');
```
Para documentales, ademas de los inserts hay que crear las relaciones en las tablas `_lnk`:
```sql
INSERT INTO games_documentary_lnk (game_id, documentary_id) VALUES (game_id, doc_id);
INSERT INTO chapters_documentary_lnk (chapter_id, documentary_id, chapter_ord) VALUES (ch_id, doc_id, 1);
```
## API Endpoints
La API de Strapi se consume desde Next.js via funciones en `apps/web/src/lib/api.ts`:
| Funcion | Descripcion |
|---------|------------|
| `getGames(locale)` | Lista todos los juegos con portada |
| `getGameBySlug(slug, locale)` | Juego con documental y capitulos |
| `getDocumentaryByGameSlug(slug, locale)` | Documental completo de un juego |
Todas las llamadas requieren el header `Authorization: Bearer <STRAPI_API_TOKEN>`.

258
docs/deployment.md Normal file
View File

@@ -0,0 +1,258 @@
# Guia de Despliegue
## Entornos
| Entorno | Compose File | Servicios |
|---------|-------------|-----------|
| **Desarrollo local** | `docker-compose.dev.yml` + `docker-compose.maple2.yml` | Todos |
| **Produccion** | `docker-compose.yml` | Web + CMS + DB + Nginx + SSL |
## Desarrollo Local
### Requisitos
- Docker Engine 24+
- Docker Compose v2+
- 8 GB RAM minimo (16 GB con todos los servidores de juegos)
- 50 GB disco libre
### Levantar todo
```bash
cd docker/
# 1. Stack principal
docker compose -f docker-compose.dev.yml up -d
# 2. MapleStory 2 (requiere setup previo, ver game-servers.md)
docker compose -f docker-compose.maple2.yml up -d
# 3. Verificar
docker ps
```
### Puertos en uso
| Puerto | Servicio |
|--------|---------|
| 3000 | Next.js (frontend) |
| 1337 | Strapi (CMS admin) |
| 5432 | PostgreSQL |
| 9000 | MinIO API |
| 9001 | MinIO Console |
| 23000-23001 | OpenFusion |
| 20001 | MapleStory 2 Login |
| 21001 | MapleStory 2 World |
| 20003 | MapleStory 2 Game |
| 21003 | MapleStory 2 Game gRPC |
| 3307 | MySQL (MapleStory 2) |
| 4000 | MapleStory 2 Web |
| 25565 | Minecraft FTB Evolution |
| 7777 (UDP) | SM64 Coop DX |
| 45000-45004 | N64 Netplay (TCP + UDP) |
| 6262, 6226 (UDP) | Dolphin Traversal (GC/Wii) |
## Acceso Externo (fuera de la red local)
### Requisitos
- Router con acceso al panel de administracion
- Dominio en Cloudflare (consultoria-as.com)
- Token de API de Cloudflare con permisos Zone > DNS > Edit
### 1. Crear token de Cloudflare
1. Ir a https://dash.cloudflare.com/profile/api-tokens
2. Create Token > Edit zone DNS (template)
3. Zone Resources: Include > Specific zone > consultoria-as.com
4. Copiar el token generado a `CF_API_TOKEN` en `docker/.env`
### 2. Configurar variables
En `docker/.env`:
```env
PUBLIC_HOST=play.consultoria-as.com
CF_API_TOKEN=tu-token-aqui
```
En `servers/maple2/.env`:
```env
GAME_IP=play.consultoria-as.com
LOGIN_IP=play.consultoria-as.com
```
### 3. Port forwarding en el router
Abrir estos puertos TCP en el router, apuntando a la IP local del servidor (192.168.10.234):
| Puerto | Servicio | Protocolo |
|--------|----------|-----------|
| 23000 | OpenFusion (login) | TCP |
| 23001 | OpenFusion (shard) | TCP |
| 20001 | MapleStory 2 (login) | TCP |
| 20003 | MapleStory 2 (game) | TCP |
| 25565 | Minecraft FTB Evolution | TCP |
| 7777 | SM64 Coop DX | UDP |
| 45000-45004 | N64 Netplay (Mario Party) | TCP + UDP |
| 6262, 6226 | Dolphin Traversal (GC/Wii) | UDP |
**No forwardear**: PostgreSQL (5432), MinIO (9000/9001), Strapi (1337), Next.js (3000), MySQL (3307). Estos son servicios internos.
### 4. Levantar servicios
```bash
cd docker
docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.maple2.yml up -d
```
El contenedor `cloudflare-ddns` actualizara automaticamente el registro DNS `play.consultoria-as.com` con tu IP publica cada 5 minutos.
### 5. Conexion desde fuera
| Juego | Direccion |
|-------|-----------|
| Minecraft FTB Evolution | `play.consultoria-as.com:25565` |
| OpenFusion (FusionFall) | `play.consultoria-as.com:23000` |
| MapleStory 2 | `play.consultoria-as.com:20001` |
| SM64 Coop DX | `play.consultoria-as.com:7777` (UDP) |
| N64 Netplay (Mario Party) | `play.consultoria-as.com:45000` |
| Dolphin Netplay (GC/Wii) | Traversal Server: `play.consultoria-as.com:6262` |
### Detener servicios
```bash
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.maple2.yml down
```
### Reconstruir imagenes
```bash
docker compose -f docker-compose.dev.yml build --no-cache
docker compose -f docker-compose.maple2.yml build --no-cache
```
## Produccion
### Archivo: docker-compose.yml
El compose de produccion incluye:
- **Nginx** como reverse proxy (puertos 80/443)
- **Certbot** para certificados SSL Let's Encrypt
- Sin servidores de juegos (se configuran aparte segun el VPS)
### Variables de entorno requeridas
```env
# Base de datos
DATABASE_NAME=afterlife
DATABASE_USERNAME=afterlife
DATABASE_PASSWORD=<password-seguro>
# MinIO
MINIO_ROOT_USER=afterlife
MINIO_ROOT_PASSWORD=<password-seguro>
# Strapi keys (generar con: openssl rand -base64 32)
APP_KEYS=<key1>,<key2>,<key3>,<key4>
API_TOKEN_SALT=<salt>
ADMIN_JWT_SECRET=<secret>
TRANSFER_TOKEN_SALT=<salt>
JWT_SECRET=<secret>
# API
STRAPI_API_TOKEN=<token-generado-en-admin>
PUBLIC_STRAPI_URL=https://tu-dominio.com
# Domain
DOMAIN=tu-dominio.com
```
### Deploy manual al VPS
```bash
# En el VPS
cd /opt/project-afterlife
git pull origin main
docker compose build
docker compose up -d
```
### CI/CD Automatico
El archivo `.github/workflows/deploy.yml` automatiza el deploy:
1. **Trigger**: Push a `main`
2. **Accion**: SSH al VPS, pull, build, restart
**Secrets de GitHub requeridos**:
- `VPS_HOST` — Hostname o IP del VPS
- `VPS_USER` — Usuario SSH
- `VPS_SSH_KEY` — Llave privada SSH
### SSL con Certbot
```bash
# Primera vez: obtener certificado
docker compose run --rm certbot certonly \
--webroot --webroot-path=/var/www/certbot \
-d tu-dominio.com
# Renovacion automatica (cron)
0 0 * * * docker compose run --rm certbot renew
```
## Backups
### Base de datos CMS (PostgreSQL)
```bash
# Exportar
docker exec docker-postgres-1 pg_dump -U afterlife afterlife > backup_$(date +%Y%m%d).sql
# Importar
cat backup.sql | docker exec -i docker-postgres-1 psql -U afterlife afterlife
```
### Base de datos MapleStory 2 (MySQL)
```bash
# Exportar
docker exec maple2-db mysqldump -u root -pmaplestory --databases maple-data game-server > backup_ms2_$(date +%Y%m%d).sql
# Importar
cat backup_ms2.sql | docker exec -i maple2-db mysql -u root -pmaplestory
```
### Mundo de Minecraft
```bash
# Exportar
docker cp minecraft-ftb:/data/world ./backup_mc_world_$(date +%Y%m%d)/
# Importar
docker cp ./backup_mc_world/ minecraft-ftb:/data/world
docker restart minecraft-ftb
```
### Volumenes Docker (completo)
```bash
# Listar volumenes
docker volume ls | grep afterlife
# Backup de un volumen
docker run --rm -v docker_postgres_data:/data -v $(pwd):/backup \
alpine tar czf /backup/postgres_data.tar.gz -C /data .
```
## Monitoreo
### Estado de contenedores
```bash
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
### Uso de recursos
```bash
docker stats --no-stream
```
### Logs en tiempo real
```bash
docker logs -f --tail 50 <container-name>
```
### Health checks
PostgreSQL y MySQL tienen healthchecks configurados en los compose files. Verificar con:
```bash
docker inspect --format='{{.State.Health.Status}}' docker-postgres-1
docker inspect --format='{{.State.Health.Status}}' maple2-db
```

395
docs/game-servers.md Normal file
View File

@@ -0,0 +1,395 @@
# Servidores de Juegos
Guia de setup, operacion y troubleshooting de cada servidor de juegos.
## OpenFusion (FusionFall)
### Resumen
| Dato | Valor |
|------|-------|
| Emulador | [OpenFusion](https://github.com/OpenFusionProject/OpenFusion) |
| Lenguaje | C++ |
| Puerto | 23000 (login), 23001 (shard) |
| Base de datos | SQLite (embebida) |
| RAM | ~254 MB |
### Archivos
```
servers/openfusion/
├── Dockerfile # Ubuntu 24.04, copia binario + config
├── docker-entrypoint.sh # Genera config.ini desde env vars
├── config.ini # Configuracion del servidor
├── fusion # Binario compilado (no en git)
├── sql/ # Migraciones SQLite
└── tdata/ # Datos del juego (NPCs, mobs, drops)
```
### Configuracion
Variables de entorno en `docker-compose.dev.yml`:
- `OPENFUSION_SHARD_IP`: IP publica del servidor (default: 192.168.10.234)
- `OPENFUSION_MOTD`: Mensaje del dia
### Conexion de cliente
1. Descargar el cliente FusionFall
2. Usar el launcher de OpenFusion apuntando a `192.168.10.234:23000`
### Troubleshooting
- **"Connection refused"**: Verificar que el contenedor esta corriendo y los puertos estan mapeados
- **Datos de juego**: Los archivos `tdata/` contienen los NPCs, mobs y drops. Si faltan, el mundo estara vacio
---
## MapleStory 2
### Resumen
| Dato | Valor |
|------|-------|
| Emulador | [Maple2](https://github.com/MS2Community/Maple2) |
| Lenguaje | C# / .NET 8 |
| Puertos | 20001 (login), 21001 (world), 20003/21003 (game ch0), 4000 (web) |
| Base de datos | MySQL 8.0 (puerto 3307) |
| RAM total | ~1.4 GB (5 contenedores) |
### Arquitectura Multi-Servicio
```
maple2-mysql (3307)
├── maple2-world (21001) ← Coordinador central, gRPC
│ │
│ ├── maple2-login (20001) ← Autenticacion, seleccion de personaje
│ │
│ └── maple2-game-ch0 (20003/21003) ← Canal de juego (channelId=1)
└── maple2-web (4000) ← API web auxiliar
```
Los servidores se comunican entre si via **gRPC** (HTTP/2). El World server actua como coordinador central. Los Game servers se conectan al World al iniciar.
### Setup Inicial (Primera Vez)
#### 1. Clonar el repositorio
```bash
cd servers/
git clone --recurse-submodules https://github.com/MS2Community/Maple2.git maple2
```
#### 2. Descargar datos del cliente
Se necesita el cliente de MapleStory 2 (~14 GB). Los archivos se colocan en `servers/maple2/client-data/Data/`.
Fuentes del cliente:
- [adventure-island-online-2 releases](https://github.com/shuabritze/adventure-island-online-2/releases) (6 partes ZIP)
- Extraer todo a `servers/maple2/client-data/`
#### 3. Aplicar XML Patches
Descargar [MapleStory2-XML v1.2.1](https://github.com/MS2Community/MapleStory2-XML/releases/tag/v1.2.1) y copiar los archivos `Server.m2d`, `Server.m2h`, `Xml.m2d`, `Xml.m2h` a `servers/maple2/client-data/Data/` (reemplazar los originales).
#### 4. Configurar .env
```bash
cp servers/maple2/.env.example servers/maple2/.env
# Editar con las IPs correctas:
# GAME_IP=192.168.10.234
# LOGIN_IP=192.168.10.234
```
#### 5. Verificar .dockerignore
El archivo `servers/maple2/.dockerignore` DEBE incluir:
```
client-data
client-download
xml-patches
```
Sin esto, el build de Docker intentara copiar 14 GB de datos al contexto.
#### 6. Ingestar datos del juego
```bash
cd docker/
docker compose -f docker-compose.maple2.yml up -d maple2-mysql
# Esperar a que MySQL este healthy
docker compose -f docker-compose.maple2.yml run --rm maple2-file-ingest \
bash -c "cd /app && dotnet restore && cd Maple2.File.Ingest && dotnet run"
```
Este proceso importa todos los datos del cliente a MySQL. Toma ~10 minutos.
#### 7. Construir y levantar servidores
```bash
docker compose -f docker-compose.maple2.yml build
docker compose -f docker-compose.maple2.yml up -d
```
### Conexion de cliente
1. Tener el cliente de MapleStory 2 instalado
2. El cliente debe apuntar a `192.168.10.234:20001` (Login Server)
3. Las IPs se configuran en `servers/maple2/.env` (`GAME_IP`, `LOGIN_IP`)
### Troubleshooting
- **"No space left on device" al buildear**: Verificar `.dockerignore` incluye `client-data`
- **"Scripting/Scripts not found"**: Ya corregido — se removio la linea COPY del Dockerfile del Game server
- **"project.assets.json not found" en file-ingest**: Ejecutar `dotnet restore` antes de `dotnet run`
- **Servidores no se conectan entre si**: Verificar que `GRPC_WORLD_IP=maple2-world` y `GRPC_GAME_IP=maple2-game-ch0` estan configurados en el compose
- **"Server not found" al seleccionar personaje**: `INSTANCED_CONTENT` debe ser `"false"` para que el canal se registre como non-instanced. Con `"false"`, el channelId es 1 y los puertos son 20003/21003 (base + channelId)
### Agregar mas canales de juego
Para agregar un segundo canal, duplicar el servicio `maple2-game-ch0` en el compose cambiando:
- Nombre: `maple2-game-ch1`
- Puertos: `20004:20004` y `21004:21004` (channelId=2)
- `GRPC_GAME_IP: maple2-game-ch1`
> **Nota sobre puertos**: Con `INSTANCED_CONTENT: "false"`, el primer canal es channelId=1 (puertos 20003/21003), el segundo seria channelId=2 (20004/21004), etc. Los puertos se calculan como `BasePort + channelId`.
---
## Minecraft: FTB Evolution
### Resumen
| Dato | Valor |
|------|-------|
| Imagen Docker | itzg/minecraft-server:java21 |
| Modpack | FTB Evolution v1.29.1 |
| Minecraft | 1.21.1 |
| NeoForge | 21.1.218 |
| Puerto | 25565 |
| RAM | ~3.5 GB (6 GB JVM heap, 8 GB limite contenedor) |
| Mods | 200+ |
### Configuracion
El servidor se configura via variables de entorno en `docker-compose.dev.yml`:
```yaml
environment:
EULA: "TRUE"
TYPE: FTBA
FTB_MODPACK_ID: 125
FTB_MODPACK_VERSION_ID: 100181
MEMORY: 6G
MAX_MEMORY: 6G
MOTD: "Project Afterlife - FTB Evolution"
DIFFICULTY: normal
MAX_PLAYERS: 20
VIEW_DISTANCE: 10
ENABLE_COMMAND_BLOCK: "true"
```
### Primer inicio
El primer inicio toma 5-10 minutos porque:
1. Descarga el FTB App installer
2. Descarga los 200+ mods del modpack
3. Instala NeoForge 21.1.218
4. Genera el mundo
### Conexion de cliente
1. Instalar [FTB App](https://www.feed-the-beast.com/app), MultiMC, ATLauncher, o Prism Launcher
2. Instalar modpack **FTB Evolution** version 1.29.1
3. Multiplayer > Add Server: `192.168.10.234:25565`
### Fix: Watchdog crash en primer inicio
Con 200+ mods, el mod Hexerei tarda mas de 60 segundos generando recetas al iniciar, lo que activa el watchdog de Minecraft. Se resuelve con `MAX_TICK_TIME: -1` en las variables de entorno del compose.
### Troubleshooting
- **Server lag**: Reducir `VIEW_DISTANCE` de 10 a 8, o aumentar `MEMORY` si hay RAM disponible
- **NeoForge install fails**: Verificar que la imagen Docker es `java21` (no java8 ni java17)
- **Watchdog crash (single tick took 60s)**: Verificar que `MAX_TICK_TIME: -1` esta configurado
---
## Super Mario 64 Coop (sm64coopdx)
### Resumen
| Dato | Valor |
|------|-------|
| Proyecto | [sm64coopdx](https://github.com/coop-deluxe/sm64coopdx) |
| Lenguaje | C |
| Puerto | 7777 (UDP) |
| Jugadores | Hasta 16 |
| RAM | ~45 MB |
### Archivos
```
servers/sm64coopdx/
├── Dockerfile # Multi-stage: compila desde fuente + runtime
├── .dockerignore
├── .gitignore # Excluye ROMs (*.z64, *.n64, *.v64)
└── baserom.us.z64 # ROM de SM64 US (no en git, requerida para build)
```
### Requisitos
Se necesita la ROM de Super Mario 64 US (`baserom.us.z64`, MD5: `20b854b239203baf6c961b850a4a51a2`) en `servers/sm64coopdx/` antes de construir la imagen Docker.
### Cómo funciona
El Dockerfile compila sm64coopdx desde fuente con `HEADLESS=1 DISCORD_SDK=0 COOPNET=0`. Incluye dos parches:
1. **float.h** — GCC 11 requiere include explícito de `<float.h>` para `FLT_EPSILON`
2. **platform.c** — El fallback headless (sin SDL2) tiene funciones renombradas que upstream no actualizo (`sys_exe_path_dir`, `sys_resource_path`)
La ROM es necesaria tanto en build (extracción de assets) como en runtime (validación MD5 al iniciar).
### Configuracion
Variables de entorno en `docker-compose.dev.yml`:
- `SM64_PORT`: Puerto UDP del servidor (default: 7777)
- `SM64_PLAYERS`: Maximo de jugadores (default: 16)
### Conexion de cliente
1. Descargar [sm64coopdx](https://github.com/coop-deluxe/sm64coopdx) (requiere compilar con la misma ROM)
2. Abrir sm64coopdx → Join → Direct Connection
3. Ingresar `play.consultoria-as.com:7777` (o `192.168.10.234:7777` en LAN)
### Mods incluidos
El build incluye mods bundled del repositorio: character-select, star-road, arena, day-night-cycle, sm74.
### Troubleshooting
- **"could not find valid vanilla us sm64 rom"**: La ROM debe estar tanto en el build como en el runtime. Verificar que el Dockerfile copia `baserom.us.z64` al stage de runtime
- **100% CPU**: Normal — el game loop headless no tiene frame limiter porque no renderiza graficos
- **No se conectan jugadores**: Verificar que el puerto 7777/UDP esta abierto en el router y que el firewall permite UDP
---
## N64 Netplay (gopher64 — Mario Party 1-3)
### Resumen
| Dato | Valor |
|------|-------|
| Proyecto | [gopher64-netplay-server](https://github.com/gopher64/gopher64-netplay-server) |
| Imagen Docker | k4rian/gopher64-netplay-server |
| Lenguaje | Go |
| Puertos | 45000-45004 (TCP + UDP) |
| Jugadores | 4 por sala, 4 salas concurrentes |
| RAM | <1 MB (heap ~660 KB) |
### Cómo funciona
Servidor relay headless que retransmite inputs de controles N64 entre jugadores via WebSocket (lobby) + UDP (gameplay). No ejecuta el juego — cada jugador corre su propio emulador con su ROM. El servidor solo coordina la sincronización.
- **Puerto 45000**: WebSocket lobby (creación de salas, chat, conexión)
- **Puertos 45001-45004**: Sesiones de juego individuales (TCP + UDP)
- **LAN discovery**: Activado por defecto, los emuladores en la misma red lo detectan automáticamente
### Configuracion
Variables de entorno en `docker-compose.dev.yml`:
- `G64NS_NAME`: Nombre del servidor ("Afterlife N64 - Mario Party")
- `G64NS_PORT`: Puerto base (default: 45000)
- `G64NS_MAXGAMES`: Partidas concurrentes (default: 4)
- `G64NS_MOTD`: Mensaje de bienvenida
- `G64NS_DISABLEBROADCAST`: Desactivar LAN discovery (default: false)
- `G64NS_ENABLEAUTH`: Activar autenticación (default: false)
### Juegos soportados
Cualquier juego de N64, pero diseñado especialmente para:
- **Mario Party 1** (N64)
- **Mario Party 2** (N64)
- **Mario Party 3** (N64)
Todos los jugadores deben usar el **mismo emulador** y el **mismo ROM** (se verifica MD5).
### Emuladores compatibles (cliente)
| Emulador | Custom server | Estado |
|----------|---------------|--------|
| **gopher64** | Si (v1.1.1+) | Activo, recomendado |
| **RMG** (Rosalie's Mupen GUI) | Si (v0.8.0+) | Activo |
| **simple64** | Si | Archivado (usar gopher64) |
### Conexion de cliente
1. Descargar [gopher64](https://github.com/gopher64/gopher64) o RMG
2. Tener el ROM de Mario Party
3. Netplay → Server: seleccionar "Custom"
4. Ingresar `play.consultoria-as.com:45000` (o `192.168.10.234:45000` en LAN)
5. Un jugador crea sala, los demás se unen
### Troubleshooting
- **"Different ROM" error**: Todos los jugadores deben tener exactamente el mismo archivo ROM (se compara MD5)
- **No se puede conectar**: Verificar puertos TCP+UDP 45000-45004 abiertos en router
- **Cross-emulator no funciona**: Todos deben usar el mismo emulador (no mezclar gopher64 con RMG)
---
## Dolphin Traversal Server (GameCube / Wii)
### Resumen
| Dato | Valor |
|------|-------|
| Proyecto | [Dolphin Emulator](https://github.com/dolphin-emu/dolphin) (componente traversal_server) |
| Lenguaje | C++ |
| Puertos | 6262, 6226 (UDP) |
| RAM | <10 MB |
### Cómo funciona
Servidor de NAT hole-punching para Dolphin netplay. **No retransmite datos de juego** — solo facilita la conexión inicial entre dos instancias de Dolphin que están detrás de NAT/firewall. Una vez conectados, el tráfico de juego fluye directamente peer-to-peer.
- **Puerto 6262 (UDP)**: Puerto principal de traversal
- **Puerto 6226 (UDP)**: Puerto alternativo para probar tipo de NAT
El servidor es completamente stateless (sin persistencia), single-threaded, y las entradas de clientes expiran después de 30 segundos de inactividad.
### Build
Multi-stage Docker build que compila solo el target `traversal_server` del repositorio completo de Dolphin. La build es pesada (~20 min) pero el binary final es tiny.
```
servers/dolphin-traversal/
├── Dockerfile # Multi-stage: debian build + debian-slim runtime
└── .dockerignore
```
### Configuracion
El traversal server **no tiene opciones de configuración**. Los puertos (6262/6226) están hardcodeados en el código fuente. No acepta argumentos de línea de comandos ni variables de entorno.
### Juegos soportados
**TODOS** los juegos de GameCube y Wii que se pueden jugar en Dolphin, incluyendo:
- Mario Party 4, 5, 6, 7
- Mario Kart: Double Dash
- Super Smash Bros. Melee
- F-Zero GX
- Kirby Air Ride
- The Legend of Zelda: Wind Waker
- Y cualquier otro juego de GC/Wii
### Conexion de cliente
1. Descargar [Dolphin Emulator](https://dolphin-emu.org/)
2. Ir a Config → General → Netplay (o Config → Network en versiones nuevas)
3. Cambiar **Traversal Server** a: `play.consultoria-as.com`
4. **Traversal Port**: `6262`
5. Un jugador hostea (NetPlay → Host), los demás se unen con el **Host Code** generado
6. Todos necesitan la misma ISO/ROM del juego
### Troubleshooting
- **No se genera Host Code**: Verificar que los puertos UDP 6262 y 6226 están abiertos
- **Conexión lenta o fallida**: El traversal solo facilita el handshake; si la conexión P2P falla, los jugadores pueden intentar Direct Connection (requiere que el host abra puertos)
- **Sin logs**: Normal — el servidor no logea nada por defecto (solo errores a stderr)
- **Desyncs en juego**: Ambos jugadores deben usar la misma versión de Dolphin y la misma ISO
---
## Operaciones Comunes
### Ver logs de un servidor
```bash
docker logs -f minecraft-ftb # Minecraft
docker logs -f maple2-world # MapleStory 2 World
docker logs -f docker-openfusion-1 # OpenFusion
docker logs -f sm64coopdx # SM64 Coop
docker logs -f n64-netplay # N64 Netplay (Mario Party)
docker logs -f dolphin-traversal # Dolphin Traversal (GC/Wii)
```
### Reiniciar un servidor
```bash
docker restart minecraft-ftb
docker restart maple2-world maple2-login docker-maple2-game-ch0-1
docker restart docker-openfusion-1
docker restart sm64coopdx
docker restart n64-netplay
docker restart dolphin-traversal
```
### Ver uso de recursos
```bash
docker stats --no-stream
```
### Backup de datos
```bash
# PostgreSQL (CMS)
docker exec docker-postgres-1 pg_dump -U afterlife afterlife > backup_cms.sql
# MySQL (MapleStory 2)
docker exec maple2-db mysqldump -u root -pmaplestory --databases maple-data game-server > backup_ms2.sql
# Minecraft (mundo completo)
docker cp minecraft-ftb:/data/world ./backup_minecraft_world/
```

View File

@@ -0,0 +1,633 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Afterlife — Social Media Posts</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
line-height: 1.7;
}
header {
text-align: center;
padding: 60px 20px 40px;
background: linear-gradient(135deg, #0d1117 0%, #161b28 50%, #1a1025 100%);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
header h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #fff 0%, #8b9cf7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
header p {
color: #6b7280;
font-size: 1.1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px 80px;
}
/* Platform Sections */
.platform-section {
margin-bottom: 60px;
}
.platform-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 30px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.platform-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-weight: 700;
flex-shrink: 0;
}
.platform-icon.reddit { background: #ff4500; color: #fff; }
.platform-icon.threads { background: #000; color: #fff; border: 1px solid rgba(255,255,255,0.2); }
.platform-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.platform-header span {
font-size: 0.85rem;
color: #6b7280;
font-weight: 400;
}
/* Post Cards */
.post-card {
background: #12141c;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
transition: border-color 0.2s;
}
.post-card:hover {
border-color: rgba(255,255,255,0.12);
}
.post-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.post-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: #9ca3af;
}
.tag.en { background: rgba(59,130,246,0.15); color: #60a5fa; }
.tag.es { background: rgba(251,146,60,0.15); color: #fb923c; }
.tag.long { background: rgba(168,85,247,0.15); color: #c084fc; }
.tag.medium { background: rgba(34,197,94,0.15); color: #4ade80; }
.tag.short { background: rgba(236,72,153,0.15); color: #f472b6; }
.copy-btn {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #9ca3af;
padding: 6px 16px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.copy-btn:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.copy-btn.copied {
background: rgba(34,197,94,0.15);
border-color: rgba(34,197,94,0.3);
color: #4ade80;
}
.post-body {
padding: 24px;
}
.post-title {
font-size: 1.15rem;
font-weight: 700;
color: #fff;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.post-content {
color: #c9cdd4;
font-size: 0.95rem;
}
.post-content p {
margin-bottom: 12px;
}
.post-content strong {
color: #fff;
font-weight: 600;
}
.post-content ul, .post-content ol {
margin: 8px 0 12px 20px;
}
.post-content li {
margin-bottom: 6px;
}
.post-content h3 {
color: #fff;
font-size: 1rem;
font-weight: 700;
margin: 20px 0 8px;
}
.post-content .game-highlight {
background: rgba(255,255,255,0.03);
border-left: 3px solid #6366f1;
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 8px 8px 0;
}
.post-content .game-highlight.drift {
border-left-color: #06b6d4;
}
/* Threads style */
.threads-post .post-content {
font-size: 1.05rem;
line-height: 1.8;
}
.divider {
height: 1px;
background: rgba(255,255,255,0.06);
margin: 12px 0;
}
.subreddit-list {
color: #6b7280;
font-size: 0.8rem;
margin-top: 4px;
}
/* Responsive */
@media (max-width: 640px) {
header h1 { font-size: 1.8rem; }
.post-meta { flex-direction: column; gap: 12px; align-items: flex-start; }
.post-body { padding: 16px; }
}
</style>
</head>
<body>
<header>
<h1>Project Afterlife</h1>
<p>Social Media Launch Posts &mdash; FusionFall &amp; Drift City</p>
</header>
<div class="container">
<!-- ========== REDDIT ENGLISH ========== -->
<section class="platform-section">
<div class="platform-header">
<div class="platform-icon reddit">R</div>
<div>
<h2>Reddit &mdash; English</h2>
<span>3 versions adapted by subreddit</span>
</div>
</div>
<!-- Reddit EN Long -->
<div class="post-card" id="reddit-en-long">
<div class="post-meta">
<div class="post-tags">
<span class="tag en">English</span>
<span class="tag long">Long</span>
</div>
<div class="subreddit-list">r/gamepreservation &middot; r/Games</div>
<button class="copy-btn" onclick="copyPost('reddit-en-long')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-title">We're building Project Afterlife &mdash; a game preservation initiative. First titles: FusionFall and Drift City</div>
<div class="post-content">
<p>We're a small team of 4 developers working on something we believe matters: preserving online games that have been shut down.</p>
<h3>What is Project Afterlife?</h3>
<p>When an online game's servers go dark, everything disappears &mdash; the worlds, the communities, the stories. Project Afterlife aims to change that in two ways:</p>
<ol>
<li><strong>Private server restoration</strong> &mdash; We reverse-engineer and rebuild game servers so people can play these titles again.</li>
<li><strong>Interactive documentaries</strong> &mdash; For each restored game, we create a chapter-based documentary on our website featuring the game's history, its rise and fall, gameplay breakdowns, and community stories &mdash; all narrated by human voice actors in audiobook format.</li>
</ol>
<h3>Our first two games:</h3>
<div class="game-highlight">
<strong>FusionFall</strong> &mdash; Cartoon Network's ambitious MMO that dropped players into a post-apocalyptic world where Dexter, Ben 10, and the Kids Next Door fought side by side against an alien invasion. It was unlike anything else &mdash; a genuine MMO built around beloved cartoon characters, with real depth. When it shut down in 2013, an entire generation lost a world they grew up in.
</div>
<div class="game-highlight drift">
<strong>Drift City</strong> &mdash; A fast-paced MMO racing game set in a futuristic open-world city. Part racing, part RPG, part open-world exploration. Players tuned cars, ran missions, and drifted through neon-lit streets. It closed its doors in 2016, leaving behind a community that still talks about it.
</div>
<p>Both will get fully restored private servers and their own interactive documentaries &mdash; narrated chapters covering their creation, golden era, decline, shutdown, and resurrection.</p>
<h3>Funding model</h3>
<p>This is a 100% donation-funded initiative. No ads, no paywalls, no premium tiers. Patreon for recurring support, Ko-fi for one-time contributions. Full transparency on fund allocation.</p>
<h3>Current status</h3>
<p>We're in active development. The web platform is being built on our own self-hosted infrastructure. We're working on the server restoration and documentary content for both FusionFall and Drift City simultaneously.</p>
<p>If you played either of these games &mdash; or if you've ever lost an online game you loved &mdash; we'd appreciate your feedback and support.</p>
<p>More updates coming soon.</p>
</div>
</div>
</div>
<!-- Reddit EN Medium -->
<div class="post-card" id="reddit-en-medium">
<div class="post-meta">
<div class="post-tags">
<span class="tag en">English</span>
<span class="tag medium">Medium</span>
</div>
<div class="subreddit-list">r/MMORPG &middot; r/gaming</div>
<button class="copy-btn" onclick="copyPost('reddit-en-medium')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-title">Remember FusionFall? Drift City? We're bringing them back &mdash; introducing Project Afterlife</div>
<div class="post-content">
<p>We've all been there. A game you sunk hundreds of hours into announces it's closing. The servers go offline. And just like that, an entire world vanishes.</p>
<p><strong>Project Afterlife</strong> is a preservation initiative by a team of 4 developers. Our mission:</p>
<ul>
<li><strong>Restore discontinued online games</strong> by rebuilding their servers &mdash; free to play.</li>
<li><strong>Document their history</strong> through interactive web documentaries &mdash; narrated by real people, with images, video, and audio you can listen to like a podcast.</li>
</ul>
<h3>First two games:</h3>
<div class="game-highlight">
<strong>FusionFall</strong> &mdash; Cartoon Network's MMO where Dexter, Ben 10, and the KND fought aliens together. Shut down in 2013. An entire generation's childhood world, gone.
</div>
<div class="game-highlight drift">
<strong>Drift City</strong> &mdash; MMO street racing with RPG elements in a futuristic open world. Closed in 2016. The drifting, the tuning, the neon city &mdash; all of it lost.
</div>
<p>We're restoring both. Playable servers + full interactive documentaries telling their stories from birth to death to resurrection.</p>
<h3>Key details:</h3>
<ul>
<li>Completely free. Funded by voluntary donations only.</li>
<li>Multilingual (English + Spanish).</li>
<li>We cover all genres &mdash; if it was online and it's gone, it's a candidate.</li>
<li>Each game gets a full documentary experience, not just a wiki page.</li>
</ul>
<p>If you ever wished you could log into FusionFall one more time, or drift through those neon streets again &mdash; stay tuned.</p>
</div>
</div>
</div>
<!-- Reddit EN Short / Community -->
<div class="post-card" id="reddit-en-short">
<div class="post-meta">
<div class="post-tags">
<span class="tag en">English</span>
<span class="tag short">Community</span>
</div>
<div class="subreddit-list">r/FusionFall &middot; r/cartoonnetwork</div>
<button class="copy-btn" onclick="copyPost('reddit-en-short')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-title">Project Afterlife &mdash; We're restoring FusionFall with a private server and building an interactive documentary about its history</div>
<div class="post-content">
<p>FusionFall was special. A real MMO set in the Cartoon Network universe, with actual depth, real quests, and a community that genuinely cared. When it shut down in 2013, a lot of us lost something meaningful.</p>
<p>We're a team of 4 developers building <strong>Project Afterlife</strong> &mdash; a game preservation initiative. FusionFall is one of our first two titles (alongside Drift City).</p>
<h3>What we're doing:</h3>
<ul>
<li>Restoring a private server so you can explore the world again</li>
<li>Creating a full interactive documentary on our website &mdash; chapters covering FusionFall's creation, the Cartoon Network vision behind it, the community that formed, how the game evolved, why it shut down, and how we're bringing it back</li>
<li>All narrated by human voice actors in audiobook format &mdash; you can read along or just listen</li>
</ul>
<p>We're funded entirely by donations. No ads, no paywalls.</p>
<p>We'd love to hear from this community. What are your strongest memories of FusionFall? What moments should we make sure to cover in the documentary?</p>
<p>Updates coming soon.</p>
</div>
</div>
</div>
</section>
<!-- ========== REDDIT SPANISH ========== -->
<section class="platform-section">
<div class="platform-header">
<div class="platform-icon reddit">R</div>
<div>
<h2>Reddit &mdash; Espa&ntilde;ol</h2>
<span>Versi&oacute;n completa para comunidades hispanohablantes</span>
</div>
</div>
<div class="post-card" id="reddit-es">
<div class="post-meta">
<div class="post-tags">
<span class="tag es">Espa&ntilde;ol</span>
<span class="tag long">Largo</span>
</div>
<div class="subreddit-list">r/espanol &middot; r/latinoamerica</div>
<button class="copy-btn" onclick="copyPost('reddit-es')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-title">Estamos creando Project Afterlife &mdash; una iniciativa de preservaci&oacute;n de juegos online. Primeros t&iacute;tulos: FusionFall y Drift City</div>
<div class="post-content">
<p>Somos un equipo de 4 programadores trabajando en algo que creemos importante: preservar juegos online que han sido cerrados.</p>
<h3>&iquest;Qu&eacute; es Project Afterlife?</h3>
<p>Cuando los servidores de un juego online se apagan, todo desaparece: los mundos, las comunidades, las historias. Project Afterlife busca cambiar eso de dos formas:</p>
<ol>
<li><strong>Restauraci&oacute;n de servidores privados</strong> &mdash; Reconstruimos los servidores del juego para que la gente pueda volver a jugar.</li>
<li><strong>Documentales interactivos</strong> &mdash; Para cada juego restaurado, creamos un documental por cap&iacute;tulos en nuestra web: la historia del juego, su auge y ca&iacute;da, an&aacute;lisis de gameplay y relatos de la comunidad. Todo narrado por personas reales en formato audiolibro.</li>
</ol>
<h3>Nuestros dos primeros juegos:</h3>
<div class="game-highlight">
<strong>FusionFall</strong> &mdash; El ambicioso MMO de Cartoon Network que nos sumerg&iacute;a en un mundo post-apocal&iacute;ptico donde Dexter, Ben 10 y los Chicos del Barrio luchaban juntos contra una invasi&oacute;n alien&iacute;gena. Era &uacute;nico: un MMO real construido alrededor de los personajes que nos marcaron de ni&ntilde;os. Cuando cerr&oacute; en 2013, toda una generaci&oacute;n perdi&oacute; un mundo en el que creci&oacute;.
</div>
<div class="game-highlight drift">
<strong>Drift City</strong> &mdash; Un juego de carreras MMO ambientado en una ciudad futurista de mundo abierto. Parte carreras, parte RPG, parte exploraci&oacute;n. Tunear coches, misiones, y derrapar por calles iluminadas con ne&oacute;n. Cerr&oacute; en 2016 dejando atr&aacute;s una comunidad que a&uacute;n lo recuerda.
</div>
<p>Ambos tendr&aacute;n servidores privados restaurados y su propio documental interactivo &mdash; cap&iacute;tulos narrados cubriendo su creaci&oacute;n, era dorada, declive, cierre y resurrecci&oacute;n.</p>
<h3>Financiaci&oacute;n</h3>
<p>Iniciativa 100% financiada por donaciones. Sin anuncios, sin muros de pago. Patreon para apoyo recurrente, Ko-fi para donaciones puntuales. Transparencia total.</p>
<h3>Estado actual</h3>
<p>Estamos en desarrollo activo. La plataforma web se est&aacute; construyendo en nuestra propia infraestructura. Trabajamos en la restauraci&oacute;n de servidores y el contenido documental de FusionFall y Drift City simult&aacute;neamente.</p>
<p>Si jugaste a alguno de estos juegos &mdash; o si alguna vez perdiste un juego online que amabas &mdash; agradecemos tu feedback y apoyo.</p>
<p>M&aacute;s novedades pronto.</p>
</div>
</div>
</div>
</section>
<!-- ========== THREADS ENGLISH ========== -->
<section class="platform-section">
<div class="platform-header">
<div class="platform-icon threads">@</div>
<div>
<h2>Threads &mdash; English</h2>
<span>Main post + reply thread</span>
</div>
</div>
<div class="post-card threads-post" id="threads-en-1">
<div class="post-meta">
<div class="post-tags">
<span class="tag en">English</span>
<span class="tag short">Post 1</span>
</div>
<button class="copy-btn" onclick="copyPost('threads-en-1')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-content">
<p>Introducing Project Afterlife.</p>
<p>Two games that deserved better: FusionFall and Drift City. Both shut down. Both forgotten by their publishers. Not by us.</p>
<p>We're a team of 4 rebuilding their servers so you can play again &mdash; and creating interactive documentaries narrated by real humans telling the full story of each game.</p>
<p>Every game deserves an afterlife. These two are first.</p>
</div>
</div>
</div>
<div class="post-card threads-post" id="threads-en-2">
<div class="post-meta">
<div class="post-tags">
<span class="tag en">English</span>
<span class="tag short">Post 2 &mdash; Reply</span>
</div>
<button class="copy-btn" onclick="copyPost('threads-en-2')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-content">
<p>FusionFall: Cartoon Network's MMO. Dexter, Ben 10, KND &mdash; fighting aliens together in an actual open world. Shut down 2013.</p>
<p>Drift City: MMO street racing in a neon-lit futuristic city. RPG progression, open world, pure adrenaline. Shut down 2016.</p>
<p>We're restoring both. Playable servers + full narrated documentaries.</p>
<p>100% free. Funded only by donations. English + Spanish.</p>
<p>Stay tuned.</p>
</div>
</div>
</div>
</section>
<!-- ========== THREADS SPANISH ========== -->
<section class="platform-section">
<div class="platform-header">
<div class="platform-icon threads">@</div>
<div>
<h2>Threads &mdash; Espa&ntilde;ol</h2>
<span>Post principal + hilo de respuesta</span>
</div>
</div>
<div class="post-card threads-post" id="threads-es-1">
<div class="post-meta">
<div class="post-tags">
<span class="tag es">Espa&ntilde;ol</span>
<span class="tag short">Post 1</span>
</div>
<button class="copy-btn" onclick="copyPost('threads-es-1')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-content">
<p>Presentamos Project Afterlife.</p>
<p>Dos juegos que merec&iacute;an m&aacute;s: FusionFall y Drift City. Ambos cerrados. Ambos olvidados por sus distribuidoras. No por nosotros.</p>
<p>Somos un equipo de 4 reconstruyendo sus servidores para que puedas volver a jugar &mdash; y creando documentales interactivos narrados por personas reales contando la historia completa de cada juego.</p>
<p>Todo juego merece una segunda vida. Estos dos son los primeros.</p>
</div>
</div>
</div>
<div class="post-card threads-post" id="threads-es-2">
<div class="post-meta">
<div class="post-tags">
<span class="tag es">Espa&ntilde;ol</span>
<span class="tag short">Post 2 &mdash; Respuesta</span>
</div>
<button class="copy-btn" onclick="copyPost('threads-es-2')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="post-body">
<div class="post-content">
<p>FusionFall: El MMO de Cartoon Network. Dexter, Ben 10, los KND &mdash; luchando juntos contra alien&iacute;genas en un mundo abierto real. Cerrado en 2013.</p>
<p>Drift City: Carreras MMO en una ciudad futurista iluminada con ne&oacute;n. Progresi&oacute;n RPG, mundo abierto, adrenalina pura. Cerrado en 2016.</p>
<p>Estamos restaurando ambos. Servidores jugables + documentales narrados completos.</p>
<p>100% gratis. Financiado solo por donaciones. Espa&ntilde;ol + ingl&eacute;s.</p>
<p>Pronto m&aacute;s novedades.</p>
</div>
</div>
</div>
</section>
</div>
<script>
function copyPost(id) {
const card = document.getElementById(id);
const titleEl = card.querySelector('.post-title');
const contentEl = card.querySelector('.post-content');
let text = '';
if (titleEl) {
text += titleEl.textContent.trim() + '\n\n';
}
// Walk through content and build plain text
const children = contentEl.children;
for (const child of children) {
if (child.tagName === 'H3') {
text += '**' + child.textContent.trim() + '**\n\n';
} else if (child.tagName === 'P') {
let pText = child.innerHTML
.replace(/<strong>/g, '**')
.replace(/<\/strong>/g, '**')
.replace(/&mdash;/g, '—')
.replace(/&amp;/g, '&')
.replace(/<[^>]*>/g, '')
.replace(/&[a-z]+;/g, (m) => {
const map = {
'&iquest;': '?', '&ntilde;': 'n', '&aacute;': 'a',
'&eacute;': 'e', '&iacute;': 'i', '&oacute;': 'o',
'&uacute;': 'u', '&Aacute;': 'A', '&Eacute;': 'E',
'&Iacute;': 'I', '&Oacute;': 'O', '&Uacute;': 'U',
'&middot;': '·'
};
return map[m] || m;
});
text += pText.trim() + '\n\n';
} else if (child.tagName === 'UL') {
for (const li of child.querySelectorAll('li')) {
let liText = li.innerHTML
.replace(/<strong>/g, '**')
.replace(/<\/strong>/g, '**')
.replace(/&mdash;/g, '—')
.replace(/<[^>]*>/g, '');
text += '- ' + liText.trim() + '\n';
}
text += '\n';
} else if (child.tagName === 'OL') {
let i = 1;
for (const li of child.querySelectorAll('li')) {
let liText = li.innerHTML
.replace(/<strong>/g, '**')
.replace(/<\/strong>/g, '**')
.replace(/&mdash;/g, '—')
.replace(/<[^>]*>/g, '');
text += i + '. ' + liText.trim() + '\n';
i++;
}
text += '\n';
} else if (child.classList.contains('game-highlight')) {
let ghText = child.innerHTML
.replace(/<strong>/g, '**')
.replace(/<\/strong>/g, '**')
.replace(/&mdash;/g, '—')
.replace(/<[^>]*>/g, '');
text += ghText.trim() + '\n\n';
}
}
navigator.clipboard.writeText(text.trim()).then(() => {
const btn = card.querySelector('.copy-btn');
btn.classList.add('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy';
}, 2000);
});
}
</script>
</body>
</html>

4405
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,5 +14,11 @@
"devDependencies": {
"turbo": "^2"
},
"packageManager": "npm@10.8.0"
"packageManager": "npm@10.8.0",
"overrides": {
"react": "^19",
"react-dom": "^19",
"@types/react": "^19",
"@types/react-dom": "^19"
}
}

View File

@@ -0,0 +1,2 @@
*.md
.git

View File

@@ -0,0 +1,51 @@
# Dolphin Emulator Traversal Server
# Lightweight NAT hole-punching relay for Dolphin netplay
# Supports ALL GameCube/Wii games via Dolphin
# --- Build stage ---
FROM debian:bookworm AS build
RUN apt-get update && apt-get install -y \
build-essential cmake git pkg-config \
libfmt-dev libenet-dev libcurl4-openssl-dev \
libbz2-dev liblzma-dev libzstd-dev zlib1g-dev \
liblzo2-dev liblz4-dev libspng-dev \
libusb-1.0-0-dev libevdev-dev libpugixml-dev libxxhash-dev \
libminiupnpc-dev libhidapi-dev libsystemd-dev libudev-dev \
glslang-dev glslang-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /dolphin
RUN git clone --depth 1 https://github.com/dolphin-emu/dolphin.git . \
&& git submodule update --init --recursive --depth 1
RUN mkdir build && cd build && cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_QT=OFF \
-DENABLE_NOGUI=OFF \
-DENABLE_CLI=OFF \
-DENABLE_TESTS=OFF \
-DUSE_DISCORD_PRESENCE=OFF \
-DENABLE_AUTOUPDATE=OFF \
-DENABLE_ANALYTICS=OFF
RUN cd build && cmake --build . --target traversal_server -j$(nproc)
# --- Runtime stage ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
libfmt9 libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -s /bin/false dolphin
COPY --from=build /dolphin/build/Binaries/traversal_server /usr/local/bin/traversal_server
USER dolphin
EXPOSE 6262/udp
EXPOSE 6226/udp
CMD ["traversal_server"]

View File

@@ -0,0 +1,96 @@
MAINFRAME_ID = 7
-- AfterCoin Bridge config
local BRIDGE_URL = "http://afc-bridge:3001"
function addPlayer(player, name)
rednet.send(MAINFRAME_ID, {type="addPlayer", player=player, name=name}, "otto")
rednet.receive("otto")
return
end
-- Get wallet info from bridge API
function getWalletInfo(diskId)
local ok, result = pcall(function()
local response = http.get(BRIDGE_URL .. "/api/wallet/" .. tostring(diskId))
if not response then return nil end
local data = textutils.unserialiseJSON(response.readAll())
response.close()
return data
end)
if ok and result and result.success then
return result
end
return nil
end
local drive = peripheral.wrap("top")
rednet.open("left")
while true do
term.clear()
term.setCursorPos(1,1)
term.setTextColor(colors.yellow)
print("=== Card Generator ===")
print("")
print("Insert a floppy disk")
print("in the drive above.")
os.pullEvent("disk")
os.sleep(0.5)
local player = drive.getDiskID()
if player then
term.setTextColor(colors.white)
print("")
term.write("Username: ")
local name = read()
term.setTextColor(colors.yellow)
print("Generating card for "..name.."...")
addPlayer(player, name)
drive.setDiskLabel(name.."'s L'Otto Card - $0")
local mountPath = drive.getMountPath()
if mountPath then
local filePath = fs.combine(mountPath, "bal")
local file = fs.open(filePath, "w")
if file then
file.write("0")
file.close()
end
end
-- Display wallet info for MetaMask
term.setTextColor(colors.lime)
print("Card created!")
print("")
local wallet = getWalletInfo(player)
if wallet then
term.setTextColor(colors.cyan)
print("== AfterCoin Wallet ==")
term.setTextColor(colors.white)
print("Address:")
print(wallet.address)
print("")
print("To view in MetaMask:")
print("Network: AfterLife")
print("RPC: play.consultoria-as.com:8545")
print("Chain ID: 8888")
print("")
print("Import wallet key at:")
print("/api/wallet/" .. tostring(player))
print("")
term.setTextColor(colors.yellow)
print("Press any key to eject...")
os.pullEvent("key")
end
drive.ejectDisk()
term.setTextColor(colors.lime)
print("Ejected.")
else
term.setTextColor(colors.red)
print("ERROR: Could not read disk.")
os.sleep(3)
end
end

View File

@@ -0,0 +1,164 @@
rednet.open("left")
local databasePath = "players"
local database
-- AfterCoin Bridge config
local BRIDGE_URL = "http://afc-bridge:3001"
local BRIDGE_SECRET = "afterlife_bridge_dev_2024"
local SYNC_INTERVAL = 30 -- seconds between chain sync polls
-- HTTP helper: POST to bridge API
local function bridgePost(endpoint, body)
local url = BRIDGE_URL .. endpoint
local jsonBody = textutils.serialiseJSON(body)
local ok, result = pcall(function()
local response, failReason = http.post(url, jsonBody, {
["Content-Type"] = "application/json",
["x-bridge-secret"] = BRIDGE_SECRET
})
if not response then
print("[Bridge] POST failed: " .. tostring(failReason))
return nil
end
local data = textutils.unserialiseJSON(response.readAll())
response.close()
return data
end)
if not ok then
print("[Bridge] POST error: " .. tostring(result))
return nil
end
return result
end
-- HTTP helper: GET from bridge API
local function bridgeGet(endpoint)
local url = BRIDGE_URL .. endpoint
local ok, result = pcall(function()
local response, failReason = http.get(url)
if not response then
print("[Bridge] GET failed: " .. tostring(failReason))
return nil
end
local data = textutils.unserialiseJSON(response.readAll())
response.close()
return data
end)
if not ok then
print("[Bridge] GET error: " .. tostring(result))
return nil
end
return result
end
-- Sync on-chain balance for a player (returns on-chain balance or nil)
local function syncFromChain(diskId)
local resp = bridgeGet("/api/balance/" .. tostring(diskId))
if resp and resp.success then
return resp.balance
end
return nil
end
-- Save database to disk
local function saveDatabase()
local file = fs.open(databasePath, "w")
file.write(textutils.serialise(database))
file.close()
end
if not fs.exists(databasePath) then
database = {}
local file, err = fs.open(databasePath, "w")
if not file then
print("ERROR creating db: "..tostring(err))
print("Trying alternate path...")
databasePath = "/players.txt"
file, err = fs.open(databasePath, "w")
if not file then
print("FATAL: "..tostring(err))
return
end
end
file.write("{}")
file.close()
else
local file = fs.open(databasePath, "r")
database = textutils.unserialise(file.readAll())
file.close()
end
print("Database loaded.")
print("AfterCoin bridge: " .. BRIDGE_URL)
-- Rednet message handler
local function messageLoop()
while true do
local id, data = rednet.receive("otto")
print(textutils.serialise(data))
if data.type == "getPlayerBalance" then
print("Fetching balance for ", data.player)
local chainBalance = syncFromChain(data.player)
if chainBalance and database[data.player] then
database[data.player].balance = chainBalance
end
rednet.send(id, database[data.player], "otto")
elseif data.type == "setPlayerBalance" then
print("Setting balance for ", data.player, " to ", data.balance)
local oldBalance = database[data.player].balance
local diff = data.balance - oldBalance
database[data.player].balance = data.balance
saveDatabase()
if diff > 0 then
print("[Bridge] Minting " .. diff .. " AFC")
bridgePost("/api/deposit", {diskId=tostring(data.player), amount=diff})
elseif diff < 0 then
print("[Bridge] Burning " .. math.abs(diff) .. " AFC")
bridgePost("/api/withdraw", {diskId=tostring(data.player), amount=math.abs(diff)})
end
rednet.send(id, nil, "otto")
elseif data.type == "addPlayer" then
print("Adding player: #"..data.player, data.name)
database[data.player] = {
name=data.name,
balance=0
}
saveDatabase()
print("[Bridge] Registering wallet for " .. data.name)
bridgePost("/api/register", {diskId=tostring(data.player), name=data.name})
rednet.send(id, nil, "otto")
elseif data.type == "getLeaderboard" then
print("Sending leaderboard")
local leaderboard = {}
for pid, pdata in pairs(database) do
table.insert(leaderboard, {name=pdata.name, balance=pdata.balance})
end
table.sort(leaderboard, function(a, b) return a.balance > b.balance end)
rednet.send(id, leaderboard, "otto")
end
end
end
-- Periodic chain sync loop
local function syncLoop()
while true do
os.sleep(SYNC_INTERVAL)
local changed = false
for pid, pdata in pairs(database) do
local chainBalance = syncFromChain(pid)
if chainBalance and chainBalance ~= pdata.balance then
print("[Sync] " .. pdata.name .. ": " .. pdata.balance .. " -> " .. chainBalance .. " AFC")
database[pid].balance = chainBalance
changed = true
end
end
if changed then
saveDatabase()
print("[Sync] Database updated from chain.")
end
end
end
-- Run both loops in parallel
print("Starting message handler + chain sync (every " .. SYNC_INTERVAL .. "s)...")
parallel.waitForAll(messageLoop, syncLoop)

View File

@@ -0,0 +1,3 @@
*.zip
*.db
data/

View File

@@ -0,0 +1,23 @@
FROM ubuntu:24.04
WORKDIR /usr/src/app
RUN apt-get update && \
apt-get install -y --no-install-recommends libsqlite3-0 && \
rm -rf /var/lib/apt/lists/*
COPY fusion /usr/local/bin/fusion
RUN chmod +x /usr/local/bin/fusion
COPY sql ./sql
COPY tdata ./tdata
COPY config.ini ./config.ini
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p data
EXPOSE 23000/tcp
EXPOSE 23001/tcp
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@@ -0,0 +1,32 @@
# OpenFusion Server Configuration (Docker)
verbosity=3
sandbox=false
[login]
port=23000
acceptallwheelnames=true
acceptallcustomnames=true
autocreateaccounts=true
authmethods=password
dbsaveinterval=240
[shard]
port=23001
ip=127.0.0.1
viewdistance=16000
timeout=60000
simulatemobs=true
motd=Bienvenido a Project Afterlife - FusionFall Academy
enabledpatches=1013
xdtdata=xdt1013.json
disablefirstuseflag=true
accountlevel=1
eventmode=0
dbpath=data/database.db
[monitor]
enabled=false
port=8003
listenip=0.0.0.0
interval=5000

View File

@@ -0,0 +1,21 @@
#!/bin/sh
set -e
CONFIG="/usr/src/app/config.ini"
# Override shard IP (the address clients connect to after login)
if [ -n "$SHARD_IP" ]; then
sed -i "s/^ip=.*/ip=$SHARD_IP/" "$CONFIG"
fi
# Override MOTD
if [ -n "$MOTD" ]; then
sed -i "s/^motd=.*/motd=$MOTD/" "$CONFIG"
fi
# Override account level
if [ -n "$ACCOUNT_LEVEL" ]; then
sed -i "s/^accountlevel=.*/accountlevel=$ACCOUNT_LEVEL/" "$CONFIG"
fi
exec /usr/local/bin/fusion

View File

@@ -0,0 +1,18 @@
BEGIN TRANSACTION;
-- New Columns
ALTER TABLE Accounts ADD BanReason TEXT DEFAULT '' NOT NULL;
ALTER TABLE RaceResults ADD RingCount INTEGER DEFAULT 0 NOT NULL;
ALTER TABLE RaceResults ADD Time INTEGER DEFAULT 0 NOT NULL;
-- Fix timestamps in Meta
INSERT INTO Meta (Key, Value) VALUES ('Created', 0);
INSERT INTO Meta (Key, Value) VALUES ('LastMigration', strftime('%s', 'now'));
UPDATE Meta SET Value = (SELECT Created FROM Meta WHERE Key = 'ProtocolVersion') Where Key = 'Created';
-- Get rid of 'Created' Column
CREATE TABLE Temp(Key TEXT NOT NULL UNIQUE, Value INTEGER NOT NULL);
INSERT INTO Temp SELECT Key, Value FROM Meta;
DROP TABLE Meta;
ALTER TABLE Temp RENAME TO Meta;
-- Update DB Version
UPDATE Meta SET Value = 2 WHERE Key = 'DatabaseVersion';
UPDATE Meta SET Value = strftime('%s', 'now') WHERE Key = 'LastMigration';
COMMIT;

View File

@@ -0,0 +1,37 @@
/*
It is recommended in the SQLite manual to turn off
foreign keys when making schema changes that involve them
*/
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- New table to store code items
CREATE TABLE RedeemedCodes(
PlayerID INTEGER NOT NULL,
Code TEXT NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, Code)
);
-- Change Coordinates in Players table to non-plural form
ALTER TABLE Players RENAME COLUMN XCoordinates TO XCoordinate;
ALTER TABLE Players RENAME COLUMN YCoordinates TO YCoordinate;
ALTER TABLE Players RENAME COLUMN ZCoordinates TO ZCoordinate;
-- Fix email attachments not being unique enough
CREATE TABLE Temp (
PlayerID INTEGER NOT NULL,
MsgIndex INTEGER NOT NULL,
Slot INTEGER NOT NULL,
ID INTEGER NOT NULL,
Type INTEGER NOT NULL,
Opt INTEGER NOT NULL,
TimeLimit INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, MsgIndex, Slot)
);
INSERT INTO Temp SELECT * FROM EmailItems;
DROP TABLE EmailItems;
ALTER TABLE Temp RENAME TO EmailItems;
-- Update DB Version
UPDATE Meta SET Value = 3 WHERE Key = 'DatabaseVersion';
UPDATE Meta SET Value = strftime('%s', 'now') WHERE Key = 'LastMigration';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,28 @@
/*
It is recommended in the SQLite manual to turn off
foreign keys when making schema changes that involve them
*/
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Change username column (Login) to be case-insensitive
CREATE TABLE Temp (
AccountID INTEGER NOT NULL,
Login TEXT NOT NULL UNIQUE COLLATE NOCASE,
Password TEXT NOT NULL,
Selected INTEGER DEFAULT 1 NOT NULL,
AccountLevel INTEGER NOT NULL,
Created INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
LastLogin INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
BannedUntil INTEGER DEFAULT 0 NOT NULL,
BannedSince INTEGER DEFAULT 0 NOT NULL,
BanReason TEXT DEFAULT '' NOT NULL,
PRIMARY KEY(AccountID AUTOINCREMENT)
);
INSERT INTO Temp SELECT * FROM Accounts;
DROP TABLE Accounts;
ALTER TABLE Temp RENAME TO Accounts;
-- Update DB Version
UPDATE Meta SET Value = 4 WHERE Key = 'DatabaseVersion';
UPDATE Meta SET Value = strftime('%s', 'now') WHERE Key = 'LastMigration';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,19 @@
/*
It is recommended in the SQLite manual to turn off
foreign keys when making schema changes that involve them
*/
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- New table to store auth cookies
CREATE TABLE Auth (
AccountID INTEGER NOT NULL,
Cookie TEXT NOT NULL,
Expires INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY(AccountID) REFERENCES Accounts(AccountID) ON DELETE CASCADE,
UNIQUE (AccountID)
);
-- Update DB Version
UPDATE Meta SET Value = 5 WHERE Key = 'DatabaseVersion';
UPDATE Meta SET Value = strftime('%s', 'now') WHERE Key = 'LastMigration';
COMMIT;
PRAGMA foreign_keys=ON;

Some files were not shown because too many files have changed in this diff Show More