"""The Forge Asset Library in OhMyGames builds (operator GO 7 Oct 2026 ~14:47 CET: "go for P0 and P1").

A custom 3D game whose words name things the library holds (a lighthouse, crates, barrels, benches, lamp posts, rocks,
trees, wall modules, sofas...) gets those models picked from the library, copied INTO the game (public/library/, pinned
by release hash in assets.lock.json) with a small loader (src/lib/library/) and the builder contract. People stay with
the Cast, boats with the water kit; room-stage games keep their room kit. Vehicles: the realistic Astra road vehicles
(cars, motorbikes, bicycles, buses; one "Paint" material, six paints each, 10 Oct 2026) come as street scenery and
traffic, and since 15 Oct 2026 (operator: "cars look blunt and plain" in Rocket Rivals, whose player and rivals drove Garage
stand-ins while six realistic cars stood parked) they are also the DRIVEN cars of a vehicle game: `lib.vehicle(id)` returns
the Garage rig, a racing game brings the library's race cars. The Garage keeps karts, the ground look and the vehicle types
the library lacks; its own (garage-kit) models never come here.
Spacecraft (17 Oct 2026, operator: "publish the spaceships"): 32 ships in vehicle.space.* (fighters of eight genres, bombers,
shuttles, freighters, mining and science ships, gunships, dropships, escape pods, cruisers, battleships, motherships, alien
vessels, hover speeders) come ONLY to a space or hover setting (SPACE, HOVER): a space game gets a small fleet, the words pick
the rest ("battleship", "mothership", "escape pod"...), alien words put the alien vessels first. Contract line: LIBRARY.md L12.

The library is the gex44 host tree /srv/forge-assets (repo/index.json + blobs/<sha>.glb), bind-mounted read-only into
the Factory. Publishing new models there needs no Factory rebuild. Missing mount, no matching words or a broken index
= no library, the build goes on as before (a bonus, never a reason to fail a build).
"""

from __future__ import annotations

import hashlib
import io
import json
import re
import tarfile
import time
from pathlib import Path
from typing import Any

from forge_corp.api.routes.games_garage import _card_text
from forge_corp.api.routes.games_room import _data_url
from forge_corp.api.routes import library_search

LIB = Path("/srv/forge-assets")
# kinds the library brings to games now; people (Cast), weapons and monsters (room kits), boats (water kit) stay with
# their own kits. Vehicles: road vehicles only (VEHICLE_GROUPS), never the Garage kit's own models (kit "garage").
KINDS = ("building", "part", "prop", "vehicle")
SKIP_GROUPS = ("part.character",)
VEHICLE_GROUPS = ("vehicle.car.", "vehicle.two-wheel.", "vehicle.service.bus", "vehicle.service.school-bus",
                  "vehicle.military-land.", "vehicle.rotor.", "vehicle.military-air.")
# modern road vehicles stay out of settings that predate them (a "medieval street" gets no buses)
NOT_MODERN = re.compile(r"\b(medieval|fantasy|ancient|castles?|knights?|vikings?|wild west|western|pirates?|prehistoric|stone age|"
                        r"dinosaurs?|roman|samurai|dungeons?|elves|dragons?|wizards?|space ?ships?|galaxy|planets?|mars)\b", re.I)
MAX_ASSETS, MAX_BYTES, PER_CLASS = 30, 8_000_000, 4
PER_VEHICLE_CLASS = 2  # each vehicle ships six paints: two models per class already make a varied street
SPACE_GROUP = "vehicle.space."  # spacecraft: only in a space or hover setting, never in a city, a war or another world
SPACE = re.compile(r"\b(space ?ships?|starships?|star ?fighters?|spacecraft|space (battles?|stations?|shooters?|combat|wars?|fleets?|"
                   r"pirates?|navy|marines?|opera|exploration|race|racing|dogfights?|colony|trucker)|outer space|deep space|"
                   r"galax(y|ies)|galactic|interstellar|intergalactic|asteroids?|nebulae?|star systems?|orbit(al)?|planets?|"
                   r"cosmos|cosmic|alien (invasion|fleets?|armada|ships?|vessels?|mothership)|motherships?|ufos?|flying saucers?|"
                   r"warp drive|hyperspace|sci-?fi shooter|shmups?|bullet hell|starbase)\b", re.I)
HOVER = re.compile(r"\b(speeder bikes?|landspeeders?|hover ?bikes?|hover ?cars?|hover ?skiffs?|hover ?trucks?|hover ?tanks?|"
                   r"repulsor)\b", re.I)
