# TulipWars 2026 v0.1.10 complete install/update package.
# Upload this single file to Tulip Web/Desktop, or use the included physical updater.
import os
import sys
import gc
import tulip

VERSION = '0.1.10'
PACKAGE = 'tulipwars2026'
API_URL = 'https://elementalspark.com/tulipwars2026/api.php'
FILES = {'README.txt': 'TulipWars 2026 v0.1.10 FULL\n\nAnonymous BBS-style space game for Tulip Web, Desktop, and Tulip CC hardware.\nAll sound is synthesized by AMY. No login API and no automatic Tulip World posts.\n\nv0.1.10 COMPLETE INSTALLER + PHYSICAL SERIAL RECOVERY\n- Fixes AMY Web platforms where tulip.amy_ticks_ms is present but not callable.\n- Adds a frame-level AMY safety stop instead of repeating traceback floods.\n- Adds a persistent old-terminal touch keypad on every screen.\n- Numbered menu choices can be tapped directly or selected on the keypad.\n- Touch chat keyboard includes A-Z, 0-9, space, delete, enter, and escape.\n- Tracker controls are all available from the touch terminal.\n- Replaces the physical updater with explicit Tulip-port detection and REPL wake-up.\n- Keeps the six-Juno-voice cap and completely unloads ambience in Tracker.\n- Tulip Web tracker export continues to download a real .MID file.\n\n\nv0.1.10 INSTALLER/HARDWARE RECOVERY\n- Restores the missing _user_dir installer helper that caused the startup NameError.\n- Replaces the malformed hardware helper with a real multi-line Python program.\n- Physical transfer uses the friendly MicroPython REPL and pyserial only.\n- No mpremote raw-REPL dependency is used by the physical updater.\n- Keeps all v0.1.9 touch, AMY clock, six-voice, and MIDI-download behavior unchanged.\n', 'tulipwars2026.py': 'import sys\nimport tulip\n\n# Tulip temporarily adds a package folder to the module path while importing the\n# main file. UIScreen activation happens later, after that temporary path may be\n# gone. Keep the real package folder on sys.path before loading sibling modules.\ntw_config = None\ntw_storage = None\nClient = None\nAmbientEngine = None\nElement115Tracker = None\n\nAPP = None\nGAME = None\nBOOT_ERROR = None\n\n\ndef _prepare_module_path(app=None):\n    candidates = []\n    if app is not None:\n        try:\n            candidates.append(app.app_dir)\n        except Exception:\n            pass\n    try:\n        filename = __file__\n        if \'/\' in filename:\n            candidates.append(filename.rsplit(\'/\', 1)[0])\n    except Exception:\n        pass\n    for path in candidates:\n        if path and path not in sys.path:\n            sys.path.insert(0, path)\n\n\ndef _load_modules(app=None):\n    global tw_config, tw_storage, Client, AmbientEngine, Element115Tracker\n    if tw_config is not None:\n        return\n    _prepare_module_path(app)\n    import tw_config as loaded_config\n    import tw_storage as loaded_storage\n    from tw_net import Client as LoadedClient\n    from tw_audio import AmbientEngine as LoadedAmbientEngine\n    from tw_tracker import Element115Tracker as LoadedTracker\n    tw_config = loaded_config\n    tw_storage = loaded_storage\n    Client = LoadedClient\n    AmbientEngine = LoadedAmbientEngine\n    Element115Tracker = LoadedTracker\n\n\nclass TulipWarsGame:\n    WIDTH = 128\n    HEIGHT = 50\n    CONTENT_WIDTH = 95\n    KEYPAD_X = 96\n    TFB_CHAR_W = 8\n    TFB_CHAR_H = 12\n    FG = 159\n    DIM = 67\n    BRIGHT = 231\n    ACCENT = 45\n    ALERT = 196\n    GOOD = 82\n    BG = 0\n\n    def __init__(self, app):\n        self.app = app\n        self.app_dir = getattr(app, "app_dir", ".")\n        self.local = tw_storage.load(self.app_dir, tw_config.CALLSIGN_PREFIX)\n        self.client = Client(\n            tw_config.SERVER_URL,\n            self.local["device_id"],\n            self.local["callsign"],\n            tw_config.GAME_VERSION,\n        )\n        self.audio = AmbientEngine()\n        self.tracker = Element115Tracker(self.app_dir)\n        self.screen = "home"\n        self.data = {}\n        self.online = False\n        self.message = ""\n        self.last_heartbeat = 0\n        self.nav_systems = []\n        self.market_items = []\n        self.market_selected = 0\n        self.chat_messages = []\n        self.composing = False\n        self.compose_text = ""\n        self.world_messages = []\n        self.last_tracker_redraw = 0\n        self.request_pending = False\n        self.heartbeat_pending = False\n        self.touch_keys = []\n        self.last_touch_ms = 0\n        self.audio_fault = None\n\n    def start(self):\n        tulip.gpu_reset()\n        tulip.tfb_start()\n        tulip.tfb_font(0)\n        tulip.bg_clear(self.BG)\n        # Reactivating the app always returns to the command deck. This avoids\n        # silently reallocating ambience behind a tracker screen after another\n        # Tulip app temporarily deactivated TulipWars.\n        if self.screen == "tracker":\n            self.tracker.release()\n            self.screen = "home"\n        self.audio.start()\n        if self.client.configured():\n            self.connect()\n        else:\n            self.screen = "configure"\n            self.draw()\n\n    def stop(self):\n        self.tracker.release()\n        self.audio.stop()\n\n    def _enter_tracker(self):\n        # The Element115 console gets exclusive ownership of the managed Juno\n        # pool. Merely opening the tracker unloads the ambient PatchSynth; it is\n        # not left allocated silently while the grid is being edited.\n        self.audio.suspend()\n        self.tracker.release()\n        self.screen = "tracker"\n        self.message = "AMBIENT JUNO UNLOADED // TRACKER VOICE CAP: 6"\n\n    def _leave_tracker(self):\n        # Release all tracker voices first, then rebuild the procedural ambient\n        # synth. Ambient notes do not begin until its delayed next chord.\n        self.tracker.release()\n        self.screen = "home"\n        self.audio.resume()\n        self.message = "TRACKER UNLOADED // PROCEDURAL AMBIENCE RESTORED"\n\n    def connect(self):\n        self.message = "CONTACTING ANONYMOUS RELAY..."\n        self.draw()\n\n        def connected(response):\n            self.request_pending = False\n            self._apply(response)\n            self.online = True\n            self.message = response.get("message", "ANONYMOUS RELAY CONNECTED")\n            self.screen = "home"\n            self.last_heartbeat = tulip.ticks_ms()\n            self.draw()\n\n        def failed(exc):\n            self.request_pending = False\n            self.online = False\n            self.message = str(exc)\n            self.screen = "offline"\n            self.draw()\n\n        self.request_pending = True\n        self.client.hello(done=connected, fail=failed)\n\n    def _apply(self, response):\n        for key in ("player", "universe", "online", "battle", "market", "event"):\n            if key in response:\n                self.data[key] = response[key]\n        if "messages" in response:\n            self.chat_messages = response["messages"]\n        if "message" in response:\n            self.message = response["message"]\n        systems = self.data.get("universe", {}).get("systems", [])\n        self.nav_systems = systems[:9]\n        self.market_items = self.data.get("market", {}).get("items", [])\n\n    def _call(self, fn, *args, after=None):\n        if self.client.async_mode and self.request_pending:\n            self.message = "ANONYMOUS RELAY BUSY..."\n            return None\n\n        result = {"response": None}\n\n        def succeeded(response):\n            self.request_pending = False\n            result["response"] = response\n            self._apply(response)\n            self.online = True\n            self.audio.sfx("ok")\n            if after is not None:\n                after(response)\n            if self.client.async_mode:\n                self.draw()\n\n        def failed(exc):\n            self.request_pending = False\n            self.online = False\n            self.message = str(exc)\n            self.audio.sfx("damage")\n            if self.client.async_mode:\n                self.draw()\n\n        if self.client.async_mode:\n            self.request_pending = True\n        fn(*args, done=succeeded, fail=failed)\n        return result["response"]\n\n    def _blank(self):\n        blank = " " * self.WIDTH\n        for y in range(self.HEIGHT):\n            tulip.tfb_str(0, y, blank, 0, self.FG, self.BG)\n\n    def _line(self, y, text="", fg=None, bg=None, fmt=0):\n        if y < 0 or y >= self.HEIGHT:\n            return\n        if fg is None:\n            fg = self.FG\n        if bg is None:\n            bg = self.BG\n        text = str(text).replace("\\n", " ")\n        if len(text) > self.CONTENT_WIDTH:\n            text = text[:self.CONTENT_WIDTH]\n        tulip.tfb_str(0, y, text + (" " * (self.CONTENT_WIDTH - len(text))), fmt, fg, bg)\n        # Menu lines beginning with [X] can be touched directly as well as by\n        # using the terminal keypad. This makes every numbered screen choice\n        # accessible without a hardware keyboard.\n        if len(text) >= 3 and text[0] == "[" and text[2] == "]":\n            self.touch_keys.append((0, y, self.CONTENT_WIDTH - 1, y, ord(text[1])))\n\n    def _pad_write(self, x, y, text, fg=None, bg=None, fmt=0):\n        if y < 0 or y >= self.HEIGHT or x < 0 or x >= self.WIDTH:\n            return\n        if fg is None:\n            fg = self.FG\n        if bg is None:\n            bg = self.BG\n        text = str(text)[:self.WIDTH - x]\n        tulip.tfb_str(x, y, text, fmt, fg, bg)\n\n    def _touch_layout(self):\n        if self.screen == "chat" and self.composing:\n            return [\n                (("1", 49), ("2", 50), ("3", 51), ("4", 52)),\n                (("5", 53), ("6", 54), ("7", 55), ("8", 56)),\n                (("9", 57), ("0", 48), ("A", 65), ("B", 66)),\n                (("C", 67), ("D", 68), ("E", 69), ("F", 70)),\n                (("G", 71), ("H", 72), ("I", 73), ("J", 74)),\n                (("K", 75), ("L", 76), ("M", 77), ("N", 78)),\n                (("O", 79), ("P", 80), ("Q", 81), ("R", 82)),\n                (("S", 83), ("T", 84), ("U", 85), ("V", 86)),\n                (("W", 87), ("X", 88), ("Y", 89), ("Z", 90)),\n                (("SPC", 32), ("DEL", 8), ("ENT", 13), ("ESC", 27)),\n            ]\n        return [\n            (("1", 49), ("2", 50), ("3", 51), ("R", 82)),\n            (("4", 52), ("5", 53), ("6", 54), ("B", 66)),\n            (("7", 55), ("8", 56), ("9", 57), ("S", 83)),\n            (("0", 48), ("F", 70), ("D", 68), ("T", 84)),\n            (("W", 87), ("P", 80), ("M", 77), ("E", 69)),\n            (("[", 91), ("]", 93), ("-", 45), ("+", 43)),\n            ((",", 44), (".", 46), ("SPC", 32), ("ENT", 13)),\n            (("DEL", 8), ("ESC", 27), ("BACK", 48), ("REF", 82)),\n        ]\n\n    def _draw_touch_keypad(self):\n        # The TFB default font is 8x12 on a 128x50 character grid. The keypad\n        # therefore uses character-space hit boxes that map consistently to the\n        # physical 1024x600 touch panel and to mouse clicks in Tulip Web.\n        for row in range(self.HEIGHT):\n            self._pad_write(self.KEYPAD_X - 1, row, "|", self.ACCENT)\n        title = "TOUCH CHAT" if self.screen == "chat" and self.composing else "TOUCH TERMINAL"\n        self._pad_write(self.KEYPAD_X + 8, 0, title[:16], self.BRIGHT, 17, 0x10)\n        self._pad_write(self.KEYPAD_X, 1, "-" * 32, self.ACCENT)\n        layout = self._touch_layout()\n        start_y = 4 if len(layout) >= 10 else 6\n        for row_index, row_keys in enumerate(layout):\n            y = start_y + row_index * 4\n            for col_index, item in enumerate(row_keys):\n                label, code = item\n                x = self.KEYPAD_X + col_index * 8\n                shown = str(label)[:5].center(5)\n                self._pad_write(x, y, "+-----+", self.DIM)\n                self._pad_write(x, y + 1, "|" + shown + "|", self.BRIGHT, 17)\n                self._pad_write(x, y + 2, "+-----+", self.DIM)\n                self.touch_keys.append((x, y, x + 6, y + 2, code))\n        self._pad_write(self.KEYPAD_X, 47, "TAP KEY OR MENU LINE".center(32), self.DIM)\n\n    def touch(self, up):\n        if not up:\n            return\n        try:\n            now = tulip.ticks_ms()\n        except Exception:\n            now = 0\n        if now and now - self.last_touch_ms < 140:\n            return\n        try:\n            points = tulip.touch()\n            px = int(points[0])\n            py = int(points[1])\n        except Exception:\n            return\n        if px < 0 or py < 0:\n            return\n        col = px // self.TFB_CHAR_W\n        row = py // self.TFB_CHAR_H\n        for x0, y0, x1, y1, code in reversed(self.touch_keys):\n            if x0 <= col <= x1 and y0 <= row <= y1:\n                self.last_touch_ms = now\n                self.key(code)\n                return\n\n    def _header(self, section):\n        self._line(0, "=" * self.WIDTH, self.ACCENT)\n        title = " TULIPWARS 2026 // %s // ANONYMOUS SIGNAL %s " % (\n            section,\n            "ONLINE" if self.online else "OFFLINE",\n        )\n        self._line(1, title, self.BRIGHT, 17, 0x10)\n        self._line(2, "=" * self.WIDTH, self.ACCENT)\n\n    def _footer(self, help_text="[0] BACK"):\n        self._line(46, "-" * self.WIDTH, self.DIM)\n        self._line(47, help_text, self.BRIGHT)\n        self._line(48, self.message, self.GOOD if self.online else self.ALERT)\n        self._line(49, "CTRL-Q QUITS  //  CTRL-TAB SWITCHES APPS", self.DIM)\n\n    def draw(self):\n        self.touch_keys = []\n        self._blank()\n        method = getattr(self, "draw_" + self.screen, None)\n        if method is None:\n            self.screen = "home"\n            method = self.draw_home\n        method()\n        self._draw_touch_keypad()\n\n    def draw_configure(self):\n        self._header("FIRST CONTACT")\n        self._line(5, "THE SHIP CONSOLE NEEDS YOUR SELF-HOSTED PHP RELAY.", self.BRIGHT)\n        self._line(7, "1. CTRL-TAB TO THE REPL, THEN cd(\'tulipwars2026\')")\n        self._line(8, "2. import tw_setup; tw_setup.configure()")\n        self._line(9, "3. ENTER YOUR HTTPS .../api.php URL, RETURN TO /user, RUN AGAIN")\n        self._line(12, "NO ACCOUNT, PASSWORD, EMAIL, OAUTH, OR LOGIN API IS USED.", self.GOOD)\n        self._line(14, "A RANDOM LOCAL SHIP ID IS STORED ONLY IN local_state.json.")\n        self._line(16, "CALLSIGN: %s" % self.local["callsign"], self.ACCENT)\n        self._footer("EDIT CONFIG, THEN RESTART THE PACKAGE")\n\n    def draw_offline(self):\n        self._header("RELAY FAILURE")\n        self._line(5, "THE SHIP CAN STILL RUN THE ELEMENT115 TRACKER OFFLINE.", self.BRIGHT)\n        self._line(7, "[R] RETRY RELAY")\n        self._line(8, "[6] OPEN ELEMENT115 TRACKER")\n        self._line(10, "CHECK WI-FI, SERVER_URL, HTTPS, AND WEBSITE/data WRITE PERMISSION.")\n        self._footer("[R] RETRY   [6] TRACKER")\n\n    def draw_home(self):\n        self._header("COMMAND DECK")\n        player = self.data.get("player", {})\n        system = player.get("system_name", "UNKNOWN")\n        self._line(4, "CAPTAIN %-18s  SHIP %-20s" % (player.get("callsign", self.local["callsign"]), player.get("ship_name", "LOST TULIP")), self.BRIGHT)\n        self._line(5, "LOCATION %-22s CREDITS %7s  FUEL %3s  HULL %3s  SHIELD %3s" % (\n            system,\n            player.get("credits", "-"),\n            player.get("fuel", "-"),\n            player.get("hull", "-"),\n            player.get("shield", "-"),\n        ))\n        self._line(7, "[1] SHIP STATUS / CARGO")\n        self._line(8, "[2] NAVIGATION / EXPLORE SPACE")\n        self._line(9, "[3] STATION COMMODITY EXCHANGE")\n        self._line(10, "[4] LONG-RANGE SCAN / ALIEN BATTLE")\n        self._line(11, "[5] ANONYMOUS STATION CHAT")\n        self._line(12, "[6] ELEMENT115 TRACKER / MIDI EXPORT")\n        self._line(13, "[7] TULIP WORLD PUBLIC LOUNGE - READ ONLY HERE")\n        self._line(15, "[R] REFRESH RELAY")\n        online = self.data.get("online", [])\n        self._line(18, "CAPTAINS CURRENTLY DOCKED", self.ACCENT)\n        if online:\n            for index, captain in enumerate(online[:12]):\n                self._line(20 + index, "%-22s %s" % (captain.get("callsign", "ANON"), captain.get("activity", "DOCKED")))\n        else:\n            self._line(20, "NO OTHER ACTIVE SIGNALS AT THIS STATION.", self.DIM)\n        self._footer("[1-7] SELECT TERMINAL   [R] REFRESH   // TOUCH KEYPAD READY")\n\n    def draw_status(self):\n        self._header("SHIP STATUS")\n        player = self.data.get("player", {})\n        self._line(4, "SHIP: %s" % player.get("ship_name", "LOST TULIP"), self.BRIGHT)\n        self._line(5, "CALLSIGN: %s" % player.get("callsign", self.local["callsign"]))\n        self._line(6, "SYSTEM: %s" % player.get("system_name", "UNKNOWN"))\n        self._line(7, "CREDITS: %s" % player.get("credits", 0))\n        self._line(8, "FUEL: %s / 100" % player.get("fuel", 0))\n        self._line(9, "HULL: %s / 100" % player.get("hull", 0))\n        self._line(10, "SHIELD: %s / 100" % player.get("shield", 0))\n        self._line(12, "CARGO HOLD", self.ACCENT)\n        cargo = player.get("cargo", {})\n        items = self.data.get("universe", {}).get("commodities", [])\n        for index, item in enumerate(items):\n            self._line(14 + index, "%-18s %3d" % (item.get("name", item.get("id", "ITEM")), cargo.get(item.get("id"), 0)))\n        self._footer()\n\n    def draw_navigation(self):\n        self._header("NAVIGATION")\n        player = self.data.get("player", {})\n        self._line(4, "CURRENT SYSTEM: %s  //  FUEL: %s" % (player.get("system_name", "UNKNOWN"), player.get("fuel", 0)), self.BRIGHT)\n        self._line(6, "SELECT A DESTINATION. THE RELAY CALCULATES FUEL AND ENCOUNTERS.", self.DIM)\n        for index, system in enumerate(self.nav_systems):\n            marker = "*" if system.get("id") == player.get("system") else " "\n            self._line(8 + index, "[%d] %s %-22s  TYPE %-12s  DANGER %s" % (\n                index + 1,\n                marker,\n                system.get("name", "UNKNOWN"),\n                system.get("type", "SPACE"),\n                system.get("danger", 1),\n            ))\n        self._footer("[1-9] WARP   [0] BACK")\n\n    def draw_market(self):\n        self._header("COMMODITY EXCHANGE")\n        player = self.data.get("player", {})\n        self._line(4, "CREDITS: %s  //  SELECT ITEM, THEN BUY OR SELL ONE UNIT" % player.get("credits", 0), self.BRIGHT)\n        if not self.market_items:\n            self._line(7, "NO MARKET DATA. PRESS [R] TO REFRESH.", self.ALERT)\n        for index, item in enumerate(self.market_items[:8]):\n            selected = ">" if index == self.market_selected else " "\n            cargo = player.get("cargo", {}).get(item.get("id"), 0)\n            self._line(7 + index, "%s[%d] %-16s BUY %5d  SELL %5d  HOLD %3d" % (\n                selected,\n                index + 1,\n                item.get("name", item.get("id", "ITEM")),\n                item.get("buy", 0),\n                item.get("sell", 0),\n                cargo,\n            ), self.BRIGHT if selected == ">" else self.FG)\n        self._footer("[1-8] SELECT   [B] BUY 1   [S] SELL 1   [R] REFRESH   [0] BACK")\n\n    def draw_combat(self):\n        self._header("DEEP-SPACE OPERATIONS")\n        battle = self.data.get("battle")\n        player = self.data.get("player", {})\n        if battle and battle.get("active"):\n            self._line(4, "HOSTILE CONTACT: %s" % battle.get("enemy_name", "ALIEN"), self.ALERT)\n            self._line(6, "ENEMY HULL   %3d / %3d" % (battle.get("enemy_hull", 0), battle.get("enemy_max_hull", 100)))\n            self._line(7, "YOUR HULL    %3d / 100" % player.get("hull", 0))\n            self._line(8, "YOUR SHIELD  %3d / 100" % player.get("shield", 0))\n            self._line(11, "[F] FIRE PULSE CANNONS")\n            self._line(12, "[D] DIVERT POWER TO SHIELDS")\n            self._line(13, "[R] EMERGENCY RETREAT")\n            self._line(16, battle.get("log", "THE SIGNAL SCREAMS THROUGH THE VOID."), self.ACCENT)\n            self._footer("[F] FIRE   [D] DEFEND   [R] RETREAT   [0] BACK")\n        else:\n            self._line(5, "NO ACTIVE HOSTILE CONTACT.", self.GOOD)\n            self._line(7, "[S] SCAN FOR SIGNALS, SALVAGE, TRADERS, AND ALIENS")\n            event = self.data.get("event")\n            if event:\n                self._line(10, event.get("title", "LAST SCAN"), self.ACCENT)\n                self._line(11, event.get("text", ""))\n            self._footer("[S] SCAN   [0] BACK")\n\n    def draw_chat(self):\n        self._header("STATION CHAT")\n        player = self.data.get("player", {})\n        self._line(4, "LOCAL BOARD: %s // MANUAL POSTS ONLY" % player.get("system_name", "UNKNOWN"), self.BRIGHT)\n        start = max(0, len(self.chat_messages) - 16)\n        y = 6\n        for item in self.chat_messages[start:]:\n            text = "<%s> %s" % (item.get("callsign", "ANON"), item.get("message", ""))\n            self._line(y, text)\n            y += 1\n        if not self.chat_messages:\n            self._line(7, "THE STATION BOARD IS SILENT.", self.DIM)\n        if self.composing:\n            self._line(42, "> " + self.compose_text + "_", self.BRIGHT, 17)\n            self._footer("TYPE MESSAGE   ENTER SENDS   ESC CANCELS")\n        else:\n            self._footer("[T] TYPE MESSAGE   [R] REFRESH   [0] BACK")\n\n    def draw_tracker(self):\n        self._header("ELEMENT115 TRACKER")\n        self._line(4, "JUNO PATCH %03d  BPM %03d  PLAY %s  MIDI OUT %s" % (\n            self.tracker.patch,\n            self.tracker.bpm,\n            "YES" if self.tracker.playing else "NO",\n            "YES" if self.tracker.midi_thru else "NO",\n        ), self.BRIGHT)\n        self._line(5, "AMBIENT UNLOADED // HARD CAP: 6 SIMULTANEOUS JUNO NOTES", self.GOOD)\n        for index, row in enumerate(self.tracker.rows_for_display()):\n            self._line(8 + index, row, self.BRIGHT if index == self.tracker.row else self.FG)\n        self._line(18, "CURSOR +/@   NOTE X   PLAYHEAD :/*", self.DIM)\n        self._line(20, "[W/S] ROW   [[/]] STEP   [-/+] NOTE   [SPACE] TOGGLE")\n        self._line(21, "[R] RANDOMIZE   [P] PLAY/STOP   [M] MIDI OUT   [E] EXPORT .MID")\n        self._line(22, "[,] BPM -/+  //  WEB DOWNLOADS; HARDWARE SAVES LOCALLY")\n        if self.tracker.last_export:\n            status = "BROWSER DOWNLOADED" if self.tracker.last_browser_download else "SAVED LOCALLY"\n            self._line(25, "LAST EXPORT: %s // %s" % (self.tracker.last_export, status), self.GOOD)\n        self._footer("[0] BACK   TOUCH CONTROLS ACTIVE   AMY AUDIO ONLY")\n\n    def draw_world(self):\n        self._header("TULIP WORLD PUBLIC LOUNGE")\n        self._line(4, "READ-ONLY INSIDE TULIPWARS. NOTHING IS POSTED AUTOMATICALLY.", self.GOOD)\n        self._line(5, "USE run(\'worldui\') SEPARATELY WHEN YOU CHOOSE TO CHAT PUBLICLY.", self.DIM)\n        y = 7\n        if self.world_messages:\n            for item in self.world_messages[-12:]:\n                self._line(y, "<%s> %s" % (item.get("username", "ANON"), item.get("content", "")))\n                y += 1\n        else:\n            self._line(8, "PRESS [R] TO READ THE LATEST PUBLIC TULIP WORLD MESSAGES.")\n        self._line(23, "MANUAL PACKAGE UPDATE COMMAND", self.ACCENT)\n        if tw_config.WORLD_AUTHOR:\n            command = "world.download(\'%s\',\'%s\')" % (tw_config.WORLD_PACKAGE, tw_config.WORLD_AUTHOR)\n        else:\n            command = "world.download(\'%s\')" % tw_config.WORLD_PACKAGE\n        self._line(25, command, self.BRIGHT)\n        self._line(27, "THE GAME NEVER CALLS world.post_message().", self.GOOD)\n        self._footer("[R] READ PUBLIC LOUNGE   [0] BACK")\n\n    def key(self, code):\n        if self.composing:\n            self._chat_key(code)\n            return\n        if code in (48,):  # 0\n            if self.screen == "tracker":\n                self._leave_tracker()\n            else:\n                self.screen = "home"\n            self.draw()\n            return\n        try:\n            char = chr(code)\n        except Exception:\n            return\n        upper = char.upper()\n\n        if self.screen == "configure":\n            return\n        if self.screen == "offline":\n            if upper == "R":\n                self.connect()\n            elif char == "6":\n                self._enter_tracker()\n                self.draw()\n            return\n        if self.screen == "home":\n            self._key_home(char, upper)\n        elif self.screen == "navigation":\n            self._key_navigation(char)\n        elif self.screen == "market":\n            self._key_market(char, upper)\n        elif self.screen == "combat":\n            self._key_combat(upper)\n        elif self.screen == "chat":\n            self._key_chat(upper)\n        elif self.screen == "tracker":\n            self._key_tracker(char, upper)\n        elif self.screen == "world":\n            if upper == "R":\n                self.read_world()\n\n    def _key_home(self, char, upper):\n        if char == "1":\n            self.screen = "status"\n        elif char == "2":\n            self.screen = "navigation"\n        elif char == "3":\n            self.screen = "market"\n            self._call(self.client.market)\n        elif char == "4":\n            self.screen = "combat"\n        elif char == "5":\n            self.screen = "chat"\n            self._call(self.client.chat_list)\n        elif char == "6":\n            self._enter_tracker()\n        elif char == "7":\n            self.screen = "world"\n        elif upper == "R":\n            self._call(self.client.status)\n        self.draw()\n\n    def _key_navigation(self, char):\n        if char >= "1" and char <= "9":\n            index = ord(char) - ord("1")\n            if index < len(self.nav_systems):\n                destination = self.nav_systems[index]\n                self._call(\n                    self.client.travel,\n                    destination.get("id"),\n                    after=lambda response: self.audio.sfx("warp"),\n                )\n        self.draw()\n\n    def _key_market(self, char, upper):\n        if char >= "1" and char <= "8":\n            index = ord(char) - ord("1")\n            if index < len(self.market_items):\n                self.market_selected = index\n        elif upper == "R":\n            self._call(self.client.market)\n        elif upper in ("B", "S") and self.market_items:\n            item = self.market_items[self.market_selected]\n            side = "buy" if upper == "B" else "sell"\n            self._call(self.client.trade, item.get("id"), side, 1)\n        self.draw()\n\n    def _key_combat(self, upper):\n        battle = self.data.get("battle")\n        if battle and battle.get("active"):\n            command = None\n            if upper == "F":\n                command = "fire"\n                self.audio.sfx("laser")\n            elif upper == "D":\n                command = "defend"\n            elif upper == "R":\n                command = "retreat"\n            if command:\n                def combat_done(response):\n                    if response.get("damage_taken", 0) > 0:\n                        self.audio.sfx("damage")\n                self._call(self.client.combat, command, after=combat_done)\n        elif upper == "S":\n            self._call(self.client.scan)\n        self.draw()\n\n    def _key_chat(self, upper):\n        if upper == "R":\n            self._call(self.client.chat_list)\n        elif upper == "T":\n            self.composing = True\n            self.compose_text = ""\n        self.draw()\n\n    def _chat_key(self, code):\n        if code in (10, 13):\n            text = self.compose_text.strip()\n            self.composing = False\n            self.compose_text = ""\n            if text:\n                self._call(self.client.chat_post, text)\n            self.draw()\n            return\n        if code == 27:\n            self.composing = False\n            self.compose_text = ""\n            self.draw()\n            return\n        if code in (8, 127):\n            self.compose_text = self.compose_text[:-1]\n            self.draw()\n            return\n        if 32 <= code <= 126 and len(self.compose_text) < 80:\n            self.compose_text += chr(code)\n            self.draw()\n\n    def _key_tracker(self, char, upper):\n        redraw = True\n        if upper == "W":\n            self.tracker.move_row(-1)\n        elif upper == "S":\n            self.tracker.move_row(1)\n        elif char == "[":\n            self.tracker.move_step(-1)\n        elif char == "]":\n            self.tracker.move_step(1)\n        elif char in ("-", "_"):\n            self.tracker.change_note(-1)\n        elif char in ("+", "="):\n            self.tracker.change_note(1)\n        elif char == ",":\n            self.tracker.change_bpm(-1)\n        elif char == ".":\n            self.tracker.change_bpm(1)\n        elif char == " ":\n            self.tracker.toggle()\n        elif upper == "R":\n            was_playing = self.tracker.playing\n            self.tracker.stop()\n            self.tracker.release()\n            self.tracker.randomize()\n            if was_playing:\n                self.tracker.start()\n        elif upper == "P":\n            if self.tracker.playing:\n                self.tracker.stop()\n            else:\n                self.tracker.start()\n        elif upper == "M":\n            self.tracker.midi_thru = not self.tracker.midi_thru\n        elif upper == "E":\n            try:\n                path, downloaded = self.tracker.export()\n                if downloaded:\n                    self.message = "MIDI EXPORTED AND DOWNLOADED: %s" % path\n                else:\n                    self.message = "MIDI EXPORTED LOCALLY: %s" % path\n            except Exception as exc:\n                self.message = "MIDI EXPORT FAILED: %s" % exc\n        else:\n            redraw = False\n        if redraw:\n            self.draw()\n\n    def read_world(self):\n        try:\n            if tulip.board() in ("WEB", "AMYBOARD_WEB"):\n                self.message = "TULIP WEB USES ASYNC WORLD ACCESS; OPEN worldui DIRECTLY."\n            else:\n                import world\n                messages = world.messages(n=12, mtype="text")\n                messages.reverse()\n                self.world_messages = messages\n                self.message = "PUBLIC LOUNGE READ. NO MESSAGE WAS POSTED."\n                self.online = True\n        except Exception as exc:\n            self.message = "WORLD READ FAILED: %s" % exc\n        self.draw()\n\n    def tick(self):\n        now = tulip.ticks_ms()\n        if self.screen == "tracker":\n            # AmbientEngine is fully released for the entire tracker screen,\n            # even while playback is stopped and the user is only editing.\n            if self.tracker.playing:\n                advanced = self.tracker.tick(now)\n                if advanced and now - self.last_tracker_redraw > 40:\n                    self.last_tracker_redraw = now\n                    self.draw()\n        else:\n            if self.audio_fault is None:\n                try:\n                    self.audio.tick(now)\n                except Exception as exc:\n                    # A platform audio-clock difference must never flood the\n                    # frame callback with tracebacks or make the game unusable.\n                    self.audio_fault = str(exc)\n                    self.audio.stop()\n                    self.message = "AMY SAFETY STOP: %s" % self.audio_fault\n                    self.draw()\n\n        if self.online and now - self.last_heartbeat >= tw_config.HEARTBEAT_MS:\n            self.last_heartbeat = now\n            # This heartbeat goes only to the user\'s private PHP host.\n            # It never touches Tulip World.\n            if self.screen not in ("chat", "tracker") and not self.heartbeat_pending and not self.request_pending:\n                self.heartbeat_pending = True\n\n                def heartbeat_ok(response):\n                    self.heartbeat_pending = False\n                    self._apply(response)\n                    self.online = True\n\n                def heartbeat_fail(_exc):\n                    self.heartbeat_pending = False\n                    self.online = False\n\n                self.client.heartbeat(done=heartbeat_ok, fail=heartbeat_fail)\n\n\ndef _fatal_screen(exc):\n    # Keep failures visible on Tulip Web/hardware instead of leaving a black app.\n    try:\n        tulip.gpu_reset()\n        tulip.tfb_start()\n        tulip.tfb_font(0)\n        tulip.bg_clear(0)\n        message = str(exc).replace("\\n", " ")\n        lines = [\n            "=" * 128,\n            " TULIPWARS 2026 // STARTUP ERROR ",\n            "=" * 128,\n            "",\n            "THE DISPLAY IS WORKING, BUT STARTUP HIT AN ERROR:",\n            message[:128],\n            "",\n            "REFRESH TULIP WEB, THEN RUN THE FULL INSTALLER AGAIN.",\n        ]\n        for y, text in enumerate(lines):\n            tulip.tfb_str(0, y, text + (" " * max(0, 128 - len(text))), 0, 231 if y != 5 else 196, 0)\n    except Exception:\n        pass\n\n\ndef activate(app):\n    global GAME\n    try:\n        if BOOT_ERROR is not None:\n            raise BOOT_ERROR\n        _load_modules(app)\n        if GAME is None:\n            GAME = TulipWarsGame(app)\n        GAME.start()\n        tulip.keyboard_callback(on_key)\n        tulip.touch_callback(on_touch)\n        tulip.frame_callback(on_frame, app)\n    except Exception as exc:\n        _fatal_screen(exc)\n\n\ndef deactivate(app):\n    tulip.keyboard_callback()\n    try:\n        tulip.touch_callback()\n    except Exception:\n        pass\n    tulip.frame_callback()\n    if GAME is not None:\n        GAME.stop()\n\n\ndef quit_app(app):\n    deactivate(app)\n\n\ndef on_key(code):\n    if GAME is not None:\n        GAME.key(code)\n\n\ndef on_touch(up):\n    if GAME is not None:\n        GAME.touch(up)\n\n\ndef on_frame(app):\n    if not getattr(app, "active", True):\n        return\n    if GAME is not None:\n        try:\n            GAME.tick()\n        except Exception as exc:\n            # Last-resort frame guard: stop audio and keep the terminal alive.\n            try:\n                GAME.audio.stop()\n                GAME.message = "FRAME SAFETY STOP: %s" % exc\n                GAME.draw()\n            except Exception:\n                pass\n\n\ndef run(app):\n    global APP, BOOT_ERROR\n    APP = app\n    BOOT_ERROR = None\n    _prepare_module_path(app)\n    try:\n        # Import while Tulip is still inside the package loader. The explicit\n        # path remains available later when the UIScreen activates.\n        _load_modules(app)\n    except Exception as exc:\n        BOOT_ERROR = exc\n    app.game = True\n\n    # Tulip UIScreens hide the text framebuffer by default. TulipWars draws its\n    # entire console on that framebuffer, so keeping it visible is mandatory.\n    app.keep_tfb = True\n    app.bg_color = 0\n    app.offset_y = 0\n\n    # Keep the task bar visible in Tulip Web while testing. Hardware/Desktop\n    # retain the intended full-screen game presentation.\n    try:\n        app.hide_task_bar = tulip.board() != "WEB"\n    except Exception:\n        app.hide_task_bar = False\n\n    app.activate_callback = activate\n    app.deactivate_callback = deactivate\n    app.quit_callback = quit_app\n    app.present()\n', 'tw_audio.py': 'import random\nimport tulip\nimport amy\nimport synth\n\n\ndef _audio_ticks_ms():\n    """Return a usable AMY scheduling clock on hardware, Desktop, and Web."""\n    try:\n        fn = getattr(tulip, "amy_ticks_ms", None)\n        if callable(fn):\n            return int(fn())\n    except Exception:\n        pass\n    try:\n        fn = getattr(tulip, "ticks_ms", None)\n        if callable(fn):\n            return int(fn())\n    except Exception:\n        pass\n    return 0\n\n\nclass AmbientEngine:\n    """AMY-only procedural LOST SIGNALS-inspired Juno ambience.\n\n    Hardware rule: never allocate more than six Juno voices. The ambient bed\n    deliberately uses only four, leaving headroom for Tulip itself and for the\n    short single-oscillator interface effects.\n    """\n\n    MAX_JUNO_VOICES = 6\n    AMBIENT_VOICES = 4\n    SFX_OSC = 119\n\n    JUNO_PATCHES = [0, 1, 5, 11, 18, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120]\n    ROOTS = [34, 36, 38, 41, 43, 45]\n    CHORDS = [\n        [0, 3, 7, 10],\n        [0, 5, 7, 12],\n        [0, 2, 7, 9],\n        [0, 3, 8, 12],\n        [0, 7, 10, 15],\n    ]\n\n    def __init__(self):\n        self.synth = None\n        self.patch = None\n        self.next_chord_ms = 0\n        self.pending_off = []\n        self.chord_count = 0\n        self.enabled = True\n        self.suspended = False\n\n    def _configure_fx(self):\n        try:\n            amy.reverb(0.62)\n            amy.echo(level=0.16, delay_ms=random.choice([620, 740, 860, 980]), feedback=0.38)\n        except Exception:\n            pass\n\n    def _all_off(self):\n        if self.synth is not None:\n            try:\n                self.synth.all_notes_off()\n            except Exception:\n                pass\n        self.pending_off = []\n\n    def _release_synth(self):\n        self._all_off()\n        if self.synth is not None:\n            try:\n                self.synth.release()\n            except Exception:\n                pass\n        self.synth = None\n\n    def _new_synth(self):\n        # Release the old Juno allocation before requesting another one. This is\n        # essential on Tulip hardware, where overlapping PatchSynth allocations\n        # can trigger AMY overload warning tones.\n        self._release_synth()\n        self.patch = random.choice(self.JUNO_PATCHES)\n        try:\n            self.synth = synth.PatchSynth(num_voices=self.AMBIENT_VOICES, patch=self.patch)\n        except Exception:\n            self.synth = None\n\n    def start(self):\n        self.enabled = True\n        self.suspended = False\n        self._configure_fx()\n        self._new_synth()\n        self.next_chord_ms = tulip.ticks_ms() + 600\n\n    def suspend(self):\n        """Completely unload the ambient Juno while another console owns AMY."""\n        self.enabled = False\n        self.suspended = True\n        self._release_synth()\n\n    def pause(self):\n        # Kept as a compatibility alias for older callers.\n        self.suspend()\n\n    def resume(self):\n        self.enabled = True\n        self.suspended = False\n        self._configure_fx()\n        if self.synth is None:\n            self._new_synth()\n        self.next_chord_ms = tulip.ticks_ms() + 700\n\n    def stop(self):\n        self.enabled = False\n        self.suspended = False\n        self._release_synth()\n        # Do not reset AMY globally; another Tulip app may be sounding.\n\n    def tick(self, now_ms):\n        if self.synth is None or not self.enabled or self.suspended:\n            return\n\n        keep = []\n        for off_ms, note in self.pending_off:\n            if now_ms >= off_ms:\n                try:\n                    self.synth.note_off(note)\n                except Exception:\n                    pass\n            else:\n                keep.append((off_ms, note))\n        self.pending_off = keep\n\n        if now_ms < self.next_chord_ms:\n            return\n\n        self.chord_count += 1\n        if self.chord_count % random.choice([4, 5, 6]) == 0:\n            self._new_synth()\n            if self.synth is None:\n                return\n\n        # Reuse the same four managed voices instead of allowing long Juno\n        # releases from one chord to overlap a second chord.\n        self._all_off()\n        root = random.choice(self.ROOTS)\n        chord = random.choice(self.CHORDS)\n        spread = random.choice([0, 0, 12])\n        duration = random.randint(6500, 10500)\n        amy_now = _audio_ticks_ms()\n        for index, interval in enumerate(chord[:self.AMBIENT_VOICES]):\n            note = root + interval + (spread if index >= 2 else 0)\n            velocity = 0.09 + (random.random() * 0.10)\n            try:\n                self.synth.note_on(note, velocity, time=amy_now + index * random.randint(120, 340))\n                self.pending_off.append((now_ms + duration + index * 120, note))\n            except Exception:\n                pass\n        self.next_chord_ms = now_ms + random.randint(9000, 15000)\n\n    def sfx(self, kind="ok"):\n        # One reusable oscillator prevents interface sounds from accumulating.\n        # Tracker mode intentionally skips these effects to keep its six Juno\n        # voices exclusive on hardware.\n        if self.suspended:\n            return\n        try:\n            now = _audio_ticks_ms()\n            amy.send(osc=self.SFX_OSC, vel=0, time=now)\n            if kind == "laser":\n                amy.send(osc=self.SFX_OSC, wave=amy.SAW_DOWN, note=82, vel=0.22, time=now + 2)\n                amy.send(osc=self.SFX_OSC, note=55, time=now + 80)\n                amy.send(osc=self.SFX_OSC, vel=0, time=now + 190)\n            elif kind == "damage":\n                amy.send(osc=self.SFX_OSC, wave=amy.NOISE, vel=0.18, time=now + 2)\n                amy.send(osc=self.SFX_OSC, vel=0, time=now + 180)\n            elif kind == "warp":\n                amy.send(osc=self.SFX_OSC, wave=amy.SINE, note=42, vel=0.18, time=now + 2)\n                amy.send(osc=self.SFX_OSC, note=76, time=now + 260)\n                amy.send(osc=self.SFX_OSC, vel=0, time=now + 580)\n            else:\n                amy.send(osc=self.SFX_OSC, wave=amy.SINE, note=76, vel=0.13, time=now + 2)\n                amy.send(osc=self.SFX_OSC, vel=0, time=now + 95)\n        except Exception:\n            pass\n', 'tw_config.py': '# TulipWars 2026 defaults.\n# A separate /user/tulipwars_user_config.py overrides these values so that\n# Tulip World package updates never erase a player\'s server configuration.\nSERVER_URL = "https://elementalspark.com/tulipwars2026/api.php"\nGAME_VERSION = "0.1.10"\nGAME_TITLE = "TulipWars 2026"\nWORLD_PACKAGE = "tulipwars2026"\nWORLD_AUTHOR = ""\nCALLSIGN_PREFIX = "TULIP"\nHEARTBEAT_MS = 60000\n\n\ndef _load_user_config():\n    global SERVER_URL, WORLD_AUTHOR\n    try:\n        import tulip\n        rooted = tulip.root_dir() + "user/tulipwars_user_config.py"\n    except Exception:\n        rooted = "/user/tulipwars_user_config.py"\n    paths = (\n        rooted,\n        "/user/tulipwars_user_config.py",\n        "../tulipwars_user_config.py",\n        "tulipwars_user_config.py",\n    )\n    for path in paths:\n        try:\n            namespace = {}\n            with open(path, "r") as handle:\n                exec(handle.read(), namespace)\n            if namespace.get("SERVER_URL"):\n                SERVER_URL = namespace["SERVER_URL"]\n            if "WORLD_AUTHOR" in namespace:\n                WORLD_AUTHOR = namespace["WORLD_AUTHOR"]\n            return\n        except Exception:\n            pass\n\n\n_load_user_config()\n', 'tw_midi.py': '# Minimal Standard MIDI File type-0 exporter for the Element115 console.\n\n\ndef _u16(value):\n    return bytes([(value >> 8) & 255, value & 255])\n\n\ndef _u32(value):\n    return bytes([\n        (value >> 24) & 255,\n        (value >> 16) & 255,\n        (value >> 8) & 255,\n        value & 255,\n    ])\n\n\ndef _vlq(value):\n    value = int(value)\n    out = [value & 0x7F]\n    value >>= 7\n    while value:\n        out.insert(0, (value & 0x7F) | 0x80)\n        value >>= 7\n    return bytes(out)\n\n\ndef export_midi(pattern, notes, bpm, filename, program=0):\n    ppqn = 96\n    step_ticks = ppqn // 4  # sixteenth notes\n    gate_ticks = int(step_ticks * 0.72)\n    tempo = int(60000000 / max(30, min(300, bpm)))\n\n    events = []\n    # order 0 note-offs before order 1 note-ons when sharing a timestamp.\n    events.append((0, -2, bytes([0xFF, 0x51, 0x03, (tempo >> 16) & 255, (tempo >> 8) & 255, tempo & 255])))\n    events.append((0, -1, bytes([0xC0, program & 0x7F])))\n\n    for row in range(len(pattern)):\n        note = int(notes[row]) & 0x7F\n        for step in range(len(pattern[row])):\n            if pattern[row][step]:\n                tick = step * step_ticks\n                velocity = max(28, 92 - row * 5)\n                events.append((tick, 1, bytes([0x90, note, velocity])))\n                events.append((tick + gate_ticks, 0, bytes([0x80, note, 0])))\n\n    events.sort(key=lambda item: (item[0], item[1]))\n    track = bytearray()\n    last_tick = 0\n    for tick, _order, data in events:\n        track.extend(_vlq(tick - last_tick))\n        track.extend(data)\n        last_tick = tick\n\n    end_tick = 16 * step_ticks\n    track.extend(_vlq(max(0, end_tick - last_tick)))\n    track.extend(bytes([0xFF, 0x2F, 0x00]))\n\n    header = b"MThd" + _u32(6) + _u16(0) + _u16(1) + _u16(ppqn)\n    chunk = b"MTrk" + _u32(len(track)) + bytes(track)\n    with open(filename, "wb") as handle:\n        handle.write(header)\n        handle.write(chunk)\n    return filename\n\n\ndef _base64_file(filename):\n    try:\n        import ubinascii as binascii\n    except Exception:\n        import binascii\n    with open(filename, "rb") as handle:\n        data = handle.read()\n    # MicroPython\'s b2a_base64 is most portable with RFC-sized chunks.\n    encoded = []\n    for offset in range(0, len(data), 57):\n        part = binascii.b2a_base64(data[offset:offset + 57])\n        if not isinstance(part, str):\n            part = part.decode()\n        encoded.append(part.strip())\n    return "".join(encoded)\n\n\ndef download_in_browser(filename):\n    """Start a real browser download in Tulip Web; no-op on hardware/Desktop."""\n    try:\n        import tulip\n        if tulip.board() not in ("WEB", "AMYBOARD_WEB"):\n            return False\n        import js\n        name = filename.rsplit("/", 1)[-1]\n        href = "data:audio/midi;base64," + _base64_file(filename)\n        anchor = js.document.createElement("a")\n        anchor.setAttribute("href", href)\n        anchor.setAttribute("download", name)\n        anchor.setAttribute("style", "display:none")\n        js.document.body.appendChild(anchor)\n        anchor.click()\n        js.document.body.removeChild(anchor)\n        return True\n    except Exception:\n        # The local .mid remains safely exported even if a browser blocks the\n        # download gesture or changes its JavaScript bridge.\n        return False\n', 'tw_net.py': 'import json\nimport tulip\n\nIS_WEB = tulip.board() in ("WEB", "AMYBOARD_WEB")\n\nif IS_WEB:\n    import js\n    requests = None\nelse:\n    try:\n        import tuliprequests as requests\n    except Exception:\n        try:\n            import requests\n        except Exception:\n            requests = None\n\n\nclass NetworkError(Exception):\n    pass\n\n\nclass Client:\n    def __init__(self, server_url, device_id, callsign, version):\n        self.server_url = server_url\n        self.device_id = device_id\n        self.callsign = callsign\n        self.version = version\n        self.async_mode = IS_WEB\n\n    def configured(self):\n        return self.server_url.startswith("http") and "YOURDOMAIN" not in self.server_url\n\n    def _body(self, action, payload=None):\n        body = {\n            "action": action,\n            "device_id": self.device_id,\n            "callsign": self.callsign,\n            "version": self.version,\n        }\n        if payload:\n            for key in payload:\n                body[key] = payload[key]\n        return body\n\n    def _validate(self, data):\n        if not isinstance(data, dict):\n            raise NetworkError("Relay returned invalid JSON")\n        if not data.get("ok", False):\n            raise NetworkError(data.get("error", "Server rejected request"))\n        return data\n\n    def _post_sync(self, action, payload=None):\n        if requests is None:\n            raise NetworkError("No HTTP requests module is available")\n        if not self.configured():\n            raise NetworkError("Configure the TulipWars relay URL")\n        try:\n            response = requests.post(self.server_url, json=self._body(action, payload))\n            status = getattr(response, "status_code", 200)\n            if status < 200 or status >= 300:\n                raise NetworkError("Server returned HTTP %d" % status)\n            return self._validate(response.json())\n        except NetworkError:\n            raise\n        except Exception as exc:\n            raise NetworkError("Connection failed: %s" % exc)\n\n    def _post_web(self, action, payload=None, done=None, fail=None):\n        if not self.configured():\n            error = NetworkError("Configure the TulipWars relay URL")\n            if fail is not None:\n                fail(error)\n                return None\n            raise error\n\n        body = json.dumps(self._body(action, payload))\n        options = js.JSON.parse(json.dumps({\n            "method": "POST",\n            "headers": {"content-type": "application/json"},\n            "body": body,\n        }))\n\n        def decoded(text):\n            try:\n                data = self._validate(json.loads(str(text)))\n                if done is not None:\n                    done(data)\n                return data\n            except Exception as exc:\n                return failed(exc)\n\n        def failed(exc):\n            error = exc if isinstance(exc, NetworkError) else NetworkError("Connection failed: %s" % exc)\n            if fail is not None:\n                fail(error)\n                return None\n            print(error)\n            return None\n\n        return js.fetch(self.server_url, options).then(\n            lambda response: response.text()\n        ).then(decoded).catch(failed)\n\n    def _request(self, action, payload=None, done=None, fail=None):\n        if self.async_mode:\n            return self._post_web(action, payload, done, fail)\n        try:\n            data = self._post_sync(action, payload)\n            if done is not None:\n                done(data)\n            return data\n        except Exception as exc:\n            if fail is not None:\n                fail(exc)\n                return None\n            raise\n\n    def hello(self, done=None, fail=None):\n        return self._request("hello", done=done, fail=fail)\n\n    def status(self, done=None, fail=None):\n        return self._request("status", done=done, fail=fail)\n\n    def heartbeat(self, done=None, fail=None):\n        return self._request("heartbeat", done=done, fail=fail)\n\n    def travel(self, system_id, done=None, fail=None):\n        return self._request("travel", {"destination": system_id}, done, fail)\n\n    def market(self, done=None, fail=None):\n        return self._request("market", done=done, fail=fail)\n\n    def trade(self, commodity, side, quantity=1, done=None, fail=None):\n        return self._request("trade", {\n            "commodity": commodity,\n            "side": side,\n            "quantity": quantity,\n        }, done, fail)\n\n    def scan(self, done=None, fail=None):\n        return self._request("scan", done=done, fail=fail)\n\n    def combat(self, command, done=None, fail=None):\n        return self._request("combat", {"command": command}, done, fail)\n\n    def chat_list(self, done=None, fail=None):\n        return self._request("chat_list", done=done, fail=fail)\n\n    def chat_post(self, message, done=None, fail=None):\n        return self._request("chat_post", {"message": message}, done, fail)\n', 'tw_setup.py': '# Run from the Tulip REPL after entering the package folder:\n#   cd(\'tulipwars2026\')\n#   import tw_setup\n#   tw_setup.configure()\n\n\ndef configure():\n    print("TulipWars 2026 anonymous relay setup")\n    print("Enter the complete HTTPS URL ending in /api.php")\n    print("Example: https://example.com/tulipwars2026/api.php")\n    url = input().strip()\n    if not url.startswith("https://") or not url.endswith("api.php"):\n        print("That URL does not look complete. Nothing was changed.")\n        return False\n    text = "SERVER_URL = %r\\nWORLD_AUTHOR = \'\'\\n" % url\n    try:\n        import tulip\n        rooted = tulip.root_dir() + "user/tulipwars_user_config.py"\n    except Exception:\n        rooted = "/user/tulipwars_user_config.py"\n    paths = (rooted, "/user/tulipwars_user_config.py", "../tulipwars_user_config.py")\n    tried = {}\n    for path in paths:\n        if path in tried:\n            continue\n        tried[path] = True\n        try:\n            with open(path, "w") as handle:\n                handle.write(text)\n            print("Saved anonymous relay configuration to", path)\n            print("Return to the user folder and run(\'tulipwars2026\')")\n            return True\n        except Exception:\n            pass\n    print("Could not write tulipwars_user_config.py")\n    return False\n', 'tw_storage.py': 'import json\nimport os\nimport random\n\n\ndef _exists(path):\n    try:\n        os.stat(path)\n        return True\n    except Exception:\n        return False\n\n\ndef _hex_byte(value):\n    return ("%02X" % (int(value) & 255))\n\n\ndef _entropy_hex(byte_count=8):\n    # Tulip Web\'s MicroPython random.getrandbits() accepts at most 32 bits.\n    # Prefer os.urandom, but never request a >32-bit random integer fallback.\n    try:\n        raw = os.urandom(byte_count)\n        return "".join(_hex_byte(raw[index]) for index in range(len(raw)))\n    except Exception:\n        parts = []\n        while len(parts) < byte_count:\n            try:\n                value = random.getrandbits(32)\n            except Exception:\n                value = random.randint(0, 0x7FFFFFFF)\n            parts.extend((\n                (value >> 24) & 255,\n                (value >> 16) & 255,\n                (value >> 8) & 255,\n                value & 255,\n            ))\n        return "".join(_hex_byte(value) for value in parts[:byte_count])\n\n\ndef make_identity(prefix="TULIP"):\n    # Anonymous persistent installation identity. It is not an account and is\n    # never associated with an email, password, profile, or login API.\n    device_id = _entropy_hex(8)\n    callsign = "%s-%s" % (prefix, device_id[-4:])\n    return {"device_id": device_id, "callsign": callsign}\n\n\ndef load(app_dir, prefix="TULIP"):\n    path = app_dir + "/local_state.json"\n    if _exists(path):\n        try:\n            with open(path, "r") as handle:\n                data = json.load(handle)\n            if data.get("device_id") and data.get("callsign"):\n                return data\n        except Exception:\n            pass\n    data = make_identity(prefix)\n    save(app_dir, data)\n    return data\n\n\ndef save(app_dir, data):\n    path = app_dir + "/local_state.json"\n    with open(path, "w") as handle:\n        json.dump(data, handle)\n    return path\n', 'tw_tracker.py': 'import random\nimport tulip\nimport synth\n\nimport tw_midi\n\n\nclass Element115Tracker:\n    ROWS = 8\n    STEPS = 16\n    MAX_JUNO_VOICES = 6\n    PATCHES = [0, 1, 5, 11, 18, 24, 32, 40, 48, 56, 64, 72, 80, 96, 112, 120]\n\n    def __init__(self, app_dir):\n        self.app_dir = app_dir\n        self.pattern = [[False for _ in range(self.STEPS)] for _ in range(self.ROWS)]\n        self.notes = [36, 43, 48, 51, 55, 60, 63, 67]\n        self.row = 0\n        self.step = 0\n        self.play_step = 0\n        self.bpm = 72\n        self.playing = False\n        self.midi_thru = False\n        self.next_step_ms = 0\n        self.pending_off = []\n        self.synth = None\n        self.patch = random.choice(self.PATCHES)\n        self.last_export = ""\n        self.last_browser_download = False\n        self.randomize()\n\n    def randomize(self):\n        root = random.choice([34, 36, 38, 41, 43])\n        scale = [0, 3, 5, 7, 10, 12, 15, 17]\n        self.notes = [root + scale[i] for i in range(self.ROWS)]\n        for row in range(self.ROWS):\n            density = 0.18 + row * 0.025\n            for step in range(self.STEPS):\n                anchor = step in (0, 4, 8, 12)\n                self.pattern[row][step] = (random.random() < density) or (row == 0 and anchor and random.random() < 0.65)\n        self.patch = random.choice(self.PATCHES)\n\n    def toggle(self):\n        self.pattern[self.row][self.step] = not self.pattern[self.row][self.step]\n\n    def move_row(self, delta):\n        self.row = (self.row + delta) % self.ROWS\n\n    def move_step(self, delta):\n        self.step = (self.step + delta) % self.STEPS\n\n    def change_note(self, delta):\n        self.notes[self.row] = max(24, min(96, self.notes[self.row] + delta))\n\n    def change_bpm(self, delta):\n        self.bpm = max(35, min(220, self.bpm + delta))\n\n    def _ensure_synth(self):\n        if self.synth is None:\n            try:\n                self.synth = synth.PatchSynth(num_voices=self.MAX_JUNO_VOICES, patch=self.patch)\n            except Exception:\n                self.synth = None\n\n    def _rows_for_step(self, step):\n        active = [row for row in range(self.ROWS) if self.pattern[row][step]]\n        if len(active) <= self.MAX_JUNO_VOICES:\n            return active\n        # Rotate which two dense rows are omitted so all eight tracker lanes can\n        # still be heard over time while hardware never receives >6 note-ons.\n        offset = step % len(active)\n        rotated = active[offset:] + active[:offset]\n        return rotated[:self.MAX_JUNO_VOICES]\n\n    def start(self):\n        self._ensure_synth()\n        if self.synth is not None:\n            try:\n                self.synth.all_notes_off()\n            except Exception:\n                pass\n        self.pending_off = []\n        self.playing = True\n        self.play_step = 0\n        self.next_step_ms = tulip.ticks_ms()\n\n    def stop(self):\n        self.playing = False\n        if self.synth is not None:\n            try:\n                self.synth.all_notes_off()\n            except Exception:\n                pass\n        self.pending_off = []\n        if self.midi_thru:\n            for note in self.notes:\n                try:\n                    tulip.midi_out([0x80, note & 127, 0])\n                except Exception:\n                    pass\n\n    def release(self):\n        self.stop()\n        if self.synth is not None:\n            try:\n                self.synth.release()\n            except Exception:\n                pass\n        self.synth = None\n\n    def tick(self, now_ms):\n        if self.synth is not None:\n            keep = []\n            for off_ms, note in self.pending_off:\n                if now_ms >= off_ms:\n                    try:\n                        self.synth.note_off(note)\n                    except Exception:\n                        pass\n                    if self.midi_thru:\n                        try:\n                            tulip.midi_out([0x80, note & 127, 0])\n                        except Exception:\n                            pass\n                else:\n                    keep.append((off_ms, note))\n            self.pending_off = keep\n\n        if not self.playing or now_ms < self.next_step_ms:\n            return False\n\n        self._ensure_synth()\n        step_ms = int(60000 / self.bpm / 4)\n        gate = max(40, int(step_ms * 0.62))\n        for row in self._rows_for_step(self.play_step):\n            note = self.notes[row]\n            velocity = max(0.16, 0.42 - row * 0.022)\n            if self.synth is not None:\n                try:\n                    self.synth.note_on(note, velocity)\n                except Exception:\n                    pass\n            if self.midi_thru:\n                try:\n                    tulip.midi_out([0x90, note & 127, int(velocity * 127)])\n                except Exception:\n                    pass\n            self.pending_off.append((now_ms + gate, note))\n\n        self.play_step = (self.play_step + 1) % self.STEPS\n        self.next_step_ms = now_ms + step_ms\n        return True\n\n    def export(self):\n        try:\n            suffix_value = random.getrandbits(16)\n        except Exception:\n            suffix_value = random.randint(0, 65535)\n        suffix = "%04X" % suffix_value\n        filename = self.app_dir + "/ELEMENT115_%s.mid" % suffix\n        self.last_export = tw_midi.export_midi(self.pattern, self.notes, self.bpm, filename, self.patch)\n        self.last_browser_download = tw_midi.download_in_browser(self.last_export)\n        return self.last_export, self.last_browser_download\n\n    def rows_for_display(self):\n        lines = []\n        for row in range(self.ROWS):\n            cells = []\n            for step in range(self.STEPS):\n                active = self.pattern[row][step]\n                if row == self.row and step == self.step:\n                    cell = "@" if active else "+"\n                elif self.playing and step == self.play_step:\n                    cell = "*" if active else ":"\n                else:\n                    cell = "X" if active else "."\n                cells.append(cell)\n            lines.append("%d N%02d |%s|" % (row + 1, self.notes[row], "".join(cells)))\n        return lines\n'}
PACKAGE_FILES = ['README.txt', 'tulipwars2026.py', 'tw_audio.py', 'tw_config.py', 'tw_midi.py', 'tw_net.py', 'tw_setup.py', 'tw_storage.py', 'tw_tracker.py']
OLD_USER_FILES = ['INSTALL_TULIPWARS_WEB.py', 'INSTALL_TULIPWARS_WEB_v0.1.3.py', 'INSTALL_TULIPWARS_WEB_v0.1.4.py', 'REPAIR_TULIPWARS_BLACK_SCREEN_v0.1.5.py', 'INSTALL_TULIPWARS_WEB_v0.1.7.py', 'INSTALL_OR_UPDATE_TULIPWARS_v0.1.8.py', 'INSTALL_OR_UPDATE_TULIPWARS_v0.1.9.py', 'INSTALL_OR_UPDATE_TULIPWARS_v0.1.10.py', 'tulipwars2026.tar']

