Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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({
Expand Down
137 changes: 137 additions & 0 deletions backend/src/controllers/merchandiseLinesController.js
Original file line number Diff line number Diff line change
@@ -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" });
}
};
14 changes: 14 additions & 0 deletions backend/src/models/Studio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions backend/src/routes/merchandiseLinesRoutes.js
Original file line number Diff line number Diff line change
@@ -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;
78 changes: 78 additions & 0 deletions backend/src/services/simulation/engines/merchandiseLinesEngine.js
Original file line number Diff line number Diff line change
@@ -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;
};
4 changes: 4 additions & 0 deletions backend/src/services/simulation/engines/tickEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
73 changes: 73 additions & 0 deletions backend/tests/merchandiseLinesEngine.test.js
Original file line number Diff line number Diff line change
@@ -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);
});