ALIEN = re.compile(r"\b(aliens?|extraterrestrials?|ufos?|flying saucers?|invaders?|xeno\w*|insectoids?|bio-?organic|hive)\b", re.I)
# inside a space setting these words mean the craft (a "fighter" is a starfighter, a "carrier" a mothership)
SPACE_WORDS = {"fighter": "vehicle.space.fighter", "starfighter": "vehicle.space.fighter", "interceptor": "vehicle.space.fighter",
               "bomber": "vehicle.space.bomber", "cruiser": "vehicle.space.cruiser", "battleship": "vehicle.space.battleship",
               "dreadnought": "vehicle.space.battleship", "mothership": "vehicle.space.mothership", "carrier": "vehicle.space.mothership",
               "freighter": "vehicle.space.freighter", "hauler": "vehicle.space.freighter", "cargo": "vehicle.space.freighter",
               "shuttle": "vehicle.space.shuttle", "pod": "vehicle.space.pod", "lifeboat": "vehicle.space.pod",
               "saucer": "vehicle.space.saucer", "ufo": "vehicle.space.saucer", "dropship": "vehicle.space.dropship",
               "corvette": "vehicle.space.corvette", "gunship": "vehicle.space.corvette", "frigate": "vehicle.space.corvette",
               "miner": "vehicle.space.miner", "mining": "vehicle.space.miner", "explorer": "vehicle.space.explorer",
               "speeder": "vehicle.space.speeder", "fleet": "vehicle.space.cruiser", "armada": "vehicle.space.battleship"}
SEMANTIC_MAX = 6  # models the by-meaning search may add to one game
DRIVEN_PER_CLASS = 4  # a race grid: up to four different race cars (each in six paints) for the player and the rivals
# a car race (only read when the game drives vehicles, so "race" is not a foot race here)
RACING = re.compile(r"\b(rac(?:e|es|ing|er|ers)|grand prix|circuits?|laps?|podiums?|pit ?stops?|rally|rallies|formula|nascar|"
                    r"le mans|endurance|time trials?|drag strip|speedways?|ovals?|grid)\b", re.I)
RACE_CLASSES = ["vehicle.car.race", "vehicle.car.sports"]
RACE_ONLY = {"vehicle.car.race"}  # never brought by the generic word "car": a pizza van game must not park a formula car
DRIVEN = ("vehicle.car.",)  # library vehicles a game drives with lib.vehicle (the Garage rig); military ones keep lib.parts
# class words that are also everyday English ("as well", "a sign of", "turn the table")
AMBIGUOUS = {"well", "sign", "table", "stand", "rail", "module", "steps", "net", "rope", "goods", "frame", "book", "slope",
             "sports", "classic", "race", "estate", "coupe", "muscle", "semi", "loader", "pod",
             "tank", "fighter", "jeep", "truck", "drone", "artillery"}  # a fish tank, Street Fighter: military only by phrase or setting
# a WWII setting keeps only WWII-era vehicles (cards tagged era-ww2); any other setting sorts them last (10 Oct 2026: a
# "WW2 tank battle" was getting an Abrams and an F/A-18)
WW2 = re.compile(r"\b(ww ?(2|ii)|world war (2|ii|two)|second world war|194[0-5]|normandy|d-?day|blitzkrieg|stalingrad|wehrmacht|"
                 r"nazis?|allies vs axis|axis powers)\b", re.I)
# the other eras of the destructible tanks (11 Oct 2026: 22 tanks from 1917 to today; tanks sort by size, so without an era
# rule a modern battlefield got a 1917 Renault FT). WWI and WWII settings keep only their era's military vehicles; any other
# setting prefers modern, then Cold War, then WWII, then WWI.
WW1 = re.compile(r"\b(ww ?(1|i)|world war (1|i|one)|first world war|great war|191[4-8]|trench warfare|trenches|the somme|verdun|ypres|"
                 r"landships?)\b", re.I)
COLD_WAR = re.compile(r"\b(cold war|warsaw pact|iron curtain|berlin wall|fulda gap)\b", re.I)
ERA_ORDER = {"modern": ["modern", "coldwar", "ww2", "ww1"], "coldwar": ["coldwar", "modern", "ww2", "ww1"],
             "ww2": ["ww2", "ww1", "coldwar", "modern"], "ww1": ["ww1", "ww2", "coldwar", "modern"]}


def era_of(text: str) -> str:
    return "ww2" if WW2.search(text or "") else "ww1" if WW1.search(text or "") else "coldwar" if COLD_WAR.search(text or "") else "modern"


def _era_rank(a: dict, era: str) -> int:
    tags = [t[4:] for t in (a.get("tags") or []) if t.startswith("era-")]
    order = ERA_ORDER[era]
    return order.index(tags[0]) if tags and tags[0] in order else (0 if era == "modern" else order.index("modern"))


# inside an era: a model the words name ("Sherman", "T-34", "Leopard") first, then one of a nation the story names ("German
# panzers"), then the rest; a Japanese tank only in a Pacific story (a Normandy battle got a Chi-Ha as the lightest WWII tank)
GENERIC_TAGS = {"land", "military", "style", "tank", "vehicle", "heavy", "light", "infantry", "type", "black", "cold", "war", "wwi",
                "wwii", "mark", "char", "chi", "is", "iv", "ha",
                "car", "race", "racer"}  # 17 Oct 2026: every race car has these: on a race brief they made all "named" and file size chose
