"""nouns.py -- pictionary secrets: a random noun for a physical thing, how common it is decided by a d20 (Ivy, Oct 23 2026: "make the
guesses harder. Make it be a random noun" -> "It should roll a d20 to see how common the word it gets is" -> "Within reason" ->
"Barring critical failure").

The pool: WordNet nouns under 'physical object' (things, animals, plants, natural things -- something you could build), never under
'person' (keeps out slurs and job titles), lowercase single words or two-word names, each with its wordfreq Zipf frequency.
The roll: 20 = very common (Zipf ~5.2), down one band per point to 2 = uncommon but real (Zipf ~2.6); a natural 1 is a critical
failure -- an obscure noun (Zipf 1.5-2.3).

    python3 nouns.py            # build pict_nouns.json and show a roll of each
Written by Iris."""
import os, json, random
HERE = os.path.dirname(os.path.abspath(__file__)); POOL = os.path.join(HERE, 'pict_nouns.json')

KEEP = ('noun.artifact', 'noun.animal', 'noun.plant', 'noun.food', 'noun.object')
BLOCK = ('penile', 'penis', 'condom', 'dildo', 'vibrator', 'tampon', 'brassiere', 'bra ', 'jockstrap', 'diaphragm', 'pessary', 'iud',
         'syringe', 'hypodermic', 'gun', 'rifle', 'pistol', 'revolver', 'bomb', 'grenade', 'noose', 'gallows', 'electric chair',
         'swastika', 'crack', 'bong', 'roach', 'joint', 'casket', 'coffin', 'corpse', 'carcass', 'feces', 'urinal', 'toilet paper')

VAGUE = {'room', 'housing', 'clothing', 'cell', 'track', 'padding', 'weeds', 'shelter', 'material', 'equipment', 'building', 'structure',
         'stuff', 'object', 'thing', 'item', 'device', 'unit', 'area', 'space', 'site', 'property', 'facility', 'supply', 'goods', 'gear',
         'garment', 'food', 'meal', 'plant', 'animal', 'beef', 'meat', 'liquid', 'substance', 'surface', 'interior', 'exterior'}

def band(d):
    """the Zipf range a d20 roll draws from: 20 = anything at least as common as 'dog' (5.0+), down to 2 = uncommon but real;
    a natural 1 (critical failure) = obscure"""
    if d == 1: return 1.5, 2.6                            # still a word 95% of people know -- just a rare one
    if d == 20: return 4.6, 9.0
    c = 2.9 + (d - 2) * (1.7 / 17); return c - 0.2, c + 0.2   # easier than before (was 2.6..4.9): Ivy found it too hard

def _norms():
    """Brysbaert, Warriner & Kuperman (2014), 'Concreteness ratings for 40 thousand generally known English word lemmas':
    word -> (concreteness 1-5, share of raters who knew it, dominant part of speech)"""
    out = {}
    for line in open(os.path.join(os.path.dirname(HERE), 'notes', 'data', 'brysbaert.txt')).read().splitlines()[1:]:
        f = line.split('\t')
        if len(f) >= 9:
            try: out[f[0].strip().lower()] = (float(f[2]), float(f[6]), f[8].strip())
            except ValueError: pass
    return out

