"""
title: STEM Adventure
description: Render a deterministic text adventure with rooms, inventory, and exportable play records.
version: 0.2.1
"""
import json
from fastapi.responses import HTMLResponse

DEFAULT_SCENARIO = {'title': 'Prism Laboratory', 'introduction': 'Enter a fictional laboratory inspired by Newton’s prism experiments. Investigate light, then compare observations with historical sources.', 'start': 'study', 'rooms': {'study': {'name': 'Study', 'description': 'A notebook asks whether a prism creates colours or separates light. A storeroom lies north; a dark laboratory lies east.', 'exits': {'north': 'storeroom', 'east': 'laboratory'}, 'items': []}, 'storeroom': {'name': 'Storeroom', 'description': 'A glass prism and a screen with a narrow opening rest on a shelf.', 'exits': {'south': 'study'}, 'items': ['prism', 'screen']}, 'laboratory': {'name': 'Laboratory', 'description': 'A shutter controls a narrow beam of sunlight. A pale wall faces the window. You can open or close the shutter, use a prism and screen, test red light, and record a result.', 'exits': {'west': 'study'}, 'items': []}}, 'actions': [{'command': 'open shutter', 'room': 'laboratory', 'requires_items': [], 'requires_flags': [], 'sets_flags': ['beam'], 'clears_flags': [], 'text': 'A narrow beam of sunlight crosses the dark room and reaches the wall.'}, {'command': 'close shutter', 'room': 'laboratory', 'requires_items': [], 'requires_flags': [], 'sets_flags': [], 'clears_flags': ['beam', 'spectrum', 'red_selected', 'red_tested', 'recorded'], 'text': 'The shutter closes. The beam and visible spectrum disappear. Reopen it to repeat the experiment.'}, {'command': 'use prism', 'room': 'laboratory', 'requires_items': ['prism'], 'requires_flags': ['beam'], 'sets_flags': ['spectrum'], 'clears_flags': [], 'text': 'You place the prism in the beam. An elongated band of colours appears on the wall. This observation alone does not settle whether the prism creates or separates colours.'}, {'command': 'use screen', 'room': 'laboratory', 'requires_items': ['screen'], 'requires_flags': ['spectrum'], 'sets_flags': ['red_selected'], 'clears_flags': [], 'text': "The screen's opening isolates red light from the coloured band. You can test how that selected light behaves under further refraction."}, {'command': 'test red light', 'room': 'laboratory', 'requires_items': ['prism'], 'requires_flags': ['red_selected', 'beam'], 'sets_flags': ['red_tested'], 'clears_flags': [], 'text': "In this simplified repeat of the experiment, selected red light is refracted again and remains red. Compare this result with Newton's account before interpreting it."}, {'command': 'record result', 'room': 'laboratory', 'requires_items': [], 'requires_flags': ['red_tested'], 'sets_flags': ['recorded'], 'clears_flags': [], 'text': 'Recorded: sunlight formed a spectrum, selected red light remained red after further refraction. The game is complete. Export your record to examine the procedure, failed actions, and interpretation.'}], 'goal_flags': ['recorded']}
HTML_TEMPLATE = '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>STEM Adventure</title>\n<style>\n*{box-sizing:border-box}\nhtml,body{margin:0;background:#141414;color:#e6e2d8;font:16px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}\nmain{max-width:820px;margin:auto;padding:24px}\nh1{font:700 24px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.025em;text-transform:uppercase;margin:0 0 14px}\np{margin:10px 0}.muted{color:#b8b8b8;font:14px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}\n.arcade-mark{font-size:12px;letter-spacing:.12em;color:#bcb6a7;border-top:3px double #777;padding-top:12px;margin:0 0 18px}\n#status{font-size:14px;color:#ccc;border-block:1px solid #555;padding:7px 0}\n#log{white-space:pre-wrap;overflow-wrap:anywhere;border-bottom:3px double #666;margin:18px 0;padding-bottom:16px;max-height:360px;overflow:auto}\n.entry{margin:0 0 18px}.command{color:#aaa}.result{white-space:pre-wrap}\n.command-entry{display:flex;align-items:center;gap:10px;border:1px solid #666;background:#1b1b1b;padding-left:12px}\ninput{min-width:0;flex:1;background:transparent;color:inherit;border:0;padding:10px 0;font:inherit;min-height:44px}\nbutton{background:#252525;color:inherit;border:0;border-left:1px solid #555;border-radius:0;width:44px;min-height:44px;font:700 22px ui-monospace,SFMono-Regular,Consolas,monospace;cursor:pointer}\nbutton:hover{background:#333}button:focus-visible,input:focus-visible{outline:2px solid white;outline-offset:2px}\n#notice{color:#ccc;white-space:pre-wrap;overflow-wrap:anywhere}#notice:empty{display:none}.hint{font-size:12px;color:#999;margin-top:8px}\ndetails{margin-top:16px}textarea{width:100%;min-height:150px;background:#202020;color:#ddd;font:13px/1.5 monospace;border:1px solid #555;padding:10px}\n@media(max-width:480px){main{padding:16px}h1{font-size:22px}.arcade-mark{letter-spacing:.04em}}\n</style>\n</head>\n<body><main>\n<div class="arcade-mark" aria-hidden="true">░▒▓ STEM / TEXT ADVENTURE ▓▒░</div>\n<h1 id="title"></h1><p id="intro" class="muted"></p><p id="status" aria-live="polite"></p>\n<div id="log" role="log" aria-label="Game transcript"></div>\n<div class="command-entry"><label for="command" aria-hidden="true">❯</label><input id="command" aria-label="Game command" placeholder="Enter command" autocomplete="off" maxlength="120"><button id="enter" type="button" aria-label="Enter command">↵</button></div>\n<p class="hint">Type help for commands.</p><p id="notice" role="status"></p>\n<input id="file" type="file" accept=".json,application/json" hidden>\n<details id="record-panel" hidden><summary>Copy play record</summary><textarea id="record" readonly aria-label="Play record"></textarea></details>\n</main>\n<script>/* Deterministic text-adventure engine. No network, storage, or dynamic code. */\n(function (root) {\n  \'use strict\';\n  function validate(s) {\n    if (!s || typeof s !== \'object\' || Array.isArray(s)) throw Error(\'Scenario must be an object.\');\n    for (const key of [\'title\', \'introduction\', \'start\']) if (typeof s[key] !== \'string\' || !s[key].trim() || s[key].length > 3000) throw Error(\'Invalid \' + key + \'.\');\n    if (!s.rooms || typeof s.rooms !== \'object\' || Array.isArray(s.rooms)) throw Error(\'Rooms are required.\');\n    const ids = Object.keys(s.rooms);\n    if (ids.length < 2 || ids.length > 12 || !ids.includes(s.start)) throw Error(\'Use 2–12 rooms and a valid start.\');\n    const items = new Set();\n    for (const id of ids) {\n      if (!/^[a-z][a-z0-9_-]{0,39}$/.test(id)) throw Error(\'Invalid room ID.\');\n      const r = s.rooms[id];\n      if (!r || typeof r.name !== \'string\' || typeof r.description !== \'string\' || !r.name.trim() || !r.description.trim() || r.name.length > 100 || r.description.length > 3000) throw Error(\'Invalid room text.\');\n      if (!r.exits || typeof r.exits !== \'object\' || Array.isArray(r.exits)) throw Error(\'Invalid exits.\');\n      for (const [direction, target] of Object.entries(r.exits)) if (![\'north\',\'south\',\'east\',\'west\',\'up\',\'down\'].includes(direction) || !ids.includes(target)) throw Error(\'Invalid exit.\');\n      if (!Array.isArray(r.items) || r.items.length > 12) throw Error(\'Invalid items.\');\n      for (const item of r.items) {\n        if (typeof item !== \'string\' || !/^[a-z][a-z -]{0,39}$/.test(item) || items.has(item)) throw Error(\'Items need unique, plain names.\');\n        items.add(item);\n      }\n    }\n    if (!Array.isArray(s.actions) || s.actions.length < 1 || s.actions.length > 30) throw Error(\'Use 1–30 actions.\');\n    const commands = new Set(), flags = new Set();\n    for (const a of s.actions) {\n      if (!a || typeof a.command !== \'string\' || !/^[a-z][a-z -]{1,59}$/.test(a.command) || /^(go |take |look$|inventory$|help$|undo$|restart$|save$|load$|discuss$)/.test(a.command) || commands.has(a.command)) throw Error(\'Invalid or duplicate action command.\');\n      commands.add(a.command);\n      if (!ids.includes(a.room) || typeof a.text !== \'string\' || !a.text.trim() || a.text.length > 3000) throw Error(\'Invalid action.\');\n      for (const k of [\'requires_items\',\'requires_flags\',\'sets_flags\',\'clears_flags\']) {\n        if (!Array.isArray(a[k]) || a[k].length > 20 || a[k].some(x => typeof x !== \'string\' || !/^[a-z][a-z0-9_ -]{0,39}$/.test(x))) throw Error(\'Invalid action condition.\');\n      }\n      if (a.requires_items.some(x => !items.has(x))) throw Error(\'Action refers to an unknown item.\');\n      a.sets_flags.forEach(x => flags.add(x));\n    }\n    if (!Array.isArray(s.goal_flags) || !s.goal_flags.length || s.goal_flags.length > 20 || s.goal_flags.some(x => !flags.has(x))) throw Error(\'Goal needs reachable flag names.\');\n    for (const a of s.actions) if (a.requires_flags.concat(a.clears_flags).some(x => !flags.has(x))) throw Error(\'Unknown flag condition.\');\n    const seen = new Set([s.start]), queue = [s.start];\n    while (queue.length) for (const next of Object.values(s.rooms[queue.shift()].exits)) if (!seen.has(next)) {seen.add(next); queue.push(next);}\n    if (seen.size !== ids.length) throw Error(\'Every room must be reachable from start.\');\n    return s;\n  }\n  function create(s) {\n    validate(s);\n    return {room:s.start, inventory:[], flags:[], taken:[], commands:[], events:[], complete:false};\n  }\n  function describe(s, state) {\n    const r=s.rooms[state.room], items=r.items.filter(x=>!state.taken.includes(x));\n    return r.name+\'\\n\'+r.description+\'\\nExits: \'+Object.keys(r.exits).join(\', \')+(items.length?\'\\nYou see: \'+items.join(\', \'):\'\');\n  }\n  function act(s, old, input) {\n    const state=JSON.parse(JSON.stringify(old));\n    let command=String(input).trim().toLowerCase().replace(/\\s+/g,\' \'), text=\'\', valid=true;\n    const aliases={n:\'go north\',s:\'go south\',e:\'go east\',w:\'go west\',u:\'go up\',d:\'go down\',i:\'inventory\',l:\'look\'};\n    command=Object.hasOwn(aliases,command)?aliases[command]:command;\n    if (command===\'look\') text=describe(s,state);\n    else if (command===\'inventory\') text=\'Inventory: \'+(state.inventory.join(\', \')||\'empty\');\n    else if (command===\'help\') {const actions=s.actions.filter(a=>a.room===state.room).map(a=>a.command);text=\'look · inventory · go [direction] · take [item]\\nundo — reverse one move\\nrestart — begin again\\nsave — download play record\\nload — restore saved record\\ndiscuss — place record in chat\'+(actions.length?\'\\n\\nActions here: \'+actions.join(\', \'):\'\');}\n    else if (command.startsWith(\'go \')) {\n      const exits=s.rooms[state.room].exits, direction=command.slice(3);\n      const target=Object.hasOwn(exits,direction)?exits[direction]:null;\n      if (!target) {valid=false;text=\'No exit in that direction.\';}\n      else {state.room=target;text=describe(s,state);}\n    } else if (command.startsWith(\'take \')) {\n      const item=command.slice(5);\n      if (!s.rooms[state.room].items.includes(item)||state.taken.includes(item)) {valid=false;text=\'That item is not here.\';}\n      else {state.inventory.push(item);state.taken.push(item);text=\'Taken: \'+item+\'.\';}\n    } else {\n      const a=s.actions.find(a=>a.command===command&&a.room===state.room);\n      if (!a) {valid=false;text=\'Unknown action here. Type help to see available commands.\';}\n      else if (a.requires_items.some(x=>!state.inventory.includes(x))) {valid=false;text=\'You need: \'+a.requires_items.filter(x=>!state.inventory.includes(x)).join(\', \')+\'.\';}\n      else if (a.requires_flags.some(x=>!state.flags.includes(x))) {valid=false;text=\'Conditions are not ready. Examine earlier observations or try another action.\';}\n      else {\n        state.flags=state.flags.filter(x=>!a.clears_flags.includes(x));\n        for (const f of a.sets_flags) if (!state.flags.includes(f)) state.flags.push(f);\n        text=a.text;\n      }\n    }\n    state.complete=s.goal_flags.every(x=>state.flags.includes(x));\n    state.commands.push(command);\n    state.events.push({turn:state.commands.length,command,valid,room:state.room,text,flags:[...state.flags],inventory:[...state.inventory],complete:state.complete});\n    return state;\n  }\n  function replay(s,commands) {\n    if (!Array.isArray(commands)||commands.length>300||commands.some(c=>typeof c!==\'string\'||c.length>120)) throw Error(\'Invalid play record.\');\n    return commands.reduce((state,c)=>act(s,state,c),create(s));\n  }\n  root.Adventure={validate,create,describe,act,replay};\n  if (typeof module!==\'undefined\'&&module.exports) module.exports=root.Adventure;\n})(typeof globalThis!==\'undefined\'?globalThis:this);\n</script>\n<script>\n\'use strict\';\nlet scenario=/*SCENARIO*/, state=Adventure.create(scenario);\nconst byId=id=>document.getElementById(id);\nfunction recordJSON(){return JSON.stringify({version:1,scenario,commands:state.commands,result:{room:state.room,inventory:state.inventory,flags:state.flags,complete:state.complete},events:state.events},null,2)}\nfunction render(){\n byId(\'title\').textContent=\'▣ \'+scenario.title;byId(\'intro\').textContent=scenario.introduction;\n byId(\'status\').textContent=\'◉ \'+scenario.rooms[state.room].name+\' · Moves: \'+state.commands.length+\' · \'+(state.complete?\'Complete\':\'In progress\');\n byId(\'log\').replaceChildren();const first=document.createElement(\'p\');first.className=\'entry\';first.textContent=Adventure.describe(scenario,Adventure.create(scenario));byId(\'log\').append(first);\n for(const e of state.events){const item=document.createElement(\'div\'),c=document.createElement(\'div\'),result=document.createElement(\'div\');item.className=\'entry\';c.className=\'command\';c.textContent=\'❯ \'+e.command;result.className=\'result\';result.textContent=e.text;item.append(c,result);byId(\'log\').append(item)}\n byId(\'log\').scrollTop=byId(\'log\').scrollHeight;byId(\'record\').value=recordJSON();reportHeight();\n}\nfunction saveRecord(){const blob=new Blob([recordJSON()],{type:\'application/json\'}),url=URL.createObjectURL(blob),a=document.createElement(\'a\');a.href=url;a.download=\'stem-adventure-record.json\';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);byId(\'notice\').textContent=\'Game saved. Type load to restore this record.\'}\nfunction discussRecord(){\n parent.postMessage({type:\'input:prompt\',text:\'Review this STEM Adventure play record. Identify a failed action, explain its prerequisite, and distinguish scripted observations from historical evidence.\\n\\n\'+recordJSON()},\'*\');\n byId(\'notice\').textContent=parent===window?\'Copy your play record into Sandbox to discuss this run.\':\'Review your play record in chat, then send it. Copy it below if needed.\';byId(\'record-panel\').hidden=false;\n}\nfunction run(raw){\n const command=raw.trim().toLowerCase().replace(/\\s+/g,\' \');if(!command)return;\n byId(\'notice\').textContent=\'\';byId(\'command\').value=\'\';\n if(command===\'undo\'){state=Adventure.replay(scenario,state.commands.slice(0,-1));render();byId(\'notice\').textContent=\'Previous move undone.\'}\n else if(command===\'restart\'){state=Adventure.create(scenario);byId(\'record-panel\').hidden=true;render();byId(\'notice\').textContent=\'Game restarted.\'}\n else if(command===\'save\')saveRecord();\n else if(command===\'load\')byId(\'file\').click();\n else if(command===\'discuss\')discussRecord();\n else if(state.commands.length>=300)byId(\'notice\').textContent=\'Type save to keep this run, then restart.\';\n else {state=Adventure.act(scenario,state,command);render()}\n byId(\'command\').focus();reportHeight();\n}\nbyId(\'enter\').onclick=()=>run(byId(\'command\').value);\nbyId(\'command\').addEventListener(\'keydown\',e=>{if(e.key===\'Enter\')e.preventDefault()});\nbyId(\'command\').addEventListener(\'keyup\',e=>{if(e.key===\'Enter\'){e.preventDefault();run(e.currentTarget.value)}});\nbyId(\'file\').onchange=async()=>{try{const f=byId(\'file\').files[0];if(!f)return;if(f.size>200000)throw Error(\'Record exceeds 200 KB.\');const record=JSON.parse(await f.text());if(record.version!==1)throw Error(\'Unsupported record version.\');const next=Adventure.validate(record.scenario),restored=Adventure.replay(next,record.commands);scenario=next;state=restored;render();byId(\'notice\').textContent=\'Game loaded by replaying saved commands.\'}catch(e){byId(\'notice\').textContent=\'Could not load game. \'+e.message}byId(\'file\').value=\'\';reportHeight()};\nfunction reportHeight(){parent.postMessage({type:\'iframe:height\',height:document.documentElement.scrollHeight},\'*\')}\nwindow.addEventListener(\'load\',reportHeight);new ResizeObserver(reportHeight).observe(document.body);render();\n</script></body></html>\n'