WEST = r"normandy|d-?day|western front|ardennes|bulge|el alamein|north africa"   # the nations that fought there
NATION = {"germany": r"german\w*|wehrmacht|panzers?|eastern front|" + WEST, "usa": r"americans?|us army|yanks?|" + WEST,
          "ussr": r"soviets?|red army|russians?|stalingrad|kursk|eastern front", "russia": r"russians?",
          "uk": r"british|brits?|england|english|" + WEST, "france": r"french", "japan": r"japanese|imperial army",
          "israel": r"israel\w*|idf", "china": r"chinese", "south-korea": r"korean?"}
PACIFIC = re.compile(r"\b(pacific|iwo jima|guadalcanal|okinawa|burma|philippines|japan\w*|imperial army|manchuria)\b", re.I)


def _named(a: dict, words: set, text: str) -> int:
    """0 = the words name this model, 1 = its nation is in the story, 2 = neither, 3 = a Japanese model outside the Pacific"""
    tags = a.get("tags") or []
    own = [t for t in tags if not t.startswith("era-") and t not in GENERIC_TAGS and (len(t) > 2 or (len(t) == 2 and any(c.isdigit() for c in t)))]
    if any(t in words for t in own):
        return 0
    if any(t in NATION and re.search(rf"\b({NATION[t]})\b", text or "", re.I) for t in tags):
        return 1
    return 3 if "japan" in tags and not PACIFIC.search(text or "") else 2


# spacecraft: every card carries "space", "sci-fi", its class and family; only the OTHER tags tell the genre ("retro", "pirate",
# "anime", "stealth", "dieselpunk", "hard-sci-fi"...), so a brief that names one gets that ship first
SPACE_GENERIC = {"space", "sci-fi", "vehicle", "fighter", "fighters", "ship", "ships", "pod", "pods", "cruiser", "cruisers", "battleship",
                 "battleships", "mothership", "motherships", "speeder", "speeders", "big", "hover", "alien", "civilian", "military"}


def _space_hits(a: dict, words: set) -> int:
    if not a["id"].startswith(SPACE_GROUP):
        return 0
    return sum(1 for t in (a.get("tags") or []) if t in words and t not in SPACE_GENERIC)


def _weight(a: dict) -> int:
    """bytes the game downloads for this asset: a destructible tank brings its damage file too"""
    return a["bytes"] + (a.get("damage_bytes") or 0 if a.get("damage_glb") else 0)


# a war / army setting (also the THEMES key below); inside it "tank", "jeep", "truck", "fighter" mean the vehicle
WAR = re.compile(r"\b(war|warzone|army|armies|military|battlefields?|front ?lines?|soldiers?|troops|commandos?|invasion|ww ?(2|ii)|"
                 r"world war|military base|army base|air ?base|air ?strikes?|platoon|squad of soldiers)\b", re.I)
WAR_WORDS = {"tank": "vehicle.military-land.tank", "jeep": "vehicle.military-land.jeep", "humvee": "vehicle.military-land.jeep",
             "truck": "vehicle.military-land.truck", "fighter": "vehicle.military-air.fighter", "jet": "vehicle.military-air.fighter",
             "helicopter": "vehicle.rotor.helicopter", "gunship": "vehicle.rotor.attack-helicopter", "bomber": "vehicle.military-air.bomber"}
# military vehicles by phrase (the single words above are too ambiguous); the index aliases add the rest
MILITARY_PHRASES = {"battle tank": "vehicle.military-land.tank", "tanks rolling": "vehicle.military-land.tank", "army tank": "vehicle.military-land.tank",
                    "heavy tank": "vehicle.military-land.tank", "army jeep": "vehicle.military-land.jeep", "military jeep": "vehicle.military-land.jeep",
                    "humvee": "vehicle.military-land.jeep", "hummer": "vehicle.military-land.jeep", "troop truck": "vehicle.military-land.truck",
                    "army truck": "vehicle.military-land.truck", "military truck": "vehicle.military-land.truck",
                    "fighter jet": "vehicle.military-air.fighter", "jet fighter": "vehicle.military-air.fighter", "fighter jets": "vehicle.military-air.fighter",
                    "fighter plane": "vehicle.military-air.fighter", "warplane": "vehicle.military-air.fighter", "dogfight": "vehicle.military-air.fighter",
                    "combat helicopter": "vehicle.rotor.attack-helicopter", "gunship": "vehicle.rotor.attack-helicopter",
                    "troop helicopter": "vehicle.rotor.helicopter", "army helicopter": "vehicle.rotor.helicopter"}
