feat: add game server infrastructure and documentary improvements
Some checks failed
Deploy / deploy (push) Has been cancelled

- Add Docker Compose for OpenFusion (FusionFall), MapleStory 2, and
  Minecraft FTB Infinity Evolved game servers
- Add MapleStory 2 multi-service compose (MySQL, World, Login, Web, Game)
- Add OpenFusion Dockerfile and configuration files
- Fix CMS Dockerfile, web Dockerfile, and documentary components
- Add root layout, globals.css, not-found page, and text formatting utils
- Update .gitignore to exclude large game server repos and data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-23 12:10:12 +00:00
parent 0df69b38d5
commit aea2283d8f
32 changed files with 4005 additions and 1219 deletions

6
.gitignore vendored
View File

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

View File

@@ -2,9 +2,12 @@ FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm install
COPY . . 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 FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app

View File

@@ -15,10 +15,15 @@
"@strapi/plugin-cloud": "^5.36.0", "@strapi/plugin-cloud": "^5.36.0",
"@strapi/plugin-users-permissions": "^5.36.0", "@strapi/plugin-users-permissions": "^5.36.0",
"pg": "^8.13.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": { "devDependencies": {
"typescript": "^5.3.0" "typescript": "^5.3.0",
"esbuild": "^0.25.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0 <=24.x.x", "node": ">=20.0.0 <=24.x.x",

View File

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

View File

@@ -14,13 +14,14 @@ WORKDIR /app/apps/web
RUN npm run build RUN npm run build
FROM node:20-alpine AS production FROM node:20-alpine AS production
WORKDIR /app/apps/web WORKDIR /app
COPY --from=base /app/apps/web/.next ./.next COPY --from=base /app/package.json ./
COPY --from=base /app/apps/web/public ./public COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/apps/web/package.json ./ COPY --from=base /app/packages ./packages
COPY --from=base /app/apps/web/node_modules ./node_modules COPY --from=base /app/apps/web/.next ./apps/web/.next
COPY --from=base /app/node_modules /app/node_modules COPY --from=base /app/apps/web/package.json ./apps/web/
COPY --from=base /app/packages /app/packages COPY --from=base /app/apps/web/next.config.ts ./apps/web/
WORKDIR /app/apps/web
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "start"] CMD ["npm", "start"]

View File

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

View File

@@ -24,7 +24,7 @@ export default async function GamePage({
return ( return (
<> <>
<GameHeader game={game} /> <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} /> <GameInfo game={game} locale={locale} />
{game.screenshots && ( {game.screenshots && (
<ScreenshotGallery screenshots={game.screenshots} /> <ScreenshotGallery screenshots={game.screenshots} />

View File

@@ -1,11 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server"; import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; 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 = { export const metadata: Metadata = {
title: { title: {
@@ -30,8 +48,11 @@ export default async function LocaleLayout({
const messages = await getMessages(); const messages = await getMessages();
return ( return (
<html lang={locale}> <html
<body className="bg-gray-950 text-white min-h-screen flex flex-col"> 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}> <NextIntlClientProvider messages={messages}>
<Navbar /> <Navbar />
<main className="flex-1 pt-16">{children}</main> <main className="flex-1 pt-16">{children}</main>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import type { Chapter } from "@afterlife/shared"; import type { Chapter } from "@afterlife/shared";
import { formatTextToHtml } from "@/lib/format";
interface ChapterContentProps { interface ChapterContentProps {
chapter: Chapter; chapter: Chapter;
@@ -7,9 +8,22 @@ interface ChapterContentProps {
export function ChapterContent({ chapter }: ChapterContentProps) { export function ChapterContent({ chapter }: ChapterContentProps) {
return ( 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 && ( {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 <Image
src={chapter.coverImage.url} src={chapter.coverImage.url}
alt={chapter.coverImage.alternativeText || chapter.title} alt={chapter.coverImage.alternativeText || chapter.title}
@@ -18,10 +32,10 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
/> />
</div> </div>
)} )}
<h2 className="text-3xl font-bold mb-6">{chapter.title}</h2>
<div <div
className="prose prose-invert prose-lg max-w-none" className="chapter-prose"
dangerouslySetInnerHTML={{ __html: chapter.content }} dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
/> />
</article> </article>
); );

View File

@@ -9,27 +9,39 @@ interface ChapterNavProps {
onSelectChapter: (id: number, index: number) => void; onSelectChapter: (id: number, index: number) => void;
} }
export function ChapterNav({ chapters, activeChapterId, onSelectChapter }: ChapterNavProps) { export function ChapterNav({
chapters,
activeChapterId,
onSelectChapter,
}: ChapterNavProps) {
const t = useTranslations("documentary"); const t = useTranslations("documentary");
return ( return (
<nav className="w-64 flex-shrink-0 hidden lg:block"> <nav className="w-72 flex-shrink-0 hidden lg:block">
<div className="sticky top-20"> <div className="sticky top-24">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-[0.15em] mb-5">
{t("chapters")} {t("chapters")}
</h3> </h3>
<ol className="space-y-1"> <ol className="space-y-0.5">
{chapters.map((chapter, index) => ( {chapters.map((chapter, index) => (
<li key={chapter.id}> <li key={chapter.id}>
<button <button
onClick={() => onSelectChapter(chapter.id, index)} 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 chapter.id === activeChapterId
? "bg-blue-600/20 text-blue-400 font-medium" ? "bg-amber-500/10 text-amber-400 font-medium border-l-2 border-amber-500 rounded-l-none"
: "text-gray-400 hover:text-white hover:bg-white/5" : "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} {chapter.title}
</button> </button>
</li> </li>

View File

@@ -37,6 +37,7 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
const chapter = chapters.find((c) => c.id === chapterId); const chapter = chapters.find((c) => c.id === chapterId);
if (chapter) { if (chapter) {
setActiveChapter(chapter); setActiveChapter(chapter);
window.scrollTo({ top: 0, behavior: "smooth" });
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId); const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
if (trackIndex !== -1) { if (trackIndex !== -1) {
audio.goToTrack(trackIndex); audio.goToTrack(trackIndex);
@@ -47,13 +48,28 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
return ( return (
<> <>
<ReadingProgress /> <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 <ChapterNav
chapters={chapters} chapters={chapters}
activeChapterId={activeChapter.id} activeChapterId={activeChapter.id}
onSelectChapter={handleSelectChapter} onSelectChapter={handleSelectChapter}
/> />
<div className="flex-1 pb-24"> <div className="flex-1 min-w-0 pb-24">
<ChapterContent chapter={activeChapter} /> <ChapterContent chapter={activeChapter} />
</div> </div>
</div> </div>
@@ -67,7 +83,9 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
onToggle={audio.toggle} onToggle={audio.toggle}
onSeek={audio.seek} onSeek={audio.seek}
onChangeRate={audio.changeRate} onChangeRate={audio.changeRate}
onToggleContinuous={() => audio.setContinuousMode(!audio.continuousMode)} onToggleContinuous={() =>
audio.setContinuousMode(!audio.continuousMode)
}
/> />
</> </>
); );

View File

@@ -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 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"> <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> <h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
<p className="text-gray-400 text-lg"> {game.title}
</h1>
<p className="text-gray-400 text-lg font-body">
{game.developer} · {game.releaseYear}{game.shutdownYear} {game.developer} · {game.releaseYear}{game.shutdownYear}
</p> </p>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import type { Game } from "@afterlife/shared"; import type { Game } from "@afterlife/shared";
import { formatTextToHtml } from "@/lib/format";
interface GameInfoProps { interface GameInfoProps {
game: Game; game: Game;
@@ -17,36 +18,46 @@ export function GameInfo({ game, locale }: GameInfoProps) {
}; };
return ( 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="md:col-span-2">
<div <div
className="prose prose-invert max-w-none" className="prose-editorial"
dangerouslySetInnerHTML={{ __html: game.description }} dangerouslySetInnerHTML={{ __html: formatTextToHtml(game.description) }}
/> />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-gray-900 rounded-lg p-6 border border-white/5"> <div className="bg-gradient-to-b from-gray-900 to-gray-900/50 rounded-xl p-6 border border-white/[0.07]">
<dl className="space-y-4 text-sm"> <dl className="divide-y divide-white/5 text-sm">
<div> <div className="pb-3">
<dt className="text-gray-500">{t("developer")}</dt> <dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
<dd className="text-white font-medium">{game.developer}</dd> {t("developer")}
</dt>
<dd className="text-gray-100 font-medium">{game.developer}</dd>
</div> </div>
{game.publisher && ( {game.publisher && (
<div> <div className="py-3">
<dt className="text-gray-500">{t("publisher")}</dt> <dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
<dd className="text-white font-medium">{game.publisher}</dd> {t("publisher")}
</dt>
<dd className="text-gray-100 font-medium">{game.publisher}</dd>
</div> </div>
)} )}
<div> <div className="py-3">
<dt className="text-gray-500">{t("released")}</dt> <dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
<dd className="text-white font-medium">{game.releaseYear}</dd> {t("released")}
</dt>
<dd className="text-gray-100 font-medium">{game.releaseYear}</dd>
</div> </div>
<div> <div className="py-3">
<dt className="text-gray-500">{t("shutdown")}</dt> <dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
<dd className="text-white font-medium">{game.shutdownYear}</dd> {t("shutdown")}
</dt>
<dd className="text-gray-100 font-medium">{game.shutdownYear}</dd>
</div> </div>
<div> <div className="pt-3">
<dt className="text-gray-500">{t("server_status")}</dt> <dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
{t("server_status")}
</dt>
<dd className={`font-medium ${statusColors[game.serverStatus]}`}> <dd className={`font-medium ${statusColors[game.serverStatus]}`}>
{t(`status_${game.serverStatus}`)} {t(`status_${game.serverStatus}`)}
</dd> </dd>
@@ -58,7 +69,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
href={game.serverLink} href={game.serverLink}
target="_blank" target="_blank"
rel="noopener noreferrer" 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")} {t("play_now")}
</a> </a>
@@ -66,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
{game.documentary && ( {game.documentary && (
<Link <Link
href={`/${locale}/games/${game.slug}/documentary`} 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")} {t("view_documentary")}
</Link> </Link>

View File

@@ -12,8 +12,9 @@ export async function getGames(locale: string): Promise<StrapiListResponse<Game>
path: "/games", path: "/games",
locale, locale,
params: { params: {
"populate[coverImage]": "*", "populate[coverImage][fields][0]": "url",
"populate[documentary]": "*", "populate[coverImage][fields][1]": "alternativeText",
"populate[documentary][fields][0]": "title",
"sort": "createdAt:desc", "sort": "createdAt:desc",
}, },
}); });
@@ -25,9 +26,19 @@ export async function getGameBySlug(slug: string, locale: string): Promise<Strap
locale, locale,
params: { params: {
"filters[slug][$eq]": slug, "filters[slug][$eq]": slug,
"populate[coverImage]": "*", "populate[coverImage][fields][0]": "url",
"populate[screenshots]": "*", "populate[coverImage][fields][1]": "alternativeText",
"populate[documentary][populate][chapters][populate]": "*", "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}`, path: `/chapters/${chapterId}`,
locale, locale,
params: { params: {
"populate[audioFile]": "*", "populate[audioFile][fields][0]": "url",
"populate[coverImage]": "*", "populate[audioFile][fields][1]": "name",
"populate[audioFile][fields][2]": "mime",
"populate[coverImage][fields][0]": "url",
"populate[coverImage][fields][1]": "alternativeText",
"populate[coverImage][fields][2]": "width",
"populate[coverImage][fields][3]": "height",
}, },
}); });
} }

View File

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

View File

@@ -0,0 +1,117 @@
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
environment:
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN:-}
NEXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL:-http://localhost:1337}
ports:
- "3000:3000"
openfusion:
build:
context: ../servers/openfusion
dockerfile: Dockerfile
restart: unless-stopped
environment:
SHARD_IP: ${OPENFUSION_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:java8
restart: unless-stopped
container_name: minecraft-ftb
environment:
EULA: "TRUE"
TYPE: FTBA
FTB_MODPACK_ID: 23
FTB_MODPACK_VERSION_ID: 99
MEMORY: 6G
MAX_MEMORY: 6G
MOTD: "Project Afterlife - FTB Infinity Evolved"
DIFFICULTY: normal
MAX_PLAYERS: 20
VIEW_DISTANCE: 10
ENABLE_COMMAND_BLOCK: "true"
JVM_DD_OPTS: "fml.queryResult=confirm"
JVM_XX_OPTS: "-XX:+UseParNewGC -XX:+CMSIncrementalPacing -XX:+CMSClassUnloadingEnabled -XX:ParallelGCThreads=2 -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10"
# Fix: Forge 1.7.10 manifest expects versioned jar name on classpath
PRE_LAUNCH_CMD: "cp -n /data/minecraft_server.jar /data/minecraft_server.1.7.10.jar 2>/dev/null; true"
ports:
- "25565:25565"
volumes:
- minecraft_ftb_data:/data
deploy:
resources:
limits:
memory: 8G
volumes:
postgres_data:
minio_data:
openfusion_data:
minecraft_ftb_data:

View File

@@ -0,0 +1,119 @@
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
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:
- "20002:20002"
- "21002:21002"
env_file:
- ../servers/maple2/.env
environment:
DB_IP: maple2-mysql
GRPC_GAME_IP: maple2-game-ch0
GRPC_WORLD_IP: maple2-world
INSTANCED_CONTENT: "true"
volumes:
maple2_mysql:
maple2_dotnet_tools:

4284
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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)
);