Compare commits
15 Commits
a167c6643b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea142501fa | ||
|
|
a3bd1ac2e6 | ||
|
|
9436bb2faf | ||
|
|
d4d22e987b | ||
|
|
1cc3baf58b | ||
|
|
ad8fcae10c | ||
|
|
a76d513659 | ||
|
|
7dc1d2e0e5 | ||
|
|
eac2671529 | ||
|
|
14279a878c | ||
|
|
e65260c69b | ||
|
|
81e978947e | ||
|
|
e4404b209d | ||
|
|
aea2283d8f | ||
|
|
0df69b38d5 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
220
README.md
Normal 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.
|
||||
@@ -2,9 +2,12 @@ FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
"relation": "oneToMany",
|
||||
"target": "api::chapter.chapter",
|
||||
"mappedBy": "documentary"
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "datetime"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ WORKDIR /app/apps/web
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app/apps/web
|
||||
COPY --from=base /app/apps/web/.next ./.next
|
||||
COPY --from=base /app/apps/web/public ./public
|
||||
COPY --from=base /app/apps/web/package.json ./
|
||||
COPY --from=base /app/apps/web/node_modules ./node_modules
|
||||
COPY --from=base /app/node_modules /app/node_modules
|
||||
COPY --from=base /app/packages /app/packages
|
||||
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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
"@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",
|
||||
|
||||
33
apps/web/src/app/[locale]/afc/buy/failure/page.tsx
Normal file
33
apps/web/src/app/[locale]/afc/buy/failure/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/web/src/app/[locale]/afc/buy/page.tsx
Normal file
138
apps/web/src/app/[locale]/afc/buy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/app/[locale]/afc/buy/pending/page.tsx
Normal file
39
apps/web/src/app/[locale]/afc/buy/pending/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/app/[locale]/afc/buy/success/page.tsx
Normal file
39
apps/web/src/app/[locale]/afc/buy/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/app/[locale]/afc/history/page.tsx
Normal file
101
apps/web/src/app/[locale]/afc/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/app/[locale]/afc/page.tsx
Normal file
90
apps/web/src/app/[locale]/afc/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/app/[locale]/afc/redeem/page.tsx
Normal file
184
apps/web/src/app/[locale]/afc/redeem/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default async function GamePage({
|
||||
return (
|
||||
<>
|
||||
<GameHeader game={game} />
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<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} />
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
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 { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import "./globals.css";
|
||||
|
||||
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: {
|
||||
@@ -30,8 +48,11 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="bg-gray-950 text-white min-h-screen flex flex-col">
|
||||
<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}>
|
||||
<Navbar />
|
||||
<main className="flex-1 pt-16">{children}</main>
|
||||
|
||||
16
apps/web/src/app/api/afc/balance/route.ts
Normal file
16
apps/web/src/app/api/afc/balance/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
71
apps/web/src/app/api/afc/create-preference/route.ts
Normal file
71
apps/web/src/app/api/afc/create-preference/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
50
apps/web/src/app/api/afc/lib/bridge.ts
Normal file
50
apps/web/src/app/api/afc/lib/bridge.ts
Normal 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();
|
||||
}
|
||||
9
apps/web/src/app/api/afc/lib/mercadopago.ts
Normal file
9
apps/web/src/app/api/afc/lib/mercadopago.ts
Normal 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 };
|
||||
16
apps/web/src/app/api/afc/payments/route.ts
Normal file
16
apps/web/src/app/api/afc/payments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
49
apps/web/src/app/api/afc/redeem/route.ts
Normal file
49
apps/web/src/app/api/afc/redeem/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
apps/web/src/app/api/afc/redemptions/route.ts
Normal file
16
apps/web/src/app/api/afc/redemptions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
apps/web/src/app/api/afc/verify-disk/route.ts
Normal file
15
apps/web/src/app/api/afc/verify-disk/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
113
apps/web/src/app/api/afc/webhook/route.ts
Normal file
113
apps/web/src/app/api/afc/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
54
apps/web/src/app/globals.css
Normal file
54
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
9
apps/web/src/app/layout.tsx
Normal file
9
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return children;
|
||||
}
|
||||
13
apps/web/src/app/not-found.tsx
Normal file
13
apps/web/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/components/afc/AfcPackageCard.tsx
Normal file
49
apps/web/src/components/afc/AfcPackageCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/afc/BalanceDisplay.tsx
Normal file
36
apps/web/src/components/afc/BalanceDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/components/afc/DiskIdInput.tsx
Normal file
77
apps/web/src/components/afc/DiskIdInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/afc/PaymentHistoryTable.tsx
Normal file
52
apps/web/src/components/afc/PaymentHistoryTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/afc/PrizeCard.tsx
Normal file
41
apps/web/src/components/afc/PrizeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/components/afc/RedeemForm.tsx
Normal file
82
apps/web/src/components/afc/RedeemForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/afc/RedemptionHistoryTable.tsx
Normal file
52
apps/web/src/components/afc/RedemptionHistoryTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/afc/StatusBadge.tsx
Normal file
26
apps/web/src/components/afc/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import type { Chapter } from "@afterlife/shared";
|
||||
import { formatTextToHtml } from "@/lib/format";
|
||||
|
||||
interface ChapterContentProps {
|
||||
chapter: Chapter;
|
||||
@@ -7,9 +8,22 @@ interface ChapterContentProps {
|
||||
|
||||
export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
return (
|
||||
<article className="max-w-3xl">
|
||||
<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-8">
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
|
||||
<Image
|
||||
src={chapter.coverImage.url}
|
||||
alt={chapter.coverImage.alternativeText || chapter.title}
|
||||
@@ -18,10 +32,10 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold mb-6">{chapter.title}</h2>
|
||||
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: chapter.content }}
|
||||
className="chapter-prose"
|
||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -9,27 +9,39 @@ interface ChapterNavProps {
|
||||
onSelectChapter: (id: number, index: number) => void;
|
||||
}
|
||||
|
||||
export function ChapterNav({ chapters, activeChapterId, onSelectChapter }: ChapterNavProps) {
|
||||
export function ChapterNav({
|
||||
chapters,
|
||||
activeChapterId,
|
||||
onSelectChapter,
|
||||
}: ChapterNavProps) {
|
||||
const t = useTranslations("documentary");
|
||||
|
||||
return (
|
||||
<nav className="w-64 flex-shrink-0 hidden lg:block">
|
||||
<div className="sticky top-20">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
||||
<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-1">
|
||||
<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 rounded-lg text-sm transition-colors ${
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
chapter.id === activeChapterId
|
||||
? "bg-blue-600/20 text-blue-400 font-medium"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
? "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 text-gray-600 mr-2">{index + 1}.</span>
|
||||
<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>
|
||||
|
||||
@@ -37,6 +37,7 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
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);
|
||||
@@ -47,13 +48,28 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 flex gap-8">
|
||||
|
||||
{/* 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 pb-24">
|
||||
<div className="flex-1 min-w-0 pb-24">
|
||||
<ChapterContent chapter={activeChapter} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +83,9 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
onToggle={audio.toggle}
|
||||
onSeek={audio.seek}
|
||||
onChangeRate={audio.changeRate}
|
||||
onToggleContinuous={() => audio.setContinuousMode(!audio.continuousMode)}
|
||||
onToggleContinuous={() =>
|
||||
audio.setContinuousMode(!audio.continuousMode)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,10 @@ export function GameHeader({ game }: GameHeaderProps) {
|
||||
)}
|
||||
<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-2">{game.title}</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -17,36 +18,46 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="md:col-span-2">
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: game.description }}
|
||||
className="prose-editorial"
|
||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(game.description) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
|
||||
<dl className="space-y-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("developer")}</dt>
|
||||
<dd className="text-white font-medium">{game.developer}</dd>
|
||||
<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>
|
||||
<dt className="text-gray-500">{t("publisher")}</dt>
|
||||
<dd className="text-white font-medium">{game.publisher}</dd>
|
||||
<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>
|
||||
<dt className="text-gray-500">{t("released")}</dt>
|
||||
<dd className="text-white font-medium">{game.releaseYear}</dd>
|
||||
<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>
|
||||
<dt className="text-gray-500">{t("shutdown")}</dt>
|
||||
<dd className="text-white font-medium">{game.shutdownYear}</dd>
|
||||
<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>
|
||||
<dt className="text-gray-500">{t("server_status")}</dt>
|
||||
<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>
|
||||
@@ -58,7 +69,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
href={game.serverLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
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>
|
||||
@@ -66,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
{game.documentary && (
|
||||
<Link
|
||||
href={`/${locale}/games/${game.slug}/documentary`}
|
||||
className="block w-full text-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@ export function Navbar() {
|
||||
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") },
|
||||
];
|
||||
|
||||
82
apps/web/src/hooks/useDiskId.ts
Normal file
82
apps/web/src/hooks/useDiskId.ts
Normal 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
88
apps/web/src/lib/afc.ts
Normal 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;
|
||||
}
|
||||
@@ -12,8 +12,9 @@ export async function getGames(locale: string): Promise<StrapiListResponse<Game>
|
||||
path: "/games",
|
||||
locale,
|
||||
params: {
|
||||
"populate[coverImage]": "*",
|
||||
"populate[documentary]": "*",
|
||||
"populate[coverImage][fields][0]": "url",
|
||||
"populate[coverImage][fields][1]": "alternativeText",
|
||||
"populate[documentary][fields][0]": "title",
|
||||
"sort": "createdAt:desc",
|
||||
},
|
||||
});
|
||||
@@ -25,9 +26,19 @@ export async function getGameBySlug(slug: string, locale: string): Promise<Strap
|
||||
locale,
|
||||
params: {
|
||||
"filters[slug][$eq]": slug,
|
||||
"populate[coverImage]": "*",
|
||||
"populate[screenshots]": "*",
|
||||
"populate[documentary][populate][chapters][populate]": "*",
|
||||
"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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -49,8 +60,13 @@ export async function getChapter(
|
||||
path: `/chapters/${chapterId}`,
|
||||
locale,
|
||||
params: {
|
||||
"populate[audioFile]": "*",
|
||||
"populate[coverImage]": "*",
|
||||
"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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
apps/web/src/lib/format.ts
Normal file
18
apps/web/src/lib/format.ts
Normal 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("");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
12
blockchain/Dockerfile
Normal 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"]
|
||||
183
blockchain/contracts/AfterCoin.sol
Normal file
183
blockchain/contracts/AfterCoin.sol
Normal 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
27
blockchain/genesis.json
Normal 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
50
blockchain/init-geth.sh
Executable 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
|
||||
@@ -17,3 +17,23 @@ 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
|
||||
|
||||
243
docker/docker-compose.dev.yml
Normal file
243
docker/docker-compose.dev.yml
Normal 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
|
||||
120
docker/docker-compose.maple2.yml
Normal file
120
docker/docker-compose.maple2.yml
Normal 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:
|
||||
@@ -28,6 +28,16 @@ http {
|
||||
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;
|
||||
|
||||
32
docker/nginx/rpc-ssl.conf
Normal file
32
docker/nginx/rpc-ssl.conf
Normal 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
483
docs/aftercoin.md
Normal 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
161
docs/architecture.md
Normal 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
145
docs/cms-content.md
Normal 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
258
docs/deployment.md
Normal 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
395
docs/game-servers.md
Normal 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/
|
||||
```
|
||||
633
docs/social-media-posts.html
Normal file
633
docs/social-media-posts.html
Normal 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 — FusionFall & 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 — 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 · 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 — 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 — the worlds, the communities, the stories. Project Afterlife aims to change that in two ways:</p>
|
||||
<ol>
|
||||
<li><strong>Private server restoration</strong> — We reverse-engineer and rebuild game servers so people can play these titles again.</li>
|
||||
<li><strong>Interactive documentaries</strong> — 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 — all narrated by human voice actors in audiobook format.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Our first two games:</h3>
|
||||
|
||||
<div class="game-highlight">
|
||||
<strong>FusionFall</strong> — 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 — 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> — 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 — 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 — or if you've ever lost an online game you loved — 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 · 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 — 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 — free to play.</li>
|
||||
<li><strong>Document their history</strong> through interactive web documentaries — 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> — 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> — MMO street racing with RPG elements in a futuristic open world. Closed in 2016. The drifting, the tuning, the neon city — 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 — 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 — 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 · 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 — 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> — 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 — 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 — 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 — Español</h2>
|
||||
<span>Versió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ñol</span>
|
||||
<span class="tag long">Largo</span>
|
||||
</div>
|
||||
<div class="subreddit-list">r/espanol · 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 — una iniciativa de preservación de juegos online. Primeros tí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>¿Qué 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ón de servidores privados</strong> — Reconstruimos los servidores del juego para que la gente pueda volver a jugar.</li>
|
||||
<li><strong>Documentales interactivos</strong> — Para cada juego restaurado, creamos un documental por capítulos en nuestra web: la historia del juego, su auge y caída, aná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> — El ambicioso MMO de Cartoon Network que nos sumergía en un mundo post-apocalíptico donde Dexter, Ben 10 y los Chicos del Barrio luchaban juntos contra una invasión alienígena. Era único: un MMO real construido alrededor de los personajes que nos marcaron de niños. Cuando cerró en 2013, toda una generación perdió un mundo en el que creció.
|
||||
</div>
|
||||
|
||||
<div class="game-highlight drift">
|
||||
<strong>Drift City</strong> — Un juego de carreras MMO ambientado en una ciudad futurista de mundo abierto. Parte carreras, parte RPG, parte exploración. Tunear coches, misiones, y derrapar por calles iluminadas con neón. Cerró en 2016 dejando atrás una comunidad que aún lo recuerda.
|
||||
</div>
|
||||
|
||||
<p>Ambos tendrán servidores privados restaurados y su propio documental interactivo — capítulos narrados cubriendo su creación, era dorada, declive, cierre y resurrección.</p>
|
||||
|
||||
<h3>Financiació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á construyendo en nuestra propia infraestructura. Trabajamos en la restauración de servidores y el contenido documental de FusionFall y Drift City simultáneamente.</p>
|
||||
<p>Si jugaste a alguno de estos juegos — o si alguna vez perdiste un juego online que amabas — agradecemos tu feedback y apoyo.</p>
|
||||
<p>Má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 — 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 — 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 — 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 — 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 — Españ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ñ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ían má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 — 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ñol</span>
|
||||
<span class="tag short">Post 2 — 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 — luchando juntos contra alienígenas en un mundo abierto real. Cerrado en 2013.</p>
|
||||
<p>Drift City: Carreras MMO en una ciudad futurista iluminada con neón. Progresió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ñol + inglés.</p>
|
||||
<p>Pronto má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(/—/g, '—')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/&[a-z]+;/g, (m) => {
|
||||
const map = {
|
||||
'¿': '?', 'ñ': 'n', 'á': 'a',
|
||||
'é': 'e', 'í': 'i', 'ó': 'o',
|
||||
'ú': 'u', 'Á': 'A', 'É': 'E',
|
||||
'Í': 'I', 'Ó': 'O', 'Ú': 'U',
|
||||
'·': '·'
|
||||
};
|
||||
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(/—/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(/—/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(/—/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>
|
||||
4395
package-lock.json
generated
4395
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
servers/dolphin-traversal/.dockerignore
Normal file
2
servers/dolphin-traversal/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*.md
|
||||
.git
|
||||
51
servers/dolphin-traversal/Dockerfile
Normal file
51
servers/dolphin-traversal/Dockerfile
Normal 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"]
|
||||
96
servers/minecraft/lua-scripts/cardgen_startup.lua
Normal file
96
servers/minecraft/lua-scripts/cardgen_startup.lua
Normal 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
|
||||
164
servers/minecraft/lua-scripts/mainframe_startup.lua
Normal file
164
servers/minecraft/lua-scripts/mainframe_startup.lua
Normal 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)
|
||||
3
servers/openfusion/.dockerignore
Normal file
3
servers/openfusion/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
*.zip
|
||||
*.db
|
||||
data/
|
||||
23
servers/openfusion/Dockerfile
Normal file
23
servers/openfusion/Dockerfile
Normal 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"]
|
||||
32
servers/openfusion/config.ini
Normal file
32
servers/openfusion/config.ini
Normal 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
|
||||
21
servers/openfusion/docker-entrypoint.sh
Normal file
21
servers/openfusion/docker-entrypoint.sh
Normal 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
|
||||
18
servers/openfusion/sql/migration1.sql
Normal file
18
servers/openfusion/sql/migration1.sql
Normal 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;
|
||||
37
servers/openfusion/sql/migration2.sql
Normal file
37
servers/openfusion/sql/migration2.sql
Normal 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;
|
||||
28
servers/openfusion/sql/migration3.sql
Normal file
28
servers/openfusion/sql/migration3.sql
Normal 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;
|
||||
19
servers/openfusion/sql/migration4.sql
Normal file
19
servers/openfusion/sql/migration4.sql
Normal 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;
|
||||
8
servers/openfusion/sql/migration5.sql
Normal file
8
servers/openfusion/sql/migration5.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
BEGIN TRANSACTION;
|
||||
-- New Columns
|
||||
ALTER TABLE Accounts ADD Email TEXT DEFAULT '' NOT NULL;
|
||||
ALTER TABLE Accounts ADD LastPasswordReset INTEGER DEFAULT 0 NOT NULL;
|
||||
-- Update DB Version
|
||||
UPDATE Meta SET Value = 6 WHERE Key = 'DatabaseVersion';
|
||||
UPDATE Meta SET Value = strftime('%s', 'now') WHERE Key = 'LastMigration';
|
||||
COMMIT;
|
||||
171
servers/openfusion/sql/tables.sql
Normal file
171
servers/openfusion/sql/tables.sql
Normal file
@@ -0,0 +1,171 @@
|
||||
CREATE TABLE IF NOT EXISTS Accounts (
|
||||
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,
|
||||
Email TEXT DEFAULT '' NOT NULL,
|
||||
LastPasswordReset INTEGER DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY(AccountID AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Players (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
AccountID INTEGER NOT NULL,
|
||||
FirstName TEXT NOT NULL COLLATE NOCASE,
|
||||
LastName TEXT NOT NULL COLLATE NOCASE,
|
||||
NameCheck INTEGER NOT NULL,
|
||||
Slot INTEGER NOT NULL,
|
||||
Created INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
||||
LastLogin INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
||||
Level INTEGER DEFAULT 1 NOT NULL,
|
||||
Nano1 INTEGER DEFAULT 0 NOT NULL,
|
||||
Nano2 INTEGER DEFAULT 0 NOT NULL,
|
||||
Nano3 INTEGER DEFAULT 0 NOT NULL,
|
||||
AppearanceFlag INTEGER DEFAULT 0 NOT NULL,
|
||||
TutorialFlag INTEGER DEFAULT 0 NOT NULL,
|
||||
PayZoneFlag INTEGER DEFAULT 0 NOT NULL,
|
||||
XCoordinate INTEGER NOT NULL,
|
||||
YCoordinate INTEGER NOT NULL,
|
||||
ZCoordinate INTEGER NOT NULL,
|
||||
Angle INTEGER NOT NULL,
|
||||
HP INTEGER NOT NULL,
|
||||
FusionMatter INTEGER DEFAULT 0 NOT NULL,
|
||||
Taros INTEGER DEFAULT 0 NOT NULL,
|
||||
BatteryW INTEGER DEFAULT 0 NOT NULL,
|
||||
BatteryN INTEGER DEFAULT 0 NOT NULL,
|
||||
Mentor INTEGER DEFAULT 5 NOT NULL,
|
||||
CurrentMissionID INTEGER DEFAULT 0 NOT NULL,
|
||||
WarpLocationFlag INTEGER DEFAULT 0 NOT NULL,
|
||||
SkywayLocationFlag BLOB NOT NULL,
|
||||
FirstUseFlag BLOB NOT NULL,
|
||||
Quests BLOB NOT NULL,
|
||||
PRIMARY KEY(PlayerID AUTOINCREMENT),
|
||||
FOREIGN KEY(AccountID) REFERENCES Accounts(AccountID) ON DELETE CASCADE,
|
||||
UNIQUE (AccountID, Slot),
|
||||
UNIQUE (FirstName, LastName)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Appearances (
|
||||
PlayerID INTEGER UNIQUE NOT NULL,
|
||||
Body INTEGER DEFAULT 0 NOT NULL,
|
||||
EyeColor INTEGER DEFAULT 1 NOT NULL,
|
||||
FaceStyle INTEGER DEFAULT 1 NOT NULL,
|
||||
Gender INTEGER DEFAULT 1 NOT NULL,
|
||||
HairColor INTEGER DEFAULT 1 NOT NULL,
|
||||
HairStyle INTEGER DEFAULT 1 NOT NULL,
|
||||
Height INTEGER DEFAULT 0 NOT NULL,
|
||||
SkinColor INTEGER DEFAULT 1 NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Inventory (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
Slot INTEGER NOT NULL,
|
||||
ID INTEGER NOT NULL,
|
||||
Type INTEGER NOT NULL,
|
||||
Opt INTEGER NOT NULL,
|
||||
TimeLimit INTEGER DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
UNIQUE (PlayerID, Slot)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS QuestItems (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
Slot INTEGER NOT NULL,
|
||||
ID INTEGER NOT NULL,
|
||||
Opt INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
UNIQUE (PlayerID, Slot)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Nanos (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
ID INTEGER NOT NULL,
|
||||
Skill INTEGER NOT NULL,
|
||||
Stamina INTEGER DEFAULT 150 NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
UNIQUE (PlayerID, ID)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS RunningQuests (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
TaskID INTEGER NOT NULL,
|
||||
RemainingNPCCount1 INTEGER NOT NULL,
|
||||
RemainingNPCCount2 INTEGER NOT NULL,
|
||||
RemainingNPCCount3 INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Buddyships (
|
||||
PlayerAID INTEGER NOT NULL,
|
||||
PlayerBID INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerAID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(PlayerBID) REFERENCES Players(PlayerID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Blocks (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
BlockedPlayerID INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(BlockedPlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS EmailData (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
MsgIndex INTEGER NOT NULL,
|
||||
ReadFlag INTEGER NOT NULL,
|
||||
ItemFlag INTEGER NOT NULL,
|
||||
SenderID INTEGER NOT NULL,
|
||||
SenderFirstName TEXT NOT NULL COLLATE NOCASE,
|
||||
SenderLastName TEXT NOT NULL COLLATE NOCASE,
|
||||
SubjectLine TEXT NOT NULL,
|
||||
MsgBody TEXT NOT NULL,
|
||||
Taros INTEGER NOT NULL,
|
||||
SendTime INTEGER NOT NULL,
|
||||
DeleteTime INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
UNIQUE(PlayerID, MsgIndex)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS EmailItems (
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS RaceResults (
|
||||
EPID INTEGER NOT NULL,
|
||||
PlayerID INTEGER NOT NULL,
|
||||
Score INTEGER NOT NULL,
|
||||
RingCount INTEGER NOT NULL,
|
||||
Time INTEGER NOT NULL,
|
||||
Timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS RedeemedCodes (
|
||||
PlayerID INTEGER NOT NULL,
|
||||
Code TEXT NOT NULL,
|
||||
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
|
||||
UNIQUE (PlayerID, Code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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)
|
||||
);
|
||||
2
servers/sm64coopdx/.dockerignore
Normal file
2
servers/sm64coopdx/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*.md
|
||||
.git
|
||||
4
servers/sm64coopdx/.gitignore
vendored
Normal file
4
servers/sm64coopdx/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# SM64 ROM — copyrighted, not distributable
|
||||
*.z64
|
||||
*.n64
|
||||
*.v64
|
||||
57
servers/sm64coopdx/Dockerfile
Normal file
57
servers/sm64coopdx/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# sm64coopdx headless dedicated server
|
||||
# Requires baserom.us.z64 (SM64 US ROM) in build context
|
||||
|
||||
# --- Build stage ---
|
||||
FROM ubuntu:jammy AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
binutils-mips-linux-gnu \
|
||||
bsdmainutils \
|
||||
build-essential \
|
||||
libcapstone-dev \
|
||||
pkgconf \
|
||||
python3 \
|
||||
libz-dev \
|
||||
libcurl4-openssl-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /sm64
|
||||
|
||||
RUN git clone --depth 1 https://github.com/coop-deluxe/sm64coopdx.git .
|
||||
|
||||
COPY baserom.us.z64 .
|
||||
|
||||
# Patch: add missing float.h include (GCC 11 strictness)
|
||||
RUN sed -i '1i #include <float.h>' src/engine/math_util.c
|
||||
|
||||
# Patch: fix platform.c fallback — upstream renamed functions but forgot the #else branch
|
||||
RUN sed -i 's/const char \*sys_exe_path(void) {/const char *sys_exe_path_dir(void) {\n return ".";\n}\n\nconst char *sys_resource_path(void) {\n return ".";\n}\n\nconst char *sys_exe_path(void) {/' src/pc/platform.c
|
||||
|
||||
RUN make HEADLESS=1 DISCORD_SDK=0 COOPNET=0 -j$(nproc)
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM ubuntu:jammy
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libcurl4 \
|
||||
libz1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash sm64
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
COPY --from=build /sm64/build/us_pc/ .
|
||||
COPY --from=build /sm64/baserom.us.z64 .
|
||||
|
||||
RUN mkdir -p /server/mods /server/save && chown -R sm64:sm64 /server
|
||||
|
||||
USER sm64
|
||||
|
||||
EXPOSE 7777/udp
|
||||
|
||||
ENV SM64_PORT=7777
|
||||
ENV SM64_PLAYERS=16
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "./sm64coopdx --server ${SM64_PORT} --headless --playercount ${SM64_PLAYERS} --skip-intro"]
|
||||
14
services/afc-bridge/Dockerfile
Normal file
14
services/afc-bridge/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY contracts/ ./contracts/
|
||||
COPY public/ ./public/
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
357
services/afc-bridge/contracts/AfterCoin.json
Normal file
357
services/afc-bridge/contracts/AfterCoin.json
Normal file
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"contractName": "AfterCoin",
|
||||
"abi": [
|
||||
{
|
||||
"inputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "constructor"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "required",
|
||||
"type": "uint256"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "available",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "InsufficientAllowance",
|
||||
"type": "error"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "account",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "required",
|
||||
"type": "uint256"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "available",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "InsufficientBalance",
|
||||
"type": "error"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "NotOwner",
|
||||
"type": "error"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "ZeroAddress",
|
||||
"type": "error"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "owner",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": false,
|
||||
"internalType": "uint256",
|
||||
"name": "value",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "Approval",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": true,
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"indexed": false,
|
||||
"internalType": "uint256",
|
||||
"name": "value",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "Transfer",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "tokenOwner",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
}
|
||||
],
|
||||
"name": "allowance",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "spender",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "approve",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "account",
|
||||
"type": "address"
|
||||
}
|
||||
],
|
||||
"name": "balanceOf",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "bridgeTransfer",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "burnFrom",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint8",
|
||||
"name": "",
|
||||
"type": "uint8"
|
||||
}
|
||||
],
|
||||
"stateMutability": "pure",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "mint",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "name",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "string",
|
||||
"name": "",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "pure",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "owner",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "",
|
||||
"type": "address"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "symbol",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "string",
|
||||
"name": "",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "pure",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "totalSupply",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "transfer",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "from",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "address",
|
||||
"name": "to",
|
||||
"type": "address"
|
||||
},
|
||||
{
|
||||
"internalType": "uint256",
|
||||
"name": "amount",
|
||||
"type": "uint256"
|
||||
}
|
||||
],
|
||||
"name": "transferFrom",
|
||||
"outputs": [
|
||||
{
|
||||
"internalType": "bool",
|
||||
"name": "",
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"bytecode": "0x6080604052348015600f57600080fd5b50600380546001600160a01b031916331790556108ac806100316000396000f3fe608060405234801561001057600080fd5b50600436106100cf5760003560e01c8063671afbce1161008c5780638da5cb5b116100665780638da5cb5b146101c657806395d89b41146101f1578063a9059cbb14610210578063dd62ed3e1461022357600080fd5b8063671afbce1461017757806370a082311461018a57806379cc6790146101b357600080fd5b806306fdde03146100d4578063095ea7b31461010b57806318160ddd1461012e57806323b872dd14610140578063313ce5671461015357806340c10f1914610162575b600080fd5b60408051808201909152600981526820b33a32b921b7b4b760b91b60208201525b6040516101029190610714565b60405180910390f35b61011e61011936600461077e565b61025c565b6040519015158152602001610102565b6000545b604051908152602001610102565b61011e61014e3660046107a8565b610273565b60405160008152602001610102565b61017561017036600461077e565b6102f9565b005b6101756101853660046107a8565b6103d3565b6101326101983660046107e5565b6001600160a01b031660009081526001602052604090205490565b6101756101c136600461077e565b61040e565b6003546101d9906001600160a01b031681565b6040516001600160a01b039091168152602001610102565b60408051808201909152600381526241464360e81b60208201526100f5565b61011e61021e36600461077e565b61052e565b610132610231366004610807565b6001600160a01b03918216600090815260026020908152604080832093909416825291909152205490565b600061026933848461053b565b5060015b92915050565b6001600160a01b038316600090815260026020908152604080832033845290915281205460001981146102e357828110156102d657604051630c95cf2760e11b815233600482015260248101849052604481018290526064015b60405180910390fd5b6102e3853385840361053b565b6102ee8585856105e2565b506001949350505050565b6003546001600160a01b03163314610324576040516330cd747160e01b815260040160405180910390fd5b6001600160a01b03821661034b5760405163d92e233d60e01b815260040160405180910390fd5b8060008082825461035c9190610850565b90915550506001600160a01b03821660009081526001602052604081208054839290610389908490610850565b90915550506040518181526001600160a01b038316906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b6003546001600160a01b031633146103fe576040516330cd747160e01b815260040160405180910390fd5b6104098383836105e2565b505050565b6003546001600160a01b03163314610439576040516330cd747160e01b815260040160405180910390fd5b6001600160a01b0382166104605760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b038216600090815260016020526040902054818110156104b35760405163db42144d60e01b81526001600160a01b038416600482015260248101839052604481018290526064016102cd565b6001600160a01b03831660009081526001602052604081208383039055805483919081906104e2908490610863565b90915550506040518281526000906001600160a01b038516907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef906020015b60405180910390a3505050565b60006102693384846105e2565b6001600160a01b0383166105625760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0382166105895760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0383811660008181526002602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259101610521565b6001600160a01b0383166106095760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b0382166106305760405163d92e233d60e01b815260040160405180910390fd5b6001600160a01b038316600090815260016020526040902054818110156106835760405163db42144d60e01b81526001600160a01b038516600482015260248101839052604481018290526064016102cd565b6001600160a01b038085166000908152600160205260408082208585039055918516815290812080548492906106ba908490610850565b92505081905550826001600160a01b0316846001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161070691815260200190565b60405180910390a350505050565b602081526000825180602084015260005b818110156107425760208186018101516040868401015201610725565b506000604082850101526040601f19601f83011684010191505092915050565b80356001600160a01b038116811461077957600080fd5b919050565b6000806040838503121561079157600080fd5b61079a83610762565b946020939093013593505050565b6000806000606084860312156107bd57600080fd5b6107c684610762565b92506107d460208501610762565b929592945050506040919091013590565b6000602082840312156107f757600080fd5b61080082610762565b9392505050565b6000806040838503121561081a57600080fd5b61082383610762565b915061083160208401610762565b90509250929050565b634e487b7160e01b600052601160045260246000fd5b8082018082111561026d5761026d61083a565b8181038181111561026d5761026d61083a56fea2646970667358221220385d4e7aa6f29433bdf52f6b52498c4501151f35e2ecf304acc5bb0007e5327264736f6c63430008220033"
|
||||
}
|
||||
1358
services/afc-bridge/package-lock.json
generated
Normal file
1358
services/afc-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
services/afc-bridge/package.json
Normal file
15
services/afc-bridge/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "afc-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "AfterCoin bridge API - syncs Minecraft casino balances to private Ethereum chain",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"deploy": "node src/deploy.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ethers": "^6.13.0",
|
||||
"better-sqlite3": "^11.0.0"
|
||||
}
|
||||
}
|
||||
158
services/afc-bridge/public/afc-icon.svg
Normal file
158
services/afc-bridge/public/afc-icon.svg
Normal file
@@ -0,0 +1,158 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<!-- Main gradient - dark teal to cyan -->
|
||||
<linearGradient id="coinGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0D1B2A"/>
|
||||
<stop offset="50%" stop-color="#1B2D45"/>
|
||||
<stop offset="100%" stop-color="#0D1B2A"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Rim gradient -->
|
||||
<linearGradient id="rimGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5CC"/>
|
||||
<stop offset="25%" stop-color="#00BFA6"/>
|
||||
<stop offset="50%" stop-color="#00E5CC"/>
|
||||
<stop offset="75%" stop-color="#00BFA6"/>
|
||||
<stop offset="100%" stop-color="#00E5CC"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Inner glow gradient -->
|
||||
<radialGradient id="innerGlow" cx="50%" cy="45%" r="50%">
|
||||
<stop offset="0%" stop-color="#00E5CC" stop-opacity="0.15"/>
|
||||
<stop offset="100%" stop-color="#0D1B2A" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Phoenix flame gradient -->
|
||||
<linearGradient id="phoenixGrad" x1="50%" y1="100%" x2="50%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00BFA6"/>
|
||||
<stop offset="40%" stop-color="#00E5CC"/>
|
||||
<stop offset="70%" stop-color="#4DFFD2"/>
|
||||
<stop offset="100%" stop-color="#AAFFF0"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Subtle glow filter -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
|
||||
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Drop shadow for depth -->
|
||||
<filter id="coinShadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle shadow -->
|
||||
<circle cx="256" cy="260" r="230" fill="#000" opacity="0.3" filter="url(#coinShadow)"/>
|
||||
|
||||
<!-- Outer coin body -->
|
||||
<circle cx="256" cy="256" r="230" fill="url(#coinGrad)" stroke="url(#rimGrad)" stroke-width="6"/>
|
||||
|
||||
<!-- Inner rim -->
|
||||
<circle cx="256" cy="256" r="210" fill="none" stroke="#00E5CC" stroke-width="1.5" opacity="0.5"/>
|
||||
<circle cx="256" cy="256" r="205" fill="none" stroke="#00E5CC" stroke-width="0.5" opacity="0.25"/>
|
||||
|
||||
<!-- Inner glow -->
|
||||
<circle cx="256" cy="256" r="210" fill="url(#innerGlow)"/>
|
||||
|
||||
<!-- Decorative ring with dashes -->
|
||||
<circle cx="256" cy="256" r="195" fill="none" stroke="#00E5CC" stroke-width="1" stroke-dasharray="3 8" opacity="0.35"/>
|
||||
|
||||
<!-- Central phoenix/afterlife symbol - stylized rising flame/phoenix wing -->
|
||||
<g transform="translate(256,256)" filter="url(#softGlow)">
|
||||
<!-- Central rising flame / phoenix shape -->
|
||||
<path d="
|
||||
M 0,-95
|
||||
C 15,-80 25,-55 20,-35
|
||||
C 18,-20 8,-5 0,10
|
||||
C -8,-5 -18,-20 -20,-35
|
||||
C -25,-55 -15,-80 0,-95
|
||||
Z
|
||||
" fill="url(#phoenixGrad)" opacity="0.9"/>
|
||||
|
||||
<!-- Left wing -->
|
||||
<path d="
|
||||
M -5,0
|
||||
C -20,-15 -50,-40 -65,-55
|
||||
C -75,-65 -80,-60 -75,-48
|
||||
C -68,-35 -45,-15 -25,5
|
||||
C -15,15 -8,12 -5,0
|
||||
Z
|
||||
" fill="url(#phoenixGrad)" opacity="0.75"/>
|
||||
|
||||
<!-- Right wing -->
|
||||
<path d="
|
||||
M 5,0
|
||||
C 20,-15 50,-40 65,-55
|
||||
C 75,-65 80,-60 75,-48
|
||||
C 68,-35 45,-15 25,5
|
||||
C 15,15 8,12 5,0
|
||||
Z
|
||||
" fill="url(#phoenixGrad)" opacity="0.75"/>
|
||||
|
||||
<!-- Left outer wing -->
|
||||
<path d="
|
||||
M -25,5
|
||||
C -40,-5 -70,-25 -88,-32
|
||||
C -98,-36 -100,-30 -92,-22
|
||||
C -82,-12 -55,5 -35,18
|
||||
C -25,25 -18,18 -25,5
|
||||
Z
|
||||
" fill="#00E5CC" opacity="0.45"/>
|
||||
|
||||
<!-- Right outer wing -->
|
||||
<path d="
|
||||
M 25,5
|
||||
C 40,-5 70,-25 88,-32
|
||||
C 98,-36 100,-30 92,-22
|
||||
C 82,-12 55,5 35,18
|
||||
C 25,25 18,18 25,5
|
||||
Z
|
||||
" fill="#00E5CC" opacity="0.45"/>
|
||||
|
||||
<!-- Small flame tip accent -->
|
||||
<path d="
|
||||
M 0,-95
|
||||
C 5,-105 3,-115 0,-120
|
||||
C -3,-115 -5,-105 0,-95
|
||||
Z
|
||||
" fill="#AAFFF0" opacity="0.8"/>
|
||||
</g>
|
||||
|
||||
<!-- "AFC" text -->
|
||||
<text x="256" y="330"
|
||||
font-family="'Orbitron', 'Rajdhani', 'Exo 2', 'Segoe UI', sans-serif"
|
||||
font-size="52"
|
||||
font-weight="700"
|
||||
fill="#00E5CC"
|
||||
text-anchor="middle"
|
||||
letter-spacing="14"
|
||||
filter="url(#glow)">AFC</text>
|
||||
|
||||
<!-- "AFTERCOIN" subtitle -->
|
||||
<text x="256" y="360"
|
||||
font-family="'Orbitron', 'Rajdhani', 'Exo 2', 'Segoe UI', sans-serif"
|
||||
font-size="14"
|
||||
font-weight="400"
|
||||
fill="#00BFA6"
|
||||
text-anchor="middle"
|
||||
letter-spacing="8"
|
||||
opacity="0.7">AFTERCOIN</text>
|
||||
|
||||
<!-- Top decorative dots -->
|
||||
<circle cx="206" cy="385" r="2" fill="#00E5CC" opacity="0.4"/>
|
||||
<circle cx="256" cy="390" r="2" fill="#00E5CC" opacity="0.4"/>
|
||||
<circle cx="306" cy="385" r="2" fill="#00E5CC" opacity="0.4"/>
|
||||
|
||||
<!-- Subtle circuit-like lines at bottom -->
|
||||
<line x1="220" y1="400" x2="292" y2="400" stroke="#00E5CC" stroke-width="0.5" opacity="0.2"/>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
23
services/afc-bridge/src/config.js
Normal file
23
services/afc-bridge/src/config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
if (!process.env.ADMIN_PRIVATE_KEY) {
|
||||
throw new Error("ADMIN_PRIVATE_KEY environment variable is required");
|
||||
}
|
||||
|
||||
if (!process.env.BRIDGE_SECRET) {
|
||||
throw new Error("BRIDGE_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
// Normalize private key to have 0x prefix for ethers.js
|
||||
const rawKey = process.env.ADMIN_PRIVATE_KEY;
|
||||
const normalizedKey = rawKey.startsWith("0x") ? rawKey : "0x" + rawKey;
|
||||
|
||||
const config = {
|
||||
GETH_RPC_URL: process.env.GETH_RPC_URL || "http://geth:8545",
|
||||
ADMIN_PRIVATE_KEY: normalizedKey,
|
||||
AFC_CONTRACT_ADDRESS: process.env.AFC_CONTRACT_ADDRESS || "",
|
||||
BRIDGE_SECRET: process.env.BRIDGE_SECRET,
|
||||
PORT: parseInt(process.env.PORT) || 3001,
|
||||
DB_PATH: process.env.DB_PATH || "/data/bridge.db",
|
||||
GAS_FUND_AMOUNT: process.env.GAS_FUND_AMOUNT || "0.01",
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
181
services/afc-bridge/src/db.js
Normal file
181
services/afc-bridge/src/db.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const Database = require("better-sqlite3");
|
||||
const config = require("./config");
|
||||
|
||||
const db = new Database(config.DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
// Create tables if they don't exist
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
disk_id TEXT PRIMARY KEY,
|
||||
address TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contract_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
disk_id TEXT NOT NULL,
|
||||
amount_afc INTEGER NOT NULL,
|
||||
amount_mxn REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
mp_preference_id TEXT,
|
||||
mp_payment_id TEXT,
|
||||
tx_hash TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redemptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
disk_id TEXT NOT NULL,
|
||||
amount_afc INTEGER NOT NULL,
|
||||
prize_type TEXT NOT NULL,
|
||||
prize_detail TEXT NOT NULL,
|
||||
delivery_info TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
burn_tx_hash TEXT,
|
||||
admin_notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
function getWallet(diskId) {
|
||||
const stmt = db.prepare("SELECT * FROM wallets WHERE disk_id = ?");
|
||||
return stmt.get(diskId) || null;
|
||||
}
|
||||
|
||||
function createWallet(diskId, address, privateKey, name) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO wallets (disk_id, address, private_key, name) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
return stmt.run(diskId, address, privateKey, name);
|
||||
}
|
||||
|
||||
function getContractAddress() {
|
||||
const stmt = db.prepare(
|
||||
"SELECT value FROM contract_state WHERE key = 'contract_address'"
|
||||
);
|
||||
const row = stmt.get();
|
||||
return row ? row.value : null;
|
||||
}
|
||||
|
||||
function setContractAddress(address) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO contract_state (key, value) VALUES ('contract_address', ?)"
|
||||
);
|
||||
return stmt.run(address);
|
||||
}
|
||||
|
||||
function getAllWallets() {
|
||||
const stmt = db.prepare("SELECT * FROM wallets");
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
// Payments
|
||||
function createPayment(id, diskId, amountAfc, amountMxn) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO payments (id, disk_id, amount_afc, amount_mxn) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
return stmt.run(id, diskId, amountAfc, amountMxn);
|
||||
}
|
||||
|
||||
function getPayment(id) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE id = ?");
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
function updatePayment(id, fields) {
|
||||
const allowed = ["status", "mp_preference_id", "mp_payment_id", "tx_hash"];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) {
|
||||
sets.push(`${key} = ?`);
|
||||
values.push(fields[key]);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return null;
|
||||
sets.push("updated_at = CURRENT_TIMESTAMP");
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE payments SET ${sets.join(", ")} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
function getPaymentsByDiskId(diskId) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE disk_id = ? ORDER BY created_at DESC");
|
||||
return stmt.all(diskId);
|
||||
}
|
||||
|
||||
function getPaymentByMpPaymentId(mpPaymentId) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE mp_payment_id = ?");
|
||||
return stmt.get(mpPaymentId) || null;
|
||||
}
|
||||
|
||||
// Redemptions
|
||||
function createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO redemptions (id, disk_id, amount_afc, prize_type, prize_detail, delivery_info, burn_tx_hash) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
return stmt.run(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash);
|
||||
}
|
||||
|
||||
function getRedemption(id) {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE id = ?");
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
function getRedemptionsByDiskId(diskId) {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE disk_id = ? ORDER BY created_at DESC");
|
||||
return stmt.all(diskId);
|
||||
}
|
||||
|
||||
function getPendingRedemptions() {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
function updateRedemption(id, fields) {
|
||||
const allowed = ["status", "admin_notes"];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) {
|
||||
sets.push(`${key} = ?`);
|
||||
values.push(fields[key]);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return null;
|
||||
sets.push("updated_at = CURRENT_TIMESTAMP");
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE redemptions SET ${sets.join(", ")} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
getWallet,
|
||||
createWallet,
|
||||
getContractAddress,
|
||||
setContractAddress,
|
||||
getAllWallets,
|
||||
createPayment,
|
||||
getPayment,
|
||||
updatePayment,
|
||||
getPaymentsByDiskId,
|
||||
getPaymentByMpPaymentId,
|
||||
createRedemption,
|
||||
getRedemption,
|
||||
getRedemptionsByDiskId,
|
||||
getPendingRedemptions,
|
||||
updateRedemption,
|
||||
};
|
||||
33
services/afc-bridge/src/deploy.js
Normal file
33
services/afc-bridge/src/deploy.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const config = require("./config");
|
||||
const db = require("./db");
|
||||
const { waitForGeth, deployContract } = require("./ethereum");
|
||||
|
||||
async function main() {
|
||||
console.log("=== AfterCoin Contract Deployment ===");
|
||||
|
||||
// Wait for the Geth node to be reachable
|
||||
await waitForGeth();
|
||||
|
||||
// Check if already deployed
|
||||
const existing = db.getContractAddress();
|
||||
if (existing) {
|
||||
console.log("Contract already deployed at:", existing);
|
||||
console.log(
|
||||
"To redeploy, clear the contract_state table in the database."
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Deploy the contract
|
||||
const address = await deployContract();
|
||||
console.log("=== Deployment complete ===");
|
||||
console.log("Contract address:", address);
|
||||
return address;
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error("Deployment failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
64
services/afc-bridge/src/ethereum.js
Normal file
64
services/afc-bridge/src/ethereum.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const { ethers } = require("ethers");
|
||||
const path = require("path");
|
||||
const config = require("./config");
|
||||
const db = require("./db");
|
||||
|
||||
const abiPath = path.join(__dirname, "..", "contracts", "AfterCoin.json");
|
||||
const artifact = require(abiPath);
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(config.GETH_RPC_URL);
|
||||
const adminWallet = new ethers.Wallet(config.ADMIN_PRIVATE_KEY, provider);
|
||||
|
||||
function getContract() {
|
||||
const address = db.getContractAddress() || config.AFC_CONTRACT_ADDRESS;
|
||||
if (!address) {
|
||||
throw new Error(
|
||||
"AfterCoin contract not deployed yet. Run 'npm run deploy' first."
|
||||
);
|
||||
}
|
||||
return new ethers.Contract(address, artifact.abi, adminWallet);
|
||||
}
|
||||
|
||||
async function deployContract() {
|
||||
console.log("Deploying AfterCoin contract...");
|
||||
console.log("Admin address:", adminWallet.address);
|
||||
|
||||
const factory = new ethers.ContractFactory(
|
||||
artifact.abi,
|
||||
artifact.bytecode,
|
||||
adminWallet
|
||||
);
|
||||
|
||||
const contract = await factory.deploy();
|
||||
await contract.waitForDeployment();
|
||||
|
||||
const address = await contract.getAddress();
|
||||
db.setContractAddress(address);
|
||||
|
||||
console.log("AfterCoin deployed at:", address);
|
||||
return address;
|
||||
}
|
||||
|
||||
async function waitForGeth(retryInterval = 5000, maxRetries = 60) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const blockNumber = await provider.getBlockNumber();
|
||||
console.log(`Geth is ready (block #${blockNumber})`);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.log(
|
||||
`Waiting for Geth... attempt ${i + 1}/${maxRetries} (${err.message})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryInterval));
|
||||
}
|
||||
}
|
||||
throw new Error(`Geth did not respond after ${maxRetries} retries`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
provider,
|
||||
adminWallet,
|
||||
getContract,
|
||||
deployContract,
|
||||
waitForGeth,
|
||||
};
|
||||
87
services/afc-bridge/src/index.js
Normal file
87
services/afc-bridge/src/index.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
const config = require("./config");
|
||||
const db = require("./db");
|
||||
const { waitForGeth, deployContract, getContract } = require("./ethereum");
|
||||
|
||||
const registerRouter = require("./routes/register");
|
||||
const depositRouter = require("./routes/deposit");
|
||||
const withdrawRouter = require("./routes/withdraw");
|
||||
const balanceRouter = require("./routes/balance");
|
||||
const walletRouter = require("./routes/wallet");
|
||||
const paymentsRouter = require("./routes/payments");
|
||||
const redemptionsRouter = require("./routes/redemptions");
|
||||
|
||||
const app = express();
|
||||
|
||||
// Serve static files (token icon, etc.)
|
||||
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
|
||||
// Parse JSON request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Auth middleware — only applies to state-changing (POST) routes
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== "POST" && req.method !== "PATCH") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const secret = req.headers["x-bridge-secret"];
|
||||
if (secret !== config.BRIDGE_SECRET) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ success: false, error: "Unauthorized: invalid bridge secret" });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Mount routes
|
||||
app.use(registerRouter);
|
||||
app.use(depositRouter);
|
||||
app.use(withdrawRouter);
|
||||
app.use(balanceRouter);
|
||||
app.use(walletRouter);
|
||||
app.use(paymentsRouter);
|
||||
app.use(redemptionsRouter);
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error("Unhandled error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message || "Internal server error",
|
||||
});
|
||||
});
|
||||
|
||||
// Startup sequence
|
||||
async function start() {
|
||||
// 1. Wait for Geth node to be reachable
|
||||
await waitForGeth();
|
||||
|
||||
// 2. Deploy contract if not already deployed
|
||||
const existing = db.getContractAddress();
|
||||
if (!existing) {
|
||||
console.log("No contract found in database, deploying...");
|
||||
await deployContract();
|
||||
} else {
|
||||
console.log("Contract already deployed at:", existing);
|
||||
}
|
||||
|
||||
// Verify the contract is accessible
|
||||
const contract = getContract();
|
||||
const contractAddress = await contract.getAddress();
|
||||
|
||||
// 3. Start listening
|
||||
app.listen(config.PORT, () => {
|
||||
console.log("=== AFC Bridge API ===");
|
||||
console.log(`Listening on port ${config.PORT}`);
|
||||
console.log(`AfterCoin contract: ${contractAddress}`);
|
||||
console.log(`Geth RPC: ${config.GETH_RPC_URL}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error("Failed to start AFC Bridge:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
31
services/afc-bridge/src/routes/balance.js
Normal file
31
services/afc-bridge/src/routes/balance.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
const { getContract } = require("../ethereum");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/api/balance/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const wallet = db.getWallet(diskId);
|
||||
if (!wallet) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Wallet not found for this diskId" });
|
||||
}
|
||||
|
||||
const contract = getContract();
|
||||
const balance = await contract.balanceOf(wallet.address);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diskId,
|
||||
balance: Number(balance),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
54
services/afc-bridge/src/routes/deposit.js
Normal file
54
services/afc-bridge/src/routes/deposit.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
const { getContract } = require("../ethereum");
|
||||
const txQueue = require("../txQueue");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/deposit", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId, amount } = req.body;
|
||||
|
||||
if (!diskId || amount === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "diskId and amount are required" });
|
||||
}
|
||||
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "amount must be a positive integer" });
|
||||
}
|
||||
|
||||
const wallet = db.getWallet(diskId);
|
||||
if (!wallet) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Wallet not found for this diskId" });
|
||||
}
|
||||
|
||||
const contract = getContract();
|
||||
|
||||
// Mint tokens to the player's wallet via the transaction queue
|
||||
const receipt = await txQueue.enqueue(async () => {
|
||||
const tx = await contract.mint(wallet.address, amount);
|
||||
return tx.wait();
|
||||
});
|
||||
|
||||
// Read the new on-chain balance
|
||||
const balance = await contract.balanceOf(wallet.address);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diskId,
|
||||
amount,
|
||||
balance: Number(balance),
|
||||
txHash: receipt.hash,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
93
services/afc-bridge/src/routes/payments.js
Normal file
93
services/afc-bridge/src/routes/payments.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/payments", async (req, res, next) => {
|
||||
try {
|
||||
const { id, diskId, amountAfc, amountMxn } = req.body;
|
||||
|
||||
if (!id || !diskId || amountAfc === undefined || amountMxn === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "id, diskId, amountAfc, and amountMxn are required" });
|
||||
}
|
||||
|
||||
db.createPayment(id, diskId, amountAfc, amountMxn);
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/payments/history/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const payments = db.getPaymentsByDiskId(diskId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payments,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/payments/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
if (!payment) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Payment not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/api/payments/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
if (!payment) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Payment not found" });
|
||||
}
|
||||
|
||||
const result = db.updatePayment(id, req.body);
|
||||
if (!result) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
const updated = db.getPayment(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
106
services/afc-bridge/src/routes/redemptions.js
Normal file
106
services/afc-bridge/src/routes/redemptions.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/redemptions", async (req, res, next) => {
|
||||
try {
|
||||
const { id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash } = req.body;
|
||||
|
||||
if (!id || !diskId || amountAfc === undefined || !prizeType || !prizeDetail) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "id, diskId, amountAfc, prizeType, and prizeDetail are required" });
|
||||
}
|
||||
|
||||
db.createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash);
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/admin/pending", async (req, res, next) => {
|
||||
try {
|
||||
const redemptions = db.getPendingRedemptions();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemptions,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/history/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const redemptions = db.getRedemptionsByDiskId(diskId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemptions,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
if (!redemption) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Redemption not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/api/redemptions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
if (!redemption) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Redemption not found" });
|
||||
}
|
||||
|
||||
const result = db.updateRedemption(id, req.body);
|
||||
if (!result) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
const updated = db.getRedemption(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
60
services/afc-bridge/src/routes/register.js
Normal file
60
services/afc-bridge/src/routes/register.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const { Router } = require("express");
|
||||
const { ethers } = require("ethers");
|
||||
const config = require("../config");
|
||||
const db = require("../db");
|
||||
const { adminWallet, provider } = require("../ethereum");
|
||||
const txQueue = require("../txQueue");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/register", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId, name } = req.body;
|
||||
|
||||
if (!diskId || !name) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "diskId and name are required" });
|
||||
}
|
||||
|
||||
// Check if wallet already exists
|
||||
const existing = db.getWallet(diskId);
|
||||
if (existing) {
|
||||
return res.json({
|
||||
success: true,
|
||||
diskId: existing.disk_id,
|
||||
address: existing.address,
|
||||
name: existing.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new random wallet
|
||||
const wallet = ethers.Wallet.createRandom();
|
||||
|
||||
// Save to database
|
||||
db.createWallet(diskId, wallet.address, wallet.privateKey, name);
|
||||
|
||||
// Fund the new wallet with gas ETH from the admin wallet
|
||||
await txQueue.enqueue(async () => {
|
||||
const tx = await adminWallet.sendTransaction({
|
||||
to: wallet.address,
|
||||
value: ethers.parseEther(config.GAS_FUND_AMOUNT),
|
||||
});
|
||||
await tx.wait();
|
||||
console.log(
|
||||
`Funded ${wallet.address} with ${config.GAS_FUND_AMOUNT} ETH (tx: ${tx.hash})`
|
||||
);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diskId,
|
||||
address: wallet.address,
|
||||
name,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
29
services/afc-bridge/src/routes/wallet.js
Normal file
29
services/afc-bridge/src/routes/wallet.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/api/wallet/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const wallet = db.getWallet(diskId);
|
||||
if (!wallet) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Wallet not found for this diskId" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diskId,
|
||||
address: wallet.address,
|
||||
privateKey: wallet.private_key,
|
||||
name: wallet.name,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
63
services/afc-bridge/src/routes/withdraw.js
Normal file
63
services/afc-bridge/src/routes/withdraw.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
const { getContract } = require("../ethereum");
|
||||
const txQueue = require("../txQueue");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/withdraw", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId, amount } = req.body;
|
||||
|
||||
if (!diskId || amount === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "diskId and amount are required" });
|
||||
}
|
||||
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "amount must be a positive integer" });
|
||||
}
|
||||
|
||||
const wallet = db.getWallet(diskId);
|
||||
if (!wallet) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Wallet not found for this diskId" });
|
||||
}
|
||||
|
||||
const contract = getContract();
|
||||
|
||||
// Check on-chain balance before burning
|
||||
const balance = await contract.balanceOf(wallet.address);
|
||||
if (Number(balance) < amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Insufficient balance: has ${Number(balance)}, requested ${amount}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Burn tokens from the player's wallet via the transaction queue
|
||||
const receipt = await txQueue.enqueue(async () => {
|
||||
const tx = await contract.burnFrom(wallet.address, amount);
|
||||
return tx.wait();
|
||||
});
|
||||
|
||||
// Read updated balance
|
||||
const newBalance = await contract.balanceOf(wallet.address);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diskId,
|
||||
amount,
|
||||
balance: Number(newBalance),
|
||||
txHash: receipt.hash,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user