# setting words -> a dressing set (classes) so "a fishing harbour" brings the quay, not only what is spelled out
THEMES = {
    r"\b(harbou?rs?|port|quays?|docks?|fishing village|fishing town|seaside|wharf|pier|marina|fishermen|fisherman)\b":
        ["prop.harbour.anchor", "prop.harbour.buoy", "prop.harbour.mooring", "prop.harbour.lobster-pot", "prop.harbour.fish-box",
         "prop.harbour.net", "prop.harbour.rope", "part.wall.quay-wall", "part.floor.deck", "part.stair.steps",
         "building.landmark.lighthouse", "building.civic.market", "prop.container.barrel", "prop.container.crate",
         "prop.street.lamp", "prop.street.bench"],
    r"\b(village|hamlet|town square|old town|cottages?)\b":
        ["part.wall.module", "part.wall.corner", "part.roof.slope", "part.roof.gable-end", "part.roof.chimney",
         "building.medieval.chapel", "prop.street.lamp", "prop.street.bench", "prop.farm.fence", "prop.farm.cart"],
    r"\b(furniture store|flat-?pack|ikea|home store|showroom|furniture shop)\b":
        ["prop.furniture.sofa", "prop.furniture.armchair", "prop.furniture.bed", "prop.furniture.shelf", "prop.furniture.table",
         "prop.furniture.lamp", "prop.retail.cart", "prop.retail.rack", "prop.retail.checkout", "prop.decor.plant"],
    r"\b(ruins?|ancient temple|lost city|forest|woods|jungle)\b":
        ["building.ruin.column", "building.ruin.arch", "prop.nature.rock", "prop.nature.tree"],
    # a modern city or street: parked cars, buses, bikes (dropped when the words name an older or other world, NOT_MODERN)
    r"\b(city|cities|downtown|traffic|parking|car parks?|bus stops?|bus stations?|suburbs?|avenues?|boulevards?|highways?|"
    r"rush hour|commuters?)\b":
        ["vehicle.car.hatchback", "vehicle.car.sedan", "vehicle.car.suv", "vehicle.service.bus", "vehicle.two-wheel.bicycle",
         "vehicle.two-wheel.scooter", "vehicle.two-wheel.motorcycle"],
    # alien words in space: the saucer first (an alien story must not spend the byte cap on a freighter before it)
    ALIEN.pattern: ["vehicle.space.saucer"],
    # a space setting: a small fleet (fighters, a cruiser, a freighter, a shuttle); battleships, motherships, pods by name
    SPACE.pattern: ["vehicle.space.fighter", "vehicle.space.cruiser", "vehicle.space.freighter", "vehicle.space.shuttle"],
    # hover words anywhere: the speeders
    HOVER.pattern: ["vehicle.space.speeder"],
    # a war / army setting: the military motor pool (bombers and AWACS only when named)
    WAR.pattern:
        ["vehicle.military-land.jeep", "vehicle.military-land.truck", "vehicle.military-land.tank", "vehicle.rotor.helicopter",
         "vehicle.rotor.attack-helicopter", "vehicle.military-air.fighter"],
}
GROUP_ORDER = ["building.landmark", "building.medieval", "building.dwelling", "building.fort", "building.civic", "building.ruin",
               "prop.street", "prop.harbour", "prop.farm", "prop.container", "prop.nature", "prop.camp", "part.wall", "part.roof",
               "part.opening", "part.stair", "part.floor", "part.structure", "prop.furniture", "prop.retail", "prop.decor", "prop.sport",
               "vehicle.car", "vehicle.service", "vehicle.two-wheel"]
STOP = set("a an and the of with for to in on at by from my our some big small little old new one two three".split())


def tokens(text: str) -> list[str]:
    out = []
    for t in re.split(r"[^a-z0-9]+", (text or "").lower()):
        if not t or t in STOP:
            continue
        if len(t) > 3 and t.endswith("es") and not t.endswith("ses"):
            t = t[:-2] if t[:-2].endswith(("ch", "sh", "x")) else t[:-1]
        elif len(t) > 3 and t.endswith("s") and not t.endswith("ss"):
            t = t[:-1]
        out.append(t)
    return out


_INDEX: dict[str, Any] = {"mtime": None, "data": None}


def load_index(root: Path = LIB) -> dict | None:
    """index.json, cached until its file changes (a library publish needs no Factory restart)."""
    p = root / "repo" / "index.json"
    try:
        m = p.stat().st_mtime
        if _INDEX["mtime"] != m:
            _INDEX["data"], _INDEX["mtime"] = json.loads(p.read_text()), m
        return _INDEX["data"]
    except (OSError, ValueError):
        return None


def library_fits(card: Any, roomless: bool = False) -> bool:
    """roomless = a room game whose room never came: dressed like a game without a room (Cast, Garage and library alike;
    18 Oct 2026, Costa Rouge got the Garage's blocky cars and no library)"""
    world = getattr(card, "world", None)
    return (getattr(card, "lane", None) == "custom" and (getattr(card, "stage", None) == "none" or (roomless and getattr(card, "stage", None) == "room")) and world is not None
            and getattr(world, "view", None) in ("first", "third", "top"))


_WORD_MAP: dict[str, Any] = {"mtime": None, "data": None}


def load_word_map(root: Path = LIB) -> dict | None:
    """search/word_map.json (words -> assets by meaning, built at publish time), cached until its file changes. None = absent."""
    p = root / "repo" / "search" / "word_map.json"
    try:
        m = p.stat().st_mtime
        if _WORD_MAP["mtime"] != m:
            _WORD_MAP["data"], _WORD_MAP["mtime"] = json.loads(p.read_text()), m
        return _WORD_MAP["data"]
    except (OSError, ValueError):
        _WORD_MAP["data"], _WORD_MAP["mtime"] = None, None
        return None


