diff --git a/backend/src/app.js b/backend/src/app.js index b3d554ea..91352626 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -42,6 +42,7 @@ import contractRoutes from "./routes/contractRoutes.js"; import testScreeningRoutes from "./routes/testScreeningRoutes.js"; import recordsRoutes from "./routes/recordsRoutes.js"; import insuranceRoutes from "./routes/insuranceRoutes.js"; +import facilityRoutes from "./routes/facilityRoutes.js"; import agencyRoutes from "./routes/agencyRoutes.js"; @@ -136,6 +137,7 @@ app.use("/api/contracts", apiRateLimiter, contractRoutes); app.use("/api/movies", apiRateLimiter, testScreeningRoutes); app.use("/api/records", apiRateLimiter, recordsRoutes); app.use("/api/insurance", apiRateLimiter, insuranceRoutes); +app.use("/api/facilities", apiRateLimiter, facilityRoutes); app.use("/api/talent-agencies", apiRateLimiter, agencyRoutes); diff --git a/backend/src/controllers/facilityController.js b/backend/src/controllers/facilityController.js new file mode 100644 index 00000000..3b8c4406 --- /dev/null +++ b/backend/src/controllers/facilityController.js @@ -0,0 +1,78 @@ +import StudioFacility from "../models/StudioFacility.js"; +import Studio from "../models/Studio.js"; +import { calculateFacilityUpgrade } from "../services/simulation/engines/facilityEngine.js"; + +export const getStudioFacilities = async (req, res, next) => { + try { + const facilities = await StudioFacility.find({ studioId: req.user.studioId }); + return res.status(200).json({ success: true, data: facilities }); + } catch (error) { + next(error); + } +}; + +export const buildFacility = async (req, res, next) => { + try { + const { facilityType } = req.body; + + const existing = await StudioFacility.findOne({ studioId: req.user.studioId, facilityType }); + const currentTier = existing ? existing.tierLevel : 0; + const upgradeDetails = calculateFacilityUpgrade(facilityType, currentTier || 1); + + const studio = await Studio.findById(req.user.studioId); + if (studio.money < upgradeDetails.cost) { + return res.status(400).json({ success: false, message: "Insufficient studio funds to build facility" }); + } + + studio.money -= upgradeDetails.cost; + await studio.save(); + + let facility; + if (existing) { + existing.tierLevel = upgradeDetails.nextTier; + existing.qualityBoost = upgradeDetails.qualityBoost; + existing.maintenanceCostPerWeek = upgradeDetails.maintenanceCostPerWeek; + existing.rentalIncomePerWeek = upgradeDetails.rentalIncomePerWeek; + facility = await existing.save(); + } else { + facility = await StudioFacility.create({ + studioId: req.user.studioId, + facilityType, + tierLevel: 1, + qualityBoost: upgradeDetails.qualityBoost, + maintenanceCostPerWeek: upgradeDetails.maintenanceCostPerWeek, + rentalIncomePerWeek: upgradeDetails.rentalIncomePerWeek, + }); + } + + return res.status(201).json({ + success: true, + message: `Facility ${facilityType} successfully commissioned`, + data: facility, + }); + } catch (error) { + next(error); + } +}; + +export const toggleFacilityRental = async (req, res, next) => { + try { + const { facilityId, isRentedToThirdParty } = req.body; + + const facility = await StudioFacility.findOne({ _id: facilityId, studioId: req.user.studioId }); + if (!facility) { + return res.status(404).json({ success: false, message: "Facility not found" }); + } + + facility.isRentedToThirdParty = isRentedToThirdParty; + await facility.save(); + + return res.status(200).json({ + success: true, + message: `Facility third-party rental status updated to ${isRentedToThirdParty}`, + data: facility, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/models/StudioFacility.js b/backend/src/models/StudioFacility.js new file mode 100644 index 00000000..059101a2 --- /dev/null +++ b/backend/src/models/StudioFacility.js @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; + +const studioFacilitySchema = new mongoose.Schema( + { + studioId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Studio", + required: true, + index: true, + }, + facilityType: { + type: String, + required: true, + enum: ["SOUNDSTAGE_COMPLEX", "VFX_VIRTUAL_PRODUCTION_LED", "POST_PRODUCTION_SUITE", "BACKLOT_SET"], + }, + tierLevel: { + type: Number, + default: 1, + min: 1, + max: 5, + }, + maintenanceCostPerWeek: { + type: Number, + required: true, + default: 10000, + }, + qualityBoost: { + type: Number, + default: 5, + }, + rentalIncomePerWeek: { + type: Number, + default: 15000, + }, + isRentedToThirdParty: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +const StudioFacility = mongoose.model("StudioFacility", studioFacilitySchema); +export default StudioFacility; diff --git a/backend/src/routes/facilityRoutes.js b/backend/src/routes/facilityRoutes.js new file mode 100644 index 00000000..7e7bbd43 --- /dev/null +++ b/backend/src/routes/facilityRoutes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middleware/authMiddleware.js"; +import validateRequest from "../middleware/validationMiddleware.js"; +import { + getStudioFacilities, + buildFacility, + toggleFacilityRental, +} from "../controllers/facilityController.js"; +import { buildFacilitySchema, toggleRentalSchema } from "../validators/facilityValidators.js"; + +const router = express.Router(); + +router.use(protect); + +router.get("/list", getStudioFacilities); +router.post("/build", validateRequest(buildFacilitySchema), buildFacility); +router.post("/rental", validateRequest(toggleRentalSchema), toggleFacilityRental); + +export default router; diff --git a/backend/src/services/simulation/engines/facilityEngine.js b/backend/src/services/simulation/engines/facilityEngine.js new file mode 100644 index 00000000..1fd5a14c --- /dev/null +++ b/backend/src/services/simulation/engines/facilityEngine.js @@ -0,0 +1,45 @@ +/** + * Facility Engine + * Calculates maintenance costs, production quality boosts, and third-party rental yields for studio real estate. + */ + +export function calculateFacilityUpgrade(facilityType, currentTier = 1) { + const baseCosts = { + SOUNDSTAGE_COMPLEX: 500000, + VFX_VIRTUAL_PRODUCTION_LED: 1200000, + POST_PRODUCTION_SUITE: 350000, + BACKLOT_SET: 450000, + }; + + const nextTier = Math.min(5, currentTier + 1); + const cost = (baseCosts[facilityType] || 500000) * currentTier; + const qualityBoost = nextTier * 4; + const maintenanceCostPerWeek = Math.round(cost * 0.01); + const rentalIncomePerWeek = Math.round(maintenanceCostPerWeek * 1.5); + + return { + nextTier, + cost, + qualityBoost, + maintenanceCostPerWeek, + rentalIncomePerWeek, + }; +} + +export function processWeeklyFacilities(facilities = []) { + let totalMaintenance = 0; + let totalRentalIncome = 0; + + facilities.forEach((f) => { + totalMaintenance += f.maintenanceCostPerWeek || 0; + if (f.isRentedToThirdParty) { + totalRentalIncome += f.rentalIncomePerWeek || 0; + } + }); + + return { + totalMaintenance, + totalRentalIncome, + netFinancialFlow: totalRentalIncome - totalMaintenance, + }; +} diff --git a/backend/src/validators/facilityValidators.js b/backend/src/validators/facilityValidators.js new file mode 100644 index 00000000..3d049368 --- /dev/null +++ b/backend/src/validators/facilityValidators.js @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const buildFacilitySchema = z.object({ + facilityType: z.enum(["SOUNDSTAGE_COMPLEX", "VFX_VIRTUAL_PRODUCTION_LED", "POST_PRODUCTION_SUITE", "BACKLOT_SET"]), +}); + +export const toggleRentalSchema = z.object({ + facilityId: z.string().min(1, "Facility ID is required"), + isRentedToThirdParty: z.boolean(), +}); diff --git a/backend/tests/facilityEngine.test.js b/backend/tests/facilityEngine.test.js new file mode 100644 index 00000000..fb0de81f --- /dev/null +++ b/backend/tests/facilityEngine.test.js @@ -0,0 +1,29 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + calculateFacilityUpgrade, + processWeeklyFacilities, +} from "../src/services/simulation/engines/facilityEngine.js"; + +describe("Facility Engine Unit Tests", () => { + it("calculateFacilityUpgrade returns correct cost and quality boost", () => { + const details = calculateFacilityUpgrade("VFX_VIRTUAL_PRODUCTION_LED", 1); + + assert.strictEqual(details.nextTier, 2); + assert.strictEqual(details.cost, 1200000); + assert.strictEqual(details.qualityBoost, 8); + }); + + it("processWeeklyFacilities calculates maintenance costs and rental income", () => { + const facilities = [ + { maintenanceCostPerWeek: 10000, rentalIncomePerWeek: 15000, isRentedToThirdParty: true }, + { maintenanceCostPerWeek: 5000, rentalIncomePerWeek: 7500, isRentedToThirdParty: false }, + ]; + + const result = processWeeklyFacilities(facilities); + + assert.strictEqual(result.totalMaintenance, 15000); + assert.strictEqual(result.totalRentalIncome, 15000); + assert.strictEqual(result.netFinancialFlow, 0); + }); +}); diff --git a/docs/studio-vfx-facility-infrastructure.md b/docs/studio-vfx-facility-infrastructure.md new file mode 100644 index 00000000..a72072ad --- /dev/null +++ b/docs/studio-vfx-facility-infrastructure.md @@ -0,0 +1,18 @@ +# Studio Real Estate & VFX Facility Infrastructure System + +## Overview +The Studio Facility Infrastructure module allows movie studios to acquire physical real estate, construct advanced soundstages, build LED virtual production suites, and establish post-production sound facilities. + +## Facility Categories & Benefits +1. **SOUNDSTAGE_COMPLEX**: Cuts production delays and improves film shooting capacity. +2. **VFX_VIRTUAL_PRODUCTION_LED**: Provides major quality boosts for sci-fi/action projects. +3. **POST_PRODUCTION_SUITE**: Enhances film editing, color grading, and audio mastering. +4. **BACKLOT_SET**: Provides reusable outdoor sets and lowers production location budgets. + +## Rental Yield Monetization +Idle studio facilities can be leased to third-party independent filmmakers to generate weekly recurring rental revenue for the studio treasury. + +## API Endpoints +- `GET /api/facilities/list`: Fetch all facilities owned by the studio. +- `POST /api/facilities/build`: Construct or upgrade a studio facility. +- `POST /api/facilities/rental`: Toggle third-party rental status. diff --git a/frontend/src/pages/studio/FacilityManager.jsx b/frontend/src/pages/studio/FacilityManager.jsx new file mode 100644 index 00000000..4133eaa8 --- /dev/null +++ b/frontend/src/pages/studio/FacilityManager.jsx @@ -0,0 +1,80 @@ +import React, { useState, useEffect } from "react"; +import api from "../../api/apiClient"; + +const FacilityManager = () => { + const [facilities, setFacilities] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchFacilities(); + }, []); + + const fetchFacilities = async () => { + try { + setLoading(true); + const res = await api.get("/facilities/list"); + if (res.data.success) { + setFacilities(res.data.data); + } + } catch (err) { + console.error("Failed to load facilities", err); + } finally { + setLoading(false); + } + }; + + const handleToggleRental = async (facilityId, isRented) => { + try { + const res = await api.post("/facilities/rental", { + facilityId, + isRentedToThirdParty: !isRented, + }); + if (res.data.success) { + fetchFacilities(); + } + } catch (err) { + console.error("Failed to toggle rental", err); + } + }; + + return ( +
Manage studio real estate, soundstages, LED virtual production stages, and third-party rentals.
+ + {loading ? ( +No physical facility assets constructed yet.
+Quality Boost: +{f.qualityBoost} pts
+Weekly Maintenance: ${f.maintenanceCostPerWeek?.toLocaleString()}
+Rental Yield: ${f.rentalIncomePerWeek?.toLocaleString()}/wk
+