def _exists(path):
    try:
        os.stat(path)
        return True
    except Exception:
        return False

def _safe_remove(path):
    try:
        os.remove(path)
    except Exception:
        pass

def _ensure_dir(path):
    try:
        os.mkdir(path)
    except Exception:
        pass

def _writable_dir(path):
    marker = path.rstrip('/') + '/.tulipwars_write_test'
    try:
        with open(marker, 'w') as handle:
            handle.write('ok')
        os.remove(marker)
        return True
    except Exception:
        _safe_remove(marker)
        return False

def _user_dir():
    # Tulip Web and Tulip CC use /user. Desktop/test environments may use cwd.
    candidates = ['/user']
    try:
        candidates.append(os.getcwd())
    except Exception:
        pass
    candidates.append('.')
    seen = []
    for candidate in candidates:
        if not candidate or candidate in seen:
            continue
        seen.append(candidate)
        if _exists(candidate) and _writable_dir(candidate):
            if candidate != '/' and candidate.endswith('/'):
                candidate = candidate[:-1]
            return candidate
    return '.'

def _stop_old_app():
    try:
        old_app = tulip.running_apps.get(PACKAGE)
        if old_app is not None:
            old_app.quit()
    except Exception:
        pass

def _purge_modules():
    for module_name in (
        'tulipwars2026', 'tw_audio', 'tw_config', 'tw_midi', 'tw_net',
        'tw_setup', 'tw_storage', 'tw_tracker', 'tulipwars_user_config'
    ):
        try:
            del sys.modules[module_name]
        except Exception:
            pass