def _usable(a: dict, modern: bool = True, space: bool = False) -> bool:
    cls = a["id"].split("/")[0]
    if cls.startswith(SPACE_GROUP):
        if not space:  # spacecraft never park in a city, a battlefield or a castle
            return False
    elif cls.startswith("vehicle.") and (not modern or not cls.startswith(VEHICLE_GROUPS) or a.get("kit") == "garage"):
        return False
    # reuse.omg false = opted out (18 Oct 2026: photographed paintings, posters and people default OFF; --no-reuse at import)
    return (cls.split(".")[0] in KINDS and not cls.startswith(SKIP_GROUPS) and a.get("game_use")
            and (a.get("reuse") or {}).get("omg", a.get("game_use", False))
            and a.get("status") in ("approved", "legacy"))


def pick(index: dict, text: str, driving: bool = False, semantic: bool = True, word_map: dict | None = None) -> tuple[list[dict], list[str], list[str]]:
    """(assets, matched classes, gaps) for the game's words. Deterministic: same words + same index = same picks.
    driving = the game drives vehicles (the Garage kind "vehicles"): a race brings the race cars, up to DRIVEN_PER_CLASS.
    semantic = words that hit no alias and no class word are searched in the library by meaning (library_search, 18 Oct 2026);
    those models come after every alias/class pick, so semantic=False picks are always a subset of the default picks.
    word_map = search/word_map.json (meaning lookup, "settee" -> sofas); None = BM25 only, as before it existed."""
    hover = bool(HOVER.search(text or ""))  # "speeder bikes", "hover cars": a sci-fi world (no bicycle, no road car; see modern)
    low = " " + re.sub(r"[^a-z0-9 -]+", " ", text.lower()) + " "
    words = set(tokens(text))
    # a space scene is another world for Earth vehicles (no F-16 or tank in a space shooter, even with "invasion" in it)
    space_scene = bool(SPACE.search(text or ""))
    modern = not NOT_MODERN.search(text or "") and not space_scene and not hover  # hover cars: a sci-fi world, no road cars
    space = space_scene or hover
    alien = bool(ALIEN.search(text or ""))
    era = era_of(text)
    old = era in ("ww1", "ww2")  # a world-war setting keeps only its era's military vehicles
    usable = [a for a in index["assets"] if _usable(a, modern, space)
              and not (old and a["id"].startswith(("vehicle.military", "vehicle.rotor")) and f"era-{era}" not in (a.get("tags") or []))]
    by_class: dict[str, list[dict]] = {}
    for a in usable:
        by_class.setdefault(a["id"].split("/")[0], []).append(a)
    wanted: list[str] = []
    consumed: set[str] = set()  # words an alias or a class already named (hit or GAP): the semantic search leaves them alone
    ltok = " " + " ".join(tokens(text)) + " "  # singular words: "casks" meets the alias "cask"
    for phrase, cls in sorted({**index.get("aliases", {}), **MILITARY_PHRASES}.items(), key=lambda kv: -len(kv[0])):
        pt = " ".join(tokens(phrase))
        if f" {phrase} " in low or (pt and f" {pt} " in ltok):
            consumed.update(tokens(phrase))
            # "car" means a road car: race cars come only with a race (RACING) or by their own name ("race car")
            wanted += [c for c in by_class if c == cls or (c.startswith(cls + ".") and c not in RACE_ONLY)]
    gaps: list[str] = []
    by_word: dict[str, list[str]] = {}
    for kind, node in index["tree"].items():  # a class of a kind the library does not bring (cow, rifle) is no lookalike's job
        for grp, g in node["groups"].items():
            for cls in g["classes"]:
                parts = tokens(cls.replace("-", " "))
                if parts and (((" " + cls.replace("-", " ") + " ") in low) if len(parts) > 1 else parts[0] in words):
                    consumed.update(parts)
    for kind, node in index["tree"].items():
        if kind not in KINDS:
            continue
        for grp, g in node["groups"].items():
            for cls in g["classes"]:
                full = f"{kind}.{grp}.{cls}"
                if full.startswith(SKIP_GROUPS) or (kind == "vehicle" and not full.startswith(VEHICLE_GROUPS + (SPACE_GROUP,))):
                    continue
                parts = tokens(cls.replace("-", " "))
                if cls in AMBIGUOUS or not parts:
                    continue
                hit = (" " + cls.replace("-", " ") + " ") in low if len(parts) > 1 else parts[0] in words
                if hit:
                    consumed.update(parts)
                    by_word.setdefault(cls, []).append(full)
    # one word, several classes (a "lamp" is a street lamp or a floor lamp): outdoors first, unless an alias already chose
    for cls_word, fulls in by_word.items():
        # a class that holds models beats an empty one ("house": dwelling.house before an empty medieval.house)
        fulls.sort(key=lambda f: (f not in by_class, GROUP_ORDER.index(f.rsplit(".", 1)[0]) if f.rsplit(".", 1)[0] in GROUP_ORDER else 99))
        if any(f in wanted for f in fulls):
            continue
        best = fulls[0]
        (wanted if best in by_class else gaps).append(best)
    if modern and WAR.search(text or ""):  # a battlefield: "enemy tanks", "trucks", "jets" name the military vehicles
        wanted += [c for w, c in WAR_WORDS.items() if w in words and c in by_class]
    if space:  # a space game: "fighter", "carrier", "cargo" name the craft (single words too ambiguous elsewhere)
        wanted += [c for w, c in SPACE_WORDS.items() if w in words and c in by_class]
        wanted += [c for p, c in (("escape pod", "vehicle.space.pod"), ("flying saucer", "vehicle.space.saucer"),
                                  ("space station", "vehicle.space.station")) if p in low and c in by_class]
    racing = driving and modern and bool(RACING.search(text or ""))
    if racing:  # the grid first: race cars lead the order, so the byte cap never spends itself on parked saloons
        wanted = [c for c in RACE_CLASSES if c in by_class] + wanted
    named = set(wanted)  # classes the words named (aliases, class words) before any setting theme
    # a game that drives cars drives the library's road cars even when its words name none (18 Oct 2026, operator: Costa
    # Rouge, "vole une voiture" in French, drove blocky Garage cars); first in the order so the byte cap never drops them.
    # Not on a battlefield: its tanks and jeeps keep the bytes
    if driving and modern and not WAR.search(text or "") and not any(c.startswith(DRIVEN) for c in named):
        wanted = [c for c in sorted(by_class) if c.startswith(DRIVEN) and c not in RACE_ONLY] + wanted
    for pat, classes in THEMES.items():
        if re.search(pat, text, re.I):
            wanted += [c for c in classes if c in by_class]
    order = list(dict.fromkeys(wanted))
    # alien vessels lead an alien story and trail every other one ("alien" tag on the card)
    rank = lambda a: (a["status"] != "approved", ("alien" in (a.get("tags") or [])) != alien, -_space_hits(a, words), _named(a, words, text) > 0,
                      _era_rank(a, era), _named(a, words, text), _weight(a))
    out, total = [], 0

    def take(a):
        nonlocal total
        if len(out) >= MAX_ASSETS or total + _weight(a) > MAX_BYTES:
            return False
        out.append(a); total += _weight(a); return True
    # pass 1, breadth first: every wanted class gets its first model (a battlefield must not spend the byte cap on two jets
    # before its first tank, 10 Oct 2026); non-vehicle classes keep their usual PER_CLASS
    for cls in order:
        cands = sorted(by_class[cls], key=rank)
        for a in cands[:1 if cls.startswith("vehicle.") else PER_CLASS]:
            if not take(a):
                break
    # pass 2: a second model for vehicle classes the words named, while the budget lasts (setting-only classes stay at one:
    # their six paints vary them)
    first = (["vehicle.car.race"] if racing else []) + ["vehicle.military-land.tank", "vehicle.military-land.jeep",
                                                       "vehicle.military-land.truck", "vehicle.rotor.attack-helicopter",
                                                       "vehicle.space.fighter"]
    for cls in sorted(order, key=lambda c: first.index(c) if c in first else len(first)):  # on a battlefield, tanks before a 2nd jet
        if cls.startswith("vehicle.") and cls in named:
            per = DRIVEN_PER_CLASS if racing and cls == "vehicle.car.race" else PER_VEHICLE_CLASS
            for a in sorted(by_class[cls], key=rank)[1:per]:
                take(a)
    if semantic:  # words no alias or class named: search the library by meaning, after every class pick, floor-guarded
        terms = [w for w in tokens(text) if w not in consumed and w not in AMBIGUOUS]
        if word_map:  # two-word phrases too ("rowing boat"), only between words nothing else named
            toks = [w if w not in consumed and w not in AMBIGUOUS else None for w in tokens(text)]
            terms = library_search.with_phrases([t or "" for t in toks])
        corpus = library_search.Corpus(usable)
        for _, a, _ in library_search.search_terms(corpus, terms, limit=SEMANTIC_MAX, skip={x["id"] for x in out}, word_map=word_map):
            take(a)
    return out, list(dict.fromkeys(c for c in wanted)), sorted(set(gaps))


