diff --git a/assets/black-bg.jpeg b/assets/black-bg.jpeg new file mode 100644 index 0000000..f200a23 Binary files /dev/null and b/assets/black-bg.jpeg differ diff --git a/assets/blueLight.png b/assets/blueLight.png new file mode 100644 index 0000000..793e449 Binary files /dev/null and b/assets/blueLight.png differ diff --git a/assets/minigame.png b/assets/minigame.png new file mode 100644 index 0000000..07a8c5e Binary files /dev/null and b/assets/minigame.png differ diff --git a/assets/normalBackground.png b/assets/normalBackground.png new file mode 100644 index 0000000..1de49f9 Binary files /dev/null and b/assets/normalBackground.png differ diff --git a/assets/shoeprint.png b/assets/shoeprint.png new file mode 100644 index 0000000..820af7e Binary files /dev/null and b/assets/shoeprint.png differ diff --git a/backend/server.js b/backend/server.js index b5a034d..ff3f2d3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -264,6 +264,32 @@ function buildInitialNotesState() { }; } +const STATIC_COMMUNITY_CASES = [ + { + caseCode: 'BEAST-001', + title: 'Beast Boy Case', + author: 'Community Spotlight', + description: 'Track a prank gone wrong at Titans Tower and uncover which clue is actually a trap.', + gameplayRating: 4.8, + updatedAt: null, + }, + { + caseCode: 'ACM-001', + title: 'The Night of the Build', + author: 'Community Spotlight', + description: 'A late-night build collapses minutes before demo day. Reconstruct the timeline and expose what really broke.', + gameplayRating: 4.9, + updatedAt: null, + }, +]; + +const STATIC_COMMUNITY_CONTRIBUTORS = [ + { name: 'Swarna', caseCount: 7, averageRating: 3.9, bestRating: 5.0 }, + { name: 'Nandy', caseCount: 6, averageRating: 3.8, bestRating: 5.0 }, + { name: 'Ryan', caseCount: 5, averageRating: 4.7, bestRating: 4.9 }, + { name: 'Urmi', caseCount: 4, averageRating: 2.6, bestRating: 4.8 }, +]; + // ── Routes ── app.post('/cases/create', async (req, res) => { try { @@ -349,15 +375,14 @@ app.post('/cases/create', async (req, res) => { }; // Case content is global and user-agnostic. - const caseResult = await casesCollection.updateOne( + await casesCollection.updateOne( { sessionId }, { $setOnInsert: caseDoc }, { upsert: true } ); - console.log('[/cases/create] cases write:', caseResult.upsertedCount, 'inserted,', caseResult.matchedCount, 'matched'); // User profile stores which cases this user has created. - const userResult = await usersCollection.updateOne( + await usersCollection.updateOne( { userId }, { $setOnInsert: { userId, createdAt: now }, @@ -366,19 +391,16 @@ app.post('/cases/create', async (req, res) => { }, { upsert: true } ); - console.log('[/cases/create] users write:', userResult.upsertedCount, 'inserted,', userResult.matchedCount, 'matched'); // Game collection stores user-specific gameplay state. - const gameResult = await gameCollection.updateOne( + await gameCollection.updateOne( { sessionId, userId }, { $setOnInsert: gameDoc }, { upsert: true } ); - console.log('[/cases/create] game write:', gameResult.upsertedCount, 'inserted,', gameResult.matchedCount, 'matched'); res.json({ success: true, sessionId }); } catch (err) { - console.error('[/cases/create] Error:', err.message); res.status(500).json({ error: err.message }); } }); @@ -539,10 +561,8 @@ app.post('/cases/:sessionId/outcome', async (req, res) => { featured: false, feedbackAt: null, }, - updatedAt: nowIso(), - lastAutosavedAt: nowIso(), }, - $inc: { revision: 1 } + $inc: { revision: 1 }, } ); @@ -558,9 +578,7 @@ app.post('/cases/:sessionId/outcome', async (req, res) => { app.post('/cases/:sessionId/feedback', async (req, res) => { try { - const { sessionId } = req.params; const { gameplayRating, featured } = req.body; - const userId = String(req.body.userId ?? '').trim(); if (![1, 2, 3, 4, 5].includes(Number(gameplayRating))) { return res.status(400).json({ error: 'gameplayRating must be 1-5' }); @@ -569,24 +587,7 @@ app.post('/cases/:sessionId/feedback', async (req, res) => { return res.status(400).json({ error: 'featured must be boolean' }); } - const result = await gameCollection.updateOne( - userId ? { sessionId, userId } : { sessionId }, - { - $set: { - 'outcome.gameplayRating': Number(gameplayRating), - 'outcome.featured': featured, - 'outcome.feedbackAt': nowIso(), - updatedAt: nowIso(), - lastAutosavedAt: nowIso(), - }, - $inc: { revision: 1 }, - } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: 'Case not found' }); - } - + // Rating/featured feedback is intentionally static and not persisted in MongoDB. res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -642,95 +643,32 @@ app.get('/community/feed', async (req, res) => { ? Math.max(1, Math.min(30, requestedLimit)) : 12; - const gameDocs = await gameCollection - .find( - { 'outcome.featured': true }, - { - projection: { - _id: 0, - userId: 1, - sessionId: 1, - updatedAt: 1, - 'outcome.gameplayRating': 1, - }, - } - ) - .sort({ updatedAt: -1 }) - .limit(limit) - .toArray(); - - const sessionIds = [...new Set(gameDocs.map((doc) => doc.sessionId).filter(Boolean))]; - const caseDocs = await casesCollection - .find( - { sessionId: { $in: sessionIds } }, - { - projection: { - _id: 0, - sessionId: 1, - caseId: 1, - 'caseData.caseReport.caseId': 1, - 'caseData.caseReport.caseTitle': 1, - 'caseData.caseReport.officialBriefing': 1, - 'caseData.caseReport.setting': 1, - }, - } - ) - .toArray(); - - const caseBySessionId = new Map(caseDocs.map((doc) => [doc.sessionId, doc])); - - const cases = gameDocs.map((gameDoc) => { - const caseDoc = caseBySessionId.get(gameDoc.sessionId) ?? {}; - const report = caseDoc.caseData?.caseReport ?? {}; - return { - caseCode: report.caseId ?? caseDoc.caseId ?? gameDoc.sessionId, - title: report.caseTitle ?? 'Untitled Case', - author: gameDoc.userId ? `Detective ${gameDoc.userId.slice(0, 8)}` : 'Anonymous Detective', - description: report.officialBriefing ?? report.setting ?? 'No case description available.', - gameplayRating: Number(gameDoc.outcome?.gameplayRating ?? 0), - updatedAt: gameDoc.updatedAt ?? null, - }; + res.json({ + cases: STATIC_COMMUNITY_CASES.slice(0, limit), + contributors: STATIC_COMMUNITY_CONTRIBUTORS, }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); - const contributors = await gameCollection - .aggregate([ - { - $match: { - userId: { $type: 'string', $ne: '' }, - 'outcome.featured': true, - 'outcome.gameplayRating': { $gte: 1, $lte: 5 }, - }, - }, - { - $group: { - _id: '$userId', - caseCount: { $sum: 1 }, - averageRating: { $avg: '$outcome.gameplayRating' }, - bestRating: { $max: '$outcome.gameplayRating' }, - }, - }, - { $sort: { averageRating: -1, caseCount: -1 } }, - { $limit: 8 }, - { - $project: { - _id: 0, - userId: '$_id', - caseCount: 1, - averageRating: { $round: ['$averageRating', 2] }, - bestRating: 1, - }, - }, - ]) +app.get('/cases/user/:userId', async (req, res) => { + console.log("User ID case fetching endpoint reached!!!"); + console.log("userId:", req.params.userId); + try { + const { userId } = req.params; + + const docs = await db.collection("cases") + .find({ userId }, { projection: { _id: 0 } }) + .sort({ updatedAt: -1 }) .toArray(); - const formattedContributors = contributors.map((c) => ({ - name: `Detective ${String(c.userId).slice(0, 8)}`, - caseCount: Number(c.caseCount ?? 0), - averageRating: Number(c.averageRating ?? 0), - bestRating: Number(c.bestRating ?? 0), - })); + if (!docs.length) { + console.log("No cases found for this user"); + return res.json([]); + } - res.json({ cases, contributors: formattedContributors }); + res.json(docs); } catch (err) { res.status(500).json({ error: err.message }); } @@ -809,45 +747,6 @@ app.get('/cases/user/:userId', async (req, res) => { res.status(500).json({ error: err.message }); } }) - -// PATCH is used as it's altering a state of an existing resource (PUT and POST are for creating new entries) -app.patch('/cases/:sessionId/star', async (req, res) => { - try { - const { sessionId } = req.params; - const { userId, isStarred } = req.body; - - if (!userId || !String(userId).trim()) { - return res.status(400).json({ error: 'userId is required' }); - } - - if (typeof isStarred !== 'boolean') { - return res.status(400).json({ error: 'isStarred must be boolean' }); - } - - const result = await gameCollection.updateOne( - { - $or: [{ sessionId }, { caseId: sessionId }], - userId: String(userId).trim(), - }, - { - $set: { - isStarred, - updatedAt: nowIso(), - lastAutosavedAt: nowIso(), - }, - $inc: { revision: 1 }, - } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ error: 'Case not found for this user' }); - } - - res.json({ success: true, sessionId, isStarred }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); /* app.post('/case/create', async (req, res) => { console.log('[/case/create] received:', req.body.caseId); diff --git a/src/Accuse.css b/src/Accuse.css index 437fdfc..21536d7 100644 --- a/src/Accuse.css +++ b/src/Accuse.css @@ -6,9 +6,12 @@ justify-content: center; align-items: flex-start; min-height: 100vh; + height: 100vh; padding: 0; box-sizing: border-box; transition: background-color 2.5s ease; + overflow-y: auto; + overflow-x: hidden; } .accuse-flash { @@ -39,21 +42,59 @@ } .accuse-verdict { - font-size: clamp(2.2rem, 4vw, 2.6rem); + font-size: clamp(2rem, 5vw, 3.2rem); font-family: 'Press Start 2P', cursive; letter-spacing: -1px; text-transform: uppercase; - margin: 2px 0; + margin: 4px 0 8px; width: 100%; text-align: center; white-space: normal; overflow-wrap: break-word; word-break: break-word; text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.1); + line-height: 1.1; } .accuse-guilty { - color: #004f0d; + /* color: #004f0d; */ + color: #7a1f1f; +} + +.accuse-verdict-name { + display: block; + font-size: clamp(0.95rem, 2.2vw, 1.6rem); + letter-spacing: 0.08em; + line-height: 1; + margin: 0 0 0.4rem 0; +} + +.accuse-verdict-word{ + display: block; + font-size: clamp(0.6rem, 1.1vw, 0.85rem); + letter-spacing: 0.08em; + line-height: 1; + margin: 0 0 0.4rem 0; +} + +.accuse-verdict-stack { + display: grid; + grid-auto-flow: row; + justify-items: center; + align-items: start; + row-gap: 0.35rem; +} + +.accuse-verdict-guilty { + display: block; + font-size: clamp(4rem, 15vw, 8rem); + line-height: 1; + letter-spacing: -0.06em; + margin: 0; + color: #7a1f1f; + text-shadow: + 3px 3px 0 rgba(0, 0, 0, 0.12), + 1px 1px 0 rgba(255, 255, 255, 0.1); } .accuse-innocent { @@ -131,7 +172,7 @@ color: #101010; text-align: left; line-height: 1.8; - margin: 0 0 1.5rem; + margin: 0 0 .5rem; width: 100%; border: 2px solid #000000; padding: 12px 16px; @@ -248,9 +289,11 @@ .accuse-buttons .detective-button { width: 100%; - background: #5a0a0a; + height: 60px; + background: #7a1f1f; color: #ffffff; border: 2px solid #2b0000; + filter: none; } .accuse-buttons .detective-button:hover { @@ -316,6 +359,7 @@ .footer-strip { margin-top: -14px; + margin-top: 50px; border-top: 2px solid #000000; padding-top: 8px; } \ No newline at end of file diff --git a/src/Accuse.tsx b/src/Accuse.tsx index a2aaf68..71665ea 100644 --- a/src/Accuse.tsx +++ b/src/Accuse.tsx @@ -145,7 +145,7 @@ function PoliceSiren() { function Accuse() { const navigate = useNavigate(); - const { accusationResult, resetGame, seed, currentSessionId, player } = useGameStore(); + const { accusationResult, player } = useGameStore(); const [phase, setPhase] = useState<'flash' | 'dark' | 'reveal'>('flash'); const [gameplayRating, setGameplayRating] = useState(null); @@ -155,7 +155,7 @@ function Accuse() { const [feedbackError, setFeedbackError] = useState(null); const audioRefs = useRef([]); - const saveFeedback = async (nextRating: number, nextFeatured: boolean) => { + const saveFeedback = async (nextRating: number, _nextFeatured: boolean) => { if (feedbackSaving) return; if (nextRating === null) { @@ -163,34 +163,13 @@ function Accuse() { return; } - const sessionId = - currentSessionId || - localStorage.getItem('lastSessionId') || - ''; - - if (!sessionId) { - setFeedbackError('Could not determine case session id.'); - return; - } - setFeedbackSaving(true); setFeedbackError(null); try { - const res = await fetch(`http://localhost:3000/cases/${encodeURIComponent(sessionId)}/feedback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: seed?.userId ?? '', - gameplayRating: nextRating, - featured: nextFeatured, - }), - }); - - if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Keep rating/featured behavior local and static. + await Promise.resolve(nextRating); setFeedbackSaved(true); - } catch { - setFeedbackError('Could not save rating right now.'); } finally { setFeedbackSaving(false); } @@ -289,7 +268,15 @@ function Accuse() {

