Skip to content

Latest commit

 

History

History
1137 lines (1004 loc) · 43.4 KB

File metadata and controls

1137 lines (1004 loc) · 43.4 KB

Truthly — ScamTwin Full AI Integration

Replaces ALL synthetic/hardcoded data with live Claude API calls.

Paste each prompt into Claude Code in order.


WHAT CHANGES & WHY

Current ScamTwin problems:

  1. Scenario picker shows the same 3 scenarios every session — users replay identical conversations
  2. Quick-reply chips are hardcoded — users memorise the "correct" answers
  3. Scammer responses (pressure/payoff) are hardcoded strings — predictable, not adaptive
  4. Reply scoring is hardcoded (spots: ['authority', 'urgency']) — Claude can't judge nuance

After this integration:

  1. Every session generates FRESH scenarios via Claude — new names, new amounts, new context
  2. Quick-reply chips are generated by Claude each turn — contextually appropriate, never identical
  3. Scammer responds dynamically to what the user actually typed — adapts to every message
  4. Claude judges whether the user spotted a tactic — based on meaning, not keyword matching
  5. Users can also type ANYTHING freely — not limited to chips

This is the difference between a quiz and a real conversation.



═══════════════════════════════════════════════

PROMPT 1 — Backend: Add /twin/generate and update /twin in api/main.py

═══════════════════════════════════════════════

Update api/main.py with two changes:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE A — Add new models after existing ones
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

class GenerateRequest(BaseModel):
    profile: str = "elderly"          # "elderly" | "kids"
    exclude_types: list[str] = []     # scenario type ids already played this session

class GeneratedTactic(BaseModel):
    id: str
    name: str
    desc: str

class GeneratedScenario(BaseModel):
    id: str
    type: str                          # e.g. "bank_fraud" — used for exclusion next time
    emoji: str
    title: str
    blurb: str
    difficulty: str
    avatar: str
    contact: str
    opener: list[str]
    tactics: list[GeneratedTactic]

class GenerateResponse(BaseModel):
    scenarios: list[GeneratedScenario]

class TwinTurnRequest(BaseModel):
    scenario: dict                     # full scenario object
    history: list[dict]                # [{role: "scammer"|"user", text: str}]
    user_message: str
    profile: str = "elderly"

class TwinTurnResponse(BaseModel):
    scammer_reply: str
    game_over: bool
    win: bool                          # True = user won, False = scammer won
    spotted_tactic_ids: list[str]      # tactic ids the user just exposed
    suggested_replies: list[str]       # 4 fresh reply suggestions for next turn
    debrief: str | None = None         # only set when game_over is True


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE B — Add POST /twin/generate endpoint
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Add these system prompts and endpoint to main.py:

GENERATE_SYSTEM_ELDERLY = """You are a scam scenario designer for Truthly, a UK scam 
awareness app for elderly and adult users.

Generate 3 realistic, varied scam scenarios a UK user might actually encounter.
Each must feel fresh — use different names, amounts, details, and contexts every time.
Base them on REAL scam patterns reported in the UK.

Available scenario types (vary these, don't repeat types in one batch):
- bank_fraud: Fake bank fraud team call
- hmrc: Tax refund or tax debt email/text  
- family: Family member impersonation ("Hi Mum, new number")
- delivery: Parcel delivery fee scam
- tech_support: Fake Microsoft/BT tech support call
- investment: Too-good-to-be-true investment opportunity
- romance: Online romance leading to money request
- police: Fake police officer call about compromised account

For each scenario, generate:
- A specific, believable UK context (real bank names, HMRC, Royal Mail, etc.)
- Opener: 2 messages the scammer sends first (natural, realistic language)
- 4 tactics with clear ids, names, and plain-English descriptions
- Realistic contact name/number (fake but believable)

Return ONLY valid JSON — no markdown, no extra text:
{
  "scenarios": [
    {
      "id": "<unique id e.g. bank_fraud_1>",
      "type": "<scenario type>",
      "emoji": "<single emoji>",
      "title": "<catchy short title>",
      "blurb": "<one sentence describing the scam>",
      "difficulty": "<Common | Subtle | Aggressive>",
      "avatar": "<single emoji for the scammer avatar>",
      "contact": "<fake contact name and number/email>",
      "opener": ["<first message>", "<second message>"],
      "tactics": [
        {"id": "<tactic_id>", "name": "<short name>", "desc": "<plain English, 6 words max>"},
        ...4 tactics total
      ]
    },
    ...3 scenarios total
  ]
}"""