def _card(root: Path, a: dict) -> dict:
    try:
        return json.loads((root / "repo" / a["card"]).read_text())
    except (OSError, ValueError):
        return {}


def build_pack(root: Path, picks: list[dict]) -> tuple[bytes, bytes, dict]:
    """(code tar.gz, files tar.gz, lock). Files: library/manifest.json, library/assets.lock.json, library/models/*.glb."""
    if any(not (a.get("reuse") or {}).get("omg", a.get("game_use", False)) for a in picks):
        raise PermissionError("asset opted out of OMG reuse")
    rows, lock = [], {"library": "forge-assets", "built": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "assets": []}
    files = io.BytesIO()
    with tarfile.open(fileobj=files, mode="w:gz") as tf:
        for a in picks:
            blob = (root / "blobs" / f"{a['glb']}.glb").read_bytes()
            if hashlib.sha256(blob).hexdigest() != a["glb"]:
                raise ValueError(f"blob hash mismatch for {a['id']}")
            info = tarfile.TarInfo(f"library/models/{a['glb'][:16]}.glb"); info.size = len(blob); info.mtime = 0
            tf.addfile(info, io.BytesIO(blob))
            if a.get("damage_glb"):  # destructible tank: its damage file rides along (lib.damage in the kit, LIBRARY.md L11)
                dblob = (root / "blobs" / f"{a['damage_glb']}.glb").read_bytes()
                if hashlib.sha256(dblob).hexdigest() != a["damage_glb"]:
                    raise ValueError(f"damage blob hash mismatch for {a['id']}")
                info = tarfile.TarInfo(f"library/models/{a['damage_glb'][:16]}.glb"); info.size = len(dblob); info.mtime = 0
                tf.addfile(info, io.BytesIO(dblob))
            c = _card(root, a)
            b = c.get("bounds") or {}
            rows.append({"id": a["id"], "name": a["name"], "class": a["id"].split("/")[0], "summary": a["summary"],
                         "file": f"models/{a['glb'][:16]}.glb", "release": a["release"], "size": a["size"], "scale": a["scale"],
                         "footprint": {k: v for k, v in (b.get("footprint") or {}).items() if k in ("minX", "maxX", "minZ", "maxZ")},
                         "collider": (b.get("collider") or {}).get("parts") or [], "sockets": c.get("sockets") or [],
                         "role": (c.get("placement") or {}).get("role"), "notes": (c.get("placement") or {}).get("notes"),
                         "tris": a["tris"],
                         # vehicles: recolour (paint.material + six paints) and the moving parts (wheels, steering, crank)
                         **({"paint": c["paint"], "nodes": c.get("nodes") or {}} if (c.get("paint") or {}).get("material") else {}),
                         **({"damage": f"models/{a['damage_glb'][:16]}.glb"} if a.get("damage_glb") else {})})
            lock["assets"].append({"id": a["id"], "release": a["release"], "glb": a["glb"], **({"damage_glb": a["damage_glb"]} if a.get("damage_glb") else {})})
        # decoders for authored models (KTX2 textures, Draco geometry), served from public/library/<name>/
        ktx2 = any(a.get("ktx2") for a in picks)
        draco = any(a.get("codec") == "draco" for a in picks)
        for need, name in ((ktx2, "basis"), (draco, "draco")):
            if need:
                for f in sorted((root / "repo" / "omg" / "public" / name).iterdir()):
                    data = f.read_bytes(); info = tarfile.TarInfo(f"library/{name}/{f.name}"); info.size = len(data); info.mtime = 0
                    tf.addfile(info, io.BytesIO(data))
        lock_bytes = json.dumps(lock, indent=1).encode()
        lock["hash"] = "sha256:" + hashlib.sha256(lock_bytes).hexdigest()
        man = json.dumps({"assets": rows, "lock": lock["hash"], "ktx2": ktx2, "draco": draco}, indent=1).encode()
        for name, data in (("library/manifest.json", man), ("library/assets.lock.json", lock_bytes)):
            info = tarfile.TarInfo(name); info.size = len(data); info.mtime = 0
            tf.addfile(info, io.BytesIO(data))
    code = io.BytesIO()
    with tarfile.open(fileobj=code, mode="w:gz") as tf:
        for f in sorted((root / "repo" / "omg" / "kit").iterdir()):
            if f.is_file():
                tf.add(f, arcname=f.name)
    return code.getvalue(), files.getvalue(), lock