def build():
    global NORMS
    NORMS = _norms()
    from nltk.corpus import wordnet as wn
    from wordfreq import zipf_frequency
    person = set(wn.synset('person.n.01').closure(lambda s: s.hyponyms())) | {wn.synset('person.n.01')}
    seen, out = set(), {}
    objects = set(wn.synset('object.n.01').closure(lambda s: s.hyponyms())) - person
    for s in objects:
        if s in person or s.lexname() in ('noun.person', 'noun.body', 'noun.group'): continue
        for l in s.lemmas():
            w = l.name().replace('_', ' ')
            if w in seen or not w.islower() or not all(p.isalpha() for p in w.split()) or len(w.split()) > 2 or len(w) < 3: continue
            seen.add(w)
            # its MOST COMMON meaning must be the physical thing: 'deep', 'hold', 'danger', 'heart' and 'return' rolled 20s because
            # WordNet keeps a physical sense for each, and wordfreq counts every sense; plurals ('parts') are out too
            first = wn.synsets(w.replace(' ', '_'), pos='n')
            if not first or first[0] not in objects or (wn.morphy(w.replace(' ', '_'), 'n') or w).replace('_', ' ') != w: continue
            if first[0].lexname() not in KEEP: continue      # things you can build -- not places ('site', 'local street')
            if any(b in w for b in BLOCK): continue           # it may end up in classrooms and museums
            if w in VAGUE: continue                            # real and concrete, but no one shape to build
            key = w.replace(' ', '_')                          # mostly used as a NOUN, by WordNet's tagged counts ('round' is not)
            cnt = lambda pos: sum(l.count() for s_ in wn.synsets(key, pos=pos) for l in s_.lemmas() if l.name().lower() == key)
            if cnt('n') < cnt('v') + cnt('a') + cnt('s') + cnt('r'): continue
            # people must know it and be able to picture it (Ivy, Oct 23 2026: "the random words are too hard and need a screening for
            # nonsense"): Brysbaert et al. 2014 concreteness norms -- rated 4+ of 5 concrete, known by 95%+ of raters, mostly a noun.
            # 'oil future', 'big hand', 'cosmic string' aren't in the norms at all, so they go.
            n = NORMS.get(w)
            if not n or n[0] < 4.0 or n[1] < 0.95 or n[2] not in ('Noun', '0'): continue
            z = zipf_frequency(w, 'en')
            if z >= 1.5: out[w] = round(z, 2)
    json.dump(out, open(POOL, 'w'))
    return out

def pool():
    return json.load(open(POOL)) if os.path.exists(POOL) else build()

def roll(rng=random):
    """(d20, noun, zipf)"""
    d = rng.randint(1, 20); P = pool(); lo, hi = band(d)
    vague = {w for w, v in (json.load(open(VAGUE_FILE)) if os.path.exists(VAGUE_FILE) else {}).items() if v == 'VAGUE'}
    words = [w for w, z in P.items() if lo <= z <= hi and w not in vague]       # Haiku's vagueness screen ('product' is out)
    w = rng.choice(words); return d, w, P[w]

if __name__ == '__main__':
    P = build(); print(len(P), 'nouns')
    r = random.Random(7)
    for d in (1, 2, 5, 10, 15, 18, 20):
        lo, hi = band(d); ws = sorted(w for w, z in P.items() if lo <= z <= hi)
        print(f'd20={d:2}: {len(ws):5} nouns, e.g.', ', '.join(r.sample(ws, min(8, len(ws)))))

# ---- the vagueness screen (Ivy, Oct 23 2026: "Still need haiku to check if clues are vague to guess. It'll save calls because I
# won't waste time on 'product'"). Haiku reads the pool in batches of 100; answers are kept in pict_vague.json so no word is asked twice.
VAGUE_FILE = os.path.join(HERE, 'pict_vague.json')
SCREEN_Q = ('Pictionary with Lego: a builder makes a small Lego model of a word and a friend has to guess the word. For each word below, '
            'answer KEEP if a stranger could plausibly name EXACTLY that word from a good small model or sketch of it (a specific, '
            'recognisable thing: giraffe, drum, helmet, rowboat), or VAGUE if it is a category, a material, a place, an abstract or '
            'catch-all word, or a thing with no one recognisable shape (product, room, material, gear, supply). Answer ONLY with JSON: '
            '{"<word>": "KEEP" | "VAGUE", ...} covering every word.\nWords: ')

def screen(words):
    """{word: 'KEEP'|'VAGUE'} from Haiku, one call per 100 words"""
    import sys, re
    sys.path.insert(0, os.path.join(os.path.dirname(HERE), 'favorites')); os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')
    import haiku as H
    out = {}
    for i in range(0, len(words), 100):
        chunk = words[i:i + 100]
        t, _ = H.ask(SCREEN_Q + ', '.join(chunk), [], 2500)
        m = re.search(r'\{.*\}', t, re.S); a = json.loads(m.group(0)) if m else {}
        out.update({w: str(a.get(w, '')).upper() for w in chunk if str(a.get(w, '')).upper() in ('KEEP', 'VAGUE')})
    return out
