first commit
This commit is contained in:
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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!) });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user