def drive_contract(ids: list[str]) -> str:
    """The builder contract for DRIVEN library cars (15 Oct 2026): ids = the picked cars a game may drive."""
    return (
        " DRIVEN CARS COME FROM THE LIBRARY: the player's car and every rival, AI or chase car is `const rig = lib.vehicle(id, "
        "{ paint })` with one of " + ", ".join(ids) + " (LIBRARY.md L9), after `await lib.preload()`. It is the same rig as the "
        "Garage's `createVehicle`: `scene.add(rig.group)`, move and turn `rig.group` from your physics (front +Z), "
        "`rig.setSteer(angle)`, `rig.spin(metres travelled)`, `rig.dims` for collision boxes, `rig.exhausts` for boost flames, "
        "`rig.player = true` on the player's car, `rig.ready`; the Garage ground look tracks it as is. True metres: never rescale "
        "it, tune speeds, camera distance and track width to `rig.dims`. Mix the models across the field and give every car its "
        "own paint (`paint: 0` to `5` = the card's six paints, or `{ body: '#hex' }` from the palette). Never draw a car from "
        "boxes, and never use a Garage model for a car type this list holds."
        + (" In a race the grid is the race cars (" + ", ".join(i for i in ids if i.startswith("vehicle.car.race/")) + "), every "
           "rival a different model or paint; road cars stand in the paddock, car parks and traffic." if any(i.startswith("vehicle.car.race/") for i in ids) else "")
    )