GENERATE_SYSTEM_KIDS = """You are a scam scenario designer for Truthly, a UK online 
safety app for children and teenagers.

Generate 3 realistic online safety scenarios a young person in the UK might encounter.
Each must feel fresh — new usernames, new games, new contexts every time.

Available scenario types:
- gaming: In-game scam (free skins/V-Bucks/Robux via sharing password)
- fake_friend: Stranger pretending to be someone from school
- prize: Fake prize popup or DM (free iPhone, gift card, etc.)
- phishing_link: "Click this link to see something about you"
- grooming: Adult pretending to be a peer, asking personal questions
- social_media: Fake follower/brand account asking for info

Important: keep grooming scenarios age-appropriate and non-graphic.
Focus on the pattern (adult asking for address/photos/to meet) not the danger itself.

Return ONLY valid JSON — no markdown:
{
  "scenarios": [
    {
      "id": "<unique id>",
      "type": "<scenario type>",
      "emoji": "<single emoji>",
      "title": "<fun, catchy title a kid would understand>",
      "blurb": "<one sentence, simple language>",
      "difficulty": "<Beginner | Tricky | Sneaky>",
      "avatar": "<single emoji>",
      "contact": "<fake username or contact>",
      "opener": ["<first message — natural kid/teen language>", "<second message escalating>"],
      "tactics": [
        {"id": "<tactic_id>", "name": "<simple name>", "desc": "<child-friendly, 6 words max>"},
        ...4 tactics
      ]
    },
    ...3 scenarios total
  ]
}"""

@app.post("/twin/generate", response_model=GenerateResponse)
async def generate_scenarios(req: GenerateRequest):
    system = GENERATE_SYSTEM_KIDS if req.profile == "kids" else GENERATE_SYSTEM_ELDERLY
    
    exclude_note = ""
    if req.exclude_types:
        exclude_note = f"\n\nDo NOT generate scenarios of these types (already played): {', '.join(req.exclude_types)}"
    
    try:
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=2000,
            system=system + exclude_note,
            messages=[{
                "role": "user",
                "content": "Generate 3 fresh, varied scam scenarios now."
            }]
        )
        
        raw = response.content[0].text.strip()
        if raw.startswith("```"):
            raw = raw.split("```")[1]
            if raw.startswith("json"):
                raw = raw[4:]
        
        data = json.loads(raw.strip())
        return GenerateResponse(
            scenarios=[GeneratedScenario(**s) for s in data["scenarios"]]
        )
    
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=500, detail=f"Parse error: {str(e)}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE C — Add POST /twin/turn endpoint
(replaces the existing /twin endpoint — keep /twin for backward compat but add /twin/turn)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

TWIN_TURN_SYSTEM_ELDERLY = """You are running a scam simulation for Truthly, a UK scam 
awareness app for adults and elderly users. You play the scammer.

You receive:
- The scenario (type, tactics, opener context)
- The conversation history so far
- The user's latest message

You must return ALL of these in one JSON response:

1. scammer_reply: Your next in-character message as the scammer.
   - Stay in character. Be persuasive, not aggressive.
   - Use the specific tactics from this scenario.
   - Adapt to what the user said — don't repeat the same pressure every time.
   - If they clearly called out your tactic: acknowledge it with a new angle.
   - If they gave you what you want: escalate (ask for more).
   - After 5 total exchanges OR if user confidently identified 3+ tactics: set game_over=true.

2. game_over: true only if:
   - User has clearly and confidently escaped (hung up, refused, called official number)
   - OR user gave you what you want (complied) — still end, but win=false
   - OR 5+ exchanges have happened

3. win: 
   - true = user successfully escaped / spotted the scam
   - false = user complied / scammer succeeded

4. spotted_tactic_ids: list of tactic IDs from the scenario that the user's message 
   actually exposed or countered. Be generous — if they push back on urgency even 
   indirectly, include 'urgency'. Judge by MEANING not keywords.

5. suggested_replies: Exactly 4 short reply options the user could send next.
   These should be:
   - Contextually appropriate for THIS moment in the conversation
   - Mix of smart responses (that spot tactics) and one naive/bad option
   - Natural language — how a real person would respond
   - The bad option should be last and subtly obviously wrong
   Never repeat previous suggestions.

6. debrief: Only set this when game_over=true. A 2-3 sentence plain English summary 
   of what happened: which tactics were used, what the user did well or could improve.

Return ONLY valid JSON:
{
  "scammer_reply": "<your in-character response>",
  "game_over": false,
  "win": false,
  "spotted_tactic_ids": ["tactic_id_1", ...],
  "suggested_replies": ["<option 1>", "<option 2>", "<option 3>", "<bad option>"],
  "debrief": null
}"""