def validate_scenario(s):
    """Reject malformed scenarios before producing an iframe."""
    import re
    def require(condition, message):
        if not condition:
            raise ValueError(message)
    def text(value, limit=3000):
        return isinstance(value, str) and 0 < len(value.strip()) <= limit
    require(isinstance(s, dict), "Scenario must be an object.")
    for field in ("title", "introduction", "start"):
        require(text(s.get(field)), "Invalid " + field)
    rooms = s.get("rooms")
    require(isinstance(rooms, dict) and 2 <= len(rooms) <= 12, "Use 2–12 rooms.")
    require(s["start"] in rooms, "Start room is missing.")
    items = set()
    for identifier, room in rooms.items():
        require(re.fullmatch(r"[a-z][a-z0-9_-]{0,39}", identifier), "Invalid room ID.")
        require(isinstance(room, dict), "Invalid room.")
        require(text(room.get("name"), 100) and text(room.get("description")), "Invalid room text.")
        exits = room.get("exits")
        require(isinstance(exits, dict), "Invalid exits.")
        for direction, target in exits.items():
            require(direction in ("north", "south", "east", "west", "up", "down") and isinstance(target,str) and target in rooms, "Invalid exit.")
        found = room.get("items")
        require(isinstance(found, list) and len(found) <= 12, "Invalid items.")
        for item in found:
            require(isinstance(item, str) and re.fullmatch(r"[a-z][a-z -]{0,39}", item) and item not in items, "Invalid or duplicate item.")
            items.add(item)
    actions = s.get("actions")
    require(isinstance(actions, list) and 1 <= len(actions) <= 30, "Use 1–30 actions.")
    commands, flags = set(), set()
    for action in actions:
        require(isinstance(action, dict), "Invalid action.")
        command = action.get("command")
        require(isinstance(command, str) and re.fullmatch(r"[a-z][a-z -]{1,59}", command) and not re.match(r"^(go |take |look$|inventory$|help$|undo$|restart$|save$|load$|discuss$)", command) and command not in commands, "Invalid or duplicate command.")
        commands.add(command)
        require(isinstance(action.get("room"), str) and action["room"] in rooms and text(action.get("text")), "Invalid action text or room.")
        for field in ("requires_items", "requires_flags", "sets_flags", "clears_flags"):
            value = action.get(field)
            require(isinstance(value, list) and len(value) <= 20 and all(isinstance(x, str) and re.fullmatch(r"[a-z][a-z0-9_ -]{0,39}", x) for x in value), "Invalid condition.")
        require(all(x in items for x in action["requires_items"]), "Unknown required item.")
        flags.update(action["sets_flags"])
    goals = s.get("goal_flags")
    require(isinstance(goals, list) and 0 < len(goals) <= 20 and all(isinstance(x,str) and x in flags for x in goals), "Invalid goal flags.")
    for action in actions:
        require(all(x in flags for x in action["requires_flags"] + action["clears_flags"]), "Unknown flag condition.")
    seen, queue = {s["start"]}, [s["start"]]
    while queue:
        for target in rooms[queue.pop(0)]["exits"].values():
            if target not in seen:
                seen.add(target)
                queue.append(target)
    require(len(seen) == len(rooms), "Every room must be reachable.")
    return s