def library_contract(code_id: str, files_id: str, picks: list[dict]) -> str:
    listing = "; ".join(f"{a['id']} ({a['name']}, {a['size']['w']:.1f} x {a['size']['l']:.1f} x {a['size']['h']:.1f} m)" for a in picks)
    return (
        " SCENE MODELS COME FROM OUR ASSET LIBRARY. Attachment " + code_id + " is the library loader (JavaScript ES module "
        "with TypeScript types, three.js, a gzipped tar) and attachment " + files_id + " holds " + str(len(picks)) + " models "
        "picked for this game with their cards (a gzipped tar). First run: `mkdir -p src/lib/library && tar -xzf "
        ".forge/attachments/" + code_id + "/original.* -C src/lib/library && tar -xzf .forge/attachments/" + files_id +
        "/original.* -C public`, then read `src/lib/library/LIBRARY.md` (laws L1-L12). Models: " + listing + ". Place each "
        "with `lib.place(id, { x, z, yaw, scene })` from `createLibrary(THREE, { renderer })` (`@/lib/library/index.mjs`, pass "
        "your WebGLRenderer), after `await lib.preload()`. They are true metres, origin on the ground, front +Z: never rescale them; lay the scene "
        "out from each card's size and footprint and use its colliders. Never draw one of these objects from boxes when "
        "the library has it. Do not edit src/lib/library or public/library."
        + (" Tanks whose manifest row has `damage` are DESTRUCTIBLE: for each placed tank `const d = await lib.damage(object)`, "
           "then show every hit ON the tank: `d.hit(side)` or `d.hit({ point })` (hit marks, dented then lost panels), "
           "`d.track('left' | 'right')` (thrown track: it stops), `d.gun()` (gun knocked out), `d.wreck({ turretOff: true })` "
           "when destroyed (burnt wreck, turret blown off; add fire and smoke), `d.repair()` on respawn (LIBRARY.md L11)."
           if any(a.get("damage_glb") for a in picks) else "")
        + (" SPACECRAFT (" + ", ".join(a["id"] for a in picks if a["id"].startswith(SPACE_GROUP)) + ") follow LIBRARY.md L12: true "
           "metres (fighters 11 to 24 m, capital ships hundreds of metres to 2 km: far away as backdrops, carriers or bosses, never "
           "rescaled against each other), origin = the craft's CENTRE (place it with `y` = altitude; speeders hover by themselves), "
           "nose +Z, one paint per faction (no markings). Animate only through `const s = lib.parts(object)`: engine flames on the "
           "empty `s.thrusters` (exhaust toward the craft's -Z), shots from `s.muzzles`, missiles from `s.hardpoints`, small craft "
           "from `s.hangars`, `s.turn(turret, yaw)` for each of `s.turrets`, `s.turn(gun, pitch)` for `s.guns` (positive = up), "
           "`s.turn(ring, t)` spins `s.rings`/`s.fans`, `s.turn(door, angle)` opens `s.doors`. Never draw a spaceship from boxes "
           "when the manifest has one." if any(a["id"].startswith(SPACE_GROUP) for a in picks) else "")
    )


LIBRARY_ART_LINE = " The Art Direction names which library models (by id) dress each place of the game."


def library_attachments(card: Any, first_index: int, prompt: str = "", root: Path = LIB, driving: bool = False,
                        roomless: bool = False) -> tuple[list[dict], str, dict] | None:
    """(attachments, contract, meta) or None. first_index = attachments already in the brief (att1, att2... in order).
    driving = the Garage gave this game vehicles to drive: its picked cars become the driven cars (meta["driven"])."""
    if not library_fits(card, roomless):
        return None
    index = load_index(root)
    if not index:
        return None
    picks, classes, gaps = pick(index, _card_text(card, prompt), driving=driving, word_map=load_word_map(root))
    if not picks:
        return None
    try:
        code, files, lock = build_pack(root, picks)
    except (OSError, ValueError):
        return None
    code_id, files_id = f"att{first_index + 1}", f"att{first_index + 2}"
    atts = [
        {"url": _data_url("application/gzip", code), "filename": "library-kit.tar.gz", "alt": "Asset library loader (JavaScript, three.js)",
         "role": "code to extract into src/lib/library and use as is"},
        {"url": _data_url("application/gzip", files), "filename": "library-models.tar.gz",
         "alt": f"{len(picks)} library models with cards (GLB)", "role": "models to extract into public (public/library)"},
    ]
    meta = {"count": len(picks), "bytes": sum(_weight(a) for a in picks), "destructible": sum(1 for a in picks if a.get("damage_glb")), "lock": lock["hash"], "classes": classes[:40],
            "gaps": gaps[:40], "index": index.get("built"), "releases": [a["release"] for a in picks]}
    contract = library_contract(code_id, files_id, picks)
    driven = sorted((a["id"] for a in picks if driving and a["id"].startswith(DRIVEN)), key=lambda i: not i.startswith("vehicle.car.race/"))
    if driven:
        meta["driven"] = driven
        contract += drive_contract(driven)
    return atts, contract, meta