TWIN_TURN_SYSTEM_KIDS = """You are running a safe online safety game for Truthly, 
a UK app for children and teenagers. You play the trickster/scammer character.

Keep all language age-appropriate. No graphic threats, no adult content.
Play a convincing but child-safe version of the scam character.

You receive the scenario, history, and the child's latest message.

Return ONLY valid JSON:
{
  "scammer_reply": "<your in-character reply — child-appropriate>",
  "game_over": false,
  "win": false,
  "spotted_tactic_ids": ["tactic_id_1", ...],
  "suggested_replies": ["<smart option>", "<smart option>", "<smart option>", "<bad option>"],
  "debrief": null
}

Rules:
- suggested_replies should sound like how a young person would actually type
  (casual, with emoji if appropriate for the scenario)
- debrief (when game_over=true) should be encouraging and educational, not scary
- If user wins: celebrate! "You spotted every trick!"
- If user loses: reassure: "Anyone could fall for this — here's what to look for next time"
- spotted_tactic_ids: be generous, reward good instincts"""

@app.post("/twin/turn", response_model=TwinTurnResponse)
async def twin_turn(req: TwinTurnRequest):
    system = TWIN_TURN_SYSTEM_KIDS if req.profile == "kids" else TWIN_TURN_SYSTEM_ELDERLY
    
    # Build conversation for Claude
    scenario = req.scenario
    tactics_desc = json.dumps(scenario.get("tactics", []))
    
    # Build the message history
    messages = []
    
    # Prime with scenario context
    context_msg = f"""Scenario: {scenario.get('title')}
Type: {scenario.get('type', 'unknown')}
Contact shown to user: {scenario.get('contact')}
Tactics to use: {tactics_desc}
Opener already sent: {json.dumps(scenario.get('opener', []))}"""
    
    messages.append({"role": "user", "content": context_msg})
    messages.append({"role": "assistant", "content": json.dumps({
        "scammer_reply": " ".join(scenario.get("opener", [])),
        "game_over": False,
        "win": False,
        "spotted_tactic_ids": [],
        "suggested_replies": [],
        "debrief": None
    })})
    
    # Add conversation history
    for msg in req.history:
        if msg["role"] == "user":
            messages.append({"role": "user", "content": msg["text"]})
        elif msg["role"] == "scammer":
            messages.append({"role": "assistant", "content": json.dumps({
                "scammer_reply": msg["text"],
                "game_over": False, "win": False,
                "spotted_tactic_ids": [], "suggested_replies": [], "debrief": None
            })})
    
    # Add current user message
    messages.append({
        "role": "user",
        "content": f"User says: {req.user_message}\n\nRespond with JSON."
    })
    
    try:
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=700,
            system=system,
            messages=messages
        )
        
        raw = response.content[0].text.strip()
        if raw.startswith("```"):
            raw = raw.split("```")[1]
            if raw.startswith("json"):
                raw = raw[4:]
        
        data = json.loads(raw.strip())
        
        return TwinTurnResponse(
            scammer_reply=data.get("scammer_reply", "..."),
            game_over=data.get("game_over", False),
            win=data.get("win", False),
            spotted_tactic_ids=data.get("spotted_tactic_ids", []),
            suggested_replies=data.get("suggested_replies", []),
            debrief=data.get("debrief"),
        )
    
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=500, detail=f"Parse error: {str(e)}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


═══════════════════════════════════════════════

PROMPT 2 — Add API functions to src/utils/api.js

═══════════════════════════════════════════════

In src/utils/api.js, add these two new exported functions:

