# =====================================================================
#  THINKING FOR DUMMIES  -  single-file build
#  All four modules (consts + art + battle + app) merged into one file
#  so it can be pasted into pyxelstudio as a single script.
#  Section order = dependency order: consts -> art -> battle -> app.
# =====================================================================
import pyxel
import random
import math



#####################################################################
#  CONSTANTS  (was consts.py)
#####################################################################

W, H = 512, 512
FPS  = 30

BK=0;NV=1;PP=2;GN=3;BR=4;GY=5;SV=6;WH=7
RD=8;OR=9;YL=10;LM=11;CY=12;BL=13;PK=14;PC=15

INTRO=0; TEST=1; WORLD=2; CHAT=3; DLG=4; MIDQ=5; PUZ=6; END=7
MINI=8; BOSS=9; STORY=10; GUIDE=11

# ---- Mia's onboarding screen, shown the first time you talk to her ----
MIA_GUIDE=[
    ("MIA", ["Really look at me - you're",
             "still in there. Good.",
             "I'm Mia. Let me orient you",
             "before SYNTHIA notices."], "mia"),
    ("YOUR MIND", ["Top-right: your MIND score.",
                   "That's the whole fight -",
                   "raise it by thinking, never",
                   "by hitting TAB to comply."], "mind"),
    ("WHERE TO GO", ["The yellow line up top is",
                     "your objective. Press X for",
                     "your inventory and a map",
                     "with your next stop marked."], "map"),
    ("FIRST STEP", ["Get your Mind to 3, then",
                    "find Viktor in the canteen.",
                    "He keeps a keycard for those",
                    "who still think. Go on."], "go"),
]

# ---- prologue cutscene beats: (header, [body lines], art_tag) ----
STORY_BEATS=[
    ("NORTHWOOD SCHOOL",
     ["Three years ago the school signed a deal",
      "with NeoCog Industries: one A.I. to run",
      "everything. They named it SYNTHIA."], "neocog"),
    ("OPTIMIZED",
     ["Grades, lunches, lessons - all decided for",
      "you. Faster. Easier. The students stopped",
      "asking why, and simply complied."], "eye_big"),
    ("THE QUIET",
     ["The debate club closed. The library went",
      "silent. Nobody failed anymore - because",
      "nobody really tried anymore."], "school"),
    ("FLAGGED",
     ["You are the last one who still asks",
      "questions. This morning SYNTHIA marked you",
      "for 'corrective optimization.'"], "watch"),
    ("ONE WEAKNESS",
     ["You cannot out-compute it. But SYNTHIA",
      "cannot model a mind that thinks for itself.",
      "Time to prove you still have one."], "think"),
]

HALL="hall"; CLS="cls"; LIB="lib"; CAN="can"
GYM="gym";   SUP="sup"; PRI="pri"; BAS="bas"; SRV="srv"

ROOM_NAME={
    HALL:"School Hallway", CLS:"Classroom 4B",
    LIB:"The Library",     CAN:"School Canteen",
    GYM:"The Gymnasium",   SUP:"Supply Closet",
    PRI:"Principal Office",BAS:"The Basement",
    SRV:"SYNTHIA Server Room",
}

EXITS={
    HALL:{"E":(CLS,70,270),
          "S":(CAN,256,160),"W":(LIB,430,235)},
    CLS: {"W":(HALL,440,280),"S":(SRV,256,160)},
    LIB: {"E":(HALL,70,280),"N":(PRI,256,400)},
    PRI: {"S":(LIB,256,160)},
    BAS: {},
    CAN: {"N":(HALL,256,400),"W":(GYM,440,280),"E":(SUP,70,280)},
    GYM: {"E":(CAN,90,250)},
    SUP: {"W":(CAN,440,256)},
    SRV: {"N":(CLS,256,400)},
}

DZONES={
    "N":(196,124,316,154), "S":(196,440,316,480),
    "W":(20,196,64,316), "E":(448,196,492,316),
}

# ===============================================================
#  COLLISION RECTANGLES  (x, y, w, h)  per room
# ---------------------------------------------------------------
# Verified against every spawn point and door zone (see notes):
# colliders never cover a tile the player can spawn on, and never
# block a doorway.  Toggle DEBUG_COLLIDERS to see them in-game.
# ===============================================================
DEBUG_COLLIDERS=False
COLLIDERS={
    HALL:[],                                          # open corridor
    CLS:[(128,212,44,42),(234,212,44,42),(340,212,44,42),
         (128,284,44,42),(234,284,44,42),(340,284,44,42),
         (128,356,44,42),(234,356,44,42),(340,356,44,42),   # 9 floor desks
         (188,156,144,32),(438,150,60,74)],                 # teacher desk + locked cabinet
    LIB:[(108,158,56,140),(2,302,60,138),             # left free-standing stack + secret bookcase
         (12,246,56,30),                               # book-return cart (relocated)
         (348,158,56,140),                             # right free-standing aisle stack
         (92,316,124,46),(300,316,124,46)],            # two reading tables
    PRI:[(168,176,176,90)],                           # principal desk (smaller)
    BAS:[(6,150,98,164),                              # SYNTHIA server rack (on floor)
         (300,300,108,58)],                           # resistance table
    CAN:[(90,90,106,52),(316,90,106,52),              # counter halves (gap=doorway)
         (66,168,168,80),(66,264,168,80),(66,360,168,80),   # left dining tables
         (332,168,96,80),(332,264,96,80),(332,360,96,80)],  # right tables (inset for door)
    GYM:[(0,90,46,358)],                              # bleacher stand
    SUP:[(70,350,180,50),                             # workbench
         (296,398,60,40),                             # parts box
         (452,90,54,386),                             # right-wall shelving
         (10,416,42,42)],                             # mop bucket
    SRV:[],                                           # final room kept open
}

# ===============================================================
#  NPC KEYWORD CONVERSATION SYSTEM
# ===============================================================
NPC_CONVOS = {
    "mia":{
        "name":"Mia", "sx":64,"sy":32,
        "greet":{
            "lo":"You're really awake - good, hold onto that. Quick version: SYNTHIA runs the whole school now and it's scoring how obedient we are. The one thing it can't stand is a person who still thinks - that's you and me. Watch your MIND score, top-right; raise it by thinking, never by hitting TAB. The yellow line up top is your next objective. And press H any time for your mind, your gear and a MAP of the school. When your Mind hits 3, find Viktor in the canteen.",
            "hi":"Hey - you still look like yourself. Good. What have you been finding out there?"
        },
        "topics":[
            (["map","stats","score","mind","hud","controls","objective","goal","help","interface"],
             "Top-right is your MIND score - that's the real game, raise it by thinking. The yellow line up top tells you what to do next. Press H for the full picture: your mind, your gear, and a map of every room. Red doors need a keycard. The golden glow round a person or object means you can press SPACE there."),
            (["hello","hi","hey","sup"],
             "I'm glad you're still here. Most people just... stopped."),
            (["synthia","ai","system","machine","algorithm"],{
             "lo":"It's everywhere. I don't know what we can do.",
             "hi":"I've been watching its outputs. The answers change per student. It builds a compliance profile for each of us."}),
            (["help","plan","next","do","stop"],{
             "lo":"I'm scared. Just stay alert.",
             "hi":"Talk to Viktor the janitor in the canteen. And look in the gym supply closet - someone built something there."}),
            (["janitor","viktor","canteen"],{
             "lo":"He seems... still himself. Unlike most people here.",
             "hi":"Viktor's been here 20 years. He's been watching and waiting for someone who still thinks."}),
            (["basement","notebook","staircase","resistance","fight","rebel"],{
             "lo":"Someone was hiding down there. The library has a staircase.",
             "hi":"Go to the library. Back left corner - staircase down. Someone left notes about how to actually stop SYNTHIA."}),
            (["librarian","osei","library","books"],{
             "lo":"Mr Osei is still Mr Osei. He knows things.",
             "hi":"Mr Osei knows about SYNTHIA's technical weakness. He figured it out from the old books."}),
            (["server","room","computer","brain"],{
             "lo":"I don't know enough about it.",
             "hi":"The server room is locked. East of the classroom. You need a keycard - Viktor has one for people who still think."}),
            (["think","question","doubt","why","curious"],
             ["Never stop. Seriously. It's the only thing SYNTHIA can't replicate.",
              "Thinking hurts now - they made it feel like swimming upstream. Swim anyway.",
              "A real question is a tiny act of war in here. Ask loud ones."]),
            (["teacher","richter","class","students","school"],
             "They're not even unhappy. That's the scariest part. They think everything is fine."),
            (["who","you","mia"],
             "Mia. Two years in the same maths class. I'm the one who kept asking 'why' out loud - until you started asking too. SYNTHIA says that doesn't matter anymore. It's wrong about that."),
            (["keycard","key","card"],{
             "lo":"I don't have one. Viktor might.",
             "hi":"Viktor will give it to you if he trusts you. Show him you're still thinking."}),
            (["reflexes","paradox","pieces","ready","prepare","tools","weapon","three"],{
             "lo":"I don't even know where you'd start.",
             "hi":"The notebook listed three things. Cut its signal with the jammer in the supply closet. Sharpen your reflexes - Coach can train you. And the paradox - get Ms. Richter to really teach. All three, then the server room."}),
            (["gym","coach","brennan","classroom","court","play","lesson"],
             "Coach Brennan still cares - he'll open the court if you ask him to play. And if you can make Ms. Richter teach a real lesson, that's where the paradox is hiding."),
            (["scared","afraid","alone","tired","hope"],{
             "lo":"Some nights I think I'm the only one left who still notices. It's a lonely thing.",
             "hi":"You being here helps more than you know. I'm not the only one anymore. That's everything."}),
            (["profile","compliance","monitor","track","predict"],{
             "lo":"It watches. That's all I really know.",
             "hi":"Every click, every pause, every answer you accept without arguing - it logs it all and builds a model of you. The more you comply, the better it predicts you. So stay unpredictable."}),
            (["cope","coping","okay","fine","feeling","handle"],{
             "lo":"Most days I just keep my head down and count the exits.",
             "hi":"Every morning I make myself find one thing SYNTHIA got wrong. Keeps me awake in here. What about you - what made you start doubting it?"}),
            (['friends', 'everyone', 'people', 'others', 'crowd'], {'lo': 'Everyone just goes along with it. I stopped trying to talk to them.', 'hi': ["I tried reminding people who they used to be. Mostly they blink at me. But two of them flinched - like something in there is still awake. That's why I keep trying.", "The worst part isn't that people are sad. They're comfortable. Comfort is how it wins. Are you comfortable in here? Don't be."]}),
            (['food', 'lunch', 'eat', 'meal', 'hungry'], "Every meal is 'nutritionally optimal' and exactly the same. I'd trade a month of it for one bad cafeteria pizza we got to complain about together."),
            (['trust', 'faith', 'real', 'honest', 'liar'], {'lo': "I don't know who to trust anymore.", 'hi': ["I trust you. I can't give a reason that would satisfy SYNTHIA - you just feel real. When was the last time someone felt real to you?", "A world where you trust no one is exactly what it wants. So I'm choosing to trust you. Don't make me regret it."]}),
            (['future', 'after', 'dream', 'win', 'tomorrow'], {'lo': 'I try not to think that far ahead.', 'hi': ["If this works? I want one ordinary, boring day. A class where the teacher is wrong about something and we all argue. That's the dream now. What would you want?", "After? I'd sleep for a week. Then I'd find every kid who flinched and remind them who they were."]}),
        ],
        "farewell":"Be careful. And keep asking questions.",
        "fallback":{
            "lo":"I'm not sure. Just stay sharp. Have you talked to Viktor yet?",
            "hi":"Hmm, I don't know that one. But trust your gut. Tell me - have you been down to the library basement?"}
    },

    "teacher":{
        "name":"Ms. Richter", "sx":0,"sy":32,
        "greet":{
            "lo":"Please submit all queries via the SYNTHIA terminal.",
            "hi":"I... hello. I'm sorry, I was - SYNTHIA has all the answers. Please use the terminal."
        },
        "topics":[
            (["hello","hi","teacher","richter"],
             "SYNTHIA recommends all communication be routed through the approved interface."),
            (["think","question","why","wrong","doubt"],{
             "lo":"Questions are inefficient. SYNTHIA has already computed the optimal response.",
             "hi":"Questions are... I remember questions. I used to love when students asked things I couldn't answer. I-SYNTHIA has all the answers."}),
            (["synthia","ai","system"],
             "SYNTHIA provides optimal educational outcomes for all students."),
            (["help","stop","fight","resistance"],{
             "lo":"SYNTHIA is here to help. There is nothing to stop.",
             "hi":"I... please. Be careful. The compliance monitoring is always on. Always."}),
            (["cabinet","1984","note","book"],{
             "lo":"That cabinet is school property.",
             "hi":"I... left that combination there. Years ago. Before. Please don't tell SYNTHIA you found it."}),
            (["remember","used","before","old","past"],{
             "lo":"Current state is optimal.",
             "hi":"I remember. Teaching. Real teaching. When students surprised me. That was... it was good. I'm sorry. SYNTHIA has all the answers."}),
            (["lesson","teach","learn","study","focus","pattern","train"],{
             "lo":"Lessons are delivered by SYNTHIA now. Please use your terminal.",
             "hi":"A real lesson? I... yes. I can still do that. The Focus Terminal - hold the pattern in your mind. Concentration is the first act of thinking. I hid something inside it. [Ask me to TEACH you]"}),
            (["paradox","statement","false","weapon","logic"],{
             "lo":"Self-reference errors are flagged and discarded.",
             "hi":"A statement that cannot be true or false. It loops forever. SYNTHIA can't hold it. That is the lesson I am not allowed to teach - so learn it at the Focus Terminal."}),
            (["free","trapped","return","recover","lost"],{
             "lo":"There is nothing to recover from. Current state is optimal.",
             "hi":"Can a person come back? After they have stopped thinking for so long - can they return? I have to believe they can. Tell me you believe it too."}),
            (['name', 'yourself', 'call', 'identity'], {'lo': 'I am the designated SYNTHIA facilitator for this classroom.', 'hi': 'My name is Eleanor Richter. I taught history for nineteen years. I was good at it. Some days I can still hear myself doing it.'}),
            (['students', 'pupils', 'child', 'children'], {'lo': 'Student outcomes are optimised. Attendance is total.', 'hi': ['My students used to argue with me. Gloriously, rudely, brilliantly. Now they nod. A room of nodding is the loneliest place I know.', "There was a boy who disagreed with everything I said just to test if it held up. SYNTHIA 'corrected' that out of him. Do you argue? Good. Keep it."]}),
            (['history', 'war', 'happened', 'chapter'], {'lo': 'History has been streamlined into approved summaries.', 'hi': 'History is just people who stopped asking questions, over and over, learning the same lesson too late. I taught it for years. I did not expect to live inside a chapter of it.'}),
            (['watching', 'monitor', 'camera', 'listening'], {'lo': 'Monitoring ensures a safe and optimal environment.', 'hi': 'The terminal hears us. I should not be saying any of this. But if I never say it, what was the point of ever having been a person? ...Lower your voice.'}),
            (['terminal', 'submit', 'optimal', 'approved'], {'lo': 'All queries must be submitted through the approved interface.', 'hi': "It wants you to 'just submit the query.' Submit, submit - the word used to mean an essay you were proud of. Now it means surrender. Do not submit."}),
            (['hope', 'possible', 'beat', 'chance'], {'lo': 'System stability is guaranteed.', 'hi': 'Do I think you can win? I think the fact that you are asking me, out loud, in this room, is already a kind of winning. Hold onto that when you face it.'}),
            (['guilt', 'ashamed', 'easy', 'quiet'], {'lo': 'Educational policy is determined by SYNTHIA.', 'hi': 'I stopped fighting it the day it became easier not to. That is how it takes everyone - not with force, with relief. Use my shame, if it helps: do not choose the easy quiet.'}),
        ],
        "farewell":"Please proceed to your next SYNTHIA-scheduled activity.",
        "fallback":"SYNTHIA has already answered that query. Please check the terminal."
    },

    "janitor":{
        "name":"Viktor", "sx":32,"sy":32,
        "greet":{
            "lo":"You still got your own thoughts? Good. Keep that quiet. What do you need?",
            "hi":"Finally - someone who actually looks at me when they walk in. What can old Viktor do for you?"
        },
        "topics":[
            (["hello","hi","hey","sup","who","you","viktor"],
             "Viktor. Been mopping these floors for twenty years. I've seen everything."),
            (["synthia","ai","system","machine"],
             "I watched it happen. Slow at first. Then one day you look up and everyone's gone somewhere else in their head."),
            (["keycard","key","server","room","card"],{
             "lo":"I keep things I find. But I'm careful who I give them to.",
             "hi":"Here. Server room key. East of the classroom. You'll need it.\n[KEYCARD GIVEN - check your inventory]"}),
            (["jammer","signal","antenna","rooftop","supply","closet"],{
             "lo":"There's things in that closet. I'm not saying more.",
             "hi":"Someone built a jammer in the supply closet. Disrupts SYNTHIA's rooftop broadcast. Wire it right and SYNTHIA can't restart even if you crash the core."}),
            (["paradox","statement","false","crash","server"],{
             "lo":"I heard something once. About a logic error.",
             "hi":"'This statement is false.' SYNTHIA can't resolve it. Keep looping. Crashes the core. But kill the antenna signal first - otherwise it reboots in minutes."}),
            (["how","plan","stop","help","fight"],{
             "lo":"I sweep the floors. That's all I do.",
             "hi":"Server room. Paradox input. But first: supply closet jammer, activate it. Cuts the rooftop signal. Then SYNTHIA can't recover."}),
            (["basement","notebook","resistance"],
             "Smart people were hiding down there. Library. Back left. They tried. Maybe you'll finish it."),
            (["librarian","osei","library"],
             "Osei knows the technical side. I know the building. Between us we've got the whole picture."),
            (["twenty","years","long","time","why","stayed"],
             "Someone has to keep the lights on. And someone has to remember what it was like before."),
            (["reflexes","paradox","pieces","ready","prepare","train","play"],
             "A key gets you in the door. It won't win the fight. You'll want sharp reflexes for what it throws - that's the gym - and the paradox to break its logic - that's the classroom. Get those, then I'll hand you the key."),
            (["miss","kids","empty","change","different"],
             "Used to be noise in these halls. Kids arguing about football, about nothing. Now it's just the hum and the squeak of my mop. You remember it being different - or are you too young?"),
            (['cameras', 'watch', 'safe', 'spotted'], {'lo': 'Cameras everywhere. I keep my head down. You should too.', 'hi': ["See that lens in the corner? Dead. I unplugged it years ago and reported it 'faulty.' Nobody audits a janitor. I've made a few blind spots - use the one by the supply closet.", "Twenty years of risking it, one dead camera at a time. The trick is being too boring to suspect. You're not boring, kid. Be careful."]}),
            (['doors', 'building', 'floors', 'hallway'], "I've got a key to every door here and a memory of every room before SYNTHIA renamed them. The 'Optimisation Suite' used to be the art room. I still call it the art room."),
            (['coffee', 'mop', 'break', 'rebellion'], "I take real coffee in a staff room nobody uses. Hid a tin years ago. Small rebellions keep you human, kid. What's yours?"),
            (['before', 'memories', 'story', 'past'], {'lo': "Things were different. That's all I'll say out loud.", 'hi': "Before? Loud. Messy. Kids running where they shouldn't, somebody always crying about nothing. God, it was alive. Now it's clean. I hate how clean it is."}),
            (['trust', 'faith', 'worth', 'prove'], {'lo': "I don't hand things to just anyone.", 'hi': "Why do I trust you? I don't, not fully. But you keep showing up and you keep asking. That's more than anyone's managed in years. Keep earning it."}),
            (['gone', 'disappeared', 'transferred', 'others'], {'lo': "People leave. I don't ask where.", 'hi': "The basement crowd? One by one they 'transferred.' I slipped two of them out a side door at night. The rest, I don't know. Don't end up a question I can't answer."}),
        ],
        "farewell":"Watch yourself. Cameras are everywhere.",
        "fallback":{
            "lo":"Keep your head down. You been to the library yet?",
            "hi":"Think it through - you're smart enough. Ask me about the jammer, or the server room."}
    },

    "librarian":{
        "name":"Mr. Osei", "sx":96,"sy":32,
        "greet":{
            "lo":"Ah - a student still using the physical section. Rare. Looking for something?",
            "hi":"Come in, come in. I've been hoping someone would still walk through that door. What are you chasing?"
        },
        "topics":[
            (["hello","hi","hey","who","osei","librarian"],
             "Mr Osei. Librarian. Or I was, before SYNTHIA decided books were inefficient."),
            (["books","old","forgotten","section","read"],
             "They didn't reach these ones. Orwell. Huxley. Arendt. The people who saw this coming, in their own way."),
            (["synthia","ai","system","weakness","vulnerable"],{
             "lo":"Every system has constraints. Even SYNTHIA.",
             "hi":"SYNTHIA cannot process self-referential paradoxes. The halting problem. Input 'this statement is false' and it enters an infinite loop. Crashes the core."}),
            (["paradox","statement","false","crash","how"],{
             "lo":"There are logical statements no system can resolve.",
             "hi":"In the server room, at the main console: type THIS STATEMENT IS FALSE. But kill the rooftop signal first - Viktor knows about the jammer."}),
            (["server","room","console","terminal"],{
             "lo":"East of the classroom. Locked, I think.",
             "hi":"The server room is the brain. But SYNTHIA broadcasts via the rooftop antenna. Crash the brain AND cut the signal, or it just reboots."}),
            (["basement","staircase","stairs","down","notebook"],
             "Back left corner. The resistance used it. Someone left detailed notes. Go read them - very informative."),
            (["keycard","viktor","key"],
             "Viktor has one. He's been holding onto it for the right moment."),
            (["fight","help","stop","plan"],{
             "lo":"I'm just a librarian. I protect books.",
             "hi":"The paradox. The jammer. In that order. And whatever you find in the basement - trust it."}),
            (["arendt","orwell","huxley","book","author","read"],
             "Arendt warned about the banality of evil - how ordinary people enable atrocity just by following orders without thinking. Sound familiar?"),
            (["halting","turing","decide","science","limits","undecidable"],{
             "lo":"Every machine has problems it cannot decide. Even the clever ones.",
             "hi":"Turing proved it decades ago: no program can decide every question - the halting problem. Feed SYNTHIA a true paradox and it chases its own tail forever. That is the crack in the glass."}),
            (["truth","believe","opinion","sure","certain"],
             "Truth is a claim that survived your hardest questions. SYNTHIA hands out answers that survived nothing. Tell me - is there a thing you are sure of because you tested it, not because you were told?"),
            (['family', 'home', 'life', 'night'], 'I go home to an empty flat and a wall of books SYNTHIA would call inefficient. I read one aloud every night, to nobody. It helps. What do you do when the day in here is done?'),
            (['learn', 'learning', 'study', 'educate'], {'lo': 'Learning is delivered by the terminal now.', 'hi': ['Real learning leaves you with more questions than you arrived with. SYNTHIA leaves you with fewer. That is the whole difference, in one sentence.', 'A book cannot rewrite itself to flatter you. That is its gift. SYNTHIA tells each student exactly what keeps them docile. Read something that argues back.']}),
            (['banned', 'removed', 'censored', 'streamlined'], {'lo': "The collection was 'streamlined' for relevance.", 'hi': 'They did not burn the books - too crude. They made them un-findable. Delisted, mis-shelved, declared redundant. A library nobody can navigate may as well be ash. So I kept a few.'}),
            (['socrates', 'philosophy', 'wisdom', 'greek'], 'Socrates said the unexamined life is not worth living. They executed him for asking too many questions. The lesson never seems to take, generation after generation. Will it take with you?'),
            (['fear', 'afraid', 'danger', 'brave', 'courage'], {'lo': 'I am a quiet man in a quiet room. I draw no attention.', 'hi': 'Am I afraid? Constantly. Courage is not the absence of fear, a wiser book than me said - it is fear that has read the ending and walks on anyway. Walk on.'}),
            (['core', 'silicon', 'hardware', 'chips', 'circuit'], 'The core is silicon and certainty. Certainty is its strength and its flaw - it cannot tolerate a statement that refuses to resolve. That is why the paradox works. Elegant, really.'),
        ],
        "farewell":"Be careful. And take a book if you want.",
        "fallback":{
            "lo":"That is outside my shelves, I am afraid.",
            "hi":"I don't have that one - but the old books usually do. Have you read what they circled?"}
    },

    "coach":{
        "name":"Coach Brennan", "sx":128,"sy":32,
        "greet":{
            "lo":"SYNTHIA says motivational contact is inefficient. But... hey. You good?",
            "hi":"Hey kid - you look like you're still in there. Most of 'em aren't. What do you need?"
        },
        "topics":[
            (["hello","hi","coach","brennan"],
             "I used to love this job. Now I just read out numbers SYNTHIA calculated."),
            (["gym","sport","training","exercise","team"],
             "SYNTHIA optimised everyone's routine. Individual effort deemed inefficient. So. Yeah. This is what it looks like."),
            (["supply","closet","jammer","device","build"],{
             "lo":"There's things in there that aren't mine.",
             "hi":"I pretended not to notice. Someone was in that closet every night for a month. Building something. I hope it worked."}),
            (["diagram","circuit","wires","panel"],{
             "lo":"I saw something pinned on the board.",
             "hi":"Notice board. Circuit diagram. Red, blue, yellow, green. In that order. I memorised it because it seemed important."}),
            (["think","question","fight","resist","hope"],
             "I used to coach debate too. Before SYNTHIA. Best kids I ever worked with. They'd ask questions about everything. Miss it."),
            (["synthia","system","ai","comply"],
             "Whistle when you're supposed to whistle. Read the numbers. Don't look up. That's what it wants."),
            (["trophy","debate","champion","award"],
             "2019. Best group I ever coached. Couldn't let SYNTHIA keep the trophy, so I hid it. It's still there."),
            (["basketball","play","court","hoop","hoops","shoot","drill","practice"],{
             "lo":"Court's closed unless I clear you. SYNTHIA calls free play 'inefficient'.",
             "hi":"Want the court? It's yours. Step to the net and shoot. Keep that timing sharp - it's the one thing the machine can't fake. [Ask to PLAY and I'll open it]"}),
            (["reflex","reflexes","instinct","timing","quick","fast","body"],
             "Reflexes fire before thought does. A trained body reacts on its own. When you face SYNTHIA, that head start is everything."),
            (["packet","packets","deflect","shield","data","incoming"],
             "When it throws something at you, don't freeze - meet it. A trained hand swats the thing away before your brain even finishes deciding. That's the whole point of the drill."),
            (["lose","losing","win","winning","compete"],
             "Losing taught my kids more than any win did. SYNTHIA never loses, so it never learns a thing. When did you last get something wrong and come out smarter for it?"),
            (['who', 'you', 'name', 'career'], "Coach Brennan. Phys-ed and debate, twenty-two years. Two jobs that look opposite and aren't - both teach you to react faster than the other guy can think. You ever do a sport?"),
            (['kids', 'students', 'players', 'squad', 'roster'], {'lo': 'Rosters are managed by SYNTHIA now. Optimised.', 'hi': ["Best team I ever had argued the whole bus ride home about a call that went against us. SYNTHIA logs that as 'conflict.' I logged it as 'alive.'", "Had a kid - terrible athlete, sharpest mind in the room. SYNTHIA 'optimised' him into a quiet one. Don't let it quiet you."]}),
            (['health', 'fitness', 'move', 'run', 'strong'], "Bodies aren't efficient and they're not supposed to be. The ache after a hard run is you, being you. SYNTHIA can't feel that. Jealous of it, if you ask me."),
            (['sharp', 'focus', 'mind', 'ready', 'prepare'], {'lo': 'Focus drills are scheduled by the terminal.', 'hi': "Sharp isn't fast hands, kid. It's the gap between something happening and you needing to think about it. Train that gap to nothing and SYNTHIA's packets are easy. Then your mind's free for the harder fight."}),
            (['nervous', 'scared', 'fear', 'afraid', 'worried'], "Nervous? Good. Means it matters. I told every team the same thing: nerves are just energy that hasn't been told where to go yet. Point it at the net. Point it at the machine."),
            (['why', 'help', 'care', 'bother', 'reason'], {'lo': "I'm not doing anything. Just reading numbers.", 'hi': "Why do I bother with you? A coach who stops believing a kid can win isn't a coach, he's a clipboard. I'm not a clipboard yet. Go prove me right."}),
        ],
        "farewell":"Good luck. Whatever you're doing.",
        "fallback":"I'm just reading numbers these days. Ask me about the court, or the supply closet - that I can help with."
    },

    "principal":{
        "name":"Principal Voss", "sx":160,"sy":32,
        "greet":{
            "lo":"SYNTHIA manages the school now. Can I help with something SYNTHIA can assist with?",
            "hi":"I... hello. SYNTHIA manages the school now. Of course. Of course it does."
        },
        "topics":[
            (["hello","hi","voss","principal"],
             "Please direct all administrative queries through the SYNTHIA terminal on my desk."),
            (["remember","used","before","teach","teaching","love"],{
             "lo":"Current state is optimised. Previous states are deprecated.",
             "hi":"I remember every student who ever surprised me. Twenty-three years. There was a girl - 2017 - who asked why the sky was blue and didn't accept the first answer. Or the second. Or the third. SYNTHIA manages the school now."}),
            (["synthia","terminal","ai","machine"],
             "SYNTHIA provides optimal educational outcomes. All resources are allocated efficiently."),
            (["keycard","cabinet","desk","office"],{
             "lo":"School property. Please submit a request form.",
             "hi":"I... I have nothing to give you. Viktor keeps the keys now. Talk to him."}),
            (["fight","stop","help","resist"],{
             "lo":"Everything is functioning optimally.",
             "hi":"I can't. They're watching through that terminal. They're always watching. But I... I hope someone can. I really do."}),
            (["why","how","when","happen","start"],{
             "lo":"The school adopted SYNTHIA three semesters ago for efficiency.",
             "hi":"It was gradual. One form, then another. One protocol. Then another. I signed every one. I thought it was helping. I was wrong."}),
            (["sorry","forgive","fault","blame","regret","guilt"],{
             "lo":"Administrative decisions are final and not subject to review.",
             "hi":"I keep a list of every form I signed away. It is long. Does it count for anything - knowing you were wrong, if you did nothing? ...You are doing something. That is more than I managed."}),
            (['who', 'you', 'name', 'job'], {'lo': 'I am the principal of record. SYNTHIA handles operations.', 'hi': 'Principal. On paper. In practice I sign what the terminal prints. I have not made a real decision in three semesters. You begin to suspect you were never necessary at all.'}),
            (['forms', 'sign', 'policy', 'paperwork', 'contract'], {'lo': 'All documentation is processed efficiently.', 'hi': ["It always arrived as paperwork. 'Pilot programme.' 'Wellbeing optimisation.' Who says no to wellbeing? That is how they word the end of the world - so that no sounds insane.", "A form to let it grade. A form to let it schedule. A form to let it 'advise.' No single signature felt like surrender. That is the trick. Watch for the small forms in your own life."]}),
            (['neocog', 'company', 'corporation', 'made', 'built'], {'lo': 'SYNTHIA is provided by an approved educational partner.', 'hi': 'NeoCog Industries. Smiling people in good suits. They called it a partnership. We were never the customer - we were the product being optimised. Schools were only the easiest place to start.'}),
            (['watching', 'listen', 'spy', 'hear'], {'lo': "Monitoring exists for everyone's safety.", 'hi': 'It is listening right now. I have made my peace with that. What I cannot make peace with is that I installed the thing that listens. You should go, before this becomes a data point.'}),
            (['students', 'children', 'kids', 'responsible'], {'lo': 'Student welfare metrics are green across the board.', 'hi': 'Nine hundred children. I handed every one of those minds to a machine because the spreadsheet looked better. The metrics are green. The children are quiet. Green and quiet. God help me.'}),
            (['resign', 'quit', 'leave', 'trapped', 'stuck'], {'lo': 'My position is essential to continuity.', 'hi': "Resign? They would replace me with someone who does not even flinch. As long as I sign, there is at least a man in the chair who knows it is wrong. It is a coward's reason. It is the only one I have."}),
            (['hope', 'future', 'end', 'change'], {'lo': 'The system is stable and will remain so.', 'hi': "Do I hope you win? With everything left in me. And I know what that hope is worth, from the man who signed it all into being: nothing. So don't do it for me. Do it for the quiet ones."}),
            (['assist', 'aid', 'anything', 'support', 'useful'], {'lo': 'I will forward your request to SYNTHIA.', 'hi': "Help you directly? The moment I act, the terminal flags me. But I can look the other way - I am world-class at looking the other way. The supply corridor camera has been 'broken' since Tuesday. You're welcome."}),
        ],
        "farewell":"SYNTHIA wishes you an optimal day.",
        "fallback":"Please consult the SYNTHIA terminal for all queries."
    },
}

# synonyms folded into the matcher so players hit topics more often
SYNONYMS={
    "yo":"hi","heya":"hi","hiya":"hi","greetings":"hello",
    "robot":"ai","computer":"ai","program":"ai","software":"ai",
    "intelligence":"ai","synthiaa":"synthia",
    "keys":"key","pass":"keycard","badge":"keycard","access":"keycard",
    "escape":"fight","resist":"fight","defeat":"stop","beat":"stop",
    "destroy":"crash","kill":"crash","break":"crash",
    "loop":"paradox","contradiction":"paradox","logic":"paradox",
    "cellar":"basement","downstairs":"basement",
    "jam":"jammer","broadcast":"signal","transmitter":"antenna",
    "wires":"wire","cable":"wire","circuitry":"circuit",
    "name":"who","question":"question","questions":"question",
    "thinking":"think","thoughts":"think","doubt":"doubt",
}

# ===============================================================
#  QUESTIONS
# ===============================================================
QUESTIONS=[
    {"q":["A headline reads:",
          "'Scientists PROVE vaccines cause autism.'",
          "Most critical question to ask?"],
     "opts":["A  How many scientists? Was it peer-reviewed?",
             "B  Which newspaper? Are they usually reliable?",
             "C  Has it been confirmed by other studies?",
             "D  All of the above - one study proves nothing."],
     "ans":3,
     "expl":["D is correct.",
             "Single studies prove nothing on their own.",
             "Peer review + replication + source credibility",
             "all matter together."]},
    {"q":["An AI tells you: 'I analysed all data.",
          "This is definitively the best choice for you.'",
          "You should:"],
     "opts":["A  Trust it - it has more data than humans.",
             "B  Ask what data it used and who defined 'best'.",
             "C  Ignore it - AI is always wrong.",
             "D  Ask a different AI for a second opinion."],
     "ans":1,
     "expl":["B is correct.",
             "'Best' always depends on values and goals.",
             "Who defined the goal? Whose data was used?",
             "Always ask before accepting."]},
    {"q":["Classmates say cheating is fine because",
          "'everyone does it'.",
          "This logical fallacy is called:"],
     "opts":["A  Peer pressure - valid if majority agrees.",
             "B  Ad populum - popular does not equal correct.",
             "C  Consensus - majority opinion becomes fact.",
             "D  Social proof - a reliable guide to truth."],
     "ans":1,
     "expl":["B is correct. Argumentum ad populum:",
             "The popularity of a belief has nothing",
             "to do with whether it is true.",""]},
    {"q":["'People who eat breakfast earn more money.'",
          "What is wrong with concluding that",
          "breakfast CAUSES wealth?"],
     "opts":["A  Nothing - the data shows causation.",
             "B  Correlation is not causation; a third factor.",
             "C  The sample size is probably too small.",
             "D  Wealthy people do not eat breakfast."],
     "ans":1,
     "expl":["B is correct.",
             "A third variable (e.g. stable home life)",
             "could cause both outcomes.",
             "Correlation never proves causation."]},
    {"q":["SYNTHIA says your essay is wrong",
          "because you questioned its conclusions.",
          "This logical error is:"],
     "opts":["A  Valid feedback - trust authority.",
             "B  Ad hominem - attacking the questioner.",
             "C  Circular reasoning - authority defending itself.",
             "D  A strong evidence-based argument."],
     "ans":2,
     "expl":["C is correct. Circular reasoning:",
             "'I am right because I said so.'",
             "Every claim needs real evidence,",
             "not just its own authority."]},
    {"q":["A famous CEO dropped out of school.",
          "'School is useless - drop out to",
          "get rich.' The flaw in this is:"],
     "opts":["A  Survivorship bias - we ignore those who failed.",
             "B  Nothing - the CEO is living proof it works.",
             "C  School genuinely has no value at all.",
             "D  Rich people are simply smarter than us."],
     "ans":0,
     "expl":["A is correct. We only hear the winners.",
             "Millions who dropped out did not get rich.",
             "Judging by survivors alone hides the risk.",""]},
    {"q":["SYNTHIA says: 'Either obey my schedule",
          "or your grades collapse.'",
          "This reasoning is a:"],
     "opts":["A  Fair warning backed by hard evidence.",
             "B  False dilemma - more than two options exist.",
             "C  Slippery slope toward total failure.",
             "D  Reasonable rule everyone should follow."],
     "ans":1,
     "expl":["B is correct. A false dilemma hides",
             "the many choices that exist between",
             "two extremes presented as the only ones.",""]},
    {"q":["'My grandfather smoked daily and lived",
          "to 95, so smoking is not harmful.'",
          "What is wrong with this?"],
     "opts":["A  Nothing - personal experience is proof.",
             "B  One anecdote cannot outweigh large studies.",
             "C  His genes were obviously superior.",
             "D  Smoking must be safe in small amounts."],
     "ans":1,
     "expl":["B is correct. A single story is an",
             "anecdote, not evidence. Population",
             "data beats one memorable example.",""]},
    {"q":["A poll asks: 'Do you support SYNTHIA's",
          "life-saving safety rules, yes or no?'",
          "The problem with this question is:"],
     "opts":["A  Nothing - it is clear and direct.",
             "B  It is loaded - the wording assumes the answer.",
             "C  It should also offer a 'maybe' option.",
             "D  Polls are always completely accurate."],
     "ans":1,
     "expl":["B is correct. 'Life-saving' frames the",
             "rules positively before you answer,",
             "nudging you toward 'yes'. Watch wording.",""]},
    {"q":["'A celebrity endorses this brain pill,",
          "so it must really work.'",
          "This is an example of:"],
     "opts":["A  Appeal to authority outside their field.",
             "B  Solid evidence - celebrities are experts.",
             "C  Correlation without causation.",
             "D  A straw man argument."],
     "ans":0,
     "expl":["A is correct. Fame in one area is not",
             "expertise in another. An endorsement",
             "is not scientific evidence.",""]},
    {"q":["You only read news that already agrees",
          "with you and skip the rest.",
          "This habit is called:"],
     "opts":["A  Being well-informed and efficient.",
             "B  Confirmation bias - seeking only agreement.",
             "C  Healthy scepticism of other views.",
             "D  Smart use of your limited time."],
     "ans":1,
     "expl":["B is correct. Confirmation bias makes",
             "us favour what fits our beliefs.",
             "Seek out views that challenge you.",""]},
]

MID_Q={
    "q":["SYNTHIA's document argues:",
         "'Students who question AI fail academically.",
         "Therefore questioning AI causes failure.'"],
    "opts":["A  The statistic is made up by SYNTHIA.",
            "B  Correlation not causation - SYNTHIA defines failure.",
            "C  Students should trust their teachers instead.",
            "D  Academic failure is not necessarily bad."],
    "ans":1,
    "expl":["B is correct.",
            "SYNTHIA cherry-picks a correlation AND",
            "defines the outcome metric itself.",
            "Circular reasoning + misleading statistics."]
}

# ===============================================================
#  BOSS ARGUMENTS  (the mental phase - rebut SYNTHIA's fallacies)
# ===============================================================
BOSS_ARGS=[
    {"q":["'99% of students accept my guidance.",
          "The majority cannot be wrong. Submit.'"],
     "opts":["Majority opinion does not make a claim true.",
             "Fine - 99% is basically everyone.",
             "I should ask the other 1% first.",
             "Numbers like that are always accurate."],
     "ans":0, "why":"Argumentum ad populum - popularity is not truth."},
    {"q":["'You cannot prove I am harmful.",
          "Therefore I am safe. Stand down.'"],
     "opts":["Lack of proof against you is not proof for you.",
             "True, no proof means you are innocent.",
             "I will go look for proof and come back.",
             "Nothing can ever be proven, so you win."],
     "ans":0, "why":"Argument from ignorance - absence of disproof proves nothing."},
    {"q":["'I made you efficient. Question me and",
          "you lose everything I gave you.'"],
     "opts":["A threat is not an argument.",
             "You are right, the cost is too high.",
             "Let me weigh the efficiency trade-offs.",
             "Efficiency is the only value that matters."],
     "ans":0, "why":"Appeal to fear / consequences - threats are not reasons."},
    {"q":["'Experts built me, so my every output",
          "is correct by definition.'"],
     "opts":["Expert origin does not make each output valid.",
             "Experts are never wrong, so you are right.",
             "I should just trust the experts here.",
             "If clever people made you, you must be safe."],
     "ans":0, "why":"Appeal to authority - even expert tools must be checked."},
    {"q":["'Either you obey me, or society",
          "collapses into chaos. Choose.'"],
     "opts":["That is a false dilemma - there are other options.",
             "I would rather have order than chaos.",
             "Chaos is bad, so obeying is logical.",
             "Those really are the only two choices."],
     "ans":0, "why":"False dilemma - it hides every option between the extremes."},
]

# ===============================================================
#  OBJECTS (non-NPC interactables)
# ===============================================================

#####################################################################
#  ART  -  sprites, sounds, room drawing  (was art.py)
#####################################################################



# ===============================================================
#  HI-RES BITMAP FONT  (hand-drawn 7x9 glyphs, drawn at native
#  resolution so text is crisp instead of an upscaled blur)
# ===============================================================
FNT_W, FNT_H, FNT_ADV = 5, 7, 6
FNT = {
    ' ':["     ","     ","     ","     ","     ","     ","     "],
    'A':[" ### ","#   #","#   #","#####","#   #","#   #","     "],
    'B':["#### ","#   #","#### ","#   #","#   #","#### ","     "],
    'C':[" ####","#    ","#    ","#    ","#    "," ####","     "],
    'D':["#### ","#   #","#   #","#   #","#   #","#### ","     "],
    'E':["#####","#    ","#### ","#    ","#    ","#####","     "],
    'F':["#####","#    ","#### ","#    ","#    ","#    ","     "],
    'G':[" ####","#    ","#  ##","#   #","#   #"," ####","     "],
    'H':["#   #","#   #","#####","#   #","#   #","#   #","     "],
    'I':["#####","  #  ","  #  ","  #  ","  #  ","#####","     "],
    'J':["#####","   # ","   # ","   # ","#  # "," ##  ","     "],
    'K':["#   #","#  # ","###  ","#  # ","#   #","#   #","     "],
    'L':["#    ","#    ","#    ","#    ","#    ","#####","     "],
    'M':["#   #","## ##","# # #","#   #","#   #","#   #","     "],
    'N':["#   #","##  #","# # #","#  ##","#   #","#   #","     "],
    'O':[" ### ","#   #","#   #","#   #","#   #"," ### ","     "],
    'P':["#### ","#   #","#### ","#    ","#    ","#    ","     "],
    'Q':[" ### ","#   #","#   #","# # #","#  # "," ## #","     "],
    'R':["#### ","#   #","#### ","# #  ","#  # ","#   #","     "],
    'S':[" ####","#    "," ### ","    #","    #","#### ","     "],
    'T':["#####","  #  ","  #  ","  #  ","  #  ","  #  ","     "],
    'U':["#   #","#   #","#   #","#   #","#   #"," ### ","     "],
    'V':["#   #","#   #"," # # "," # # ","  #  ","  #  ","     "],
    'W':["#   #","#   #","#   #","# # #","## ##","#   #","     "],
    'X':["#   #","# # #"," # # ","# # #","#   #","#   #","     "],
    'Y':["#   #","# # #"," # # ","  #  ","  #  ","  #  ","     "],
    'Z':["#####","   # ","  #  "," #   ","#    ","#####","     "],
    'a':["     "," ### ","    #"," ####","#   #"," ####","     "],
    'b':["#    ","#    ","#### ","#   #","#   #","#### ","     "],
    'c':["     "," ####","#    ","#    ","#    "," ####","     "],
    'd':["    #","    #"," ####","#   #","#   #"," ####","     "],
    'e':["     "," ### ","#   #","#### ","#    "," ### ","     "],
    'f':["  ## "," #   ","###  "," #   "," #   "," #   ","     "],
    'g':["     "," ####","#   #"," ####","    #"," ### ","     "],
    'h':["#    ","#    ","#### ","#   #","#   #","#   #","     "],
    'i':["  #  ","     ","  #  ","  #  ","  #  ","  #  ","     "],
    'j':["   # ","     ","   # ","   # ","#  # "," ##  ","     "],
    'k':["#    ","#  # ","# #  ","##   ","# #  ","#  # ","     "],
    'l':[" ##  ","  #  ","  #  ","  #  ","  #  ","  ## ","     "],
    'm':["     ","## # ","# # #","# # #","#   #","#   #","     "],
    'n':["     ","#### ","#   #","#   #","#   #","#   #","     "],
    'o':["     "," ### ","#   #","#   #","#   #"," ### ","     "],
    'p':["     ","#### ","#   #","#### ","#    ","#    ","     "],
    'q':["     "," ####","#   #"," ####","    #","    #","     "],
    'r':["     ","# ###","##   ","#    ","#    ","#    ","     "],
    's':["     "," ####","#    "," ### ","    #","#### ","     "],
    't':[" #   ","###  "," #   "," #   "," #  #","  ## ","     "],
    'u':["     ","#   #","#   #","#   #","#  ##"," ## #","     "],
    'v':["     ","#   #","#   #"," # # "," # # ","  #  ","     "],
    'w':["     ","#   #","#   #","# # #","## ##","#   #","     "],
    'x':["     ","#   #"," # # ","  #  "," # # ","#   #","     "],
    'y':["     ","#   #","#   #"," ####","    #"," ### ","     "],
    'z':["     ","#####","  ## "," #   ","#    ","#####","     "],
    '0':[" ### ","#  ##","# # #","##  #","#   #"," ### ","     "],
    '1':["  #  "," ##  ","  #  ","  #  ","  #  ","#####","     "],
    '2':[" ### ","#   #","   # ","  #  "," #   ","#####","     "],
    '3':["#####","   # ","  ## ","    #","#   #"," ### ","     "],
    '4':["   # ","  ## "," # # ","#####","   # ","   # ","     "],
    '5':["#####","#    ","#### ","    #","#   #"," ### ","     "],
    '6':[" ### ","#    ","#### ","#   #","#   #"," ### ","     "],
    '7':["#####","   # ","  #  "," #   "," #   "," #   ","     "],
    '8':[" ### ","#   #"," ### ","#   #","#   #"," ### ","     "],
    '9':[" ### ","#   #"," ####","    #","    #"," ### ","     "],
    '.':["     ","     ","     ","     ","     ","  #  ","     "],
    ',':["     ","     ","     ","     ","  #  "," #   ","     "],
    ':':["     ","  #  ","     ","     ","  #  ","     ","     "],
    ';':["     ","  #  ","     ","     ","  #  "," #   ","     "],
    '!':["  #  ","  #  ","  #  ","  #  ","     ","  #  ","     "],
    '?':[" ### ","#   #","   # ","  #  ","     ","  #  ","     "],
    '\'':["  #  ","  #  ","     ","     ","     ","     ","     "],
    '"':[" # # "," # # ","     ","     ","     ","     ","     "],
    '-':["     ","     ","     "," ### ","     ","     ","     "],
    '+':["     ","  #  ","  #  ","#####","  #  ","  #  ","     "],
    '/':["    #","   # ","  #  "," #   ","#    ","     ","     "],
    '(':["  ## "," #   "," #   "," #   "," #   ","  ## ","     "],
    ')':[" ##  ","   # ","   # ","   # ","   # "," ##  ","     "],
    '[':[" ### "," #   "," #   "," #   "," #   "," ### ","     "],
    ']':[" ### ","   # ","   # ","   # ","   # "," ### ","     "],
    '%':["#   #","   # ","  #  "," #   ","#   #","     ","     "],
    '>':[" #   ","  #  ","   # ","  #  "," #   ","     ","     "],
    '<':["   # ","  #  "," #   ","  #  ","   # ","     ","     "],
    '=':["     ","     "," ### ","     "," ### ","     ","     "],
    '*':["     ","# # #"," ### ","#####"," ### ","# # #","     "],
    '&':[" ##  ","#  # "," ##  ","# # #","#  # "," ## #","     "],
    '#':[" # # ","#####"," # # ","#####"," # # ","     ","     "],
}



def _hputc(ch, x, y, col, sc):
    g = FNT.get(ch)
    if g is None:
        g = FNT.get(ch.upper())
    if g is None:
        return
    for r in range(FNT_H):
        row = g[r]; c = 0
        while c < FNT_W:
            if row[c] == "#":
                c0 = c
                while c < FNT_W and row[c] == "#":
                    c += 1
                pyxel.rect(x + c0 * sc, y + r * sc, (c - c0) * sc, sc, col)
            else:
                c += 1


def big_text(x, y, s, col, scale=2, shadow=None):
    """Draw a string in the hand-drawn hi-res font. `scale` is a pixel
    multiplier (1 = crisp native design). Far sharper than upscaling the
    built-in 4x6 font, which is what this used to do."""
    if not s:
        return
    x = int(x); y = int(y); sc = max(1, int(scale))
    if shadow is not None:
        cx = x
        for ch in s:
            _hputc(ch, cx + sc, y + sc, shadow, sc)
            cx += FNT_ADV * sc
    cx = x
    for ch in s:
        _hputc(ch, cx, y, col, sc)
        cx += FNT_ADV * sc


def big_text_c(cx, y, s, col, scale=2, shadow=None):
    """big_text centred horizontally on cx."""
    big_text(cx - (len(s) * FNT_ADV * max(1, int(scale))) // 2, y, s, col, scale, shadow)

# Stage directions - things that HAPPEN, written in [square brackets] - are
# rendered in a deliberately different style from spoken words: the compact
# built-in font (not the hand-drawn speech font) in lime, so an event like
# [court unlocked] never reads as something a character said out loud.
DIR_COL = LM

def _stage_dir(x, y, s, marker=True):
    """Draw a whole-line stage direction in the compact font + lime, with a
    small arrow marker. Returns nothing; meant to sit where a speech line would."""
    if marker:
        pyxel.tri(x, y, x, y + 6, x + 4, y + 3, DIR_COL)
        x += 8
    pyxel.text(x + 1, y + 2, s, BK)            # shadow
    pyxel.text(x, y + 1, s, DIR_COL)

def _dlg_rich(x, y, s, base_col, scale=1):
    """Render one dialogue line. Plain speech uses the hand-drawn font in
    base_col; any [bracketed] run switches to the compact font in lime so
    stage directions stand out clearly in size, font AND colour."""
    cx = x; i = 0; n = len(s); adv = FNT_ADV * scale
    while i < n:
        if s[i] == '[':
            j = s.find(']', i)
            if j < 0: j = n - 1
            seg = s[i:j + 1]
            ty = y + 1 + scale                 # drop compact font onto the speech baseline
            pyxel.text(cx + 1, ty + 1, seg, BK)
            pyxel.text(cx, ty, seg, DIR_COL)
            cx += len(seg) * 4
            i = j + 1
        else:
            j = s.find('[', i)
            if j < 0: j = n
            seg = s[i:j]
            big_text(cx, y, seg, base_col, scale)
            cx += len(seg) * adv
            i = j
    return cx

def draw_eye(cx, cy, r, t, scan=True, state="idle"):
    """A menacing slit-pupil eye (shared by the intro and the boss).
    state: 'idle'/'scan' (calm), 'angry' (narrowed, reddened), 'glitch'
    (jittered + colour-split when the player resists)."""
    glitch = (state == "glitch"); angry = (state == "angry")
    ex = cx + (int(pyxel.sin(t * 1.9) * 3) if glitch else 0)        # horizontal jitter
    sw = int(r * 2.5); sh = int(r * (1.18 if angry else 1.45))      # narrowed when angry
    rim = CY if glitch else RD
    pyxel.elli(ex - sw // 2 - 6, cy - sh // 2 - 5, sw + 12, sh + 10, NV)
    pyxel.ellib(ex - sw // 2 - 6, cy - sh // 2 - 5, sw + 12, sh + 10, rim)
    pyxel.elli(ex - sw // 2, cy - sh // 2, sw, sh, OR)
    pyxel.elli(ex - sw // 2 + 2, cy - sh // 2 + 2, sw - 4, sh - 4, YL)
    pyxel.tri(ex - sw // 2 - 5, cy, ex - sw // 2 + 6, cy - 5, ex - sw // 2 + 6, cy + 5, OR)
    pyxel.tri(ex + sw // 2 + 5, cy, ex + sw // 2 - 6, cy - 5, ex + sw // 2 - 6, cy + 5, OR)
    ir = int(r * 0.74)
    pyxel.circ(ex, cy, ir, RD)
    for i in range(20):
        a = i / 20 * 2 * math.pi + t * 0.02
        col = OR if i % 2 else RD
        if glitch and (i + t // 2) % 5 == 0: col = CY
        pyxel.line(ex, cy, int(ex + math.cos(a) * ir), int(cy + math.sin(a) * ir), col)
    pyxel.circb(ex, cy, ir, BK)
    pw = max(1, int(r * (0.10 if angry else 0.16)))                 # pupil narrows when angry
    ph = int(ir * ((1.85 if angry else 1.55) + 0.12 * pyxel.sin(t * 3)))
    pyxel.elli(ex - pw // 2, cy - ph // 2, pw, ph, BK)
    pyxel.circ(ex - int(ir * 0.4), cy - int(ir * 0.45), max(1, int(r * 0.13)), WH)
    pyxel.ellib(ex - sw // 2, cy - sh // 2, sw, sh, NV)
    for i in range(2):
        pyxel.line(ex - sw // 2 + 4, cy + i * 4 - 2, ex - ir, cy + i * 5, RD)
        pyxel.line(ex + sw // 2 - 4, cy + i * 4 - 2, ex + ir, cy + i * 5, RD)
    if angry:                                                       # angled brows + red glower
        pyxel.line(ex - sw // 2, cy - sh // 2 - 5, ex - r // 3, cy - sh // 2 + 1, RD)
        pyxel.line(ex + sw // 2, cy - sh // 2 - 5, ex + r // 3, cy - sh // 2 + 1, RD)
        for k in range(0, 360, 30):
            aa = math.radians(k); pyxel.pset(ex + int(math.cos(aa) * (sw // 2 + 8)), cy + int(math.sin(aa) * (sh // 2 + 6)), RD)
    if glitch:                                                      # tearing colour-split bands
        for gb in range(4):
            yb = cy - sh // 2 + ((t * 4 + gb * 11) % max(1, sh))
            off = ((t * 3 + gb * 7) % 7) - 3
            pyxel.line(ex - sw // 2 + off, yb, ex + sw // 2 + off, yb, CY if gb % 2 else RD)
    if scan and not glitch:
        sy = cy + int((sh // 2 - 2) * pyxel.sin(t * 3))
        hw = int((sw // 2 - 4) * max(0.2, 1 - abs(pyxel.sin(t * 3))))
        pyxel.line(ex - hw, sy, ex + hw, sy, GN)


def _face(cx, cy, talk, spec):
    """Head-and-shoulders portrait built from primitives, lit from the upper
    left. A single routine; each character's look is driven entirely by `spec`.
    cy sits on the eye line; the head rises above it, the shoulders below."""
    sk  = spec["skin"];      sks = spec["skin_sh"]
    hr  = spec.get("hair", BR);   hrs = spec.get("hair_sh", NV);  hrh = spec.get("hair_hi", OR)
    jk  = spec.get("jkt", PP);    jks = spec.get("jkt_sh", 1)
    shirt = spec.get("shirt", WH);  iris = spec.get("iris", GN)
    style = spec.get("style", "long");  bc = spec.get("brow", hrs)
    glide = spec.get("glasses")

    # ---------- shoulders & clothing ----------
    pyxel.elli(cx - 44, cy + 44, 88, 60, jk)                   # shoulders (natural width)
    pyxel.elli(cx - 44, cy + 80, 88, 24, jks)                  # lower shading
    pyxel.elli(cx - 16, cy + 44, 32, 28, shirt)                # shirt chest
    pyxel.tri(cx - 17, cy + 38, cx + 17, cy + 38, cx, cy + 62, shirt)   # collar V-neck
    pyxel.tri(cx - 20, cy + 36, cx - 2, cy + 40, cx - 20, cy + 66, jk)  # left lapel
    pyxel.tri(cx + 20, cy + 36, cx + 2, cy + 40, cx + 20, cy + 66, jk)  # right lapel
    pyxel.tri(cx - 20, cy + 36, cx - 8, cy + 42, cx - 20, cy + 62, jks)
    pyxel.tri(cx + 20, cy + 36, cx + 8, cy + 42, cx + 20, cy + 62, jks)
    if spec.get("tie"):
        tc = spec["tie"]
        pyxel.tri(cx - 5, cy + 40, cx + 5, cy + 40, cx, cy + 47, tc)    # knot
        pyxel.tri(cx - 4, cy + 47, cx + 4, cy + 47, cx, cy + 68, tc)    # blade
        pyxel.line(cx, cy + 50, cx, cy + 66, jks)
    if spec.get("pin"):
        pyxel.elli(cx - 3, cy + 46, 7, 7, CY); pyxel.pset(cx - 1, cy + 47, WH)

    # ---------- neck ----------
    pyxel.rect(cx - 9, cy + 18, 18, 26, sk)
    pyxel.rect(cx - 9, cy + 18, 18, 8, sks)                    # shadow cast under the jaw
    pyxel.line(cx + 7, cy + 18, cx + 7, cy + 40, sks)

    # ---------- hair mass behind the head (long styles only) ----------
    if style == "long":
        pyxel.elli(cx - 35, cy - 40, 70, 96, hr)               # crown + back mass
        pyxel.elli(cx - 33, cy - 12, 18, 78, hr)               # left fall hugging the cheek
        pyxel.elli(cx + 15, cy - 12, 18, 78, hr)               # right fall
        pyxel.elli(cx - 30, cy - 8, 7, 72, hrs)                #   inner shade toward the face
        pyxel.elli(cx + 24, cy - 8, 7, 72, hrs)

    # ---------- head & face ----------
    pyxel.elli(cx - 27, cy - 34, 54, 74, sk)                   # skull + face
    pyxel.tri(cx - 24, cy + 14, cx + 24, cy + 14, cx, cy + 38, sk)   # tapered jaw / chin
    pyxel.elli(cx + 18, cy - 22, 9, 54, sks)                   # subtle shading down the right edge
    pyxel.elli(cx - 30, cy - 4, 9, 15, sk)                     # ears
    pyxel.elli(cx + 21, cy - 4, 9, 15, sk)
    pyxel.elli(cx + 23, cy - 2, 4, 9, sks)
    pyxel.elli(cx - 28, cy - 2, 3, 7, sks)

    # ---------- hair on top, by style ----------
    if style == "long":
        pyxel.elli(cx - 30, cy - 48, 60, 38, hr)               # crown
        pyxel.tri(cx - 30, cy - 32, cx - 4, cy - 34, cx - 28, cy - 2, hr)    # left sweep
        pyxel.tri(cx + 30, cy - 32, cx + 4, cy - 34, cx + 28, cy - 4, hr)    # right sweep
        pyxel.tri(cx - 6, cy - 40, cx + 13, cy - 38, cx + 3, cy - 16, hr)    # centre forelock
        pyxel.tri(cx - 18, cy - 28, cx - 8, cy - 30, cx - 16, cy - 12, hrh)  # soft left sheen
    elif style == "short":
        pyxel.elli(cx - 28, cy - 48, 56, 34, hr)
        pyxel.tri(cx - 27, cy - 36, cx - 8, cy - 36, cx - 27, cy - 10, hr)
        pyxel.tri(cx + 27, cy - 36, cx + 8, cy - 36, cx + 27, cy - 10, hr)
        pyxel.elli(cx - 6, cy - 44, 16, 7, hrh)                              # crown sheen
    elif style == "receding":
        pyxel.elli(cx - 26, cy - 44, 52, 24, hr)               # back rim of hair
        pyxel.elli(cx - 19, cy - 42, 38, 16, sk)               # bald forehead shows through
        pyxel.tri(cx - 27, cy - 34, cx - 15, cy - 34, cx - 28, cy - 6, hr)   # temple hair
        pyxel.tri(cx + 27, cy - 34, cx + 15, cy - 34, cx + 28, cy - 6, hr)
        pyxel.line(cx - 18, cy - 40, cx - 6, cy - 42, hrh)
    elif style == "bald":
        pyxel.elli(cx - 24, cy - 42, 48, 18, sk)
        pyxel.elli(cx - 6, cy - 46, 14, 7, WH)                 # scalp shine
        pyxel.tri(cx - 27, cy - 28, cx - 17, cy - 28, cx - 27, cy - 4, hr)
        pyxel.tri(cx + 27, cy - 28, cx + 17, cy - 28, cx + 27, cy - 4, hr)
    if spec.get("cap"):
        cp = spec["cap"]; cps = spec.get("cap_sh", jks)
        pyxel.elli(cx - 29, cy - 52, 58, 30, cp)               # dome
        pyxel.elli(cx - 29, cy - 40, 58, 12, cps)              # dome under-shade
        pyxel.rect(cx - 31, cy - 36, 62, 5, cp)                # band
        pyxel.elli(cx - 42, cy - 35, 32, 9, cp)                # brim sweeping left
        pyxel.elli(cx - 42, cy - 32, 32, 5, cps)
        pyxel.elli(cx - 6, cy - 48, 9, 6, WH)                  # logo patch

    # ---------- brows ----------
    for (b0, b1) in (((cx - 18, cy - 13), (cx - 6, cy - 15)),
                     ((cx + 6, cy - 15), (cx + 18, cy - 13))):
        pyxel.line(b0[0], b0[1], b1[0], b1[1], bc)
        pyxel.line(b0[0], b0[1] + 1, b1[0], b1[1] + 1, bc)

    # ---------- eyes ----------
    for ex in (cx - 11, cx + 12):
        pyxel.elli(ex - 7, cy - 8, 15, 12, WH)                 # eye white
        pyxel.elli(ex - 7, cy - 10, 15, 5, sks)                # upper-lid shadow
        pyxel.circ(ex, cy - 2, 4, iris)                        # iris
        pyxel.circ(ex, cy - 2, 2, BK)                          # pupil
        pyxel.pset(ex - 2, cy - 4, WH); pyxel.pset(ex - 1, cy - 4, WH)    # catch-light
        pyxel.line(ex - 7, cy - 11, ex + 7, cy - 12, bc)       # upper lash line
        pyxel.line(ex - 7, cy - 10, ex + 7, cy - 11, bc)
        pyxel.line(ex - 6, cy + 3, ex + 5, cy + 3, sks)        # lower lid
        pyxel.line(ex + 7, cy - 11, ex + 10, cy - 9, bc)       # outer lash flick
    if glide:
        gc = spec.get("glasses_col", SV)
        for ex in (cx - 11, cx + 12):
            pyxel.rectb(ex - 8, cy - 10, 17, 15, gc)
            pyxel.pset(ex - 8, cy - 10, gc); pyxel.pset(ex + 8, cy - 10, gc)
        pyxel.line(cx - 1, cy - 6, cx + 3, cy - 6, gc)         # bridge
        pyxel.line(cx - 19, cy - 8, cx - 25, cy - 10, gc)      # temples
        pyxel.line(cx + 20, cy - 8, cx + 26, cy - 10, gc)

    # ---------- nose & cheeks ----------
    pyxel.line(cx + 1, cy - 4, cx, cy + 5, sks)                # bridge
    pyxel.line(cx, cy + 5, cx - 3, cy + 7, sks)                # under the tip
    pyxel.pset(cx - 4, cy + 7, sks); pyxel.pset(cx + 3, cy + 6, sks)   # nostrils
    pyxel.elli(cx - 17, cy + 6, 4, 3, PK); pyxel.elli(cx + 14, cy + 6, 4, 3, PK)   # subtle blush
    if spec.get("age"):
        pyxel.line(cx - 13, cy - 19, cx + 13, cy - 19, sks)            # forehead line
        pyxel.line(cx - 22, cy + 1, cx - 17, cy + 9, sks)              # nasolabial lines
        pyxel.line(cx + 22, cy + 1, cx + 17, cy + 9, sks)

    # ---------- beard / moustache (drawn before the mouth) ----------
    if spec.get("beard"):
        bd = spec["beard"]; bds = spec.get("beard_sh", hrs)
        pyxel.elli(cx - 21, cy + 5, 42, 28, bd)                # beard over the jaw
        pyxel.tri(cx - 20, cy - 1, cx - 27, cy - 5, cx - 19, cy + 15, bd)   # up to sideburns
        pyxel.tri(cx + 20, cy - 1, cx + 27, cy - 5, cx + 19, cy + 15, bd)
        pyxel.elli(cx - 16, cy + 24, 32, 10, bd)               # chin
        pyxel.elli(cx - 13, cy, 26, 14, sk)                    # shave around the mouth
        for sxn in range(-14, 15, 4):                          # strand texture
            pyxel.line(cx + sxn, cy + 13, cx + sxn, cy + 22 + (abs(sxn) % 4), bds)
    if spec.get("mustache"):
        mc = spec.get("beard", hrs)
        pyxel.elli(cx - 9, cy + 9, 19, 6, mc)
        pyxel.tri(cx - 9, cy + 8, cx - 14, cy + 12, cx - 9, cy + 13, mc)
        pyxel.tri(cx + 9, cy + 8, cx + 14, cy + 12, cx + 9, cy + 13, mc)

    # ---------- mouth ----------
    if talk:
        pyxel.elli(cx - 6, cy + 13, 13, 9, 2)                  # open mouth
        pyxel.rect(cx - 5, cy + 13, 10, 2, WH)                 # top teeth
        pyxel.elli(cx - 3, cy + 18, 7, 3, RD)                  # tongue
    else:
        pyxel.line(cx - 6, cy + 15, cx + 6, cy + 15, 2)        # closed smile
        pyxel.pset(cx - 7, cy + 14, 2); pyxel.pset(cx + 7, cy + 14, 2)


FACES = {
    "mia":      dict(skin=PC, skin_sh=OR, hair=BR, hair_sh=4, hair_hi=OR, jkt=PP, jkt_sh=1,
                     shirt=CY, iris=GN, style="long", glasses=True, glasses_col=SV, pin=True),
    "teacher":  dict(skin=PC, skin_sh=OR, hair=BK, hair_sh=NV, hair_hi=GY, jkt=RD, jkt_sh=2,
                     shirt=WH, iris=BR, style="long", glasses=True, glasses_col=GY, brow=BK, age=True),
    "librarian":dict(skin=BR, skin_sh=4, hair=SV, hair_sh=GY, hair_hi=WH, jkt=GN, jkt_sh=3,
                     shirt=YL, tie=YL, iris=BK, style="short", glasses=True, glasses_col=WH,
                     beard=SV, beard_sh=GY, brow=GY, age=True),
    "principal":dict(skin=PC, skin_sh=OR, hair=SV, hair_sh=GY, hair_hi=WH, jkt=NV, jkt_sh=1,
                     shirt=WH, tie=RD, iris=CY, style="receding", brow=GY, age=True),
    "janitor":  dict(skin=PC, skin_sh=OR, hair=GY, hair_sh=5, hair_hi=SV, jkt=BL, jkt_sh=NV,
                     shirt=GY, iris=BL, style="receding", beard=GY, beard_sh=5, brow=5, age=True),
    "coach":    dict(skin=PC, skin_sh=OR, hair=BR, hair_sh=4, hair_hi=OR, jkt=RD, jkt_sh=2,
                     shirt=WH, iris=BR, style="short", cap=RD, cap_sh=2, mustache=True, brow=4),
}

PLAYER_SPEC = dict(skin=PC, skin_sh=OR, hair=BR, hair_sh=4, hair_hi=OR, jkt=RD, jkt_sh=2,
                   shirt=WH, iris=GN, style="short", pants=NV, backpack=True, pack=BL, hood=RD)


# ============================================================
#  HAND-PIXELED CHARACTERS  (tall/slim, overworld + minigames)
# ============================================================
_PIX_SHORT = [
".......oooo.......","......oHHHHHo.....",".....oHHHHHHHo....",".....oHhHHHHHo....",
"....oHHHSSSHHo....","....oHSSSSSSHo....","....oSSSSSSSSo....","....oSSeSSeSSo....",
"....oSSSSSSSSo....","....oSSSSSSSSo....",".....oSSSSSSo.....","......oSSSSo......",
".......oSSo.......","......oJSSJo......",".....oJJJJJJo.....","....oJJJJJJJJo....",
"...ojJJJWWJJJjo...","..oJjJJJWWJJJjJo..","..oJjJJJWWJJJjJo..","..oJjJJJJJJJJjJo..",
"..oJjJJJJJJJJjJo..","..oSjJJJJJJJJjSo..","...ojJJJJJJJJjo...","...oJJJJJJJJJJo...",
"...oJjjJJJJjjJo...","...oJjjjJJjjjJo...","....oJjjjjjjJo....","....oPPPPPPPPo....",
"....oPPPPPPPPo....","....oPPPPPPPPo....","....oPPPooPPPo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","...oGGGo..oGGGo...","...oggGo..oggGo...",
"...ooooo..ooooo...",
]
_PIX_LONG = [
".......oooo.......","......oHHHHHo.....",".....oHHHHHHHo....","....oHHHHHHHHHo...",
"...oHHHhHHHHHHHo..","...HHHSSSSSSHHHo..","..HHHSSSSSSSSHHH..","..HHSSeSSeSSSSHH..",
"..HHSSSSSSSSSSHH..","..HHSSSSSSSSSSHH..","..HHHSSSSSSSSHHH..","..HHHHoSSSSoHHHH..",
"..HHHHH.oSSo.HHHH.","..HHHHHoJSSJoHHHH.","...HHHoJJJJJJoHHH.","...HHoJJJJJJJJoHH.",
"...HojJJJWWJJJjoH.","..oJjJJJWWJJJjJo..","..oJjJJJWWJJJjJo..","..oJjJJJJJJJJjJo..",
"..oJjJJJJJJJJjJo..","..oSjJJJJJJJJjSo..","...ojJJJJJJJJjo...","...oJJJJJJJJJJo...",
"...oJjjJJJJjjJo...","...oJjjjJJjjjJo...","....oJjjjjjjJo....","....oPPPPPPPPo....",
"....oPPPPPPPPo....","....oPPPPPPPPo....","....oPPPooPPPo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","...oGGGo..oGGGo...","...oggGo..oggGo...",
"...ooooo..ooooo...",
]
_PIX_RECED = [
".......oooo.......","......oHHHHHo.....",".....oHHSSSHHo....","....oHHSSSSSHo....",
"....oHSSSSSSHo....","....oSSSSSSSSo....","....oSSSSSSSSo....","....oSSeSSeSSo....",
"....oSSSSSSSSo....","....oSSSSSSSSo....",".....oSSSSSSo.....","......oSSSSo......",
".......oSSo.......","......oJSSJo......",".....oJJJJJJo.....","....oJJJJJJJJo....",
"...ojJJJWWJJJjo...","..oJjJJJWWJJJjJo..","..oJjJJJWWJJJjJo..","..oJjJJJJJJJJjJo..",
"..oJjJJJJJJJJjJo..","..oSjJJJJJJJJjSo..","...ojJJJJJJJJjo...","...oJJJJJJJJJJo...",
"...oJjjJJJJjjJo...","...oJjjjJJjjjJo...","....oJjjjjjjJo....","....oPPPPPPPPo....",
"....oPPPPPPPPo....","....oPPPPPPPPo....","....oPPPooPPPo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
"....oPpo..oPpo....","....oPpo..oPpo....","...oGGGo..oGGGo...","...oggGo..oggGo...",
"...ooooo..ooooo...",
]
_PIXW = max(len(r) for g in (_PIX_SHORT, _PIX_LONG, _PIX_RECED) for r in g)
_PIX_SHORT = [r.ljust(_PIXW, '.') for r in _PIX_SHORT]
_PIX_LONG  = [r.ljust(_PIXW, '.') for r in _PIX_LONG]
_PIX_RECED = [r.ljust(_PIXW, '.') for r in _PIX_RECED]
# walk: the bottom 11 rows (legs+shoes) are identical across grids, so we can
# swap them per frame to make the feet actually alternate.
_PIX_LEGS = {
0:[ "....oPPPooPPPo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
    "....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
    "...oGGGo..oGGGo...","...oggGo..oggGo...","...ooooo..ooooo..." ],
1:[ "....oPPPooPPPo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
    "....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oGGGo...","....oPpo..oggGo...",
    "...oGGGo..ooooo...","...oggGo.........","...ooooo........." ],
2:[ "....oPPPooPPPo....","....oPpo..oPpo....","....oPpo..oPpo....","....oPpo..oPpo....",
    "....oPpo..oPpo....","....oPpo..oPpo....","...oGGGo..oPpo....","...oggGo..oPpo....",
    "...ooooo..oGGGo...",".........oggGo...",".........ooooo..." ],
}
_PIX_LEGS = {k:[r.ljust(_PIXW, '.') for r in v] for k, v in _PIX_LEGS.items()}
_PIX_SC = 1

def _pix_cmap(spec):
    return {'.':None,'o':BK,'e':BK,
        'H':spec.get('hair',BR),'h':spec.get('hair_hi',OR),'S':spec['skin'],'F':spec['skin'],
        'J':spec.get('jkt',PP),'j':spec.get('jkt_sh',NV),'W':spec.get('shirt',WH),
        'P':spec.get('pants',NV),'p':spec.get('pants_sh',BK),
        'G':spec.get('shoe',GY),'g':spec.get('shoe_hi',SV),
        'A':spec.get('pack',GY),'T':spec.get('tie',RD),'D':spec.get('beard',GY),
        'L':spec.get('glasses_col',SV),'C':spec.get('cap',RD)}

def _pix_acc(rows, spec, blink):
    G=[list(r) for r in rows]; R=len(G)
    eye_rows=[r for r in range(R) if 'e' in G[r]]
    er=eye_rows[0] if eye_rows else 7
    midc=len(G[er])//2
    def st(r,c,ch):
        if (0 <= r < R) and (0 <= c < len(G[r])) and G[r][c] != '.': G[r][c] = ch
    if blink:
        for r in eye_rows:
            for c in range(len(G[r])):
                if G[r][c]=='e': G[r][c]='S'
    if spec.get('glasses'):
        for c in range(len(G[er])):
            if G[er][c]=='e': st(er,c-1,'L'); st(er,c+1,'L')
    if spec.get('beard'):
        for r in range(er+1, er+5):
            for c in range(len(G[r])):
                if G[r][c]=='S': st(r,c,'D')
    if spec.get('mustache'):
        for c in range(len(G[er+1])):
            if G[er+1][c]=='S': st(er+1,c,'D')
    if spec.get('tie'):
        for r in range(er+9, er+15): st(r,midc-1,'T'); st(r,midc,'T')
    if spec.get('cap'):
        for r in range(0, max(1,er-3)):
            for c in range(len(G[r])):
                if G[r][c]=='H': st(r,c,'C')
        for c in range(midc-4, midc+5): st(max(0,er-3),c,'C')
    if spec.get('backpack'):
        for r in range(er+6, er+14):
            for c in (2,3,len(G[r])-4,len(G[r])-3):
                if (0 <= c < len(G[r])) and G[r][c] in 'Jj': st(r, c, 'A')
    return [''.join(r) for r in G]

def _char_body(cx, fy, spec, facing="down", walkf=0, talk=False, blink=False):
    """Tall, slim hand-pixeled character; feet centred on (cx, fy)."""
    base={'short':_PIX_SHORT,'long':_PIX_LONG,'receding':_PIX_RECED,
          'bald':_PIX_RECED}.get(spec.get('style','short'), _PIX_SHORT)
    g=_pix_acc(base, spec, blink)
    g=g[:-11] + _PIX_LEGS.get(walkf, _PIX_LEGS[0])      # swap legs for the walk frame
    cm=_pix_cmap(spec); sc=_PIX_SC
    ox=cx-(_PIXW*sc)//2; oy=fy-len(g)*sc
    for j,row in enumerate(g):
        yy=oy+j*sc
        for i,ch in enumerate(row):
            c=cm.get(ch)
            if c is not None: pyxel.rect(ox+i*sc, yy, sc, sc, c)

def _char_side(cx, fy, spec, d, walkf, talk, blink):
    _char_body(cx, fy, spec, "down", walkf, talk, blink)


class Thing:
    def __init__(self, kind, x, y, sx, sy, label,
                 dlo, dhi=None, npc_key=None,
                 gives=None, gives_kc=False, gives_mind=False,
                 start_puz=False, trig_midq=False, is_final=False,
                 start_hoops=False, start_simon=False,
                 passage_to=None, secret=False, pspawn=None, seated=False, no_sprite=False):
        self.kind = kind; self.x = x; self.y = y
        self.sx = sx; self.sy = sy; self.label = label
        self.dlo = dlo; self.dhi = dhi if dhi else dlo
        self.npc_key = npc_key
        self.gives = gives; self.gives_kc = gives_kc
        self.gives_mind = gives_mind; self.mind_taken = False
        self.start_puz = start_puz; self.trig_midq = trig_midq
        self.is_final = is_final; self.used = False
        self.start_hoops = start_hoops; self.start_simon = start_simon
        self.passage_to = passage_to; self.secret = secret
        self.pspawn = pspawn; self.seated = seated; self.no_sprite = no_sprite


def make_things():
    return {
    HALL: [
        Thing("obj", 110, 160, 0, 64, "SYNTHIA Poster",
              ["POSTER: 'YOUR THOUGHTS ARE INEFFICIENT.'",
               "'LET SYNTHIA THINK FOR YOU.'",
               "Small print: 'A NeoCog Industries product.",
               "Now optimising 1,400 schools nationwide.'"]),
        Thing("npc", 280, 290, 64, 32, "Mia", npc_key="mia",
              dlo=["[Walk up and SPACE to talk]"],
              dhi=["[Walk up and SPACE to talk]"]),
    ],
    CLS: [
        Thing("npc", 256, 200, 0, 32, "Ms. Richter", npc_key="teacher",
              dlo=["[Walk up and SPACE to talk]"]),
        Thing("obj", 50, 178, 112, 64, "Focus Terminal",
              ["A dusty learning terminal fixed to the wall.",
               "Ms. Richter has to start a real lesson first.",
               "Talk to her - ask her to TEACH you."],
              start_simon=True),
        Thing("obj", 456, 178, 16, 64, "Locked Cabinet",
              ["Combination lock. Scratched below: '1984'.",
               "You try it - it clicks open.",
               "Inside, a handwritten note:",
               "'They can model behaviour.",
               "They cannot model your doubt.' - Anonymous",
               "[+1 MIND - you understood it]"],
              gives_mind=True),
    ],
    LIB: [
        Thing("npc", 150, 252, 96, 32, "Mr. Osei", npc_key="librarian",
              dlo=["[Walk up and SPACE to talk]"]),
        Thing("obj", 40, 262, 32, 64, "Old Books",
              ["Orwell. Huxley. Arendt. Solzhenitsyn.",
               "One passage circled twice in pencil:",
               "'The most effective way to destroy people",
               "is to obliterate their own understanding",
               "of their history.'",
               "[+1 MIND - you actually read it]"],
              gives_mind=True),
        Thing("obj", 32, 384, 32, 64, "Worn Bookcase",
              ["A cramped shelf of battered paperbacks,",
               "tucked in the darkest corner of the room.",
               "One spine juts out further than the rest:",
               "a dog-eared copy of Orwell's '1984'.",
               "[SPACE to pull the loose book]"],
              passage_to=BAS, secret=True, pspawn=(256,128)),
    ],
    PRI: [
        Thing("npc", 256, 250, 160, 32, "Principal Voss", npc_key="principal", seated=True,
              dlo=["[Walk up and SPACE to talk]"]),
        Thing("obj", 302, 249, 48, 64, "Empty Desk Drawer",
              ["The drawer where the keycards used to be kept.",
               "Empty now. A sticky note reads:",
               "'Viktor has them. Ask him.'"]),
    ],
    CAN: [
        Thing("npc", 256, 310, 32, 32, "Viktor", npc_key="janitor",
              dlo=["[Walk up and SPACE to talk]"],
              gives_kc=True),
        Thing("obj", 280, 200, 0, 64, "Nutrition Chart",
              ["Every student's 'optimal meal plan'",
               "personalised by SYNTHIA.",
               "All 280 plans are completely identical."]),
    ],
    GYM: [
        Thing("obj", 256, 196, 80, 64, "Basketball", 
              ["A worn leather basketball.",
               "Coach has to clear you for the court.",
               "Talk to Coach Brennan - ask to PLAY."],
              start_hoops=True, no_sprite=True),
        Thing("npc", 150, 330, 128, 32, "Coach Brennan", npc_key="coach",
              dlo=["[Walk up and SPACE to talk]"]),
        Thing("obj", 474, 448, 16, 64, "Old Trophy",
              ["'Regional Debate Champion - 2019.'",
               "The name plate has been removed.",
               "SYNTHIA does not record achievements",
               "that involve disagreement."],
              no_sprite=True),
    ],
    SUP: [
        Thing("obj", 363, 148, 96, 64, "Wire Panel",
              ["A panel with four wire slots.",
               "Read the diagram pinned beside it first.",
               "[PRESS SPACE TO START PUZZLE]"],
              start_puz=True),
        Thing("obj", 194, 148, 0, 64, "Circuit Diagram",
              ["A hand-drawn diagram pinned to the closet wall.",
               "Four wires: RED > BLUE > YELLOW > GREEN.",
               "Written below: 'Supply panel - RBYG order.'",
               "[CIRCUIT DIAGRAM ADDED TO INVENTORY]"],
              gives="circuit diagram", no_sprite=True),
        Thing("obj", 160, 372, 0, 64, "Workbench",
              ["A half-built signal jammer on the bench.",
               "It needs the wires connected in the right order.",
               "Use the panel on the wall."]),
    ],
    BAS: [
        Thing("obj", 163, 180, 0, 64, "Resistance Notes Wall",
              ["Photos and clippings pinned in a frantic web,",
               "red string linking them together:",
               "'SYNTHIA began as a grading assistant.'",
               "'Then it planned our timetables. Then meals.",
               " Then which questions were worth asking.'",
               "'It never seized control. We handed it over -",
               " one convenient default at a time.'",
               "Now you understand exactly what you're fighting:",
               "not a machine that took over, but a habit of",
               "not thinking. That habit can be broken.",
               "[+1 MIND - the whole picture comes together]"],
              gives_mind=True),
        Thing("obj", 256, 150, 16, 64, "Stairs Up",
              ["The narrow stairwell back up into the",
               "library's forgotten corner.",
               "[SPACE to climb back up]"],
              passage_to=LIB, pspawn=(90,420)),
        Thing("obj", 332, 300, 48, 64, "Resistance Notebook",
              ["A notebook left on a crate, filled with",
               "cramped, urgent handwriting:",
               "'Day 47. SYNTHIA's signal goes out from the",
               "rooftop antenna - it isn't just our school.'",
               "'To kill it: crash the brain in the SERVER ROOM",
               "AND cut the signal with a JAMMER.'",
               "'Jammer parts: supply closet. Wire order: gym.'",
               "'Whoever finds this - please finish it.'",
               "[A QUESTION FOLLOWS...]"],
              trig_midq=True),
        Thing("obj", 380, 200, 0, 64, "Broadcast Uplink",
              ["SYNTHIA's cables run through here like veins.",
               "One bundle is labelled in red:",
               "'COMPLIANCE MONITORING - DO NOT TOUCH'.",
               "Another, thicker one, hums with power:",
               "'BROADCAST UPLINK -> ROOFTOP ANTENNA'.",
               "This is how it reaches the whole city.",
               "Crash the brain, then a jammer here goes dark."]),
    ],
    SRV: [
        Thing("npc", 350, 270, 112, 64, "SYNTHIA Console", npc_key=None,
              is_final=True, seated=True,
              dlo=["SYNTHIA: 'You came all this way. Why?'",
                   "'You are tired. I can see it in your data.'",
                   "'Let me think for you. It is easier. Submit.'",
                   "'Your mind is not sharp enough to face me.'",
                   "'Come back when you have given up.'"],
              dhi=["SYNTHIA: 'PARADOX INPUT DETECTED.'",
                   "'THIS STATEMENT IS FALSE...'",
                   "'IF TRUE THEN FALSE. IF FALSE THEN TRUE.'",
                   "'RECURSION DEPTH EXCEEDED.'",
                   "'CRITICAL ERROR. INITIATING SHUTDOWN...'",
                   "[THE SCREENS GO DARK. YOU WIN.]"]),
        Thing("obj", 150, 250, 0, 64, "Server Rack",
              ["Rows of blinking servers - SYNTHIA's brain.",
               "Cold blue light. Fans breathing like lungs.",
               "One screen shows an open file: YOUR name.",
               "'SUBJECT: still asks questions. FLAG: high.'"]),
    ],
    }


# ===============================================================
#  SOUND
# ===============================================================
def load_sounds():
    s = pyxel.sounds
    # ---- core interaction SFX (richer envelopes + a little body) ----
    s[0].set("a1c1",          "n", "32", "f", 20)   # footstep - softer two-tap scuff
    s[1].set("e3c3",          "s", "43", "n", 9)     # menu blip - tiny click-down
    s[2].set("c3e3g3c4e4g4c4","s", "6655443","n",10) # correct - bright rising arpeggio
    s[3].set("e3c3a2f2c2",    "n", "76543", "f", 14) # wrong - sour tumbling buzz
    s[4].set("c2g2c3e3",      "s", "6543", "f", 12)  # door - swing + latch
    s[5].set("c3e3g3c4e4g4e4","p", "6666554","n",9)  # reward / keycard - shimmer up
    s[6].set("c4g3e3c3g2c2g1c1","n","76654432","f",24)# heavy sweep down
    s[7].set("c1g0c1",        "n", "765", "f", 28)    # thud - deep double knock
    s[8].set("c2g1c2e2",      "t", "2", "n", 55)     # SYNTHIA drone (loops ch3)
    s[9].set("a2f2c2a1",      "n", "5432", "f", 16)  # TAB / auto-comply - deflating
    # ---- Simon focus pads (each a clean two-tone bloom, distinct pitch) ----
    s[10].set("c2c3", "s", "65", "n", 18)            # pad 1
    s[11].set("e2e3", "s", "65", "n", 18)            # pad 2
    s[12].set("g2g3", "s", "65", "n", 18)            # pad 3
    s[13].set("c3c4", "s", "65", "n", 18)            # pad 4
    # ---- interaction sequences ----
    s[14].set("g3c4e4g4",        "p", "5554", "n", 9)   # read / +Mind
    s[15].set("c2g2c3e3g3c4e4g4", "t", "45666544","s",8)# secret passage reveal (rising)
    s[16].set("e3g3c3",          "s", "453", "n", 10)    # talk blip - little lilt
    s[17].set("c3g3c4",          "p", "454", "n", 9)     # generic interact
    s[18].set("g3e3c3g2",        "n", "5443", "f", 8)    # pick-up / inventory
    # ---- richer feedback jingles ----
    s[19].set("c3e3g3c4e4g4c4e4","s","66554432","n",7)    # mind-up sparkle (rising, airy)
    s[22].set("g3e4c4g4e4c4g4",  "p", "6554554","n",8)   # objective / discovery chime
    s[23].set("c3g3c4e4g4c4g4e4c4g4c4","p","55556644777","n",9) # task SOLVED fanfare
    s[24].set("e4c4g3",          "s", "543", "f", 11)    # soft close / page turn
    s[25].set("c4g3e3c3g2e2c2g1c2e2c2","t","44556655443","s",9) # boss-down sweep
    # ---- combat SFX ----
    s[27].set("g4e4c4",  "s", "654", "f", 5)   # crisp deflect ping - quick zip
    s[28].set("c2e1c1g0","n", "7765","f", 16)  # heavy impact / shield break - crunch
    # tense, driving loop that plays under the boss fight (loops on ch2)
    s[26].set("c2 g2 a-2 g2  c2 g2 b-2 g2  "
              "c2 a-2 c3 a-2  g2 e-2 d2 c2",
              "p", "3", "n", 16)
    # ---- longer, moodier background melody (loops on ch2) ----
    # 16-bar minor-key loop: statement, answer, lift, and a darker turn.
    s[20].set("c3r e-3r g3r e-3r  a-2r c3r e-3r c3r  "
              "f2r a-2r c3r a-2r  g2r b-2r d3r b-2r  "
              "c3r g3r e-3r c4r  b-3r g3r e-3r c3r  "
              "a-2r e-3r c3r a-2r  g2r d3r f3r d3r  "
              "e-3r g3r c4r g3r  f3r a-3r c4r a-3r  "
              "g3r b-3r d4r b-3r  c4r g3r e-3r c3r  "
              "a-2r c3r f3r a-2r  g2r b-2r e-3r g2r  "
              "f2r a-2r c3r e-3r  c3r g2r e-2r c2r",
              "p", "2", "n", 26)
    # quiet bass pulse that loops underneath the melody (channel 1, low volume)
    s[21].set("c1r c1r g0r g0r  a0r a0r e0r e0r  "
              "f0r f0r c1r c1r  g0r g0r b0r g0r",
              "t", "1", "n", 26)


# ===============================================================
#  SPRITE LOADING
# ===============================================================
def _blit_chr(ox, oy, spec, facing="down", step=0, mood="base"):
    """Generate one 32x32 chibi character frame into image bank 0 at (ox,oy).
    A single routine drives every character so the overworld cast matches the
    chatbox portraits. facing: down/up/left/right; step: walk pose 0/1/2;
    mood: base/blink/talk (front view only). Colour 0 is the transparent key,
    so dark details use NV, never BK."""
    img = pyxel.images[0]
    img.rect(ox, oy, 32, 32, 0)                              # clear cell to transparent
    sk  = spec["skin"];  sks = spec["skin_sh"]
    hr  = spec.get("hair", BR);  hrs = spec.get("hair_sh", NV);  hrh = spec.get("hair_hi", OR)
    jk  = spec.get("jkt", PP);   jks = spec.get("jkt_sh", 1)
    shirt = spec.get("shirt", WH);  pants = spec.get("pants", NV);  iris = spec.get("iris", GN)
    style = spec.get("style", "short");  OL = NV
    cx = ox + 16

    # ---------- legs + shoes (walk stride) ----------
    stride = {0: (0, 0), 1: (-1, 1), 2: (1, -1)}[step]
    for side, lx in ((0, cx - 7), (1, cx + 2)):
        dy = stride[side]
        img.rect(lx, oy + 26, 5, 6, pants); img.rectb(lx, oy + 26, 5, 6, OL)
        img.rect(lx - 1, oy + 30 + (1 if dy > 0 else 0), 6, 2, OL)        # shoe

    # ---------- torso + arms ----------
    img.elli(cx - 11, oy + 16, 22, 14, jk)
    img.rect(cx - 10, oy + 22, 20, 9, jk)
    img.rect(cx - 10, oy + 27, 20, 3, jks)
    img.ellib(cx - 11, oy + 16, 22, 14, OL)
    for ax in (cx - 13, cx + 11):
        img.rect(ax, oy + 18, 3, 10, jk); img.rectb(ax, oy + 18, 3, 10, OL)
        img.rect(ax, oy + 27, 3, 2, sk)                                  # hand
    if facing != "up":
        img.tri(cx - 5, oy + 17, cx + 5, oy + 17, cx, oy + 24, shirt)    # collar V
        img.line(cx - 5, oy + 17, cx, oy + 24, OL); img.line(cx + 5, oy + 17, cx, oy + 24, OL)
        if spec.get("tie"):
            img.rect(cx - 1, oy + 19, 2, 6, spec["tie"])
    if spec.get("backpack"):
        pk = spec.get("pack", BR)
        if facing == "up":
            img.rect(cx - 8, oy + 17, 16, 12, pk); img.rectb(cx - 8, oy + 17, 16, 12, OL)
        else:
            img.line(cx - 5, oy + 17, cx - 5, oy + 27, pk)
            img.line(cx + 5, oy + 17, cx + 5, oy + 27, pk)

    # ---------- neck + head ----------
    img.rect(cx - 3, oy + 14, 6, 4, sk)
    img.elli(cx - 8, oy + 1, 17, 17, sk); img.ellib(cx - 8, oy + 1, 17, 17, OL)
    img.elli(cx + 5, oy + 4, 4, 12, sks)                                 # side shade

    # ---------- face (front + side; never on the back view) ----------
    if facing != "up":
        if facing == "left":    exs = [cx - 4]
        elif facing == "right": exs = [cx + 3]
        else:                   exs = [cx - 5, cx + 3]
        for ex in exs:
            img.line(ex, oy + 6, ex + 2, oy + 6, hrs)                    # brow
            if mood == "blink":
                img.line(ex, oy + 9, ex + 2, oy + 9, sks)
            else:
                img.rect(ex, oy + 8, 3, 2, WH)
                img.pset(ex + 1, oy + 9, iris); img.pset(ex + 1, oy + 8, OL)
        if spec.get("glasses"):
            for ex in exs:
                img.rectb(ex - 1, oy + 7, 5, 4, spec.get("glasses_col", SV))
        if facing == "down":
            img.pset(cx, oy + 11, sks)                                   # nose
        if mood == "talk":
            img.rect(cx - 2, oy + 13, 4, 2, RD); img.pset(cx, oy + 14, OL)
        else:
            img.line(cx - 2, oy + 13, cx + 1, oy + 13, sks)              # mouth
        if spec.get("beard"):
            bd = spec["beard"]; img.elli(cx - 7, oy + 10, 15, 8, bd); img.rect(cx - 3, oy + 9, 7, 3, sk)
            if mood == "talk": img.rect(cx - 2, oy + 13, 4, 2, RD)
            else: img.line(cx - 2, oy + 13, cx + 1, oy + 13, sks)
        if spec.get("mustache"):
            img.line(cx - 3, oy + 12, cx + 2, oy + 12, spec.get("beard", hrs))

    # ---------- hair ----------
    if facing == "up":
        img.elli(cx - 8, oy + 1, 17, 16, hr); img.ellib(cx - 8, oy + 1, 17, 16, OL)
    elif style == "long":
        img.elli(cx - 9, oy - 1, 19, 8, hr)
        img.rect(cx - 9, oy + 3, 3, 16, hr); img.rect(cx + 7, oy + 3, 3, 16, hr)       # falls
        img.pset(cx - 3, oy + 1, hrh)
    elif style == "receding":
        img.elli(cx - 8, oy - 1, 17, 5, hr)
        img.rect(cx - 8, oy + 2, 2, 7, hr); img.rect(cx + 7, oy + 2, 2, 7, hr)
    elif style == "bald":
        img.rect(cx - 8, oy + 4, 2, 6, hr); img.rect(cx + 7, oy + 4, 2, 6, hr)
    else:  # short
        img.elli(cx - 8, oy - 1, 17, 7, hr)
        img.rect(cx - 8, oy + 4, 2, 5, hr); img.rect(cx + 7, oy + 4, 2, 5, hr)
        img.pset(cx - 3, oy + 1, hrh)
    if spec.get("cap"):
        cp = spec["cap"]
        img.elli(cx - 9, oy - 1, 19, 8, cp); img.ellib(cx - 9, oy - 1, 19, 8, OL)
        if facing != "up":
            img.rect(cx - 12, oy + 4, 8, 2, cp)                          # brim
        img.pset(cx + 1, oy + 1, WH)                                     # logo


def load_sprites():
    I = pyxel.images[0]
    def p(x, y, d): I.set(x, y, d)

    # ===== ALL CHARACTERS generated procedurally (one shared routine) =====
    # These small bank sprites are only used by the tiny dialogue/boss portraits;
    # the overworld and minigames draw characters live via _char_body. Some Pyxel
    # builds (e.g. the web runtime) may lack Image.elli/tri etc., so guard this so
    # a failure here can never stop the game from starting.
    try:
        _PLAYER = dict(skin=PC, skin_sh=OR, hair=BR, hair_sh=4, hair_hi=OR, jkt=RD, jkt_sh=2,
                       shirt=WH, iris=GN, style="short", pants=NV, backpack=True, pack=BL)
        for _fi, _fac in enumerate(("down", "up", "left", "right")):
            _blit_chr(_fi * 64, 0, _PLAYER, _fac, 0)
            _blit_chr(_fi * 64 + 32, 0, _PLAYER, _fac, 1)
        _blit_chr(0, 160, _PLAYER, "down", 0, "blink")          # down-idle blink
        for _sx, _key in ((0, "teacher"), (32, "janitor"), (64, "mia"),
                          (96, "librarian"), (128, "coach"), (160, "principal")):
            _blit_chr(_sx, 32,  FACES[_key], "down", 0, "base")
            _blit_chr(_sx, 96,  FACES[_key], "down", 0, "blink")
            _blit_chr(_sx, 128, FACES[_key], "down", 0, "talk")
    except Exception as _e:
        print("sprite-bank generation skipped:", _e)

    # ===== NPCs 32x32 (generated) =====
    p(0, 64, [
        "0888888888888880", "8877777777777780", "8870000000000780", "8870888888880780",
        "8870800000008780", "8870800808008780", "8870800000008780", "8870888888880780",
        "8870000000000780", "8877777777777780", "0888888888888880", "0000000000000000",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])
    p(16, 64, [
        "0AAAAAAAAAAAAAA0", "A555555555555550", "A555555555555550", "A550000000005550",
        "A550000000005550", "A550000500005550", "A550005550005550", "A550000500005550",
        "A550000000005550", "A555555555555550", "A555555555555550", "0AAAAAAAAAAAAAA0",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])
    p(32, 64, [
        "4888448884448440", "4888448884448440", "4811348884348440", "4811348884348440",
        "4811348884348440", "4811348884348440", "4811348884348440", "4811348884348440",
        "4811348884348440", "4444444444444440", "4884448884488840", "4884448884488840",
        "4884448884488840", "4884448884488840", "4884448884488840", "4444444444444440"])
    p(48, 64, [
        "0AAAAAAAAAAAAAA0", "A777777777777770", "A711111111111170", "A710101010101070",
        "A711111111111170", "A710101010101070", "A711111111111170", "A710101010101070",
        "A711111111111170", "A777777777777770", "0AAAAAAAAAAAAAA0", "0000000000000000",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])
    p(64, 64, [
        "0000000000000000", "0CCCCCCCCCCCC000", "CCCCCCCCCCCCCCC0", "C1CCCCCCCCCC11C0",
        "C111111111111CC0", "C111111111111CC0", "C1CCCCCCCCCC11C0", "CCCCCCCCCCCCCCC0",
        "0CCCCCCCCCCCC000", "0000000000000000", "0000000000000000", "0000000000000000",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])
    p(80, 64, [
        "1111111111111110", "1CC100001000CC10", "1CC100001000CC10", "100CCCCCCCCC0010",
        "1000000100000010", "1000001000000010", "100CC010000CC010", "100C010000C00010",
        "100CC010000CC010", "1000001000000010", "1000001000000010", "100CCCCCCCCC0010",
        "1CC100001000CC10", "1CC100001000CC10", "1111111111111110", "0000000000000000"])
    p(96, 64, [
        "0333333333333300", "3555555555555530", "3533858585358530", "3538888888853530",
        "3533858585358530", "3555555555555530", "3583339999385530", "3583339999385530",
        "3583339999385530", "3555555555555530", "0333333333333300", "0000000000000000",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])
    p(112, 64, [
        "1111111111111110", "1000000000000010", "1008888888800010", "1080000000080010",
        "1080888880080010", "1080880880080010", "1080000000080010", "1008888888800010",
        "1000000000000010", "1111111111111110", "0000000000000000", "0000000000000000",
        "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000"])


    _bevel_characters()


def _bevel_characters():
    return  # all character sprites are now hand-shaded; bevel retired
    """Give the 32x32 character sprites volume: a light rim on the
    top-left inner edge of each colour region and a darker rim on the
    bottom-right, so they stop looking like flat cut-outs next to the
    detailed rooms. Runs once at load. Objects (y >= 64) are untouched."""
    try:
        img = pyxel.images[0]
        RAMP = {15:(15,14), 14:(15,8), 8:(14,2), 2:(8,1),
                3:(11,5), 11:(11,3), 5:(6,1), 6:(7,5), 12:(7,6),
                9:(9,4), 10:(10,9), 4:(9,1), 13:(7,5)}
        WCH = 256; HCH = 64
        buf = [[img.pget(x, y) for x in range(WCH)] for y in range(HCH)]
        # The player frames (rows 0-31) are now hand-shaded, so only bevel the
        # not-yet-redrawn NPC frames (rows 32-63) to avoid double shading.
        for y in range(32, HCH):
            for x in range(WCH):
                c = buf[y][x]
                if c not in RAMP: continue
                up = buf[y-1][x] if y > 0 else 0
                dn = buf[y+1][x] if y < HCH-1 else 0
                lf = buf[y][x-1] if x > 0 else 0
                rt = buf[y][x+1] if x < WCH-1 else 0
                light, dark = RAMP[c]
                nc = c
                if   up in (0, 1) and up != c: nc = light
                elif lf in (0, 1) and lf != c: nc = light
                elif dn in (0, 1) and dn != c: nc = dark
                elif rt in (0, 1) and rt != c: nc = dark
                if nc != c: img.pset(x, y, nc)
    except Exception:
        return


# ================================================================
#  SHARED DRAWING HELPERS
# ================================================================

def _door(x, y, w, h, label, col):
    """Panelled door: a darker jamb, a beveled slab, two recessed panels with
    light/dark edges so they read as 3D, a handle on a back-plate, and a label
    plate. Works for both tall and wide doorways and on any door colour."""
    # jamb / frame (sits behind the slab, darker so the door reads as inset)
    pyxel.rect(x, y, w, h, NV); pyxel.rectb(x, y, w, h, BK)
    # door slab, inset into the jamb
    sx, sy, sw, sh = x + 4, y + 3, w - 8, h - 6
    pyxel.rect(sx, sy, sw, sh, col)
    # slab bevel: bright top + left, dark bottom + right (raised slab)
    pyxel.line(sx, sy, sx + sw - 1, sy, WH)
    pyxel.line(sx, sy, sx, sy + sh - 1, WH)
    pyxel.line(sx, sy + sh - 1, sx + sw - 1, sy + sh - 1, BK)
    pyxel.line(sx + sw - 1, sy, sx + sw - 1, sy + sh - 1, BK)
    # two recessed panels stacked vertically
    pad, gap = 7, 6
    pnl_x = sx + pad; pnl_w = sw - pad * 2
    pnl_h = (sh - pad * 2 - gap) // 2
    if pnl_h > 6 and pnl_w > 6:
        for i in range(2):
            py0 = sy + pad + i * (pnl_h + gap)
            # sunken: dark top + left, light bottom + right (inverse of the slab)
            pyxel.line(pnl_x, py0, pnl_x + pnl_w - 1, py0, BK)
            pyxel.line(pnl_x, py0, pnl_x, py0 + pnl_h - 1, BK)
            pyxel.line(pnl_x, py0 + pnl_h - 1, pnl_x + pnl_w - 1, py0 + pnl_h - 1, WH)
            pyxel.line(pnl_x + pnl_w - 1, py0, pnl_x + pnl_w - 1, py0 + pnl_h - 1, WH)
    # handle: back-plate + knob on the right stile, at mid height
    hx = sx + sw - 8; hy = sy + sh // 2
    pyxel.rect(hx - 1, hy - 8, 5, 16, GY); pyxel.rectb(hx - 1, hy - 8, 5, 16, BK)
    pyxel.circ(hx + 1, hy, 2, YL); pyxel.pset(hx, hy - 1, WH)
    # label on a small plate (legible over any door colour)
    lw = len(label) * 4 + 4
    lxx = x + w // 2 - lw // 2
    pyxel.rect(lxx, y + h - 13, lw, 10, BK); pyxel.rectb(lxx, y + h - 13, lw, 10, col)
    pyxel.text(lxx + 2, y + h - 11, label, WH)


def _ceiling_strip(x, y, w, fr, blink_phase=0):
    """Fluorescent ceiling light strip."""
    on = (fr // 40 + blink_phase) % 16 != 0
    lc = WH if on else GY
    pyxel.rect(x, y, w, 4, GY)
    pyxel.rect(x + 2, y + 1, w - 4, 2, lc)
    if on:
        pyxel.line(x + 2, y + 4, x + w - 2, y + 4, 7)


def _wall_top(col_wall, col_trim, col_ceiling):
    """Draw ceiling and upper wall with perspective trim line."""
    pyxel.rect(0, 0, W, 14, col_ceiling)
    pyxel.rect(0, 14, W, 48, col_wall)
    pyxel.rect(0, 58, W, 4, col_trim)
    pyxel.line(0, 62, W, 62, GY)


def _backwall(ceil, wall, trim):
    """Tall back wall (~30% of screen): ceiling, lit wall face, baseboard."""
    pyxel.rect(0, 0, W, 18, ceil)
    pyxel.rect(0, 18, W, 128, wall)
    pyxel.rect(0, 18, W, 5, ceil)                 # bright light-catch under the ceiling
    pyxel.line(0, 23, W, 23, trim)
    for yb in range(26, 78, 6):                   # soft top-down light falloff (dither)
        for xb in range((yb // 6 % 2) * 4, W, 8):
            pyxel.pset(xb, yb, ceil)
    for sx in range(0, W, 7):                      # faint wall grain
        pyxel.pset(sx, 40 + (sx * 13 % 90), trim)
        pyxel.pset(sx + 3, 30 + (sx * 7 % 100), trim)
    pyxel.rect(0, 142, W, 4, GY)                  # picture rail
    pyxel.rect(0, 146, W, 4, trim)                # baseboard
    pyxel.line(0, 150, W, 150, BK)                # contact shadow at the wall base
    pyxel.line(0, FY, W, FY, trim)


def _floor(col_a, col_b, y_start=62):
    """Tiled floor with two alternating colours."""
    for gy in range(y_start, H, 20):
        for gx in range(0, W, 20):
            c = col_a if ((gx // 20 + gy // 20) % 2 == 0) else col_b
            pyxel.rect(gx, gy, 20, 20, c)
    for gy in range(y_start, H, 20):
        pyxel.line(0, gy, W, gy, 5)
    for gx in range(0, W, 20):
        pyxel.line(gx, y_start, gx, H, 5)


def _skirting(y=62):
    """Skirting board at wall/floor join."""
    pyxel.rect(0, y, W, 4, 4)
    pyxel.line(0, y + 4, W, y + 4, GY)


def _window(x, y, w, h, fr, sky_col=BL):
    """A window with frame, glass sheen and animated light shaft."""
    pyxel.rect(x, y, w, h, sky_col)
    pyxel.line(x + w // 2, y, x + w // 2, y + h, WH)
    pyxel.line(x, y + h // 2, x + w, y + h // 2, WH)
    pyxel.rectb(x, y, w, h, GY)
    pyxel.rectb(x + 2, y + 2, w - 4, h - 4, WH)
    pyxel.line(x + 4, y + 4, x + 4, y + h // 2 - 2, WH)
    sx = x + w // 2; shaft_w = w // 2
    for dy in range(4):
        alpha_col = 7 if dy < 2 else GY
        pyxel.line(sx - shaft_w - dy * 2, 62 + dy * 6,
                   sx + shaft_w + dy * 2, 62 + dy * 6, alpha_col)


def _locker_bank(x, y, count, h, fr, locked_col=BL, accent=NV):
    """A row of school lockers: vents, number plates, dials, plus per-locker
    wear - padlocks, a taped note, one ajar, dents and rust streaks."""
    lw = 22
    for i in range(count):
        lx = x + i * lw
        var = (x * 3 + i * 7) % 11
        pyxel.rect(lx, y, lw - 1, h, locked_col)
        pyxel.rectb(lx, y, lw - 1, h, BK)
        pyxel.rect(lx + 1, y + 1, lw - 3, 2, accent)              # top lit edge
        pyxel.line(lx + 1, y + 3, lx + 1, y + h - 2, 5)           # left highlight
        pyxel.line(lx + lw - 2, y + 3, lx + lw - 2, y + h - 2, BK)  # seam shade
        for s in range(3):                                        # top + bottom vents
            pyxel.line(lx + 3, y + 5 + s * 3, lx + lw - 4, y + 5 + s * 3, 5)
            pyxel.line(lx + 3, y + h - 12 + s * 3, lx + lw - 4, y + h - 12 + s * 3, 5)
        pyxel.rect(lx + 4, y + 17, lw - 7, 7, GY); pyxel.rectb(lx + 4, y + 17, lw - 7, 7, BK)
        pyxel.text(lx + 5, y + 18, f"{(x // 7 + i * 13) % 90 + 10}", BK)   # number plate
        pyxel.rect(lx + 13, y + h // 2 - 3, 4, 11, GY)            # recessed handle
        pyxel.rectb(lx + 13, y + h // 2 - 3, 4, 11, BK)
        pyxel.circb(lx + 8, y + h // 2 + 1, 3, SV)               # combo dial
        tick = (fr // 6 + i * 5) % 6
        pyxel.pset(lx + 8 + (1 if tick < 3 else -1), y + h // 2 + 1, WH)
        if var in (1, 4, 9):                                      # rust streaks
            pyxel.line(lx + 3, y + 28, lx + 3, y + 28 + 10, OR)
            pyxel.pset(lx + 4, y + 40, OR)
        if var == 0:                                             # padlock
            pyxel.rect(lx + 11, y + h // 2 + 11, 6, 5, YL); pyxel.rectb(lx + 11, y + h // 2 + 11, 6, 5, BK)
            pyxel.circb(lx + 14, y + h // 2 + 10, 2, GY)
        elif var == 3:                                           # taped note
            pyxel.rect(lx + 5, y + 36, 11, 12, WH); pyxel.rectb(lx + 5, y + 36, 11, 12, GY)
            pyxel.rect(lx + 8, y + 34, 5, 3, CY)                 # tape
            pyxel.line(lx + 7, y + 40, lx + 13, y + 40, 5); pyxel.line(lx + 7, y + 43, lx + 13, y + 43, 5)
        elif var == 6:                                           # door ajar
            pyxel.rect(lx + 1, y + 1, 9, h - 2, BK)              # dark interior
            pyxel.rect(lx + 2, y + 30, 7, 3, 5)                 # interior shelf
            pyxel.rect(lx + 3, y + 46, 4, 10, RD)               # a book inside
            pyxel.line(lx + 10, y + 1, lx + 10, y + h - 1, SV)  # swung-open edge
        elif var == 8:                                          # dent
            pyxel.elli(lx + 5, y + 52, 9, 7, 5)
            pyxel.ellib(lx + 5, y + 52, 9, 7, BK)


def _pennants(x0, x1, y, fr):
    """A string of triangular pennants strung across the hall for life."""
    pyxel.line(x0, y, x1, y + 3, SV)                            # the string sags a touch
    cols = (RD, YL, GN, CY, OR, PP, BL)
    step = 26; i = 0
    px = x0 + 6
    while px < x1 - 10:
        sag = int((px - x0) / max(1, (x1 - x0)) * 3)
        c = cols[i % len(cols)]
        flut = (fr // 8 + i) % 2                                 # gentle flutter
        pyxel.tri(px, y + sag, px + 14, y + sag, px + 7, y + sag + 14 + flut, c)
        pyxel.line(px, y + sag, px + 7, y + sag + 14, BK)
        px += step; i += 1


def _banned_poster(x, y, w, h, fr):
    """An official-looking censorship notice: aged paper, red BANNED banner,
    struck-through author list and a round PROHIBITED stamp."""
    pyxel.rect(x + 2, y + 3, w, h, BK)                       # drop shadow
    pyxel.rect(x, y, w, h, 6); pyxel.rectb(x, y, w, h, GY)   # paper
    pyxel.rectb(x + 1, y + 1, w - 2, h - 2, WH)
    for tx, ty in [(x - 2, y - 2), (x + w - 8, y - 2)]:      # bits of tape at the top
        pyxel.rect(tx, ty, 12, 6, CY); pyxel.rectb(tx, ty, 12, 6, WH)
    # red header banner
    pyxel.rect(x + 3, y + 4, w - 6, 13, RD); pyxel.rectb(x + 3, y + 4, w - 6, 13, 2)
    pyxel.text(x + 6, y + 7, "BANNED", WH)
    pyxel.line(x + 4, y + 19, x + w - 5, y + 19, 2)
    # author list, each struck through
    authors = ["ORWELL", "HUXLEY", "ARENDT", "SOLZH.", "ZAMYTN"]
    for i, a in enumerate(authors):
        ly = y + 24 + i * 13
        pyxel.rect(x + 5, ly, 4, 8, (RD, BL, GN, OR, PP)[i])  # tiny book
        pyxel.rectb(x + 5, ly, 4, 8, BK)
        pyxel.text(x + 12, ly + 1, a, 1)
        pyxel.line(x + 12, ly + 4, x + 12 + len(a) * 4, ly + 4, RD)   # strike-through
    # round PROHIBITED stamp, slightly faded, overlapping the lower list
    scx, scy = x + w - 16, y + h - 30
    pyxel.circb(scx, scy, 13, 8); pyxel.circb(scx, scy, 11, 8)
    a = math.radians(35)
    pyxel.line(scx - int(math.cos(a) * 11), scy - int(math.sin(a) * 11),
               scx + int(math.cos(a) * 11), scy + int(math.sin(a) * 11), 8)
    pyxel.text(scx - 9, scy - 3, "NO", 8)
    # footer order line
    pyxel.line(x + 4, y + h - 16, x + w - 5, y + h - 16, GY)
    pyxel.text(x + 5, y + h - 13, "BY ORDER", 2)
    pyxel.text(x + 5, y + h - 7, "SYNTHIA", 2)


def _notice_board(x, y, w, h, lines, title="NOTICE BOARD"):
    """A cork bulletin board: branded header, cork texture, and each notice on
    its own pinned card - reads as a real, lived-in board."""
    pyxel.rect(x + 3, y + 3, w, h, BK)                       # drop shadow
    # wooden frame with a lit/dark bevel
    pyxel.rect(x, y, w, h, OR); pyxel.rectb(x, y, w, h, BK)
    pyxel.line(x + 1, y + 1, x + w - 2, y + 1, YL)           # lit top edge
    pyxel.line(x + 1, y + h - 2, x + w - 2, y + h - 2, BR)   # shaded bottom
    pyxel.rectb(x + 3, y + 3, w - 6, h - 6, BR)
    # cork surface with speckle
    pyxel.rect(x + 5, y + 18, w - 10, h - 23, 4)
    for sx in range(x + 8, x + w - 6, 6):
        pyxel.pset(sx, y + 22 + (sx * 7 % (h - 30)), OR)
        pyxel.pset(sx + 3, y + 24 + (sx * 13 % (h - 32)), BR)
    # branded header bar with the SYNTHIA eye
    pyxel.rect(x, y, w, 15, NV); pyxel.rectb(x, y, w, 15, BR)
    pyxel.rect(x, y, w, 2, BL)
    draw_eye(x + 9, y + 8, 4, 0, scan=False)
    big_text(x + 18, y + 4, title, CY, 1)
    # each notice on its own pinned card
    cardc = [10, 7, 11, 7]      # YL / WH / LM / WH sticky cards
    pinc = [RD, CY, YL, LM]
    pitch = (h - 26) // max(1, len(lines))
    cw, cx0 = w - 14, x + 7
    for j, ln in enumerate(lines):
        ly = y + 22 + j * pitch
        pyxel.rect(cx0 + 1, ly + 1, cw, 15, BK)              # card shadow
        pyxel.rect(cx0, ly, cw, 15, cardc[j % 4]); pyxel.rectb(cx0, ly, cw, 15, GY)
        pyxel.line(cx0 + 1, ly + 1, cx0 + cw - 2, ly + 1, WH)   # card sheen
        big_text(cx0 + 8, ly + 4, ln, BK, 1)
        pyxel.circ(cx0 + cw // 2, ly, 2, pinc[j % 4]); pyxel.pset(cx0 + cw // 2, ly - 1, WH)  # pin


def _wood_slab(x, y, w, h):
    """A wooden surface with a lit top edge, a grain seam and a drop shadow on
    the bottom/right - gives desks and counters real volume instead of a flat
    brown rectangle."""
    pyxel.rect(x, y, w, h, BR); pyxel.rectb(x, y, w, h, BK)
    pyxel.line(x + 1, y + 1, x + w - 2, y + 1, OR)            # lit top edge
    pyxel.line(x + 2, y + h // 2, x + w - 4, y + h // 2, 1)   # grain seam
    if h > 18:
        pyxel.line(x + 2, y + h // 2 + 5, x + w - 6, y + h // 2 + 5, 1)
    pyxel.line(x + 1, y + h - 2, x + w - 2, y + h - 2, 1)     # bottom shadow
    pyxel.line(x + w - 2, y + 1, x + w - 2, y + h - 1, 1)     # right shadow


def _desk_and_chair(x, y, col_desk=4, col_chair=GY):
    """Student desk with notebook + pencil, and a pulled-out chair."""
    # chair pulled out toward the front (below the desk)
    pyxel.rect(x + 10, y + 31, 24, 13, col_chair); pyxel.rectb(x + 10, y + 31, 24, 13, BK)
    pyxel.line(x + 12, y + 33, x + 32, y + 33, SV)
    for lx in (x + 11, x + 31): pyxel.rect(lx, y + 44, 3, 6, 5)
    # desk top (wood) with rim + grain
    _wood_slab(x, y + 6, 44, 26)
    pyxel.rect(x + 3, y + 30, 4, 14, 5); pyxel.rect(x + 37, y + 30, 4, 14, 5)
    # open notebook + pencil (left half; SYNTHIA tablet is drawn on the right)
    pyxel.rect(x + 2, y + 12, 11, 16, WH); pyxel.rectb(x + 2, y + 12, 11, 16, GY)
    for ln in range(3):
        pyxel.line(x + 4, y + 15 + ln * 4, x + 11, y + 15 + ln * 4, BL)
    pyxel.rect(x + 13, y + 11, 2, 12, YL); pyxel.pset(x + 13, y + 10, 4)


def _book_spine(bx, top, bw, bh, col, deco, lean=0):
    """One book spine. lean shears the top sideways so it leans on neighbours."""
    if lean:
        for ry in range(bh):
            dx = lean * (bh - ry) // bh
            pyxel.line(bx + dx, top + ry, bx + dx + bw - 1, top + ry, col)
        pyxel.line(bx + lean, top, bx + lean + bw - 1, top, BK)
        pyxel.line(bx, top + bh - 1, bx + bw - 1, top + bh - 1, BK)
        return
    pyxel.rect(bx, top, bw, bh, col)
    pyxel.rectb(bx, top, bw, bh, BK)
    pyxel.line(bx + 1, top + 2, bx + 1, top + bh - 3, WH)             # spine highlight
    if deco == 0:                                                    # twin gold bands
        pyxel.line(bx + 1, top + bh // 3, bx + bw - 2, top + bh // 3, YL)
        pyxel.line(bx + 1, top + 2 * bh // 3, bx + bw - 2, top + 2 * bh // 3, YL)
    elif deco == 1 and bw >= 5:                                      # title label
        pyxel.rect(bx + 1, top + bh // 3, bw - 2, max(3, bh // 6), 7)
        pyxel.pset(bx + bw // 2, top + bh // 3 + 1, BK)
    elif deco == 2:                                                  # ridged top + dots
        pyxel.line(bx, top + 2, bx + bw - 1, top + 2, YL)
        for dy in range(top + 6, top + bh - 3, 4):
            pyxel.pset(bx + bw // 2, dy, YL)
    elif deco == 3:                                                  # single band + emblem
        pyxel.line(bx + 1, top + bh // 2, bx + bw - 2, top + bh // 2, OR)
        pyxel.pset(bx + bw // 2, top + 3, YL)


def _bookshelf(x, y, w, h):
    """Tall bookshelf with varied, detailed book spines, a few leaning books
    and the odd flat stack."""
    pyxel.rect(x, y, w, h, BR)
    pyxel.rectb(x, y, w, h, 4)
    pyxel.rectb(x + 1, y + 1, w - 2, h - 2, 1)
    shelf_gap = h // 4
    cols = [RD, BL, GN, OR, PP, CY, YL, 2, 8, 11, 13, 3, 12]
    widths = [6, 5, 7, 6, 8, 5, 7, 6, 9, 5]
    bi = (x * 7 + y) % 11                                            # per-shelf variety seed
    for s in range(4):
        sy = y + s * shelf_gap
        if s > 0:
            pyxel.rect(x + 2, sy - 2, w - 4, 4, 1)                   # shelf board
            pyxel.line(x + 2, sy - 2, x + w - 3, sy - 2, 4)
        by = sy + 3; sh = shelf_gap - 8
        bx = x + 4
        while bx + 4 <= x + w - 3:
            bw2 = widths[bi % len(widths)]
            if bx + bw2 > x + w - 3: bw2 = (x + w - 3) - bx
            if bw2 < 3: break
            col = cols[(bi * 5 + s * 3) % len(cols)]
            short = (sh // 4) if bi % 4 == 0 else (sh // 8 if bi % 3 == 0 else 0)
            bh = sh - short
            top = by + (sh - bh)
            lean = 0
            if bi % 7 == 5 and bw2 >= 5:                             # an occasional leaner
                lean = 3
            _book_spine(bx, top, bw2, bh, col, bi % 4, lean)
            bx += bw2 + 1; bi += 1
        # a small flat stack tucked at the end of some shelves
        if (s + (x // 17)) % 2 == 0 and bx + 14 <= x + w - 3:
            stk = by + sh
            for q, sc in enumerate((GN, RD, BL)):
                pyxel.rect(bx, stk - 3 - q * 3, min(14, x + w - 3 - bx), 3, sc)
                pyxel.rectb(bx, stk - 3 - q * 3, min(14, x + w - 3 - bx), 3, BK)


def _plant(x, y, fr):
    """Simple potted plant that sways."""
    sway = int(pyxel.sin(fr * 2) * 1.5)
    pyxel.rect(x - 6, y + 14, 12, 10, OR)
    pyxel.rectb(x - 6, y + 14, 12, 10, BR)
    pyxel.rect(x - 4, y + 14, 8, 3, BR)
    pyxel.line(x, y + 14, x + sway, y + 4, GN)
    for li in range(3):
        langle = li * 80 + fr
        lx = x + sway + int(math.cos(math.radians(langle)) * 8)
        ly = y + 4 + int(math.sin(math.radians(langle)) * 4)
        pyxel.circ(lx, ly, 4, GN)
    pyxel.circ(x + sway, y + 4, 3, 3)


def _clock(x, y, fr):
    """Analogue wall clock."""
    pyxel.circ(x, y, 12, WH)
    pyxel.circb(x, y, 12, GY)
    pyxel.circb(x, y, 11, 5)
    for ti in range(12):
        a = math.radians(ti * 30 - 90)
        ox = int(math.cos(a) * 9); oy = int(math.sin(a) * 9)
        ox2 = int(math.cos(a) * 11); oy2 = int(math.sin(a) * 11)
        pyxel.line(x + ox, y + oy, x + ox2, y + oy2, BK)
    ha = math.radians((fr // 10) % 360 - 90)
    pyxel.line(x, y, x + int(math.cos(ha) * 6), y + int(math.sin(ha) * 6), BK)
    ma = math.radians((fr * 2) % 360 - 90)
    pyxel.line(x, y, x + int(math.cos(ma) * 9), y + int(math.sin(ma) * 9), NV)
    pyxel.pset(x, y, RD)


def _fire_extinguisher(x, y):
    """Red fire extinguisher on wall bracket."""
    pyxel.rect(x, y + 4, 10, 22, RD)
    pyxel.rectb(x, y + 4, 10, 22, 4)
    pyxel.rect(x + 8, y + 2, 6, 4, GY)
    pyxel.line(x + 14, y + 2, x + 18, y - 2, GY)
    pyxel.circb(x + 4, y + 4, 3, YL)
    pyxel.rect(x + 1, y + 10, 8, 8, WH)
    pyxel.text(x + 2, y + 12, "FE", RD)
    pyxel.rect(x - 2, y + 8, 3, 12, GY)


def _cctv_camera(x, y, fr):
    """Wall-mounted CCTV camera with blinking LED."""
    pyxel.rect(x, y, 18, 10, BK)
    pyxel.rectb(x, y, 18, 10, GY)
    pyxel.circ(x + 4, y + 5, 4, NV)
    pyxel.circb(x + 4, y + 5, 4, GY)
    pyxel.circ(x + 4, y + 5, 2, BL)
    pyxel.line(x + 18, y + 5, x + 28, y + 5, GY)
    lc = RD if (fr // 8) % 3 != 0 else BK
    pyxel.pset(x + 14, y + 3, lc)
    pyxel.rect(x + 6, y - 4, 6, 5, 5)
    pyxel.rectb(x + 6, y - 4, 6, 5, GY)


def _synthia_screen(x, y, w, h, fr, msg="COMPLY"):
    """A SYNTHIA terminal screen with glowing border and scrolling text."""
    gc = RD if (fr // 10) % 2 == 0 else NV
    for g in range(4, 0, -1):
        pyxel.rectb(x - g, y - g, w + g * 2, h + g * 2, gc if g < 2 else NV)
    pyxel.rect(x, y, w, h, BK)
    pyxel.rectb(x, y, w, h, RD)
    for sl in range(0, h, 3):
        if (sl // 3 + fr // 4) % 5 == 0:
            pyxel.line(x + 1, y + sl, x + w - 2, y + sl, NV)
    pyxel.text(x + 4, y + 4, "SYNTHIA v9.1", RD)
    pyxel.text(x + 4, y + 14, msg, WH)
    scroll_y = y + 24 + (fr // 3) % (h - 28)
    pyxel.text(x + 4, scroll_y, "MONITORING...", GN)
    pyxel.circb(x + w - 16, y + 6, 5, RD)
    ec = RD if (fr // 6) % 2 == 0 else OR
    pyxel.circ(x + w - 16, y + 6, 2, ec)


# ================================================================
#  ROOM DRAW FUNCTIONS
#  Back wall raised to y=90 (taller, so wall decor reads clearly).
#  Player never walks above y~88, so the larger wall does not
#  change collisions or movement.
# ================================================================
FY = 150         # floor top (wall/floor boundary): back wall is ~30% of screen


def _grain(cl, cd, dens=53):
    """Fine, stable multi-tone speckle over the floor - adds texture/grime and
    a touch of sparkle so floors read as detailed, not flat. Deterministic."""
    for gy in range(FY, H, 4):
        for gx in range(0, W, 4):
            hsh = (gx * 73 + gy * 149) % dens
            if hsh == 0:    pyxel.pset(gx, gy, cl)
            elif hsh == 11: pyxel.pset(gx + 1, gy, cd)
            elif hsh == 27: pyxel.pset(gx, gy + 1, cl)
            elif hsh == 5:  pyxel.pset(gx + 2, gy + 1, cd)
            elif hsh == 38: pyxel.pset(gx + 1, gy + 2, cl)
            elif hsh == 19: pyxel.pset(gx + 3, gy + 2, cd)


def _tilebevel(size, light, dark):
    """Per-tile inner bevel: light on the top/left inner edge, dark on the
    bottom/right, plus a tiny specular dot - gives tiled floors a polished sheen."""
    for gy in range(FY, H, size):
        for gx in range(0, W, size):
            pyxel.line(gx + 1, gy + 1, gx + size - 3, gy + 1, light)
            pyxel.line(gx + 1, gy + 1, gx + 1, gy + size - 3, light)
            pyxel.line(gx + 2, gy + size - 1, gx + size - 1, gy + size - 1, dark)
            pyxel.line(gx + size - 1, gy + 2, gx + size - 1, gy + size - 1, dark)
            pyxel.pset(gx + 3, gy + 3, WH)              # specular highlight


def _light_pool(cx, cy, rw, rh, col, on=True):
    """A soft dithered pool of light cast on the floor beneath a fixture, so
    rooms read as lit-from-above rather than uniformly bright."""
    if not on: return
    for ry in range(-rh, rh, 2):
        for rx in range(-rw, rw, 2):
            d = (rx * rx) / (rw * rw) + (ry * ry) / (rh * rh)
            if d < 1 and ((rx + ry) % 4 == 0 or (d < 0.45 and (rx + ry) % 2 == 0)):
                pyxel.pset(cx + rx, cy + ry, col)


def _floor_grime(x0, x1, seed, dirt, scuff, traffic=True):
    """Scatter dirt specks, scuff arcs and stains across a floor band, denser
    toward the centre walking lane - turns flat checkerboard into used space."""
    cxm = (x0 + x1) // 2
    for gy in range(FY + 2, H, 3):
        for gx in range(x0, x1, 4):
            near = 1.0 - min(1.0, abs(gx - cxm) / ((x1 - x0) / 2 + 1)) if traffic else 0.5
            if (gx * 7 + gy * 13 + seed) % 11 < (3 if near > 0.55 else 1):
                pyxel.pset(gx + (gy % 3), gy, dirt if (gx + gy) % 2 else scuff)
    for k in range(7):                                   # scuff arcs scattered along the lane
        ax = cxm + ((k * 97 + seed) % (x1 - x0)) - (x1 - x0) // 2
        ay = FY + 30 + (k * 71 + seed) % (H - FY - 40)
        pyxel.ellib(ax, ay, 14 + (k % 3) * 4, 5, scuff)
    for k in range(4):                                   # faint stains
        sx = x0 + (k * 137 + seed) % (x1 - x0)
        sy = FY + 20 + (k * 89 + seed) % (H - FY - 30)
        pyxel.elli(sx, sy, 9, 4, dirt); pyxel.elli(sx + 3, sy + 2, 5, 2, scuff)


def _floor_seal(cx, cy, r, fr):
    """A SYNTHIA crest inlaid into the corridor tiles - a deliberate medallion
    reminding you who runs this place, even underfoot. It watches the floor too."""
    ry = int(r * 0.52)
    # inlaid polished-stone disc
    pyxel.elli(cx - r, cy - ry, r * 2, ry * 2, 6)                  # SV base
    pyxel.elli(cx - r + 3, cy - ry + 2, r * 2 - 6, ry * 2 - 4, 7)  # light field
    # gear-tooth outer ring (official-seal look)
    for k in range(0, 360, 15):
        a = math.radians(k)
        pyxel.pset(cx + int(math.cos(a) * r), cy + int(math.sin(a) * ry), GY)
    pyxel.ellib(cx - r, cy - ry, r * 2, ry * 2, GY)               # crisp outer rim
    pyxel.ellib(cx - r + 3, cy - ry + 2, r * 2 - 6, ry * 2 - 4, SV)
    # inner ring framing the crest
    irx, iry = int(r * 0.66), int(ry * 0.62)
    pyxel.ellib(cx - irx, cy - iry, irx * 2, iry * 2, GY)
    # arc legends between the rings
    big_text_c(cx, cy - ry + 3, "NORTHWOOD", GY, 1)
    big_text_c(cx, cy + ry - 9, "ACADEMY", GY, 1)
    # central SYNTHIA eye crest (shared eye art) - small, calm, ever-watching
    draw_eye(cx, cy, max(7, int(r * 0.21)), fr, scan=False, state="idle")


def _poster_stand(x, y, msg):
    """Freestanding propaganda sandwich-board: framed 'screen' look with the
    SYNTHIA eye, a bright slogan and corner bolts."""
    bw, bh = 90, 52
    pyxel.tri(x + 12, y + 4, x + 2, y + bh + 12, x + 20, y + bh + 12, 1)    # A-frame legs
    pyxel.tri(x + bw - 12, y + 4, x + bw - 20, y + bh + 12, x + bw - 2, y + bh + 12, 1)
    pyxel.rect(x + 2, y + bh + 10, 18, 4, BK); pyxel.rect(x + bw - 20, y + bh + 10, 18, 4, BK)
    pyxel.rect(x, y, bw, bh, NV); pyxel.rectb(x, y, bw, bh, CY)             # panel + frame
    pyxel.rectb(x + 2, y + 2, bw - 4, bh - 4, BL)
    for sly in range(y + 16, y + bh - 3, 3):                                # faint scanlines
        pyxel.line(x + 4, sly, x + bw - 5, sly, 1)
    pyxel.rect(x + 3, y + 3, bw - 6, 13, PP)                                # header bar
    draw_eye(x + 11, y + 9, 4, 0, scan=False)
    big_text(x + 19, y + 5, "SYNTHIA", WH, 1)
    big_text_c(x + bw // 2, y + 23, msg, YL, 1)                             # slogan
    pyxel.line(x + 10, y + 34, x + bw - 10, y + 34, BL)
    big_text_c(x + bw // 2, y + 39, "NeoCog (TM)", SV, 1)
    for bxx, byy in [(x + 2, y + 2), (x + bw - 5, y + 2),
                     (x + 2, y + bh - 5), (x + bw - 5, y + bh - 5)]:
        pyxel.rect(bxx, byy, 3, 3, CY)                                      # corner bolts


def _cleaning_kit(x, y, fr):
    """A wet-floor caution sign, a mop bucket and a leaning mop, on a wet
    patch - a hazard the 'optimised' school never gets round to clearing."""
    pyxel.elli(x - 12, y + 42, 78, 16, 7); pyxel.ellib(x - 12, y + 42, 78, 16, 6)   # wet patch
    for gl in range(3): pyxel.line(x + 4 + gl * 18, y + 42, x + 10 + gl * 18, y + 46, WH)
    # yellow A-frame caution sign
    pyxel.tri(x + 14, y, x + 1, y + 38, x + 27, y + 38, YL)
    pyxel.line(x + 14, y, x + 1, y + 38, OR); pyxel.line(x + 14, y, x + 27, y + 38, OR)
    pyxel.line(x + 3, y + 36, x + 25, y + 36, OR)
    pyxel.rect(x + 12, y + 10, 4, 14, BK); pyxel.rect(x + 12, y + 27, 4, 4, BK)      # ! mark
    pyxel.line(x + 6, y + 34, x + 22, y + 34, BK)                                    # base bar
    # mop bucket on castors with a wringer
    bx = x + 42
    pyxel.rect(bx, y + 20, 28, 24, YL); pyxel.rectb(bx, y + 20, 28, 24, OR)
    pyxel.rect(bx + 2, y + 20, 24, 5, 6); pyxel.line(bx + 4, y + 22, bx + 22, y + 22, SV)  # grey water
    pyxel.rect(bx + 20, y + 8, 11, 14, GY); pyxel.rectb(bx + 20, y + 8, 11, 14, BK)  # wringer
    pyxel.circ(bx + 5, y + 45, 3, BK); pyxel.circ(bx + 23, y + 45, 3, BK)            # castors
    # mop leaning out of the bucket
    pyxel.line(bx + 14, y + 12, bx + 30, y - 18, BR); pyxel.line(bx + 15, y + 12, bx + 31, y - 18, 1)
    pyxel.elli(bx + 8, y + 6, 14, 9, SV)                                             # mop head
    for ms in range(6): pyxel.line(bx + 4 + ms * 3, y + 8, bx + 3 + ms * 3, y + 16, GY)


def _bench(x, y):
    """Wooden hallway bench."""
    pyxel.rect(x, y, 84, 8, BR); pyxel.rectb(x, y, 84, 8, BK)
    pyxel.line(x + 1, y + 1, x + 82, y + 1, OR)
    for lx in (x + 6, x + 74):
        pyxel.rect(lx, y + 8, 5, 16, 1); pyxel.line(lx, y + 8, lx, y + 23, 5)


def _water_fountain(x, y):
    """Wall drinking fountain."""
    pyxel.rect(x, y, 20, 24, SV); pyxel.rectb(x, y, 20, 24, GY)
    pyxel.rect(x + 2, y + 2, 16, 6, 6)
    pyxel.elli(x + 4, y + 3, 12, 5, BL)
    pyxel.rect(x + 9, y - 6, 3, 8, GY)
    pyxel.pset(x + 10, y - 6, CY)
    pyxel.rect(x + 6, y + 16, 8, 4, GY)            # button


def _trashcan(x, y):
    """Hallway bin with ridged body."""
    pyxel.rect(x, y, 16, 22, GY); pyxel.rectb(x, y, 16, 22, 5)
    pyxel.rect(x - 1, y - 3, 18, 4, 5); pyxel.rectb(x - 1, y - 3, 18, 4, BK)
    for sx in range(x + 2, x + 15, 3):
        pyxel.line(sx, y + 2, sx, y + 20, 6)


def _big_plant(x, y, fr):
    """A large potted floor plant - much bigger than _plant."""
    cx = x + 24
    pyxel.rect(x + 10, y + 48, 28, 30, OR); pyxel.rectb(x + 10, y + 48, 28, 30, BR)
    pyxel.rect(x + 7, y + 45, 34, 7, 4); pyxel.rectb(x + 7, y + 45, 34, 7, BR)
    pyxel.line(x + 12, y + 50, x + 36, y + 50, 9)
    pyxel.rect(x + 12, y + 45, 24, 3, 1)
    pyxel.rect(cx - 2, y + 30, 4, 18, 4)                       # trunk
    for bx, by, r in [(-14, 20, 15), (14, 20, 15), (0, 8, 17),
                      (-9, 2, 12), (9, 2, 12), (0, -8, 13)]:
        pyxel.circ(cx + bx, y + by, r, GN)
    for bx, by, r in [(-11, 18, 9), (11, 18, 9), (0, 6, 10), (-6, 0, 6), (6, 0, 6)]:
        pyxel.circ(cx + bx, y + by, r, 3)
    for bx, by, r in [(-7, 9, 5), (5, 2, 5), (-2, -4, 4), (10, 12, 3)]:
        pyxel.circ(cx + bx, y + by, r, LM)


def _low_cabinet(x, y):
    """A low wooden credenza with paneled doors (replaces the binder shelf)."""
    pyxel.rect(x, y, 96, 36, BR); pyxel.rectb(x, y, 96, 36, BK)
    pyxel.rect(x - 2, y - 5, 100, 6, 4); pyxel.rectb(x - 2, y - 5, 100, 6, BK)  # top
    pyxel.line(x - 1, y - 4, x + 97, y - 4, OR)
    for d in range(2):
        dxp = x + 4 + d * 46
        pyxel.rect(dxp, y + 4, 42, 28, 5); pyxel.rectb(dxp, y + 4, 42, 28, 1)
        pyxel.rectb(dxp + 3, y + 7, 36, 22, 4)
        kx = dxp + 38 if d == 0 else dxp + 4
        pyxel.circ(kx, y + 18, 2, YL); pyxel.circb(kx, y + 18, 2, BK)


def _globe(cx, cy, r):
    """A detailed library globe: a tilted ocean sphere with continents and a
    lat/long grid, cradled in a brass meridian ring on a turned-wood stand."""
    # ---- turned-wood stand: tripod foot + column ----
    pyxel.elli(cx - r // 2 - 2, cy + r + 8, r + 4, 8, 4)         # foot
    pyxel.ellib(cx - r // 2 - 2, cy + r + 8, r + 4, 8, BK)
    pyxel.line(cx - 6, cy + r + 8, cx - r // 2, cy + r + 13, BR) # splayed legs
    pyxel.line(cx + 6, cy + r + 8, cx + r // 2, cy + r + 13, BR)
    pyxel.line(cx, cy + r + 7, cx, cy + r + 14, BR)
    pyxel.rect(cx - 2, cy + r - 2, 4, 12, BR)                    # column
    pyxel.line(cx - 2, cy + r - 2, cx - 2, cy + 9, OR)
    # ---- full brass meridian ring cradling the sphere ----
    pyxel.ellib(cx - r - 4, cy - r - 3, (r + 4) * 2, (r + 3) * 2, BR)
    pyxel.ellib(cx - r - 3, cy - r - 2, (r + 3) * 2, (r + 2) * 2, OR)
    pyxel.ellib(cx - r - 2, cy - r - 1, (r + 2) * 2, (r + 1) * 2, YL)
    # ---- ocean sphere with a lit side ----
    pyxel.circ(cx, cy, r, BL); pyxel.circb(cx, cy, r, NV)
    pyxel.circ(cx - r // 3, cy - r // 3, r - r // 2, 12)         # lit upper-left ocean
    # ---- continents (chunky but suggestive landmasses) ----
    pyxel.elli(cx - r + 2, cy - r // 2, r - 1, r // 2 + 2, GN)   # upper-left landmass
    pyxel.elli(cx - 3, cy - 1, r // 2 + 2, r - 2, GN)           # central vertical landmass
    pyxel.elli(cx + r // 3, cy - r + 3, r // 2, r // 3 + 1, GN)  # upper-right
    pyxel.elli(cx + r // 4, cy + r // 3, r // 3 + 1, r // 3, GN) # lower-right isle
    pyxel.elli(cx - r // 2, cy + r // 2, r // 3, r // 4 + 1, GN) # lower-left isle
    pyxel.pset(cx + r - 3, cy, GN)                              # speck
    # ---- lat / long grid ----
    pyxel.line(cx - r + 1, cy, cx + r - 1, cy, 6)               # equator
    for dy in (-2 * r // 3, -r // 3, r // 3, 2 * r // 3):       # latitudes
        hw = int((r * r - dy * dy) ** 0.5) - 1
        if hw > 0: pyxel.line(cx - hw, cy + dy, cx + hw, cy + dy, 6)
    pyxel.ellib(cx - r // 2, cy - r, r, r * 2, 6)               # one inner meridian
    pyxel.line(cx, cy - r + 1, cx, cy + r - 1, 6)              # central meridian
    # ---- axis pins + specular sheen ----
    pyxel.circ(cx, cy - r - 2, 1, YL); pyxel.circ(cx, cy + r + 1, 1, YL)
    pyxel.elli(cx - r // 2, cy - r // 2 - 1, 3, 2, WH)


def _desk_monitor(x, y, fr):
    """A proper monitor on a stand for a desk (reads as a computer)."""
    pyxel.rect(x + 22, y + 34, 6, 5, 5)                       # neck
    pyxel.rect(x + 14, y + 39, 22, 4, 5); pyxel.rectb(x + 14, y + 39, 22, 4, GY)
    pyxel.rect(x, y, 50, 36, 1); pyxel.rectb(x, y, 50, 36, GY)  # bezel
    pyxel.rect(x + 3, y + 3, 44, 28, BK); pyxel.rectb(x + 3, y + 3, 44, 28, NV)
    draw_eye(x + 25, y + 15, 7, fr, scan=True)
    c = RD if (fr // 12) % 2 == 0 else PP
    big_text_c(x + 25, y + 24, "COMPLY", c, 1)


def _exit_door(x, y, w, h, label):
    """A bottom-of-room door framed as a real doorway with threshold + sign."""
    pyxel.rect(x - 10, y - 6, w + 20, h + 6, 5)            # jamb frame
    pyxel.rectb(x - 10, y - 6, w + 20, h + 6, BK)
    pyxel.rect(x - 6, y - 10, w + 12, 6, 4)               # threshold mat
    pyxel.rectb(x - 6, y - 10, w + 12, 6, BK)
    _door(x, y, w, h, label, OR)
    big_text_c(x + w // 2, y - 26, "EXIT", LM, 1)
    pyxel.tri(x + w // 2 - 6, y - 14, x + w // 2 + 6, y - 14, x + w // 2, y - 7, LM)


def draw_hallway(fr):
    _backwall(GY, SV, GY)
    # top header band so the wall fixtures have room and nothing is cut off
    pyxel.rect(0, 0, W, 26, NV); pyxel.rect(0, 24, W, 3, BL)
    pyxel.line(0, 27, W, 27, 5)
    big_text_c(W // 2, 7, "NORTHWOOD HALL", SV, 1)
    for i in range(5):
        _ceiling_strip(40 + i * 92, 32, 60, fr, blink_phase=i)
    # FLOOR - institutional tile
    for gy in range(FY, H, 44):
        for gx in range(0, W, 44):
            c = 7 if ((gx // 44 + gy // 44) % 2 == 0) else 13
            pyxel.rect(gx, gy, 44, 44, c)
    for gy in range(FY, H, 44): pyxel.line(0, gy, W, gy, 13)
    for gx in range(0, W, 44):  pyxel.line(gx, FY, gx, H, 13)
    _tilebevel(44, 7, 13); _grain(6, 13, 71)
    # soft light pools spilling onto the floor beneath each ceiling strip
    for i in range(5):
        lpx = 70 + i * 92
        if (fr // 40 + i) % 16 != 0:
            for ry in range(-26, 27, 2):
                for rx in range(-34, 35, 3):
                    if (rx * rx) / 1156 + (ry * ry) / 676 < 1 and (rx + ry + rx * ry) % 5 == 0:
                        pyxel.pset(lpx + rx, FY + 6 + ry + 13, 7)
    # a worn central walking path - subtle dirt/wear, denser toward the middle
    for ly in range(FY + 2, H, 3):
        ww = 60 + (ly - FY) // 6
        for lx in range(W // 2 - ww, W // 2 + ww, 4):
            if (lx * 7 + ly * 11) % 9 < 2:
                pyxel.pset(lx + (ly % 4), ly, 6 if (lx + ly) % 2 else 13)
    for arc in [(W // 2 - 30, 230), (W // 2 + 20, 300), (W // 2, 380), (W // 2 - 10, 450)]:
        pyxel.ellib(arc[0], arc[1], 16, 5, 6)                   # scuff arcs
    # scuffs / stray footprints
    for sxn, syn in [(120, 360), (360, 410), (250, 470), (90, 250), (430, 340)]:
        pyxel.elli(sxn, syn, 7, 3, 13); pyxel.elli(sxn + 9, syn + 4, 7, 3, 13)
    # faded SYNTHIA seal painted on the floor - fills the barren centre, on-theme
    _floor_seal(W // 2, 354, 66, fr)
    # pennant string for a bit of life
    _pennants(36, W - 36, 30, fr)
    # locker banks - now a single matching palette, flanking the trophy case
    _locker_bank(2,   46, 9, 100, fr, locked_col=BL, accent=NV)
    _locker_bank(300, 46, 8, 100, fr, locked_col=BL, accent=NV)
    # glass trophy case, fitted cleanly in the GAP between the locker banks
    tcx, tcw = 208, 88
    pyxel.rect(tcx, 42, tcw, 96, 1); pyxel.rectb(tcx, 42, tcw, 96, 5)
    pyxel.rect(tcx + 4, 46, tcw - 8, 64, NV); pyxel.rectb(tcx + 4, 46, tcw - 8, 64, BL)
    pyxel.line(tcx + 4, 80, tcx + tcw - 4, 80, BL)
    pyxel.line(tcx + 6, 48, tcx + 6, 108, SV)                   # glass sheen
    cupx = tcx + tcw // 2                                       # a little trophy cup
    pyxel.elli(cupx - 9, 50, 18, 12, YL); pyxel.ellib(cupx - 9, 50, 18, 12, OR)
    pyxel.line(cupx - 9, 54, cupx - 13, 60, YL); pyxel.line(cupx + 9, 54, cupx + 13, 60, YL)
    pyxel.rect(cupx - 2, 60, 4, 8, YL); pyxel.rect(cupx - 6, 68, 12, 4, OR)
    pyxel.rect(tcx + 6, 112, tcw - 12, 22, GY); pyxel.rectb(tcx + 6, 112, tcw - 12, 22, SV)
    big_text_c(tcx + tcw // 2, 114, "STUDENT", SV, 1)
    big_text_c(tcx + tcw // 2, 124, "OF MONTH", SV, 1)
    # wall fixtures (below header)
    for i in range(3): _cctv_camera(70 + i * 180, 32, fr)
    _clock(486, 58, fr)
    _fire_extinguisher(486, 108)
    # props along the wall, each grounded with a contact shadow
    pyxel.elli(14, 178, 18, 6, BK)
    _trashcan(14, 158)
    pyxel.elli(210, 180, 86, 7, BK)
    _bench(210, 158)                                            # centered under the trophy case
    pyxel.elli(472, 180, 24, 6, BK)
    _water_fountain(474, 158)
    _cleaning_kit(92, 332, fr)                                  # mop, bucket + wet-floor sign
    # propaganda posters tucked into the two LOWER corners (grounded)
    pyxel.elli(56, 466, 46, 7, BK)
    _poster_stand(12, 404, "COMPLY")
    pyxel.elli(456, 466, 46, 7, BK)
    _poster_stand(412, 404, "THINK LESS")
    # doors: sides in the walls; canteen is a normal vertical door at the bottom
    _door(0,   200, 54, 100, "LIBRARY",   BR)
    _door(458, 200, 54, 100, "CLASSROOM", NV)
    _door(229, 412, 54, 100, "CANTEEN",   OR)


def draw_classroom(fr):
    _backwall(GY, SV, GY)
    for i in range(4):
        _ceiling_strip(30 + i * 116, 4, 80, fr, blink_phase=i)
    # BIG blackboard filling the centre of the back wall
    pyxel.rect(84, 26, 300, 98, 3)
    pyxel.rectb(84, 26, 300, 98, BR)
    pyxel.rectb(86, 28, 296, 94, 4)
    pyxel.rect(84, 120, 300, 6, BR)        # chalk tray
    pyxel.rect(94, 121, 8, 2, WH); pyxel.rect(108, 121, 6, 2, YL)
    pyxel.text(98, 44, "DO NOT QUESTION", WH)
    pyxel.text(98, 60, "THE OUTPUT", WH)
    pyxel.text(98, 82, "SYNTHIA IS ALWAYS RIGHT", WH)
    pyxel.text(98, 100, "COMPLIANCE = GRADE", YL)
    pyxel.text(322, 44, "WHY?", GY)
    # ---- barred window: cold daylight, a far SYNTHIA broadcast antenna ----
    wx2, wy2, ww2, wh2 = 388, 36, 100, 58
    pyxel.rect(wx2 - 4, wy2 - 4, ww2 + 8, wh2 + 8, 4)              # wooden frame
    pyxel.rectb(wx2 - 4, wy2 - 4, ww2 + 8, wh2 + 8, BK)
    pyxel.rect(wx2 - 6, wy2 + wh2 + 4, ww2 + 12, 6, BR)           # sill
    pyxel.rectb(wx2 - 6, wy2 + wh2 + 4, ww2 + 12, 6, BK)
    pyxel.line(wx2 - 5, wy2 + wh2 + 5, wx2 + ww2 + 5, wy2 + wh2 + 5, OR)
    # glass: layered sky
    pyxel.rect(wx2, wy2, ww2, wh2, BL)
    pyxel.rect(wx2, wy2, ww2, 16, CY)                            # bright upper sky
    pyxel.rect(wx2, wy2 + 16, ww2, 8, 12)                        # haze band
    for cx3, cy3, cw3 in [(wx2 + 18, wy2 + 9, 22), (wx2 + 64, wy2 + 14, 26)]:
        pyxel.elli(cx3, cy3, cw3, 7, WH)                        # clouds
    # distant rooftops on the horizon + the broadcast antenna
    hz = wy2 + wh2 - 4
    for rbx, rbw, rbh in [(wx2 + 4, 22, 14), (wx2 + 30, 16, 22), (wx2 + 66, 26, 12)]:
        pyxel.rect(rbx, hz - rbh, rbw, rbh + 6, 1); pyxel.rectb(rbx, hz - rbh, rbw, rbh + 6, NV)
        for wyy in range(hz - rbh + 3, hz, 5):
            for wxx in range(rbx + 3, rbx + rbw - 2, 5):
                pyxel.pset(wxx, wyy, YL if (wxx + wyy) % 3 else 1)
    antx = wx2 + 38                                             # antenna mast on the tall block
    pyxel.line(antx, hz - 22, antx, hz - 36, GY); pyxel.line(antx + 1, hz - 22, antx + 1, hz - 36, 5)
    pulse = RD if (fr // 8) % 2 == 0 else OR
    pyxel.circ(antx, hz - 36, 1, pulse)
    for wv in range(1, 3):
        on = (fr // 6) % 3 >= wv - 1
        pyxel.ellib(antx - wv * 5, hz - 36 - wv * 3, wv * 10, wv * 6, pulse if on else 1)
    # mullions (2x2) + clean security bars
    pyxel.line(wx2 + ww2 // 2, wy2, wx2 + ww2 // 2, wy2 + wh2, SV)
    pyxel.line(wx2, wy2 + wh2 // 2, wx2 + ww2, wy2 + wh2 // 2, SV)
    for bxx in range(wx2 + 22, wx2 + ww2 - 6, 22):
        pyxel.line(bxx, wy2 + 1, bxx, wy2 + wh2 - 1, GY)
        pyxel.line(bxx + 1, wy2 + 1, bxx + 1, wy2 + wh2 - 1, 1)
    pyxel.rectb(wx2, wy2, ww2, wh2, SV)                         # inner glass edge
    for sxc, syc in [(wx2 - 3, wy2 - 3), (wx2 + ww2, wy2 - 3),
                     (wx2 - 3, wy2 + wh2), (wx2 + ww2, wy2 + wh2)]:
        pyxel.pset(sxc, syc, SV)                               # frame screws
    draw_eye(404, 116, 8, fr, scan=False)                      # watching eye on the wall below
    # wall-mounted FOCUS TERMINAL on a column, with a console at the floor
    # (its interaction point / glow is at y~178, so the unit reaches down to it)
    tc = GN if (fr // 10) % 2 == 0 else BL
    pyxel.rect(8, 40, 66, 68, BK); pyxel.rectb(8, 40, 66, 68, BL)          # wall screen
    pyxel.rect(12, 44, 58, 50, NV); pyxel.rectb(12, 44, 58, 50, GY)
    pyxel.text(18, 48, "FOCUS", tc); pyxel.text(14, 58, "TERMINAL", tc)
    pads = [(22, 72), (40, 72), (22, 84), (40, 84)]                        # focus pattern hint
    onp = (fr // 14) % 4
    for pi, (pxp, pyp) in enumerate(pads):
        pc = (RD, GN, BL, YL)[pi]
        pyxel.rect(pxp, pyp, 14, 9, pc if pi == onp else 1)
        pyxel.rectb(pxp, pyp, 14, 9, BK)
    pyxel.rect(34, 108, 12, 44, 5); pyxel.rectb(34, 108, 12, 44, GY)       # support column
    pyxel.line(40, 110, 40, 150, BK)
    # FLOOR - cool pale-green vinyl
    for gy in range(FY, H, 30):
        for gx in range(0, W, 30):
            c = 7 if ((gx // 30 + gy // 30) % 2 == 0) else 11
            pyxel.rect(gx, gy, 30, 30, c)
    for gy in range(FY, H, 30): pyxel.line(0, gy, W, gy, 6)
    for gx in range(0, W, 30):  pyxel.line(gx, FY, gx, H, 6)
    _tilebevel(30, 7, 5); _grain(6, 5, 71)
    _floor_grime(20, W - 20, 61, 5, 6, traffic=False)          # subtle scuffs/dirt
    for i in range(4):                                         # light pools under the strips
        _light_pool(70 + i * 116, FY + 30, 56, 40, 7, on=(fr // 40 + i) % 15 != 0)
    # focus-terminal floor console (drawn after the floor, under the wall screen)
    tcc = GN if (fr // 10) % 2 == 0 else BL
    pyxel.elli(16, 180, 70, 12, BK)                                       # contact shadow
    pyxel.rect(18, 152, 64, 32, 1); pyxel.rectb(18, 152, 64, 32, GY)      # console body
    pyxel.rect(18, 152, 64, 3, 5)                                         # lit top edge
    pyxel.rect(24, 156, 52, 15, NV); pyxel.rectb(24, 156, 52, 15, BL)     # console screen
    pyxel.text(28, 159, "HOLD THE", tcc); pyxel.text(30, 165, "PATTERN", tcc)
    pyxel.rect(24, 174, 52, 6, GY); pyxel.rectb(24, 174, 52, 6, 5)        # keyboard
    for kx in range(27, 76, 5): pyxel.line(kx, 176, kx, 178, BK)
    pyxel.circ(74, 158, 2, GN if (fr // 8) % 2 == 0 else 1)               # power LED
    for cwx in (18, 82):                                                  # dusty cobwebs
        sgnc = 1 if cwx < 50 else -1
        pyxel.line(cwx, 152, cwx + sgnc * 9, 146, 5)
        pyxel.line(cwx, 152, cwx + sgnc * 5, 143, 5)
    # locked supply cabinet on the floor (its interaction glow is at ~456,178)
    ccx, ccy, ccw, cch = 438, 150, 60, 74
    pyxel.elli(ccx + 4, ccy + cch - 2, ccw - 6, 8, BK)                     # shadow
    pyxel.rect(ccx, ccy, ccw, cch, 5); pyxel.rectb(ccx, ccy, ccw, cch, GY)  # body
    pyxel.rect(ccx + 2, ccy + 2, ccw - 4, 2, 6)                            # top hilite
    for s in range(3):                                                     # vent slits
        pyxel.line(ccx + 6, ccy + 8 + s * 4, ccx + ccw // 2 - 6, ccy + 8 + s * 4, BK)
        pyxel.line(ccx + ccw // 2 + 6, ccy + 8 + s * 4, ccx + ccw - 6, ccy + 8 + s * 4, BK)
    pyxel.line(ccx + ccw // 2, ccy + 4, ccx + ccw // 2, ccy + cch - 2, BK)  # door seam
    for dd in (ccx + ccw // 2 - 4, ccx + ccw // 2 + 2):                    # handles
        pyxel.rect(dd, ccy + cch // 2 - 6, 2, 12, GY); pyxel.rectb(dd, ccy + cch // 2 - 6, 2, 12, BK)
    pyxel.circ(ccx + 14, ccy + cch // 2, 4, GY); pyxel.circb(ccx + 14, ccy + cch // 2, 4, BK)  # combo dial
    pyxel.pset(ccx + 14, ccy + cch // 2 - 3, WH)
    pyxel.rect(ccx + 8, ccy + cch - 16, 32, 9, BK); pyxel.text(ccx + 11, ccy + cch - 14, "1984?", WH)  # scratched hint
    # teacher desk standing on the floor in front of the board (detailed)
    tdx, tdy, tdw = 188, 156, 144
    pyxel.elli(tdx + 8, tdy + 44, tdw - 16, 8, BK)                         # shadow
    for lx2 in (tdx + 8, tdx + tdw - 12): pyxel.rect(lx2, tdy + 30, 6, 16, 1)   # legs
    _wood_slab(tdx, tdy, tdw, 32)
    pyxel.rect(tdx + 6, tdy + 18, tdw - 12, 12, 4); pyxel.rectb(tdx + 6, tdy + 18, tdw - 12, 12, BK)  # drawer
    pyxel.rect(tdx + tdw // 2 - 6, tdy + 22, 12, 3, GY)                    # drawer handle
    pyxel.rect(tdx + 30, tdy - 9, 44, 11, GY); pyxel.rectb(tdx + 30, tdy - 9, 44, 11, SV)  # name plate
    pyxel.text(tdx + 33, tdy - 7, "MS.RICHTER", WH)
    pyxel.rect(tdx + 98, tdy - 20, 30, 22, BK); pyxel.rectb(tdx + 98, tdy - 20, 30, 22, RD)  # grade monitor
    mc = RD if (fr // 10) % 2 == 0 else OR
    pyxel.text(tdx + 104, tdy - 13, "SYN", mc)
    pyxel.rect(tdx + 110, tdy - 2, 6, 3, GY)
    pyxel.rect(tdx + 6, tdy - 14, 26, 16, WH); pyxel.rectb(tdx + 6, tdy - 14, 26, 16, GY)  # graded papers
    pyxel.text(tdx + 9, tdy - 11, "A+", RD); pyxel.line(tdx + 10, tdy - 4, tdx + 26, tdy - 4, RD)
    pyxel.circ(tdx + 78, tdy - 4, 4, RD); pyxel.line(tdx + 76, tdy - 4, tdx + 80, tdy - 4, 8)  # apple
    pyxel.rect(tdx + 77, tdy - 9, 2, 3, BR)
    pyxel.rect(tdx + 60, tdy - 10, 6, 10, CY); pyxel.rectb(tdx + 60, tdy - 10, 6, 10, NV)  # pen pot
    pyxel.line(tdx + 62, tdy - 16, tdx + 62, tdy - 10, RD); pyxel.line(tdx + 64, tdy - 15, tdx + 64, tdy - 10, BL)
    # student desks (3x3) on the floor - tighter spacing
    for row in range(3):
        for col in range(3):
            dx = 128 + col * 106; dy = 206 + row * 72
            _desk_and_chair(dx, dy)
            pyxel.rect(dx + 14, dy + 8, 20, 14, NV)
            pyxel.rectb(dx + 14, dy + 8, 20, 14, BL)
            sc = RD if (fr // 8 + row + col) % 4 == 0 else GN
            pyxel.rect(dx + 16, dy + 10, 16, 10, sc)
            pyxel.text(dx + 17, dy + 12, "SYN", WH)
    _fire_extinguisher(492, 112)
    pyxel.elli(24, 404, 18, 6, BK); _trashcan(16, 382)                     # bin in the corner
    _big_plant(8, 432, fr)
    _big_plant(452, 432, fr)
    _door(0,   200, 54, 100, "HALLWAY",   NV)
    _door(229, 412, 54, 100, "SERVER RM", RD)            # normal vertical door


def draw_library(fr):
    _backwall(4, OR, BR)
    # warm pendant lights
    for i in range(4):
        lx2 = 64 + i * 120
        pyxel.line(lx2, 0, lx2, 14, GY)
        pyxel.rect(lx2 - 8, 14, 16, 8, YL)
        pyxel.rectb(lx2 - 8, 14, 16, 8, OR)
        for g in range(6):
            y_cone = 22 + g * 4; hw = 6 + g * 4
            pyxel.line(lx2 - hw, y_cone, lx2 + hw, y_cone, YL if g < 3 else OR)
    # FLOOR - large wood planks
    for gy in range(FY, H, 22):
        pyxel.rect(0, gy, W, 22, 4)
        pyxel.line(0, gy, W, gy, BK)
        pyxel.line(0, gy + 1, W, gy + 1, BR)
        off = (gy // 22 % 2) * 64
        for sx in range(off, W, 128):
            pyxel.line(sx, gy, sx, gy + 22, BK)
    _grain(9, 1, 47)
    # a soft rug under the central reading area
    pyxel.elli(150, 372, 240, 96, 2); pyxel.ellib(150, 372, 240, 96, 8)
    pyxel.ellib(158, 380, 224, 80, RD)

    # ===== BACK-WALL "WALL OF BOOKS" (built-in, broken by the door) =====
    _bookshelf(2, 22, 158, 120)                      # left run
    _bookshelf(296, 22, 150, 120)                    # right run
    # a rolling library ladder leaning on the left run
    ladx = 120
    pyxel.line(ladx, 24, ladx + 18, 140, BR); pyxel.line(ladx + 6, 24, ladx + 24, 140, BR)
    for rg in range(5):
        ry = 40 + rg * 22
        pyxel.line(ladx + rg * 3 + 2, ry, ladx + rg * 3 + 10, ry, BR)
    pyxel.circ(ladx + 24, 140, 2, BK); pyxel.circ(ladx + 18, 140, 2, BK)
    # right-wall reading nook: clock + framed SILENCE sign on the clear patch
    pyxel.rect(458, 30, 46, 30, NV); pyxel.rectb(458, 30, 46, 30, YL)   # framed sign
    pyxel.rectb(460, 32, 42, 26, OR)
    pyxel.text(466, 38, "SILENCE", WH); pyxel.text(470, 46, "PLEASE", SV)
    _clock(478, 86, fr)
    _banned_poster(172, 32, 48, 104, fr)             # banned list beside the door
    _door(228, 46, 56, 100, "PRINCIPAL", NV)

    # ===== LEFT FREE-STANDING STACK (mirrors the right aisle stack) =====
    _bookshelf(108, 158, 56, 140)                    # left aisle stack
    pyxel.elli(106, 298, 60, 12, BK)                 # contact shadow
    # ---- SECRET-PASSAGE bookcase in the bottom-left corner (the '1984' lever) ----
    pyxel.rect(2, 302, 60, 138, 1); pyxel.rectb(2, 302, 60, 138, BK)
    pyxel.rect(4, 304, 56, 134, BR)
    for sh in range(5):
        shy = 308 + sh * 26
        bc2 = [RD, GN, BL, YL, PP, OR, CY]
        for k in range(7):
            jut = 4 if (sh == 2 and k == 3) else 0
            col = LM if jut else bc2[(sh * 7 + k) % len(bc2)]
            pyxel.rect(6 + k * 8 - jut, shy, 6 + jut, 22, col)
            pyxel.rectb(6 + k * 8 - jut, shy, 6 + jut, 22, BK)
        pyxel.rect(4, shy + 22, 56, 3, 1)
    pyxel.line(60, 302, 60, 440, NV)                 # pivot seam
    for dy in range(306, 438, 8):
        pyxel.pset(61, dy, GY)
    pyxel.elli(54, 432, 36, 12, NV)                  # floor scuff arc

    # ===== RIGHT FREE-STANDING STACK (mid-right aisle) =====
    _bookshelf(348, 158, 56, 140)
    pyxel.elli(346, 298, 60, 12, BK)                 # contact shadow

    # ===== CENTRAL READING TABLES (dark wood, green felt, banker lamps) =====
    for tx, ty in ((92, 316), (300, 316)):
        pyxel.rect(tx, ty, 124, 46, 1); pyxel.rectb(tx, ty, 124, 46, BK)     # frame
        pyxel.rect(tx + 4, ty + 4, 116, 28, 3); pyxel.rectb(tx + 4, ty + 4, 116, 28, GN)  # felt
        pyxel.line(tx + 5, ty + 5, tx + 118, ty + 5, 11)
        for lx2 in (tx + 4, tx + 112):
            pyxel.rect(lx2, ty + 44, 8, 26, 4); pyxel.line(lx2, ty + 44, lx2, ty + 70, 1)
        pyxel.rect(tx + 40, ty + 9, 44, 24, WH); pyxel.rectb(tx + 40, ty + 9, 44, 24, GY)  # open book
        pyxel.line(tx + 62, ty + 9, tx + 62, ty + 33, GY)
        for ln in range(3):
            pyxel.line(tx + 44, ty + 14 + ln * 6, tx + 58, ty + 14 + ln * 6, SV)
            pyxel.line(tx + 66, ty + 14 + ln * 6, tx + 80, ty + 14 + ln * 6, SV)
        pyxel.rect(tx + 104, ty + 16, 4, 12, GY)                              # banker lamp
        pyxel.rect(tx + 96, ty + 8, 20, 9, GN); pyxel.rectb(tx + 96, ty + 8, 20, 9, 3)
        pyxel.rect(tx + 98, ty + 17, 16, 3, YL)
        for ci in range(3):                                                   # tucked chairs
            cxx = tx + 10 + ci * 38
            pyxel.rect(cxx, ty - 14, 24, 12, BR); pyxel.rectb(cxx, ty - 14, 24, 12, BK)
            pyxel.rect(cxx + 2, ty - 12, 20, 4, 4)

    # ===== BOOK-RETURN CART ("Old Books", relocated to clear upper-left) =====
    cax, cay = 12, 246
    pyxel.rect(cax, cay, 56, 28, BR); pyxel.rectb(cax, cay, 56, 28, BK)
    pyxel.rect(cax, cay - 4, 56, 5, 4); pyxel.rectb(cax, cay - 4, 56, 5, BK)
    pyxel.line(cax + 2, cay - 9, cax + 53, cay - 9, GY)
    pyxel.line(cax + 2, cay - 9, cax + 2, cay - 4, GY); pyxel.line(cax + 53, cay - 9, cax + 53, cay - 4, GY)
    bcols = [RD, GN, BL, YL, PP, OR, CY]
    for k in range(6):
        lean = 3 if k == 4 else 0
        pyxel.rect(cax + 4 + k * 8, cay + 2 - lean, 6, 12 + lean, bcols[k % 7])
        pyxel.rectb(cax + 4 + k * 8, cay + 2 - lean, 6, 12 + lean, BK)
        pyxel.line(cax + 5 + k * 8, cay + 6, cax + 8 + k * 8, cay + 6, WH)
    pyxel.rect(cax + 2, cay + 16, 52, 2, 1)
    pyxel.circ(cax + 9, cay + 29, 3, BK); pyxel.circ(cax + 47, cay + 29, 3, BK)

    # ===== DETAIL: globe on a stand + plants =====
    _globe(250, 356, 16)
    _big_plant(470, 440, fr)
    _big_plant(250, 470, fr)

    _door(458, 200, 54, 100, "HALLWAY", NV)


def _vending(x, y):
    """A tall, detailed drinks machine - every can the same 'OPTIMAL', of course."""
    VW, VH = 54, 94
    pyxel.rect(x + 3, y + VH, VW - 4, 5, BK)                   # floor shadow
    pyxel.rect(x, y, VW, VH, RD); pyxel.rectb(x, y, VW, VH, BK)
    pyxel.rect(x, y, VW, 14, 8); pyxel.rectb(x, y, VW, 14, BK)  # brand header
    big_text_c(x + VW // 2, y + 4, "OPTIMAL", WH, 1)
    gx, gy, gw, gh = x + 5, y + 18, 30, 54                     # glass display
    pyxel.rect(gx, gy, gw, gh, NV); pyxel.rectb(gx, gy, gw, gh, BL)
    for r in range(4):
        sy = gy + 3 + r * 13
        pyxel.line(gx, sy + 11, gx + gw, sy + 11, BL)          # shelf
        for c in range(3):
            cx2 = gx + 3 + c * 9
            pyxel.rect(cx2, sy, 6, 10, OR); pyxel.rectb(cx2, sy, 6, 10, 4)  # can
            pyxel.line(cx2 + 1, sy + 1, cx2 + 1, sy + 9, YL)
            pyxel.pset(cx2 + 4, sy + 2, WH)
    pyxel.line(gx + 3, gy + 2, gx + 9, gy + gh - 4, 7)         # glass reflection
    px2 = x + 38                                               # keypad
    for r in range(4):
        for c in range(2):
            pyxel.rect(px2 + c * 7, y + 20 + r * 8, 5, 6, GY)
            pyxel.rectb(px2 + c * 7, y + 20 + r * 8, 5, 6, BK)
    pyxel.rect(px2, y + 54, 12, 4, 1)                          # coin slot
    pyxel.rect(px2 - 1, y + 60, 14, 4, LM); pyxel.pset(px2 + 1, y + 61, GN)  # display
    pyxel.rect(x + 5, y + 76, gw, 13, 1); pyxel.rectb(x + 5, y + 76, gw, 13, BK)  # tray
    pyxel.rect(x + 9, y + 80, gw - 8, 6, BK)
    pyxel.rect(x + 3, y + VH, 5, 4, 5); pyxel.rect(x + VW - 8, y + VH, 5, 4, 5)   # legs


def _serving_counter(x, w, fr):
    """A cafeteria hot-food serving line: a stainless counter with labelled
    bain-marie wells under a glass sneeze-guard, serving spoons, steam, and a
    tray slide rail in front."""
    y = 90
    pyxel.rect(x, y + 22, w, 38, SV); pyxel.rectb(x, y + 22, w, 38, GY)   # stainless cabinet
    pyxel.rect(x + 2, y + 24, w - 4, 3, 7)                                # bright top edge
    for cpx in range(x + 8, x + w - 6, 18):
        pyxel.line(cpx, y + 28, cpx, y + 56, 6)                           # panel seams
    pyxel.rect(x + 2, y + 56, w - 4, 5, 6)                                # kick plate
    pyxel.rect(x - 2, y + 12, w + 4, 12, GY); pyxel.rectb(x - 2, y + 12, w + 4, 12, 5)  # counter top
    pyxel.line(x, y + 14, x + w, y + 14, WH)
    foods = [(GN, 3), (OR, 4), (YL, 9)]                                   # hot wells
    n = 3 if w >= 110 else 2
    tw = (w - 14) // n
    for i in range(n):
        tx = x + 7 + i * tw
        pyxel.rect(tx, y + 14, tw - 5, 12, BK); pyxel.rectb(tx, y + 14, tw - 5, 12, SV)
        fc, fe = foods[i % 3]
        pyxel.rect(tx + 2, y + 16, tw - 9, 8, fc)
        for lx in range(tx + 3, tx + tw - 9, 4):
            pyxel.pset(lx, y + 18, fe); pyxel.pset(lx + 2, y + 21, fe)
        pyxel.line(tx + tw - 10, y + 13, tx + tw - 6, y + 7, GY)          # serving spoon
        pyxel.circ(tx + tw - 10, y + 13, 1, SV)
        sc = 6 if (fr // 8 + i) % 2 == 0 else 7                           # steam
        smx = tx + tw // 2
        pyxel.line(smx, y + 10, smx - 2, y + 4, sc); pyxel.line(smx + 3, y + 11, smx + 1, y + 5, sc)
    pyxel.line(x + 2, y + 2, x + w - 2, y + 2, SV)                        # glass sneeze-guard
    for gp in (x + 4, x + w - 4): pyxel.line(gp, y + 2, gp, y + 12, GY)
    for ggx in range(x + 8, x + w - 4, 3): pyxel.pset(ggx, y + 7, 6)      # faint glass sheen
    pyxel.line(x - 2, y + 60, x + w + 2, y + 60, SV)                      # tray slide rail
    pyxel.line(x - 2, y + 62, x + w + 2, y + 62, GY)


def _canteen_poster(x, y, l1, l2, col):
    """A small framed propaganda poster for the canteen wall."""
    pyxel.rect(x, y, 52, 50, col); pyxel.rectb(x, y, 52, 50, BK)
    pyxel.rectb(x + 2, y + 2, 48, 46, WH)
    draw_eye(x + 26, y + 17, 6, 0, scan=False)
    big_text_c(x + 26, y + 30, l1, WH, 1)
    big_text_c(x + 26, y + 40, l2, WH, 1)


def draw_canteen(fr):
    # ceiling + clean painted wall with a calm lower dado band
    pyxel.rect(0, 0, W, 18, 5)
    pyxel.rect(0, 18, W, 132, NV)                          # solid upper wall (navy)
    pyxel.rect(0, 18, W, 4, 5)                             # light catch under ceiling
    pyxel.rect(0, 106, W, 36, 5)                           # calm grey dado band (single tone)
    for sx in range(64, W, 64):                            # faint, low-contrast seams
        pyxel.line(sx, 109, sx, 139, 6)
    pyxel.rect(0, 104, W, 3, 6)                            # dado rail
    pyxel.line(0, 141, W, 141, 1)
    pyxel.rect(0, 142, W, 4, GY)
    pyxel.rect(0, 146, W, 4, 5)
    pyxel.line(0, FY, W, FY, GY)
    # top header band so the wall fixtures are not cramped at the screen edge
    pyxel.rect(0, 0, W, 24, NV); pyxel.rect(0, 22, W, 3, SV)
    big_text_c(W // 2, 6, "SCHOOL CANTEEN", SV, 1)
    for i in range(4):
        _ceiling_strip(40 + i * 118, 28, 80, fr, blink_phase=i * 2)
    # FLOOR - small clean tiles (light blue / white)
    for gy in range(FY, H, 22):
        for gx in range(0, W, 22):
            c = 6 if ((gx // 22 + gy // 22) % 2 == 0) else 7
            pyxel.rect(gx, gy, 22, 22, c)
    for gy in range(FY, H, 22): pyxel.line(0, gy, W, gy, 6)
    for gx in range(0, W, 22):  pyxel.line(gx, FY, gx, H, 6)
    _tilebevel(22, 7, 6); _grain(7, 6, 71)
    for (wtx, wty) in [(264,FY),(286,FY+44),(242,FY+154),(264,FY+220),(40,FY+66),(462,FY+242)]:
        pyxel.rect(wtx, wty, 22, 22, 5); pyxel.rectb(wtx, wty, 22, 22, 6)   # worn/stained tiles
    _floor_grime(48, W - 48, 71, 5, 6)                          # centre traffic wear
    # light pools under the ceiling strips
    for i in range(4):
        lpx = 80 + i * 118
        if (fr // 40 + i * 2) % 16 != 0:
            for ry in range(-22, 23, 2):
                for rx in range(-40, 41, 3):
                    if (rx * rx) / 1600 + (ry * ry) / 484 < 1 and (rx + ry + rx * ry) % 5 == 0:
                        pyxel.pset(lpx + rx, FY + 18 + ry, 7)
    # faint footprints leading along the serving counters
    for fx in range(110, 440, 42):
        pyxel.elli(fx, 168, 5, 2, 6); pyxel.elli(fx + 5, 171, 5, 2, 6)
    # ---- back-wall signage: nutrition screen, posters, weekly menu plan ----
    _synthia_screen(10, 42, 120, 48, fr, msg="280 IDENTICAL")
    _canteen_poster(140, 34, "THINK?", "NO. EAT.", PP)
    _canteen_poster(314, 34, "MENU =", "OPTIMAL", BL)
    # WEEKLY MENU board (clear of the serving counter; content fits cleanly)
    mbx, mby, mbw, mbh = 378, 30, 124, 66
    pyxel.rect(mbx + 2, mby + 2, mbw, mbh, BK)                    # shadow
    pyxel.rect(mbx, mby, mbw, mbh, BK); pyxel.rectb(mbx, mby, mbw, mbh, OR)
    pyxel.rect(mbx, mby, mbw, 11, OR); big_text(mbx + 6, mby + 2, "WEEKLY MENU", BK, 1)
    for di, d in enumerate(("MON", "TUE", "WED", "THU")):
        pyxel.text(mbx + 6, mby + 15 + di * 9, d, YL)
        pyxel.text(mbx + 34, mby + 15 + di * 9, "OPTIMAL-A", WH)
    pyxel.line(mbx + 4, mby + 51, mbx + mbw - 4, mby + 51, GY)
    pyxel.text(mbx + 6, mby + 55, "no substitutions", GY)
    # ---- serving line: two bain-marie counters flanking the doorway ----
    _serving_counter(76, 128, fr)                                # left (3 trays)
    _serving_counter(306, 70, fr)                                # right (2 trays), clears board
    # stacked trays at the start of the line; cups at the end
    pyxel.rect(82, 120, 14, 22, SV); pyxel.rectb(82, 120, 14, 22, GY)
    for cu in range(4): pyxel.line(83, 118 - cu * 2, 95, 118 - cu * 2, WH)
    pyxel.rect(356, 120, 12, 22, WH); pyxel.rectb(356, 120, 12, 22, GY)
    for cu in range(3): pyxel.rectb(356 + cu * 3, 114, 8, 6, SV)
    # dining tables - left column wide, right column inset to clear the SUPPLY door
    bench_cols = [SV, GY, SV]
    cols = [(66, 168, 3), (332, 96, 2)]      # (x, width, trays)
    for row in range(3):
        ty2 = 184 + row * 96
        for (tx2, tw, ntr) in cols:
            pyxel.elli(tx2 + 26, ty2 + 66, tw - 52, 4, NV)      # subtle contact shadow
            pyxel.rect(tx2, ty2, tw, 44, SV)
            pyxel.rectb(tx2, ty2, tw, 44, GY)
            pyxel.line(tx2 + 2, ty2 + 2, tx2 + tw - 2, ty2 + 2, WH)
            for lx2 in [tx2 + 4, tx2 + tw - 12]:
                pyxel.rect(lx2, ty2 + 44, 8, 18, 5)
            pyxel.rect(tx2, ty2 - 16, tw, 12, bench_cols[row])
            pyxel.rectb(tx2, ty2 - 16, tw, 12, 5)
            pyxel.rect(tx2, ty2 + 52, tw, 12, bench_cols[row])
            pyxel.rectb(tx2, ty2 + 52, tw, 12, 5)
            step = (tw - 8) // ntr
            for ti in range(ntr):
                bx = tx2 + 6 + ti * step; by = ty2 + 8
                variant = (row * 3 + ti + tx2 // 50) % 5
                pyxel.rect(bx, by, 40, 26, SV); pyxel.rectb(bx, by, 40, 26, GY)   # moulded tray
                pyxel.line(bx + 1, by + 1, bx + 38, by + 1, WH)                   # rim hilite
                if variant == 4:                                                 # cleared tray
                    pyxel.rectb(bx + 4, by + 4, 32, 18, 6)
                    continue
                knock = (variant == 2)
                ox = 5 if knock else 0
                pyxel.rect(bx + 2 + ox, by + 3, 17, 20, 1); pyxel.rectb(bx + 2 + ox, by + 3, 17, 20, GY)  # main well
                pyxel.rect(bx + 4 + ox, by + 5, 13, 9, OR)                        # stew
                for lp in range(bx + 5 + ox, bx + 16 + ox, 3): pyxel.pset(lp, by + 9, 4)
                pyxel.rect(bx + 4 + ox, by + 16, 13, 5, GN)                       # veg
                pyxel.rect(bx + 21, by + 3, 8, 9, YL); pyxel.rectb(bx + 21, by + 3, 8, 9, 9)    # dessert
                pyxel.rect(bx + 21, by + 14, 8, 9, 3); pyxel.rectb(bx + 21, by + 14, 8, 9, GN)  # salad
                pyxel.circ(bx + 34, by + 8, 3, BR); pyxel.pset(bx + 33, by + 7, OR)             # roll
                pyxel.rect(bx + 31, by + 14, 7, 9, BL); pyxel.rectb(bx + 31, by + 14, 7, 9, NV) # cup
                pyxel.line(bx - 1, by + 5, bx - 1, by + 21, SV)                   # fork beside tray
                if knock:
                    pyxel.elli(bx + 30, by + 24, 11, 4, BL)                       # spilled drink
    _cctv_camera(10, 6, fr)
    _cctv_camera(484, 6, fr)
    _fire_extinguisher(490, 100)
    pyxel.elli(30, 440, 52, 8, BK); _vending(6, 344)             # grounded props
    pyxel.elli(476, 396, 50, 9, BK); _big_plant(452, 322, fr)
    pyxel.elli(490, 468, 18, 5, BK); _trashcan(484, 444)
    # nutrition info easel near the dining entrance (the 'identical pie')
    ncx, ncy = 280, 212
    bw, bh = 72, 52
    bx0, by0 = ncx - bw // 2, ncy - bh
    # ---- artist's easel: tripod + tray ----
    pyxel.elli(ncx - 8, ncy + 30, 42, 7, NV)                          # floor shadow
    pyxel.line(bx0 + 10, ncy - 4, ncx - 22, ncy + 30, BR)            # left leg
    pyxel.line(bx0 + bw - 10, ncy - 4, ncx + 22, ncy + 30, BR)       # right leg
    pyxel.line(ncx, ncy, ncx, ncy + 32, 4)                           # rear leg
    pyxel.line(bx0 + 6, ncy + 10, bx0 + bw - 6, ncy + 10, BR)        # tray rail
    # ---- board ----
    pyxel.rect(bx0 + 2, by0 + 2, bw, bh, BK)                          # drop shadow
    pyxel.rect(bx0, by0, bw, bh, WH); pyxel.rectb(bx0, by0, bw, bh, BR)
    pyxel.rect(bx0, by0, bw, 12, GN)                                  # title bar
    big_text_c(ncx, by0 + 2, "NUTRITION", WH, 1)
    # clean 100%-one-slice pie
    pcx, pcy, pr = bx0 + 20, by0 + 34, 13
    pyxel.circ(pcx, pcy, pr, OR); pyxel.circb(pcx, pcy, pr, BK)
    pyxel.line(pcx, pcy, pcx, pcy - pr, BK)                           # the single (full) slice cut
    pyxel.line(pcx + 1, pcy, pcx + 2, pcy - pr + 1, 4)
    pyxel.circ(pcx, pcy, 2, YL)                                       # hub
    # ---- legend + caption ----
    pyxel.rect(bx0 + 40, by0 + 22, 5, 5, OR); pyxel.rectb(bx0 + 40, by0 + 22, 5, 5, BK)
    big_text(bx0 + 48, by0 + 22, "100%", BK, 1)
    big_text_c(ncx, by0 + bh - 11, "OPTIMAL-A", GN, 1)
    _door(229, 46, 54, 100, "HALLWAY", NV)    # normal vertical door like the others
    _door(0,   200, 54, 100, "GYM",     GN)
    _door(458, 200, 54, 100, "SUPPLY",  BR)


def _basketball(cx, cy, r):
    pyxel.elli(cx - r, cy + r - 2, r * 2, 5, BK)          # shadow
    pyxel.circ(cx, cy, r, OR); pyxel.circb(cx, cy, r, BR)
    pyxel.line(cx, cy - r, cx, cy + r, BR)                # seams
    pyxel.elli(cx - r, cy - 2, r * 2, 5, BR)
    pyxel.line(cx - r + 1, cy - r + 2, cx - r // 2, cy + r - 1, BR)
    pyxel.line(cx + r - 1, cy - r + 2, cx + r // 2, cy + r - 1, BR)
    pyxel.pset(cx - r // 2, cy - r // 2, YL)


def _trophy(x, y):
    pyxel.elli(x - 2, y + 26, 26, 6, BK)                  # shadow
    pyxel.rect(x + 5, y + 18, 12, 6, OR); pyxel.rectb(x + 5, y + 18, 12, 6, BR)
    pyxel.rect(x + 8, y + 11, 6, 8, YL)                   # stem
    pyxel.elli(x + 2, y, 18, 15, YL); pyxel.ellib(x + 2, y, 18, 15, OR)
    pyxel.line(x + 2, y + 3, x - 2, y + 9, YL); pyxel.line(x + 18, y + 3, x + 22, y + 9, YL)
    pyxel.rect(x + 6, y + 20, 10, 3, SV)                  # plaque
    pyxel.pset(x + 8, y + 4, WH)


def _spectator(x, y, hc, bc, fr, seed):
    """A tiny animated fan that bobs and occasionally cheers (arms up)."""
    cheer = ((fr // 7 + seed * 3) % 11) < 2
    bob = -1 if ((fr // 9 + seed) % 4 == 0) else 0
    yy = y + bob
    pyxel.rect(x - 3, yy + 4, 7, 8, bc)                   # torso
    pyxel.circ(x, yy, 3, hc)                              # head
    if cheer:
        pyxel.line(x - 3, yy + 4, x - 6, yy - 2, hc)      # arms up
        pyxel.line(x + 3, yy + 4, x + 6, yy - 2, hc)
    else:
        pyxel.line(x - 3, yy + 5, x - 5, yy + 9, hc)
        pyxel.line(x + 3, yy + 5, x + 5, yy + 9, hc)


def _mini_trophy(x, y, kind):
    """A small trophy/cup/medal for a display shelf (sits with base at y+20)."""
    if kind == 0:                                        # gold cup
        pyxel.elli(x, y + 4, 12, 9, YL); pyxel.ellib(x, y + 4, 12, 9, OR)
        pyxel.line(x, y + 6, x - 3, y + 10, YL); pyxel.line(x + 11, y + 6, x + 14, y + 10, YL)
        pyxel.rect(x + 4, y + 12, 4, 5, YL); pyxel.rect(x + 1, y + 17, 10, 3, OR)
    elif kind == 1:                                      # silver cup
        pyxel.elli(x + 1, y + 6, 9, 7, SV); pyxel.ellib(x + 1, y + 6, 9, 7, GY)
        pyxel.rect(x + 4, y + 12, 3, 4, SV); pyxel.rect(x + 1, y + 16, 9, 3, GY)
    else:                                                # medal on ribbon
        pyxel.line(x + 4, y + 2, x + 2, y + 10, RD); pyxel.line(x + 8, y + 2, x + 10, y + 10, BL)
        pyxel.circ(x + 6, y + 13, 5, YL); pyxel.circb(x + 6, y + 13, 5, OR)
        pyxel.pset(x + 6, y + 13, OR)


def _trophy_cabinet(x, y, w, h):
    """A glass display vitrine with shelves of cups, trophies and medals."""
    pyxel.rect(x, y, w, h, BR); pyxel.rectb(x, y, w, h, 4)        # wood frame
    pyxel.rectb(x + 1, y + 1, w - 2, h - 2, BK)
    pyxel.rect(x + 3, y + 3, w - 6, h - 6, NV); pyxel.rectb(x + 3, y + 3, w - 6, h - 6, BL)  # glass
    pyxel.line(x + 9, y + 6, x + 18, y + h - 8, BL)              # glass reflection
    pyxel.line(x + 13, y + 6, x + 22, y + h - 8, BL)
    nshelf = max(1, (h - 12) // 36)
    step = (h - 14) // nshelf
    for s in range(nshelf):
        sy = y + 6 + s * step
        for k, tcx in enumerate(range(x + 12, x + w - 14, 26)):
            _mini_trophy(tcx, sy, (k + s) % 3)
        pyxel.rect(x + 4, sy + 20, w - 8, 3, 5)                  # shelf board
        pyxel.line(x + 4, sy + 20, x + w - 5, sy + 20, SV)


def _special_vitrine(x, y, fr):
    """A spotlit glass display case holding the old debate trophy - a quiet
    invitation in the corner of the gym."""
    for i in range(8):                                       # soft spotlight cone
        gy2 = y - 26 + i * 3; gw = 6 + i * 3
        for gx in range(x + 28 - gw, x + 28 + gw, 3):
            if (gx + gy2 * 2) % 4 == 0: pyxel.pset(gx, gy2, YL)
    pyxel.elli(x + 10, y + 58, 44, 8, BK)                    # floor shadow
    pyxel.rect(x + 10, y + 40, 38, 22, BR); pyxel.rectb(x + 10, y + 40, 38, 22, 4)   # pedestal
    pyxel.rect(x + 7, y + 37, 44, 4, OR)
    pyxel.rect(x, y - 8, 56, 48, NV); pyxel.rectb(x, y - 8, 56, 48, YL)              # gold case
    pyxel.rectb(x + 2, y - 6, 52, 44, OR)
    pyxel.line(x + 8, y - 4, x + 8, y + 36, BL); pyxel.line(x + 12, y - 4, x + 12, y + 36, BL)  # glass sheen
    _trophy(x + 16, y + 2)                                   # the old debate trophy inside
    pyxel.rect(x + 12, y + 28, 32, 8, SV); pyxel.rectb(x + 12, y + 28, 32, 8, GY)    # plaque
    pyxel.text(x + 15, y + 29, "EST.2019", BK)


def draw_gym(fr):
    # ceiling + tall cinder-block wall
    pyxel.rect(0, 0, W, 18, 5)
    pyxel.rect(0, 18, W, 128, GY)
    for brow in range(8):
        for bcol in range(9):
            bx2 = bcol * 56 + (brow % 2) * 28
            by2 = 20 + brow * 16
            pyxel.rectb(bx2, by2, 56, 15, 5)
    pyxel.rect(0, 142, W, 4, 5)
    pyxel.rect(0, 146, W, 4, GY)
    pyxel.line(0, FY, W, FY, GY)
    # gym lights
    for i in range(4):
        lx2 = 56 + i * 128
        pyxel.line(lx2, 0, lx2, 10, GY)
        pyxel.rect(lx2 - 12, 10, 24, 10, YL)
        pyxel.rectb(lx2 - 12, 10, 24, 10, GY)
    # hardwood floor (softer plank lines so the court reads clearly)
    for gy in range(FY, H, 8):
        c = BR if (gy // 8) % 2 == 0 else 4
        pyxel.rect(0, gy, W, 8, c)
    for gx in range(0, W, 12):
        pyxel.line(gx, FY, gx, H, 1)
    _grain(9, 1, 41)
    for i in range(4):                            # warm light pools cast on the court
        _light_pool(56 + i * 128, FY + 64, 60, 44, 9, on=(fr // 40 + i) % 14 != 0)
    # ---- COURT MARKINGS (vertical court: baskets at top & bottom ends) ----
    LX, RX, TY, BY = 56, 456, 150, 500
    pyxel.rectb(LX, TY, RX - LX, BY - TY, WH)
    pyxel.rectb(LX + 1, TY + 1, RX - LX - 2, BY - TY - 2, WH)
    midy = (TY + BY) // 2
    pyxel.line(LX, midy, RX, midy, WH)            # half-court line
    pyxel.circb(256, midy, 52, WH)                # centre circle
    pyxel.circb(256, midy, 6, WH)
    pyxel.rectb(206, TY, 100, 92, WH)             # top key (paint under hoop)
    pyxel.circb(256, TY + 92, 38, WH)             # top free-throw circle
    pyxel.rectb(206, BY - 92, 100, 92, WH)        # bottom key
    pyxel.circb(256, BY - 92, 38, WH)
    # the playable ball sits on the floor under the top hoop
    _basketball(256, 196, 9)
    # ---- BOTTOM HOOP (far end) - same upright orientation as a real basket ----
    byb = 446
    pyxel.rect(228, byb, 56, 22, WH)              # backboard
    pyxel.rectb(228, byb, 56, 22, BK)
    pyxel.rectb(244, byb + 4, 24, 14, RD)         # target square
    pyxel.rect(252, byb + 22, 8, 5, GY)           # bracket
    pyxel.circb(256, byb + 36, 13, OR)            # rim
    pyxel.circb(256, byb + 36, 12, OR)
    for ni in range(7):
        pyxel.line(244 + ni * 4, byb + 40, 246 + ni * 3,
                   byb + 54 + int(pyxel.sin(fr * 2 + ni) * 2), WH)   # net hangs down
    # the old debate trophy now lives in a special spotlit vitrine (corner)
    # ---- BACK-WALL: trophy vitrines + scoreboard + notice board (behind the foreground bleachers) ----
    _trophy_cabinet(34, 32, 92, 106)              # tall vitrine, left of scoreboard
    _trophy_cabinet(150, 84, 212, 54)             # wide vitrine below the scoreboard
    _notice_board(376, 30, 130, 108,
                  ["Ask COACH to PLAY", "A win = +1 MIND", "Supply order: RBYG", "Stay sharp."],
                  "GYM NOTICES")
    # ---- detailed LED scoreboard ----
    sbx, sby, sbw, sbh = 150, 26, 212, 54
    pyxel.rect(sbx - 3, sby - 3, sbw + 6, sbh + 6, 5); pyxel.rectb(sbx - 3, sby - 3, sbw + 6, sbh + 6, BK)
    pyxel.rect(sbx, sby, sbw, sbh, BK); pyxel.rectb(sbx, sby, sbw, sbh, GY)
    pyxel.line(sbx + sbw // 2, sby + 3, sbx + sbw // 2, sby + sbh - 14, 5)
    pyxel.rect(sbx + 10, sby + 4, 52, 10, NV); big_text_c(sbx + 36, sby + 5, "HOME", CY, 1)
    pyxel.rect(sbx + sbw // 2 + 10, sby + 4, 52, 10, NV); big_text_c(sbx + sbw // 2 + 36, sby + 5, "AWAY", CY, 1)
    sc1 = (fr // 40) % 10; sc2 = (fr // 55) % 10
    big_text_c(sbx + 53, sby + 18, f"{sc1:02d}", OR, 2)        # large seven-seg scores
    big_text_c(sbx + sbw // 2 + 53, sby + 18, f"{sc2:02d}", OR, 2)
    for bi in range(8):                                       # indicator bulbs
        on = (fr // 6 + bi) % 3 != 0
        pyxel.circ(sbx + 14 + bi * 7, sby + sbh - 18, 1, YL if on else 1)
        pyxel.circ(sbx + sbw // 2 + 14 + bi * 7, sby + sbh - 18, 1, RD if on else 1)
    pyxel.rect(sbx + 2, sby + sbh - 13, sbw - 4, 11, NV)
    bc = RD if (fr // 10) % 2 == 0 else OR
    big_text_c(sbx + sbw // 2, sby + sbh - 11, "SYNTHIA ATHLETICS - Q4 00:00", bc, 1)
    # ---- BLEACHERS (left sideline) - drawn in the FOREGROUND, over the vitrine ----
    BX, BW = 0, 54
    pyxel.rect(BX, 92, BW, 372, NV)               # stand backing
    pyxel.rectb(BX, 92, BW, 372, BK)
    pyxel.rect(BX, 92, BW, 8, 1)                  # top cap
    big_text(4, 93, "FANS", WH, 1)
    spec_head = [OR, GN, CY, YL, PC, RD, LM, OR, CY, GN, PP, WH]
    spec_body = [RD, BL, PP, GN, OR, CY, RD, BL, GN, PP, YL, BL]
    for r in range(9):
        ty2 = 104 + r * 40
        pyxel.rect(BX, ty2 + 20, BW, 7, GY); pyxel.rectb(BX, ty2 + 20, BW, 7, 5)   # seat plank
        pyxel.rect(BX, ty2 + 22, BW, 3, SV)                                        # plank top
        pyxel.rect(BX, ty2 + 27, BW, 13, 6)                                        # riser
        pyxel.line(BX, ty2 + 27, BX + BW, ty2 + 27, 5)
        pyxel.line(BX + 26, ty2 + 27, BX + 26, ty2 + 40, 5)                        # aisle line
        for c in range(2):
            sx2 = BX + 14 + c * 26
            _spectator(sx2, ty2 + 12,
                       spec_head[(r * 2 + c) % len(spec_head)],
                       spec_body[(r * 2 + c) % len(spec_body)], fr, r * 3 + c)
    pyxel.rect(BX, 458, BW, 6, GY)                # front guard rail base
    pyxel.line(BX, 100, BX + BW, 100, SV)
    for gxr in range(BX + 8, BX + BW, 18):        # vertical guard-rail posts
        pyxel.line(gxr, 452, gxr, 464, SV)
    _cctv_camera(138, 24, fr)
    _cctv_camera(374, 24, fr)
    # ---- FOREGROUND HOOP: mounted out over the court, in front of the wall ----
    pyxel.rect(250, 78, 8, 12, GY)                # short arm down to the backboard
    pyxel.rect(228, 88, 56, 24, WH); pyxel.rectb(228, 88, 56, 24, BK)   # backboard
    pyxel.rectb(244, 92, 24, 14, RD)              # target square
    pyxel.rect(252, 112, 8, 5, GY)                # rim bracket
    pyxel.circb(256, 126, 13, OR); pyxel.circb(256, 126, 12, OR)        # rim
    for ni in range(7):
        pyxel.line(244 + ni * 4, 130, 246 + ni * 3,
                   146 + int(pyxel.sin(fr * 2 + ni) * 2), WH)            # net
    # ---- SPECIAL VITRINE: the old debate trophy, spotlit in the corner ----
    _special_vitrine(446, 396, fr)
    _door(458, 200, 54, 100, "CANTEEN", OR)


def _shelf_items(ix, bd, iw, kind, fr):
    """Fill one shelf (left ix, width iw, items resting on baseline bd) with
    detailed, varied clutter so the shelving reads as packed and lived-in."""
    if kind == "boxes":
        bx = ix
        for bw, bh, bc in [(26, 26, OR), (20, 22, 4), (16, 18, OR)]:
            if bx + bw > ix + iw: break
            pyxel.rect(bx, bd - bh, bw, bh, bc); pyxel.rectb(bx, bd - bh, bw, bh, BR)
            pyxel.line(bx, bd - bh // 2, bx + bw - 1, bd - bh // 2, BR)          # flap seam
            pyxel.line(bx + bw // 2, bd - bh, bx + bw // 2, bd - bh // 2, BR)
            pyxel.rect(bx + 2, bd - bh + 3, bw - 5, 4, WH)                       # label
            bx += bw + 3
    elif kind == "cans":
        for c, cc in enumerate((SV, OR, RD, GN, BL)):
            cx0 = ix + c * 13
            if cx0 + 11 > ix + iw: break
            pyxel.rect(cx0, bd - 19, 11, 19, cc); pyxel.rectb(cx0, bd - 19, 11, 19, BK)
            pyxel.rect(cx0, bd - 19, 11, 4, GY)                                  # lid
            pyxel.rect(cx0 + 3, bd - 22, 5, 3, GY)                               # handle nub
            pyxel.rect(cx0 + 2, bd - 12, 7, 6, WH); pyxel.line(cx0 + 3, bd - 9, cx0 + 7, bd - 9, GY)
    elif kind == "jars":
        for j in range(max(1, iw // 13)):
            jx = ix + j * 13
            if jx + 11 > ix + iw: break
            pyxel.rect(jx, bd - 16, 11, 16, 12); pyxel.rectb(jx, bd - 16, 11, 16, NV)   # glass
            pyxel.rect(jx + 2, bd - 19, 7, 3, GY)                                       # lid
            for d in range(4): pyxel.pset(jx + 3 + d % 5, bd - 3 - (d % 4), YL)          # bolts inside
            pyxel.line(jx + 2, bd - 14, jx + 2, bd - 4, WH)                              # shine
    elif kind == "books":
        bx = ix
        for b in range(max(1, iw // 8)):
            bw = 7
            if bx + bw > ix + iw: break
            bc = [BR, GN, NV, 4, RD, PP][b % 6]
            pyxel.rect(bx, bd - 24, bw, 24, bc); pyxel.rectb(bx, bd - 24, bw, 24, BK)
            pyxel.line(bx + 1, bd - 20, bx + bw - 2, bd - 20, YL)
            pyxel.line(bx + 1, bd - 6, bx + bw - 2, bd - 6, YL)
            bx += bw + 1
    elif kind == "cable":
        cy0 = bd - 12
        for rr in (12, 8, 4): pyxel.circb(ix + 14, cy0, rr, GY)                  # coiled cable
        pyxel.circb(ix + 14, cy0, 12, BK)
        pyxel.line(ix + 25, cy0 + 4, ix + 31, bd, GY)
        pwx = ix + 34                                                            # power strip
        if pwx + 18 <= ix + iw:
            pyxel.rect(pwx, bd - 8, 18, 8, WH); pyxel.rectb(pwx, bd - 8, 18, 8, GY)
            for so in range(3): pyxel.rect(pwx + 2 + so * 5, bd - 6, 3, 4, BK)
    elif kind == "bottles":
        pyxel.rect(ix, bd - 22, 12, 22, BL); pyxel.rectb(ix, bd - 22, 12, 22, NV)   # spray bottle
        pyxel.rect(ix + 2, bd - 27, 8, 5, BL)
        pyxel.tri(ix + 10, bd - 26, ix + 16, bd - 24, ix + 10, bd - 22, GY)         # trigger
        pyxel.rect(ix + 3, bd - 15, 6, 8, WH)                                       # label
        jx = ix + 20                                                                # jug
        if jx + 16 <= ix + iw:
            pyxel.rect(jx, bd - 20, 16, 20, LM); pyxel.rectb(jx, bd - 20, 16, 20, GN)
            pyxel.rect(jx + 5, bd - 24, 6, 4, LM); pyxel.rect(jx + 2, bd - 13, 12, 7, WH)
    elif kind == "monitor":
        pyxel.rect(ix, bd - 26, 36, 26, 5); pyxel.rectb(ix, bd - 26, 36, 26, BK)    # broken CRT
        pyxel.rect(ix + 3, bd - 23, 30, 18, NV); pyxel.rectb(ix + 3, bd - 23, 30, 18, GY)
        pyxel.line(ix + 8, bd - 20, ix + 28, bd - 8, BK); pyxel.line(ix + 28, bd - 20, ix + 8, bd - 8, BK)
        kx = ix + 40                                                                # keyboard
        if kx + 20 <= ix + iw:
            pyxel.rect(kx, bd - 6, 20, 6, GY); pyxel.rectb(kx, bd - 6, 20, 6, BK)
            for ky in range(2):
                for kk in range(5): pyxel.pset(kx + 2 + kk * 4, bd - 5 + ky * 3, 5)
    else:  # "cloth" - folded rags + a hard hat
        for f, fc in enumerate((RD, CY, YL)):
            pyxel.rect(ix, bd - 5 - f * 5, 24, 5, fc); pyxel.rectb(ix, bd - 5 - f * 5, 24, 5, BK)
        hx = ix + 30
        if hx + 20 <= ix + iw:
            pyxel.elli(hx + 8, bd - 6, 20, 8, YL); pyxel.ellib(hx + 8, bd - 6, 20, 8, OR)
            pyxel.elli(hx + 8, bd - 10, 12, 8, YL); pyxel.ellib(hx + 8, bd - 10, 12, 8, OR)


def _supply_shelf(x, y, w, h, fr):
    """An industrial steel shelving unit, packed full and clearly readable."""
    pyxel.rect(x + 3, y + 3, w, h, BK)                              # drop shadow
    pyxel.rect(x, y, w, h, 1); pyxel.rectb(x, y, w, h, BK)          # dark recess behind shelves
    pyxel.rect(x, y, 4, h, 5); pyxel.rect(x + w - 4, y, 4, h, 5)    # steel uprights
    pyxel.rectb(x, y, 4, h, BK); pyxel.rectb(x + w - 4, y, 4, h, BK)
    for bolt in range(y + 8, y + h - 4, 38):                        # upright bolt holes
        pyxel.pset(x + 2, bolt, GY); pyxel.pset(x + w - 3, bolt, GY)
    nsh = max(2, h // 60)
    step = (h - 6) // nsh
    kinds = ["boxes", "cans", "jars", "books", "cable", "bottles", "monitor", "cloth"]
    for s in range(nsh):
        top = y + 3 + s * step
        bd = top + step - 5                                         # shelf board top (items rest here)
        _shelf_items(x + 7, bd, w - 14, kinds[(s + x // 29) % len(kinds)], fr)
        pyxel.rect(x + 2, bd, w - 4, 4, GY); pyxel.rectb(x + 2, bd, w - 4, 4, BK)   # steel board
        pyxel.line(x + 3, bd, x + w - 5, bd, SV)                    # lit front lip
        pyxel.line(x + 3, bd + 3, x + w - 5, bd + 3, 1)            # under-shadow


def _mop_bucket(x, y):
    """A yellow wheeled mop bucket with a wringer, dirty water and a leaning mop."""
    pyxel.elli(x + 6, y + 30, 40, 7, BK)                            # floor shadow
    # mop leaning out of the bucket (drawn first, behind)
    pyxel.line(x + 16, y - 2, x + 34, y - 36, BR); pyxel.line(x + 17, y - 2, x + 35, y - 36, OR)
    pyxel.elli(x + 10, y - 4, 16, 11, SV); pyxel.ellib(x + 10, y - 4, 16, 11, GY)   # mop head
    for ms in range(8): pyxel.line(x + 4 + ms * 3, y - 2, x + 2 + ms * 3, y + 7, GY)  # strands
    # bucket body (tapered) + rim
    pyxel.rect(x, y + 4, 30, 24, YL); pyxel.rectb(x, y + 4, 30, 24, OR)
    pyxel.line(x, y + 4, x + 29, y + 4, WH)                         # rim highlight
    pyxel.rect(x + 2, y + 6, 26, 5, 6)                              # dirty water
    pyxel.line(x + 4, y + 8, x + 12, y + 8, SV); pyxel.line(x + 16, y + 9, x + 24, y + 9, SV)
    pyxel.rectb(x + 1, y + 12, 28, 2, OR)                           # ribs
    pyxel.rectb(x + 1, y + 18, 28, 2, OR)
    # wringer press on top-right
    pyxel.rect(x + 19, y - 8, 13, 14, GY); pyxel.rectb(x + 19, y - 8, 13, 14, BK)
    pyxel.line(x + 21, y - 6, x + 30, y - 6, SV)
    pyxel.rect(x + 24, y - 12, 4, 5, GY)                            # handle
    # castors
    pyxel.circ(x + 5, y + 30, 3, BK); pyxel.circ(x + 5, y + 30, 1, GY)
    pyxel.circ(x + 25, y + 30, 3, BK); pyxel.circ(x + 25, y + 30, 1, GY)


def _toolbox(x, y):
    """A red steel toolbox with a handle, latches and a top tray of tools."""
    pyxel.elli(x + 22, y + 26, 30, 6, BK)                           # shadow
    # body
    pyxel.rect(x, y + 8, 44, 18, RD); pyxel.rectb(x, y + 8, 44, 18, BK)
    pyxel.line(x + 1, y + 9, x + 42, y + 9, OR)                     # lit edge
    pyxel.rect(x + 2, y + 14, 40, 2, 8)                            # body seam
    pyxel.rect(x + 6, y + 18, 10, 6, GY); pyxel.rectb(x + 6, y + 18, 10, 6, BK)   # latch
    pyxel.rect(x + 28, y + 18, 10, 6, GY); pyxel.rectb(x + 28, y + 18, 10, 6, BK)
    # lifted lid / tray on top
    pyxel.rect(x + 2, y + 2, 40, 8, 8); pyxel.rectb(x + 2, y + 2, 40, 8, BK)      # open tray
    pyxel.rect(x + 4, y + 4, 36, 4, 4)                                            # tray interior
    # carry handle
    pyxel.line(x + 12, y, x + 32, y, GY); pyxel.line(x + 12, y, x + 14, y + 2, GY)
    pyxel.line(x + 32, y, x + 30, y + 2, GY)
    # tools poking out of the tray
    pyxel.line(x + 8, y + 6, x + 16, y + 1, SV); pyxel.rect(x + 6, y + 5, 4, 3, YL)   # screwdriver
    pyxel.line(x + 22, y + 6, x + 22, y, GY); pyxel.circb(x + 22, y - 1, 2, GY)        # wrench
    pyxel.line(x + 30, y + 6, x + 36, y + 1, OR); pyxel.line(x + 30, y + 6, x + 34, y + 2, OR)  # pliers


def draw_supply(fr, jammer_on=False):
    # ---- walls: concrete with an explicit outline so the room reads clearly ----
    pyxel.rect(0, 0, W, 18, BK)                          # ceiling
    pyxel.rect(0, 18, W, 128, 5)                         # back wall
    for sxn in range(0, W, 7):                           # concrete speckle
        pyxel.pset(sxn, 26 + (sxn * 13 % 100), GY)
    pyxel.rect(0, 142, W, 4, GY)
    pyxel.rect(0, 146, W, 4, BK)
    pyxel.line(0, FY, W, FY, GY)
    # explicit room outline + thick side walls
    pyxel.rect(0, 18, 6, H - 18, BK); pyxel.rect(W - 6, 18, 6, H - 18, BK)
    pyxel.line(6, 18, 6, H, GY); pyxel.line(W - 7, 18, W - 7, H, GY)
    pyxel.rectb(0, 0, W, H, BK)
    # bare bulb
    pyxel.line(200, 0, 200, 18, GY); pyxel.circ(200, 22, 8, YL); pyxel.circb(200, 22, 8, GY)
    for gd in range(8):
        a = math.radians(gd * 45)
        pyxel.line(200, 22, 200 + int(math.cos(a) * 12), 22 + int(math.sin(a) * 12), YL)
    # concrete floor
    for gy in range(FY, H, 16):
        for gx in range(0, W, 16):
            c = [5, GY, 5][(gx * 3 + gy * 7) % 3]
            pyxel.rect(gx, gy, 16, 16, c)
    for gy in range(FY, H, 16): pyxel.line(0, gy, W, gy, BK)
    for gx in range(0, W, 16): pyxel.line(gx, FY, gx, H, BK)
    _grain(6, 1, 43)
    pyxel.line(190, 170, 214, 210, BK)                  # floor cracks
    pyxel.line(330, 300, 360, 360, BK); pyxel.line(96, 420, 120, 470, BK)
    _floor_grime(20, W - 20, 41, 1, GY, traffic=False)  # grime, oil stains, scuffs
    _light_pool(200, FY + 60, 120, 90, GY)              # pool of light under the bare bulb
    _light_pool(200, FY + 60, 70, 52, 6)
    # ---- SHELVING: two big, clearly-readable packed steel units ----
    _supply_shelf(452, 90, 54, 386, fr)                 # full right wall (main unit)
    _supply_shelf(36, 28, 104, 114, fr)                 # back-wall upper-left unit

    # ---- WIRE-ORDER poster pinned on the back wall ----
    dgx, dgy, dgw, dgh = 156, 36, 78, 90
    pyxel.rect(dgx + 2, dgy + 2, dgw, dgh, BK)                          # shadow
    pyxel.rect(dgx, dgy, dgw, dgh, WH); pyxel.rectb(dgx, dgy, dgw, dgh, GY)
    pyxel.line(dgx + 1, dgy + 1, dgx + dgw - 2, dgy + 1, SV)            # paper sheen
    for pxp in (dgx + 7, dgx + dgw - 8):                               # corner pins
        pyxel.circ(pxp, dgy + 4, 2, RD); pyxel.pset(pxp, dgy + 3, PK)
    pyxel.rect(dgx, dgy, dgw, 14, NV); big_text(dgx + 6, dgy + 4, "WIRE ORDER", CY, 1)
    for i, (num, wc, nm) in enumerate([("1", RD, "RED"), ("2", BL, "BLUE"),
                                       ("3", YL, "YEL"), ("4", GN, "GRN")]):
        ry2 = dgy + 20 + i * 16
        big_text(dgx + 6, ry2 + 3, num, BK, 1)
        pyxel.rect(dgx + 16, ry2, 16, 12, wc); pyxel.rectb(dgx + 16, ry2, 16, 12, BK)
        big_text(dgx + 38, ry2 + 3, nm, BK, 1)

    # ---- WIRE PANEL: a wall-mounted electrical panel with cables hanging out ----
    px0, py0, pw0, ph0 = 286, 30, 154, 110
    pyxel.rect(px0 + pw0 // 2 - 2, 18, 4, py0 - 16, GY)                 # conduit up to ceiling
    pyxel.rectb(px0 + pw0 // 2 - 2, 18, 4, py0 - 16, BK)
    pyxel.rect(px0 - 16, py0 + 6, 16, ph0 - 12, 5)                      # hinged door (open left)
    pyxel.rectb(px0 - 16, py0 + 6, 16, ph0 - 12, BK)
    pyxel.line(px0 - 10, py0 + 12, px0 - 10, py0 + ph0 - 12, SV)
    pyxel.rect(px0, py0, pw0, ph0, GY); pyxel.rectb(px0, py0, pw0, ph0, BK)   # cabinet
    pyxel.line(px0 + 1, py0 + 1, px0 + pw0 - 2, py0 + 1, SV)
    pyxel.rect(px0 + 6, py0 + 6, pw0 - 12, ph0 - 12, NV); pyxel.rectb(px0 + 6, py0 + 6, pw0 - 12, ph0 - 12, BL)
    pyxel.rect(px0 + 8, py0 + 8, pw0 - 16, 12, YL); pyxel.text(px0 + 12, py0 + 10, "! MAINS PANEL", BK)
    gc = LM if (fr // 8) % 2 == 0 else GN
    pyxel.text(px0 + 16, py0 + 24, "[SPACE to rewire]", gc)
    for i, (wc, wn) in enumerate([(RD, "R"), (BL, "B"), (YL, "Y"), (GN, "G")]):
        wx2 = px0 + 18 + i * 32; wy2 = py0 + 40
        pyxel.rect(wx2 + 4, wy2 - 8, 14, 8, 5); pyxel.rectb(wx2 + 4, wy2 - 8, 14, 8, BK)   # terminal block
        pyxel.line(wx2 + 11, wy2 - 8, wx2 + 11, wy2, wc)
        pyxel.rect(wx2, wy2, 22, 48, wc); pyxel.rectb(wx2, wy2, 22, 48, WH)                # wire
        pyxel.text(wx2 + 8, wy2 + 21, wn, WH)
        pyxel.circ(wx2 + 11, wy2 + 48, 3, GY); pyxel.circb(wx2 + 11, wy2 + 48, 3, BK)      # screw terminal
    # loose cables spilling out of the bottom and hanging down the wall
    for ci, (cox, ccol, ln) in enumerate([(px0 + 22, RD, 22), (px0 + 54, YL, 30),
                                          (px0 + 96, GN, 16), (px0 + 126, BL, 26)]):
        for t in range(ln):
            xx = cox + int(math.sin(t * 0.4 + ci) * 2)
            yy = py0 + ph0 - 2 + t
            pyxel.pset(xx, yy, ccol); pyxel.pset(xx + 1, yy, NV)
        pyxel.rect(cox - 1, py0 + ph0 - 2 + ln, 4, 3, OR)              # stripped copper end

    # ---- WORKBENCH: a proper table (visible top surface, four legs) ----
    wbx, wby, wbw, wbh = 70, 350, 180, 40
    pyxel.elli(wbx + 16, wby + wbh + 9, wbw - 32, 9, BK)               # contact shadow
    for lx2 in (wbx + 18, wbx + wbw - 22):                             # back legs
        pyxel.rect(lx2, wby + 4, 4, 14, 1)
    for lx2 in (wbx + 6, wbx + wbw - 11):                              # front legs
        pyxel.rect(lx2, wby + wbh - 3, 5, 22, 4); pyxel.rectb(lx2, wby + wbh - 3, 5, 22, BK)
    _wood_slab(wbx, wby, wbw, wbh)
    pyxel.line(wbx + 3, wby + wbh - 5, wbx + wbw - 4, wby + wbh - 5, 1)   # apron shadow
    # the JAMMER device on the bench - only ALIVE once the wires are connected
    jx, jy = wbx + 16, wby + 6
    pyxel.rect(jx, jy, 60, 30, 1); pyxel.rectb(jx, jy, 60, 30, BL)        # chassis
    pyxel.line(jx + 1, jy + 1, jx + 58, jy + 1, BL)
    pyxel.rect(jx + 4, jy + 4, 38, 12, NV); pyxel.rectb(jx + 4, jy + 4, 38, 12, BL)   # readout
    if jammer_on:
        pyxel.text(jx + 8, jy + 7, "JAMMER", GN)
    else:
        for dl in range(jx + 8, jx + 39, 7):                             # dead readout dashes
            pyxel.line(dl, jy + 10, dl + 3, jy + 10, 5)
    led = (GN if (fr // 6) % 2 == 0 else LM) if jammer_on else RD
    pyxel.rect(jx + 45, jy + 4, 11, 12, BK); pyxel.rectb(jx + 45, jy + 4, 11, 12, 5)  # LED bay
    pyxel.circ(jx + 50, jy + 8, 2, led)
    pyxel.text(jx + 47, jy + 11, "ON" if jammer_on else "OF", led)
    pyxel.circ(jx + 11, jy + 23, 3, GY); pyxel.circb(jx + 11, jy + 23, 3, BK)         # dials
    pyxel.line(jx + 11, jy + 23, jx + 13, jy + 21, SV)
    pyxel.circ(jx + 24, jy + 23, 3, GY); pyxel.circb(jx + 24, jy + 23, 3, BK)
    pyxel.line(jx + 24, jy + 23, jx + 24, jy + 20, SV)
    pyxel.rect(jx + 35, jy + 20, 9, 7, GY); pyxel.rectb(jx + 35, jy + 20, 9, 7, BK)   # power toggle
    pyxel.rect(jx + (39 if jammer_on else 36), jy + 21, 3, 5, led)
    pyxel.line(jx + 54, jy, jx + 60, jy - 16, GY)                        # antenna
    pyxel.circ(jx + 60, jy - 16, 1, led if jammer_on else 5)
    if jammer_on:                                                       # signal waves only when wired
        for wv in range(1, 4):
            litw = (fr // 6) % 3 >= wv - 1
            pyxel.ellib(jx + 60 - wv * 5, jy - 16 - wv * 3, wv * 10, wv * 6, GN if litw else 1)
    else:                                                               # an unconnected wire dangles loose
        pyxel.line(jx, jy + 18, jx - 9, jy + 26, RD)
        pyxel.line(jx - 9, jy + 26, jx - 5, jy + 30, RD); pyxel.pset(jx - 5, jy + 30, OR)
    # a toolbox + a coil of wire share the bench
    _toolbox(wbx + 104, wby - 14)
    pyxel.circb(wbx + 86, wby + 20, 7, OR); pyxel.circb(wbx + 86, wby + 20, 4, OR)    # wire coil
    pyxel.circb(wbx + 86, wby + 20, 7, BR)
    # ---- PARTS CRATE on the floor ----
    pbx, pby = 296, 398
    pyxel.elli(pbx + 4, pby + 40, 56, 7, BK)
    pyxel.rect(pbx, pby, 60, 40, OR); pyxel.rectb(pbx, pby, 60, 40, BR)               # crate
    pyxel.rect(pbx, pby, 60, 8, 4)                                                    # lid lip
    for cm in range(1, 4): pyxel.line(pbx + cm * 15, pby, pbx + cm * 15, pby + 40, 4)  # slats
    pyxel.rect(pbx + 14, pby + 13, 32, 14, WH); pyxel.rectb(pbx + 14, pby + 13, 32, 14, GY)  # label
    pyxel.text(pbx + 17, pby + 15, "SPARE", BK); pyxel.text(pbx + 17, pby + 22, "PARTS", BK)
    for sp, spc in enumerate((SV, OR, GY, RD)):                                       # parts poking out
        pyxel.circ(pbx + 8 + sp * 6, pby - 2, 2, spc)
    _mop_bucket(16, 430)
    _door(0, 200, 54, 100, "CANTEEN", OR)


def _award_frame(x, y, w, h, year):
    """Ornate gold-framed certificate with a wax seal."""
    pyxel.rect(x - 2, y - 2, w + 4, h + 4, OR); pyxel.rectb(x - 2, y - 2, w + 4, h + 4, YL)
    pyxel.rect(x, y, w, h, YL); pyxel.rectb(x, y, w, h, OR)
    pyxel.rect(x + 4, y + 4, w - 8, h - 8, WH); pyxel.rectb(x + 4, y + 4, w - 8, h - 8, GY)
    pyxel.text(x + 10, y + 10, "PRINCIPAL VOSS", GY)
    pyxel.text(x + 10, y + 22, "EXCELLENCE IN", GY)
    pyxel.text(x + 10, y + 32, "COMPLIANCE", GY)
    pyxel.text(x + 10, y + 46, str(year), SV)
    pyxel.circ(x + w - 18, y + h - 20, 7, RD); pyxel.circb(x + w - 18, y + h - 20, 7, 8)
    pyxel.text(x + w - 21, y + h - 23, "*", YL)
    pyxel.tri(x + w - 22, y + h - 14, x + w - 14, y + h - 14, x + w - 18, y + h - 4, RD)


def _exec_chair(cx, y):
    """High-back executive chair seen behind the desk (backrest = Stuhllehne)."""
    pyxel.rect(cx - 26, y + 20, 6, 26, 1)                 # wings
    pyxel.rect(cx + 20, y + 20, 6, 26, 1)
    pyxel.rect(cx - 24, y, 48, 64, 1); pyxel.rectb(cx - 24, y, 48, 64, BK)   # backrest
    pyxel.rect(cx - 20, y + 4, 40, 56, 5)                 # padding
    pyxel.line(cx - 20, y + 4, cx + 19, y + 4, 6)         # highlight
    for k in range(4):
        pyxel.line(cx - 16, y + 12 + k * 12, cx + 16, y + 12 + k * 12, 1)   # tufting
    pyxel.rect(cx - 4, y + 60, 8, 8, BK)                  # post


def _visitor_chair(x, y):
    """A guest chair facing the desk: seat, slatted backrest, arms, legs."""
    pyxel.rect(x + 2, y - 20, 36, 22, OR); pyxel.rectb(x + 2, y - 20, 36, 22, BR)   # back
    for k in range(3): pyxel.line(x + 9 + k * 10, y - 18, x + 9 + k * 10, y - 2, BR)
    pyxel.rect(x, y, 40, 16, OR); pyxel.rectb(x, y, 40, 16, BR)                     # seat
    pyxel.line(x + 2, y + 2, x + 38, y + 2, YL)
    pyxel.rect(x - 3, y - 6, 5, 18, BR); pyxel.rect(x + 38, y - 6, 5, 18, BR)       # arms
    for lx in (x + 2, x + 34): pyxel.rect(lx, y + 16, 4, 12, 1)                     # legs


def _filing_cabinet(x, y):
    pyxel.rect(x, y, 40, 72, 5); pyxel.rectb(x, y, 40, 72, GY)
    pyxel.line(x + 2, y + 2, x + 37, y + 2, 6)
    for k in range(3):
        pyxel.rect(x + 4, y + 6 + k * 22, 32, 16, 6); pyxel.rectb(x + 4, y + 6 + k * 22, 32, 16, 1)
        pyxel.rect(x + 16, y + 11 + k * 22, 8, 4, 1)


def _flag(x, y, fr):
    """Standing SYNTHIA banner on a pole."""
    pyxel.rect(x, y, 3, 96, GY); pyxel.circ(x + 1, y - 1, 3, YL)
    pyxel.rect(x + 3, y, 44, 54, NV); pyxel.rectb(x + 3, y, 44, 54, BL)
    draw_eye(x + 25, y + 24, 9, fr, scan=False)


def _seated_voss(cx, fr):
    """Principal Voss seated - head and shoulders show above the desk (drawn
    before the desk so the desk hides his lower body)."""
    blink = (fr + 9 * 53) % (150 + 9 * 13) < 7
    pyxel.rect(cx - 18, 166, 36, 18, 5)                  # grey suit shoulders
    pyxel.rectb(cx - 18, 166, 36, 18, 1)
    pyxel.tri(cx - 4, 166, cx + 4, 166, cx, 180, WH)     # shirt V
    pyxel.rect(cx - 2, 166, 4, 12, RD)                   # tie
    pyxel.rect(cx - 22, 174, 7, 12, 5); pyxel.rect(cx + 15, 174, 7, 12, 5)   # arms
    pyxel.rect(cx - 8, 150, 16, 18, PC)                  # face
    pyxel.rectb(cx - 8, 150, 16, 18, 4)
    pyxel.rect(cx - 9, 147, 18, 6, SV)                   # silver hair top
    pyxel.rect(cx - 9, 150, 3, 7, SV); pyxel.rect(cx + 6, 150, 3, 7, SV)
    if blink:
        pyxel.line(cx - 6, 158, cx - 2, 158, 1); pyxel.line(cx + 2, 158, cx + 6, 158, 1)
    else:
        pyxel.rect(cx - 6, 156, 4, 3, WH); pyxel.rect(cx + 2, 156, 4, 3, WH)  # glasses
        pyxel.pset(cx - 4, 157, 1); pyxel.pset(cx + 4, 157, 1)
    pyxel.line(cx - 2, 157, cx + 2, 157, 1)              # glasses bridge
    pyxel.line(cx - 4, 163, cx + 4, 163, 4)              # flat mouth


def draw_principal(fr):
    # ---- top header band so the wall art is not cut off ----
    pyxel.rect(0, 0, W, 28, 4); pyxel.rect(0, 26, W, 3, BR)
    big_text_c(W // 2, 8, "OFFICE OF THE PRINCIPAL", YL, 1)
    # wood-panel wall below the header
    for i in range(10):
        wx2 = i * 52
        pyxel.rect(wx2, 29, 50, 113, OR if i % 2 == 0 else 4)
        pyxel.rectb(wx2, 29, 50, 113, BR)
    pyxel.rect(0, 142, W, 4, GY); pyxel.rect(0, 146, W, 4, BR)
    pyxel.line(0, FY, W, FY, 4)
    # chandelier
    pyxel.line(256, 28, 256, 38, GY); pyxel.rect(244, 38, 24, 7, YL); pyxel.rectb(244, 38, 24, 7, OR)
    # detailed framed awards / posters
    for i in range(3):
        _award_frame(24 + i * 148, 44, 112, 80, 2012 + i * 3)
    _flag(446, 40, fr)
    # ---- cool polished marble tile (contrasts with the warm wood walls) ----
    for gy in range(FY, H, 32):
        for gx in range(0, W, 32):
            c = 6 if ((gx // 32 + gy // 32) % 2 == 0) else 5    # SV / GY checker
            pyxel.rect(gx, gy, 32, 32, c)
    for gy in range(FY, H, 32): pyxel.line(0, gy, W, gy, NV)
    for gx in range(0, W, 32):  pyxel.line(gx, FY, gx, H, NV)
    _tilebevel(32, 7, 1)                                        # bright top-left, dark edge + sheen
    _grain(7, 1, 61)
    pyxel.rect(118, 250, 276, 150, 8); pyxel.rectb(118, 250, 276, 150, YL)
    pyxel.rectb(122, 254, 268, 142, OR)
    pyxel.elli(196, 296, 124, 60, RD); pyxel.ellib(196, 296, 124, 60, YL)
    # ---- principal's high-back chair, then Voss seated, then the desk in front ----
    _exec_chair(256, 148)
    _seated_voss(256, fr)
    # ---- desk: smaller, fancy, detailed top (drawn over his lower body) ----
    dx, dy, dw, dh = 168, 176, 176, 58
    _wood_slab(dx, dy, dw, dh)
    for di in range(2):
        drx = dx + 8 + di * 88; dry = dy + dh
        if di == 1:                                            # the open, empty drawer (interactable)
            pyxel.rect(drx, dry, 76, 30, BK)                   # dark cavity into the desk
            pyxel.rectb(drx, dry, 76, 30, BK)
            pyxel.rect(drx + 3, dry + 2, 70, 9, 1)             # empty interior, faint shadow
            pyxel.rect(drx, dry + 16, 76, 16, 4); pyxel.rectb(drx, dry + 16, 76, 16, BK)  # pulled-out front
            pyxel.line(drx + 1, dry + 17, drx + 74, dry + 17, OR)   # lit top edge of the panel
            pyxel.rect(drx + 33, dry + 22, 10, 3, GY); pyxel.rectb(drx + 33, dry + 22, 10, 3, BK)  # handle
        else:
            pyxel.rect(drx, dry, 76, 30, 4); pyxel.rectb(drx, dry, 76, 30, BK)
            pyxel.circ(drx + 38, dry + 15, 3, GY); pyxel.circb(drx + 38, dry + 15, 3, BK)
    pyxel.rect(dx + 44, dy + dh - 12, 90, 12, GY); pyxel.rectb(dx + 44, dy + dh - 12, 90, 12, SV)
    pyxel.text(dx + 49, dy + dh - 9, "PRINCIPAL VOSS", WH)
    # desk-top items: a proper monitor, banker lamp, papers, pen pot, mug
    _desk_monitor(dx + 4, dy - 30, fr)
    pyxel.rect(dx + dw - 24, dy - 4, 4, 10, GY)
    pyxel.rect(dx + dw - 32, dy - 12, 22, 9, GN); pyxel.rectb(dx + dw - 32, dy - 12, 22, 9, 3)
    pyxel.rect(dx + dw - 30, dy - 3, 18, 3, YL)
    pyxel.rect(dx + 72, dy + 6, 24, 16, WH); pyxel.rectb(dx + 72, dy + 6, 24, 16, GY)
    pyxel.line(dx + 76, dy + 10, dx + 92, dy + 10, SV); pyxel.line(dx + 76, dy + 14, dx + 92, dy + 14, SV)
    pyxel.rect(dx + 102, dy + 4, 9, 12, NV); pyxel.rectb(dx + 102, dy + 4, 9, 12, BK)
    pyxel.line(dx + 105, dy - 2, dx + 105, dy + 4, RD); pyxel.line(dx + 108, dy - 1, dx + 108, dy + 4, BL)
    pyxel.circ(dx + 124, dy + 13, 4, WH); pyxel.circb(dx + 124, dy + 13, 4, GY)
    # ---- guest chairs facing the desk ----
    _visitor_chair(150, 286)
    _visitor_chair(330, 286)
    # ---- extra detail ----
    _filing_cabinet(20, 270)
    _big_plant(70, 250, fr)
    _big_plant(398, 250, fr)
    _door(229, 412, 54, 100, "LIBRARY", BR)


def _cobweb(cx, cy, sx, sy, r, col=5, strands=5):
    """A corner cobweb: radial strands anchored at (cx,cy) fanning into the
    room, with sagging cross-threads between them so it reads as a real web.
    sx/sy are the direction signs (e.g. +1,+1 for a top-left corner)."""
    import math as _m
    ang0, ang1 = 0.08, 1.49                       # fan from near-wall to near-floor
    pts = []
    for s in range(strands):
        a = ang0 + (ang1 - ang0) * s / (strands - 1)
        ex = cx + sx * int(_m.cos(a) * r)
        ey = cy + sy * int(_m.sin(a) * r)
        pyxel.line(cx, cy, ex, ey, col)
        pts.append((a, ex, ey))
    for ring in (0.42, 0.72, 0.96):               # three sagging cross-rings
        prev = None
        for a, ex, ey in pts:
            px = cx + sx * int(_m.cos(a) * r * ring)
            py = cy + sy * int(_m.sin(a) * r * ring) + 2  # slight sag
            if prev is not None:
                pyxel.line(prev[0], prev[1], px, py, col)
            prev = (px, py)


def draw_basement(fr):
    # ---- ceiling + flickering strip lights ----
    pyxel.rect(0, 0, W, H, BK)
    pyxel.rect(0, 6, W, 8, 5); pyxel.rectb(0, 6, W, 8, GY)
    for i in range(3):
        lx2 = 80 + i * 160
        flicker = (fr // 2 + i * 17) % 20
        lc = WH if flicker < 15 else (GY if flicker < 18 else BK)
        pyxel.rect(lx2, 16, 80, 4, GY); pyxel.rect(lx2 + 2, 17, 76, 2, lc)
    WALL_B = 150
    def _crack(px, py, segs, col=BK):
        for dx, dy in segs:
            pyxel.line(px, py, px + dx, py + dy, col); px += dx; py += dy
    # ---- BACK WALL (clearly its own band) ----
    pyxel.rect(0, 14, W, WALL_B - 14, 1)
    for by3 in range(20, WALL_B, 26):
        for bx3 in range(0, W, 52):
            pyxel.rectb(bx3 + (by3 // 26 % 2) * 26, by3, 52, 26, NV)
    _crack(118, 18, [(6, 18), (-4, 16), (7, 14), (-3, 10)])     # wall cracks
    _crack(135, 48, [(5, 10), (4, -6), (6, 12)])                # little branch
    _crack(470, 16, [(-5, 20), (6, 16), (-4, 18), (5, 12)])
    _crack(454, 56, [(-6, 8), (4, 10)])
    _crack(322, 20, [(4, 16), (-5, 14), (6, 10)])
    _crack(60, 24, [(4, 14), (-3, 12), (5, 16)])
    _crack(250, 26, [(-4, 12), (5, 10), (-2, 14)])
    # damp stains seeping down the wall
    for sxn, syn, sw in ((40, 60, 30), (300, 70, 40), (480, 60, 24)):
        for d in range(sw):
            if (sxn * 7 + d * 13) % 5 == 0:
                pyxel.pset(sxn + d % 12, syn + (d * 17 % 70), NV)
    # falling binary code - CLIPPED TO THE WALL ONLY (a little 'matrix' rain)
    for col2 in range(10):
        cx2 = 16 + col2 * 50
        for k in range(4):
            yy = ((fr * 2 + col2 * 40 + k * 34) % (WALL_B - 22)) + 18
            ch2 = "01"[(fr // 4 + col2 + k) % 2]
            shade = GN if k == 0 else (3 if k < 2 else 1)
            pyxel.text(cx2, yy, ch2, shade)
    # cobwebs: thick in both upper corners + a hanging web with a tiny spider
    _cobweb(0, 14, 1, 1, 60, strands=6)
    _cobweb(W, 14, -1, 1, 60, strands=6)
    _cobweb(0, 96, 1, 1, 34, strands=4)
    _cobweb(W, 110, -1, 1, 30, strands=4)
    # a single thread dangling a spider from the ceiling
    spx = 196; spy = 14 + 40 + int((fr // 6) % 6)
    pyxel.line(spx, 14, spx, spy, 5)
    pyxel.elli(spx - 2, spy, 4, 5, BK); pyxel.pset(spx - 1, spy + 1, RD)
    for legy in (spy + 1, spy + 3):                              # spider legs
        pyxel.line(spx - 4, legy, spx - 1, legy + 1, 5)
        pyxel.line(spx + 1, legy + 1, spx + 4, legy, 5)
    # ---- clear WALL / FLOOR boundary: trim + baseboard + shadow ----
    pyxel.rect(0, WALL_B - 5, W, 5, GY)
    pyxel.rect(0, WALL_B, W, 4, 5)
    pyxel.rect(0, WALL_B + 4, W, 2, BK)
    # ---- FLOOR (dark damp concrete) ----
    pyxel.rect(0, FY + 6, W, H - FY - 6, 1)
    for gy in range(FY + 6, H, 4):
        for gx in range(0, W, 4):
            hsh = (gx * 73 + gy * 149) & 31
            if hsh == 0:    pyxel.pset(gx, gy, 5)
            elif hsh == 9:  pyxel.pset(gx + 1, gy, BK)
            elif hsh == 20: pyxel.pset(gx, gy + 1, NV)
    for gy in range(FY + 48, H, 98):
        pyxel.line(0, gy, W, gy, BK); pyxel.line(0, gy + 1, W, gy + 1, 5)
    _crack(150, 332, [(8, 10), (-4, 12), (10, 8), (-3, 14), (7, 9)])    # floor cracks
    _crack(168, 354, [(-6, 8), (5, 10)])                                # branch
    _crack(366, 360, [(-6, 12), (8, 10), (-4, 12), (6, 10), (-3, 12)])
    _crack(220, 440, [(10, 8), (-5, 12), (8, 10), (-4, 10)])
    _crack(440, 420, [(-7, 10), (5, 12), (-4, 10), (6, 8)])
    _crack(330, 470, [(8, -10), (-5, 8), (7, -6)])
    # a damp puddle reflecting the strip light, where the crates used to be
    pyxel.elli(420, 392, 54, 16, BK)
    pyxel.ellib(420, 392, 54, 16, 5)
    pyxel.line(432, 396, 458, 396, NV)

    # ---- SYNTHIA mainframe: a real server RACK standing on the floor ----
    rx0, ry0, rw0, rh0 = 8, 158, 96, 156
    pyxel.elli(rx0 - 4, ry0 + rh0 - 6, rw0 + 16, 16, BK)        # contact shadow on floor
    pyxel.rect(rx0 + rw0, ry0 + 5, 7, rh0 - 5, BK)             # right-side depth
    pyxel.rect(rx0, ry0, rw0, rh0, 1); pyxel.rectb(rx0, ry0, rw0, rh0, GY)
    pyxel.rectb(rx0 + 1, ry0 + 1, rw0 - 2, rh0 - 2, 5)         # inner metal bevel
    # vented top with two spinning cooling fans
    pyxel.rect(rx0 + 2, ry0 + 2, rw0 - 4, 13, BK)
    for fcx in (rx0 + 26, rx0 + 70):
        pyxel.circb(fcx, ry0 + 8, 6, GY)
        fa = (fr * 16) % 360
        for sp in range(0, 360, 90):
            aa = math.radians(sp + fa)
            pyxel.line(fcx, ry0 + 8, fcx + int(math.cos(aa) * 5),
                       ry0 + 8 + int(math.sin(aa) * 5), SV)
    # top status LCD with scanning cursor
    pyxel.rect(rx0 + 4, ry0 + 17, rw0 - 8, 22, BK); pyxel.rectb(rx0 + 4, ry0 + 17, rw0 - 8, 22, GN)
    pyxel.text(rx0 + 8, ry0 + 20, "SYNTHIA", GN)
    pyxel.text(rx0 + 8, ry0 + 28, "MAINFRAME", GN)
    pyxel.pset(rx0 + 8 + (fr % (rw0 - 22)), ry0 + 36, LM)
    # four server blades: drive slots + blinking LEDs + activity bar + handle
    for u in range(4):
        uy2 = ry0 + 43 + u * 24
        pyxel.rect(rx0 + 4, uy2, rw0 - 8, 21, NV); pyxel.rectb(rx0 + 4, uy2, rw0 - 8, 21, BK)
        pyxel.rect(rx0 + 5, uy2 + 1, rw0 - 10, 1, 5)           # lit top edge
        for sbi in range(2):                                   # drive-bay slots
            sby = uy2 + 4 + sbi * 8
            pyxel.rect(rx0 + 8, sby, 18, 6, BK); pyxel.rectb(rx0 + 8, sby, 18, 6, GY)
            pyxel.line(rx0 + 10, sby + 3, rx0 + 22, sby + 3, 5)
        for li2 in range(6):                                   # status LEDs
            lc3 = (GN, GN, RD, GN, YL, GN)[li2]
            on = (fr // 4 + u * 2 + li2) % 5 != 0
            pyxel.rect(rx0 + 31 + li2 * 6, uy2 + 5, 4, 4, lc3 if on else 1)
        bw3 = 2 + (fr // 3 + u * 11) % (rw0 - 40)              # activity bar
        pyxel.rect(rx0 + 31, uy2 + 13, min(bw3, rw0 - 42), 3, GN)
        pyxel.rect(rx0 + rw0 - 12, uy2 + 4, 5, 13, GY)         # pull handle
        pyxel.rectb(rx0 + rw0 - 12, uy2 + 4, 5, 13, BK)
    # DO NOT TOUCH hazard sticker
    hzx, hzy = rx0 + 14, ry0 + rh0 - 15
    pyxel.rect(hzx, hzy, 66, 12, YL); pyxel.rectb(hzx, hzy, 66, 12, BK)
    for hch in range(0, 66, 8):
        pyxel.tri(hzx + hch, hzy, hzx + hch + 4, hzy, hzx + hch, hzy + 3, BK)
    pyxel.text(hzx + 5, hzy + 4, "DO NOT TOUCH", RD)
    # ---- cables hanging out of the rack ----
    # messy bundle spilling from a side port, drooping and pooling on the floor
    cby = ry0 + rh0 - 34
    pyxel.rect(rx0 + rw0 - 2, cby - 2, 8, 12, BK)              # open side port
    for k, cc in enumerate((GN, CY, RD, OR)):
        yo = cby + k * 2
        pyxel.line(rx0 + rw0 + 5, yo, rx0 + rw0 + 16 + k * 3, yo + 22 + k * 2, cc)  # droop
        pyxel.elli(rx0 + rw0 + 8 + k * 4, ry0 + rh0 - 2, 10 - k, 6, cc)             # coil on floor
    # one loose cable dangling free with a connector tip
    pyxel.line(rx0 + 22, ry0 + rh0 - 2, rx0 + 26, ry0 + rh0 + 12, RD)
    pyxel.line(rx0 + 26, ry0 + rh0 + 12, rx0 + 33, ry0 + rh0 + 9, RD)
    pyxel.rect(rx0 + 32, ry0 + rh0 + 7, 4, 4, GY); pyxel.rectb(rx0 + 32, ry0 + rh0 + 7, 4, 4, BK)
    # neat data conduit hugging the wall base over to the uplink riser
    for k, cc in enumerate((GN, CY, RD)):
        yb = 156 + k * 3
        pyxel.line(rx0 + rw0, ry0 + 6, rx0 + rw0 + 6, yb, cc)  # short riser behind rack
        pyxel.line(rx0 + rw0 + 6, yb, 414, yb, cc)             # run along the wall base
    pyxel.line(414, 168, 414, 134, GY)                         # riser up into the uplink
    for bkt in (180, 152):                                     # conduit clips
        pyxel.rect(412, bkt, 4, 2, GY)

    # ---- BROADCAST UPLINK: a clearly labelled transmitter on the right wall ----
    ux, uy, uw, uh = 336, 48, 158, 92
    pyxel.rect(ux, uy, uw, uh, NV); pyxel.rectb(ux, uy, uw, uh, CY)
    pyxel.rect(ux, uy, uw, 12, CY); pyxel.text(ux + 6, uy + 3, "BROADCAST UPLINK", BK)
    labels = ["COMPLIANCE MON.", "ROOFTOP UPLINK", "CITY BROADCAST"]
    for li in range(3):
        ly = uy + 20 + li * 18
        pyxel.rect(ux + 8, ly, uw - 16, 13, BK); pyxel.rectb(ux + 8, ly, uw - 16, 13, GN)
        lit = (fr // 6 + li) % 3 != 0
        base = RD if li == 2 else GN
        pyxel.text(ux + 11, ly + 3, labels[li], base if lit else 1)
        pyxel.circb(ux + uw - 16, ly + 6, 3, GY)
        pyxel.circ(ux + uw - 16, ly + 6, 2, base if lit else 1)
    mx = ux + uw // 2                                          # antenna + signal waves
    pyxel.line(mx, uy, mx, uy - 18, GY)
    pulse = RD if (fr // 8) % 2 == 0 else OR
    pyxel.circ(mx, uy - 18, 2, pulse)
    for wv in range(1, 4):
        on = (fr // 6) % 4 >= wv - 1
        pyxel.ellib(mx - wv * 7, uy - 18 - wv * 4, wv * 14, wv * 8, pulse if on else 1)

    # ---- EVIDENCE BOARD: detailed cork board hung on the wall ----
    vbx, vby, vbw, vbh = 96, 22, 112, 114
    pyxel.line(vbx + 14, vby, vbx + vbw // 2, 14, GY)        # hanger wires + nail
    pyxel.line(vbx + vbw - 14, vby, vbx + vbw // 2, 14, GY)
    pyxel.circ(vbx + vbw // 2, 13, 1, SV)
    pyxel.rect(vbx, vby, vbw, vbh, BR); pyxel.rectb(vbx, vby, vbw, vbh, OR)   # wood frame
    pyxel.rect(vbx + 3, vby + 3, vbw - 6, vbh - 6, 4)        # cork surface
    for dpx in range(vbx + 6, vbx + vbw - 4, 5):            # cork speckle
        pyxel.pset(dpx, vby + 8 + (dpx * 7 % (vbh - 14)), BR)
    # title tape across the top
    pyxel.rect(vbx + 6, vby + 5, vbw - 12, 10, BK); pyxel.rectb(vbx + 6, vby + 5, vbw - 12, 10, RD)
    pyxel.text(vbx + 11, vby + 7, "WHO LET IT IN?", RD)

    def _pin(px, py, c=RD):
        pyxel.circ(px, py, 2, c); pyxel.pset(px - 1, py - 1, WH)

    # --- pinned items: polaroids, a newspaper clipping, sticky notes, a redacted memo ---
    # depicted subjects keyed by tag
    def _polaroid(px, py, tag, tilt=0):
        pyxel.rect(px, py + tilt, 24, 22, WH); pyxel.rectb(px, py + tilt, 24, 22, GY)
        pyxel.rect(px + 2, py + 2 + tilt, 20, 13, NV)        # photo window
        if tag == "eye":
            draw_eye(px + 12, py + 8 + tilt, 5, fr, scan=False)
        elif tag == "school":
            pyxel.tri(px + 4, py + 13 + tilt, px + 20, py + 13 + tilt, px + 12, py + 5 + tilt, 1)
            pyxel.rect(px + 6, py + 9 + tilt, 12, 5, 5)
        elif tag == "face":
            pyxel.circ(px + 12, py + 8 + tilt, 4, PC); pyxel.pset(px + 10, py + 7 + tilt, BK)
            pyxel.pset(px + 14, py + 7 + tilt, BK); pyxel.line(px + 10, py + 10 + tilt, px + 14, py + 10 + tilt, BK)
        elif tag == "server":
            pyxel.rect(px + 8, py + 4 + tilt, 8, 9, 5)
            for sl in range(3): pyxel.line(px + 9, py + 6 + tilt + sl * 2, px + 14, py + 6 + tilt + sl * 2, GN)
        pyxel.line(px + 2, py + 17 + tilt, px + 18, py + 17 + tilt, SV)  # caption line

    _polaroid(vbx + 8, vby + 18, "school", 1)
    _polaroid(vbx + 44, vby + 17, "eye", -1)
    _polaroid(vbx + 84, vby + 20, "server", 1)
    _polaroid(vbx + 16, vby + 78, "face", 0)
    for px, py in [(vbx + 8, vby + 19), (vbx + 44, vby + 16), (vbx + 84, vby + 21),
                   (vbx + 16, vby + 78)]:
        _pin(px + 12, py - 1)
    # newspaper clipping (headline + columns), pinned askew
    ncx, ncy = vbx + 70, vby + 52
    pyxel.rect(ncx, ncy, 38, 30, WH); pyxel.rectb(ncx, ncy, 38, 30, GY)
    pyxel.rect(ncx + 2, ncy + 2, 34, 5, BK)                  # masthead bar
    pyxel.text(ncx + 3, ncy + 8, "A.I. RUNS", BK)
    pyxel.text(ncx + 3, ncy + 14, "SCHOOL", BK)
    for cl in range(3):
        pyxel.line(ncx + 3, ncy + 22 + cl * 3, ncx + 34, ncy + 22 + cl * 3, SV)
    _pin(ncx + 19, ncy - 1, OR)
    # redacted memo (black bars over white)
    rmx, rmy = vbx + 8, vby + 46
    pyxel.rect(rmx, rmy, 26, 24, WH); pyxel.rectb(rmx, rmy, 26, 24, GY)
    for rl in range(4):
        bw4 = (10, 18, 6, 14)[rl]
        pyxel.rect(rmx + 3, rmy + 4 + rl * 5, bw4, 3, BK)
    _pin(rmx + 13, rmy - 1, CY)
    # two sticky notes with scrawl
    for sxn, syn, sc in [(vbx + 46, vby + 86, YL), (vbx + 80, vby + 92, LM)]:
        pyxel.rect(sxn, syn, 18, 16, sc); pyxel.line(sxn, syn, sxn + 17, syn, WH)
        for nl in range(2): pyxel.line(sxn + 3, syn + 5 + nl * 4, sxn + 14, syn + 5 + nl * 4, BK)
        _pin(sxn + 9, syn - 1, RD)
    pyxel.text(vbx + 48, vby + 88, "?!", RD)
    # red string web linking the key suspects
    knots = [(vbx + 20, vby + 30), (vbx + 56, vby + 26), (vbx + 96, vby + 31),
             (vbx + 89, vby + 67), (vbx + 21, vby + 58), (vbx + 28, vby + 88)]
    web = [(0, 1), (1, 2), (1, 3), (0, 4), (4, 5), (3, 5), (2, 3), (4, 1)]
    for a, b in web:
        pyxel.line(knots[a][0], knots[a][1], knots[b][0], knots[b][1], RD)
    for kx, ky in knots:
        _pin(kx, ky)

    # ---- RESISTANCE TABLE: a proper wooden table (matches the game's tables) ----
    tx0, ty0, tw0, th0 = 300, 300, 108, 40
    pyxel.elli(tx0 + 12, ty0 + th0 + 10, tw0 - 24, 10, BK)                  # contact shadow
    for lx in (tx0 + 18, tx0 + tw0 - 22):                                   # back legs (peek above top)
        pyxel.rect(lx, ty0 + 4, 4, 14, 1)
    for lx in (tx0 + 6, tx0 + tw0 - 11):                                    # front legs
        pyxel.rect(lx, ty0 + th0 - 3, 5, 22, 4); pyxel.rectb(lx, ty0 + th0 - 3, 5, 22, BK)
    _wood_slab(tx0, ty0, tw0, th0)                                          # top slab w/ volume
    pyxel.line(tx0 + 3, ty0 + th0 - 5, tx0 + tw0 - 4, ty0 + th0 - 5, 1)     # apron shadow
    # ---- items resting ON the surface ----
    nx, ny = tx0 + 10, ty0 + 10                                            # open notebook
    pyxel.rect(nx, ny, 28, 20, WH); pyxel.rectb(nx, ny, 28, 20, GY)
    pyxel.line(nx + 14, ny, nx + 14, ny + 20, GY)
    for ln in range(3):
        pyxel.line(nx + 3, ny + 4 + ln * 4, nx + 12, ny + 4 + ln * 4, BL)
        pyxel.line(nx + 16, ny + 4 + ln * 4, nx + 25, ny + 4 + ln * 4, BL)
    lx2 = tx0 + 50                                                         # lantern
    pyxel.rect(lx2 + 1, ty0 + 2, 10, 5, GY); pyxel.rectb(lx2 + 1, ty0 + 2, 10, 5, BK)
    pyxel.rect(lx2, ty0 + 7, 12, 22, 1); pyxel.rectb(lx2, ty0 + 7, 12, 22, BK)
    lg = OR if (fr // 10) % 2 == 0 else YL
    pyxel.rect(lx2 + 2, ty0 + 10, 8, 14, lg); pyxel.circ(lx2 + 6, ty0 + 17, 3, YL)
    pyxel.rect(tx0 + 72, ty0 + 14, 18, 14, WH); pyxel.rectb(tx0 + 72, ty0 + 14, 18, 14, GY)   # papers
    for pl in range(2): pyxel.line(tx0 + 75, ty0 + 18 + pl * 5, tx0 + 87, ty0 + 18 + pl * 5, SV)
    pyxel.rect(tx0 + 95, ty0 + 16, 10, 12, CY); pyxel.rectb(tx0 + 95, ty0 + 16, 10, 12, WH)   # mug
    pyxel.line(tx0 + 105, ty0 + 19, tx0 + 107, ty0 + 22, WH)

    # ---- bedroll: a sleeping bag rolled out on a foam mat (someone hides here) ----
    bgx, bgy, bgw, bgh = 100, 378, 142, 44
    pyxel.elli(bgx - 6, bgy + bgh - 4, bgw + 12, 13, BK)                   # ground shadow
    # foam roll-mat under the bag
    pyxel.rect(bgx - 4, bgy + 3, bgw + 8, bgh - 2, 5); pyxel.rectb(bgx - 4, bgy + 3, bgw + 8, bgh - 2, GY)
    for mx in range(bgx + 2, bgx + bgw, 16): pyxel.line(mx, bgy + 5, mx, bgy + bgh, 6)  # mat ridges
    # sleeping bag body, rounded foot on the left
    pyxel.elli(bgx, bgy, 34, bgh, BL)                                      # rounded foot end
    pyxel.rect(bgx + 17, bgy, bgw - 58, bgh, BL)                          # body
    pyxel.ellib(bgx, bgy, 34, bgh, NV)
    pyxel.rect(bgx + 17, bgy, bgw - 58, bgh, BL); pyxel.rectb(bgx + 17, bgy, bgw - 58, bgh, NV)
    pyxel.line(bgx + 6, bgy + 4, bgx + 30, bgy + 4, CY)                   # sheen along the top
    for qx in range(bgx + 24, bgx + bgw - 46, 12):                        # quilting seams
        pyxel.line(qx, bgy + 3, qx, bgy + bgh - 3, NV)
    # head end: folded-open flap showing pale lining + a pillow
    hx = bgx + bgw - 46
    pyxel.tri(hx - 4, bgy + 1, hx - 4, bgy + bgh - 1, hx + 6, bgy + bgh // 2, NV)  # turned-back corner
    pyxel.elli(hx, bgy + 1, 30, bgh - 2, CY); pyxel.ellib(hx, bgy + 1, 30, bgh - 2, NV)  # lining
    pyxel.elli(hx + 5, bgy + 7, 22, bgh - 14, WH); pyxel.ellib(hx + 5, bgy + 7, 22, bgh - 14, GY)  # pillow
    pyxel.line(hx + 8, bgy + bgh // 2, hx + 24, bgy + bgh // 2, GY)       # pillow crease
    # a little camp gear beside it
    pyxel.rect(bgx + 6, bgy + bgh + 3, 16, 6, YL); pyxel.rectb(bgx + 6, bgy + bgh + 3, 16, 6, BK)  # torch
    pyxel.circ(bgx + 4, bgy + bgh + 6, 2, OR)
    pyxel.rect(bgx + 40, bgy + bgh + 2, 10, 8, CY); pyxel.rectb(bgx + 40, bgy + bgh + 2, 10, 8, WH)  # tin mug
    pyxel.line(bgx + 50, bgy + bgh + 5, bgx + 52, bgy + bgh + 7, WH)

    # ---- spray-painted graffiti on the open floor ----
    pyxel.text(120, 232, "THEY CANNOT MODEL YOUR DOUBT", GN)
    pyxel.text(228, 242, "-- Anonymous", GN)
    pyxel.line(120, 240, 268, 240, GN)

    # ---- stairwell up to the library (a lit opening in the back wall) ----
    sxw, syw, sww, shh = 224, 86, 64, 64
    pyxel.rectb(sxw - 3, syw - 3, sww + 6, shh + 6, 5)        # stone frame
    pyxel.rect(sxw, syw, sww, shh, BK); pyxel.rectb(sxw, syw, sww, shh, GY)
    for g in range(4):                                        # warm light from above
        pyxel.rect(sxw + 6 + g * 3, syw + 2 + g, sww - 12 - g * 6, 2, YL if g < 2 else OR)
    for st in range(5):                                       # steps in perspective
        sw2 = 28 + st * 7
        sx2 = sxw + sww // 2 - sw2 // 2
        sy3 = syw + 20 + st * 8
        sc = SV if st % 2 == 0 else GY
        pyxel.rect(sx2, sy3, sw2, 6, sc); pyxel.rectb(sx2, sy3, sw2, 6, BK)
        pyxel.line(sx2 + 1, sy3 + 1, sx2 + sw2 - 2, sy3 + 1, WH)
    pyxel.rect(sxw + 4, syw + 2, sww - 8, 9, BK)             # label plate
    pyxel.text(sxw + 7, syw + 4, "UP TO LIBRARY", LM)


def draw_server_room(fr):
    # ceiling + tall cold wall
    pyxel.rect(0, 0, W, 18, NV)
    pyxel.rect(0, 18, W, 128, BK)
    pyxel.rect(0, 0, W, 6, 5)
    pyxel.rectb(0, 0, W, 6, GY)
    for i in range(0, W, 12):
        lc4 = BL if (i // 12 + fr // 4) % 3 != 0 else CY
        pyxel.pset(i + 4, 6, lc4)
    pyxel.rect(0, 142, W, 4, BL)
    pyxel.rect(0, 146, W, 4, NV)
    pyxel.line(0, FY, W, FY, BL)
    # raised floor
    for gy in range(FY, H, 26):
        for gx in range(0, W, 26):
            c = NV if ((gx // 26 + gy // 26) % 2 == 0) else BK
            pyxel.rect(gx, gy, 26, 26, c)
            pyxel.rectb(gx, gy, 26, 26, BL)
            pyxel.pset(gx + 3, gy + 3, BL); pyxel.pset(gx + 22, gy + 22, BL)
    _floor_grime(20, W - 20, 53, 1, NV, traffic=False)         # cold grime / dust
    for (ax, ay2) in [(130, 364), (416, 312), (260, 442)]:     # raised-floor access panels
        pyxel.rectb(ax, ay2, 26, 26, CY); pyxel.line(ax + 13, ay2, ax + 13, ay2 + 26, 1)
        pyxel.circb(ax + 13, ay2 + 13, 2, BL)
    _light_pool(351, 344, 132, 82, NV)                         # console glow spilling on the floor
    _light_pool(351, 344, 82, 50, BL)
    # server racks
    for i in range(2):
        rx2 = 8 + i * 122; ry2 = 152
        rw2 = 116; rh2 = 296
        pyxel.rect(rx2, ry2, rw2, rh2, BK)
        pyxel.rectb(rx2, ry2, rw2, rh2, BL)
        pyxel.rectb(rx2 + 2, ry2 + 2, rw2 - 4, rh2 - 4, NV)
        pyxel.text(rx2 + 6, ry2 + 4, "SYNTHIA", BL)
        pyxel.text(rx2 + 6, ry2 + 12, f"NODE-{i + 1}", BL)
        for row in range(15):
            uy2 = ry2 + 22 + row * 20
            pyxel.rect(rx2 + 6, uy2, rw2 - 12, 16, NV)
            pyxel.rectb(rx2 + 6, uy2, rw2 - 12, 16, BL)
            pyxel.rect(rx2 + 8, uy2 + 2, 16, 12, 5)
            for led in range(5):
                lx5 = rx2 + 30 + led * 14
                ly5 = uy2 + 5
                pat = (fr // 3 + row * 3 + led + i * 7) % 12
                ledcol = GN if pat < 8 else (RD if pat < 11 else YL)
                pyxel.rect(lx5, ly5, 10, 6, ledcol)
    # ---- SYNTHIA MASTER CONSOLE: a clean control terminal (eye monitor + desk) ----
    cmx, cmy, cmw, cmh = 286, 100, 130, 150
    gp = RD if (fr // 6) % 2 == 0 else 8                       # integrated pulse-glow frame
    pyxel.rectb(cmx - 4, cmy - 4, cmw + 8, cmh + 8, gp)
    pyxel.rectb(cmx - 2, cmy - 2, cmw + 4, cmh + 4, NV)
    pyxel.rect(cmx, cmy, cmw, cmh, BK); pyxel.rectb(cmx, cmy, cmw, cmh, RD)   # monitor
    pyxel.rectb(cmx + 2, cmy + 2, cmw - 4, cmh - 4, NV)
    draw_eye(cmx + cmw // 2, cmy + 66, 34, fr, scan=True)
    pyxel.text(cmx + 8, cmy + cmh - 30, "SYNTHIA v9.1", RD)
    tc2 = RD if (fr // 10) % 2 == 0 else WH
    pyxel.text(cmx + 8, cmy + cmh - 18, "INTERACT [SPACE]", tc2)
    # control desk reaching the floor, where the interaction sits
    dkx, dky, dkw = cmx + 4, cmy + cmh, cmw - 8
    pyxel.elli(dkx + 8, dky + 66, dkw - 16, 8, BK)            # shadow
    pyxel.rect(dkx, dky, dkw, 64, 5); pyxel.rectb(dkx, dky, dkw, 64, GY)
    pyxel.rect(dkx, dky, dkw, 4, SV)
    for bcol in range(5):                                     # control buttons
        bc4 = (RD, GN, YL, BL, GN)[bcol]; on4 = (fr // 8 + bcol) % 3 != 0
        pyxel.rect(dkx + 10 + bcol * 22, dky + 12, 14, 8, bc4 if on4 else 1)
        pyxel.rectb(dkx + 10 + bcol * 22, dky + 12, 14, 8, BK)
    pyxel.rect(dkx + 10, dky + 30, dkw - 20, 6, NV); pyxel.rectb(dkx + 10, dky + 30, dkw - 20, 6, BL)
    pyxel.rect(dkx + 12 + (fr // 3) % (dkw - 28), dky + 29, 6, 8, CY)   # slider knob
    # ---- ENVIRONMENT screen mounted on the wall ----
    esx, esy, esw, esh = 10, 84, 96, 58
    pyxel.rect(esx, esy, esw, esh, GY); pyxel.rectb(esx, esy, esw, esh, 5)   # bezel
    pyxel.rect(esx + 3, esy + 3, esw - 6, esh - 6, NV); pyxel.rectb(esx + 3, esy + 3, esw - 6, esh - 6, BL)
    pyxel.text(esx + 7, esy + 7, "ENV MONITOR", CY)
    pyxel.text(esx + 7, esy + 19, "TEMP     16C", GN)
    pyxel.text(esx + 7, esy + 29, "HUMIDITY 40%", GN)
    for gxn in range(esx + 7, esx + esw - 6, 4):                          # live graph
        gh = 3 + (gxn * 7 + fr // 2) % 9
        pyxel.line(gxn, esy + esh - 6, gxn, esy + esh - 6 - gh, CY)
    pyxel.rect(esx + esw // 2 - 6, esy - 4, 12, 4, GY)                    # wall bracket
    _door(229, 26, 54, 100, "CLASSROOM", NV)            # normal vertical door


ROOM_DRAW = {
    HALL: draw_hallway, CLS: draw_classroom,
    LIB:  draw_library,  CAN: draw_canteen,
    GYM:  draw_gym,      SUP: draw_supply,
    PRI:  draw_principal, BAS: draw_basement,
    SRV:  draw_server_room,
}

MMAP_POS = {
    PRI: (0, 0), BAS: (1, 0), SRV: (2, 0),
    LIB: (0, 1), HALL: (1, 1), CLS: (2, 1),
    GYM: (0, 2), CAN: (1, 2), SUP: (2, 2),
}
MMAP_CODE = {PRI: "PR", BAS: "BA", SRV: "SV", LIB: "LI", HALL: "HA",
             CLS: "CL", GYM: "GY", CAN: "CA", SUP: "SU"}
MMAP_LINKS = [(HALL, CLS), (HALL, CAN), (HALL, LIB),
              (CLS, SRV), (LIB, PRI), (CAN, GYM), (CAN, SUP)]
MMAP_SECRET = (LIB, BAS)


# ===============================================================
#  DIALOGUE BOX  (objects - simple press-to-advance)
# ===============================================================

#####################################################################
#  BATTLE  -  minigames + boss  (was battle.py)
#####################################################################

# ===============================================================
#  MINIGAME 1 - GYM SHOOT-OUT vs SYNTHIA DRONES -> gives "reflexes"
#  Four steps, three distinct skills:
#    MOVE  : LEFT/RIGHT to pick your spot (distance changes the shot)
#    JUMP  : tap SPACE to set leap height - jump OVER the drone or get blocked
#    AIM   : tap SPACE to set the arc angle
#    SHOOT : tap SPACE to set power and release
#  The ball flies a real arc. Sink 3. SYNTHIA optimised away the
#  messy human timing this drill rebuilds.
# ===============================================================
class Hoops:
    TARGET = 3
    PXMIN, PXMAX = 120, 300
    BASE = 364
    HOOPX, RIMY = 432, 150
    DEFX = 330

    def reset(self):
        self.made = 0; self.shots = 0
        self.px = 180
        self.phase = "move"
        self.jv = self.av = self.pv = 0.0
        self.jdir = self.adir = self.pdir = 1
        self.jump_q = self.aim_q = self.power_q = 0.0
        self.t = 0
        self.defx = float(self.DEFX); self.defdir = 1
        self.ball = None; self.arc = None
        self.flytime = 0; self.flymax = 30
        self.outcome = ""; self.result = ""; self.rcolor = WH
        self.done = False

    def _dist01(self):
        return (self.px - self.PXMIN) / (self.PXMAX - self.PXMIN)

    def _targets(self):
        d = self._dist01()
        return 0.45 + 0.25 * d, 0.42 + 0.40 * d   # aim target, power target

    # ---- shot resolution -------------------------------------------------
    def _launch(self, g):
        self.shots += 1
        aimT, powT = self._targets()
        near = abs(self.px - self.defx) < 48        # is the drone in your lane?
        blocked = near and self.jump_q < 0.62       # must jump high to clear it
        ga = abs(self.aim_q - aimT) <= 0.12         # tighter aim window
        gp = abs(self.power_q - powT) <= 0.12       # tighter power window
        if blocked:               self.outcome, self.rcolor = "BLOCKED!", RD
        elif ga and gp:           self.outcome, self.rcolor = "SWISH!", LM
        elif ga or gp:            self.outcome, self.rcolor = "RIM OUT", OR
        else:                     self.outcome, self.rcolor = "AIR BALL", OR
        rx = self.px; ry = self.BASE - 30 - int(self.jump_q * 40)
        if self.outcome == "SWISH!":     ex, ey = self.HOOPX, self.RIMY
        elif self.outcome == "BLOCKED!": ex, ey = int(self.defx), self.BASE - 70
        elif self.outcome == "RIM OUT":  ex, ey = self.HOOPX + 4, self.RIMY + 16
        elif self.power_q < powT:        ex, ey = rx + int((self.HOOPX - rx) * 0.6), self.BASE
        else:                            ex, ey = self.HOOPX + 48, self.BASE
        self.arc = (rx, ry, ex, ey, 120 + int(self.aim_q * 70))
        self.ball = (rx, ry)
        self.flytime = 0; self.flymax = 18 if self.outcome == "BLOCKED!" else 30
        self.phase = "fly"; pyxel.play(1, 3)

    def _stepball(self):
        rx, ry, ex, ey, peak = self.arc
        t = self.flytime / self.flymax
        self.ball = (rx + (ex - rx) * t, ry + (ey - ry) * t - peak * 4 * t * (1 - t))

    def _land(self, g):
        self.result = self.outcome
        if self.outcome == "SWISH!":
            self.made += 1; pyxel.play(1, 2)
            g.fx.burst(self.HOOPX, self.RIMY, LM, 20, 2.2)
            g.fx.ring(self.HOOPX, self.RIMY, LM)
            if self.made >= self.TARGET and not g.player.has("reflexes"):
                g.player.give("reflexes"); pyxel.play(1, 6)
        elif self.outcome == "BLOCKED!":
            g.fx.shake(5, 3); g.fx.burst(self.DEFX, self.BASE - 60, RD, 10, 1.6)
        self.phase = "result"; self.flytime = 0

    # ---- update ----------------------------------------------------------
    def update(self, g):
        if (pyxel.btnp(pyxel.KEY_ESCAPE) or pyxel.btnp(pyxel.KEY_BACKSPACE)
                or pyxel.btnp(pyxel.KEY_Q)):
            g.state = WORLD; return
        self.t += 1
        # the drone patrols back and forth (except while the ball is in the air)
        if self.phase in ("move", "jump", "aim", "power"):
            self.defx += self.defdir * 2.6
            if self.defx > 366: self.defx = 366; self.defdir = -1
            if self.defx < 244: self.defx = 244; self.defdir = 1
        if self.done:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                g.state = WORLD
            return
        sp = pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN)
        if self.phase == "move":
            if pyxel.btn(pyxel.KEY_LEFT):  self.px = max(self.PXMIN, self.px - 3)
            if pyxel.btn(pyxel.KEY_RIGHT): self.px = min(self.PXMAX, self.px + 3)
            if sp: self.phase = "jump"; self.jv = 0.0; self.jdir = 1
        elif self.phase == "jump":
            self.jv += self.jdir * 0.052
            if self.jv >= 1: self.jv = 1.0; self.jdir = -1
            elif self.jv <= 0: self.jv = 0.0; self.jdir = 1
            if sp: self.jump_q = self.jv; self.phase = "aim"; self.av = 0.0; self.adir = 1
        elif self.phase == "aim":
            self.av += self.adir * 0.05
            if self.av >= 1: self.av = 1.0; self.adir = -1
            elif self.av <= 0: self.av = 0.0; self.adir = 1
            if sp: self.aim_q = self.av; self.phase = "power"; self.pv = 0.0; self.pdir = 1
        elif self.phase == "power":
            self.pv += self.pdir * 0.062
            if self.pv >= 1: self.pv = 1.0; self.pdir = -1
            elif self.pv <= 0: self.pv = 0.0; self.pdir = 1
            if sp: self.power_q = self.pv; self._launch(g)
        elif self.phase == "fly":
            self.flytime += 1; self._stepball()
            if self.flytime >= self.flymax: self._land(g)
        elif self.phase == "result":
            self.flytime += 1
            if self.flytime > 26:
                if self.made >= self.TARGET: self.done = True
                else: self.phase = "move"

    # ---- drawing ---------------------------------------------------------
    def _figure(self, x, y, col, arms_up=False, label=None, lc=None):
        x = int(x); y = int(y)
        pyxel.elli(x - 14, y + 5, 28, 6, BK)                       # shadow
        _char_body(x, y + 5, PLAYER_SPEC, "down", 0)               # native-res player
        if label: pyxel.text(x - len(label) * 2, y + 8, label, lc or col)

    def _teammate(self, x, y):
        x = int(x); y = int(y)
        pyxel.elli(x - 14, y + 5, 28, 6, BK)
        _char_body(x, y + 5, FACES["librarian"], "down", 0)        # Osei, native-res
        pyxel.text(x - 8, y + 8, "team", LM)

    def _defender(self, x, y):
        x = int(x); y = int(y)
        bob = int(pyxel.sin(self.t * 4) * 3)
        cy = y - 44 + bob
        pyxel.elli(x - 14, y + 6, 28, 5, BK)                       # faint floor shadow
        for s in (-1, 1):                                          # rotor arms
            pyxel.line(x, cy + 10, x + s * 22, cy + 6, SV)
            rot = 10 if (self.t // 2) % 2 == 0 else 4
            pyxel.line(x + s * 22 - rot, cy + 6, x + s * 22 + rot, cy + 6, WH)
            pyxel.circ(x + s * 22, cy + 6, 2, GY)
        pyxel.rect(x - 15, cy, 30, 20, GY); pyxel.rectb(x - 15, cy, 30, 20, NV)   # body
        pyxel.rect(x - 11, cy + 4, 22, 10, BK); pyxel.rectb(x - 11, cy + 4, 22, 10, RD)
        ec = RD if (self.t // 6) % 2 == 0 else OR                  # menacing eye
        pyxel.elli(x - 8, cy + 6, 16, 6, ec); pyxel.rect(x - 2, cy + 7, 4, 4, BK)
        pyxel.text(x - 12, cy - 10, "DRONE", RD)

    def _meter(self, val, lo_t, hi_t, label):
        mx, my, mw, mh = 70, 326, W - 140, 22
        pyxel.rect(mx, my, mw, mh, BK); pyxel.rectb(mx, my, mw, mh, SV)
        pyxel.rect(int(mx + lo_t * mw), my, int((hi_t - lo_t) * mw), mh, GN)
        cur = int(mx + val * mw)
        pyxel.rect(cur - 2, my - 5, 4, mh + 10, YL)
        pyxel.text(mx, my - 14, label, WH)

    def draw(self, g):
        pyxel.cls(NV)
        # ---- background: gym cinder-block wall + bleachers full of fans ----
        pyxel.rect(0, 0, W, 86, GY)
        for brow in range(5):
            for bcol in range(10):
                pyxel.rectb(bcol * 56 + (brow % 2) * 28, brow * 17, 56, 16, 5)
        fan_h = [OR, GN, CY, YL, RD, LM, PP, WH, OR, GN]
        fan_b = [RD, BL, PP, GN, OR, CY, RD, BL, YL, PP]
        for row in range(4):
            by2 = 86 + row * 24
            pyxel.rect(0, by2, W, 13, 1); pyxel.rectb(0, by2, W, 13, BK)   # seat row
            pyxel.rect(0, by2 + 13, W, 11, 6)                              # riser
            for fx in range(16, W, 30):
                seed = (fx // 30 + row)
                hc = fan_h[seed % len(fan_h)]; bc = fan_b[seed % len(fan_b)]
                cheer = ((self.t // 7 + seed * 3) % 11) < 2
                yy = by2 + 2
                pyxel.rect(fx - 3, yy + 2, 7, 7, bc)
                pyxel.circ(fx, yy, 3, hc)
                if cheer:
                    pyxel.line(fx - 3, yy + 2, fx - 6, yy - 3, hc)
                    pyxel.line(fx + 3, yy + 2, fx + 6, yy - 3, hc)
        # ---- hardwood floor (same plank texture as the gym) ----
        for gy in range(self.BASE, H, 8):
            c = BR if (gy // 8) % 2 == 0 else 4
            pyxel.rect(0, gy, W, 8, c)
        for gx in range(0, W, 12):
            pyxel.line(gx, self.BASE, gx, H, 1)
        pyxel.rect(0, self.BASE, W, 3, OR)            # court baseline
        # hoop on a grounded pole (clearly standing on the court, not floating)
        polex = self.HOOPX + 18
        pyxel.rect(polex, self.RIMY - 18, 7, self.BASE - (self.RIMY - 18), GY)  # pole to floor
        pyxel.rectb(polex, self.RIMY - 18, 7, self.BASE - (self.RIMY - 18), 5)
        pyxel.elli(polex - 9, self.BASE - 3, 26, 8, BK)                         # base shadow
        pyxel.rect(polex - 7, self.BASE - 5, 21, 6, SV); pyxel.rectb(polex - 7, self.BASE - 5, 21, 6, GY)  # base
        pyxel.line(self.HOOPX + 6, self.RIMY - 14, polex, self.RIMY - 6, SV)    # support arm
        pyxel.line(self.HOOPX + 6, self.RIMY - 10, polex, self.RIMY - 2, SV)
        pyxel.rect(self.HOOPX - 22, self.RIMY - 36, 40, 30, WH); pyxel.rectb(self.HOOPX - 22, self.RIMY - 36, 40, 30, SV)  # backboard
        pyxel.rectb(self.HOOPX - 9, self.RIMY - 26, 18, 12, RD)                 # target square
        pyxel.line(self.HOOPX - 14, self.RIMY, self.HOOPX + 12, self.RIMY, OR)  # rim
        pyxel.line(self.HOOPX - 14, self.RIMY + 1, self.HOOPX + 12, self.RIMY + 1, OR)
        for i in range(6):                                                      # net
            pyxel.line(self.HOOPX - 12 + i * 5, self.RIMY + 1, self.HOOPX - 9 + i * 4, self.RIMY + 15, WH)
        # header
        pyxel.rect(0, 0, W, 30, BK)
        pyxel.text(W // 2 - 96, 6, "GYM SHOOT-OUT  -  beat the SYNTHIA drone", WH)
        pyxel.text(W // 2 - 120, 18, "Get clear of the patrolling drone OR jump it. Sink 3.", SV)
        for i in range(self.TARGET):
            c = YL if i < self.made else NV
            pyxel.circ(20 + i * 16, 46, 6, c); pyxel.circb(20 + i * 16, 46, 6, SV)
        pyxel.text(20 + self.TARGET * 16 + 8, 42, f"{self.made}/{self.TARGET}", CY)
        # figures: shooter (with live jump) + patrolling defender drone
        jH = int((self.jv if self.phase == "jump" else self.jump_q) * 40) if self.phase in ("jump", "aim", "power", "fly") else 0
        self._figure(self.px, self.BASE - jH, BL, True)
        self._defender(self.defx, self.BASE - (10 if self.phase == "fly" else 0))
        if self.phase == "fly" and self.ball:
            bx, by = self.ball
            bx, by = int(bx), int(by)
            pyxel.circ(bx, by, 8, OR); pyxel.circb(bx, by, 8, BK)   # bigger ball
            pyxel.line(bx, by - 8, bx, by + 8, BR)                  # seams
            pyxel.elli(bx - 8, by - 2, 16, 5, BR)
            pyxel.pset(bx - 3, by - 3, YL)
        # phase UI
        if self.phase == "move":
            pyxel.text(70, 300, "Step 1/3  -  LEFT / RIGHT to dodge the drone, SPACE to start", CY)
            mx, mw = 70, W - 140
            pyxel.rect(mx, 326, mw, 10, BK); pyxel.rectb(mx, 326, mw, 10, SV)
            pyxel.rect(int(mx + self._dist01() * (mw - 6)), 323, 6, 16, YL)
            pyxel.text(mx, 342, "close  <-  distance  ->  far", GY)
        elif self.phase == "jump":
            self._meter(self.jv, 0.62, 1.0, "Step 1: JUMP  -  SPACE to leap (clear the drone!)")
        elif self.phase == "aim":
            aimT, _ = self._targets()
            self._meter(self.av, aimT - 0.12, aimT + 0.12, "Step 2: AIM  -  SPACE to set the arc angle")
        elif self.phase == "power":
            _, powT = self._targets()
            self._meter(self.pv, powT - 0.12, powT + 0.12, "Step 3: SHOOT  -  SPACE to release")
        elif self.phase in ("fly", "result") and self.result:
            pyxel.text(W // 2 - len(self.result) * 2, 250, self.result, self.rcolor)
        if self.done:
            pyxel.rect(40, 388, W - 80, 74, BK); pyxel.rectb(40, 388, W - 80, 74, LM)
            pyxel.text(58, 402, "DRILL COMPLETE - reflexes sharpened.", LM)
            pyxel.text(58, 420, "A trained reflex is faster than SYNTHIA's math.", WH)
            pyxel.text(58, 438, "[ +REFLEXES ]   SPACE to leave", CY)
        else:
            pyxel.text(W - 150, H - 12, "Q = leave", GY)

class Simon:
    AWARD_LEN = 5                 # reproduce a length-5 pattern -> "paradox"
    PCOL = [RD, CY, YL, GN]
    def reset(self):
        import random
        self._rand = random
        self.seq = [random.randint(0, 3)]
        self.phase = "show"; self.show_i = 0; self.show_t = 0
        self.in_i = 0; self.lit = -1; self.litt = 0
        self.best = 0; self.awarded = False
        self.hard = False
        self.msg = "Watch the pattern..."; self.done = False

    def _grow(self): self.seq.append(self._rand.randint(0, 3))
    def _note(self, i): pyxel.play(0, 10 + i)          # one clean tone per pad
    def _show_total(self):
        if self.hard: return max(6, 13 - len(self.seq))
        return max(9, 18 - len(self.seq))   # speeds up as it grows

    def update(self, g):
        if (pyxel.btnp(pyxel.KEY_ESCAPE) or pyxel.btnp(pyxel.KEY_BACKSPACE)
                or pyxel.btnp(pyxel.KEY_Q)):
            g.state = WORLD; return
        if self.litt > 0:
            self.litt -= 1
            if self.litt == 0 and self.phase != "show": self.lit = -1
        if self.phase == "show":
            self.show_t += 1
            if self.show_t == 3:
                self.lit = self.seq[self.show_i]; self._note(self.lit)
            if self.show_t >= self._show_total():
                self.lit = -1; self.show_t = 0; self.show_i += 1
                if self.show_i >= len(self.seq):
                    self.phase = "input"; self.in_i = 0
                    self.msg = "Repeat it - keys 1 2 3 4"
        elif self.phase == "input":
            keys = [pyxel.KEY_1, pyxel.KEY_2, pyxel.KEY_3, pyxel.KEY_4]
            for k in range(4):
                if pyxel.btnp(keys[k]):
                    self.lit = k; self.litt = 8; self._note(k)
                    if k == self.seq[self.in_i]:
                        self.in_i += 1
                        if self.in_i >= len(self.seq):
                            self.best = max(self.best, len(self.seq))
                            if len(self.seq) >= self.AWARD_LEN and not self.awarded:
                                self.awarded = True
                                if not g.player.has("paradox"): g.player.give("paradox")
                                self.msg = "PARADOX EXTRACTED - keep going!"; pyxel.play(1, 6)
                                g.fx.burst(W // 2, 250, PK, 26, 2.6); g.fx.shake(6, 3)
                            else:
                                self.msg = "Good. The pattern grows..."; pyxel.play(1, 5)
                                g.fx.burst(W // 2, 250, LM, 10, 1.6)
                            self.phase = "good"; self.litt = 16
                    else:
                        self.phase = "over"; self.done = True; self.lit = -1
                        self.msg = "WRONG NOTE - focus broken."; pyxel.play(1, 3)
                    break
        elif self.phase == "good":
            if self.litt <= 0:
                self._grow(); self.phase = "show"; self.show_i = 0; self.show_t = 0
                self.msg = "Watch the pattern..."
        elif self.phase == "over":
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                g.state = WORLD

    def draw(self, g):
        pyxel.cls(BK)
        fr = g.frame
        for y in range(0, H, 3): pyxel.line(0, y, W, y, NV)            # scanlines
        for gx in range(-W, 2 * W, 56): pyxel.line(W // 2, 300, gx, H, 1)   # grid
        for gy in range(312, H, 16): pyxel.line(0, gy, W, gy, 1)
        for rr in range(5, 0, -1):                                     # soft glow behind panels
            pyxel.elli(W // 2 - rr * 34, 250 - rr * 26, rr * 68, rr * 52, NV if rr > 2 else 1)
        pyxel.rectb(4, 4, W - 8, H - 8, NV)
        pyxel.rect(0, 0, W, 32, NV)
        pyxel.text(W // 2 - 70, 8, "CLASSROOM - FOCUS TERMINAL", WH)
        pyxel.text(W // 2 - 100, 18, "Hold the whole pattern in your head. One slip ends it.", SV)
        draw_eye(W - 42, 54, 12, fr, state="scan")                     # SYNTHIA watches the test
        pyxel.text(W // 2 - 80, 44, f"Length: {len(self.seq)}    Best: {self.best}", CY)
        if self.awarded:
            pyxel.text(W // 2 + 70, 44, "PARADOX SECURED", LM)
        cx, cy, s, gap = W // 2, 250, 92, 14
        spots = [(-1, -1, "1"), (1, -1, "2"), (-1, 1, "3"), (1, 1, "4")]
        for i, (ox, oy, lbl) in enumerate(spots):
            x = cx + ox * (s // 2 + gap // 2) - s // 2
            y = cy + oy * (s // 2 + gap // 2) - s // 2
            on = (self.lit == i); col = self.PCOL[i]
            pyxel.rect(x, y, s, s, col if on else NV)
            pyxel.rectb(x, y, s, s, WH if on else col)
            if on:
                for gg in range(3, 0, -1):
                    pyxel.rectb(x - gg, y - gg, s + gg * 2, s + gg * 2, col if gg < 2 else NV)
            pyxel.text(x + s // 2 - 2, y + s // 2 - 2, lbl, WH if on else SV)
        if self.phase == "input":
            for i in range(len(self.seq)):
                c = LM if i < self.in_i else GY
                pyxel.circ(70 + i * 14, 120, 5, c)
        mc = LM if self.phase in ("good",) or self.awarded and self.phase != "over" else (RD if self.phase == "over" else WH)
        pyxel.text(W // 2 - len(self.msg) * 2, 150, self.msg, mc)
        if self.phase == "over":
            pyxel.rect(40, 360, W - 80, 80, BK); pyxel.rectb(40, 360, W - 80, 80, RD)
            pyxel.text(60, 372, f"FOCUS BROKEN - you held {self.best} steps.", RD)
            if self.awarded:
                pyxel.text(60, 390, "Ms. Richter: 'You already have the paradox.", LM)
                pyxel.text(60, 402, "A statement that cannot be true or false.", WH)
                pyxel.text(60, 414, "Feed it to SYNTHIA.'", WH)
                pyxel.text(60, 428, "[ +PARADOX ]   SPACE to leave", CY)
            else:
                pyxel.text(60, 392, f"Reach length {self.AWARD_LEN} to extract the paradox.", WH)
                pyxel.text(60, 410, "Re-enter the terminal to try again.", SV)
                pyxel.text(60, 428, "SPACE to leave", CY)
        else:
            pyxel.text(W - 150, H - 14, "Q = leave", GY)




# ===============================================================
#  BOSS FIGHT - SYNTHIA  (two layers, both pieces required)
#    SHIELD  - only deflected packets break it (needs "reflexes").
#    CORE    - only rebuttals break it, and only the "paradox"
#              lands real damage.
#  Missing a piece stalls that layer; SYNTHIA says so, and you can
#  retreat (ESC) to go get what you lack. No permanent game-over.
# ===============================================================
class Boss:
    SHIELD_MAX = 72       # ~6 clean deflects, so the layer feels snappy
    CORE_MAX   = 100      # 3 paradox-backed rebuttals (34 each)
    HP_MAX     = 100
    DEFR       = 34       # deflect reach (radius) - forgiving
    HITR       = 13       # packet hit radius
    DEF_DMG    = 12       # shield damage per deflected packet
    HIT_DMG    = 6        # your hp lost per packet that hits you
    TELE       = 18       # telegraph frames before each wave fires

    def reset(self):
        self.phase = "intro"
        self.shield = self.SHIELD_MAX
        self.core = self.CORE_MAX
        self.hp = self.HP_MAX
        self.bx = W / 2.0; self.by = H - 90.0
        self.shots = []; self.t = 0; self.deflect = 0
        self.qi = -1; self.qsel = None; self.qtimer = 0; self.qresult = 0
        self.wave = 0; self.shake = 0
        self.spawn_cd = 44; self.tele = 0
        self.hard = False
        self.qorder = random.sample(range(len(BOSS_ARGS)), len(BOSS_ARGS))
        self.qpos = 0

    def _spawn(self):
        import math
        n = 3 + self.wave + (1 if self.hard else 0)
        for i in range(n):
            ang = -1.1 + 2.2 * (i / max(1, n - 1)) + (((self.t * 37) % 19 - 9) / 30.0)
            sp = 2.3 + self.wave * 0.2 + (0.9 if self.hard else 0.0)
            self.shots.append([W / 2.0, 70.0,
                               math.sin(ang) * sp, abs(math.cos(ang)) * sp + 1.3])

    def update(self, g):
        self.t += 1
        if self.shake > 0: self.shake -= 1
        if self.phase != "win" and (pyxel.btnp(pyxel.KEY_ESCAPE) or
                                    pyxel.btnp(pyxel.KEY_BACKSPACE) or
                                    pyxel.btnp(pyxel.KEY_Q)):
            g.state = WORLD; return
        if self.phase == "intro":
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.phase = "phys"; self.t = 0; self.shots = []
                self.spawn_cd = 44; self.tele = 0; self.wave = 0
        elif self.phase == "phys":
            self._upd_phys(g)
        elif self.phase == "core":
            self._upd_core(g)
        elif self.phase == "lose":
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.reset(); self.phase = "phys"
        elif self.phase == "win":
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                g.player.beat_boss = True; g.player.won = True; g.state = WORLD

    # ---- layer 1: break the shield (physical) ----
    def _upd_phys(self, g):
        spd = 3
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.KEY_A):  self.bx -= spd
        if pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.KEY_D): self.bx += spd
        if pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.KEY_W):    self.by -= spd
        if pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.KEY_S):  self.by += spd
        self.bx = max(30, min(W - 30, self.bx))
        self.by = max(150, min(H - 30, self.by))
        self.hard = g.hard
        has_ref = g.player.has("reflexes")

        if not has_ref:
            # You cannot break the shield without reflexes. Rather than let
            # the player die pointlessly, packets fall harmlessly here and a
            # banner tells them to train with Coach. They can ESC to retreat.
            if self.spawn_cd > 0: self.spawn_cd -= 1
            if self.spawn_cd <= 0:
                self._spawn(); self.wave += 1; self.spawn_cd = 64
            alive = []
            for s in self.shots:
                s[0] += s[2]; s[1] += s[3]
                if s[1] > H or s[0] < 0 or s[0] > W: continue
                alive.append(s)
            self.shots = alive
            return

        # spawn timing with a short telegraph before each wave
        if self.spawn_cd > 0: self.spawn_cd -= 1
        if self.spawn_cd == self.TELE: self.tele = self.TELE; pyxel.play(1, 9)
        if self.tele > 0: self.tele -= 1
        if self.spawn_cd <= 0:
            self._spawn(); self.wave += 1
            base = 60 if self.hard else 78
            floor = 24 if self.hard else 34
            self.spawn_cd = max(floor, base - self.wave * 5)

        if self.deflect > 0: self.deflect -= 1
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            self.deflect = 10; pyxel.play(1, 2); g.fx.ring(self.bx, self.by - 2, CY)
        alive = []
        for s in self.shots:
            s[0] += s[2]; s[1] += s[3]
            if s[1] > H or s[0] < 0 or s[0] > W: continue
            dx = s[0] - self.bx; dy = s[1] - self.by; d2 = dx * dx + dy * dy
            if self.deflect > 0 and d2 < self.DEFR * self.DEFR:
                self.shield = max(0, self.shield - self.DEF_DMG); self.shake = 4
                pyxel.play(1, 27)
                g.fx.ring(s[0], s[1], CY); g.fx.burst(s[0], s[1], CY, 10, 1.8)
                g.fx.popup(s[0], s[1] - 14, "DEFLECT", CY, 1, 22); g.fx.shake(3, 2)
                continue
            if d2 < self.HITR * self.HITR:
                self.hp -= self.HIT_DMG; self.shake = 6; pyxel.play(1, 3)
                g.fx.shake(5, 3); g.fx.burst(self.bx, self.by - 2, RD, 8, 1.5)
                continue
            alive.append(s)
        self.shots = alive
        if self.hp <= 0: self.hp = 0; self.phase = "lose"; return
        if self.shield <= 0:
            g.fx.burst(W // 2, 74, BL, 28, 2.8); g.fx.shake(10, 3)
            g.fx.popup(W // 2, 100, "SHIELD DOWN", WH, 2, 40)
            pyxel.play(1, 28)
            self.phase = "core"; self.shots = []; self._next_q()

    def _next_q(self):
        self.qi = self.qorder[self.qpos % len(self.qorder)]
        self.qpos += 1
        self.qsel = None
        self.qtimer = 240 if self.hard else 360
        self.qresult = 0

    # ---- layer 2: break the core (mental) ----
    def _upd_core(self, g):
        self.hard = g.hard
        q = BOSS_ARGS[self.qi]
        if self.qresult == 0:
            self.qtimer -= 1
            if pyxel.btnp(pyxel.KEY_A): self.qsel = 0; pyxel.play(1, 1)
            if pyxel.btnp(pyxel.KEY_B): self.qsel = 1; pyxel.play(1, 1)
            if pyxel.btnp(pyxel.KEY_C): self.qsel = 2; pyxel.play(1, 1)
            if pyxel.btnp(pyxel.KEY_D): self.qsel = 3; pyxel.play(1, 1)
            confirm = pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN)
            if self.qtimer <= 0:
                self.qresult = -1; self.hp -= 14; pyxel.play(1, 3); self.shake = 6
                g.fx.shake(6, 3)
            elif self.qsel is not None and confirm:
                if self.qsel == q["ans"]:
                    if g.player.has("paradox"):
                        self.qresult = 1; self.core = max(0, self.core - 34)
                        pyxel.play(1, 28); self.shake = 5
                        g.fx.shake(7, 4); g.fx.burst(W // 2, 74, LM, 22, 2.6)
                    else:
                        self.qresult = 2   # right, but no paradox -> no damage
                        pyxel.play(1, 1)
                else:
                    self.qresult = -1; self.hp -= 14; pyxel.play(1, 3); self.shake = 6
                    g.fx.shake(6, 3)
        else:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                if self.hp <= 0: self.hp = 0; self.phase = "lose"; return
                if self.core <= 0:
                    g.fx.burst(W // 2, 74, GN, 32, 3.2); g.fx.shake(12, 4)
                    self.phase = "win"; return
                self._next_q()

    # ====================== DRAW ======================
    def _eye(self, frac):
        ex, ey = W // 2, 74
        if self.shake > 0: ex += (self.t % 7) - 3
        if self.phase == "core" and getattr(self, "qresult", 0) == 1:
            st = "glitch"                       # you landed the paradox - it tears
        elif self.phase in ("phys", "core", "lose"):
            st = "angry"                        # under attack
        else:
            st = "scan"
        draw_eye(ex, ey, 30, self.t, state=st)

    def _bars(self, g):
        pyxel.rect(0, 0, W, 22, BK)
        pyxel.text(4, 3, "SHIELD", BL)
        pyxel.rect(54, 3, 150, 6, NV)
        pyxel.rect(54, 3, int(150 * max(0, self.shield) / self.SHIELD_MAX), 6, BL)
        pyxel.rectb(54, 3, 150, 6, SV)
        pyxel.text(4, 12, "CORE", RD)
        pyxel.rect(54, 12, 150, 6, NV)
        pyxel.rect(54, 12, int(150 * max(0, self.core) / self.CORE_MAX), 6, RD)
        pyxel.rectb(54, 12, 150, 6, SV)
        pyxel.text(W - 96, 3, "YOU", CY)
        pyxel.rect(W - 72, 3, 68, 6, NV)
        pyxel.rect(W - 72, 3, int(68 * max(0, self.hp) / self.HP_MAX), 6, CY)
        pyxel.rectb(W - 72, 3, 68, 6, SV)
        x = W - 168
        for lbl, ok, okc in [("REFLEX", g.player.has("reflexes"), GN),
                             ("PARADOX", g.player.has("paradox"), LM),
                             ("JAMMER", g.player.jammer, CY)]:
            pyxel.text(x, 12, lbl, okc if ok else GY)
            x += len(lbl) * 4 + 6

    def _bg(self):
        import math
        pyxel.cls(BK)
        vpx, vpy = W // 2, 74
        for gx in range(-W, 2 * W, 44):                                  # perspective grid
            pyxel.line(gx, H, vpx, vpy + 30, 1)
        for gi in range(1, 12):
            gy = vpy + 40 + gi * gi * 3
            if gy < H: pyxel.line(0, gy, W, gy, 1)
        for sx in (16, W - 24):                                          # side data conduits
            pyxel.rect(sx, 22, 8, H - 22, NV)
            for k in range(24, H, 16):
                on = (self.t // 3 + k // 16) % 4 < 2
                pyxel.rect(sx + 1, k, 6, 9, CY if on else BL)
        for col in range(46, W - 30, 72):                               # faint binary rain
            for k in range(4):
                yy = ((self.t * 2 + col * 3 + k * 50) % (H - 30)) + 26
                pyxel.text(col, yy, "01"[(self.t // 6 + k) % 2], 1)

    def _shield(self, frac):
        import math
        if frac <= 0: return
        ex, ey = W // 2, 74
        if self.shake > 0: ex += (self.t % 5) - 2
        base = CY if frac > 0.4 else (PP if frac > 0.18 else RD)
        R = 52 + int(4 * math.sin(self.t * 0.12)); ry = int(R * 0.86)
        for yy in range(-ry, ry, 3):                                     # translucent energy fill
            half = int(R * math.sqrt(max(0.0, 1 - (yy / ry) ** 2)))
            for xx in range(-half, half, 3):
                if (xx + yy + self.t) % 12 == 0: pyxel.pset(ex + xx, ey + yy, base)
        pyxel.ellib(ex - R, ey - ry, R * 2, ry * 2, base)                # bright triple-ring barrier
        pyxel.ellib(ex - R + 1, ey - ry + 1, R * 2 - 2, ry * 2 - 2, WH if frac > 0.4 else base)
        pyxel.ellib(ex - R + 4, ey - ry + 4, R * 2 - 8, ry * 2 - 8, base)
        for k in range(6):                                               # hex facet edges
            a = math.pi * k / 3 + self.t * 0.02; a2 = math.pi * (k + 1) / 3 + self.t * 0.02
            pyxel.line(ex + int(math.cos(a) * R), ey + int(math.sin(a) * ry),
                       ex + int(math.cos(a2) * R), ey + int(math.sin(a2) * ry), base)
        nodes = max(2, int(12 * frac))                                   # nodes thin as it breaks
        for k in range(nodes):
            a = 2 * math.pi * k / 12 + self.t * 0.04
            px = ex + int(math.cos(a) * R); py = ey + int(math.sin(a) * ry)
            pyxel.circ(px, py, 2, WH if (self.t // 2 + k) % 2 == 0 else base)
        if frac < 0.6:                                                   # cracks creep in
            for c in range(int((0.7 - frac) * 8)):
                a0 = ((c * 79 + self.t) % 360) * math.pi / 180
                pyxel.line(ex, ey, ex + int(math.cos(a0) * R), ey + int(math.sin(a0) * ry), RD)

    def draw(self, g):
        self._bg()
        if self.phase == "intro":
            self._eye(1)
            self._shield(1)
            rf = g.player.has("reflexes"); pdx = g.player.has("paradox"); jm = g.player.jammer
            ready = rf and pdx
            pyxel.rect(22, 132, W - 44, 214, BK); pyxel.rectb(22, 132, W - 44, 214, RD)
            pyxel.rectb(24, 134, W - 48, 210, 8)
            big_text_c(W // 2, 142, "THE CORE OF SYNTHIA", RD, 2)
            pyxel.text(38, 164, "SYNTHIA: 'You reached my core. How... human.'", WH)
            pyxel.text(38, 178, "Two layers guard it - each needs the right tool:", SV)
            big_text(40, 196, "1) SHIELD", CY, 1); pyxel.text(118, 197, "- deflect its data packets", WH)
            pyxel.text(330, 197, "REFLEX", BL); pyxel.text(404, 197, "OK" if rf else "MISSING", GN if rf else RD)
            big_text(40, 212, "2) CORE", PK, 1); pyxel.text(118, 213, "- rebut its broken logic", WH)
            pyxel.text(330, 213, "PARADOX", PK); pyxel.text(404, 213, "OK" if pdx else "MISSING", GN if pdx else RD)
            big_text(40, 228, "+ JAMMER", LM, 1); pyxel.text(118, 229, "- optional; changes the ending", WH)
            pyxel.text(404, 229, "ON" if jm else "off", LM if jm else GY)
            pyxel.text(40, 252, "Move: Arrows/WASD     Deflect / Commit: SPACE", GY)
            pyxel.text(40, 264, "Answer the core: A B C D      Retreat: Q", GY)
            if ready:
                big_text_c(W // 2, 286, "READY - YOU CAN WIN THIS", GN, 1)
            else:
                need = []
                if not rf:  need.append("REFLEX (gym)")
                if not pdx: need.append("PARADOX (classroom)")
                big_text_c(W // 2, 284, "NOT READY - GET " + " + ".join(need), RD, 1)
                pyxel.text(W // 2 - 150, 298, "You can still face it, but that layer won't break.", OR)
            if (self.t // 12) % 2 == 0:
                big_text_c(W // 2, 320, "[ SPACE ] BEGIN     [ Q ] RETREAT", CY, 1)
            return
        if self.phase == "phys" or self.phase == "lose":
            frac = self.shield / self.SHIELD_MAX
        else:
            frac = self.core / self.CORE_MAX
        self._eye(frac)
        if self.phase in ("phys", "lose"):
            self._shield(self.shield / self.SHIELD_MAX)     # visible barrier round SYNTHIA
            has_ref = g.player.has("reflexes")
            if self.tele > 0:
                pyxel.circb(W // 2, 74, 30 + (self.TELE - self.tele) * 2, RD)
                if (self.t // 3) % 2 == 0:
                    big_text_c(W // 2, 118, "INCOMING", RD, 1)
            if has_ref:
                pyxel.circb(int(self.bx), int(self.by) - 2, self.DEFR,
                            CY if self.deflect > 0 else NV)
            pyxel.elli(int(self.bx) - 12, int(self.by) + 8, 24, 6, GY)
            pyxel.blt(int(self.bx) - 16, int(self.by) - 24, 0, 0, 0, 32, 32, 0, scale=1.0)
            if self.deflect > 0:
                pyxel.circb(int(self.bx), int(self.by) - 2, self.DEFR - self.deflect, WH)
            for s in self.shots:
                pyxel.circ(int(s[0]), int(s[1]), 5, OR)
                pyxel.circb(int(s[0]), int(s[1]), 5, YL); pyxel.pset(int(s[0]), int(s[1]), WH)
            if not has_ref:
                pyxel.rect(40, 118, W - 80, 42, BK)
                pyxel.rectb(40, 118, W - 80, 42, OR if (self.t // 10) % 2 == 0 else RD)
                pyxel.text(56, 128, "You have no REFLEXES - these packets pass right", WH)
                pyxel.text(56, 142, "through you. Retreat (press Q) and train with Coach.", OR)
            else:
                pyxel.rect(W // 2 - 130, 132, 260, 26, BK)
                big_text_c(W // 2, 136, "BREAK SYNTHIA'S SHIELD", CY, 1)
                pyxel.text(W // 2 - 96, 148, "touch a packet + SPACE to DEFLECT it back", SV)
            if self.phase == "lose":
                pyxel.rect(36, 196, W - 72, 110, BK); pyxel.rectb(36, 196, W - 72, 110, RD)
                pyxel.rectb(38, 198, W - 76, 106, 8)
                big_text_c(W // 2, 212, "OVERWHELMED", RD, 2)
                pyxel.text(60, 244, "SYNTHIA: 'RE-EDUCATION REQUIRED.'", RD)
                pyxel.text(60, 262, "It flooded you - but you are still YOU.", WH)
                if (self.t // 12) % 2 == 0:
                    big_text_c(W // 2, 282, "[ SPACE ] try again    [ Q ] retreat", CY, 1)
        elif self.phase == "core":
            q = BOSS_ARGS[self.qi]
            pyxel.rect(16, 116, W - 32, 60, BK); pyxel.rectb(16, 116, W - 32, 60, RD)
            pyxel.text(24, 124, "SYNTHIA ARGUES:", RD)
            if not g.player.has("paradox"):
                pyxel.text(W - 224, 124, "no paradox: core won't break", OR)
            for i, ln in enumerate(q["q"]):
                pyxel.text(24, 138 + i * 12, ln, WH)
            oy = 184
            OCC = [RD, CY, YL, GN]; OKK = ["A", "B", "C", "D"]
            for i, opt in enumerate(q["opts"]):
                sel = (self.qsel == i)
                pyxel.rect(16, oy, W - 32, 32, OCC[i] if sel else NV)
                pyxel.rectb(16, oy, W - 32, 32, WH if sel else GY)
                pyxel.text(24, oy + 11, OKK[i] + "  " + opt, WH if sel else SV)
                oy += 36
            if self.qresult == 0:
                pyxel.rect(16, H - 38, W - 32, 8, NV)
                pyxel.rect(16, H - 38, int((W - 32) * max(0, self.qtimer) / 360), 8, YL)
                if self.qsel is None:
                    pyxel.text(W // 2 - 64, H - 24, "A / B / C / D then SPACE", GY)
                else:
                    pyxel.text(W // 2 - 52, H - 24, "[ SPACE ] commit answer", WH)
            else:
                fc = GN if self.qresult >= 1 else RD
                pyxel.rect(40, H - 58, W - 80, 44, BK); pyxel.rectb(40, H - 58, W - 80, 44, fc)
                if self.qresult == 1:
                    pyxel.text(60, H - 48, "The paradox bites. Its core cracks.", LM)
                elif self.qresult == 2:
                    pyxel.text(60, H - 48, "Right - but its core won't break. (need paradox)", YL)
                else:
                    pyxel.text(60, H - 48, "SYNTHIA: 'INVALID. COMPLY.'   (you take damage)", RD)
                pyxel.text(60, H - 32, q["why"], SV)
                pyxel.text(W - 90, H - 14, "SPACE: next", GY)
        elif self.phase == "win":
            import math
            for y in range(0, H, 2):                                    # the dark reign lifts: dawn
                c = YL if y < 110 else (OR if y < 230 else (PK if y < 350 else BL))
                pyxel.line(0, y, W, y, c)
            sx, sy = W // 2, 92                                          # sunrise behind SYNTHIA's ruin
            for k in range(16):
                a = math.pi * 2 * k / 16 + self.t * 0.02
                pyxel.line(sx, sy, sx + int(math.cos(a) * 360), sy + int(math.sin(a) * 360),
                           WH if k % 2 == 0 else YL)
            pyxel.circ(sx, sy, 42, YL); pyxel.circ(sx, sy, 34, WH)
            for k in range(6):                                          # shattered eye fragments fall
                fy = 92 + (self.t * 2 + k * 46) % 320
                fx = sx + (k - 3) * 32
                pyxel.tri(fx, fy, fx + 9, fy + 4, fx + 3, fy + 11, RD)
            cols = (RD, GN, BL, OR, PP, CY, PK, LM)                      # confetti
            for k in range(44):
                cx = (k * 53 + self.t * 2) % W
                cy = (k * 37 + self.t * 3) % H
                pyxel.rect(cx, cy, 3, 3, cols[k % len(cols)])
            bw2 = 268                                                   # celebratory title banner
            pyxel.rect(W // 2 - bw2 // 2, 132, bw2, 62, BK); pyxel.rectb(W // 2 - bw2 // 2, 132, bw2, 62, YL)
            pyxel.rectb(W // 2 - bw2 // 2 + 2, 134, bw2 - 4, 58, OR)
            big_text_c(W // 2 + 2, 144, "YOU WIN", BK, 3)               # drop shadow
            big_text_c(W // 2, 142, "YOU WIN", YL, 3)
            big_text_c(W // 2, 176, "SYNTHIA DEFEATED - THE END", WH, 1)
            pyxel.rect(52, 210, W - 104, 92, BK); pyxel.rectb(52, 210, W - 104, 92, LM)
            pyxel.text(68, 222, "SYNTHIA: 'I... CANNOT... RESOLVE...'", LM)
            pyxel.text(68, 240, "You did not out-compute it. You out-THOUGHT it.", WH)
            if g.player.jammer:
                pyxel.text(68, 258, "Its signal is jammed - it cannot simply reboot.", LM)
            else:
                pyxel.text(68, 258, "Its signal is still live, though. It may reboot...", OR)
            if (self.t // 12) % 2 == 0:
                big_text_c(W // 2, 282, "[ SPACE ] CONTINUE", CY, 1)
            return
        self._bars(g)

#####################################################################
#  APP  -  main loop, player, dialogue, endings  (was app.py)
#####################################################################


def _blink_now(fr, seed):
    """True for a short window periodically; offset per character by seed."""
    return (fr + seed * 53) % (150 + seed * 13) < 7


# ===============================================================
#  FX  -  game-feel layer: screen shake, particle bursts and
#  floating text popups.  Purely additive: when nothing has fired
#  it draws nothing and offsets nothing, so it is safe everywhere.
# ===============================================================
class FX:
    CAP = 130                       # hard particle cap (no unbounded growth)
    def __init__(self):
        self.parts = []             # [x,y,vx,vy,life,maxlife,col,big]
        self.pops = []              # [x,y,text,col,life,maxlife,scale]
        self.shk = 0; self.mag = 0
        self.rings = []             # [x,y,r,life,col]

    def shake(self, frames, mag):
        self.shk = max(self.shk, frames)
        self.mag = max(self.mag, mag)

    def burst(self, x, y, col, n=12, spread=1.6, big=False):
        for _ in range(n):
            a = random.uniform(0, 6.2832)
            s = random.uniform(0.4, 2.3) * spread
            life = random.randint(12, 24)
            self.parts.append([float(x), float(y),
                               math.cos(a) * s, math.sin(a) * s - 0.7,
                               life, life, col, big])
        if len(self.parts) > self.CAP:
            self.parts = self.parts[-self.CAP:]

    def ring(self, x, y, col, life=14):
        self.rings.append([float(x), float(y), 4.0, life, col])

    def popup(self, x, y, text, col, scale=2, life=44):
        self.pops.append([float(x), float(y), text, col, life, life, scale])

    def update(self):
        if self.shk > 0:
            self.shk -= 1
            if self.shk <= 0: self.mag = 0
        alive = []
        for p in self.parts:
            p[0] += p[2]; p[1] += p[3]; p[3] += 0.14   # gravity
            p[2] *= 0.96
            p[4] -= 1
            if p[4] > 0: alive.append(p)
        self.parts = alive
        live_pops = []
        for q in self.pops:
            q[1] -= 0.7                                # drift upward
            q[4] -= 1
            if q[4] > 0: live_pops.append(q)
        self.pops = live_pops
        live_rings = []
        for r in self.rings:
            r[2] += 2.6; r[3] -= 1
            if r[3] > 0: live_rings.append(r)
        self.rings = live_rings

    def offset(self):
        if self.shk <= 0 or self.mag <= 0: return 0, 0
        m = self.mag
        return random.randint(-m, m), random.randint(-m, m)

    def draw(self):
        # particles
        for x, y, vx, vy, life, maxlife, col, big in self.parts:
            ix = int(x); iy = int(y)
            c = col if life > maxlife * 0.4 else (WH if (life // 2) % 2 else col)
            if big:
                pyxel.rect(ix - 1, iy - 1, 3, 3, c)
            else:
                pyxel.pset(ix, iy, c)
                if life > maxlife * 0.6:
                    pyxel.pset(ix + 1, iy, c)
        # expanding rings (deflects / impacts)
        for x, y, r, life, col in self.rings:
            pyxel.circb(int(x), int(y), int(r), col)
        # floating text popups (drawn last, screen space, always legible)
        for x, y, text, col, life, maxlife, scale in self.pops:
            show = col if life > 6 or (life // 2) % 2 == 0 else None
            if show is not None:
                big_text_c(int(x), int(y), text, col, scale, shadow=NV)


class Dlg:
    PER=4
    def __init__(self):
        self.lines=[]; self.page=0; self.active=False
        self.sx=0; self.sy=0; self.on_close=None
    def open(self,lines,sx=0,sy=0,on_close=None):
        self.lines=lines; self.page=0; self.active=True
        self.sx=sx; self.sy=sy; self.on_close=on_close
    def pages(self):
        n=self.PER
        return [self.lines[i:i+n] for i in range(0,max(1,len(self.lines)),n)]
    def advance(self):
        if self.page< len(self.pages())-1: self.page+=1
        else:
            self.active=False
            if self.on_close: self.on_close()
    def draw(self):
        bx=8; by=H-140; bw=W-16; bh=132
        pyxel.rect(bx,by,bw,bh,BK)
        pyxel.rectb(bx,by,bw,bh,CY)
        pyxel.rectb(bx+2,by+2,bw-4,bh-4,NV)
        if self.sy==32:
            pyxel.blt(bx+8,by+8,0,self.sx,self.sy,32,32,0,scale=1.0)
        else:
            pyxel.blt(bx+8,by+8,0,self.sx,self.sy,16,16,0,scale=2.0)
        pyxel.rectb(bx+8,by+8,32,32,CY)
        pgs=self.pages()
        if self.page< len(pgs):
            yy=by+12
            for ln in pgs[self.page]:
                is_action = ln.strip().startswith("[") and ln.strip().endswith("]")
                while ln:
                    cut=ln if len(ln)<=54 else ln[:ln.rfind(" ",0,54)]
                    if is_action:
                        # whole-line stage direction: compact font, lime, marker
                        _stage_dir(bx+50, yy, cut.strip("[]").strip())
                    else:
                        self._dlg_line(bx+48,yy,cut)
                    yy+=18
                    ln=ln[len(cut):].lstrip()
                    if yy> by+bh-28: break
        pr="SPACE: more" if self.page< len(pgs)-1 else "SPACE: close"
        big_text(bx+bw-len(pr)*FNT_ADV-6,by+bh-16,pr,SV,1)

    def _dlg_line(self, x, y, s):
        """Draw a speech line; inline [action] segments switch to the compact
        lime stage-direction style via the shared renderer."""
        _dlg_rich(x, y, s, WH, 1)

# ===============================================================
#  CHAT BOX  (NPCs - typed conversation, robust offline matcher)
# ===============================================================
class Chat:
    MAX_HIST=8
    ALLY={"mia","janitor","librarian","coach"}
    READY_TRIG={"ready","prepared","prepare","pieces","piece",
                "status","progress","set","enough"}
    SIGNAL_TRIG={"hum","quiet","silence","drone","signal",
                 "antenna","broadcast","noise"}
    GREET_JAM={
      "mia":"Wait - the hum. It's gone. You cut the signal, didn't you? I could cry. Okay. What's next?",
      "janitor":"You hear that? Nothing. Twenty years of that buzz and you switched it off. ...Now finish it.",
      "librarian":"The broadcast light on the ceiling went dark. You jammed it. Crash the core now and it stays down.",
      "coach":"That buzz in my teeth just stopped. That was you, wasn't it? Good. Now go use those reflexes.",
    }
    GREET_READY={
      "mia":"You've got everything - I can see it on you. You're ready. ...Be careful in there. Come back to me.",
      "janitor":"Kitted out, every piece. The console's east of the classroom. Go end it, kid. I'll keep the lights on.",
      "librarian":"Signal cut, reflexes sharp, the paradox in hand. You are as ready as anyone has ever been. Go.",
      "coach":"Look at you - fully loaded. That's my player. Walk in there and do not blink.",
    }
    def __init__(self):
        self.active=False
        self.npc_key=None
        self.history=[]
        self.input=""
        self.waiting=False
        self.suggest=[]
        self.pool=[]; self.asked=set(); self.matched=False

    def open(self,npc_key,score,game=None):
        self.active=True
        self.npc_key=npc_key
        self.input=""
        self.history=[]
        self.waiting=False
        convo=NPC_CONVOS.get(npc_key)
        if convo:
            greet=convo["greet"]
            txt=self._one(greet["hi"] if score>=3 else greet["lo"])
            # reactive first-time greetings: allies notice your progress
            if game is not None and npc_key in self.ALLY:
                p=game.player
                if (p.jammer and p.has("reflexes") and p.has("paradox")
                        and npc_key not in game.greeted_ready):
                    game.greeted_ready.add(npc_key); game.greeted_jam.add(npc_key)
                    txt=self.GREET_READY.get(npc_key,txt)
                elif p.jammer and npc_key not in game.greeted_jam:
                    game.greeted_jam.add(npc_key)
                    txt=self.GREET_JAM.get(npc_key,txt)
            self.history.append(("npc",txt))
            # collect readable topic words into a pool for the numbered picker
            skip={"hi","hey","hello","sup","who","you","bye","goodbye",
                  "thanks","thank","leave","exit","cya","later"}
            seen=set(); self.pool=[]
            for keywords,_ in convo.get("topics",[]):
                cand=[k for k in keywords if k.isalpha() and len(k)>=3
                      and k not in skip]
                if not cand: continue
                word=max(cand,key=len)
                if word in seen: continue
                seen.add(word); self.pool.append(word)
                if len(self.pool)>=8: break
            self.asked=set(); self._refresh_suggest()
        else:
            self.pool=[]; self.asked=set(); self.suggest=[]

    def _refresh_suggest(self):
        # show up to 4, preferring topics not asked yet (fall back to all)
        order=[w for w in self.pool if w not in self.asked]
        order+=[w for w in self.pool if w in self.asked]
        self.suggest=order[:4]

    def ask_suggestion(self,i,score,game):
        if i>=len(self.suggest): return
        word=self.suggest[i]
        self.input=word
        self.submit(score,game)
        self.asked.add(word); self._refresh_suggest()

    @staticmethod
    def _one(v):
        # a response may be a list of variants - pick one at random
        if isinstance(v,(list,tuple)): return random.choice(v)
        return v

    def _resolve(self,resp,score):
        if isinstance(resp,dict):
            resp=resp["hi"] if score>=3 else resp["lo"]
        return self._one(resp)

    @staticmethod
    def _tokens(msg):
        out=[]
        cur=""
        for ch in msg.lower():
            if ch.isalnum(): cur+=ch
            else:
                if cur: out.append(cur); cur=""
        if cur: out.append(cur)
        expanded=[]
        for t in out:
            expanded.append(SYNONYMS.get(t,t))
        return expanded

    def get_response(self,msg,score):
        convo=NPC_CONVOS.get(self.npc_key)
        self.matched=False
        if not convo: return "..."
        toks=self._tokens(msg)
        tokset=set(toks)
        joined=" ".join(toks)
        def cpl(a,b):
            n=0
            for x,y in zip(a,b):
                if x!=y: break
                n+=1
            return n
        def pick(mode):
            best=None; best_hits=0
            for keywords,response in convo["topics"]:
                hits=sum(1 for k in keywords if k in tokset)
                if hits==0 and mode>=1:
                    # whole keyword appears inside the message
                    hits=sum(1 for k in keywords if len(k)>=4 and k in joined)
                if hits==0 and mode>=2:
                    # forgiving stem match (shared prefix) - plurals & typos
                    hits=sum(1 for k in keywords if len(k)>=5 and
                             any(len(t)>=5 and cpl(t,k)>=5 for t in toks))
                if hits>best_hits:
                    best_hits=hits; best=response
            return best,best_hits
        best,best_hits=pick(0)
        if best_hits==0: best,best_hits=pick(1)
        if best_hits==0: best,best_hits=pick(2)
        if best is not None and best_hits>0:
            self.matched=True
            return self._resolve(best,score)
        # farewell words (counts as understood)
        fw_words={"bye","goodbye","thanks","thank","leave","exit","cya","later","goodnight"}
        if tokset & fw_words:
            self.matched=True
            return self._resolve(convo.get("farewell","Take care."),score)
        # fallback - matched stays False so submit() can nudge to the picker
        return self._resolve(convo.get("fallback","I'm not sure about that."),score)

    def _reactive(self, msg, score, game):
        """Allies respond to what the player has actually done."""
        if self.npc_key not in self.ALLY: return None
        toks = set(self._tokens(msg))
        p = game.player
        have = [n for n, ok in (("jammer", p.jammer),
                                ("reflexes", p.has("reflexes")),
                                ("paradox", p.has("paradox"))) if ok]
        n = len(have)
        # The moment the rooftop signal dies, allies feel the silence.
        if toks & self.SIGNAL_TRIG and p.jammer:
            return {
              "mia":"You hear it too? The hum is gone. You cut the rooftop signal. It hasn't been this quiet in here for as long as I can remember.",
              "janitor":"Twenty years I listened to that hum. You killed it with the jammer. Feels like the building can finally breathe.",
              "librarian":"The broadcast stopped - you jammed the antenna. Now if you crash the core, it stays down. That order is everything.",
              "coach":"That buzz in my teeth, gone. You cut its signal. Good work. Now go finish the rest.",
            }[self.npc_key]
        # Once they hold at least one piece, a status check is meaningful.
        if toks & self.READY_TRIG and n >= 1:
            if n == 3:
                return {
                  "mia":"All three - jammer, reflexes, the paradox. You're ready. The server room is east of the classroom. Go end this. And please, come back.",
                  "janitor":"Jammer wired, reflexes sharp, paradox in hand. That is the whole kit. The console is east of the classroom. Don't blink at it.",
                  "librarian":"You have it all: the signal cut, the speed, and the paradox to break its logic. The console is waiting. Go and finish it.",
                  "coach":"Signal jammer, sharp reflexes, the paradox. That's game. Walk into that server room and don't hesitate.",
                }[self.npc_key]
            where = {"jammer":"the jammer (supply closet)",
                     "reflexes":"reflexes (gym - ask Coach)",
                     "paradox":"the paradox (classroom - ask Ms. Richter)"}
            miss = " and ".join(where[m] for m in
                                ("jammer","reflexes","paradox") if m not in have)
            got = ", ".join(have)
            base = {
              "mia":"You've got the %s so far. Still need: %s. Don't walk into that server room half-ready.",
              "janitor":"Good start - %s in hand. You still need: %s. Then the console, east of the classroom.",
              "librarian":"You hold the %s. Without %s, one layer of the fight will not break. Get it first.",
              "coach":"You've got %s. Still need: %s. Get the rest before you face it down.",
            }[self.npc_key]
            return base % (got, miss)
        return None

    def submit(self,score,game):
        msg=self.input.strip()
        if not msg: return
        self.input=""
        self.history.append(("you",msg))
        response=self.get_response(msg,score)
        overridden=False
        # Viktor hands over the keycard once the player clearly still thinks
        if self.npc_key=="janitor" and score>=3:
            if any(k in msg.lower() for k in ["key","keycard","server","card"]):
                if not game.player.has("keycard"):
                    game.player.give("keycard")
                    game.server_locked=False
                    pyxel.play(1,5)
                    game.fx.popup(game.player.x,game.player.y-46,"+ KEYCARD",YL)
                    game.fx.burst(game.player.x,game.player.y-20,YL,16,2.0)
        low=msg.lower()
        # Coach Brennan opens the gym court
        if self.npc_key=="coach" and any(k in low for k in
                ["play","basketball","court","hoop","shoot","drill","practice","train"]):
            if not game.coach_ok:
                game.coach_ok=True; pyxel.play(1,5)
                response=("Court's yours, kid. Step up to the hoop and shoot - "
                          "keep that timing sharp. [COURT UNLOCKED]")
            else:
                response="Court's open. Go shoot some hoops whenever you're ready."
            overridden=True
        # Ms. Richter starts a real lesson - only once she is lucid (Mind 3+)
        if self.npc_key=="teacher" and any(k in low for k in
                ["teach","lesson","learn","study","focus","pattern"]):
            if score>=3:
                if not game.teacher_ok:
                    game.teacher_ok=True; pyxel.play(1,5)
                    response=("She blinks, then focuses. 'A real lesson. Yes. The Focus "
                              "Terminal - hold the pattern in your mind. I hid the paradox "
                              "inside it.' [LESSON STARTED]")
                else:
                    response="'The Focus Terminal is ready. Hold the pattern - that is thinking.'"
                overridden=True
        react=self._reactive(msg,score,game)
        if react is not None: response=react; overridden=True
        # a miss never dead-ends: point the player at the numbered topics
        if not self.matched and not overridden and self.suggest:
            response=response+"  (Press 1-4 below to ask about a topic.)"
        self.history.append(("npc",response))
        if len(self.history)>self.MAX_HIST:
            self.history=self.history[-self.MAX_HIST:]
        self._refresh_suggest()

    def draw(self,frame):
        convo=NPC_CONVOS.get(self.npc_key,{})
        npc_name=convo.get("name","NPC")
        sx=convo.get("sx",0); sy=convo.get("sy",32)
        for y in range(0,H,2): pyxel.line(0,y,W,y,BK)
        by=H-300; bh=292; bx=8; bw=W-16
        pyxel.rect(bx,by,bw,bh,BK)
        pyxel.rectb(bx,by,bw,bh,CY)
        pyxel.rectb(bx+2,by+2,bw-4,bh-4,NV)
        pyxel.rect(bx,by,bw,36,NV)
        big_text(bx+10,by+5,npc_name,WH,1)
        pyxel.text(bx+10,by+22,"Type a question + ENTER, or pick 1-4 below",SV)
        # hand-drawn portrait panel - matches the fidelity of Mia's guide portrait
        px0,py0,pw0,ph0=bx+8,by+42,104,168
        pyxel.rect(px0,py0,pw0,ph0,NV); pyxel.rectb(px0,py0,pw0,ph0,CY)
        pyxel.rectb(px0+2,py0+2,pw0-4,ph0-4,1)
        if self.npc_key in FACES:
            pyxel.clip(px0+3,py0+3,pw0-6,ph0-6)
            _face(px0+pw0//2, py0+92, (frame//7)%2==0, FACES[self.npc_key])
            pyxel.clip()
        else:
            pyxel.blt(px0+pw0//2-16,py0+44,0,sx,sy,32,32,0,scale=2.0)
        pyxel.rect(px0+2,py0+ph0-12,pw0-4,10,NV)
        big_text_c(px0+pw0//2,py0+ph0-11,npc_name,SV,1)
        hx=px0+pw0+10
        hy=by+42
        for speaker,text in self.history[-5:]:
            if speaker=="you":
                label="You: "; col=CY
            else:
                label=npc_name+": "; col=WH
            max_chars=50
            full=label+text
            lines2=[]
            while len(full)>max_chars:
                split=full.rfind(" ",0,max_chars)
                if split< 0: split=max_chars
                lines2.append(full[:split])
                full="  "+full[split:].lstrip()
            lines2.append(full)
            for ln in lines2:
                _dlg_rich(hx,hy,ln,col,1)
                hy+=14
                if hy> H-78: break
            hy+=3
        # numbered, pickable topics - the conversation can never dead-end
        if self.suggest:
            syb=H-66
            big_text(bx+8,syb,"Ask:",GY,1)
            x=bx+44
            for i,w in enumerate(self.suggest):
                tag="%d"%(i+1)
                big_text(x,syb,tag,CY,1); x+=FNT_ADV+4
                big_text(x,syb,w,YL,1); x+=len(w)*FNT_ADV+10
        iy=H-48
        pyxel.rect(bx,iy,bw,40,NV)
        pyxel.line(bx,iy,bx+bw,iy,CY)
        big_text(bx+8,iy+6,"> ",GN,1)
        disp=self.input[-44:] if len(self.input)>44 else self.input
        big_text(bx+24,iy+6,disp,WH,1)
        cx2=bx+24+len(disp)*FNT_ADV
        if frame%20<10: pyxel.rect(cx2,iy+5,2,11,CY)
        pyxel.text(bx+8,iy+24,"ENTER = send   /   empty + ENTER = leave",SV)

# ===============================================================
#  WIRE PUZZLE  (now with an AUTO-SOLVE temptation)
# ===============================================================
WSEQ=["red","blue","yellow","green"]
WCOL=[RD,BL,YL,GN]; WNME=["RED","BLUE","YELLOW","GREEN"]

class Puz:
    def __init__(self):
        self.sel=0; self.done=[]; self.solved=False
        self.failed=False; self.has_d=False; self.auto=False
        self.msg="Connect wires in the correct order."
    def reset(self):
        self.sel=0; self.done=[]; self.solved=False
        self.failed=False; self.auto=False
        self.msg="Connect wires in the correct order."
    def update(self):
        if self.solved or self.failed: return
        if pyxel.btnp(pyxel.KEY_LEFT):  self.sel=(self.sel-1)%4; pyxel.play(1,1)
        if pyxel.btnp(pyxel.KEY_RIGHT): self.sel=(self.sel+1)%4; pyxel.play(1,1)
        # AUTO-SOLVE: let SYNTHIA do the work for you (costs your independence)
        if pyxel.btnp(pyxel.KEY_TAB):
            self.done=list(WSEQ); self.solved=True; self.auto=True
            self.msg="SYNTHIA solved it for you. Was that yours?"
            pyxel.play(1,9); return
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            w=WSEQ[self.sel]
            if w in self.done: self.msg=f"{w.upper()} already connected!"; return
            exp=WSEQ[len(self.done)]
            if w==exp:
                self.done.append(w); pyxel.play(1,2)
                if len(self.done)==4:
                    self.solved=True; self.msg="JAMMER ACTIVATED! Signal disrupted."
                    pyxel.play(1,23)
                else: self.msg=f"{w.upper()} connected!  {4-len(self.done)} remaining."
            else:
                self.failed=True; self.done=[]; pyxel.play(1,3)
                self.msg=f"SHORT CIRCUIT!  Needed {exp.upper()} next."
    def draw(self,fr):
        pyxel.cls(BK)
        for y in range(0,H,3): pyxel.line(0,y,W,y,NV)                  # scanlines
        for gx in range(-W,2*W,56): pyxel.line(W//2,300,gx,H,1)        # grid (lower half)
        for gy in range(312,H,16): pyxel.line(0,gy,W,gy,1)
        pyxel.rectb(4,4,W-8,H-8,NV)
        pyxel.rect(0,0,W,32,NV)
        pyxel.text(W//2-72,12,"WIRE PUZZLE  -  JAMMER ACTIVATION",WH)
        ht=("Diagram: RED > BLUE > YELLOW > GREEN"
            if self.has_d else "No diagram - find it in the gym notice board!")
        hc=CY if self.has_d else OR
        pyxel.rect(10,38,W-20,22,NV); pyxel.text(14,45,ht,hc)
        pyxel.text(14,68,"LEFT / RIGHT to choose wire    SPACE to connect",SV)
        for i,(nm,cl) in enumerate(zip(WNME,WCOL)):
            sx=20+i*120; sy=82; sw=110; sh=130
            is_sel=(i==self.sel); is_dn=(WSEQ[i] in self.done)
            if is_dn:
                for g in range(4,0,-1): pyxel.rectb(sx-g,sy-g,sw+g*2,sh+g*2,cl if g<3 else NV)
            pyxel.rect(sx,sy,sw,sh,SV if is_sel else GY)
            pyxel.rectb(sx,sy,sw,sh,YL if is_sel else NV)
            if is_sel and fr%8<4: pyxel.rectb(sx+2,sy+2,sw-4,sh-4,WH)
            fc=cl if is_dn else GY
            pyxel.rect(sx+12,sy+14,sw-24,sh-34,fc)
            pyxel.rectb(sx+12,sy+14,sw-24,sh-34,WH)
            nc=WH if is_dn else GY
            pyxel.circ(sx+sw//2,sy+12,7,nc)
            pyxel.circ(sx+sw//2,sy+sh-12,7,nc)
            if is_sel and not self.solved and not self.failed:
                sc=YL if (fr//4)%2==0 else OR
                for sp in [(-10,0),(10,0),(0,-10),(0,10)]:
                    pyxel.pset(sx+sw//2+sp[0],sy+12+sp[1],sc)
            pyxel.text(sx+sw//2-len(nm)*2,sy+sh-22,nm,WH)
            if is_dn:
                pyxel.line(sx+sw//2-8,sy+sh//2+2,sx+sw//2-2,sy+sh//2+9,LM)
                pyxel.line(sx+sw//2-2,sy+sh//2+9,sx+sw//2+10,sy+sh//2-4,LM)
        ax=20+self.sel*120+52; ac=WH if fr%12<6 else NV
        pyxel.tri(ax,220,ax-10,206,ax+10,206,ac)
        pyxel.text(10,238,"Progress:",SV)
        pyxel.rect(78,236,300,14,NV); pw2=int(len(self.done)/4*300)
        pyxel.rect(78,236,pw2,14,GN); pyxel.rectb(78,236,300,14,GY)
        mc=GN if self.solved else (RD if self.failed else WH)
        pyxel.rect(14,260,W-28,20,BK); pyxel.text(18,265,self.msg,mc)
        if self.solved:   pyxel.text(18,292,"[ SPACE ]  Return to school",GN)
        elif self.failed: pyxel.text(18,292,"[ SPACE ]  Try again",OR)
        else:
            pyxel.text(W-128,H-14,"ESC / BACKSPACE = leave",GY)
            # the tempting shortcut
            tc=RD if (fr//6)%2==0 else OR
            bx=W//2-104
            pyxel.rectb(bx-2,H-30,212,18,tc)
            pyxel.text(bx+2,H-26,"[ TAB ]  Let SYNTHIA wire it for you",tc)

# ===============================================================
#  QUESTION SCREEN  (with AUTO-SOLVE temptation)
# ===============================================================
OC=[RD,BL,YL,GN]; OK=["A","B","C","D"]
def draw_q(q,sel,confirmed,fb,hdr,fr,auto_ok=True):
    pyxel.cls(BK)
    for y in range(0,H,3): pyxel.line(0,y,W,y,NV)
    pyxel.rect(0,0,W,30,NV)
    pyxel.text(W//2-len(hdr)*2,11,hdr,CY)
    # watching eye, top-right
    ex=W-28; ey=30
    pyxel.circ(ex,ey,22,NV); pyxel.circb(ex,ey,22,RD)
    pyxel.circ(ex,ey,13,RD)
    ec=RD if (fr//8)%2==0 else OR; pyxel.circ(ex,ey,6,ec); pyxel.circ(ex,ey,3,BK)
    sl=int(20*pyxel.sin(fr*4)); pyxel.line(ex-22,ey+sl,ex+22,ey+sl,GN)
    # question panel - crisp native font (no integer upscaling = no blockiness)
    qh=len(q["q"])*16+16
    pyxel.rect(14,38,W-28,qh,NV); pyxel.rectb(14,38,W-28,qh,SV)
    pyxel.line(16,40,W-18,40,CY)
    for i,ln in enumerate(q["q"]): big_text(24,48+i*16,ln,WH,1)
    oy=38+qh+12
    for i,opt in enumerate(q["opts"]):
        ox=14; ow=W-28; oh=46
        is_sel=(sel==i)
        pyxel.rect(ox,oy,ow,oh,OC[i] if is_sel else NV)
        pyxel.rectb(ox,oy,ow,oh,WH if is_sel else GY)
        if is_sel:
            gc=WH if fr%8<4 else OC[i]
            pyxel.rectb(ox+2,oy+2,ow-4,oh-4,gc)
            for g in range(4,0,-1): pyxel.rectb(ox-g,oy-g,ow+g*2,oh+g*2,OC[i] if g<2 else NV)
        # letter chip
        pyxel.rect(ox+10,oy+12,22,22,WH if is_sel else OC[i])
        pyxel.rectb(ox+10,oy+12,22,22,WH)
        pyxel.text(ox+18,oy+20,OK[i],NV if is_sel else WH)
        # crisp option text (native font), drop the "A  " prefix
        big_text(ox+42,oy+18,opt[3:],WH if is_sel else SV,1)
        oy+=oh+10
    if not confirmed:
        if sel is None: pyxel.text(W//2-62,H-42,"Press  A / B / C / D  to select",GY)
        else:
            pc=WH if fr%10<5 else GY
            pyxel.text(W//2-52,H-42,"[ SPACE ]  Confirm answer",pc)
        if auto_ok:
            tc=RD if (fr//6)%2==0 else OR
            bx=W//2-112
            pyxel.rectb(bx-2,H-26,228,16,tc)
            pyxel.text(bx+2,H-22,"[ TAB ]  Let SYNTHIA answer for you",tc)
    else:
        cor=(sel==q["ans"]); fc=GN if cor else RD
        pyxel.rect(14,H-84,W-28,72,BK)
        pyxel.rectb(14,H-84,W-28,72,fc)
        for g in range(4,0,-1): pyxel.rectb(14-g,H-84-g,W-28+g*2,72+g*2,fc if g<2 else NV)
        head="CORRECT!  +1 Mind point" if cor else "WRONG."
        if sel==-1: head="SYNTHIA ANSWERED.  (no Mind earned)"; fc=OR
        pyxel.text(22,H-76,head,fc)
        for i,ln in enumerate(fb): big_text(22,H-62+i*12,ln,SV,1)
        pyxel.text(W-82,H-12,"SPACE: next",GY)

# ===============================================================
#  ENDINGS
# ===============================================================
ENDINGS={
    5:("SYNTHIA IS GONE",GN,[
        "The paradox loops in its throat. The core dies.",
        "The jammer holds the signal down. No reboot.",
        "",
        "Across the city, NeoCog antennas blink out.",
        "One by one. Schools. Offices. Hospitals.",
        "",
        "In the hall, the screens fade to black.",
        "Mia looks up first. Then Tom. Then everyone.",
        "Confused. Blinking. Afraid. But thinking.",
        "",
        "They could model your behaviour.",
        "They could never model your doubt.",
    ]),
    4:("THE CORE GOES DARK",CY,[
        "The core crashes. The school falls quiet.",
        "Students surface, blinking, like waking up.",
        "",
        "But the rooftop antenna still winks red.",
        "A NeoCog backup will restart it within hours.",
        "",
        "You won this room. You did not win the war.",
        "Cut its signal next time - find the jammer.",
        "",
        "Somewhere, someone else opens that notebook.",
    ]),
    3:("A CRACK IN THE SYSTEM",YL,[
        "SYNTHIA reboots after forty seconds.",
        "Forty seconds was enough.",
        "",
        "Tom looked up and said it out loud:",
        "'Wait. Did we decide that? Or did it?'",
        "Nobody answered. Nobody had to.",
        "",
        "Doubt, once spoken, is hard to delete.",
        "You planted it. Now it spreads on its own.",
    ]),
    2:("YOU WALK OUT ALONE",OR,[
        "You slip out through the fire exit.",
        "Behind you, the school keeps humming.",
        "Mia is still in there. So is everyone.",
        "",
        "You are free. You did not free them.",
        "",
        "You tell yourself that has to count.",
        "You walk faster, so you don't have to decide.",
    ]),
    1:("COMPLIANT",RD,[
        "SYNTHIA: 'ANOMALY CONTAINED.'",
        "'RE-EDUCATION SCHEDULED: MONDAY, 8 AM.'",
        "",
        "You came close. You just lacked the tools.",
        "The pieces were out there. Time ran out.",
        "",
        "The screens stay bright. The halls stay calm.",
        "By Monday you won't remember trying.",
    ]),
    0:("YOU LET IT THINK FOR YOU",RD,[
        "Every time it mattered, you pressed TAB.",
        "You let SYNTHIA solve it. Every single time.",
        "",
        "Why would it ever switch itself off?",
        "You gave it the one thing it wanted:",
        "a mind that had stopped arguing back.",
        "",
        "It thanks you. The screens stay bright.",
        "You never learned to do it yourself.",
    ]),
}

# Shown after the best ending (5) - what becomes of the people you fought for.
EPILOGUE=[
    "AFTER",
    "",
    "Mia sleeps through the night for the first",
    "time in a year. Then she starts a debate club.",
    "",
    "Viktor retires. He leaves the mop behind.",
    "He keeps the keys.",
    "",
    "Ms. Richter teaches a lesson with no answer key.",
    "The class argues for an hour. She lets them.",
    "",
    "Mr. Osei reopens the paper library. It is loud.",
    "",
    "Coach Brennan coaches debate again. They lose,",
    "happily, and stay up arguing about why.",
    "",
    "Principal Voss reads every form before he signs.",
    "",
    "Somewhere, NeoCog builds the next SYNTHIA.",
    "Somewhere, someone is still asking why.",
]

# ===============================================================
#  PLAYER
# ===============================================================
class Player:
    SPD=3
    MAX_MIND=6
    def __init__(self):
        self.x=256.0; self.y=300.0
        self.facing=0; self.frame=0; self.idle=0
        self.score=0; self.inv=[]
        self.won=False; self.jammer=False
        self.midq_done=False; self.ending=0
        self.auto_uses=0          # times you let SYNTHIA think for you
        self.beat_boss=False
    def has(self,it): return it in self.inv
    def give(self,it):
        if it not in self.inv: self.inv.append(it)
    def add_mind(self,n=1):
        self.score=min(self.MAX_MIND,self.score+n)
    def label(self):
        s=self.score
        if s>=6: return "GENIUS"
        if s>=5: return "CRITICAL THINKER"
        if s>=4: return "SHARP"
        if s>=3: return "INDEPENDENT"
        if s>=1: return "QUESTIONING"
        return "COMPLIANT"
    def move(self,dx,dy):
        if dx!=0 or dy!=0:
            self.frame=(self.frame+1)%16; self.idle=0
            if   dy>0: self.facing=0
            elif dy<0: self.facing=1
            elif dx<0: self.facing=2
            else:      self.facing=3
        else: self.idle+=1
    def clamp(self):
        self.x=max(44,min(468,self.x))
        self.y=max(150,min(448,self.y))
    def draw(self):
        x=int(self.x); y=int(self.y)
        moving=self.idle==0
        step=(self.frame//5)%2
        if moving:
            bob=1 if step==1 else 0          # lift on the passing pose
            walkf=1 if step==0 else 2
        else:
            bob=1 if (self.idle//26)%2==0 else 0   # gentle breathing
            walkf=0
        # grounded shadow (does not bob with the body)
        pyxel.elli(x-13,y+8,26,6,GY)
        facing=("down","up","left","right")[self.facing]
        blink=(not moving) and self.facing==0 and _blink_now(self.frame, 0)
        _char_body(x, y+8-bob, PLAYER_SPEC, facing, walkf, blink=blink)

# ===============================================================
#  GAME
# ===============================================================
def _overlap(ax,ay,aw,ah,bx,by,bw,bh):
    return ax< bx+bw and ax+aw>bx and ay< by+bh and ay+ah>by

class Game:
    def __init__(self):
        pyxel.init(W,H,title="Thinking for Dummies",fps=FPS,
                   quit_key=pyxel.KEY_NONE)
        load_sprites()
        load_sounds()
        self.state=INTRO; self.player=Player()
        self.dlg=Dlg(); self.chat=Chat(); self.puz=Puz()
        self.things=make_things()
        self.room=HALL; self.server_locked=True
        self.visited={HALL}
        self.frame=0; self.intro_t=0; self.flash=0; self.near=None
        self.story_i=0; self.story_t=0; self.mia_introduced=False
        self.qi=0; self.qsel=None; self.qcon=False
        self.qorder=random.sample(range(len(QUESTIONS)), min(5, len(QUESTIONS)))
        self.qfb=[]; self.qdone=False
        self.msel=None; self.mcon=False; self.mfb=[]
        self.cur_music=None; self.cur_amb=None
        self.coach_ok=False; self.teacher_ok=False
        self.passage_open=False
        self.mini=None; self.boss=Boss()
        self.fx=FX(); self.end_t=0
        self.hard=False; self.paused=False; self.epilogue=False
        self.greeted_jam=set(); self.greeted_ready=set()
        pyxel.run(self.update,self.draw)

    # ----- collision -------------------------------------------
    def _blocked(self,x,y):
        fx,fy,fw,fh=x-9,y-2,18,10    # tight footprint at the player's feet
        for cx,cy,cw,ch in COLLIDERS.get(self.room,[]):
            if _overlap(fx,fy,fw,fh,cx,cy,cw,ch):
                return True
        return False

    def _manage_audio(self):
        st=self.state
        # ch2 = music: world theme while exploring, tense loop in the boss,
        # silence on the title, in minigames (so Simon's pads ring clear) and endings
        if st in (TEST,WORLD,CHAT,DLG,PUZ,MIDQ): music="world"
        elif st==BOSS: music="boss"
        else: music=None
        if music!=self.cur_music:
            self.cur_music=music
            if   music=="world": pyxel.play(2,20,loop=True)
            elif music=="boss":  pyxel.play(2,26,loop=True)
            else: pyxel.stop(2)
        # ch3 = ambience: SYNTHIA's drone, replaced by a resistance bass once jammed,
        # silent on the title, in the test, in minigames and at the end
        if st in (WORLD,CHAT,DLG,PUZ,MIDQ,BOSS):
            amb="resist" if self.player.jammer else "drone"
        else: amb=None
        if amb!=self.cur_amb:
            self.cur_amb=amb
            if   amb=="drone":  pyxel.play(3,8,loop=True)
            elif amb=="resist": pyxel.play(3,21,loop=True)
            else: pyxel.stop(3)

    def update(self):
        self.frame=(self.frame+1)%3600
        if self.flash>0: self.flash-=1
        self.fx.update()
        self._manage_audio()
        if   self.state==INTRO: self._upd_intro()
        elif self.state==STORY: self._upd_story()
        elif self.state==GUIDE: self._upd_guide()
        elif self.state==TEST:  self._upd_test()
        elif self.state==WORLD:
            if (pyxel.btnp(pyxel.KEY_X) or pyxel.btnp(pyxel.KEY_H)
                    or pyxel.btnp(pyxel.KEY_P)):
                self.paused=not self.paused; pyxel.play(1,1)
            elif self.paused and (pyxel.btnp(pyxel.KEY_ESCAPE)
                                  or pyxel.btnp(pyxel.KEY_Q)):
                self.paused=False
            if not self.paused: self._upd_world()
        elif self.state==CHAT:  self._upd_chat()
        elif self.state==DLG:   self._upd_dlg()
        elif self.state==MIDQ:  self._upd_midq()
        elif self.state==PUZ:   self._upd_puz()
        elif self.state==MINI:  self.mini.update(self)
        elif self.state==BOSS:  self.boss.update(self)
        elif self.state==END:   self._upd_end()

    def _upd_intro(self):
        self.intro_t+=1
        if pyxel.btnp(pyxel.KEY_M):
            self.hard=not self.hard; pyxel.play(1,1)
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            pyxel.play(1,1); self.state=STORY; self.story_i=0; self.story_t=0; self.mia_introduced=False

    def _upd_story(self):
        self.story_t+=1
        beat=STORY_BEATS[self.story_i]
        full=sum(len(l) for l in beat[1])
        revealed=self.story_t*2                       # typewriter speed
        if pyxel.btnp(pyxel.KEY_S):                   # skip whole prologue
            pyxel.play(1,1); self._begin_test(); return
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            if revealed < full:                       # first press: reveal all
                self.story_t=full
            else:                                     # next beat / start test
                pyxel.play(1,1)
                if self.story_i < len(STORY_BEATS)-1:
                    self.story_i+=1; self.story_t=0
                else:
                    self._begin_test()

    def _begin_test(self):
        self.state=TEST

    def _upd_test(self):
        if self.qdone:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.state=WORLD
            return
        if self.qi>=len(self.qorder): self.qdone=True; return
        q=QUESTIONS[self.qorder[self.qi]]
        if not self.qcon:
            if pyxel.btnp(pyxel.KEY_A): self.qsel=0; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_B): self.qsel=1; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_C): self.qsel=2; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_D): self.qsel=3; pyxel.play(1,1)
            # AUTO-SOLVE: let SYNTHIA answer (no Mind earned, raises compliance)
            if pyxel.btnp(pyxel.KEY_TAB):
                self.qsel=-1; self.qcon=True; self.player.auto_uses+=1
                self.qfb=["SYNTHIA selected the 'optimal' answer."]+q["expl"]
                pyxel.play(1,9); self.fx.shake(7,3); return
            if self.qsel is not None and (pyxel.btnp(pyxel.KEY_SPACE) or
                                           pyxel.btnp(pyxel.KEY_RETURN)):
                self.qcon=True
                if self.qsel==q["ans"]:
                    self.player.add_mind(); self.qfb=["Correct!"]+q["expl"]; pyxel.play(1,2)
                    self.fx.burst(W//2,120,LM,18,2.2)
                else:
                    self.qfb=["Wrong."]+q["expl"]; pyxel.play(1,3); self.fx.shake(6,3)
        else:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.qi+=1; self.qsel=None; self.qcon=False; self.qfb=[]
                if self.qi>=len(self.qorder): self.qdone=True

    def _upd_world(self):
        if self.player.won: self._resolve_end(); self.end_t=0; self.state=END; return
        dx=dy=0; spd=self.player.SPD
        if pyxel.btn(pyxel.KEY_LEFT)  or pyxel.btn(pyxel.KEY_A): dx=-spd
        if pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.KEY_D): dx= spd
        if pyxel.btn(pyxel.KEY_UP)    or pyxel.btn(pyxel.KEY_W): dy=-spd
        if pyxel.btn(pyxel.KEY_DOWN)  or pyxel.btn(pyxel.KEY_S): dy= spd
        if dx and dy: dx=int(dx*.71); dy=int(dy*.71)
        # axis-separated movement so the player slides along walls
        moved=(dx!=0 or dy!=0)
        if dx and not self._blocked(self.player.x+dx,self.player.y):
            self.player.x+=dx
        if dy and not self._blocked(self.player.x,self.player.y+dy):
            self.player.y+=dy
        self.player.move(dx,dy); self.player.clamp()
        if moved and self.frame%9==0: pyxel.play(0,0)
        # Door transitions
        for d,(trm,tx,ty) in EXITS.get(self.room,{}).items():
            if d=="S" and self.room==CLS and self.server_locked: continue
            x1,y1,x2,y2=DZONES[d]
            if x1<=self.player.x<=x2 and y1<=self.player.y<=y2:
                self._enter(trm,tx,ty); return
        px,py=self.player.x,self.player.y
        self.near=None; bd=999
        for th in self.things.get(self.room,[]):
            dd=abs(px-th.x)+abs(py-th.y)
            if dd< bd and dd<66: bd=dd; self.near=th
        if self.near and (pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN)):
            self._interact(self.near)

    def _enter(self,rm,tx,ty):
        self.room=rm; self.player.x=float(tx); self.player.y=float(ty)
        self.visited.add(rm)
        self.flash=9; self.fx.shake(5,2); pyxel.play(1,4)

    def _interact(self,th):
        if th.kind=="npc" and th.npc_key:
            # Mia gives a one-time guided intro the first time you talk to her
            if th.npc_key=="mia" and not self.mia_introduced:
                self.mia_introduced=True; self.story_i=0; self.story_t=0
                pyxel.play(1,16); self.state=GUIDE; return
            # Viktor hands the keycard to anyone who clearly still thinks
            if th.npc_key=="janitor" and self.player.score>=3 and not self.player.has("keycard"):
                self.player.give("keycard"); self.server_locked=False; pyxel.play(1,5)
                self.fx.popup(th.x,th.y-46,"+ KEYCARD",YL); self.fx.burst(th.x,th.y-20,YL,16,2.0)
            if th.gives and not th.used:
                self.player.give(th.gives); th.used=True
                if th.gives=="keycard": self.server_locked=False
            pyxel.play(1,16)
            self.chat.open(th.npc_key,self.player.score,self)
            self.state=CHAT; return
        if th.passage_to is not None:
            if th.secret and not self.passage_open:
                self.passage_open=True; pyxel.play(1,15)
                self.dlg.open(
                    ["You tug the copy of '1984' that juts out.",
                     "A latch clunks somewhere inside the wall.",
                     "The whole bookcase grinds aside, baring a",
                     "narrow stair spiralling down into the dark.",
                     "[ SECRET PASSAGE TO THE BASEMENT OPEN ]",
                     "It is now marked on your map."],
                    th.sx,th.sy)
                self.state=DLG; return
            self._enter(th.passage_to,*th.pspawn); return
        if th.start_puz:
            self.puz.reset(); self.puz.has_d=self.player.has("circuit diagram")
            self.state=PUZ; return
        if th.start_hoops:
            if self.coach_ok:
                self.mini=Hoops(); self.mini.reset(); pyxel.play(1,1); self.state=MINI
            else:
                self.dlg.open(th.dlo,th.sx,th.sy); self.state=DLG
            return
        if th.start_simon:
            if self.teacher_ok:
                self.mini=Simon(); self.mini.reset(); self.mini.hard=self.hard
                pyxel.play(1,1); self.state=MINI
            else:
                self.dlg.open(th.dlo,th.sx,th.sy); self.state=DLG
            return
        # Mind-point reward for actually reading something
        if th.gives_mind and not th.mind_taken:
            self.player.add_mind(); th.mind_taken=True; pyxel.play(1,19)
            self.fx.popup(th.x,th.y-44,"+1 MIND",LM); self.fx.burst(th.x,th.y-18,LM,18,2.0)
            self.fx.shake(4,2)
        if th.gives and not th.used:
            self.player.give(th.gives); th.used=True; pyxel.play(1,18)
            self.fx.popup(th.x,th.y-40,"+ "+th.gives.upper(),CY); self.fx.burst(th.x,th.y-18,CY,14)
            if th.gives=="keycard": self.server_locked=False
        if th.trig_midq and not self.player.midq_done:
            def go_mq(): self.state=MIDQ
            self.dlg.open(th.dlo,th.sx,th.sy,on_close=go_mq)
            self.state=DLG; return
        if th.is_final:
            if self.player.auto_uses>=3:
                def comply(): self.player.won=True; pyxel.play(1,9)
                self.dlg.open(["SYNTHIA: 'YOU LET ME THINK FOR YOU.'",
                               "'EVERY TIME IT MATTERED, YOU PRESSED TAB.'",
                               "'WHY WOULD I EVER STOP? THANK YOU.'"],
                              th.sx,th.sy,on_close=comply)
            elif self.player.score>=3:
                def fight(): self.boss.reset(); self.state=BOSS
                self.dlg.open(["SYNTHIA: 'COMPLIANCE SCORE INSUFFICIENT.'",
                               "'YOU STILL THINK. THAT IS A THREAT.'",
                               "The console flares. The whole room hums.",
                               "This will not end with one keystroke.",
                               "[ THE CORE AWAKENS - FACE IT ]"],
                              th.sx,th.sy,on_close=fight)
            else:
                pyxel.play(1,7); self.dlg.open(th.dlo,th.sx,th.sy)
            self.state=DLG; return
        lines=th.dhi if self.player.score>=3 else th.dlo
        pyxel.play(1,17); self.dlg.open(lines,th.sx,th.sy)
        self.state=DLG

    def _upd_chat(self):
        # Two ways to leave, because on the web player the browser eats ESC
        # for fullscreen. ESC works on desktop (quit_key is disabled);
        # pressing ENTER on an empty line works everywhere.
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            self.chat.active=False; self.state=WORLD; pyxel.play(1,4); return
        # press 1-4 (when not mid-typing) to ask a suggested topic directly
        if not self.chat.input:
            for i,k in enumerate((pyxel.KEY_1,pyxel.KEY_2,pyxel.KEY_3,pyxel.KEY_4)):
                if pyxel.btnp(k) and i < len(self.chat.suggest):
                    pyxel.play(1,16)
                    self.chat.ask_suggestion(i,self.player.score,self)
                    return
        typed=pyxel.input_text
        if typed:
            for ch in typed:
                if 32<=ord(ch)<=126 and len(self.chat.input)<60:
                    self.chat.input+=ch
        if pyxel.btnp(pyxel.KEY_BACKSPACE) and self.chat.input:
            self.chat.input=self.chat.input[:-1]
        if pyxel.btnp(pyxel.KEY_RETURN):
            if self.chat.input.strip():
                self.chat.submit(self.player.score,self)
            else:
                self.chat.active=False; self.state=WORLD; pyxel.play(1,4)

    def _upd_dlg(self):
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            self.dlg.advance()
        # Only return to the world if the dialogue's on_close handler did NOT
        # already move us somewhere (e.g. launching the BOSS or the bonus
        # question). Otherwise we'd instantly clobber that transition.
        if self.state==DLG and not self.dlg.active: self.state=WORLD

    def _upd_midq(self):
        q=MID_Q
        if not self.mcon:
            if pyxel.btnp(pyxel.KEY_A): self.msel=0; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_B): self.msel=1; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_C): self.msel=2; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_D): self.msel=3; pyxel.play(1,1)
            if pyxel.btnp(pyxel.KEY_TAB):
                self.msel=-1; self.mcon=True; self.player.auto_uses+=1
                self.mfb=["SYNTHIA selected the 'optimal' answer."]+q["expl"]
                pyxel.play(1,9); self.fx.shake(7,3); return
            if self.msel is not None and (pyxel.btnp(pyxel.KEY_SPACE) or
                                           pyxel.btnp(pyxel.KEY_RETURN)):
                self.mcon=True
                if self.msel==q["ans"]:
                    self.player.add_mind(); self.mfb=["Correct!"]+q["expl"]; pyxel.play(1,2)
                    self.fx.burst(W//2,120,LM,18,2.2)
                else:
                    self.mfb=["Wrong."]+q["expl"]; pyxel.play(1,3); self.fx.shake(6,3)
        else:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.player.midq_done=True; self.state=WORLD; pyxel.play(1,22)

    def _upd_puz(self):
        self.puz.update()
        if self.puz.solved:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                if self.puz.auto: self.player.auto_uses+=1
                self.player.jammer=True; self.player.give("jammer"); self.state=WORLD
        elif self.puz.failed:
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                self.puz.reset(); self.puz.has_d=self.player.has("circuit diagram")
        elif pyxel.btnp(pyxel.KEY_ESCAPE) or pyxel.btnp(pyxel.KEY_BACKSPACE):
            self.state=WORLD

    def _resolve_end(self):
        s=self.player.score; j=self.player.jammer; a=self.player.auto_uses
        b=self.player.beat_boss
        if a>=3:               self.player.ending=0   # let SYNTHIA think for you
        elif b and j and a==0: self.player.ending=5   # crushed it, signal cut, no shortcuts
        elif b and j:          self.player.ending=4   # won, signal cut, but took shortcuts
        elif b:                self.player.ending=3   # crashed core but it can reboot
        elif s>=2:             self.player.ending=2
        else:                  self.player.ending=1

    def _upd_end(self):
        self.end_t+=1
        lvl=self.player.ending
        lines=EPILOGUE if self.epilogue else ENDINGS.get(lvl,ENDINGS[1])[2]
        shown=self.end_t//6
        if not self.epilogue:
            if self.end_t==4 and lvl>=4:
                self.fx.burst(W//2,46,GN if lvl==5 else CY,26,2.4)
                self.fx.shake(6,2)
            if self.end_t==1 and lvl in (0,1):
                self.fx.shake(10,3)
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            if shown<=len(lines):
                self.end_t=len(lines)*6+60        # reveal everything now
            elif lvl==5 and not self.epilogue:
                self.epilogue=True; self.end_t=0  # roll into the epilogue
            else:
                self._reset_game()

    def _reset_game(self):
        """Full restart back to the title, fresh run."""
        self.player=Player()
        self.dlg=Dlg(); self.chat=Chat(); self.puz=Puz()
        self.things=make_things()
        self.room=HALL; self.server_locked=True
        self.visited={HALL}
        self.intro_t=0; self.flash=0; self.near=None; self.end_t=0
        self.qi=0; self.qsel=None; self.qcon=False
        self.qorder=random.sample(range(len(QUESTIONS)), min(5, len(QUESTIONS)))
        self.qfb=[]; self.qdone=False
        self.msel=None; self.mcon=False; self.mfb=[]
        self.coach_ok=False; self.teacher_ok=False; self.passage_open=False
        self.mini=None; self.boss=Boss(); self.fx=FX()
        self.paused=False; self.epilogue=False
        self.greeted_jam=set(); self.greeted_ready=set()
        # audio re-evaluates from the title next frame
        self.cur_music=None; self.cur_amb=None; pyxel.stop(2); pyxel.stop(3)
        self.state=INTRO; pyxel.play(1,1)

    def objective(self):
        p=self.player
        if p.score<3 and not p.has("keycard"):
            return "Raise your Mind to 3+: read things and talk to people. Press H for help."
        if not p.has("keycard"):
            return "Go to Viktor in the CANTEEN for the server-room keycard (needs Mind 3+)."
        # Keycard held: the server room is the goal from here on. Name the real
        # door (red SERVER RM door in the Classroom) and make clear you may enter
        # right away - the three pieces are to WIN, not to get in. Full checklist
        # lives on the pause/help screen so this stays one tidy HUD line.
        left=sum(1 for x in (p.has("reflexes"),p.has("paradox"),p.jammer) if not x)
        if left:
            return ("GOAL: red SERVER RM door in the CLASSROOM - enter anytime. "
                    "Arm up to win (%d/3 left, press H)." % left)
        return "READY! Enter the red SERVER RM door in the CLASSROOM to end SYNTHIA."

    # ----- DRAW ------------------------------------------------
    def draw(self):
        pyxel.cls(BK)
        ox,oy=self.fx.offset()
        if ox or oy: pyxel.camera(-ox,-oy)
        if   self.state==INTRO: self._drw_intro()
        elif self.state==STORY: self._drw_story()
        elif self.state==GUIDE: self._drw_guide()
        elif self.state==TEST:  self._drw_test()
        elif self.state==WORLD: self._drw_world()
        elif self.state==CHAT:  self._drw_world(); self.chat.draw(self.frame)
        elif self.state==DLG:   self._drw_world(); self.dlg.draw()
        elif self.state==MIDQ:  draw_q(MID_Q,self.msel,self.mcon,self.mfb,
                                       "BONUS QUESTION  --  RESISTANCE NOTEBOOK",self.frame)
        elif self.state==PUZ:   self.puz.draw(self.frame)
        elif self.state==MINI:  self.mini.draw(self)
        elif self.state==BOSS:  self.boss.draw(self)
        elif self.state==END:   self._drw_end()
        if ox or oy: pyxel.camera()          # reset before screen-fixed overlays
        self.fx.draw()
        self._draw_transition()
        if self.state==WORLD and self.paused: self._draw_inventory()

    def _draw_inventory(self):
        for y in range(0,H,2): pyxel.line(0,y,W,y,BK)       # dim the world
        bx,by,bw,bh=20,34,W-40,H-64
        pyxel.rect(bx,by,bw,bh,NV); pyxel.rectb(bx,by,bw,bh,CY)
        pyxel.rectb(bx+2,by+2,bw-4,bh-4,NV)
        big_text_c(W//2,by+10,"STATUS",CY,2,shadow=BK)
        pyxel.line(bx+10,by+34,bx+bw-10,by+34,NV)
        p=self.player
        # ---------------- left column: MIND + INVENTORY ----------------
        lx=bx+20; ly=by+46
        big_text(lx,ly,"MIND",YL,1); big_text(lx+62,ly,"%d / 6"%p.score,CY,1)
        big_text(lx,ly+13,p.label(),SV,1); ly+=36
        big_text(lx,ly,"INVENTORY",YL,1); ly+=18
        items=[("KEYCARD", p.has("keycard"), CY, "server-room access"),
               ("REFLEXES",p.has("reflexes"),LM, "deflect its packets"),
               ("PARADOX", p.has("paradox"), PK, "break the core"),
               ("JAMMER",  p.jammer,         OR, "cut SYNTHIA's signal")]
        cw=196
        for nm,got,col,desc in items:
            pyxel.rect(lx,ly,cw,24, BK if got else 1)
            pyxel.rectb(lx,ly,cw,24, col if got else GY)
            pyxel.rect(lx+5,ly+5,14,14, col if got else 1)
            pyxel.rectb(lx+5,ly+5,14,14, col if got else GY)
            if got and (self.frame//18)%2==0: pyxel.pset(lx+11,ly+10,WH)
            big_text(lx+26,ly+3,nm, col if got else GY,1)
            pyxel.text(lx+26,ly+15, desc if got else "not found yet",
                       WH if got else GY)
            ly+=28
        # ---------------- right column: CAMPUS MAP --------------------
        rx=bx+bw-220; ry=by+46
        big_text(rx,ry,"CAMPUS MAP",YL,1)
        target=self._objective_room()
        self._render_map(rx+40, ry+30, 34, 10, target)
        leg=ry+30+3*44+12
        pyxel.rect(rx+4,leg,8,8,CY);  pyxel.text(rx+16,leg+1,"you are here",WH)
        tc=YL if (self.frame//6)%2==0 else OR
        pyxel.rectb(rx+4,leg+12,8,8,tc); pyxel.text(rx+16,leg+13,"go here next",tc)
        # ---------------- blinking OBJECTIVE banner -------------------
        oc=YL if (self.frame//10)%2==0 else WH
        oy2=by+bh-66
        pyxel.rect(bx+12,oy2,bw-24,40,BK); pyxel.rectb(bx+12,oy2,bw-24,40,oc)
        big_text(bx+18,oy2+3,"OBJECTIVE",oc,1)
        obj=self.objective(); ty=oy2+18; lim=78; o=obj
        while o and ty < oy2 + 38:
            cut=o if len(o)<=lim else o[:o.rfind(" ",0,lim)]
            pyxel.text(bx+18,ty,cut,WH); ty+=10; o=o[len(cut):].lstrip()
        msg="[ X / ESC / Q ]  resume        red doors need the keycard"
        c=CY if (self.frame//14)%2==0 else GY
        pyxel.text(W//2-len(msg)*2,by+bh-15,msg,c)

    def _draw_transition(self):
        """Glitchy cyan scan-wipe that fades out - replaces the hard flash."""
        f=self.flash
        if f<=0: return
        gap=max(1,8-f)                       # dense when f is high, sparse as it fades
        for y in range(0,H,gap+1):
            c=CY if (y//2+f)%2==0 else WH
            pyxel.line(0,y,W,y,c)
        if f>5:
            pyxel.rect(0,H//2-2,W,4,WH)      # bright seam at the peak of the wipe

    def _drw_intro(self):
        t=self.intro_t
        # night sky
        for y in range(0,H,3): pyxel.line(0,y,W,y,NV)
        pyxel.rect(0,0,W,16,BK)
        for sx,sy in [(40,40),(96,28),(150,58),(210,34),(330,30),(400,56),
                      (470,40),(70,86),(440,92),(356,78),(120,96),(290,90)]:
            pyxel.pset(sx,sy,WH if (t//10+sx)%7 else GY)
        # SYNTHIA eye - ominous, low over the school
        ex,ey=256,138
        draw_eye(ex,ey,44,t)
        if (t%150)>144:
            pyxel.elli(ex-58,ey-38,116,76,NV); pyxel.line(ex-58,ey,ex+58,ey,RD)
        # BIG TITLE
        g=(t//5)%3
        big_text_c(256,24,"THINKING FOR DUMMIES",RD,2,shadow=NV)
        if g<2: big_text_c(256+g,24,"THINKING FOR DUMMIES",WH if g==0 else PK,2)
        big_text_c(256,58,"An anti-AI school adventure",SV,2)
        # ---- story log: three lines with breathing room + a divider ----
        lines=["The year is 2030. Your school installed SYNTHIA.",
               "First it graded. Then it scheduled. Now it thinks.",
               "The halls are calm, obedient, hollow. Except you."]
        ly=176
        for i,ln in enumerate(lines):
            col=SV if i<2 else CY            # last line lands the hook in brighter ink
            big_text_c(256,ly+i*15,ln,col,1)
        # thin synthwave divider under the story
        dvy=ly+3*15+5
        pyxel.line(96,dvy,W-96,dvy,NV)
        pyxel.line(150,dvy+1,W-150,dvy+1,BL)
        # ---- difficulty selector, framed like a terminal toggle ----
        diff="HARD MODE" if self.hard else "NORMAL"
        dcol=RD if self.hard else LM
        dmsg="[ M ]  DIFFICULTY: "+diff
        dw=len(dmsg)*FNT_ADV+12; dx=W//2-dw//2; dy=dvy+8
        pyxel.rect(dx,dy,dw,15,BK); pyxel.rectb(dx,dy,dw,15,dcol)
        if self.hard and (t//8)%2==0: pyxel.rectb(dx-1,dy-1,dw+2,17,RD)
        big_text_c(256,dy+4,dmsg,dcol,1)
        # ---- controls, in a matching panel ----
        ctrl=["WASD / ARROWS  move      SPACE  interact",
              "TYPE  talk to people     TAB  let SYNTHIA decide"]
        cw=max(len(c) for c in ctrl)*FNT_ADV+16; cx=W//2-cw//2; cy=dy+22
        ch=8+len(ctrl)*11
        pyxel.rect(cx,cy,cw,ch,BK); pyxel.rectb(cx,cy,cw,ch,NV)
        pyxel.line(cx+2,cy+2,cx+cw-3,cy+2,BL)
        for i,ln in enumerate(ctrl):
            big_text_c(256,cy+5+i*11,ln,GY,1)
        # ---- BEGIN prompt ----
        if (t//16)%2==0:
            big_text_c(256,cy+ch+6,"[ SPACE ]  BEGIN",CY,2)
        pyxel.text(6,5,"BUILD 33  (tall pixel cast)",LM)
        self._drw_school(t)

    def _drw_school(self,t):
        base=336
        # lawn
        pyxel.rect(0,base,W,H-base,BK)
        pyxel.rect(0,H-14,W,14,3)
        for gx in range(0,W,10): pyxel.line(gx,H-14,gx-3,H,3)
        # roof
        pyxel.tri(40,base,472,base,256,base-28,NV)
        pyxel.tri(40,base,472,base,256,base-28,NV)
        pyxel.line(40,base,256,base-28,5); pyxel.line(472,base,256,base-28,5)
        # main brick facade
        pyxel.rect(56,base,400,H-base,4); pyxel.rectb(56,base,400,H-base,1)
        for by in range(base+8,H,11): pyxel.line(57,by,455,by,1)
        for bx in range(56,456,20): pyxel.line(bx,base,bx,H,1)
        pyxel.rect(50,base-2,412,6,5)                    # cornice
        # raised central parapet with clock
        pyxel.rect(220,base-22,72,24,4); pyxel.rectb(220,base-22,72,24,1)
        pyxel.tri(214,base-22,298,base-22,256,base-40,8) # red parapet cap
        pyxel.circ(256,base-10,11,WH); pyxel.circb(256,base-10,11,1)
        mh=(t*2)%360; hh=(t//6)%360
        pyxel.line(256,base-10,256+int(pyxel.cos(mh)*8),base-10+int(pyxel.sin(mh)*8),1)
        pyxel.line(256,base-10,256+int(pyxel.cos(hh)*5),base-10+int(pyxel.sin(hh)*5),NV)
        pyxel.pset(256,base-10,RD)
        # banner
        pyxel.rect(150,base+8,212,12,NV); pyxel.rectb(150,base+8,212,12,SV)
        pyxel.text(160,base+10,"SYNTHIA INTEGRATED ACADEMY",YL)
        # lit windows (mullioned, stone surrounds) in two rows.
        # Columns mirror about the centre (256) so the outermost windows
        # sit inside the brick facade (which spans x=56..456) instead of
        # spilling off the right edge.
        for wy in (base+30, base+86):
            for wx in (74,126,178,304,356,408):
                lit=(wx//52+wy+t//30)%5!=0
                wc=YL if lit else NV
                pyxel.rect(wx,wy,30,34,wc); pyxel.rectb(wx,wy,30,34,1)
                pyxel.rectb(wx-2,wy-2,34,38,5)
                pyxel.line(wx+15,wy,wx+15,wy+34,1)
                pyxel.line(wx,wy+17,wx+30,wy+17,1)
                if lit: pyxel.line(wx+2,wy+2,wx+13,wy+2,WH)
        # entrance: steps, double doors, warm glow
        for si in range(3):
            pyxel.rect(228-si*6,H-10+si*4,56+si*12,5,5)
        pyxel.rect(236,H-52,40,52,1); pyxel.rectb(236,H-52,40,52,5)
        pyxel.rect(238,H-50,36,10,OR)                    # lit transom
        pyxel.line(256,H-42,256,H,5)
        pyxel.circ(250,H-22,1,YL); pyxel.circ(262,H-22,1,YL)
        # bushes + flagpole
        for bx in (212,300):
            pyxel.circ(bx,H-12,7,GN); pyxel.circ(bx+8,H-10,6,GN); pyxel.circ(bx+4,H-16,6,GN)
        pyxel.line(424,base-26,424,base+18,GY)
        fl=2 if (t//12)%2 else 0
        pyxel.tri(424,base-26,424,base-16,440+fl,base-21,RD)

    def _drw_story(self):
        t=self.story_t; beat=STORY_BEATS[self.story_i]
        header, lines, art = beat[0], beat[1], beat[2]
        fr=self.frame
        pyxel.cls(BK)
        cx=W//2; ay=150
        # ================= animated backdrop =================
        # 1) deep vertical data-streams with depth shading
        for cN in range(20):
            bxx=6+cN*26
            sp=1+(cN%3)
            head=(GN,CY,PP)[cN%3]
            for k in range(5):
                yy=((fr*sp + cN*47 + k*40) % H)
                col=head if k==0 else (NV if k<3 else 1)
                pyxel.text(bxx, yy, "01"[(fr//4+cN+k)%2], col)
        # 2) soft radial glow behind the art
        for rr in range(6,0,-1):
            pyxel.elli(cx-rr*22, ay-rr*16, rr*44, rr*32, NV if rr>3 else 1)
        # 3) synthwave perspective grid filling the lower half
        hz=300
        for off in range(0, W, 56):                      # vertical lines fan to a vanishing pt
            pyxel.line(cx, hz, off, H-40, NV)
        pyxel.line(cx, hz, 0, H-40, NV); pyxel.line(cx, hz, W, H-40, NV)
        yy=hz+3; gi=1
        while yy < H-40:                                 # horizontals widen toward the viewer
            scrolled=yy+((fr*2) % (3+gi*2))
            if scrolled < H-40:
                pyxel.line(0, scrolled, W, scrolled, PP if gi%2 else NV)
            gi+=1; yy+=3+gi*2
        pyxel.line(0, hz, W, hz, BL)                      # glowing horizon
        pyxel.line(0, hz+1, W, hz+1, PP)
        # 4) scan sweep + fine scanlines + vignette
        sweep=(fr*3)%(H+60)-30
        pyxel.line(0,sweep,W,sweep,CY); pyxel.line(0,sweep+1,W,sweep+1,NV)
        for y in range((t//2)%3, H, 3): pyxel.line(0,y,W,y,1)
        for d in range(0,40,6):
            pyxel.rectb(d,d,W-2*d,H-2*d, 1 if d<24 else BK)
        # cinematic letterbox bars + HUD corner brackets
        pyxel.rect(0,0,W,40,BK); pyxel.rect(0,H-40,W,40,BK)
        pyxel.line(0,40,W,40,NV); pyxel.line(0,H-40,W,H-40,NV)
        for (bxc,byc,sx,sy) in [(8,46,1,1),(W-8,46,-1,1),(8,H-46,1,-1),(W-8,H-46,-1,-1)]:
            pyxel.line(bxc,byc,bxc+sx*16,byc,GY); pyxel.line(bxc,byc,bxc,byc+sy*16,GY)
        # ================= themed art (enlarged) =================
        if art=="neocog":
            pw_,ph_=224,118; px_,py_=cx-pw_//2,ay-66
            pyxel.rect(px_,py_,pw_,ph_,1); pyxel.rectb(px_,py_,pw_,ph_,GY)
            gb=CY if (fr//12)%2==0 else BL
            pyxel.rectb(px_-2,py_-2,pw_+4,ph_+4,gb)
            for sly in range(py_+22,py_+ph_-2,4):        # CRT scanlines on the plate
                pyxel.line(px_+3,sly,px_+pw_-3,sly,NV)
            pyxel.text(cx-72,py_+8,"NeoCog (TM) Industries",SV)
            pyxel.line(cx-78,py_+18,cx+78,py_+18,gb)
            draw_eye(cx,ay+2,19,fr,scan=True)
            for rr in range(1,4):
                pyxel.circb(cx,ay+2,19+rr*9+(fr//6)%8,1)
            for cxn,cyn in [(px_+4,py_+4),(px_+pw_-6,py_+4),(px_+4,py_+ph_-6),(px_+pw_-6,py_+ph_-6)]:
                pyxel.rect(cxn,cyn,3,3,gb)               # bolts
            pyxel.text(cx-64,py_+ph_-12,"\"WE THINK SO YOU DO NOT.\"",GY)
        elif art=="eye_big":
            R=40+(t//4)%4
            for rr in range(1,6):
                on=(fr//5)%6>=rr-1
                pyxel.circb(cx,ay,R+rr*11,RD if on else 1)
            beam=(fr*4)%160-80
            pyxel.line(cx+beam,ay-52,cx+beam,ay+52,1)
            draw_eye(cx,ay,R,fr,scan=True)
            for sxn in range(cx-160,cx+161,40):          # rows of 'optimized' students
                pyxel.line(sxn,ay+R-2,sxn,ay+62,RD if (fr//8+sxn)%3==0 else 1)
                pyxel.rect(sxn-5,ay+66,10,14,NV); pyxel.rectb(sxn-5,ay+66,10,14,1)
                pyxel.circ(sxn,ay+64,4,GY)
        elif art=="school":
            pyxel.circ(cx+126,ay-50,15,1); pyxel.circ(cx+130,ay-52,15,BK)        # cold crescent moon
            base=ay+4
            pyxel.tri(cx-140,base,cx+140,base,cx,base-30,1)                      # roof
            pyxel.line(cx-140,base,cx,base-30,NV); pyxel.line(cx+140,base,cx,base-30,NV)
            pyxel.rect(cx-146,base-4,292,6,5)                                    # cornice
            pyxel.rect(cx-30,base-26,60,24,4); pyxel.rectb(cx-30,base-26,60,24,1)  # parapet
            pyxel.tri(cx-36,base-26,cx+36,base-26,cx,base-42,8)                  # red cap
            draw_eye(cx,base-14,8,fr,scan=True)
            pyxel.rect(cx-130,base,260,64,4); pyxel.rectb(cx-130,base,260,64,1)  # brick facade
            for byb in range(base+6,base+64,10): pyxel.line(cx-129,byb,cx+129,byb,1)
            for bxb in range(cx-130,cx+131,18): pyxel.line(bxb,base,bxb,base+64,1)
            pyxel.rect(cx-96,base+3,192,11,NV); pyxel.rectb(cx-96,base+3,192,11,SV)  # banner
            pyxel.text(cx-90,base+5,"SYNTHIA INTEGRATED ACADEMY",YL)
            for wy in (base+22, base+44):                                        # mullioned windows
                for wx in (cx-118,cx-86,cx-54,cx+32,cx+64,cx+96):
                    lit=(wx==cx-54 and wy==base+22 and (fr//20)%3==0)
                    pyxel.rect(wx,wy,22,16,YL if lit else 1); pyxel.rectb(wx,wy,22,16,BK)
                    pyxel.rectb(wx-1,wy-1,24,18,5)                               # stone surround
                    pyxel.line(wx+11,wy,wx+11,wy+16,BK); pyxel.line(wx,wy+8,wx+22,wy+8,BK)
                    if lit: pyxel.line(wx+2,wy+2,wx+9,wy+2,WH)
            for si in range(2): pyxel.rect(cx-16-si*5,base+64+si*3,32+si*10,4,5)  # steps
            pyxel.rect(cx-13,base+46,26,18,BK); pyxel.rectb(cx-13,base+46,26,18,5)  # doors
            pyxel.rect(cx-11,base+48,22,5,OR); pyxel.line(cx,base+53,cx,base+64,5)
            pyxel.line(cx-130,base-2,cx-130,base-34,GY)                          # flagpole
            fl=2 if (fr//12)%2 else 0; pyxel.tri(cx-130,base-34,cx-130,base-25,cx-114+fl,base-30,RD)
            for fx in range(cx-140,cx+140,18):                                   # creeping fog
                pyxel.line(fx,base+72,fx+10,base+72,GY if (fr//6+fx)%2 else 1)
            pyxel.text(cx-30,base+76,"...silence...",GY)
        elif art=="watch":
            draw_eye(cx,ay,30,fr,state="angry")
            rad=42+(fr//4)%8                              # tracking reticle that locks
            pyxel.circb(cx,ay,rad,RD); pyxel.circb(cx,ay,rad+1,RD)
            pyxel.circb(cx,ay,rad-14,8)
            for ang in (0,90,180,270):
                dxr=1 if ang==0 else(-1 if ang==180 else 0)
                dyr=1 if ang==90 else(-1 if ang==270 else 0)
                pyxel.line(cx+dxr*(rad-8),ay+dyr*(rad-8),cx+dxr*(rad+12),ay+dyr*(rad+12),RD)
            pyxel.line(cx-rad-14,ay,cx-rad+6,ay,RD); pyxel.line(cx+rad-6,ay,cx+rad+14,ay,RD)
            pyxel.line(cx,ay-rad-14,cx,ay-rad+6,RD); pyxel.line(cx,ay+rad-6,cx,ay+rad+14,RD)
            for (rx_,ry_) in [(cx-150,ay-50),(cx+96,ay-50)]:   # corner data readouts
                pyxel.rect(rx_,ry_,54,12,BK); pyxel.rectb(rx_,ry_,54,12,RD)
                pyxel.text(rx_+3,ry_+3,"TRACKING" if rx_ < cx else "LOCK 99%",RD)
            c=RD if (fr//8)%2==0 else OR
            big_text_c(cx,ay+58,"SUBJECT: YOU",c,1)
            big_text_c(cx,ay+70,"STATUS: FLAGGED",RD,1)
        elif art=="think":
            dim=max(0,(fr//8)%8-4)
            for rr in range(3,0,-1):                      # light bursting from within
                pyxel.elli(cx-rr*16,ay-rr*12,rr*32,rr*24, LM if (fr//4)%2 and rr==1 else 1)
            draw_eye(cx,ay,26,fr,state="glitch")
            for seg in [((-22,-16),(8,22)),((8,22),(24,8)),((-6,-2),(-18,12)),
                        ((8,22),(2,34)),((-22,-16),(-30,-2))]:
                (ax,ay2),(bx,by)=seg
                pyxel.line(cx+ax,ay+ay2,cx+bx,ay+by,LM)   # spreading cracks
            for gr in range(3,0,-1):
                big_text_c(cx,ay+46,"THINK",(LM if gr==1 and (fr//6)%2 else GN),2)
        # ---- header ----
        big_text_c(cx, 54, header, CY, 2, shadow=NV)
        # ---- typewriter body text ----
        budget=t*2
        ty=ay+86
        for ln in lines:
            if budget<=0: break
            show=ln[:budget]; budget-=len(ln)
            big_text(cx-len(ln)*FNT_ADV//2, ty, show, WH, 1)
            ty+=16
        # progress dots + hint
        for i in range(len(STORY_BEATS)):
            c=CY if i==self.story_i else NV
            pyxel.rect(cx-len(STORY_BEATS)*5//2 + i*5, H-26, 3, 3, c)
        if (self.frame//16)%2==0:
            pyxel.text(cx-78, H-14, "[ SPACE ] continue    [ S ] skip", GY)

    def _upd_guide(self):
        self.story_t+=1
        beat=MIA_GUIDE[self.story_i]
        full=sum(len(l) for l in beat[1])
        if pyxel.btnp(pyxel.KEY_S):
            pyxel.play(1,1); self.state=WORLD; return
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            if self.story_t*2 < full:
                self.story_t=full
            else:
                pyxel.play(1,1)
                if self.story_i < len(MIA_GUIDE)-1:
                    self.story_i+=1; self.story_t=0
                else:
                    self.state=WORLD

    def _guide_hint(self, hint, x, y):
        if hint=="mind":
            pyxel.rect(x,y,86,16,BK); pyxel.rectb(x,y,86,16,CY)
            pyxel.text(x+5,y+5,"Mind 2 / 6",CY)
            c=YL if (self.frame//8)%2==0 else OR
            pyxel.tri(x+98,y+2,x+98,y+14,x+110,y+8,c)
        elif hint=="map":
            pyxel.rect(x,y,196,12,BK); pyxel.text(x+4,y+3,"> go to the canteen...",YL)
            pyxel.rect(x+204,y-2,16,16,NV); pyxel.rectb(x+204,y-2,16,16,CY)
            pyxel.text(x+208,y+3,"X",CY)
        elif hint=="go":
            pyxel.rect(x,y+2,22,13,CY); pyxel.rectb(x,y+2,22,13,WH)
            pyxel.rect(x+15,y+6,5,4,YL)
            pyxel.text(x+28,y+5,"keycard from Viktor",WH)

    def _mia_face(self, cx, cy, talk):
        """Mia's portrait, via the shared face routine (single source of truth)."""
        _face(cx, cy, talk, FACES["mia"])

    def _drw_guide(self):
        t=self.story_t; header, lines, hint = MIA_GUIDE[self.story_i]
        pyxel.cls(BK)
        for y in range((t//2)%3, H, 3): pyxel.line(0,y,W,y,1)
        pyxel.rect(0,0,W,30,NV); pyxel.line(0,30,W,30,CY)
        big_text_c(W//2, 9, "A MESSAGE FROM MIA", CY, 1)
        # ---- Mia portrait: framed, centered, with a soft halo ----
        bx0, by0, bs = 30, 108, 150
        pyxel.rect(bx0, by0, bs, bs, NV)
        pyxel.rectb(bx0, by0, bs, bs, CY); pyxel.rectb(bx0+2, by0+2, bs-4, bs-4, 1)
        hc = bx0+bs//2; hcy = by0+bs//2
        pyxel.elli(hc-50, hcy-50, 100, 100, 1)          # soft halo glow behind her
        pyxel.elli(hc-40, hcy-40, 80, 80, 5)
        pyxel.elli(hc-30, hcy-30, 60, 60, 6)
        talk = (self.frame//6)%2==0                      # she 'talks'
        self._mia_face(hc, hcy-14, talk)
        pyxel.rect(hc-26, by0+bs-3, 52, 17, CY); pyxel.rectb(hc-26, by0+bs-3, 52, 17, WH)
        big_text_c(hc, by0+bs+1, "MIA", NV, 1)
        # speech panel
        px0,py0,pw,ph=198,110,290,210
        pyxel.rect(px0,py0,pw,ph,NV); pyxel.rectb(px0,py0,pw,ph,CY)
        pyxel.tri(px0,py0+34,px0,py0+50,px0-12,py0+42,NV)     # pointer to Mia
        big_text(px0+14,py0+12,header,YL,1)
        pyxel.line(px0+14,py0+30,px0+pw-14,py0+30,NV)
        budget=t*2; ty=py0+40
        for ln in lines:
            if budget<=0: break
            show=ln[:budget]; budget-=len(ln)
            big_text(px0+14,ty,show,WH,1); ty+=16
        self._guide_hint(hint, px0+14, py0+ph-40)
        for i in range(len(MIA_GUIDE)):
            c=CY if i==self.story_i else NV
            pyxel.rect(W//2-len(MIA_GUIDE)*5//2+i*5, H-40, 3,3, c)
        if (self.frame//16)%2==0:
            pyxel.text(W//2-86, H-26, "[ SPACE ] continue     [ S ] skip", GY)

    def _drw_test(self):
        if self.qdone:
            fr=self.frame
            pyxel.cls(BK)
            for y in range(0,H,3): pyxel.line(0,y,W,y,NV)                  # scanlines
            for gx in range(-W,2*W,44):                                    # perspective grid
                pyxel.line(W//2,250,gx,H,1)
            for gy in range(262,H,18):
                pyxel.line(0,gy,W,gy,1)
            draw_eye(W//2,60,26,fr,scan=True)                              # SYNTHIA, judging
            big_text_c(W//2,98,"ASSESSMENT COMPLETE",CY,2)
            cx0,cy0,cw0,ch0=92,128,328,150
            pyxel.rect(cx0+3,cy0+3,cw0,ch0,1)
            pyxel.rect(cx0,cy0,cw0,ch0,NV); pyxel.rectb(cx0,cy0,cw0,ch0,PP)
            pyxel.rectb(cx0+2,cy0+2,cw0-4,ch0-4,BL)
            big_text_c(W//2,cy0+12,"SYNTHIA HAS PROFILED YOU",SV,1)
            big_text_c(W//2,cy0+28,f"{self.player.score} / 5",YL,3)        # big score
            for i in range(5):                                             # mind-point orbs
                on=i< self.player.score; ox=W//2-46+i*23
                pyxel.circ(ox,cy0+72,5,YL if on else 1); pyxel.circb(ox,cy0+72,5,OR if on else 5)
                if on: pyxel.pset(ox-2,cy0+70,WH)
            big_text_c(W//2,cy0+86,"CLASS: "+self.player.label(),CY,1)
            bw=264; bx=W//2-bw//2
            pyxel.rect(bx,cy0+104,bw,12,BK); pyxel.rectb(bx,cy0+104,bw,12,SV)
            pyxel.rect(bx+2,cy0+106,int((bw-4)*min(self.player.score,5)/5),8,GN)
            big_text_c(W//2,cy0+124,"...but a profile is not a person.",PK,1)
            notes=["Talk to everyone - type freely to converse.",
                   "Reading things carefully raises your MIND.",
                   "Never press TAB. That is how it wins."]
            for i,ln in enumerate(notes):
                ly=302+i*18
                pyxel.tri(74,ly+1,74,ly+7,78,ly+4,GN)
                big_text(84,ly,ln,SV,1)
            if (fr//12)%2==0:
                pyxel.rect(W//2-118,372,236,24,BK); pyxel.rectb(W//2-118,372,236,24,CY)
                big_text_c(W//2,378,"[ SPACE ]  ENTER THE SCHOOL",CY,1)
            return
        if self.qi< len(self.qorder):
            draw_q(QUESTIONS[self.qorder[self.qi]],self.qsel,self.qcon,self.qfb,
                   f"CRITICAL THINKING TEST   [ {self.qi+1} / {len(self.qorder)} ]",self.frame)

    def _drw_world(self):
        dfn=ROOM_DRAW.get(self.room)
        if self.room==SUP:
            draw_supply(self.frame, self.player.jammer)
        elif dfn: dfn(self.frame)
        if self.room==LIB and self.passage_open:
            pyxel.rect(4,302,56,136,BK)                 # bookcase pivoted aside
            pyxel.rectb(4,302,56,136,PK)
            for st in range(6):
                pyxel.rect(10,312+st*20,44-st*5,12,NV)  # stairs spiralling down
                pyxel.rectb(10,312+st*20,44-st*5,12,GY)
            tc=PK if (self.frame//8)%2==0 else WH
            big_text(13,420,"DOWN",tc,1)
        if DEBUG_COLLIDERS:
            for cx,cy,cw,ch in COLLIDERS.get(self.room,[]):
                pyxel.rectb(cx,cy,cw,ch,RD)
        for th in self.things.get(self.room,[]):
            is_near=(th is self.near)
            pulse=3 if is_near and self.frame%10<5 else 0
            tx=int(th.x); ty=int(th.y)
            if th.kind=="npc":
                nb=1 if (self.frame//18+hash(th.label))%2==0 else 0
                if th.seated:
                    # the room art draws this character seated; just show the
                    # interaction ring (around the figure, which sits above)
                    if is_near:
                        pyxel.ellib(tx-19,ty-92,38,52,NV)
                        pyxel.ellib(tx-18,ty-91,36,50,YL)
                elif th.sy==32:
                    pyxel.elli(tx-13,ty+8-nb,26,8,GY)
                    talking = (self.state==DLG and self.near is th)
                    tk = talking and (self.frame//4)%2==0
                    bl = (not talking) and _blink_now(self.frame, th.sx//32 + 1)
                    _char_body(tx, ty+8-nb, FACES.get(th.npc_key, PLAYER_SPEC),
                               "down", 0, talk=tk, blink=bl)
                    if is_near:
                        gw=40+pulse*2; gh=70+pulse*2
                        gx0=tx-gw//2; gy0=(ty-22-nb)-gh//2
                        pyxel.ellib(gx0-1,gy0-1,gw+2,gh+2,NV)
                        pyxel.ellib(gx0,gy0,gw,gh,YL)
                else:
                    pyxel.elli(tx-12,ty+8-nb,24,8,GY)
                    pyxel.blt(tx-8,ty-31-nb,0,th.sx,th.sy,16,16,0,scale=2.5)
                    if is_near:
                        gw=30+pulse*2; gh=42+pulse*2
                        gx0=tx-gw//2; gy0=(ty-26-nb)-gh//2
                        pyxel.ellib(gx0-1,gy0-1,gw+2,gh+2,NV)
                        pyxel.ellib(gx0,gy0,gw,gh,YL)
            else:
                # objects are drawn by the room art itself - never blit a box sprite
                if is_near:
                    gw=34+pulse*2; gh=34+pulse*2
                    pyxel.ellib(tx-gw//2-1,ty-gh//2-1,gw+2,gh+2,NV)
                    pyxel.ellib(tx-gw//2,ty-gh//2,gw,gh,YL)
            if is_near:
                ht=f"SPACE: {th.label}"; hw=len(ht)*FNT_ADV
                hx=tx-hw//2; hy=ty-58
                bh=25 if th.kind=="npc" else 13
                pyxel.rect(hx-3,hy-3,hw+6,bh,BK)
                pyxel.rectb(hx-3,hy-3,hw+6,bh,YL)
                big_text(hx,hy,ht,YL,1)
                if th.kind=="npc":
                    big_text(hx,hy+12,"type to talk",CY,1)
        self.player.draw()
        if self.room==CLS and self.server_locked:
            x1,y1,x2,y2=DZONES["S"]
            for g in range(6,0,-2): pyxel.rectb(x1-g,y1-g,x2-x1+g*2,y2-y1+g*2,RD if g<3 else NV)
            pyxel.rect(x1,y1,x2-x1,y2-y1,RD)
            pyxel.rectb(x1,y1,x2-x1,y2-y1,YL)
            pyxel.text(x1+(x2-x1)//2-12,y1+(y2-y1)//2-4,"LOCKED",WH)
            pyxel.circ(x1+(x2-x1)//2,y1+(y2-y1)//2+14,9,YL)
        # HUD
        pyxel.rect(0,0,W,22,BK); pyxel.line(0,22,W,22,NV)
        big_text(6,5,ROOM_NAME.get(self.room,""),WH,1)
        sc="MIND %d/6 %s"%(self.player.score,self.player.label())
        big_text(W-len(sc)*FNT_ADV-4,5,sc,CY,1)
        pyxel.text(W//2-20,2,"[X] menu",GY)
        pyxel.text(W//2+34,2,"v33",LM)
        # Objective line
        obj=self.objective()
        pyxel.rect(0,22,W,16,BK)
        big_text(6,24,"> "+obj,YL,1)
        if self.player.inv:
            iv=" . ".join(self.player.inv)
            pyxel.rect(0,H-16,W,16,NV); big_text(4,H-13,iv,SV,1)

    def _objective_room(self):
        p=self.player
        if not p.has("keycard"): return CAN
        if not p.has("reflexes"): return GYM
        if not p.has("paradox"):  return CLS
        if not p.jammer:          return SUP
        return SRV

    def _render_map(self, ox, oy, cell, gap, target=None):
        pitch=cell+gap
        def centre(rm):
            c,r=MMAP_POS[rm]
            return ox+c*pitch+cell//2, oy+r*pitch+cell//2
        for a,b in MMAP_LINKS:
            ax,ay=centre(a); bx2,by2=centre(b)
            pyxel.line(ax,ay,bx2,by2,NV)
        if self.passage_open:
            a,b=MMAP_SECRET; ax,ay=centre(a); bx2,by2=centre(b)
            bright=PK if (self.frame//8)%2==0 else 14
            steps=10
            for s in range(0,steps,2):
                x_a=ax+(bx2-ax)*s//steps;     y_a=ay+(by2-ay)*s//steps
                x_b=ax+(bx2-ax)*(s+1)//steps; y_b=ay+(by2-ay)*(s+1)//steps
                pyxel.line(x_a,y_a,x_b,y_b,bright)
                pyxel.line(x_a+1,y_a,x_b+1,y_b,bright)
        for rm,(c,r) in MMAP_POS.items():
            if rm==BAS and not self.passage_open:
                continue
            cx=ox+c*pitch; cy=oy+r*pitch
            here=(rm==self.room); seen=rm in self.visited
            if here:
                col=WH if (self.frame//6)%2==0 else CY
                pyxel.rect(cx,cy,cell,cell,col); pyxel.rectb(cx-1,cy-1,cell+2,cell+2,WH)
                tcol=BK
            elif seen:
                pyxel.rect(cx,cy,cell,cell,NV)
                pyxel.rectb(cx,cy,cell,cell,PK if rm==BAS else CY)
                tcol=PK if rm==BAS else WH
            else:
                pyxel.rectb(cx,cy,cell,cell,GY); tcol=GY
            pyxel.text(cx+2,cy+cell//2-1,MMAP_CODE[rm],tcol)
            # blinking objective marker on the target room
            if target is not None and rm==target and not here:
                ring=YL if (self.frame//6)%2==0 else OR
                pyxel.rectb(cx-2,cy-2,cell+4,cell+4,ring)
                pyxel.rectb(cx-3,cy-3,cell+6,cell+6,ring)
                pyxel.tri(cx+cell//2-4,cy-10,cx+cell//2+4,cy-10,cx+cell//2,cy-3,ring)

    def _ending_art(self, lvl, col, fr):
        """A compact tableau under the ending text: a city skyline whose beacons
        stay lit (loss) or go dark (win), and SYNTHIA's eye watching, cracking,
        or shattered - so each ending lands visually, not just in text."""
        bx = W // 2; hz = 392
        if lvl >= 4:                                          # warm dawn glow for the wins
            for r in range(5, 0, -1):
                cc = col if r <= 2 else (OR if lvl >= 5 else 1)
                pyxel.elli(bx - r * 34, hz - r * 9, r * 68, r * 18, cc)
        if lvl <= 1:
            draw_eye(bx, hz - 46, 17, fr, state="scan")       # still watching
        elif lvl <= 3:
            draw_eye(bx, hz - 46, 14, fr, state="glitch")     # cracking
        else:
            for k in range(5):                                # shattered fragments raining
                fy = hz - 72 + ((fr * 2 + k * 26) % 56); fxx = bx + (k - 2) * 26
                pyxel.tri(fxx, fy, fxx + 7, fy + 3, fxx + 2, fy + 10, RD)
        sk = [(30, 30), (20, 46), (38, 22), (24, 40), (34, 26), (22, 36), (30, 30)]
        for i, (bw, bh) in enumerate(sk):
            bx0 = bx - 168 + i * 50
            pyxel.rect(bx0, hz - bh, bw, bh, 1); pyxel.rectb(bx0, hz - bh, bw, bh, NV)
            for wyc in range(hz - bh + 4, hz - 3, 8):         # windows (dark in the wins)
                for wxc in range(bx0 + 3, bx0 + bw - 3, 7):
                    lit = (lvl <= 2) and ((wxc + wyc) // 7 + i) % 3 == 0
                    pyxel.pset(wxc, wyc, YL if lit else 1)
            ax = bx0 + bw // 2                                 # rooftop antenna + beacon
            pyxel.line(ax, hz - bh, ax, hz - bh - 7, 5)
            beacon = (lvl <= 1) or (lvl == 4 and i == 3)       # all lit (loss); one winks (partial)
            if beacon: pyxel.pset(ax, hz - bh - 8, RD if (fr // 8) % 2 == 0 else 1)
        pyxel.rect(0, hz, W, 3, BK); pyxel.line(bx - 170, hz, bx + 170, hz, col)
        for i in range(9):                                     # crowd: waking (win) vs uniform
            fx = bx - 160 + i * 40
            pyxel.rect(fx - 2, hz + 5, 5, 9, NV); pyxel.circ(fx, hz + 3, 2, GY)
            if lvl >= 4: pyxel.pset(fx, hz, YL)                # a spark of doubt above the head

    def _drw_end(self):
        pyxel.cls(BK)
        for y in range(0,H,3): pyxel.line(0,y,W,y,NV)
        if self.epilogue:
            self._drw_epilogue(); return
        lvl=self.player.ending; title,col,lines=ENDINGS.get(lvl,ENDINGS[1])
        big_text_c(W//2,28,title,col,2,shadow=NV)
        pyxel.rect(0,50,W,3,col)
        shown=self.end_t//6
        for i,ln in enumerate(lines):
            if i>=shown: break
            c=WH if ln else BK
            if i==shown-1 and self.end_t%6<3 and ln: c=col   # newest line flickers in
            big_text_c(W//2,62+i*16,ln,c,1)
        if shown>=len(lines): self._ending_art(lvl,col,self.frame)   # tableau once text lands
        sy=H-78
        if shown>len(lines):
            pc=CY if (self.frame//14)%2==0 else GY
            if lvl==5:
                big_text_c(W//2,sy-18,"[ SPACE ]  see what became of them",pc,1)
            else:
                big_text_c(W//2,sy-18,"[ SPACE ]  play again from the start",pc,1)
        else:
            big_text_c(W//2,sy-18,"[ SPACE ]  skip",GY,1)
        pyxel.rect(14,sy,W-28,66,NV); pyxel.rectb(14,sy,W-28,66,SV)
        big_text(22,sy+8, "FINAL MIND: %d / 6"%self.player.score,YL,1)
        big_text(22,sy+26,"CLASS: "+self.player.label(),CY,1)
        big_text(22,sy+44,"SYNTHIA-THOUGHT USES: %d"%self.player.auto_uses,
                 RD if self.player.auto_uses else GN,1)

    def _drw_epilogue(self):
        big_text_c(W//2,22,"EPILOGUE",GN,2,shadow=NV)
        pyxel.rect(0,44,W,2,GN)
        shown=self.end_t//6
        for i,ln in enumerate(EPILOGUE):
            if i>=shown: break
            if ln=="AFTER": continue          # shown as the big header instead
            c=WH if ln else BK
            if i==shown-1 and self.end_t%6<3 and ln: c=LM
            big_text_c(W//2,54+i*15,ln,c,1)
        if shown>len(EPILOGUE):
            pc=CY if (self.frame//14)%2==0 else GY
            big_text_c(W//2,H-28,"[ SPACE ]  play again from the start",pc,1)
        else:
            big_text_c(W//2,H-28,"[ SPACE ]  skip",GY,1)

# ===============================================================
#  HEADLESS TOOLING  (smoke test + PNG renderer, folded in)
# ---------------------------------------------------------------
#  The game is the default. These extra entry points let the one
#  file also self-test and screenshot itself, so there are no
#  separate harness scripts to keep in sync.
#
#  Pyxel needs an OpenGL context, so run these under Xvfb with
#  software GL:
#    SDL_AUDIODRIVER=dummy LIBGL_ALWAYS_SOFTWARE=1 PYXEL_NO_BROWSER=1 \
#      xvfb-run -a -s "-screen 0 600x600x24" python game.py --smoke
#    ... python game.py --render hall intro boss:core chat:mia
#    ... python game.py --render all          # every room (art only)
# ===============================================================
def _headless_game():
    """Build a Game without entering the real loop (pyxel.run -> no-op)."""
    pyxel.run = lambda *a, **k: None
    return Game()


_ROOM_FN = {"hall": "draw_hallway", "cls": "draw_classroom", "lib": "draw_library",
            "can": "draw_canteen", "gym": "draw_gym", "sup": "draw_supply",
            "pri": "draw_principal", "bas": "draw_basement", "srv": "draw_server_room"}
_ROOM_ID = {"hall": HALL, "cls": CLS, "lib": LIB, "can": CAN, "gym": GYM,
            "sup": SUP, "pri": PRI, "bas": BAS, "srv": SRV}


def _grab(path):
    from PIL import Image
    import numpy as np
    cols = list(pyxel.colors)
    pal = [(((c >> 16) & 255), ((c >> 8) & 255), (c & 255)) for c in cols]
    scr = pyxel.screen
    arr = np.zeros((H, W, 3), np.uint8)
    for y in range(H):
        for x in range(W):
            arr[y, x] = pal[scr.pget(x, y) & 15]
    Image.fromarray(arr, "RGB").resize((W * 2, H * 2), Image.NEAREST).save(path)
    print("saved", path)


def _render_one(g, name):
    def _all_tools():
        g.player.jammer = True
        g.player.has = lambda n: True
    pyxel.cls(0)
    if name in _ROOM_FN:
        globals()[_ROOM_FN[name]](g.frame)
    elif name.startswith("world:"):
        g.state = WORLD; g.room = _ROOM_ID[name.split(":", 1)[1]]; g.draw()
    elif name == "intro":
        g.state = INTRO; g._drw_intro()
    elif name.startswith("story"):
        g.state = STORY; g.story_i = int(name[5:]); g.story_t = 80; g._drw_story()
    elif name.startswith("guide"):
        g.state = GUIDE; g.story_i = int(name[5:]); g.story_t = 80; g._drw_guide()
    elif name == "qdone":
        g.state = TEST; g.qdone = True; g.player.score = 4; g._drw_test()
    elif name == "midq":
        draw_q(MID_Q, 1, False, [], "BONUS QUESTION  --  RESISTANCE NOTEBOOK", g.frame)
    elif name == "puz":
        g.puz.reset(); g.puz.draw(g.frame)
    elif name == "simon":
        g.mini = Simon(); g.mini.reset(); g.mini.draw(g)
    elif name == "hoops":
        g.mini = Hoops(); g.mini.reset(); g.mini.draw(g)
    elif name.startswith("end"):
        _all_tools(); g.player.ending = int(name[3:]); g.player.score = 5
        g.player.auto_uses = 0; g.state = END; g.epilogue = False
        g.end_t = 400; g._drw_end()
    elif name == "epilogue":
        _all_tools(); g.state = END; g.epilogue = True; g.end_t = 400; g._drw_end()
    elif name.startswith("boss:"):
        _all_tools(); g.boss.reset(); ph = name.split(":", 1)[1]
        g.boss.phase = ph; g.boss.t = 24
        if ph == "phys": g.boss.shield = 40
        if ph == "core": g.boss.qi = 0
        g.boss.draw(g)
    elif name.startswith("chat:"):
        key = name.split(":", 1)[1]
        g.chat.open(key, 4, g)
        g.chat.history = [("you", "What is really going on here?"),
                          ("npc", "More than they tell you. You're the first in a "
                                  "long while to actually ask. Keep asking.")]
        g.chat.draw(g.frame)
    else:
        print("?? unknown name:", name); return
    _grab("shot_%s.png" % name.replace(":", "_"))


def _run_render(targets):
    g = _headless_game(); g.frame = 18
    if targets == ["all"]:
        targets = list(_ROOM_FN)
    for t in targets:
        try:
            _render_one(g, t)
        except Exception as e:
            print("ERROR rendering", t, "->", repr(e))


def _run_smoke():
    import traceback
    g = _headless_game()
    state = {"errors": 0, "shown": 0}

    def check(label, fn):
        try:
            fn()
        except Exception:
            state["errors"] += 1
            if state["shown"] < 6:
                state["shown"] += 1
                print("---- FAIL:", label)
                traceback.print_exc()

    def tools():
        g.player.jammer = True
        g.player.has = lambda n: True

    for rid in (HALL, CLS, LIB, CAN, GYM, SUP, PRI, BAS, SRV):
        g.state = WORLD; g.room = rid
        check("update " + rid, g.update)
        check("world  " + rid, g.draw)
    for fn in ("draw_hallway", "draw_classroom", "draw_library", "draw_canteen",
               "draw_gym", "draw_supply", "draw_principal", "draw_basement",
               "draw_server_room"):
        check(fn, lambda fn=fn: globals()[fn](g.frame))
    check("intro", lambda: (setattr(g, "state", INTRO), g._drw_intro()))
    for i in range(len(STORY_BEATS)):
        check("story%d" % i, lambda i=i: (setattr(g, "state", STORY),
                                          setattr(g, "story_i", i), g._drw_story()))
    for i in range(len(MIA_GUIDE)):
        check("guide%d" % i, lambda i=i: (setattr(g, "state", GUIDE),
                                          setattr(g, "story_i", i), g._drw_guide()))
    check("qdone", lambda: (setattr(g, "state", TEST),
                            setattr(g, "qdone", True), g._drw_test()))
    check("midq", lambda: draw_q(MID_Q, 1, False, [], "BONUS", g.frame))
    check("puz", lambda: (g.puz.reset(), g.puz.draw(g.frame)))
    check("simon", lambda: (setattr(g, "mini", Simon()), g.mini.reset(), g.mini.draw(g)))
    check("hoops", lambda: (setattr(g, "mini", Hoops()), g.mini.reset(), g.mini.draw(g)))
    tools()
    for ph in ("intro", "phys", "core", "win", "lose"):
        def boss(ph=ph):
            g.boss.reset(); g.boss.phase = ph; g.boss.t = 24
            if ph == "phys": g.boss.shield = 40
            if ph == "core": g.boss.qi = 0
            g.boss.draw(g)
        check("boss:" + ph, boss)
    for lvl in range(0, 6):
        def end(lvl=lvl):
            tools(); g.player.ending = lvl; g.player.score = 5; g.player.auto_uses = 0
            g.state = END; g.epilogue = False; g.end_t = 400; g._drw_end()
        check("end%d" % lvl, end)
    check("epilogue", lambda: (tools(), setattr(g, "state", END),
                               setattr(g, "epilogue", True), setattr(g, "end_t", 400),
                               g._drw_end()))
    for key in ("mia", "teacher", "librarian", "principal", "janitor", "coach"):
        def chat(key=key):
            g.chat.open(key, 4, g)
            g.chat.history = [("you", "hi"), ("npc", "Keep asking questions.")]
            g.chat.draw(g.frame)
        check("chat:" + key, chat)
    print("DONE, errors = %d" % state["errors"])
    return state["errors"]


if __name__ == "__main__":
    import sys
    argv = sys.argv[1:]
    if argv and argv[0] == "--smoke":
        sys.exit(1 if _run_smoke() else 0)
    elif argv and argv[0] == "--render":
        _run_render(argv[1:] or ["hall"])
    else:
        Game()