From 8a91a298a79da2c0f52ba59281906c8d1dcf4c5f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 9 Jun 2026 23:41:27 +0200 Subject: [PATCH] server: database schema, migrations and seed Define Prisma schema (Player, City, Good, InventoryItem, Transaction, Mission, WorldEvent), initial migration SQL, and seed script to populate cities and goods with base game data. Co-Authored-By: Claude Sonnet 4.6 --- .../20260609211013_init/migration.sql | 178 ++++++++++++++++++ server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 157 +++++++++++++++ server/prisma/seed.ts | 81 ++++++++ 4 files changed, 419 insertions(+) create mode 100644 server/prisma/migrations/20260609211013_init/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma create mode 100644 server/prisma/seed.ts diff --git a/server/prisma/migrations/20260609211013_init/migration.sql b/server/prisma/migrations/20260609211013_init/migration.sql new file mode 100644 index 0000000..b3c4116 --- /dev/null +++ b/server/prisma/migrations/20260609211013_init/migration.sql @@ -0,0 +1,178 @@ +-- CreateEnum +CREATE TYPE "MissionStatus" AS ENUM ('STARTED', 'COMPLETED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "MissionType" AS ENUM ('DELIVERY', 'THEFT', 'SMUGGLING', 'INTEL'); + +-- CreateEnum +CREATE TYPE "EventType" AS ENUM ('MARKET_BOOM', 'POLICE_RAID', 'SHORTAGE', 'BLACKOUT'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Player" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "money" INTEGER NOT NULL DEFAULT 1000, + "reputation" INTEGER NOT NULL DEFAULT 0, + "level" INTEGER NOT NULL DEFAULT 1, + "experience" INTEGER NOT NULL DEFAULT 0, + "currentCityId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Player_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "City" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "riskLevel" INTEGER NOT NULL, + "policePressure" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "economyModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "City_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Item" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "basePrice" INTEGER NOT NULL, + "rarity" INTEGER NOT NULL, + "illegalLevel" INTEGER NOT NULL, + "weight" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Item_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InventoryItem" ( + "playerId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("playerId","itemId") +); + +-- CreateTable +CREATE TABLE "MarketPrice" ( + "cityId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "buyPrice" INTEGER NOT NULL, + "sellPrice" INTEGER NOT NULL, + "demand" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "supply" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketPrice_pkey" PRIMARY KEY ("cityId","itemId") +); + +-- CreateTable +CREATE TABLE "Mission" ( + "id" TEXT NOT NULL, + "cityId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "type" "MissionType" NOT NULL, + "difficulty" INTEGER NOT NULL, + "durationSeconds" INTEGER NOT NULL, + "rewardMoney" INTEGER NOT NULL, + "rewardXp" INTEGER NOT NULL, + "risk" DOUBLE PRECISION NOT NULL, + "requiredItemId" TEXT, + "requiredItemQuantity" INTEGER, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Mission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PlayerMission" ( + "id" TEXT NOT NULL, + "playerId" TEXT NOT NULL, + "missionId" TEXT NOT NULL, + "status" "MissionStatus" NOT NULL DEFAULT 'STARTED', + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completesAt" TIMESTAMP(3) NOT NULL, + "resolvedAt" TIMESTAMP(3), + + CONSTRAINT "PlayerMission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorldEvent" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "type" "EventType" NOT NULL, + "cityId" TEXT, + "priceModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "policeModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WorldEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Player_userId_key" ON "Player"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Player_displayName_key" ON "Player"("displayName"); + +-- CreateIndex +CREATE UNIQUE INDEX "City_name_key" ON "City"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Item_name_key" ON "Item"("name"); + +-- AddForeignKey +ALTER TABLE "Player" ADD CONSTRAINT "Player_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Player" ADD CONSTRAINT "Player_currentCityId_fkey" FOREIGN KEY ("currentCityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mission" ADD CONSTRAINT "Mission_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mission" ADD CONSTRAINT "Mission_requiredItemId_fkey" FOREIGN KEY ("requiredItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_missionId_fkey" FOREIGN KEY ("missionId") REFERENCES "Mission"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorldEvent" ADD CONSTRAINT "WorldEvent_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 0000000..48b7afb --- /dev/null +++ b/server/prisma/schema.prisma @@ -0,0 +1,157 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum MissionStatus { + STARTED + COMPLETED + FAILED +} + +enum MissionType { + DELIVERY + THEFT + SMUGGLING + INTEL +} + +enum EventType { + MARKET_BOOM + POLICE_RAID + SHORTAGE + BLACKOUT +} + +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + player Player? +} + +model Player { + id String @id @default(uuid()) + userId String @unique + displayName String @unique + money Int @default(1000) + reputation Int @default(0) + level Int @default(1) + experience Int @default(0) + currentCityId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + currentCity City @relation(fields: [currentCityId], references: [id]) + inventory InventoryItem[] + missions PlayerMission[] +} + +model City { + id String @id @default(uuid()) + name String @unique + riskLevel Int + policePressure Float @default(1.0) + economyModifier Float @default(1.0) + createdAt DateTime @default(now()) + + players Player[] + prices MarketPrice[] + missions Mission[] + events WorldEvent[] +} + +model Item { + id String @id @default(uuid()) + name String @unique + basePrice Int + rarity Int + illegalLevel Int + weight Int @default(1) + createdAt DateTime @default(now()) + + inventory InventoryItem[] + prices MarketPrice[] + missions Mission[] +} + +model InventoryItem { + playerId String + itemId String + quantity Int + + player Player @relation(fields: [playerId], references: [id]) + item Item @relation(fields: [itemId], references: [id]) + + @@id([playerId, itemId]) +} + +model MarketPrice { + cityId String + itemId String + buyPrice Int + sellPrice Int + demand Float @default(1.0) + supply Float @default(1.0) + updatedAt DateTime @updatedAt + + city City @relation(fields: [cityId], references: [id]) + item Item @relation(fields: [itemId], references: [id]) + + @@id([cityId, itemId]) +} + +model Mission { + id String @id @default(uuid()) + cityId String + title String + type MissionType + difficulty Int + durationSeconds Int + rewardMoney Int + rewardXp Int + risk Float + requiredItemId String? + requiredItemQuantity Int? + expiresAt DateTime + createdAt DateTime @default(now()) + + city City @relation(fields: [cityId], references: [id]) + requiredItem Item? @relation(fields: [requiredItemId], references: [id]) + playerMissions PlayerMission[] +} + +model PlayerMission { + id String @id @default(uuid()) + playerId String + missionId String + status MissionStatus @default(STARTED) + startedAt DateTime @default(now()) + completesAt DateTime + resolvedAt DateTime? + + player Player @relation(fields: [playerId], references: [id]) + mission Mission @relation(fields: [missionId], references: [id]) +} + +model WorldEvent { + id String @id @default(uuid()) + title String + description String + type EventType + cityId String? + priceModifier Float @default(1.0) + policeModifier Float @default(1.0) + startsAt DateTime + endsAt DateTime + createdAt DateTime @default(now()) + + city City? @relation(fields: [cityId], references: [id]) +} diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts new file mode 100644 index 0000000..4b90ec9 --- /dev/null +++ b/server/prisma/seed.ts @@ -0,0 +1,81 @@ +import { PrismaClient } from '@prisma/client'; +import { computePrices } from '../src/modules/market/pricing.js'; +import { randomFloat } from '../src/shared/validators.js'; + +const prisma = new PrismaClient(); + +/** Città iniziali (blueprint §19): economia e rischio differenziati. */ +const CITIES = [ + { name: 'Milano', riskLevel: 3, policePressure: 1.1, economyModifier: 1.25 }, + { name: 'Napoli', riskLevel: 4, policePressure: 1.3, economyModifier: 1.0 }, + { name: 'Palermo', riskLevel: 4, policePressure: 1.25, economyModifier: 0.85 }, + { name: 'Torino', riskLevel: 2, policePressure: 0.9, economyModifier: 1.0 }, + { name: 'Bari', riskLevel: 3, policePressure: 1.0, economyModifier: 0.85 }, +]; + +/** Item commerciabili iniziali (blueprint §19). */ +const ITEMS = [ + { name: 'Gioielli', basePrice: 500, rarity: 2, illegalLevel: 2, weight: 1 }, + { name: 'Componenti elettronici', basePrice: 350, rarity: 1, illegalLevel: 1, weight: 2 }, + { name: 'Documenti falsi', basePrice: 700, rarity: 3, illegalLevel: 4, weight: 1 }, + { name: 'Auto rubate', basePrice: 1800, rarity: 4, illegalLevel: 5, weight: 10 }, + { name: 'Informazioni riservate', basePrice: 1200, rarity: 4, illegalLevel: 3, weight: 1 }, + { name: 'Armi leggere', basePrice: 1500, rarity: 3, illegalLevel: 5, weight: 4 }, +]; + +async function main(): Promise { + for (const city of CITIES) { + await prisma.city.upsert({ + where: { name: city.name }, + create: city, + update: { + riskLevel: city.riskLevel, + policePressure: city.policePressure, + economyModifier: city.economyModifier, + }, + }); + } + console.log(`Città: ${CITIES.length}`); + + for (const item of ITEMS) { + await prisma.item.upsert({ + where: { name: item.name }, + create: item, + update: { + basePrice: item.basePrice, + rarity: item.rarity, + illegalLevel: item.illegalLevel, + weight: item.weight, + }, + }); + } + console.log(`Item: ${ITEMS.length}`); + + // Listino iniziale per ogni coppia città/item, con domanda e offerta + // leggermente casuali. I prezzi già esistenti non vengono toccati. + const cities = await prisma.city.findMany(); + const items = await prisma.item.findMany(); + let createdPrices = 0; + for (const city of cities) { + for (const item of items) { + const demand = Math.round(randomFloat(0.8, 1.2) * 100) / 100; + const supply = Math.round(randomFloat(0.8, 1.2) * 100) / 100; + const prices = computePrices(item.basePrice, city.economyModifier, demand, supply, 1); + const result = await prisma.marketPrice.upsert({ + where: { cityId_itemId: { cityId: city.id, itemId: item.id } }, + create: { cityId: city.id, itemId: item.id, demand, supply, ...prices }, + update: {}, + }); + if (result) createdPrices += 1; + } + } + console.log(`Prezzi di mercato: ${createdPrices}`); + console.log('Seed completato.'); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect());