diff --git a/backend/src/app.js b/backend/src/app.js index d82349e6..4bbf5e6c 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -41,6 +41,7 @@ import prRoutes from "./routes/prRoutes.js"; import contractRoutes from "./routes/contractRoutes.js"; import testScreeningRoutes from "./routes/testScreeningRoutes.js"; import recordsRoutes from "./routes/recordsRoutes.js"; +import merchandiseLinesRoutes from "./routes/merchandiseLinesRoutes.js"; const app = express(); @@ -132,6 +133,7 @@ app.use("/api/studios", apiRateLimiter, prRoutes); app.use("/api/contracts", apiRateLimiter, contractRoutes); app.use("/api/movies", apiRateLimiter, testScreeningRoutes); app.use("/api/records", apiRateLimiter, recordsRoutes); +app.use("/api/merchandise", apiRateLimiter, merchandiseLinesRoutes); app.use((req, res) => { res.status(404).json({ diff --git a/backend/src/controllers/merchandiseLinesController.js b/backend/src/controllers/merchandiseLinesController.js new file mode 100644 index 00000000..9e5dfeea --- /dev/null +++ b/backend/src/controllers/merchandiseLinesController.js @@ -0,0 +1,137 @@ +import Studio from "../models/Studio.js"; +import GameState from "../models/GameState.js"; +import { MERCH_LINE_TIERS } from "../services/simulation/engines/merchandiseLinesEngine.js"; + +const CAMPAIGN_COST = 250000; +const CAMPAIGN_DURATION_WEEKS = 4; +const CAMPAIGN_BOOST = 0.5; // +50% revenue for the line during campaign + +/** + * GET /api/merchandise/lines + * Returns all merchandise lines for the player's studio. + */ +export const getMerchandiseLines = async (req, res) => { + try { + const studio = await Studio.findOne({ owner: req.user._id }); + if (!studio) return res.status(404).json({ success: false, message: "Studio not found." }); + + res.status(200).json({ + success: true, + data: { + merchandiseLines: studio.merchandiseLines || [], + availableTiers: Object.entries(MERCH_LINE_TIERS).map(([id, cfg]) => ({ id, ...cfg })) + } + }); + } catch (error) { + console.error("getMerchandiseLines error:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +/** + * POST /api/merchandise/lines + * Launch a new merchandise product line. + * Body: { tier: "APPAREL" | "COLLECTIBLES" | "DIGITAL" | "TOYS" } + */ +export const launchMerchandiseLine = async (req, res) => { + try { + const { tier } = req.body; + if (!tier || !MERCH_LINE_TIERS[tier]) { + return res.status(400).json({ + success: false, + message: `Invalid tier. Choose one of: ${Object.keys(MERCH_LINE_TIERS).join(", ")}` + }); + } + + const studio = await Studio.findOne({ owner: req.user._id }); + if (!studio) return res.status(404).json({ success: false, message: "Studio not found." }); + + const gameState = await GameState.findOne({ user: req.user._id }); + if (!gameState) return res.status(404).json({ success: false, message: "Game state not found." }); + + // Prevent duplicates + if (!studio.merchandiseLines) studio.merchandiseLines = []; + const existing = studio.merchandiseLines.find(l => l.tier === tier && l.active); + if (existing) { + return res.status(400).json({ success: false, message: `An active ${MERCH_LINE_TIERS[tier].label} line already exists.` }); + } + + const SETUP_COST = 500000; + if ((studio.money || 0) < SETUP_COST) { + return res.status(400).json({ success: false, message: `Insufficient funds. Setup costs ₹${SETUP_COST.toLocaleString("en-IN")}.` }); + } + + studio.money -= SETUP_COST; + studio.merchandiseLines.push({ + tier, + active: true, + launchedWeek: gameState.currentWeek, + totalRevenue: 0, + campaigns: [] + }); + + await studio.save(); + + res.status(201).json({ + success: true, + message: `${MERCH_LINE_TIERS[tier].label} merchandise line launched!`, + data: studio.merchandiseLines.at(-1) + }); + } catch (error) { + console.error("launchMerchandiseLine error:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +/** + * POST /api/merchandise/lines/:lineId/campaign + * Launch a seasonal campaign for a specific merchandise line. + */ +export const launchSeasonalCampaign = async (req, res) => { + try { + const { lineId } = req.params; + + const studio = await Studio.findOne({ owner: req.user._id }); + if (!studio) return res.status(404).json({ success: false, message: "Studio not found." }); + + const gameState = await GameState.findOne({ user: req.user._id }); + if (!gameState) return res.status(404).json({ success: false, message: "Game state not found." }); + + const line = (studio.merchandiseLines || []).find(l => l._id?.toString() === lineId); + if (!line) return res.status(404).json({ success: false, message: "Merchandise line not found." }); + if (!line.active) return res.status(400).json({ success: false, message: "Merchandise line is not active." }); + + // Check for existing active campaign + const hasActiveCampaign = (line.campaigns || []).some(c => c.active); + if (hasActiveCampaign) { + return res.status(400).json({ success: false, message: "A seasonal campaign is already running for this line." }); + } + + if ((studio.money || 0) < CAMPAIGN_COST) { + return res.status(400).json({ + success: false, + message: `Insufficient funds. Campaign costs ₹${CAMPAIGN_COST.toLocaleString("en-IN")}.` + }); + } + + studio.money -= CAMPAIGN_COST; + if (!line.campaigns) line.campaigns = []; + line.campaigns.push({ + startWeek: gameState.currentWeek, + endWeek: gameState.currentWeek + CAMPAIGN_DURATION_WEEKS, + boostMultiplier: CAMPAIGN_BOOST, + active: true + }); + + await studio.save(); + + res.status(201).json({ + success: true, + message: `Seasonal campaign launched! +${CAMPAIGN_BOOST * 100}% revenue boost for ${CAMPAIGN_DURATION_WEEKS} weeks.`, + data: line.campaigns.at(-1) + }); + } catch (error) { + console.error("launchSeasonalCampaign error:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; diff --git a/backend/src/models/Studio.js b/backend/src/models/Studio.js index 2c329e2a..5514d1bd 100644 --- a/backend/src/models/Studio.js +++ b/backend/src/models/Studio.js @@ -117,6 +117,20 @@ const studioSchema = new mongoose.Schema( week: { type: Number }, reputationImpact: { type: Number }, }], + + // Merchandise Product Lines (issue #349) + merchandiseLines: [{ + tier: { type: String, enum: ["APPAREL", "COLLECTIBLES", "DIGITAL", "TOYS"], required: true }, + active: { type: Boolean, default: true }, + launchedWeek: { type: Number }, + totalRevenue: { type: Number, default: 0 }, + campaigns: [{ + startWeek: { type: Number }, + endWeek: { type: Number }, + boostMultiplier: { type: Number, default: 0.5 }, + active: { type: Boolean, default: true } + }] + }], }, { timestamps: true, diff --git a/backend/src/routes/merchandiseLinesRoutes.js b/backend/src/routes/merchandiseLinesRoutes.js new file mode 100644 index 00000000..f813b102 --- /dev/null +++ b/backend/src/routes/merchandiseLinesRoutes.js @@ -0,0 +1,15 @@ +import express from "express"; +import { protect } from "../middleware/authMiddleware.js"; +import { + getMerchandiseLines, + launchMerchandiseLine, + launchSeasonalCampaign +} from "../controllers/merchandiseLinesController.js"; + +const router = express.Router(); + +router.get("/lines", protect, getMerchandiseLines); +router.post("/lines", protect, launchMerchandiseLine); +router.post("/lines/:lineId/campaign", protect, launchSeasonalCampaign); + +export default router; diff --git a/backend/src/services/simulation/engines/merchandiseLinesEngine.js b/backend/src/services/simulation/engines/merchandiseLinesEngine.js new file mode 100644 index 00000000..7b0c4646 --- /dev/null +++ b/backend/src/services/simulation/engines/merchandiseLinesEngine.js @@ -0,0 +1,78 @@ +/** + * @fileoverview Merchandise Lines Engine (issue #349) + * + * Processes studio-level merchandise product lines with seasonal campaigns. + * Merchandise lines are independent of individual movies and represent + * branded product categories (apparel, collectibles, digital goods, etc.) + * that generate recurring revenue based on studio prestige and fan count. + * + * Each week: + * 1. Active lines generate revenue proportional to fans × prestige × quality. + * 2. Active seasonal campaigns apply a boost multiplier. + * 3. Campaigns expire after their duration. + */ + +/** + * The merchandise line tiers and their base weekly revenue rates. + */ +export const MERCH_LINE_TIERS = { + APPAREL: { label: "Apparel", baseFanRate: 0.05, basePrestigeRate: 200 }, + COLLECTIBLES: { label: "Collectibles", baseFanRate: 0.08, basePrestigeRate: 400 }, + DIGITAL: { label: "Digital Goods",baseFanRate: 0.03, basePrestigeRate: 100 }, + TOYS: { label: "Toys & Games", baseFanRate: 0.06, basePrestigeRate: 300 }, +}; + +/** + * Processes all active merchandise lines and seasonal campaigns for the studio. + * Mutates `studio` in place (adds money, expires campaigns). + * + * @param {object} gameState + * @param {object} studio + * @returns {number} Total weekly revenue from merch lines + */ +export const processMerchandiseLines = (gameState, studio) => { + if (!studio.merchandiseLines || studio.merchandiseLines.length === 0) return 0; + + const currentWeek = gameState.currentWeek; + let totalRevenue = 0; + + for (const line of studio.merchandiseLines) { + if (!line.active) continue; + + const tier = MERCH_LINE_TIERS[line.tier] || MERCH_LINE_TIERS.APPAREL; + + // Base weekly revenue from fans + prestige + const fanRevenue = Math.floor((studio.fans || 0) * tier.baseFanRate); + const prestigeRevenue = Math.floor((studio.prestige || 0) * tier.basePrestigeRate); + let lineRevenue = fanRevenue + prestigeRevenue; + + // Apply seasonal campaign boost + let campaignBoost = 1.0; + const activeCampaigns = (line.campaigns || []).filter(c => c.active); + for (const campaign of activeCampaigns) { + campaignBoost += campaign.boostMultiplier || 0; + + // Expire campaign if it's past its end week + if (currentWeek >= campaign.endWeek) { + campaign.active = false; + } + } + + lineRevenue = Math.floor(lineRevenue * campaignBoost); + line.totalRevenue = (line.totalRevenue || 0) + lineRevenue; + totalRevenue += lineRevenue; + } + + if (totalRevenue > 0) { + studio.money = (studio.money || 0) + totalRevenue; + + studio.merchandiseIncomeHistory = studio.merchandiseIncomeHistory || []; + studio.merchandiseIncomeHistory.push({ + week: currentWeek, + amount: totalRevenue, + reason: "Merchandise Line Sales" + }); + } + + return totalRevenue; +}; diff --git a/backend/src/services/simulation/engines/tickEngine.js b/backend/src/services/simulation/engines/tickEngine.js index 096b1909..e2f932d9 100644 --- a/backend/src/services/simulation/engines/tickEngine.js +++ b/backend/src/services/simulation/engines/tickEngine.js @@ -12,6 +12,7 @@ import { generateRivalStudios, processRivalStudios } from "./rivalStudioEngine.j import { processProductionEvents } from "./eventEngine.js"; import { processRandomEvents } from "./eventEngine.js"; import { processMerchandiseSales } from "./merchandiseEngine.js"; +import { processMerchandiseLines } from "./merchandiseLinesEngine.js"; import { processAnnualAwards } from "./awardsEngine.js"; import { generateNewsFromTrend, generateNewsFromEvent } from "./newsEngine.js"; import { processStreamingPlatformGrowth, processStreamingRevenue } from "./streamingEngine.js"; @@ -227,6 +228,9 @@ export const processWeeklyTick = async (gameState, studio) => { // after platform growth so royalties reflect this week's platform popularity. await processStreamingRevenue(gameState, studio); + // Process studio merchandise product lines (issue #349) + processMerchandiseLines(gameState, studio); + // 12. PR & Scandal events — roll for studio scandals and apply // reputation decay (issue #281). processScandals(gameState, studio); diff --git a/backend/tests/merchandiseLinesEngine.test.js b/backend/tests/merchandiseLinesEngine.test.js new file mode 100644 index 00000000..b350810d --- /dev/null +++ b/backend/tests/merchandiseLinesEngine.test.js @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert"; +import { processMerchandiseLines, MERCH_LINE_TIERS } from "../src/services/simulation/engines/merchandiseLinesEngine.js"; + +test("merchandiseLinesEngine: returns 0 if no lines", () => { + const gameState = { currentWeek: 10 }; + const studio = { money: 1000000, fans: 10000, prestige: 50 }; + const revenue = processMerchandiseLines(gameState, studio); + assert.strictEqual(revenue, 0); + assert.strictEqual(studio.money, 1000000); +}); + +test("merchandiseLinesEngine: generates revenue for active apparel line", () => { + const gameState = { currentWeek: 10 }; + const studio = { + money: 1000000, + fans: 10000, + prestige: 50, + merchandiseLines: [{ tier: "APPAREL", active: true, totalRevenue: 0, campaigns: [] }], + merchandiseIncomeHistory: [] + }; + const revenue = processMerchandiseLines(gameState, studio); + assert.ok(revenue > 0); + assert.ok(studio.money > 1000000); + assert.strictEqual(studio.merchandiseIncomeHistory.length, 1); +}); + +test("merchandiseLinesEngine: skips inactive lines", () => { + const gameState = { currentWeek: 10 }; + const studio = { + money: 1000000, + fans: 10000, + prestige: 50, + merchandiseLines: [{ tier: "APPAREL", active: false, totalRevenue: 0, campaigns: [] }] + }; + const revenue = processMerchandiseLines(gameState, studio); + assert.strictEqual(revenue, 0); + assert.strictEqual(studio.money, 1000000); +}); + +test("merchandiseLinesEngine: applies campaign boost", () => { + const gameState = { currentWeek: 10 }; + const studioWithoutCampaign = { + money: 1000000, fans: 10000, prestige: 50, + merchandiseLines: [{ tier: "APPAREL", active: true, totalRevenue: 0, campaigns: [] }], + merchandiseIncomeHistory: [] + }; + const studioWithCampaign = { + money: 1000000, fans: 10000, prestige: 50, + merchandiseLines: [{ tier: "APPAREL", active: true, totalRevenue: 0, campaigns: [ + { startWeek: 8, endWeek: 14, boostMultiplier: 0.5, active: true } + ]}], + merchandiseIncomeHistory: [] + }; + const revenueWithout = processMerchandiseLines(gameState, studioWithoutCampaign); + const revenueWith = processMerchandiseLines(gameState, studioWithCampaign); + assert.ok(revenueWith > revenueWithout, "Campaign should boost revenue"); +}); + +test("merchandiseLinesEngine: expires campaign after endWeek", () => { + const gameState = { currentWeek: 20 }; + const studio = { + money: 1000000, fans: 10000, prestige: 50, + merchandiseLines: [{ tier: "APPAREL", active: true, totalRevenue: 0, campaigns: [ + { startWeek: 8, endWeek: 14, boostMultiplier: 0.5, active: true } + ]}], + merchandiseIncomeHistory: [] + }; + processMerchandiseLines(gameState, studio); + // Campaign should have been expired (week 20 > endWeek 14) + const campaign = studio.merchandiseLines[0].campaigns[0]; + assert.strictEqual(campaign.active, false); +});