class Tools:
    def render_stem_adventure(self, scenario_json: str = "") -> tuple:
        """Open a playable text adventure inside chat. Leave scenario_json empty for Prism Laboratory.

        :param scenario_json: Optional JSON scenario with title, introduction, start, rooms, actions, and goal_flags. No HTML or code.
        """
        if not isinstance(scenario_json, str):
            raise ValueError("Scenario must be a JSON string.")
        if len(scenario_json) > 50000:
            raise ValueError("Scenario exceeds 50,000 characters.")
        scenario = json.loads(scenario_json) if scenario_json.strip() else DEFAULT_SCENARIO
        if not isinstance(scenario, dict):
            raise ValueError("Scenario must be a JSON object.")
        for field in ("title", "introduction", "start", "rooms", "actions", "goal_flags"):
            if field not in scenario:
                raise ValueError("Missing scenario field: " + field)
        validate_scenario(scenario)
        payload = json.dumps(scenario, ensure_ascii=True).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
        html = HTML_TEMPLATE.replace("/*SCENARIO*/", payload)
        return HTMLResponse(content=html, headers={"Content-Disposition": "inline"}), {
            "status": "rendered", "title": scenario["title"],
            "description": "Interactive adventure with explicit action rules, inventory, and typed help, undo, restart, save, load, and discuss commands. Scenario validation runs before rendering and inside the game before play.",
            "evaluation": "Ask the player to type discuss inside the game and send the resulting record before discussing their moves. Clicks inside the iframe are not automatically sent to the model.",
            "persistence": "Type save to download a play record, then load to resume after chat reload.",
        }