#!/usr/bin/env python3
"""Verify mobile/touch runtime control wiring in web/game.html.

This is a source-level guard for environments without a browser engine. It does
not replace a real pointer-gesture browser test, but it catches the common
regressions that made mobile testing unusable: missing joystick DOM, missing
touch-action suppression, wrong direction mapping, and unbound pointer events.
"""
from __future__ import annotations

import argparse
from pathlib import Path
from urllib.parse import urljoin
from urllib.request import urlopen


ROOT = Path(__file__).resolve().parents[1]
INDEX = ROOT / "web" / "game.html"


REQUIRED_NEEDLES = {
    "viewport": '<meta name="viewport" content="width=device-width, initial-scale=1" />',
    "body touch-action": "body {\n        min-height: 100vh;",
    "body disables touch gestures": "touch-action: none;",
    "canvas disables touch gestures": "canvas {\n        width: 100%;\n        height: 100%;\n        image-rendering: pixelated;\n        background: #000;\n        touch-action: none;",
    "map filter input": '<input id="mapFilterInput" type="search"',
    "map filter hidden collapsed": "body.debug-ui-collapsed #mapFilterInput",
    "route assist hidden collapsed": "body.debug-ui-collapsed #routeAssistButton",
    "route guide hidden collapsed": "body.debug-ui-collapsed #routeGuideLink",
    "route evidence hidden collapsed": "body.debug-ui-collapsed #routeEvidenceLink",
    "completion audit hidden collapsed": "body.debug-ui-collapsed #completionAuditLink",
    "cns rect link hidden collapsed": "body.debug-ui-collapsed #cnsReviewLink",
    "save scan hidden collapsed": "body.debug-ui-collapsed #saveScanLink",
    "load savedat hidden collapsed": "body.debug-ui-collapsed #loadSaveButton",
    "overview hidden collapsed": "body.debug-ui-collapsed #overviewButton",
    "object hidden collapsed": "body.debug-ui-collapsed #objectButton",
    "dialogue hidden collapsed": "body.debug-ui-collapsed #dialogueButton",
    "battle hidden collapsed": "body.debug-ui-collapsed #battleButton",
    "map filter wiring": 'mapFilterInput.addEventListener("input"',
    "map filter setup": "const filteredNames = filter",
    "joystick element": '<div id="virtualJoystick" aria-hidden="true"><div id="virtualJoystickKnob"></div></div>',
    "menu button": '<button id="virtualMenuButton" type="button" aria-label="menu" hidden>☰</button>',
    "cancel button": '<button id="virtualCancelButton" type="button" aria-label="cancel" hidden>B</button>',
    "action button": '<button id="virtualActionButton" type="button" aria-label="action">A</button>',
    "route assist button": '<button id="routeAssistButton" type="button">후보 진행</button>',
    "overview button": '<button id="overviewButton" type="button" hidden>전체</button>',
    "overview button state": "overviewButton.classList.toggle(\"active\", overviewMode && scene === \"map\");",
    "overview button handler": 'overviewButton.addEventListener("click"',
    "overview query sync": "function syncOverviewQuery()",
    "overview mode setter": "function setOverviewMode(enabled, { sync = true, notice = false } = {})",
    "overview movement exit": "if (overviewMode) setOverviewMode(false);",
    "route evidence link": '<a id="routeEvidenceLink" href="../out/route_blocker_evidence_matrix.html" title="현재 막힌 진행 근거">근거</a>',
    "completion audit link": '<a id="completionAuditLink" href="../out/completion_audit.html" title="목표 완료 감사">목표</a>',
    "save scan parked link": '<a id="saveScanLink" href="../docs/SAVEDATA_PARKED.md" title="savedata analysis parked" hidden>savedata parked</a>',
    "route assist frontier select": '<select id="routeFrontierSelect" aria-label="route assist frontier" hidden></select>',
    "mobile review controls": 'id="mobileReviewControls"',
    "joystick state": "const joystickState = {",
    "joystick radius": "const JOYSTICK_RADIUS",
    "joystick deadzone": "const JOYSTICK_DEADZONE",
    "begin joystick": "function beginVirtualJoystick(event)",
    "update joystick": "function updateVirtualJoystick(event)",
    "end joystick": "function endVirtualJoystick(event)",
    "mobile action": "function activateMobileAction(event)",
    "mobile cancel": "function activateMobileCancel(event)",
    "mobile cancel hides dialogue": "activeDialogue = null;",
    "mobile cancel closes battle item menu": "battleState.itemMenuOpen = false;",
    "mobile cancel ends battle": "endBattlePrototype(\"전투 종료\");",
    "mobile cancel exits overview": "setOverviewMode(false, { notice: true });",
    "runtime menu toggle": "function toggleRuntimeMenu()",
    "menu button state": "function updateVirtualMenuButton()",
    "cancel button state": "function updateVirtualCancelButton()",
    "mobile action advances dialogue": 'requestDialogueAdvance("mobile-action"',
    "canvas dialogue advance": 'requestDialogueAdvance("canvas-pointer"',
    "mobile battle action uses selected command": "if (scene === \"battle\") {\n          useSelectedBattleCommand();",
    "battle hud choice items": "function battleHudChoiceItems()",
    "battle hud choice window": "function battleHudChoiceWindow(choices = battleHudChoiceItems())",
    "battle hud pointer hit test": "function battleHudChoiceIndexAtPoint(clientX, clientY)",
    "battle hud pointer handler": 'if (scene === "battle") {',
    "battle hud pointer use": "useBattleHudChoiceIndex(battleChoiceIndex);",
    "mobile menu action uses selected item": "if (menuOpen) {\n          useSelectedMenuItem();",
    "menu quick save command": 'command: "quickSave"',
    "menu quick load command": 'command: "quickLoad"',
    "menu return title command": 'command: "returnToTitle"',
    "menu quick save handler": 'item?.command === "quickSave"',
    "menu quick load handler": 'item?.command === "quickLoad"',
    "menu return title handler": 'item?.command === "returnToTitle"',
    "menu return title function": "function returnToTitleScreen()",
    "menu close helper": "function closeRuntimeMenu({ sound = true } = {})",
    "menu submenu back helper": "function stepBackRuntimeMenu()",
    "mobile menu button helper": "function activateMobileMenuButton(event)",
    "mobile menu button backs submenu": "if (menuOpen) stepBackRuntimeMenu();",
    "mobile menu button handler": "activateMobileMenuButton(event);",
    "menu submenu escape branch": 'if (scene === "map" && menuOpen && (event.code === "KeyI" || event.code === "Escape"))',
    "menu submenu back hint": 'I/Esc 뒤로',
    "title start session reset": "function resetTitleStartSessionState()",
    "title start reset call": "resetTitleStartSessionState();",
    "menu return title label": 'name: "제목"',
    "menu visible rows": "const RUNTIME_MENU_VISIBLE_ROWS = 8;",
    "menu scroll window": "function runtimeMenuWindow(items = menuItems())",
    "menu pointer hit test": "function runtimeMenuItemIndexAtPoint(clientX, clientY)",
    "menu pointerdown branch": 'if (scene === "map" && menuOpen) {',
    "menu visible slice": "items.slice(menuWindow.start, menuWindow.end)",
    "menu scrolled selection": "menuWindow.start + index - 1",
    "menu pointer selection": "selectedMenuItemIndex = menuItemIndex;",
    "action prompt rect": "function actionPromptRect()",
    "action prompt hit test": "function actionPromptHitAtPoint(clientX, clientY)",
    "action prompt pointer branch": 'if (scene === "map" && actionPromptHitAtPoint(event.clientX, event.clientY))',
    "menu quick save notice": "임시 저장했습니다.",
    "menu quick load notice": "임시 저장을 불러왔습니다.",
    "title continue quick load label": 'quickLoadButton.textContent = scene === "title" ? "이어하기" : "임시 불러오기";',
    "title menu items": "function titleMenuItems()",
    "title menu geometry": "function titleMenuGeometry(items = titleMenuItems())",
    "title menu pointer hit test": "function titleMenuIndexAtPoint(clientX, clientY)",
    "title menu activation": 'function activateSelectedTitleMenuItem(inputSource = "title-menu")',
    "title menu keyboard start": 'if (scene === "title") {',
    "title menu mobile action": 'activateSelectedTitleMenuItem("mobile-action");',
    "runtime route state save payload": "function runtimeRouteStateSavePayload()",
    "runtime route state restore": "function restoreRuntimeRouteState(routeState)",
    "candidate save point prototype save": "function savePointCandidatePrototypeSave(selection)",
    "candidate save point action": "function activateSavePointCandidateAtFoot(fallback = null)",
    "candidate save point action prompt": "Enter -> ${completion.completed ? \"세이브 완료\" : \"세이브 후보\"} ${completion.count}",
    "candidate save point runtime marker": "HWANSE_LAST_SAVE_POINT_RUNTIME_SAVE",
    "candidate save point saved dialogue": "후보 저장",
    "candidate save point completion state": "function savePointPrototypeCompletionState(selection = null)",
    "candidate save point completed menu": "세이브 완료",
    "candidate battle prototype summary": "function battlePrototypeSummary(candidate, background, options = {})",
    "candidate battle runtime marker": "HWANSE_LAST_BATTLE_PROTOTYPE",
    "candidate battle button label": "completion.completed ? `전투 완료 ${label}` : `전투 ${label}`",
    "candidate battle action state": "function battleCandidateActionState()",
    "candidate battle completion notice": "function startCompletedBattleCandidateNotice(battleAction)",
    "candidate battle completion notice marker": "HWANSE_LAST_BATTLE_COMPLETION_NOTICE",
    "candidate battle action": "function startBattleCandidateAction()",
    "candidate requested battle action": "function startRequestedBattleCandidateAction()",
    "candidate battle action prompt": "kind: \"battle-candidate\"",
    "candidate battle completion state": "function battlePrototypeCompletionState(candidate = null, background = null)",
    "candidate battle enemy profiles": "BATTLE_ENEMY_PROTOTYPE_PROFILES",
    "candidate battle enemy profile function": "function battleEnemyPrototypeProfile(",
    "candidate battle enemy profile source": "prototype-enemy-profile",
    "candidate battle enemy action function": "function battleEnemyPrototypeAction(base = {})",
    "candidate battle enemy action source": "prototype-enemy-action",
    "candidate battle enemy status function": "function applyBattleEnemyStatus(target, action)",
    "candidate battle enemy status once": "enemy.statusInflicted[key] = true;",
    "candidate battle enemy status original marker": "originalStatusFormulaImplemented",
    "candidate battle item drop function": "function grantBattlePrototypeItemDrop(state)",
    "candidate battle item drop source": "prototype-enemy-drop",
    "candidate battle inventory drop item": "ensureBattleRuntimeInventoryItem(state, key)",
    "candidate battle combat formula marker": "originalCombatFormulaImplemented",
    "field encounter query": "const START_FIELD_ENCOUNTERS",
    "field encounter state": "function createFieldEncounterState(payload = {})",
    "field encounter save payload": "fieldEncounter: fieldEncounterSavePayload()",
    "field encounter restore": "function restoreFieldEncounterState(payload = null)",
    "field encounter movement hook": "checkFieldEncounterAfterStep();",
    "field encounter menu command": 'command: "toggleFieldEncounters"',
    "field encounter menu handler": 'item?.command === "toggleFieldEncounters"',
    "field encounter marker": "HWANSE_LAST_FIELD_ENCOUNTER",
    "field encounter progress": "recordPrototypeProgress(\"field-encounter\"",
    "field encounter victory progress": "field-encounter-victory",
    "field encounter source": "prototype-field-encounter",
    "candidate object inspect button": '<button id="objectButton" type="button" hidden>조사</button>',
    "candidate object selection": "function eventObjectCandidateSelection({ nearOnly = false } = {})",
    "candidate object inspect marker": "HWANSE_LAST_EVENT_OBJECT_INSPECT",
    "candidate object completion notice": "function startCompletedEventObjectCandidateNotice(selection)",
    "candidate object completion notice marker": "HWANSE_LAST_EVENT_OBJECT_COMPLETION_NOTICE",
    "candidate object inspect dialogue": "event-object-candidates",
    "candidate progress recorder": "function recordPrototypeProgress(kind, id, detail = {})",
    "candidate progress marker": "HWANSE_LAST_PROTOTYPE_PROGRESS",
    "candidate completion marker": "HWANSE_LAST_PROTOTYPE_COMPLETION",
    "candidate party join function": "function joinPrototypePartyMember(name, options = {})",
    "candidate party join menu items": "function partyJoinMenuItems()",
    "candidate party join command": 'command: "joinPrototypePartyMember"',
    "candidate party join handler": 'item?.command === "joinPrototypePartyMember"',
    "candidate party join progress": '"party-join-prototype": "동료 합류"',
    "candidate party join marker": "HWANSE_LAST_PARTY_JOIN",
    "candidate party join source": "prototype-party-join",
    "candidate dialogue party join map": "DIALOGUE_PARTY_JOIN_PROTOTYPES",
    "candidate dialogue party join function": "function applyDialoguePartyJoinPrototype(block, lines = [])",
    "candidate dialogue party join marker": "HWANSE_LAST_DIALOGUE_PARTY_JOIN",
    "candidate dialogue party join source": "prototype-dialogue-party-join",
    "candidate party completion state": "function partyPrototypeCompletionState()",
    "candidate party original marker": "originalPartyJoinEventImplemented",
    "candidate action prompt marker": "HWANSE_LAST_ACTION_PROMPT",
    "candidate dialogue action state": "function dialogueCandidateActionState()",
    "candidate dialogue action": "function startDialogueCandidateAction()",
    "candidate dialogue completion notice": "function startCompletedDialogueCandidateNotice(dialogueAction)",
    "candidate dialogue completion notice marker": "HWANSE_LAST_DIALOGUE_COMPLETION_NOTICE",
    "candidate dialogue action prompt": "Enter -> ${completion.completed ? \"대사 완료\" : \"대사\"} ${dialogueAction.label}",
    "candidate dialogue completion state": "function dialoguePrototypeCompletionState()",
    "candidate dialogue complete recorder": "function recordDialogueCompleteProgress(block, lines = [], soundEntry = null)",
    "candidate dialogue completed button": "대사 완료",
    "candidate route progress recorder": "function recordRoutePrototypeProgress(sourceMap, targetMap, detail = {})",
    "candidate route progress label": '"route-candidate": "후보 진행"',
    "confirmed transition progress recorder": "function recordMapTransitionPrototypeProgress(sourceMap, targetMap, detail = {})",
    "confirmed transition progress label": '"map-transition": "맵 전환"',
    "route assist transition progress state": "function routeAssistPathTransitionProgress(row)",
    "route assist transition progress label": "전환 완료",
    "candidate object completion state": "function eventObjectPrototypeCompletionState(selection = null)",
    "candidate object completed button": "조사 완료",
    "candidate object menu command": 'command: "selectObjectCandidate"',
    "candidate object menu handler": 'item?.command === "selectObjectCandidate"',
    "candidate progress menu command": 'command: "showPrototypeProgress"',
    "candidate progress review dialogue": "prototype-progress",
    "candidate progress menu label": "진행 기록",
    "savedat status menu command": 'command: "showStatusMenu"',
    "savedat status menu handler": 'item?.command === "showStatusMenu"',
    "savedat status menu marker": "HWANSE_LAST_STATUS_MENU",
    "savedat status menu dialogue": "status-menu",
    "savedat skill menu command": 'command: "showSkillMenu"',
    "savedat skill menu handler": 'item?.command === "showSkillMenu"',
    "savedat skill menu marker": "HWANSE_LAST_SKILL_MENU",
    "savedat skill menu dialogue": "skill-menu",
    "candidate equipment menu command": 'command: "showEquipmentMenu"',
    "candidate equipment menu handler": 'item?.command === "showEquipmentMenu"',
    "candidate equipment menu marker": "HWANSE_LAST_EQUIPMENT_MENU",
    "candidate equipment menu dialogue": "equipment-menu",
    "candidate equipment equip command": 'command: "openEquipmentEquipMenu"',
    "candidate equipment equip handler": 'item?.command === "openEquipmentEquipMenu"',
    "candidate equipment item handler": 'item?.command === "equipPrototypeItem"',
    "candidate equipment equip menu function": "function openEquipmentEquipMenu()",
    "candidate equipment equip function": "function equipPrototypeItem(item)",
    "candidate equipment change marker": "HWANSE_LAST_EQUIPMENT_CHANGE",
    "candidate equipment progress": '"equipment-prototype": "장비"',
    "candidate equipment prototype source": "prototype-equipment-effects",
    "candidate shop menu command": 'command: "showShopCandidates"',
    "candidate shop menu handler": 'item?.command === "showShopCandidates"',
    "candidate shop menu marker": "HWANSE_LAST_SHOP_CANDIDATE_MENU",
    "candidate shop menu dialogue": "shop-candidates",
    "candidate shop menu source": "exe-text-table-items-and-event-dialogue-shop-candidates",
    "candidate shop runtime gap": "originalShopRuntimeImplemented: false",
    "candidate shop action state": "function shopCandidateActionState",
    "candidate shop action": "function startShopCandidateAction()",
    "candidate shop action loader": "function activateShopCandidateAtFoot(fallback = null)",
    "candidate shop action priority": "function activateSecondaryCandidateAtFoot(options = {})",
    "candidate shop action marker": "HWANSE_LAST_SHOP_ACTION",
    "candidate shop action prompt": "kind: \"shop-candidate\"",
    "candidate shop action prompt text": "Enter -> 상점 ${shopAction.label}",
    "candidate shop buy command": 'command: "openShopBuyMenu"',
    "candidate shop buy handler": 'item?.command === "openShopBuyMenu"',
    "candidate shop item buy handler": 'item?.command === "buyShopItem"',
    "candidate shop purchase function": "function buyShopPrototypeItem(item)",
    "candidate shop purchase marker": "HWANSE_LAST_SHOP_PURCHASE",
    "candidate shop buy progress": '"shop-buy-prototype": "상점 구매"',
    "candidate shop sell command": 'command: "openShopSellMenu"',
    "candidate shop sell handler": 'item?.command === "openShopSellMenu"',
    "candidate shop item sell handler": 'item?.command === "sellShopItem"',
    "candidate shop sale function": "function sellShopPrototypeItem(item)",
    "candidate shop sale marker": "HWANSE_LAST_SHOP_SALE",
    "candidate shop sell progress": '"shop-sell-prototype": "상점 판매"',
    "candidate shop prototype price marker": "prototype-shop-prices",
    "menu unusable command notice": "지금 사용할 수 없습니다.",
    "savedat drop highlight": "body.save-drop-active .screen-wrap",
    "savedat drop file filter": "function savedatFileFromTransfer(dataTransfer)",
    "savedat drop loader": "function loadDroppedSavedatFile(file)",
    "savedat drop debug state": "window.HWANSE_LAST_SAVEDAT_DROP",
    "savedat dragover listener": 'document.addEventListener("dragover"',
    "savedat drop listener": 'document.addEventListener("drop"',
    "savedat parked notice": "savedata loading is parked. See docs/SAVEDATA_PARKED.md.",
    "mobile menu joystick navigation": "function updateMenuSelectionFromVirtualMovement()",
    "mobile battle joystick navigation": "function updateBattle(dt)",
    "mobile menu repeat throttle": "const MENU_MOVE_REPEAT_MS",
    "route assist option": 'new Set(["routeAssist", "activeNearest", "saveSelectorLinks", "mapExitCandidates"])',
    "route assist label": 'routeAssist: "후보 진행"',
    "route assist button state": 'routeAssistButton.classList.toggle("active", trialTransitions === "routeAssist")',
    "route assist button handler": 'routeAssistButton.addEventListener("click"',
    "title route assist start": 'function startRouteAssistFromTitle(target = "", options = {})',
    "title route assist button start": 'if (scene === "title" && startRouteAssistFromTitle()) return;',
    "route assist frontier handler": 'routeFrontierSelect.addEventListener("change"',
    "route assist frontier setup": "function setupRouteFrontierSelect()",
    "route assist frontier activation": "function activateRouteFrontierOption(option)",
    "route assist default frontier activation": "function activateDefaultRouteFrontierOption()",
    "route assist path progress state": "function routeAssistPathProgress(row)",
    "route assist path progress text": "function routeAssistPathProgressText(row)",
    "route assist path transition progress label": '${transitionProgress.completed ? "전환 완료" : "전환 진행"}',
    "route assist path candidate progress label": '${candidateProgress.completed ? "후보 완료" : "후보 진행"}',
    "route assist button activates default frontier": "if (activateDefaultRouteFrontierOption()) return;",
    "route assist toggle": "function toggleRouteAssist()",
    "route assist query sync": "function syncTrialTransitionQuery()",
    "map query sync": "function syncMapQuery()",
    "deferred map query sync": "function requestMapQuerySync()",
    "deferred map query flush": "function flushPendingMapQuerySync({ force = false } = {})",
    "map query set map": 'params.set("map", map.name)',
    "map query set start tile": 'params.set("startTile", `${tile.x},${tile.y}`)',
    "route assist preserves explicit target": 'trialTransitions === "routeAssist" && transitionTargetExplicit && selectedTransitionTarget',
    "route assist target query": 'params.set("transitionTarget", selectedTransitionTarget)',
    "movement query sync": "requestMapQuerySync();\n            finalizeFieldMovementStatusAfterStep(step.fieldMovementStatus);\n            applyFieldStatusEffectsAfterStep();\n            checkFieldEncounterAfterStep();",
    "idle query sync flush": "flushPendingMapQuerySync({ force: true });",
    "route assist query replace": 'window.history.replaceState(null, "", nextUrl)',
    "route assist save selector": "function usesSaveSelectorTrialTransitions()",
    "route assist map exits": "function usesMapExitTrialTransitions()",
    "route assist progress runtime": "const playableProgress = window.HWANSE_PLAYABLE_PROGRESS || {}",
    "route assist candidate reachable maps": "const candidateReachableMaps = new Set(playableProgress.saveSelectorCandidateReachableFromStart || [])",
    "route assist candidate edge map": "const candidateRouteTargetsByMap = new Map()",
    "route assist candidate target filter": "candidateRouteTargets.has(target)",
    "route assist non-confirmed candidates": "candidateReachableMaps.has(map.name) && !confirmedReachableMaps.has(map.name)",
    "route assist mobile review visibility": 'scene === "map" && (showCollisionOverlay || showEventHotspots || usesMapExitTrialTransitions())',
    "route assist exit summary": "function mapExitCandidateSummary(candidate, target)",
    "route assist preferred exit target": "function preferredMapExitCandidateTarget(candidate)",
    "route assist targeted overlay": "function routeAssistTargetedMapExitCandidate(candidate, target = preferredMapExitCandidateTarget(candidate))",
    "route assist focused overlay": "function focusedMapExitCandidate(candidate, target = preferredMapExitCandidateTarget(candidate))",
    "route assist overlay label": "function mapExitCandidateOverlayLabel(candidate, target = preferredMapExitCandidateTarget(candidate))",
    "route assist overlay stroke": 'ctx.strokeStyle = focused ? "#ffffff" : targeted ? "#ff5cc8" : colors.stroke',
    "route assist move prompt": 'const actionLabel = (exitCandidate?.autoTrigger || moveConfirmed) ? "Move/Enter" : "Enter"',
    "transition action prompt marker": 'kind: isConfirmedTransition ? "map-transition" : isTrialTransition ? "route-candidate" : "transition-candidate"',
    "transition action prompt completion": '? isConfirmedTransition ? "전환 완료 " : "후보 완료 "',
    "confirmed transition movement attempt": "function checkConfirmedTransitionAttempt(dx = 0, dy = 0)",
    "confirmed transition attempt before slide": "if (checkConfirmedTransitionAttempt(dx, dy))",
    "route assist exit priority": "...mapExitTrialTargetsAtFoot(),\n          ...saveSelectorTrialTargetsAtFoot(),",
    "tile movement target": "function movementTargetFromTile(tile, dx, dy)",
    "wall slide chooser": "function chooseMovementStep(tile, dx, dy)",
    "wall slide preference": "const wallSlidePreference = { x: 1, y: 1 };",
    "wall slide primary target": "const primary = movementTargetFromTile(tile, dx, dy);\n        if (primary) return primary;",
    "vertical input horizontal slide": "if (dy) {\n          const preferred = wallSlidePreference.x;",
    "horizontal input vertical slide": "if (dx) {\n          const preferred = wallSlidePreference.y;",
    "wall slide x preference update": "wallSlidePreference.x = sign;",
    "wall slide y preference update": "wallSlidePreference.y = sign;",
    "wall slide x preference flip": "wallSlidePreference.x = -preferred;",
    "wall slide y preference flip": "wallSlidePreference.y = -preferred;",
    "document joystick filter": "function isInteractiveTouchTarget(target)",
    "document joystick start": "function handleDocumentPointerDown(event)",
    "non-mouse map touch starts joystick": 'if (scene === "map" && event.pointerType !== "mouse")',
    "non-mouse battle touch starts joystick": 'if (scene === "battle" && event.pointerType !== "mouse")',
    "non-control document touch starts joystick": 'if (event.target === canvas || isInteractiveTouchTarget(event.target)) return;',
    "pointer capture": "canvas.setPointerCapture",
    "pointer release": "canvas.releasePointerCapture",
    "canvas pointerdown": 'canvas.addEventListener("pointerdown", handleCanvasPointerDown)',
    "document pointerdown": 'document.addEventListener("pointerdown", handleDocumentPointerDown)',
    "canvas pointermove": 'canvas.addEventListener("pointermove"',
    "canvas pointerup": 'canvas.addEventListener("pointerup", handlePointerRelease)',
    "canvas pointercancel": 'canvas.addEventListener("pointercancel", handlePointerRelease)',
    "window pointermove": 'window.addEventListener("pointermove"',
    "window pointerup": 'window.addEventListener("pointerup", handlePointerRelease)',
    "window pointercancel": 'window.addEventListener("pointercancel", handlePointerRelease)',
    "action pointerdown": 'virtualActionButton.addEventListener("pointerdown"',
    "action pointerup": 'virtualActionButton.addEventListener("pointerup"',
    "cancel pointerdown": 'virtualCancelButton.addEventListener("pointerdown"',
    "cancel pointerup": 'virtualCancelButton.addEventListener("pointerup"',
    "menu pointerdown": 'virtualMenuButton.addEventListener("pointerdown"',
    "menu pointerup": 'virtualMenuButton.addEventListener("pointerup"',
    "review touch shield": 'mobileReviewControls.addEventListener("pointerdown"',
    "review click handler": 'mobileReviewControls.addEventListener("click"',
    "coarse-only action visibility": "@media (hover: hover) and (pointer: fine)",
}


