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
+
+ onSelectResponse("protect")}
+ aria-pressed={selectedResponse === "protect"}
+ style={{ ...styles.choiceButton, ...(selectedResponse === "protect" ? styles.choiceButtonSelected : {}) }}
+ >
+ Protect the hospital first
+
+ onSelectResponse("restore")}
+ aria-pressed={selectedResponse === "restore"}
+ style={{ ...styles.choiceButton, ...(selectedResponse === "restore" ? styles.choiceButtonSelected : {}) }}
+ >
+ Restore the city grid
+
+
+
+ {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."}
+
+
1} style={styles.secondaryButton}>
+ {demoTurn > 1 ? "Turn advanced" : "Advance demo turn"}
+
+
+ );
+ }
+
+ if (stepIndex === 4) {
+ return (
+
+
SAMPLE SCORE
+ {scoreRevealed ? (
+
+
80 Impact
+
90 Clarity
+
85 Overall
+
+ ) : (
+
+ Reveal sample score
+
+ )}
+
{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}
+
+
+
+
{currentStep.title}
+
{currentStep.description}
+
+
setDemoTurn(2)}
+ scoreRevealed={scoreRevealed}
+ onRevealScore={() => setScoreRevealed(true)}
+ />
+
+
+
Skip tutorial
+
+ {stepIndex > 0 && (
+ setStepIndex((current) => current - 1)} style={styles.backButton}>Back
+ )}
+
+ {isLastStep ? "Finish tutorial" : "Continue"}
+
+
+
+
+
+ );
+}
+
+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.
+
+
+ Replay Tutorial
+
+
setSettingsOpen(false)}
+ style={{ minHeight: "44px", width: "100%", marginTop: "10px", padding: "10px", border: "1px solid #555", borderRadius: "5px", backgroundColor: "transparent", color: "#aaa", cursor: "pointer", fontFamily: "inherit", fontSize: "0.5em" }}
+ >
+ Close
+
+
+ );
+ };
+
// Animated checkmark component with fade-in effect
const ProgressItem = ({ label, isComplete }) => (
+ setSettingsOpen((open) => !open)}
+ style={{
+ minHeight: "44px",
+ padding: "8px 16px",
+ fontSize: "0.5em",
+ fontFamily: '"Press Start 2P", cursive',
+ color: "#d1d5db",
+ backgroundColor: "#111",
+ border: "1px solid #555",
+ borderRadius: "6px",
+ cursor: "pointer",
+ textTransform: "uppercase",
+ }}
+ >
+ Settings
+
+
{
clearSavedSimulation();
+ setGameplayHintVisible(true);
setSimulationStarted(true);
initializeSimulation();
}}
@@ -1223,6 +1326,26 @@ export default function SimulationPage({ initialScenario }) {
Change
)}
+ setSettingsOpen((open) => !open)}
+ style={{
+ fontFamily: '"Press Start 2P", cursive',
+ fontSize: "0.35em",
+ padding: "4px 7px",
+ minHeight: "32px",
+ backgroundColor: "#111",
+ color: "#aaa",
+ border: "1px solid #555",
+ borderRadius: "3px",
+ cursor: "pointer",
+ textTransform: "uppercase",
+ }}
+ >
+ Settings
+
{/* 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.
+ setGameplayHintVisible(false)}
+ style={{ minHeight: "36px", padding: "6px 10px", flexShrink: 0, border: "1px solid #6b7280", borderRadius: "4px", backgroundColor: "transparent", color: "#d1d5db", cursor: "pointer" }}
+ >
+ Got it
+
+
+ )}
+
{/* User Input Area - Below the grid */}