- {isCorrect ? `${accusedName} was guilty` : `${accusedName} was innocent`} + {isCorrect ? ( + + {accusedName} + found + GUILTY + + ) : ( + `${accusedName} was innocent` + )}

@@ -321,13 +308,7 @@ function Accuse() { : {accusedName} }
- - {/* RIGHT COLUMN */} -
- {explanation && ( -

{explanation}

- )} {caseCode && (

@@ -379,17 +360,19 @@ function Accuse() { {feedbackError &&

{feedbackError}

}
+ + + + + {/* RIGHT COLUMN */} +
+ {explanation && ( +

{explanation}

+ )}
diff --git a/src/App.css b/src/App.css index 7204c08..f555cb9 100644 --- a/src/App.css +++ b/src/App.css @@ -289,9 +289,14 @@ body { .chat-layered-photo { width: 140%; - top: -25%; + top: -9px; left: 60%; filter: grayscale(80%) contrast(.8); + cursor: pointer; +} + +.chat-photo-placeholder { + position: relative; } .photo-placeholder { @@ -868,6 +873,7 @@ body { /* --- FOOTER --- */ .footer-strip { + z-index: auto; width: 100%; margin-top: 12px; border-top: 4px double #000000; @@ -888,6 +894,7 @@ body { /* --- Tips Section --- */ .tip-section { + z-index:0; width: 100%; margin-top: 12px; border-top: 4px double #000000; diff --git a/src/CaseReportScreen.css b/src/CaseReportScreen.css index dda39ba..0827677 100644 --- a/src/CaseReportScreen.css +++ b/src/CaseReportScreen.css @@ -35,7 +35,7 @@ /* subtle vignette */ radial-gradient(ellipse at center, transparent 60%, rgba(80,60,20,0.18) 100%); color: var(--ink); - max-width: 740px; + max-width: 1000px; width: 100%; padding: 64px 72px; border-radius: 1px; @@ -100,11 +100,11 @@ text-align: center; margin-bottom: 8px; letter-spacing: 6px; - font-size: 10px; + font-size: 15px; text-transform: uppercase; color: var(--ink-faded); font-family: 'Bebas Neue', sans-serif; - font-size: 13px; + font-size: 18px; letter-spacing: 8px; } @@ -121,7 +121,7 @@ .case-id { display: block; font-family: 'Bebas Neue', sans-serif; - font-size: 12px; + font-size: 17px; letter-spacing: 5px; color: var(--ink-faded); margin-bottom: 8px; @@ -129,7 +129,7 @@ .report-title { font-family: 'Special Elite', cursive; - font-size: 32px; + font-size: 37px; line-height: 1.15; color: var(--ink); margin: 0 0 10px; @@ -137,7 +137,7 @@ } .report-meta { - font-size: 12px; + font-size: 17px; color: var(--ink-faded); letter-spacing: 1px; font-style: italic; @@ -152,7 +152,7 @@ border: 3px solid var(--red-stamp); color: var(--red-stamp); font-family: 'Bebas Neue', sans-serif; - font-size: 18px; + font-size: 23px; letter-spacing: 4px; padding: 4px 10px; opacity: 0.75; @@ -167,7 +167,7 @@ .report-section-title { font-family: 'Bebas Neue', sans-serif; - font-size: 15px; + font-size: 20px; letter-spacing: 4px; color: var(--ink-faded); text-transform: uppercase; @@ -186,19 +186,19 @@ .victim-name { font-family: 'Special Elite', cursive; - font-size: 20px; + font-size: 25px; margin: 0 0 4px; } .victim-details { - font-size: 13px; + font-size: 18px; color: var(--ink-faded); margin: 0 0 10px; font-style: italic; } .cause-of-death { - font-size: 13px; + font-size: 18px; color: var(--red); font-weight: 700; font-family: 'Courier Prime', monospace; @@ -206,14 +206,14 @@ } .body-found { - font-size: 13px; + font-size: 18px; color: var(--ink-faded); margin-top: 4px; } /* ── Briefing ── */ .briefing-text { - font-size: 15px; + font-size: 17px; line-height: 1.85; color: var(--ink); font-style: italic; @@ -232,7 +232,7 @@ } .report-list li { - font-size: 13.5px; + font-size: 18.5px; line-height: 1.6; padding-left: 20px; position: relative; @@ -244,18 +244,67 @@ position: absolute; left: 0; color: var(--red); - font-size: 11px; + font-size: 16px; top: 3px; } .questions-list li::before { content: '?'; font-family: 'Special Elite', cursive; - font-size: 14px; + font-size: 19px; color: var(--ink-faded); top: 1px; } +.report-evidence-spotlight { + margin: 18px 0 8px; + padding: 18px 20px; + border: 3px solid rgba(139, 26, 26, 0.8); + border-left: 10px solid var(--red); + background: #0000000a; +} + +.report-evidence-kicker { + margin: 0 0 10px; + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 2.4px; + font-size: 22px; + color: var(--red); +} + +.report-evidence-line { + margin: 0; + display: grid; + grid-template-columns: 122px 1fr; + gap: 12px; + font-size: 19px; + line-height: 1.55; + color: var(--ink); + align-items: start; +} + +.report-evidence-line + .report-evidence-line { + margin-top: 6px; +} + +.report-evidence-label { + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 1.5px; + color: var(--ink-faded); +} + +.report-evidence-value { + display: block; + color: var(--ink); +} + +.report-evidence-key { + font-family: 'Special Elite', cursive; + font-style: italic; + font-weight: 700; + color: rgba(139, 26, 26, 0.8); +} + /* ── Divider ── */ .report-divider { border: none; @@ -272,7 +321,7 @@ margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--ink-faded); - font-size: 11px; + font-size: 16px; color: var(--ink-faded); font-style: italic; } @@ -281,7 +330,7 @@ border-bottom: 1px solid var(--ink-faded); width: 160px; margin-top: 24px; - font-size: 10px; + font-size: 15px; letter-spacing: 1px; text-transform: uppercase; } @@ -295,7 +344,7 @@ background: var(--ink); color: var(--paper); font-family: 'Bebas Neue', sans-serif; - font-size: 18px; + font-size: 23px; letter-spacing: 6px; text-transform: uppercase; border: none; @@ -374,6 +423,9 @@ -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + mask: + linear-gradient(#fff 0 0) padding-box, + linear-gradient(#fff 0 0); -webkit-mask-composite: destination-out; mask-composite: exclude; pointer-events: none; diff --git a/src/CaseReportScreen.tsx b/src/CaseReportScreen.tsx index 91abadf..175f142 100644 --- a/src/CaseReportScreen.tsx +++ b/src/CaseReportScreen.tsx @@ -2,10 +2,10 @@ import { useGameStore } from "./useGameStore"; import "./CaseReportScreen.css"; import { useNavigate } from "react-router"; import { useState, useRef, useCallback, useEffect } from "react"; -import TutorialModal from './components/tutorial-modal/Tutorial'; +import TutorialModal from "./components/tutorial-modal/Tutorial"; -const LENS_SIZE = 200; // diameter in px -const ZOOM = 2.0; // zoom level +const LENS_SIZE = 350; // diameter in px +const ZOOM = 1.3; // zoom level interface LensPos { x: number; y: number; } @@ -13,12 +13,60 @@ import type { CaseFilePlayer } from "./caseFile"; type CaseReport = CaseFilePlayer["caseReport"]; +function VictimSection({ report }: { report: CaseReport }) { + return ( +
+
Victim
+
+

{report.victim.name}

+

+ {report.victim.age} years old  ·  {report.victim.occupation} +

+

✦ {report.victim.causeOfDeath}

+

+ {report.victim.background} +

+
+
+ ); +} + +function KeyEvidenceSnapshot() { + return ( +
+

KEY EVIDENCE SNAPSHOT

+

+ Weapon + + Struck from behind with a History textbook. + +

+

+ Timing + + Between 7:38 PM and 7:50 PM. + +

+

+ Location + + Hallway outside ESCW 1.315. + +

+

+ Tied To + + The textbook belongs to Mercedes. + +

+
+ ); +} + export default function CaseReportScreen() { const navigate = useNavigate(); const { player } = useGameStore(); const report = player?.caseReport; - const TUTORIAL_READY_KEY = 'tutorialReadyAfterReport'; // keeps track of whether case report has been viewed or not - const TUTORIAL_DESK_ENTERED_KEY = 'tutorialDeskEntered'; const overlayRef = useRef(null); const docRef = useRef(null); @@ -34,10 +82,6 @@ export default function CaseReportScreen() { return () => window.removeEventListener("resize", update); }, []); - useEffect(() => { - localStorage.setItem(TUTORIAL_READY_KEY, 'true'); - }, []); - const handleMouseMove = useCallback((e: React.MouseEvent) => { const overlay = overlayRef.current; if (!overlay) return; @@ -51,7 +95,6 @@ export default function CaseReportScreen() { const handleMouseLeave = useCallback(() => setLens(null), []); const handleGoToDesk = () => { - localStorage.setItem(TUTORIAL_DESK_ENTERED_KEY, 'true'); navigate('/desk'); }; @@ -106,7 +149,7 @@ export default function CaseReportScreen() {
)} -
+
Agentic Detective Bureau  ·  Homicide Division
@@ -117,23 +160,12 @@ export default function CaseReportScreen() {
Confidential
-
-
Victim
-
-

{report.victim.name}

-

- {report.victim.age} years old  ·  {report.victim.occupation} -

-

- {report.victim.background} -

-

✦ {report.victim.causeOfDeath}

-

Found at: {report.victim.bodyFoundAt}

-
-
+ +
+
Detective Briefing

{report.officialBriefing}

@@ -176,6 +208,7 @@ export default function CaseReportScreen() { function MagnifiedDocContent({ report }: { report: CaseReport }) { if (!report) return null; + return (