DIRECTION_NEEDLES = {
    "joystick left": "virtualMovementAction = rawDx > 0 ? 1 : 0;",
    "joystick up/down": "virtualMovementAction = rawDy > 0 ? 3 : 2;",
    "movement direction helper": "function directionForVector(dx, dy, fallback = player.dir)",
    "input movement direction": "const inputDir = directionForVector(dx, dy);",
    "actual movement direction": "const movementDir = directionForVector(movement.dx, movement.dy, inputDir);",
    "active movement consumes joystick": "return MOVEMENT_ACTIONS.get(virtualMovementAction) || [0, 0];",
}


def read_index() -> str:
    return INDEX.read_text(encoding="utf-8")


def verify_source(html: str) -> None:
    missing = [
        label
        for label, needle in {**REQUIRED_NEEDLES, **DIRECTION_NEEDLES}.items()
        if needle not in html
    ]
    if missing:
        raise RuntimeError(f"web/game.html mobile controls missing: {', '.join(missing)}")

    joystick_pos = html.index('id="virtualJoystick"')
    menu_pos = html.index('id="virtualMenuButton"')
    cancel_pos = html.index('id="virtualCancelButton"')
    action_pos = html.index('id="virtualActionButton"')
    canvas_listener_pos = html.index('canvas.addEventListener("pointerdown"')
    if not (joystick_pos < menu_pos < cancel_pos < action_pos < canvas_listener_pos):
        raise RuntimeError("mobile controls should be present before runtime pointer listeners")

    if html.count("virtualMovementAction = null") < 4:
        raise RuntimeError("virtual movement state is not cleared across enough stop paths")

    print("ok mobile controls source wiring")


def verify_http(base: str) -> None:
    url = urljoin(base.rstrip("/") + "/", "/web/game.html")
    with urlopen(url, timeout=8) as response:
        if response.status != 200:
            raise RuntimeError(f"{url} returned status {response.status}")
        html = response.read().decode("utf-8")
    verify_source(html)
    print(f"ok mobile controls http {url}")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--base",
        help="Optional HTTP base URL, for example http://127.0.0.1:8013",
    )
    args = parser.parse_args()
    if args.base:
        verify_http(args.base)
    else:
        verify_source(read_index())


if __name__ == "__main__":
    main()