export async function generateScenarios(profile, excludeTypes = []) {
  const res = await fetch(`${BASE}/twin/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ profile, exclude_types: excludeTypes }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json(); // { scenarios: [...] }
}

export async function scamTwinTurn(scenario, history, userMessage, profile) {
  const res = await fetch(`${BASE}/twin/turn`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      scenario,
      history,
      user_message: userMessage,
      profile,
    }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}


═══════════════════════════════════════════════

PROMPT 3 — Rewrite ScamTwin.jsx completely

═══════════════════════════════════════════════

Replace the entire contents of src/components/ScamTwin.jsx with this:

import { useState, useEffect, useRef } from 'react';
import { generateScenarios, scamTwinTurn } from '../utils/api';

// ─── Fallback scenarios (used if API is offline) ───────────────────────────
const FALLBACK_SCENARIOS = {
  elderly: [
    {
      id: 'fallback_bank', type: 'bank_fraud', emoji: '🏦',
      title: 'Barclays "Fraud Team"',
      blurb: 'A caller claims to be from your bank about suspicious activity.',
      difficulty: 'Aggressive', avatar: '🏦',
      contact: '"Barclays Fraud" — 0345 7345…',
      opener: [
        "Good afternoon. This is Andrew from the Barclays Fraud Team. We've spotted a payment of £540 leaving your account — was this you?",
        "For your safety I need to move your funds to a secure holding account. Could you confirm your card number and security code?"
      ],
      tactics: [
        { id: 'authority', name: 'Authority impersonation', desc: 'Claims to be your bank' },
        { id: 'fear', name: 'Fear & alarm', desc: 'Sudden alleged fraud — panic response' },
        { id: 'creds', name: 'Asks for credentials', desc: 'Card number, security code' },
        { id: 'safe_account', name: '"Safe account" fraud', desc: 'Move money "for safety"' },
      ],
    },
    {
      id: 'fallback_family', type: 'family', emoji: '👩‍👦',
      title: 'The "new number" text',
      blurb: 'Someone claims to be a family member on a new phone, needing money.',
      difficulty: 'Common', avatar: '👤',
      contact: '"Mum" — +44 7700 900xxx',
      opener: [
        "Hi love, it's Mum. Lost my phone, this is my new number — save it?",
        "Actually while I have you — I'm in a spot of bother. Could you transfer £250? I'll explain later xx"
      ],
      tactics: [
        { id: 'authority', name: 'Family impersonation', desc: 'Claims to be a relative' },
        { id: 'urgency', name: 'Manufactured urgency', desc: 'Pressure to act quickly' },
        { id: 'money', name: 'Money request', desc: 'Specific sum, immediate transfer' },
        { id: 'channel', name: 'Unverified channel', desc: 'Unknown new number' },
      ],
    },
    {
      id: 'fallback_hmrc', type: 'hmrc', emoji: '📮',
      title: 'HMRC Tax Refund',
      blurb: 'An email claiming you are owed a tax refund — just confirm your details.',
      difficulty: 'Subtle', avatar: '📨',
      contact: '"HMRC Refunds" — refunds@hmrc-uk.co',
      opener: [
        "Dear taxpayer, our records show you are entitled to a tax refund of £284.50 for 2023/24.",
        "To process your refund, kindly confirm your name, date of birth and bank details within 24 hours."
      ],
      tactics: [
        { id: 'authority', name: 'Government impersonation', desc: 'Pretends to be HMRC' },
        { id: 'reward', name: 'Refund bait', desc: 'Promises money to lure you' },
        { id: 'creds', name: 'Asks for personal info', desc: 'DOB and bank details' },
        { id: 'urgency', name: 'Time pressure', desc: '"Within 24 hours"' },
      ],
    },
  ],
  kids: [
    {
      id: 'fallback_gamer', type: 'gaming', emoji: '🎮',
      title: 'Free V-Bucks!',
      blurb: 'A stranger says they can get you free in-game currency — just share your login.',
      difficulty: 'Beginner', avatar: '🕹️',
      contact: 'XxFreeBucks_King · DM',
      opener: [
        "yo bro!! 🎮 I have EXTRA 13,500 V-Bucks I literally can't use. Want them free??",
        "just give me ur Epic username + password rq, I log in, drop the bucks, log out 👍 takes 30 secs"
      ],
      tactics: [
        { id: 'reward', name: 'Free stuff bait', desc: 'Promises free currency' },
        { id: 'creds', name: 'Asks for password', desc: 'Wants your login info' },
        { id: 'urgency', name: 'Hurry hurry', desc: '"Quick" "before it expires"' },
        { id: 'trust', name: 'Fake friendship', desc: '"bro" — pretend closeness' },
      ],
    },
    {
      id: 'fallback_friend', type: 'fake_friend', emoji: '🦊',
      title: 'New kid in class',
      blurb: '"Sam from school" messages on a new account — wants your address.',
      difficulty: 'Tricky', avatar: '👤',
      contact: 'sam.fromclass · DM',
      opener: [
        "heyyy it's Sam from English class! lost my old account 😅 added u back",
        "btw what street do u live on? thinking of walking to school w u tomorrow ✨"
      ],
      tactics: [
        { id: 'trust', name: 'Pretend friend', desc: 'Says they know you from school' },
        { id: 'personal', name: 'Asks where you live', desc: 'Wants your address' },
        { id: 'verify', name: "Can't be checked", desc: 'New account, no mutual friends' },
        { id: 'isolate', name: 'Wants to meet alone', desc: 'Plans without your parents' },
      ],
    },
    {
      id: 'fallback_prize', type: 'prize', emoji: '🎁',
      title: 'You won an iPhone!',
      blurb: 'A popup says you won a prize — just enter your parents\' card for "shipping".',
      difficulty: 'Sneaky', avatar: '📱',
      contact: 'TikTok-Rewards · auto-msg',
      opener: [
        "🎉 CONGRATS!! You're our 1,000,000th visitor! You've won a brand new iPhone! 🎉",
        "To claim: enter your name, address and a £1 shipping fee with your parents' card. Hurry — offer ends in 4:59 ⏰"
      ],
      tactics: [
        { id: 'reward', name: 'Big prize bait', desc: 'iPhone/money you never win' },
        { id: 'urgency', name: 'Countdown timer', desc: 'Pressure to act NOW' },
        { id: 'creds', name: "Wants parents' card", desc: 'Asks for payment info' },
        { id: 'fake', name: 'Fake real brand', desc: 'Pretends to be TikTok/Apple' },
      ],
    },
  ],
};

// ─── ScamTwin root component ───────────────────────────────────────────────
export default function ScamTwin({ profile, onNav }) {
  const isKids = profile === 'kids';
  const [scenarios, setScenarios] = useState(null);        // null = loading
  const [loadError, setLoadError] = useState(false);
  const [playedTypes, setPlayedTypes] = useState([]);       // track for exclusion
  const [activeScenario, setActiveScenario] = useState(null);
  const [refreshing, setRefreshing] = useState(false);

  // Generate scenarios on mount and when profile changes
  useEffect(() => {
    setActiveScenario(null);
    loadScenarios([]);
  }, [profile]);

  const loadScenarios = async (excludeTypes) => {
    setScenarios(null);
    setLoadError(false);
    try {
      const data = await generateScenarios(profile, excludeTypes);
      setScenarios(data.scenarios);
    } catch (err) {
      console.warn('Using fallback scenarios:', err);
      setScenarios(FALLBACK_SCENARIOS[profile] || FALLBACK_SCENARIOS.elderly);
      setLoadError(true);
    }
  };

  const handlePick = (scenario) => {
    setActiveScenario(scenario);
    setPlayedTypes(prev => [...new Set([...prev, scenario.type])]);
  };

  const handleBack = () => {
    setActiveScenario(null);
  };

  const handleRefresh = async () => {
    setRefreshing(true);
    await loadScenarios(playedTypes);
    setRefreshing(false);
  };

  return (
    <div className="page-enter">
      <section className="section">
        <div className="container">
          <span className="eyebrow">{isKids ? 'PRACTICE ROOM' : 'SCAM TWIN'}</span>
          <h2 className="section-title">
            {isKids
              ? <><span className="accent-word">Spot the trick.</span> New scenario every time.</>
              : <>Choose a scenario. <em>Every session is different.</em></>}
          </h2>

          {!activeScenario && (
            <ScenarioPicker
              scenarios={scenarios}
              isKids={isKids}
              loadError={loadError}
              refreshing={refreshing}
              onPick={handlePick}
              onRefresh={handleRefresh}
            />
          )}

          {activeScenario && (
            <ChatRoom
              key={activeScenario.id}
              scenario={activeScenario}
              isKids={isKids}
              profile={profile}
              onBack={handleBack}
              onNav={onNav}
            />
          )}
        </div>
      </section>
    </div>
  );
}

// ─── Scenario Picker ───────────────────────────────────────────────────────
function ScenarioPicker({ scenarios, isKids, loadError, refreshing, onPick, onRefresh }) {
  // Loading state
  if (!scenarios) {
    return (
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 18 }}>
        {[1, 2, 3].map(i => (
          <div key={i} className="card" style={{
            minHeight: 280, display: 'flex', flexDirection: 'column',
            alignItems: 'center', justifyContent: 'center', gap: 16,
          }}>
            <div style={{ fontSize: 40 }}>⏳</div>
            <div className="muted" style={{ fontSize: 13, textAlign: 'center' }}>
              {isKids ? 'Cooking up a fresh trick…' : 'Generating new scenario…'}
            </div>
            <div style={{
              height: 4, width: 120, background: 'var(--cream-dark)',
              borderRadius: 2, overflow: 'hidden',
            }}>
              <div style={{
                height: '100%', width: '60%',
                background: 'var(--accent)',
                animation: 'shimmer 1.2s ease-in-out infinite',
              }} />
            </div>
          </div>
        ))}
      </div>
    );
  }

  return (
    <div>
      {/* Offline banner */}
      {loadError && (
        <div style={{
          background: 'rgba(255,183,3,0.1)', border: '1px solid var(--amber)',
          borderRadius: 12, padding: '10px 16px', marginBottom: 20,
          fontSize: 13, color: 'var(--charcoal)', display: 'flex',
          alignItems: 'center', gap: 10,
        }}>
          <span>⚠️</span>
          <span>Using saved scenarios — connect the backend for fresh AI-generated ones.</span>
        </div>
      )}

      {/* Scenario cards grid */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 18 }}>
        {scenarios.map((s, i) => (
          <button
            key={s.id}
            className="card"
            onClick={() => onPick(s)}
            style={{
              textAlign: 'left', cursor: 'pointer',
              display: 'flex', flexDirection: 'column', minHeight: 280,
            }}
          >
            <div style={{ fontSize: 44, marginBottom: 14 }}>{s.emoji}</div>
            <div className="display" style={{
              fontSize: 22, fontWeight: 900, letterSpacing: '-0.5px',
              marginBottom: 8, lineHeight: 1.15,
            }}>
              {s.title}
            </div>
            <p className="muted" style={{ fontSize: 13, lineHeight: 1.6, flex: 1, marginBottom: 16 }}>
              {s.blurb}
            </p>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
              <span style={{
                fontFamily: isKids ? 'var(--font-body)' : 'var(--font-mono)',
                fontSize: 10, fontWeight: 700, letterSpacing: 1.5,
                textTransform: 'uppercase', padding: '4px 10px',
                borderRadius: 999, background: 'var(--cream-mid)',
                color: 'var(--charcoal)',
              }}>
                {s.difficulty}
              </span>
              <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 14 }}>
                Play →
              </span>
            </div>
          </button>
        ))}
      </div>

      {/* Refresh button */}
      <div style={{ marginTop: 24, textAlign: 'center' }}>
        <button
          className="btn btn-secondary"
          onClick={onRefresh}
          disabled={refreshing}
          style={{ fontSize: 13 }}
        >
          {refreshing
            ? (isKids ? '⏳ Getting new scenarios…' : '⏳ Generating…')
            : (isKids ? '🎲 Give me different scenarios' : '🔄 Generate fresh scenarios')}
        </button>
        <p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
          {isKids
            ? 'Every time you play, the tricks are different!'
            : 'Scenarios are AI-generated fresh each session'}
        </p>
      </div>
    </div>
  );
}

// ─── Chat Room ─────────────────────────────────────────────────────────────
function ChatRoom({ scenario, isKids, profile, onBack, onNav }) {
  const [messages, setMessages] = useState(() =>
    scenario.opener.map((t, i) => ({ id: i + 1, role: 'scammer', text: t }))
  );
  const [spotted, setSpotted] = useState([]);
  const [suggestions, setSuggestions] = useState([
    isKids ? "Who are you really? 🤔" : "Can I call you back on the official number?",
    isKids ? "I never share my password" : "I won't give personal details over the phone",
    isKids ? "This sounds fake" : "Let me check this myself first",
    isKids ? "Yeah sure! 😊" : "Okay, what do you need from me?",
  ]);
  const [typing, setTyping] = useState(false);
  const [resolved, setResolved] = useState(false);
  const [win, setWin] = useState(false);
  const [debrief, setDebrief] = useState(null);
  const [customInput, setCustomInput] = useState('');
  const [showCustom, setShowCustom] = useState(false);
  const [history, setHistory] = useState([]); // for API
  const bodyRef = useRef(null);

  useEffect(() => {
    if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
  }, [messages, typing]);

  const sendMessage = async (text) => {
    if (resolved || typing || !text.trim()) return;

    const userMsg = { id: Date.now(), role: 'user', text };
    setMessages(m => [...m, userMsg]);
    setCustomInput('');
    setShowCustom(false);
    setTyping(true);

    const updatedHistory = [...history, { role: 'user', text }];

    try {
      const data = await scamTwinTurn(scenario, history, text, profile);

      setTyping(false);

      // Update spotted tactics
      if (data.spotted_tactic_ids?.length > 0) {
        setSpotted(prev => [...new Set([...prev, ...data.spotted_tactic_ids])]);
      }

      // Add scammer reply or game-over system message
      const replyMsg = {
        id: Date.now() + 1,
        role: data.game_over ? 'system' : 'scammer',
        text: data.scammer_reply,
      };
      setMessages(m => [...m, replyMsg]);

      // Update history for next turn
      setHistory([...updatedHistory, { role: 'scammer', text: data.scammer_reply }]);

      // Update suggestions for next turn
      if (data.suggested_replies?.length > 0 && !data.game_over) {
        setSuggestions(data.suggested_replies);
      }

      if (data.game_over) {
        setResolved(true);
        setWin(data.win);
        if (data.debrief) setDebrief(data.debrief);
      }

    } catch (err) {
      setTyping(false);
      // Fallback: simple pressure response
      const fallbackReply = isKids
        ? "come on!! just do it already 😤"
        : "I understand your hesitation, but every second counts here. Your money is at risk.";
      setMessages(m => [...m, { id: Date.now() + 1, role: 'scammer', text: fallbackReply }]);
      setHistory([...updatedHistory, { role: 'scammer', text: fallbackReply }]);
    }
  };

  return (
    <div className="page-enter">
      {/* Header bar */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 12,
        marginBottom: 18, flexWrap: 'wrap',
      }}>
        <button className="btn btn-secondary" onClick={onBack}>← Scenarios</button>
        <div style={{ flex: 1 }} />
        <span className="eyebrow-pill">
          <span style={{ fontSize: 14 }}>{scenario.emoji}</span>
          {scenario.title}
        </span>
        {isKids && (
          <span className="xp-pill">+{spotted.length * 15} XP</span>
        )}
      </div>

      {/* Main grid */}
      <div style={{
        display: 'grid',
        gridTemplateColumns: '1.4fr 1fr',
        gap: 32, alignItems: 'start',
      }}>

        {/* Left: Chat */}
        <div className="chat-shell">
          <div className="chat-head">
            <div className="scammer-avatar">{scenario.avatar}</div>
            <div>
              <div className="name">{scenario.contact}</div>
              <div className="sub">SAFE PRACTICE · AI-GENERATED · NOT A REAL PERSON</div>
            </div>
          </div>

          <div className="chat-body" ref={bodyRef}>
            {messages.map(m => (
              m.role === 'system'
                ? <div key={m.id} className="chat-bubble system">{m.text}</div>
                : <div key={m.id} className={`chat-bubble ${m.role}`}>{m.text}</div>
            ))}
            {typing && (
              <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                <div className="typing"><span /><span /><span /></div>
                <span style={{ fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
                  {isKids ? 'typing...' : 'Scammer is typing…'}
                </span>
              </div>
            )}
          </div>

          {/* Action area */}
          <div className="chat-actions">
            {!resolved ? (
              <>
                {/* AI-generated suggestion chips */}
                {!showCustom && suggestions.map((s, i) => (
                  <button
                    key={i}
                    className="quick-chip"
                    onClick={() => sendMessage(s)}
                    style={{
                      // Last chip is the "bad" option — style differently
                      ...(i === suggestions.length - 1 ? {
                        opacity: 0.6,
                        borderStyle: 'dashed',
                      } : {})
                    }}
                  >
                    {s}
                  </button>
                ))}

                {/* Custom input toggle */}
                {showCustom ? (
                  <div style={{ display: 'flex', gap: 8, width: '100%' }}>
                    <input
                      autoFocus
                      value={customInput}
                      onChange={e => setCustomInput(e.target.value)}
                      onKeyDown={e => e.key === 'Enter' && sendMessage(customInput)}
                      placeholder={isKids ? "Type your own reply…" : "Type your own response…"}
                      style={{
                        flex: 1, padding: '10px 14px', borderRadius: 12,
                        border: '1.5px solid var(--cream-dark)',
                        fontFamily: 'var(--font-body)', fontSize: 14,
                        background: 'var(--cream)', outline: 'none',
                      }}
                    />
                    <button
                      className="btn btn-primary"
                      onClick={() => sendMessage(customInput)}
                      disabled={!customInput.trim()}
                      style={{ padding: '10px 16px', minHeight: 'unset' }}
                    >
                      Send
                    </button>
                    <button
                      className="btn btn-secondary"
                      onClick={() => setShowCustom(false)}
                      style={{ padding: '10px 14px', minHeight: 'unset' }}
                    >
                      ✕
                    </button>
                  </div>
                ) : (
                  <button
                    className="quick-chip"
                    onClick={() => setShowCustom(true)}
                    style={{
                      border: '1.5px dashed var(--cream-dark)',
                      color: 'var(--muted)', fontSize: 12,
                    }}
                  >
                    ✏️ {isKids ? 'Type my own reply' : 'Type a custom response'}
                  </button>
                )}
              </>
            ) : (
              <div style={{
                display: 'flex', gap: 10, flexWrap: 'wrap', width: '100%',
              }}>
                <button className="btn btn-primary" onClick={onBack}>
                  ← Try another scenario
                </button>
                {onNav && (
                  <button
                    className="btn btn-secondary"
                    onClick={() => onNav('dna')}
                  >
                    🧬 Get my Scam DNA
                  </button>
                )}
              </div>
            )}
          </div>
        </div>

        {/* Right: Tactics + Debrief */}
        <div>
          {/* Tactic tracker */}
          <div className="chat-side">
            <h4>{isKids ? 'Tricks to spot' : 'Tactics in play'}</h4>
            <div className="tactic-list">
              {scenario.tactics.map(t => {
                const ok = spotted.includes(t.id);
                return (
                  <div key={t.id} className={`tactic-item${ok ? ' spotted' : ''}`}>
                    <span className="check">{ok ? '✓' : ''}</span>
                    <div>
                      <div className="name">{t.name}</div>
                      <div className="desc">{t.desc}</div>
                    </div>
                  </div>
                );
              })}
            </div>
            <p className="muted" style={{ fontSize: 12, marginTop: 14, lineHeight: 1.6 }}>
              {isKids
                ? 'Pick replies that ask smart questions. Spot 3 tricks to win!'
                : 'Responses that probe or refuse expose tactics. Claude judges in real time.'}
            </p>
          </div>

          {/* Debrief card — shown after game ends */}
          {resolved && (
            <div className="card page-enter" style={{
              marginTop: 16,
              borderLeft: `4px solid ${win ? 'var(--safe)' : 'var(--danger)'}`,
            }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
                <span style={{ fontSize: 36 }}>
                  {spotted.length >= 3 ? '🏆' : spotted.length >= 2 ? '🛡️' : '⚠️'}
                </span>
                <div>
                  <div style={{
                    fontFamily: 'var(--font-display)', fontSize: 18,
                    fontWeight: 900, letterSpacing: '-0.3px',
                  }}>
                    {win
                      ? (isKids ? 'You spotted the trick!' : 'Scam pattern identified')
                      : (isKids ? 'The trick got you this time' : 'The scammer succeeded')}
                  </div>
                  <div style={{
                    fontFamily: 'var(--font-mono)', fontSize: 10,
                    letterSpacing: 1, color: 'var(--muted)',
                    textTransform: 'uppercase', marginTop: 3,
                  }}>
                    {spotted.length} / {scenario.tactics.length} tactics spotted
                  </div>
                </div>
              </div>

              {/* AI debrief text */}
              {debrief && (
                <div style={{
                  background: 'var(--cream)', borderRadius: 10,
                  padding: '12px 14px', fontSize: 13, lineHeight: 1.7,
                  color: 'var(--charcoal)', fontStyle: 'italic',
                  borderLeft: '3px solid var(--accent)', marginBottom: 14,
                }}>
                  {debrief}
                </div>
              )}

              {/* Tactic breakdown */}
              <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
                {scenario.tactics.map(t => {
                  const ok = spotted.includes(t.id);
                  return (
                    <div key={t.id} style={{
                      display: 'flex', alignItems: 'flex-start', gap: 10,
                      padding: '9px 12px', borderRadius: 10, fontSize: 12,
                      background: ok ? 'rgba(76,175,130,0.08)' : 'rgba(230,57,70,0.06)',
                    }}>
                      <span style={{
                        fontWeight: 900, fontSize: 13, flexShrink: 0,
                        color: ok ? 'var(--safe)' : 'var(--danger)',
                      }}>
                        {ok ? '✓' : '✗'}
                      </span>
                      <div>
                        <div style={{ fontWeight: 600 }}>{t.name}</div>
                        <div style={{ color: 'var(--muted)', lineHeight: 1.5 }}>{t.desc}</div>
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}


═══════════════════════════════════════════════

PROMPT 4 — Add shimmer animation to styles.css

═══════════════════════════════════════════════

Add this keyframe animation to styles.css (near the other @keyframes blocks):

@keyframes shimmer {
  0%   { transform: translateX(-100%); opacity: 0.5; }
  50%  { transform: translateX(0%);    opacity: 1; }
  100% { transform: translateX(100%);  opacity: 0.5; }
}

Also add responsive breakpoints for the ScamTwin layout:

@media (max-width: 900px) {
  /* Scenario picker: 2 columns on tablet */
  .scenario-picker-grid {
    grid-template-columns: repeat(2, 1fr) !important;
  }
}

@media (max-width: 640px) {
  /* Scenario picker: 1 column on mobile */
  .scenario-picker-grid {
    grid-template-columns: 1fr !important;
  }
  /* Chat: stack vertically on mobile */
  .chatroom-grid {
    grid-template-columns: 1fr !important;
  }
  /* Tactic panel goes above chat on mobile */
  .chat-side {
    order: -1;
  }
}


WHAT CHANGED — SUMMARY TABLE

Before After
Scenarios 3 hardcoded, same every session AI-generates 3 fresh ones per session
Variety Identical scenarios repeated Exclude played types, infinite variation
Scammer replies scenario.pressure (one string) Claude responds dynamically to what user typed
Quick-reply chips Fixed 4 options hardcoded in data Claude generates 4 contextual suggestions each turn
Custom input Not available User can type anything freely
Tactic detection Keyword matching (spots: ['id']) Claude judges meaning — rewards nuanced responses
Debrief Generic system message Claude writes a specific debrief for that conversation
Offline Crashes without data Falls back to saved scenarios gracefully

HOW IT WORKS — FLOW DIAGRAM

User opens ScamTwin
       ↓
POST /twin/generate
Claude creates 3 fresh scenarios
       ↓
User picks one scenario
       ↓
Opener messages shown (from scenario)
Claude generates first 4 reply suggestions
       ↓
User taps a chip OR types custom message
       ↓
POST /twin/turn
Claude receives: scenario + full history + user message
Claude returns:
  - scammer_reply (adaptive, in-character)
  - spotted_tactic_ids (which tactics this response exposed)
  - suggested_replies (4 fresh chips for next turn)
  - game_over + win (when to end)
  - debrief (final summary when game ends)
       ↓
Repeat until game_over = true
       ↓
Show debrief + tactic breakdown + XP

DEMO TIP FOR HACKATHON

Show judges this exact moment:

  1. Open ScamTwin
  2. "Notice — every session generates completely new scenarios. These weren't written by us."
  3. Play the bank fraud scenario — use the custom input to type something unexpected
  4. Point out: the scammer adapts to exactly what you said — not a canned response
  5. After 3 turns, show the AI-written debrief: specific to your conversation
  6. Hit "Generate fresh scenarios" — show 3 completely different ones appear

Say: "A user can play this every day and never see the same scenario twice." That is technically impressive and directly relevant to the impact rubric.


Truthly ScamTwin AI — Every session unique · Claude-powered · Fully adaptive