first commit

This commit is contained in:
2026-07-16 17:37:25 +02:00
commit 61054537c5
40 changed files with 8211 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL="postgresql://soulless:soulless@localhost:5433/soulless_necromancer?schema=public"
JWT_SECRET="soulless-necromancer-dev-secret-change-in-prod"
PORT=3001
CLIENT_ORIGIN="http://localhost:5173"
COOKIE_NAME="sn_token"
+37
View File
@@ -0,0 +1,37 @@
{
"name": "server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma db push && prisma generate",
"db:push": "prisma db push && prisma generate",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.5.0",
"bcryptjs": "^3.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.13.10",
"prisma": "^6.5.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
+102
View File
@@ -0,0 +1,102 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Specialty {
ZOMBIE
SHADOW
}
enum Zone {
WORLD
DUNGEON
}
enum ItemSlot {
WEAPON
ARMOR
ACCESSORY
CONSUMABLE
}
enum ArmyUnitType {
ZOMBIE
SHADOW
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
character Character?
}
model Character {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
specialty Specialty
level Int @default(1)
xp Int @default(0)
unspentStatPoints Int @default(0)
hp Int @default(100)
maxHp Int @default(100)
energy Int @default(50)
maxEnergy Int @default(50)
str Int @default(5)
dex Int @default(5)
int Int @default(5)
mapX Int @default(5)
mapY Int @default(5)
currentZone Zone @default(WORLD)
dungeonFloor Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventory InventoryItem[]
army ArmyUnit[]
}
model Item {
id String @id @default(cuid())
key String @unique
name String
description String
slot ItemSlot
strBonus Int @default(0)
dexBonus Int @default(0)
intBonus Int @default(0)
hpBonus Int @default(0)
rarity String @default("common")
inventory InventoryItem[]
}
model InventoryItem {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
itemId String
item Item @relation(fields: [itemId], references: [id])
equipped Boolean @default(false)
quantity Int @default(1)
@@index([characterId])
}
model ArmyUnit {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
type ArmyUnitType
tier Int @default(1)
count Int @default(1)
@@unique([characterId, type, tier])
@@index([characterId])
}
+92
View File
@@ -0,0 +1,92 @@
import { PrismaClient, ItemSlot } from "@prisma/client";
const prisma = new PrismaClient();
const items = [
{
key: "bone_dagger",
name: "Bone Dagger",
description: "A crude blade carved from a fallen warrior.",
slot: ItemSlot.WEAPON,
strBonus: 2,
dexBonus: 1,
intBonus: 0,
hpBonus: 0,
rarity: "common",
},
{
key: "shadow_veil",
name: "Shadow Veil",
description: "Cloth woven from twilight itself.",
slot: ItemSlot.ARMOR,
strBonus: 0,
dexBonus: 2,
intBonus: 1,
hpBonus: 10,
rarity: "common",
},
{
key: "grimoire_fragment",
name: "Grimoire Fragment",
description: "Pages torn from a forbidden tome.",
slot: ItemSlot.ACCESSORY,
strBonus: 0,
dexBonus: 0,
intBonus: 3,
hpBonus: 0,
rarity: "uncommon",
},
{
key: "necrotic_blade",
name: "Necrotic Blade",
description: "Steel that drinks life force.",
slot: ItemSlot.WEAPON,
strBonus: 4,
dexBonus: 0,
intBonus: 2,
hpBonus: 0,
rarity: "rare",
},
{
key: "ossuary_plate",
name: "Ossuary Plate",
description: "Armor fashioned from countless bones.",
slot: ItemSlot.ARMOR,
strBonus: 2,
dexBonus: 0,
intBonus: 0,
hpBonus: 25,
rarity: "rare",
},
{
key: "soul_phial",
name: "Soul Phial",
description: "A vial of restless essence. Restores energy.",
slot: ItemSlot.CONSUMABLE,
strBonus: 0,
dexBonus: 0,
intBonus: 0,
hpBonus: 0,
rarity: "common",
},
];
async function main() {
for (const item of items) {
await prisma.item.upsert({
where: { key: item.key },
update: item,
create: item,
});
}
console.log(`Seeded ${items.length} items.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+43
View File
@@ -0,0 +1,43 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import http from "http";
import { Server } from "socket.io";
import { config } from "./lib/config.js";
import { authRouter } from "./routes/auth.js";
import { characterRouter } from "./routes/character.js";
import { inventoryRouter } from "./routes/inventory.js";
import { registerSockets } from "./sockets/game.js";
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: config.clientOrigin,
credentials: true,
},
});
app.use(
cors({
origin: config.clientOrigin,
credentials: true,
})
);
app.use(express.json());
app.use(cookieParser());
app.get("/api/health", (_req, res) => {
res.json({ ok: true, name: "Soulless Necromancer" });
});
app.use("/api/auth", authRouter);
app.use("/api/character", characterRouter);
app.use("/api/inventory", inventoryRouter);
registerSockets(io);
server.listen(config.port, () => {
console.log(`Soulless Necromancer server on http://localhost:${config.port}`);
});
+22
View File
@@ -0,0 +1,22 @@
import dotenv from "dotenv";
dotenv.config();
export const config = {
port: Number(process.env.PORT ?? 3001),
jwtSecret: process.env.JWT_SECRET ?? "dev-secret",
clientOrigin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173",
cookieName: process.env.COOKIE_NAME ?? "sn_token",
mapWidth: 40,
mapHeight: 30,
portalX: 20,
portalY: 15,
spawnX: 5,
spawnY: 5,
dungeonWidth: 12,
dungeonHeight: 10,
/** World-map regen tick interval (ms) */
regenIntervalMs: 4000,
regenHp: 4,
regenEnergy: 2,
};
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../lib/config.js";
export type AuthPayload = {
userId: string;
characterId: string;
};
declare global {
namespace Express {
interface Request {
auth?: AuthPayload;
}
}
}
export function signToken(payload: AuthPayload): string {
return jwt.sign(payload, config.jwtSecret, { expiresIn: "7d" });
}
export function verifyToken(token: string): AuthPayload | null {
try {
return jwt.verify(token, config.jwtSecret) as AuthPayload;
} catch {
return null;
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const token =
req.cookies?.[config.cookieName] ??
(req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: undefined);
if (!token) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({ error: "Invalid token" });
return;
}
req.auth = payload;
next();
}
+161
View File
@@ -0,0 +1,161 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { config } from "../lib/config.js";
import { requireAuth, signToken } from "../middleware/auth.js";
import { baseStatsForSpecialty, serializeCharacter } from "../services/progression.js";
export const authRouter = Router();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
characterName: z.string().min(2).max(20),
specialty: z.enum(["ZOMBIE", "SHADOW"]),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
function setAuthCookie(res: import("express").Response, token: string) {
res.cookie(config.cookieName, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
authRouter.post("/register", async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid input", details: parsed.error.flatten() });
return;
}
const { email, password, characterName, specialty } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.status(409).json({ error: "Email already registered" });
return;
}
const nameTaken = await prisma.character.findFirst({ where: { name: characterName } });
if (nameTaken) {
res.status(409).json({ error: "Character name already taken" });
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const stats = baseStatsForSpecialty(specialty);
const starter = await prisma.item.findUnique({ where: { key: "soul_phial" } });
const user = await prisma.user.create({
data: {
email,
passwordHash,
character: {
create: {
name: characterName,
specialty,
str: stats.str,
dex: stats.dex,
int: stats.int,
maxHp: stats.maxHp,
hp: stats.maxHp,
maxEnergy: stats.maxEnergy,
energy: stats.maxEnergy,
mapX: config.spawnX,
mapY: config.spawnY,
inventory: starter
? {
create: {
itemId: starter.id,
quantity: 2,
},
}
: undefined,
},
},
},
include: {
character: {
include: { inventory: { include: { item: true } }, army: true },
},
},
});
const character = user.character!;
const token = signToken({ userId: user.id, characterId: character.id });
setAuthCookie(res, token);
res.status(201).json({
user: { id: user.id, email: user.email },
character: serializeCharacter(character),
});
});
authRouter.post("/login", async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid input" });
return;
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
include: {
character: {
include: { inventory: { include: { item: true } }, army: true },
},
},
});
if (!user || !(await bcrypt.compare(parsed.data.password, user.passwordHash))) {
res.status(401).json({ error: "Invalid email or password" });
return;
}
if (!user.character) {
res.status(500).json({ error: "Character missing" });
return;
}
const token = signToken({ userId: user.id, characterId: user.character.id });
setAuthCookie(res, token);
res.json({
user: { id: user.id, email: user.email },
character: serializeCharacter(user.character),
});
});
authRouter.post("/logout", (_req, res) => {
res.clearCookie(config.cookieName);
res.json({ ok: true });
});
authRouter.get("/me", requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.auth!.userId },
include: {
character: {
include: { inventory: { include: { item: true } }, army: true },
},
},
});
if (!user?.character) {
res.status(404).json({ error: "Not found" });
return;
}
res.json({
user: { id: user.id, email: user.email },
character: serializeCharacter(user.character),
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth } from "../middleware/auth.js";
import { serializeCharacter } from "../services/progression.js";
export const characterRouter = Router();
characterRouter.use(requireAuth);
async function loadCharacter(characterId: string) {
return prisma.character.findUnique({
where: { id: characterId },
include: { inventory: { include: { item: true } }, army: true },
});
}
characterRouter.get("/", async (req, res) => {
const character = await loadCharacter(req.auth!.characterId);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
res.json({ character: serializeCharacter(character) });
});
const statsSchema = z.object({
str: z.number().int().min(0).max(50).optional(),
dex: z.number().int().min(0).max(50).optional(),
int: z.number().int().min(0).max(50).optional(),
});
characterRouter.post("/stats", async (req, res) => {
const parsed = statsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid input" });
return;
}
const character = await loadCharacter(req.auth!.characterId);
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const addStr = parsed.data.str ?? 0;
const addDex = parsed.data.dex ?? 0;
const addInt = parsed.data.int ?? 0;
const total = addStr + addDex + addInt;
if (total <= 0) {
res.status(400).json({ error: "No points allocated" });
return;
}
if (total > character.unspentStatPoints) {
res.status(400).json({ error: "Not enough stat points" });
return;
}
const updated = await prisma.character.update({
where: { id: character.id },
data: {
str: character.str + addStr,
dex: character.dex + addDex,
int: character.int + addInt,
unspentStatPoints: character.unspentStatPoints - total,
maxHp: character.maxHp + addStr * 2,
maxEnergy: character.maxEnergy + addInt,
},
include: { inventory: { include: { item: true } }, army: true },
});
res.json({ character: serializeCharacter(updated) });
});
+129
View File
@@ -0,0 +1,129 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth } from "../middleware/auth.js";
import { serializeCharacter } from "../services/progression.js";
export const inventoryRouter = Router();
inventoryRouter.use(requireAuth);
async function loadCharacter(characterId: string) {
return prisma.character.findUnique({
where: { id: characterId },
include: { inventory: { include: { item: true } }, army: true },
});
}
inventoryRouter.get("/", async (req, res) => {
const character = await loadCharacter(req.auth!.characterId);
if (!character) {
res.status(404).json({ error: "Not found" });
return;
}
res.json({ character: serializeCharacter(character) });
});
const equipSchema = z.object({
inventoryItemId: z.string(),
equipped: z.boolean(),
});
inventoryRouter.post("/equip", async (req, res) => {
const parsed = equipSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid input" });
return;
}
const inv = await prisma.inventoryItem.findFirst({
where: {
id: parsed.data.inventoryItemId,
characterId: req.auth!.characterId,
},
include: { item: true },
});
if (!inv) {
res.status(404).json({ error: "Item not found" });
return;
}
if (inv.item.slot === "CONSUMABLE") {
res.status(400).json({ error: "Cannot equip consumable" });
return;
}
if (parsed.data.equipped) {
// Unequip same slot
const sameSlot = await prisma.inventoryItem.findMany({
where: {
characterId: req.auth!.characterId,
equipped: true,
item: { slot: inv.item.slot },
},
});
for (const other of sameSlot) {
await prisma.inventoryItem.update({
where: { id: other.id },
data: { equipped: false },
});
}
}
await prisma.inventoryItem.update({
where: { id: inv.id },
data: { equipped: parsed.data.equipped },
});
const character = await loadCharacter(req.auth!.characterId);
res.json({ character: serializeCharacter(character!) });
});
inventoryRouter.post("/use", async (req, res) => {
const schema = z.object({ inventoryItemId: z.string() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid input" });
return;
}
const inv = await prisma.inventoryItem.findFirst({
where: {
id: parsed.data.inventoryItemId,
characterId: req.auth!.characterId,
},
include: { item: true },
});
if (!inv || inv.item.slot !== "CONSUMABLE") {
res.status(400).json({ error: "Not a consumable" });
return;
}
const character = await prisma.character.findUnique({ where: { id: req.auth!.characterId } });
if (!character) {
res.status(404).json({ error: "Not found" });
return;
}
await prisma.character.update({
where: { id: character.id },
data: {
energy: Math.min(character.maxEnergy, character.energy + 25),
hp: Math.min(character.maxHp, character.hp + 15),
},
});
if (inv.quantity <= 1) {
await prisma.inventoryItem.delete({ where: { id: inv.id } });
} else {
await prisma.inventoryItem.update({
where: { id: inv.id },
data: { quantity: inv.quantity - 1 },
});
}
const updated = await loadCharacter(req.auth!.characterId);
res.json({ character: serializeCharacter(updated!) });
});
+35
View File
@@ -0,0 +1,35 @@
import type { ArmyUnitType, Specialty } from "@prisma/client";
import { prisma } from "../lib/prisma.js";
export function armyTypeForSpecialty(specialty: Specialty): ArmyUnitType {
return specialty === "ZOMBIE" ? "ZOMBIE" : "SHADOW";
}
export function summonEnergyCost(tier: number): number {
return 8 + tier * 4;
}
export function unitPower(type: ArmyUnitType, tier: number, count: number, casterInt: number) {
const base = type === "ZOMBIE" ? 6 : 5;
return (base + tier * 3 + Math.floor(casterInt / 2)) * count;
}
export async function addArmyUnit(
characterId: string,
type: ArmyUnitType,
tier = 1,
count = 1
) {
return prisma.armyUnit.upsert({
where: {
characterId_type_tier: { characterId, type, tier },
},
update: { count: { increment: count } },
create: { characterId, type, tier, count },
});
}
export async function getArmyPower(characterId: string, casterInt: number) {
const units = await prisma.armyUnit.findMany({ where: { characterId } });
return units.reduce((sum, u) => sum + unitPower(u.type, u.tier, u.count, casterInt), 0);
}
+348
View File
@@ -0,0 +1,348 @@
import { config } from "../lib/config.js";
import { effectiveStats, type CharacterWithRelations } from "./progression.js";
import { getArmyPower, unitPower } from "./army.js";
import type { ArmyUnitType } from "@prisma/client";
export const PLAYER_ATTACK_RANGE = 2;
export const UNIT_ATTACK_RANGE = 2;
export const ENEMY_ATTACK_RANGE = 2;
export type Enemy = {
id: string;
name: string;
x: number;
y: number;
hp: number;
maxHp: number;
atk: number;
};
export type Summon = {
id: string;
type: ArmyUnitType;
x: number;
y: number;
hp: number;
maxHp: number;
atk: number;
};
export type DungeonState = {
characterId: string;
floor: number;
width: number;
height: number;
playerX: number;
playerY: number;
energy: number;
enemies: Enemy[];
summons: Summon[];
log: string[];
over: boolean;
won: boolean;
};
function enemyName(floor: number, i: number): string {
const names = ["Bone Wretch", "Hollow Scout", "Grave Shade", "Carrion Knight", "Void Hound"];
return `${names[i % names.length]} Lv${floor}`;
}
function manhattan(ax: number, ay: number, bx: number, by: number) {
return Math.abs(ax - bx) + Math.abs(ay - by);
}
function nearestEnemy(fromX: number, fromY: number, enemies: Enemy[], range: number): Enemy | null {
const inRange = enemies
.filter((e) => e.hp > 0 && manhattan(fromX, fromY, e.x, e.y) <= range)
.sort((a, b) => manhattan(fromX, fromY, a.x, a.y) - manhattan(fromX, fromY, b.x, b.y));
return inRange[0] ?? null;
}
function nearestEnemyAny(fromX: number, fromY: number, enemies: Enemy[]): Enemy | null {
const living = enemies
.filter((e) => e.hp > 0)
.sort((a, b) => manhattan(fromX, fromY, a.x, a.y) - manhattan(fromX, fromY, b.x, b.y));
return living[0] ?? null;
}
function isWalkable(state: DungeonState, x: number, y: number, ignoreSummonId?: string): boolean {
if (x < 0 || y < 0 || x >= state.width || y >= state.height) return false;
if (x === state.playerX && y === state.playerY) return false;
if (state.enemies.some((e) => e.hp > 0 && e.x === x && e.y === y)) return false;
if (state.summons.some((s) => s.id !== ignoreSummonId && s.hp > 0 && s.x === x && s.y === y)) {
return false;
}
return true;
}
/** One orthogonal step closer to target, if possible. */
function stepToward(
state: DungeonState,
fromX: number,
fromY: number,
toX: number,
toY: number,
ignoreSummonId?: string
): { x: number; y: number } | null {
const candidates: Array<{ x: number; y: number }> = [];
if (toX !== fromX) candidates.push({ x: fromX + Math.sign(toX - fromX), y: fromY });
if (toY !== fromY) candidates.push({ x: fromX, y: fromY + Math.sign(toY - fromY) });
// Prefer the step that reduces Manhattan distance the most
candidates.sort(
(a, b) => manhattan(a.x, a.y, toX, toY) - manhattan(b.x, b.y, toX, toY)
);
for (const c of candidates) {
if (isWalkable(state, c.x, c.y, ignoreSummonId)) return c;
}
// Sidestep if direct path blocked
const sides = [
{ x: fromX + 1, y: fromY },
{ x: fromX - 1, y: fromY },
{ x: fromX, y: fromY + 1 },
{ x: fromX, y: fromY - 1 },
].filter((c) => isWalkable(state, c.x, c.y, ignoreSummonId));
sides.sort((a, b) => manhattan(a.x, a.y, toX, toY) - manhattan(b.x, b.y, toX, toY));
return sides[0] ?? null;
}
export function moveSummonTowardEnemy(state: DungeonState, summon: Summon): boolean {
const foe = nearestEnemyAny(summon.x, summon.y, state.enemies);
if (!foe) return false;
const step = stepToward(state, summon.x, summon.y, foe.x, foe.y, summon.id);
if (!step) return false;
summon.x = step.x;
summon.y = step.y;
return true;
}
export function createDungeon(character: CharacterWithRelations): DungeonState {
const floor = Math.max(1, character.dungeonFloor || 1);
const enemyCount = 3 + Math.min(4, floor);
const enemies: Enemy[] = [];
const startY = Math.floor(config.dungeonHeight / 2);
for (let i = 0; i < enemyCount; i++) {
let x = 0;
let y = 0;
let attempts = 0;
do {
x = 2 + Math.floor(Math.random() * (config.dungeonWidth - 3));
y = 1 + Math.floor(Math.random() * (config.dungeonHeight - 2));
attempts++;
} while (
attempts < 40 &&
(enemies.some((e) => e.x === x && e.y === y) || (x === 1 && y === startY))
);
const hp = 25 + floor * 12 + i * 5;
enemies.push({
id: `enemy-${i}-${Date.now()}`,
name: enemyName(floor, i),
x,
y,
hp,
maxHp: hp,
atk: 4 + floor * 2,
});
}
return {
characterId: character.id,
floor,
width: config.dungeonWidth,
height: config.dungeonHeight,
playerX: 1,
playerY: startY,
energy: character.energy,
enemies,
summons: [],
log: [`Floor ${floor}: the crypt awakens. Combat starts when in range.`],
over: false,
won: false,
};
}
export function moveInDungeon(
state: DungeonState,
x: number,
y: number
): { ok: boolean; error?: string; state: DungeonState } {
if (state.over) return { ok: false, error: "Combat over", state };
x = Math.floor(x);
y = Math.floor(y);
if (x < 0 || y < 0 || x >= state.width || y >= state.height) {
return { ok: false, error: "Out of bounds", state };
}
if (x === state.playerX && y === state.playerY) {
return { ok: false, error: "Already there", state };
}
if (state.enemies.some((e) => e.hp > 0 && e.x === x && e.y === y)) {
return { ok: false, error: "Tile blocked by enemy", state };
}
if (state.summons.some((s) => s.x === x && s.y === y)) {
return { ok: false, error: "Tile occupied by ally", state };
}
state.playerX = x;
state.playerY = y;
state.log.push(`You move to (${x}, ${y}).`);
return { ok: true, state };
}
export function playerAttackDamage(character: CharacterWithRelations, state: DungeonState): number {
const stats = effectiveStats(character);
const armyBonus = character.army.reduce(
(s, u) => s + unitPower(u.type, u.tier, u.count, stats.int),
0
);
return Math.max(
1,
Math.floor(stats.str * 1.2 + stats.int * 0.8 + armyBonus * 0.15)
);
}
/** One auto-combat round: summons move or fight, player hits if in range, enemies retaliate. */
export function resolveAutoCombatRound(
state: DungeonState,
character: CharacterWithRelations
): { state: DungeonState; damageToPlayer: number; acted: boolean } {
if (state.over) return { state, damageToPlayer: 0, acted: false };
let acted = false;
const playerDmg = playerAttackDamage(character, state);
const playerTarget = nearestEnemy(state.playerX, state.playerY, state.enemies, PLAYER_ATTACK_RANGE);
if (playerTarget) {
playerTarget.hp = Math.max(0, playerTarget.hp - playerDmg);
state.log.push(`You strike ${playerTarget.name} for ${playerDmg}.`);
acted = true;
if (playerTarget.hp <= 0) {
state.log.push(`${playerTarget.name} falls.`);
state.enemies = state.enemies.filter((e) => e.id !== playerTarget.id);
}
}
for (const summon of [...state.summons]) {
if (summon.hp <= 0) continue;
const target = nearestEnemy(summon.x, summon.y, state.enemies, UNIT_ATTACK_RANGE);
if (target) {
target.hp = Math.max(0, target.hp - summon.atk);
state.log.push(
`Your ${summon.type.toLowerCase()} hits ${target.name} for ${summon.atk}.`
);
acted = true;
if (target.hp <= 0) {
state.log.push(`${target.name} falls.`);
state.enemies = state.enemies.filter((e) => e.id !== target.id);
}
} else if (moveSummonTowardEnemy(state, summon)) {
acted = true;
}
}
let damageToPlayer = 0;
for (const enemy of [...state.enemies]) {
if (enemy.hp <= 0) continue;
const nearPlayer = manhattan(enemy.x, enemy.y, state.playerX, state.playerY) <= ENEMY_ATTACK_RANGE;
const nearSummons = state.summons.filter(
(s) => s.hp > 0 && manhattan(enemy.x, enemy.y, s.x, s.y) <= ENEMY_ATTACK_RANGE
);
if (!nearPlayer && nearSummons.length === 0) continue;
acted = true;
if (nearSummons.length > 0 && (!nearPlayer || Math.random() < 0.55)) {
const target = nearSummons[Math.floor(Math.random() * nearSummons.length)];
target.hp -= enemy.atk;
state.log.push(`${enemy.name} hits your ${target.type.toLowerCase()} for ${enemy.atk}.`);
if (target.hp <= 0) {
state.summons = state.summons.filter((s) => s.id !== target.id);
state.log.push(`Your ${target.type.toLowerCase()} is destroyed.`);
}
} else if (nearPlayer) {
damageToPlayer += enemy.atk;
}
}
if (damageToPlayer > 0) {
state.log.push(`You take ${damageToPlayer} damage.`);
}
if (state.enemies.filter((e) => e.hp > 0).length === 0) {
state.over = true;
state.won = true;
state.log.push("Floor cleared!");
}
if (state.log.length > 40) {
state.log = state.log.slice(-40);
}
return { state, damageToPlayer, acted };
}
/** Combat tick needed if fight in range OR summons can still hunt enemies. */
export function anyoneInCombatRange(state: DungeonState): boolean {
if (state.over) return false;
if (nearestEnemy(state.playerX, state.playerY, state.enemies, PLAYER_ATTACK_RANGE)) return true;
for (const s of state.summons) {
if (s.hp <= 0) continue;
if (nearestEnemy(s.x, s.y, state.enemies, UNIT_ATTACK_RANGE)) return true;
}
for (const e of state.enemies) {
if (e.hp <= 0) continue;
if (manhattan(e.x, e.y, state.playerX, state.playerY) <= ENEMY_ATTACK_RANGE) return true;
if (state.summons.some((s) => s.hp > 0 && manhattan(e.x, e.y, s.x, s.y) <= ENEMY_ATTACK_RANGE)) {
return true;
}
}
return false;
}
export function dungeonNeedsTick(state: DungeonState): boolean {
if (state.over) return false;
if (state.enemies.every((e) => e.hp <= 0)) return false;
if (anyoneInCombatRange(state)) return true;
return state.summons.some((s) => s.hp > 0);
}
export function placeSummon(
state: DungeonState,
type: ArmyUnitType,
x: number,
y: number,
casterInt: number
): { ok: boolean; error?: string; state: DungeonState } {
if (state.over) return { ok: false, error: "Combat over", state };
if (x < 0 || y < 0 || x >= state.width || y >= state.height) {
return { ok: false, error: "Out of bounds", state };
}
if (state.enemies.some((e) => e.x === x && e.y === y && e.hp > 0)) {
return { ok: false, error: "Tile occupied", state };
}
if (state.summons.some((s) => s.x === x && s.y === y)) {
return { ok: false, error: "Tile occupied", state };
}
if (x === state.playerX && y === state.playerY) {
return { ok: false, error: "Cannot summon on self", state };
}
const hp = 18 + casterInt * 2;
const atk = 4 + Math.floor(casterInt / 2);
state.summons.push({
id: `summon-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type,
x,
y,
hp,
maxHp: hp,
atk,
});
state.log.push(`You summon a ${type.toLowerCase()} at (${x},${y}).`);
return { ok: true, state };
}
export async function estimateArmyPower(characterId: string, casterInt: number) {
return getArmyPower(characterId, casterInt);
}
+121
View File
@@ -0,0 +1,121 @@
import type { Character, InventoryItem, Item, Specialty, ArmyUnit } from "@prisma/client";
import { prisma } from "../lib/prisma.js";
export type CharacterWithRelations = Character & {
inventory: (InventoryItem & { item: Item })[];
army: ArmyUnit[];
};
export function baseStatsForSpecialty(specialty: Specialty) {
if (specialty === "ZOMBIE") {
return { str: 8, dex: 4, int: 5, maxHp: 120, maxEnergy: 40 };
}
return { str: 4, dex: 7, int: 8, maxHp: 90, maxEnergy: 60 };
}
export function xpToNextLevel(level: number): number {
return 50 + level * 40;
}
export function equipmentBonuses(character: CharacterWithRelations) {
return character.inventory
.filter((i) => i.equipped)
.reduce(
(acc, inv) => ({
str: acc.str + inv.item.strBonus,
dex: acc.dex + inv.item.dexBonus,
int: acc.int + inv.item.intBonus,
hp: acc.hp + inv.item.hpBonus,
}),
{ str: 0, dex: 0, int: 0, hp: 0 }
);
}
export function effectiveStats(character: CharacterWithRelations) {
const bonus = equipmentBonuses(character);
return {
str: character.str + bonus.str,
dex: character.dex + bonus.dex,
int: character.int + bonus.int,
maxHp: character.maxHp + bonus.hp,
maxEnergy: character.maxEnergy,
};
}
export function serializeCharacter(character: CharacterWithRelations) {
const stats = effectiveStats(character);
return {
id: character.id,
name: character.name,
specialty: character.specialty,
level: character.level,
xp: character.xp,
xpToNext: xpToNextLevel(character.level),
unspentStatPoints: character.unspentStatPoints,
hp: character.hp,
maxHp: stats.maxHp,
energy: character.energy,
maxEnergy: stats.maxEnergy,
str: character.str,
dex: character.dex,
int: character.int,
effective: stats,
mapX: character.mapX,
mapY: character.mapY,
currentZone: character.currentZone,
dungeonFloor: character.dungeonFloor,
inventory: character.inventory.map((inv) => ({
id: inv.id,
equipped: inv.equipped,
quantity: inv.quantity,
item: {
id: inv.item.id,
key: inv.item.key,
name: inv.item.name,
description: inv.item.description,
slot: inv.item.slot,
strBonus: inv.item.strBonus,
dexBonus: inv.item.dexBonus,
intBonus: inv.item.intBonus,
hpBonus: inv.item.hpBonus,
rarity: inv.item.rarity,
},
})),
army: character.army.map((u) => ({
id: u.id,
type: u.type,
tier: u.tier,
count: u.count,
})),
};
}
export async function applyXp(
characterId: string,
current: Character,
amount: number
) {
let { level, xp, unspentStatPoints } = current;
xp += amount;
let leveled = 0;
while (xp >= xpToNextLevel(level)) {
xp -= xpToNextLevel(level);
level += 1;
unspentStatPoints += 3;
leveled += 1;
}
const updated = await prisma.character.update({
where: { id: characterId },
data: {
level,
xp,
unspentStatPoints,
maxHp: current.maxHp + leveled * 10,
hp: Math.min(current.hp + leveled * 10, current.maxHp + leveled * 10),
maxEnergy: current.maxEnergy + leveled * 2,
},
});
return { updated, leveled, xpGained: amount };
}
+459
View File
@@ -0,0 +1,459 @@
import type { Server, Socket } from "socket.io";
import { prisma } from "../lib/prisma.js";
import { config } from "../lib/config.js";
import { verifyToken, type AuthPayload } from "../middleware/auth.js";
import {
createDungeon,
dungeonNeedsTick,
moveInDungeon,
placeSummon,
resolveAutoCombatRound,
type DungeonState,
} from "../services/combat.js";
import {
applyXp,
effectiveStats,
serializeCharacter,
type CharacterWithRelations,
} from "../services/progression.js";
import { addArmyUnit, armyTypeForSpecialty, summonEnergyCost } from "../services/army.js";
type AuthedSocket = Socket & { data: { auth: AuthPayload } };
const worldPresence = new Map<
string,
{ characterId: string; name: string; specialty: string; x: number; y: number; socketId: string }
>();
const dungeons = new Map<string, DungeonState>();
const combatTimers = new Map<string, ReturnType<typeof setInterval>>();
const dungeonSockets = new Map<string, string>(); // characterId -> socketId
const COMBAT_TICK_MS = 900;
async function loadFullCharacter(characterId: string) {
return prisma.character.findUnique({
where: { id: characterId },
include: { inventory: { include: { item: true } }, army: true },
});
}
function broadcastWorld(io: Server) {
const players = [...worldPresence.values()].map((p) => ({
characterId: p.characterId,
name: p.name,
specialty: p.specialty,
x: p.x,
y: p.y,
}));
io.to("world").emit("world:players", {
players,
portal: { x: config.portalX, y: config.portalY },
map: { width: config.mapWidth, height: config.mapHeight },
});
}
function authHandshake(socket: Socket): AuthPayload | null {
const cookieHeader = socket.handshake.headers.cookie ?? "";
const match = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${config.cookieName}=`));
const fromCookie = match ? decodeURIComponent(match.split("=").slice(1).join("=")) : undefined;
const fromAuth = socket.handshake.auth?.token as string | undefined;
const token = fromAuth || fromCookie;
if (!token) return null;
return verifyToken(token);
}
function stopCombatLoop(characterId: string) {
const t = combatTimers.get(characterId);
if (t) {
clearInterval(t);
combatTimers.delete(characterId);
}
}
function startCombatLoop(io: Server, characterId: string) {
if (combatTimers.has(characterId)) return;
const timer = setInterval(() => {
void runCombatTick(io, characterId);
}, COMBAT_TICK_MS);
combatTimers.set(characterId, timer);
}
async function runCombatTick(io: Server, characterId: string) {
const state = dungeons.get(characterId);
if (!state || state.over) {
stopCombatLoop(characterId);
return;
}
if (!dungeonNeedsTick(state)) return;
const char = await loadFullCharacter(characterId);
if (!char || char.currentZone !== "DUNGEON") {
stopCombatLoop(characterId);
return;
}
const socketId = dungeonSockets.get(characterId);
const socket = socketId ? io.sockets.sockets.get(socketId) : undefined;
const { state: next, damageToPlayer, acted } = resolveAutoCombatRound(state, char);
if (!acted && damageToPlayer === 0) return;
let updatedChar: CharacterWithRelations = char;
if (damageToPlayer > 0) {
const newHp = Math.max(0, char.hp - damageToPlayer);
updatedChar = await prisma.character.update({
where: { id: char.id },
data: { hp: newHp },
include: { inventory: { include: { item: true } }, army: true },
});
if (newHp <= 0) {
next.over = true;
next.won = false;
next.log.push("You have fallen. Returning to the world...");
dungeons.set(characterId, next);
stopCombatLoop(characterId);
if (socket) {
socket.emit("dungeon:state", next);
socket.emit("character:update", serializeCharacter(updatedChar));
await exitDungeon(io, socket, characterId, false, true);
}
return;
}
}
dungeons.set(characterId, next);
if (socket) {
socket.emit("dungeon:state", next);
if (damageToPlayer > 0) {
socket.emit("character:update", serializeCharacter(updatedChar));
}
}
if (next.won) {
stopCombatLoop(characterId);
if (socket) {
await rewardAndExit(io, socket, updatedChar, next);
}
}
}
async function runWorldRegen(io: Server) {
for (const presence of worldPresence.values()) {
const char = await loadFullCharacter(presence.characterId);
if (!char || char.currentZone !== "WORLD") continue;
const stats = effectiveStats(char);
if (char.hp >= stats.maxHp && char.energy >= char.maxEnergy) continue;
const newHp = Math.min(stats.maxHp, char.hp + config.regenHp);
const newEnergy = Math.min(char.maxEnergy, char.energy + config.regenEnergy);
if (newHp === char.hp && newEnergy === char.energy) continue;
const updated = await prisma.character.update({
where: { id: char.id },
data: { hp: newHp, energy: newEnergy },
include: { inventory: { include: { item: true } }, army: true },
});
const socket = io.sockets.sockets.get(presence.socketId);
socket?.emit("character:update", serializeCharacter(updated));
}
}
export function registerSockets(io: Server) {
setInterval(() => {
void runWorldRegen(io);
}, config.regenIntervalMs);
io.use((socket, next) => {
const auth = authHandshake(socket);
if (!auth) {
next(new Error("Unauthorized"));
return;
}
(socket as AuthedSocket).data.auth = auth;
next();
});
io.on("connection", async (socket: Socket) => {
const auth = (socket as AuthedSocket).data.auth;
const character = await loadFullCharacter(auth.characterId);
if (!character) {
socket.disconnect(true);
return;
}
socket.join("world");
if (character.currentZone === "WORLD") {
worldPresence.set(character.id, {
characterId: character.id,
name: character.name,
specialty: character.specialty,
x: character.mapX,
y: character.mapY,
socketId: socket.id,
});
broadcastWorld(io);
} else {
let state = dungeons.get(character.id);
if (!state || state.over) {
state = createDungeon(character);
dungeons.set(character.id, state);
}
dungeonSockets.set(character.id, socket.id);
socket.join(`dungeon:${character.id}`);
socket.emit("dungeon:state", state);
startCombatLoop(io, character.id);
}
socket.emit("character:update", serializeCharacter(character));
socket.on("player:move", async (payload: { x: number; y: number }) => {
const char = await loadFullCharacter(auth.characterId);
if (!char || char.currentZone !== "WORLD") return;
const x = Math.floor(payload.x);
const y = Math.floor(payload.y);
if (x < 0 || y < 0 || x >= config.mapWidth || y >= config.mapHeight) {
socket.emit("error:message", "Out of bounds");
return;
}
if (x === char.mapX && y === char.mapY) return;
const updated = await prisma.character.update({
where: { id: char.id },
data: { mapX: x, mapY: y },
include: { inventory: { include: { item: true } }, army: true },
});
worldPresence.set(updated.id, {
characterId: updated.id,
name: updated.name,
specialty: updated.specialty,
x: updated.mapX,
y: updated.mapY,
socketId: socket.id,
});
broadcastWorld(io);
socket.emit("character:update", serializeCharacter(updated));
});
socket.on("player:enter_dungeon", async () => {
const char = await loadFullCharacter(auth.characterId);
if (!char || char.currentZone !== "WORLD") return;
const dist =
Math.abs(char.mapX - config.portalX) + Math.abs(char.mapY - config.portalY);
if (dist > 1) {
socket.emit("error:message", "Move closer to the portal");
return;
}
const floor = Math.max(1, char.dungeonFloor || 1);
const updated = await prisma.character.update({
where: { id: char.id },
data: { currentZone: "DUNGEON", dungeonFloor: floor },
include: { inventory: { include: { item: true } }, army: true },
});
worldPresence.delete(char.id);
broadcastWorld(io);
const state = createDungeon(updated);
dungeons.set(char.id, state);
dungeonSockets.set(char.id, socket.id);
socket.leave("world");
socket.join(`dungeon:${char.id}`);
socket.emit("zone:change", { zone: "DUNGEON" });
socket.emit("dungeon:state", state);
socket.emit("character:update", serializeCharacter(updated));
startCombatLoop(io, char.id);
});
socket.on("player:exit_dungeon", async () => {
stopCombatLoop(auth.characterId);
await exitDungeon(io, socket, auth.characterId, false);
});
socket.on("dungeon:move", async (payload: { x: number; y: number }) => {
const char = await loadFullCharacter(auth.characterId);
const state = dungeons.get(auth.characterId);
if (!char || !state || char.currentZone !== "DUNGEON") return;
const result = moveInDungeon(state, payload.x, payload.y);
if (!result.ok) {
socket.emit("error:message", result.error ?? "Cannot move");
return;
}
dungeons.set(auth.characterId, result.state);
dungeonSockets.set(auth.characterId, socket.id);
socket.emit("dungeon:state", result.state);
startCombatLoop(io, auth.characterId);
if (dungeonNeedsTick(result.state)) {
await runCombatTick(io, auth.characterId);
}
});
socket.on("dungeon:summon", async (payload: { x: number; y: number }) => {
await handleSummon(io, socket, auth.characterId, payload.x, payload.y);
});
socket.on("disconnect", () => {
const presence = [...worldPresence.entries()].find(([, p]) => p.socketId === socket.id);
if (presence) {
worldPresence.delete(presence[0]);
broadcastWorld(io);
}
if (dungeonSockets.get(auth.characterId) === socket.id) {
dungeonSockets.delete(auth.characterId);
// Keep dungeon state; pause combat until reconnect
stopCombatLoop(auth.characterId);
}
});
});
}
async function handleSummon(
io: Server,
socket: Socket,
characterId: string,
x: number,
y: number
) {
const char = await loadFullCharacter(characterId);
const state = dungeons.get(characterId);
if (!char || !state || char.currentZone !== "DUNGEON") return;
const cost = summonEnergyCost(1);
if (char.energy < cost) {
socket.emit("error:message", "Not enough energy");
return;
}
const stats = effectiveStats(char);
const type = armyTypeForSpecialty(char.specialty);
const placed = placeSummon(state, type, Math.floor(x), Math.floor(y), stats.int);
if (!placed.ok) {
socket.emit("error:message", placed.error ?? "Summon failed");
return;
}
const updated = await prisma.character.update({
where: { id: char.id },
data: { energy: char.energy - cost },
include: { inventory: { include: { item: true } }, army: true },
});
await addArmyUnit(char.id, type, 1, 1);
const refreshed = await loadFullCharacter(characterId);
state.energy = updated.energy;
dungeons.set(characterId, state);
dungeonSockets.set(characterId, socket.id);
socket.emit("dungeon:state", state);
socket.emit("character:update", serializeCharacter(refreshed!));
startCombatLoop(io, characterId);
if (dungeonNeedsTick(state)) {
await runCombatTick(io, characterId);
}
}
async function rewardAndExit(
io: Server,
socket: Socket,
character: CharacterWithRelations,
state: DungeonState
) {
const xpGain = 30 + state.floor * 20;
const { leveled } = await applyXp(character.id, character, xpGain);
const lootKeys = [
"bone_dagger",
"shadow_veil",
"grimoire_fragment",
"soul_phial",
"necrotic_blade",
"ossuary_plate",
];
const key = lootKeys[Math.floor(Math.random() * lootKeys.length)];
const item = await prisma.item.findUnique({ where: { key } });
if (item) {
await prisma.inventoryItem.create({
data: { characterId: character.id, itemId: item.id, quantity: 1 },
});
}
await prisma.character.update({
where: { id: character.id },
data: {
dungeonFloor: state.floor + 1,
energy: Math.min(character.maxEnergy, character.energy + 10),
},
});
socket.emit("dungeon:reward", {
xp: xpGain,
leveled,
loot: item ? item.name : null,
nextFloor: state.floor + 1,
});
await exitDungeon(io, socket, character.id, true);
}
async function exitDungeon(
io: Server,
socket: Socket,
characterId: string,
won: boolean,
defeated = false
) {
stopCombatLoop(characterId);
dungeons.delete(characterId);
dungeonSockets.delete(characterId);
const data: {
currentZone: "WORLD";
mapX?: number;
mapY?: number;
hp?: number;
} = { currentZone: "WORLD" };
if (defeated) {
data.mapX = config.spawnX;
data.mapY = config.spawnY;
const char = await prisma.character.findUnique({ where: { id: characterId } });
if (char) {
data.hp = Math.max(1, Math.floor(char.maxHp * 0.4));
}
}
const updated = await prisma.character.update({
where: { id: characterId },
data,
include: { inventory: { include: { item: true } }, army: true },
});
worldPresence.set(updated.id, {
characterId: updated.id,
name: updated.name,
specialty: updated.specialty,
x: updated.mapX,
y: updated.mapY,
socketId: socket.id,
});
socket.leave(`dungeon:${characterId}`);
socket.join("world");
broadcastWorld(io);
socket.emit("zone:change", { zone: "WORLD", won });
socket.emit("character:update", serializeCharacter(updated));
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"]
}