def _ensure_config(user_dir):
    path = user_dir + '/tulipwars_user_config.py'
    if _exists(path):
        return path, False
    with open(path, 'w') as handle:
        handle.write("SERVER_URL = %r\nWORLD_AUTHOR = ''\n" % API_URL)
    return path, True

def _clean_package(package_dir):
    # Preserve anonymous identity/progress and every exported Element115 MIDI file.
    try:
        names = os.listdir(package_dir)
    except Exception:
        names = []
    for filename in names:
        keep = filename == 'local_state.json' or (
            filename.startswith('ELEMENT115_') and filename.endswith('.mid')
        )
        if not keep:
            _safe_remove(package_dir + '/' + filename)

def _write_package(user_dir):
    package_dir = user_dir + '/' + PACKAGE
    _ensure_dir(package_dir)
    _clean_package(package_dir)
    count = 0
    for filename in PACKAGE_FILES:
        source = FILES[filename]
        if filename.endswith('.py'):
            compile(source, filename, 'exec')
        with open(package_dir + '/' + filename, 'w') as handle:
            handle.write(source)
        count += 1
        print('Installed %d/%d: %s' % (count, len(PACKAGE_FILES), filename))
    return package_dir

def _cleanup_old_installers(user_dir):
    for filename in OLD_USER_FILES:
        _safe_remove(user_dir + '/' + filename)

