From e59e3bdd67280efd1233fdc1fa006e6d9c8a23f9 Mon Sep 17 00:00:00 2001 From: CuriosityQuantified Date: Sun, 9 Aug 2026 06:20:15 -0400 Subject: [PATCH] fix #8: Feature: Tutorial Mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactive, first-run tutorial that walks new users through the core game loop and can be replayed from Settings. - components/TutorialOverlay.jsx: 6-step guided walkthrough (welcome, scenario presentation, first response, turn progression, scoring, and encouraging exploration) with a fixed sample scenario, an interactive response choice, a demo turn advance, and a sample score reveal. Steps are gated so users must interact before continuing (interactive, not passive). Includes skip/back/finish controls. - pages/simulation.jsx: launch the tutorial once for new users via the save-the-world:tutorial-status localStorage flag (SSR-safe, storage failures handled); add a Settings panel with a Replay Tutorial control; add a dismissible gameplay hint during play. - tests-e2e/tutorial.spec.js: hermetic regression suite covering all four acceptance criteria (first-time launch, interactive walkthrough, skip + replay from Settings, gameplay hint). - Legacy e2e specs (difficulty/mobile/save-resume/simulation) seed tutorial-status=completed so the first-run modal never intercepts post-onboarding gameplay flows. - .github/workflows/ci.yml: run the tutorial suite in the regressions job (3-job CI shape preserved; no code-graph job — repo has no graph). Closes #8 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 + components/TutorialOverlay.jsx | 283 +++++++++++++++++++++++++++++++++ pages/simulation.jsx | 164 ++++++++++++++++++- tests-e2e/difficulty.spec.js | 1 + tests-e2e/mobile.spec.js | 4 + tests-e2e/save-resume.spec.js | 4 + tests-e2e/simulation.spec.js | 7 + tests-e2e/tutorial.spec.js | 118 ++++++++++++++ 8 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 components/TutorialOverlay.jsx create mode 100644 tests-e2e/tutorial.spec.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d56c7b5..8c12c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,10 @@ jobs: run: npx playwright test tests-e2e/difficulty.spec.js --project=chromium --reporter=line env: CI: true + - name: Run tutorial regression suite + run: npx playwright test tests-e2e/tutorial.spec.js --project=chromium --reporter=line + env: + CI: true - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 diff --git a/components/TutorialOverlay.jsx b/components/TutorialOverlay.jsx new file mode 100644 index 0000000..88ee03a --- /dev/null +++ b/components/TutorialOverlay.jsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState } from "react"; + +const STEPS = [ + { + title: "Welcome, crisis manager", + description: "You will guide a crisis response across three turns. Read the situation, choose a response, and learn from the score at the end.", + }, + { + title: "Read the scenario", + description: "Every turn starts with a scenario. Look for the problem, the people affected, and the goal before deciding what to do.", + }, + { + title: "Make your first response", + description: "There is no single perfect answer. Pick a response below to practice making a decision, then try the real game in your own words.", + }, + { + title: "Advance the turns", + description: "Your response moves the simulation forward. New information appears each turn, so adapt your plan as the situation changes.", + }, + { + title: "Learn from your score", + description: "The final score reflects the impact and clarity of your choices. Treat the feedback as a guide for your next attempt.", + }, + { + title: "You are ready to explore", + description: "Start a simulation whenever you are ready. You can replay this walkthrough from Settings at any time.", + }, +]; + +const sampleScenario = "A coastal city has lost power. The hospital has backup batteries for one hour."; + +function StepIllustration({ stepIndex, selectedResponse, onSelectResponse, demoTurn, onAdvanceDemo, scoreRevealed, onRevealScore }) { + if (stepIndex === 0) { + return ( +
+ + Observe → Decide → Adapt + The best strategy is to make a thoughtful choice, then use the next turn to improve it. +
+ ); + } + + if (stepIndex === 1) { + return ( +
+ SAMPLE SCENARIO +

{sampleScenario}

+ Hint: prioritize the people at greatest risk. +
+ ); + } + + if (stepIndex === 2) { + return ( +
+ CHOOSE A RESPONSE +
+ + +
+ + {selectedResponse ? "Good choice — the next turn will reveal its consequences." : "Select an option to continue."} + +
+ ); + } + + if (stepIndex === 3) { + return ( +
+ TURN PROGRESSION +
TURN {demoTurn} / 3
+

+ {demoTurn === 1 ? "Your first response is in. Advance the demo to see how a new turn builds on it." : "Turn 2 brings new information. Re-read the situation and adapt your next response."} +

+ +
+ ); + } + + if (stepIndex === 4) { + return ( +
+ SAMPLE SCORE + {scoreRevealed ? ( +
+
80Impact
+
90Clarity
+
85Overall
+
+ ) : ( + + )} + {scoreRevealed ? "Use the feedback to shape a stronger next response." : "Reveal the score to see how choices are evaluated."} +
+ ); + } + + return ( +
+ + Start with a clear plan + You can pause, skip, or replay the tutorial from Settings whenever you need a refresher. +
+ ); +} + +export default function TutorialOverlay({ isOpen, onComplete, onSkip }) { + const [stepIndex, setStepIndex] = useState(0); + const [selectedResponse, setSelectedResponse] = useState(null); + const [demoTurn, setDemoTurn] = useState(1); + const [scoreRevealed, setScoreRevealed] = useState(false); + + useEffect(() => { + if (isOpen) { + setStepIndex(0); + setSelectedResponse(null); + setDemoTurn(1); + setScoreRevealed(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const needsResponse = stepIndex === 2 && !selectedResponse; + const needsTurnAdvance = stepIndex === 3 && demoTurn === 1; + const needsScoreReveal = stepIndex === 4 && !scoreRevealed; + const canContinue = !needsResponse && !needsTurnAdvance && !needsScoreReveal; + const isLastStep = stepIndex === STEPS.length - 1; + + const handleContinue = () => { + if (!canContinue) return; + if (isLastStep) { + onComplete(); + return; + } + setStepIndex((current) => current + 1); + }; + + const currentStep = STEPS[stepIndex]; + + return ( +
+
+
+ INTERACTIVE TUTORIAL + STEP {stepIndex + 1} OF {STEPS.length} +
+ +
+ ); +} + +const styles = { + overlay: { + position: "fixed", + inset: 0, + zIndex: 2000, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "16px", + backgroundColor: "rgba(0, 0, 0, 0.88)", + color: "#fff", + fontFamily: "Arial, sans-serif", + }, + modal: { + width: "min(100%, 760px)", + maxHeight: "92vh", + overflowY: "auto", + padding: "28px", + border: "2px solid #00ff00", + borderRadius: "12px", + background: "linear-gradient(145deg, #101c18, #111827)", + boxShadow: "0 0 35px rgba(0, 255, 0, 0.28)", + }, + progressRow: { + display: "flex", + justifyContent: "space-between", + gap: "12px", + alignItems: "center", + color: "#9ca3af", + fontSize: "12px", + letterSpacing: "1px", + }, + eyebrow: { color: "#00ff00", fontWeight: 700 }, + progress: { whiteSpace: "nowrap" }, + progressTrack: { + height: "5px", + marginTop: "12px", + marginBottom: "26px", + overflow: "hidden", + borderRadius: "999px", + backgroundColor: "#263238", + }, + progressBar: { height: "100%", borderRadius: "999px", backgroundColor: "#00ff00", transition: "width 0.2s ease" }, + title: { margin: "0 0 10px", color: "#fff", fontSize: "clamp(24px, 4vw, 34px)" }, + description: { margin: "0 0 22px", color: "#d1d5db", fontSize: "16px", lineHeight: 1.55 }, + illustration: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "10px", + minHeight: "170px", + justifyContent: "center", + padding: "24px", + border: "1px solid #35594a", + borderRadius: "10px", + backgroundColor: "rgba(0, 255, 0, 0.05)", + textAlign: "center", + }, + illustrationIcon: { color: "#00ff00", fontSize: "40px" }, + illustrationTitle: { color: "#b7ffca", fontSize: "18px" }, + illustrationText: { color: "#c7d2d0", fontSize: "14px", lineHeight: 1.5 }, + scenarioCard: { padding: "22px", border: "1px solid #345a87", borderRadius: "10px", backgroundColor: "rgba(0, 140, 255, 0.1)" }, + cardLabel: { display: "block", marginBottom: "12px", color: "#67e8f9", fontSize: "11px", fontWeight: 700, letterSpacing: "1.4px" }, + scenarioText: { margin: "0 0 14px", color: "#fff", fontSize: "19px", lineHeight: 1.5 }, + scenarioHint: { color: "#9ca3af", fontSize: "13px" }, + interactiveCard: { padding: "22px", border: "1px solid #4b5563", borderRadius: "10px", backgroundColor: "rgba(17, 24, 39, 0.9)" }, + choiceList: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "12px" }, + choiceButton: { minHeight: "52px", padding: "12px", border: "1px solid #4b5563", borderRadius: "7px", backgroundColor: "#1f2937", color: "#e5e7eb", cursor: "pointer", fontSize: "14px" }, + choiceButtonSelected: { borderColor: "#00ff00", backgroundColor: "#123b24", color: "#b7ffca" }, + feedback: { display: "block", marginTop: "16px", color: "#9ca3af", fontSize: "13px" }, + turnIndicator: { display: "inline-block", marginBottom: "14px", padding: "9px 14px", borderRadius: "6px", color: "#00ff00", backgroundColor: "#123b24", fontWeight: 700, letterSpacing: "1px" }, + secondaryButton: { minHeight: "44px", padding: "10px 16px", border: "1px solid #67e8f9", borderRadius: "6px", backgroundColor: "#0f2933", color: "#a5f3fc", cursor: "pointer", fontWeight: 700 }, + scoreGrid: { display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "12px", marginBottom: "12px", textAlign: "center" }, + scoreValue: { display: "block", color: "#00ff00", fontSize: "28px" }, + scoreLabel: { display: "block", marginTop: "5px", color: "#d1d5db", fontSize: "12px" }, + actions: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "12px", marginTop: "28px" }, + navigation: { display: "flex", gap: "10px", marginLeft: "auto" }, + skipButton: { minHeight: "44px", padding: "10px 14px", border: "none", background: "transparent", color: "#9ca3af", cursor: "pointer", textDecoration: "underline" }, + backButton: { minHeight: "44px", padding: "10px 16px", border: "1px solid #4b5563", borderRadius: "6px", backgroundColor: "transparent", color: "#d1d5db", cursor: "pointer" }, + nextButton: { minHeight: "44px", padding: "10px 18px", border: "2px solid #00ff00", borderRadius: "6px", backgroundColor: "#00ff00", color: "#061006", cursor: "pointer", fontWeight: 700 }, + nextButtonDisabled: { borderColor: "#4b5563", backgroundColor: "#374151", color: "#9ca3af", cursor: "not-allowed" }, +}; diff --git a/pages/simulation.jsx b/pages/simulation.jsx index fefa16a..db9099c 100644 --- a/pages/simulation.jsx +++ b/pages/simulation.jsx @@ -1,8 +1,10 @@ import React, { useState, useEffect, useRef } from "react"; import Head from "next/head"; import MediaHandler from "../components/MediaHandler"; +import TutorialOverlay from "../components/TutorialOverlay"; const STORAGE_KEY = 'save-the-world:sim-state'; +const TUTORIAL_STORAGE_KEY = 'save-the-world:tutorial-status'; const DIFFICULTY_STYLES = { easy: { color: "#00ff00", bg: "rgba(0,255,0,0.08)", activeBg: "#004400", label: "EASY", badge: "ROOKIE MODE" }, @@ -58,6 +60,11 @@ export default function SimulationPage({ initialScenario }) { const [savedSimulation, setSavedSimulation] = useState(null); const [storageWarning, setStorageWarning] = useState(null); + // Tutorial and settings state + const [tutorialOpen, setTutorialOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [gameplayHintVisible, setGameplayHintVisible] = useState(true); + // Discover backend port on component mount useEffect(() => { async function discoverBackendPort() { @@ -93,6 +100,22 @@ export default function SimulationPage({ initialScenario }) { } }, []); + // Show the walkthrough once for new users. queueMicrotask keeps the first + // client render aligned with the server-rendered start screen. + useEffect(() => { + let active = true; + let hasCompletedTutorial = false; + try { + hasCompletedTutorial = Boolean(localStorage.getItem(TUTORIAL_STORAGE_KEY)); + } catch { + // Private browsing or blocked storage should not prevent the game loading. + } + queueMicrotask(() => { + if (active && !hasCompletedTutorial) setTutorialOpen(true); + }); + return () => { active = false; }; + }, []); + // Persist simulation state to localStorage while a simulation is in progress useEffect(() => { if (!simulationId || !simulationStarted || showConclusion) return; @@ -498,9 +521,67 @@ export default function SimulationPage({ initialScenario }) { setVideosGenerated(savedSimulation.videosGenerated ?? false); setAudioGenerated(savedSimulation.audioGenerated ?? false); setSavedSimulation(null); + setGameplayHintVisible(true); setSimulationStarted(true); }; + const closeTutorial = (status) => { + try { localStorage.setItem(TUTORIAL_STORAGE_KEY, status); } catch { /* ignore */ } + setTutorialOpen(false); + setGameplayHintVisible(true); + }; + + const replayTutorial = () => { + setSettingsOpen(false); + setTutorialOpen(true); + setGameplayHintVisible(true); + }; + + const SettingsPanel = () => { + if (!settingsOpen) return null; + return ( +
+

SETTINGS

+

+ Need a refresher? Replay the interactive tutorial without leaving your simulation. +

+ + +
+ ); + }; + // Animated checkmark component with fade-in effect const ProgressItem = ({ label, isComplete }) => (
+ + )} +
{/* Content Grid */} @@ -1317,6 +1440,39 @@ export default function SimulationPage({ initialScenario }) { + {gameplayHintVisible && !showConclusion && ( +
+ Gameplay tip: Read the scenario, type a response, and press Send to advance the turn. + +
+ )} + {/* User Input Area - Below the grid */}
)} - {/* Render Conclusion Overlay */} + {/* Render overlays */} + + closeTutorial("completed")} + onSkip={() => closeTutorial("skipped")} + /> ); diff --git a/tests-e2e/difficulty.spec.js b/tests-e2e/difficulty.spec.js index de93861..836015a 100644 --- a/tests-e2e/difficulty.spec.js +++ b/tests-e2e/difficulty.spec.js @@ -127,6 +127,7 @@ test.describe('Difficulty Levels', () => { Object.keys(localStorage) .filter((k) => k.startsWith('save-the-world:')) .forEach((k) => localStorage.removeItem(k)); + localStorage.setItem('save-the-world:tutorial-status', 'completed'); }); await page.goto('/simulation'); }); diff --git a/tests-e2e/mobile.spec.js b/tests-e2e/mobile.spec.js index 48c9849..ee527b6 100644 --- a/tests-e2e/mobile.spec.js +++ b/tests-e2e/mobile.spec.js @@ -32,6 +32,10 @@ test.describe('Mobile Responsiveness', () => { await page.addInitScript((key) => { localStorage.removeItem(key); }, STORAGE_KEY); + // Existing mobile regressions exercise the post-onboarding simulation UI. + await page.addInitScript(() => { + localStorage.setItem('save-the-world:tutorial-status', 'completed'); + }); }); test('viewport meta tag is present', async ({ page }) => { diff --git a/tests-e2e/save-resume.spec.js b/tests-e2e/save-resume.spec.js index b853c2f..4400a13 100644 --- a/tests-e2e/save-resume.spec.js +++ b/tests-e2e/save-resume.spec.js @@ -25,6 +25,10 @@ test.describe('Save/Resume Functionality', () => { await page.addInitScript((key) => { localStorage.removeItem(key); }, STORAGE_KEY); + // Existing save/resume coverage starts in the post-onboarding state. + await page.addInitScript(() => { + localStorage.setItem('save-the-world:tutorial-status', 'completed'); + }); }); test('Continue button is hidden when no saved simulation exists', async ({ page }) => { diff --git a/tests-e2e/simulation.spec.js b/tests-e2e/simulation.spec.js index f16c46e..cd6e28c 100644 --- a/tests-e2e/simulation.spec.js +++ b/tests-e2e/simulation.spec.js @@ -4,6 +4,13 @@ import { test, expect } from '@playwright/test'; test.setTimeout(300000); // 5 minutes for thorough testing test.describe('Save the World Simulation - Comprehensive E2E Test', () => { + test.beforeEach(async ({ page }) => { + // This legacy flow exercises gameplay after onboarding; the tutorial has + // dedicated coverage in tutorial.spec.js. + await page.addInitScript(() => { + localStorage.setItem('save-the-world:tutorial-status', 'completed'); + }); + }); test('Complete end-to-end simulation flow with detailed error tracking', async ({ page, context, request }) => { // Setup comprehensive error tracking diff --git a/tests-e2e/tutorial.spec.js b/tests-e2e/tutorial.spec.js new file mode 100644 index 0000000..2a5995f --- /dev/null +++ b/tests-e2e/tutorial.spec.js @@ -0,0 +1,118 @@ +/** + * Interactive tutorial regression suite (issue #8). + * + * The tutorial is entirely client-side, so these tests stay hermetic. The + * gameplay-hint test stubs only the simulation-create request and WebSocket. + */ +import { test, expect } from '@playwright/test'; + +const TUTORIAL_STATUS_KEY = 'save-the-world:tutorial-status'; +const SIMULATION_STATE_KEY = 'save-the-world:sim-state'; + +const clearTutorialState = (page) => page.addInitScript(({ tutorialKey, simulationKey }) => { + localStorage.removeItem(tutorialKey); + localStorage.removeItem(simulationKey); +}, { tutorialKey: TUTORIAL_STATUS_KEY, simulationKey: SIMULATION_STATE_KEY }); + +test.describe('Tutorial Mode', () => { + test.beforeEach(async ({ page }) => { + await clearTutorialState(page); + }); + + test('launches automatically for a new user', async ({ page }) => { + await page.goto('/simulation'); + + await expect(page.getByTestId('tutorial-overlay')).toBeVisible(); + await expect(page.getByTestId('tutorial-title')).toHaveText('Welcome, crisis manager'); + await expect(page.getByTestId('tutorial-progress')).toHaveText('STEP 1 OF 6'); + await expect(page.getByTestId('tutorial-skip')).toBeVisible(); + }); + + test('walkthrough requires interaction and explains the complete game loop', async ({ page }) => { + await page.goto('/simulation'); + + await page.getByTestId('tutorial-next').click(); + await expect(page.getByTestId('tutorial-sample-scenario')).toContainText('A coastal city has lost power'); + + await page.getByTestId('tutorial-next').click(); + const next = page.getByTestId('tutorial-next'); + await expect(page.getByTestId('tutorial-response-choice')).toBeVisible(); + await expect(next).toBeDisabled(); + + await page.getByTestId('tutorial-response-protect').click(); + await expect(page.getByTestId('tutorial-response-feedback')).toContainText('Good choice'); + await expect(next).toBeEnabled(); + + await next.click(); + await expect(page.getByTestId('tutorial-turn-demo')).toBeVisible(); + await expect(next).toBeDisabled(); + await page.getByTestId('tutorial-advance-turn').click(); + await expect(page.getByTestId('tutorial-turn-indicator')).toHaveText('TURN 2 / 3'); + await expect(next).toBeEnabled(); + + await next.click(); + await expect(page.getByTestId('tutorial-score-demo')).toBeVisible(); + await expect(next).toBeDisabled(); + await page.getByTestId('tutorial-reveal-score').click(); + await expect(page.getByTestId('tutorial-score-result')).toContainText('85'); + await expect(next).toBeEnabled(); + + await next.click(); + await expect(page.getByTestId('tutorial-ready')).toBeVisible(); + await page.getByTestId('tutorial-next').click(); + await expect(page.getByTestId('tutorial-overlay')).toBeHidden(); + await expect(page.evaluate((key) => localStorage.getItem(key), TUTORIAL_STATUS_KEY)).resolves.toBe('completed'); + }); + + test('can be skipped and replayed from Settings', async ({ page }) => { + await page.goto('/simulation'); + await page.getByTestId('tutorial-skip').click(); + await expect(page.getByTestId('tutorial-overlay')).toBeHidden(); + + await page.getByTestId('settings-button').click(); + await expect(page.getByTestId('settings-panel')).toBeVisible(); + await page.getByTestId('replay-tutorial').click(); + + await expect(page.getByTestId('tutorial-overlay')).toBeVisible(); + await expect(page.getByTestId('tutorial-title')).toHaveText('Welcome, crisis manager'); + }); + + test('shows an actionable hint during gameplay', async ({ page }) => { + await page.route('**/simulations', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + simulation_id: 'tutorial-hint-sim', + current_turn_number: 1, + submission_count: 0, + max_turns: 3, + turns: [], + is_complete: false, + video_urls: [], + audio_url: null, + }), + }); + } + return route.continue(); + }); + await page.addInitScript(() => { + const OriginalWebSocket = window.WebSocket; + window.WebSocket = class extends OriginalWebSocket { + constructor() { + super('ws://localhost:1/tutorial-noop'); + } + }; + }); + + await page.goto('/simulation'); + await page.getByTestId('tutorial-skip').click(); + await page.getByRole('button', { name: 'Begin' }).click({ force: true }); + + await expect(page.getByTestId('tutorial-gameplay-hint')).toBeVisible(); + await expect(page.getByTestId('tutorial-gameplay-hint')).toContainText('type a response'); + await page.getByTestId('dismiss-tutorial-hint').click(); + await expect(page.getByTestId('tutorial-gameplay-hint')).toBeHidden(); + }); +});