def _launch(_=None):
    _purge_modules()
    gc.collect()
    print('Starting TulipWars 2026 v%s...' % VERSION)
    try:
        run(PACKAGE)
    except Exception as exc:
        print('START FAILED:', exc)
        try:
            sys.print_exception(exc)
        except Exception:
            pass

def install():
    print('=' * 64)
    print('TulipWars 2026 v%s COMPLETE INSTALL / UPDATE' % VERSION)
    print('Touch terminal + six-voice AMY safety + repaired physical installer')
    print('One file. No TAR. No Tulip World uploads.')
    print('=' * 64)
    user_dir = _user_dir()
    print('Using Tulip user directory:', user_dir)
    try:
        os.chdir(user_dir)
    except Exception:
        pass
    _stop_old_app()
    package_dir = _write_package(user_dir)
    config_path, config_created = _ensure_config(user_dir)
    _cleanup_old_installers(user_dir)
    print('Full package installed at:', package_dir)
    if config_created:
        print('Anonymous relay configured at:', config_path)
    else:
        print('Existing anonymous relay configuration preserved:', config_path)
    print('Local ship identity and Element115 MIDI exports were preserved.')
    print('Old installers and obsolete package code were removed.')
    try:
        tulip.defer(_launch, None, 300)
    except Exception:
        _launch()

install()
