import Foundation

/// One poker word, and what it means to someone who has never read a strategy book.
///
/// Definitions are one or two short sentences and are written to the same standard as the coach's
/// own copy: no jargon inside a definition unless that word is in here too. A definition that has
/// to be looked up itself has taught nobody anything.
public struct GlossaryTerm: Identifiable, Sendable, Hashable {
    /// The headword, normalised — "the button" and "button" are the same entry.
    public let id: String
    /// The word as it is listed and as the definition card titles it.
    public let term: String
    public let definition: String
    /// Other spellings that mean the same thing and should find this entry.
    public let otherNames: [String]
    /// The phrases worth underlining when they turn up in a paragraph.
    ///
    /// Not every entry has one. "Set", "turn" and "pot" are ordinary English as well as poker, and
    /// underlining them wherever they appear would mark up sentences that were never about poker —
    /// so those entries are browsable and tappable where the app places them deliberately, and are
    /// left alone in prose.
    public let marks: [String]

    init(_ term: String, _ definition: String, also otherNames: [String] = [], mark: [String]? = nil) {
        self.id = Glossary.normalise(term)
        self.term = term
        self.definition = definition
        self.otherNames = otherNames
        self.marks = mark ?? ([term] + otherNames.filter { $0.count > 3 })
    }

    /// The tap target a marked-up word carries. A scheme of our own, so a stray tap can never leave
    /// the app: the handler answers every one of these itself.
    public var link: URL { URL(string: "pokerword:\(id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id)")! }
}

/// Every poker word the app puts in front of the player, with a plain-English definition behind it.
///
/// It lives in the UI target rather than beside the coach's copy for one reason: it has to cover
/// words from every layer at once — the seat names from `PokerCore`, the made hands from `PokerAI`,
/// the leak write-ups from `PokerCoach` and the statistics from `PokerSession`. None of those
/// targets can see the others, and none of them should learn to. The screens can see all four.
///
/// The list is deliberately not a licence to write jargon. Plain English is still the rule; this is
/// for the words that have no plain equivalent — the statistics — and for the traditional seat and
/// hand names the player will hear at any real card table.
public enum Glossary {

    public static let terms: [GlossaryTerm] = seats + streetsAndTable + statistics + actions + hands + styles

    // MARK: - Seats

    private static let seats: [GlossaryTerm] = [
        GlossaryTerm("position",
                     "Where you are sitting compared with the dealer, which decides how early you have to speak. The later you speak, the more you already know when it is your turn.",
                     mark: []),

        GlossaryTerm("the button",
                     "The seat that speaks last once the shared cards are out, marked with a disc. It moves one seat to the left every hand, so everybody takes a turn in it.",
                     also: ["dealer", "the dealer", "dealer button", "BTN"],
                     mark: ["the button", "the dealer", "dealer button"]),

        GlossaryTerm("small blind",
                     "The smaller of the two bets that go in before anyone has seen a card, put in by the player on the dealer's left.",
                     also: ["SB"], mark: ["small blind"]),

        GlossaryTerm("big blind",
                     "The larger of the two bets that go in before the cards are dealt, one seat further round. It is also the unit everything else at the table is measured in.",
                     also: ["BB"], mark: ["big blind"]),

        GlossaryTerm("the blinds",
                     "The two bets that have to go in before every hand, so there is always something worth playing for. They move one seat along each hand and reach everybody in turn.",
                     mark: ["the blinds", "blinds", "blind"]),

        GlossaryTerm("ante",
                     "A small amount everybody pays in before a hand on top of the blinds. It grows as a tournament goes on, which is what stops anyone sitting and waiting forever.",
                     mark: ["ante"]),

        GlossaryTerm("under the gun",
                     "The first seat to speak, before any shared cards come out. Nobody has answered yet, so it is the hardest seat at the table and the one to play fewest hands from.",
                     also: ["UTG", "under the gun + 1", "UTG+1"],
                     mark: ["under the gun"]),

        GlossaryTerm("middle position",
                     "The seats halfway round: a few players have already spoken and plenty still get to.",
                     also: ["MP"], mark: ["middle position"]),

        GlossaryTerm("lojack",
                     "An old name for the seat three to the right of the dealer. Five players speak before you and three after.",
                     also: ["LJ"], mark: ["lojack"]),

        GlossaryTerm("hijack",
                     "An old name for the seat two to the right of the dealer. Only three players speak after you, which makes it a good seat to raise from.",
                     also: ["HJ"], mark: ["hijack"]),

        GlossaryTerm("cutoff",
                     "The seat immediately to the right of the dealer, and the second best at the table: only one player speaks after you.",
                     also: ["CO"], mark: ["cutoff"]),
    ]

    // MARK: - The table and the rounds

    private static let streetsAndTable: [GlossaryTerm] = [
        GlossaryTerm("the board",
                     "The cards dealt face up in the middle for everybody to use. Your hand is the best five cards you can make from these and the two only you can see.",
                     also: ["community cards", "shared cards"],
                     mark: ["the board", "community cards"]),

        GlossaryTerm("street",
                     "One round of betting. There are four: before the shared cards, then after the flop, the turn and the river.",
                     mark: ["street"]),

        GlossaryTerm("preflop",
                     "The first round of betting, before any shared cards are dealt. All you know is your own two cards.",
                     mark: ["preflop"]),

        GlossaryTerm("the flop",
                     "The first three shared cards, dealt face up together. Most hands are settled by how well these three fit yours.",
                     mark: ["the flop"]),

        GlossaryTerm("the turn",
                     "The fourth shared card. It is where pots get big, because everybody still in has already shown they liked the first three.",
                     mark: ["the turn"]),

        GlossaryTerm("the river",
                     "The fifth and last shared card. Nothing else is coming, so what you have now is what you finish with.",
                     mark: ["the river"]),

        GlossaryTerm("showdown",
                     "The end of a hand, once the last bet is called: everybody still in turns their cards face up and the best hand takes the money.",
                     mark: ["showdown"]),

        GlossaryTerm("pot",
                     "All the money bet so far this hand, sitting in the middle. Whoever wins the hand takes it.",
                     mark: []),

        GlossaryTerm("side pot",
                     "When somebody runs out of chips, whatever the others go on betting is kept in a second pile. Only the players who could pay for that pile can win it.",
                     mark: ["side pot"]),

        GlossaryTerm("stack",
                     "The chips you have in front of you. It is the most you can win or lose in a single hand.",
                     mark: ["stack"]),

        GlossaryTerm("short stack",
                     "Having few chips left compared with the blinds — around twelve big blinds or fewer. In a tournament there is no room to bet and then change your mind, so most decisions become all in or fold; at a cash table it just means a buy-in can be topped back up.",
                     mark: ["short stack", "short-stack"]),

        GlossaryTerm("all in",
                     "Putting every chip you have left into the middle. Nobody can push you off the hand after that, and you cannot lose more than you have already put in.",
                     also: ["all-in", "moving in"],
                     mark: ["all in", "all-in", "moving in", "moves in", "move in"]),

        GlossaryTerm("tournament",
                     "Everybody buys in for the same amount and plays until one person has all the chips, the blinds climbing as it goes and the last few players left sharing the prize money. In a cash game the blinds stay put and you leave whenever you like.",
                     mark: ["tournament"]),

        GlossaryTerm("cash game",
                     "Chips are worth their face value and you can get up whenever you like. The blinds never change, what you win stays in front of you from hand to hand, and if you lose the lot you buy back in.",
                     also: ["cash table", "cash games"],
                     mark: ["cash game", "cash table"]),

        // The cash table.

        GlossaryTerm("buy-in",
                     "What you sit down with at a cash table: a hundred big blinds here. It is yours to keep, win with, or lose.",
                     also: ["buy in", "buys back in", "bought back in", "rebuy", "rebuys"],
                     mark: ["buy-in", "buys back in", "bought back in"]),

        GlossaryTerm("cash out",
                     "Leaving a cash table with whatever is in front of you. The blinds never climb and nobody is knocked out, so the only end to a session is the one you choose.",
                     also: ["cashing out", "cashed out"],
                     mark: ["cash out", "cashing out"]),

        GlossaryTerm("session",
                     "One sitting at a cash table, from buying in to cashing out. What it came to is counted in big blinds, and in big blinds for every hundred hands, so sessions of different lengths can be compared.",
                     also: ["sessions"],
                     mark: []),

        GlossaryTerm("stakes",
                     "The size of the blinds at a cash table — 25/50 means a small blind of 25 and a big blind of 50 — and so the size of everything else, since the buy-in is a hundred big blinds.",
                     mark: ["stakes"]),

        GlossaryTerm("fixed-limit",
                     "A table where every bet is one set size: the big blind before the flop and on it, twice that on the turn and river. A bet and three raises end the raising for that round unless only two players are left in the hand.",
                     also: ["fixed limit", "limit"],
                     mark: ["fixed-limit"]),

        GlossaryTerm("pot-limit",
                     "A table where the most you can bet or raise is the size of the pot, counting your own call. It is bigger than it sounds: a raise of the pot after somebody has already raised it is twelve big blinds.",
                     also: ["pot limit"],
                     mark: ["pot-limit"]),

        GlossaryTerm("no-limit",
                     "A table where you can bet anything from the minimum up to everything you have in front of you, at any time.",
                     also: ["no limit"],
                     mark: ["no-limit"]),

        GlossaryTerm("capped",
                     "At a fixed-limit table, a round of betting that has had its bet and three raises. Nobody may raise again; the only choices left are to call or fold.",
                     also: ["cap", "the cap", "caps", "capping"],
                     mark: ["capped"]),

        GlossaryTerm("free card",
                     "At a fixed-limit table: raising on the flop when you are last to act, with a hand that needs one more card, so that everybody checks to you on the turn and you see that card without paying the bigger turn bet.",
                     also: ["free cards", "the free card"],
                     mark: ["free card"]),

        GlossaryTerm("cold call",
                     "Calling a raise before the flop when you have not bet yet yourself — a blind does not count — so the call costs two bets at once. At a fixed-limit table it is worth doing only with hands that play well against a raiser.",
                     also: ["cold-call", "cold-calling", "two bets cold"],
                     mark: ["cold call"]),

        GlossaryTerm("call down",
                     "Calling a bet on every street to the end rather than raising or folding — the right way to play a fair hand at a fixed-limit table when the pot is big and the bets are small.",
                     also: ["calling down", "called down"],
                     mark: ["call down"]),
    ]

    // MARK: - The statistics

    private static let statistics: [GlossaryTerm] = [
        GlossaryTerm("VPIP",
                     "How often you put money in before the shared cards come out, counted out of every hand dealt to you. Somebody playing a careful game sits near 18 hands in every 100.",
                     also: ["voluntarily put money in"],
                     mark: ["VPIP"]),

        GlossaryTerm("PFR",
                     "How often you are the one raising before the shared cards come out, out of every hand dealt to you. The gap between this and VPIP is how often you come in without raising.",
                     also: ["preflop raise"],
                     mark: ["PFR"]),

        GlossaryTerm("went to showdown",
                     "How often you are still there at the end with the cards turned over. Too high means you are paying to see too many endings; too low means you are giving up too easily.",
                     also: ["WTSD", "to showdown"],
                     mark: ["went to showdown", "to showdown"]),

        GlossaryTerm("won at showdown",
                     "How often you win the money once you do get to the end and the cards are turned over.",
                     mark: ["won at showdown"]),

        GlossaryTerm("bb/100",
                     "What you win or lose on average per hundred hands, counted in big blinds rather than chips so it can be compared between games of any size. Above zero means you are winning.",
                     also: ["big blinds per 100 hands", "per 100 hands", "bb per 100"],
                     mark: ["bb/100", "per 100 hands"]),

        GlossaryTerm("win rate",
                     "What you win or lose on average per hand, across a lot of hands. It takes a few hundred hands before it means anything at all, and a few thousand before it is firm.",
                     mark: ["win rate"]),
    ]

    // MARK: - What you can do

    private static let actions: [GlossaryTerm] = [
        GlossaryTerm("check", "Staying in the hand without putting money in. Only possible when nobody has bet yet this round.", mark: []),
        GlossaryTerm("bet", "Being the first to put money in on this round.", mark: []),
        GlossaryTerm("call", "Matching the bet in front of you so you can carry on with the hand.", mark: []),
        GlossaryTerm("raise", "Putting in more than the bet in front of you, so everybody else has to pay the higher amount or give up.", mark: []),
        GlossaryTerm("fold", "Giving up the hand. You lose whatever you have already put in and nothing more.", mark: []),

        GlossaryTerm("limp",
                     "Coming in for exactly the big blind instead of raising. It gives up the chance to win the money there and then, which is why the coach counts it as a mistake.",
                     also: ["limping", "limped"],
                     mark: ["limp", "limping", "limped"]),

        GlossaryTerm("three-bet",
                     "Raising somebody who has already raised. It is a strong statement, and it usually either wins the hand on the spot or leaves you one opponent.",
                     also: ["3-bet", "re-raise", "reraise"],
                     mark: ["three-bet", "re-raise"]),

        GlossaryTerm("check-raise",
                     "Checking first, then raising when somebody else bets. It wins more with a strong hand than betting straight out would.",
                     mark: ["check-raise"]),

        GlossaryTerm("continuation bet",
                     "Betting the flop after you were the one who raised before it. Most hands miss most flops, so it wins often enough to be worth doing with much more than your good hands.",
                     also: ["c-bet"],
                     mark: ["continuation bet"]),

        GlossaryTerm("value bet",
                     "Betting a hand you believe is the best one, so that a worse hand pays you off. The opposite of a bluff.",
                     mark: ["value bet"]),

        GlossaryTerm("bluff",
                     "Betting a hand that cannot win if it is called, so that a better one gives up. It only works against somebody capable of folding.",
                     mark: ["bluff", "bluffing"]),

        GlossaryTerm("steal",
                     "Raising from one of the last seats when only the blinds are left to speak, mainly to win what is already in the middle. A great deal of a good player's money comes from this.",
                     mark: ["steal", "stealing"]),

        GlossaryTerm("pot odds",
                     "What you are being asked to pay compared with what is already in the middle. Paying 100 to win 400 means you only need the best hand one time in five for the call to pay for itself.",
                     mark: ["pot odds"]),

        GlossaryTerm("outs",
                     "The cards still to come that would turn a losing hand into a winning one. Counting them is how you work out whether it is worth paying to see the next one.",
                     mark: ["outs"]),

        GlossaryTerm("draw",
                     "A hand that is worth nothing yet but would be very strong if the right card arrived. Chasing one is only worth it when the money in the middle pays for the cards you have to buy.",
                     mark: ["draw"]),
    ]

    // MARK: - What you can have

    private static let hands: [GlossaryTerm] = [
        GlossaryTerm("pocket pair", "Two cards of the same rank in your own hand, before any shared cards are out.", mark: ["pocket pair"]),
        GlossaryTerm("top pair",
                     "One of your cards has paired the highest card in the middle. A good hand, and one that plenty of things beat, which is what makes it awkward.",
                     also: ["top pair, weak kicker"], mark: ["top pair"]),
        GlossaryTerm("middle pair", "One of your cards has paired the middle card in the middle of the table. Usually worth one more card, rarely worth paying for three.", mark: ["middle pair"]),
        GlossaryTerm("bottom pair", "One of your cards has paired the lowest card in the middle. It is the weakest pair on offer and beats little except a missed hand.", mark: ["bottom pair"]),
        GlossaryTerm("overpair", "A pair in your own hand higher than every card in the middle. Strong, and worth betting.", mark: ["overpair"]),
        GlossaryTerm("underpair", "A pair in your own hand lower than the highest card in the middle. Anybody holding that card has you beaten.", mark: ["underpair"]),
        GlossaryTerm("two pair", "Two different pairs made from your cards and the ones in the middle.", mark: ["two pair"]),
        GlossaryTerm("trips", "Three of a kind made from one card of yours and a pair in the middle. Everybody can see two thirds of it, so it gets paid less than a set does.", mark: ["trips"]),
        GlossaryTerm("set", "Three of a kind made from a pocket pair of yours and one matching card in the middle. Nobody can see it coming, which is what makes it the most profitable hand in poker.", mark: []),
        GlossaryTerm("straight", "Five cards in a run, such as 5-6-7-8-9, in any suits.", mark: ["straight"]),
        GlossaryTerm("flush", "Five cards of the same suit.", mark: ["flush"]),
        GlossaryTerm("full house", "Three of a kind and a pair together.", mark: ["full house"]),
        GlossaryTerm("four of a kind", "All four cards of the same rank. It very nearly never loses.", also: ["quads"], mark: ["four of a kind", "quads"]),
        GlossaryTerm("straight flush", "Five cards of the same suit in a run — the best hand there is.", mark: ["straight flush"]),
        GlossaryTerm("the nuts", "The best hand possible on these cards. Nothing anybody else can be holding beats it.", also: ["near nuts"], mark: ["the nuts", "nuts"]),
        GlossaryTerm("kicker", "The side card that settles it when two players hold the same pair. Ace-king and ace-two both make a pair of aces; the king decides who wins.", mark: ["kicker"]),
        GlossaryTerm("ace high", "No pair at all, with an ace as your highest card. It beats other missed hands and nothing else.", mark: ["ace high"]),
        GlossaryTerm("no pair", "Your two cards have paired nothing and made nothing. Most hands finish here, which is why so many of them are won by whoever bets.", mark: ["no pair"]),

        GlossaryTerm("flush draw", "Four cards of the same suit, needing one more. It arrives about one time in three by the last card.", mark: ["flush draw"]),
        GlossaryTerm("open-ended straight draw", "Four cards in a run needing one more at either end, so eight different cards finish it.", also: ["open ended straight draw"], mark: ["open-ended straight draw"]),
        GlossaryTerm("gutshot", "Four cards towards a run with the gap in the middle. Only four cards finish it, so it is half as likely as one open at both ends.", mark: ["gutshot"]),
        GlossaryTerm("backdoor draw", "A hand that would need both of the last two cards to come good. Worth very little on its own.", mark: ["backdoor draw", "backdoor"]),
        GlossaryTerm("overcards", "Cards in your hand higher than anything in the middle. You have nothing yet, but any pair you made would be the top one.", also: ["two overcards"], mark: ["overcards"]),
        GlossaryTerm("combination draw", "A hand chasing two things at once, a suit and a run. It misses far less often than either on its own.", also: ["combo draw"], mark: ["combination draw"]),
    ]

    // MARK: - The people

    private static let styles: [GlossaryTerm] = [
        GlossaryTerm("tight", "Playing few hands and waiting for good ones.", mark: ["tight"]),
        GlossaryTerm("loose", "Playing a lot of hands, weak ones included.", mark: ["loose"]),
        GlossaryTerm("passive", "Checking and calling rather than betting and raising.", mark: ["passive"]),
        GlossaryTerm("aggressive", "Betting and raising rather than checking and calling. With the right hands it is how the money is made.", mark: ["aggressive"]),
        GlossaryTerm("calling station", "Somebody who calls far too often and hardly ever raises. Never bluff one; bet every decent hand against them.", mark: ["calling station"]),
        GlossaryTerm("nit", "Somebody who plays only the very best hands. Believe them when they put money in, and take their blinds while they wait.", mark: ["nit"]),
        GlossaryTerm("maniac", "Somebody who bets and raises constantly with almost anything. Wait for a hand and let them bet into you.", mark: ["maniac"]),
        GlossaryTerm("regular", "Somebody who plays often and knows what they are doing. Free money is rare against one.", mark: ["regular"]),
    ]

    // MARK: - Looking a word up

    /// The lookup is forgiving on purpose: it is fed labels written for people — "the button",
    /// "Top pair, weak kicker", "WTSD" — not identifiers.
    private static let index: [String: GlossaryTerm] = {
        var index: [String: GlossaryTerm] = [:]
        for term in terms {
            index[term.id] = term
            for name in term.otherNames { index[normalise(name)] = term }
        }
        return index
    }()

    public static func look(up word: String) -> GlossaryTerm? { index[normalise(word)] }

    /// Lowercase, single-spaced and without a leading article, so "The Button" and "button" are one
    /// entry rather than two.
    static func normalise(_ word: String) -> String {
        var text = word.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
        text = text.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" }).joined(separator: " ")
        for article in ["the ", "a ", "an "] where text.hasPrefix(article) {
            text = String(text.dropFirst(article.count))
            break
        }
        return text
    }

    /// Alphabetical, by the word rather than by the article in front of it.
    public static var alphabetical: [GlossaryTerm] { terms.sorted { $0.id < $1.id } }

    public static func term(forLink url: URL) -> GlossaryTerm? {
        terms.first { $0.link == url }
    }

    /// The terms a screen hands to a tappable label by name. Listed here so a renamed entry cannot
    /// quietly leave a word underlined on screen with nothing behind it — a test walks this list.
    public static let screenTerms = ["VPIP", "PFR", "went to showdown", "won at showdown",
                                     "bb/100", "the board", "position"]

    // MARK: - Finding the words in a paragraph

    public struct Mention: Sendable {
        public let term: GlossaryTerm
        public let range: Range<String.Index>
    }

    /// Where each glossary word first appears in a block of copy, earliest first and once each.
    ///
    /// Once each on purpose: a paragraph with every occurrence underlined is a paragraph nobody can
    /// read. The first one carries the definition and the rest are left as writing.
    public static func mentions(in text: String) -> [Mention] {
        var candidates: [(term: GlossaryTerm, range: Range<String.Index>)] = []
        for term in terms {
            for phrase in term.marks {
                for spelling in [phrase, phrase.hasSuffix("s") ? nil : phrase + "s"].compactMap({ $0 }) {
                    for range in wholeWordRanges(of: spelling, in: text) {
                        candidates.append((term, range))
                    }
                }
            }
        }
        // Earliest first, and the longest phrase where two start together, so "big blind" wins over
        // the "blind" inside it.
        candidates.sort { a, b in
            if a.range.lowerBound != b.range.lowerBound { return a.range.lowerBound < b.range.lowerBound }
            return text.distance(from: a.range.lowerBound, to: a.range.upperBound)
                 > text.distance(from: b.range.lowerBound, to: b.range.upperBound)
        }

        var picked: [Mention] = []
        var used = Set<String>()
        var reached = text.startIndex
        for candidate in candidates {
            guard candidate.range.lowerBound >= reached else { continue }
            guard !used.contains(candidate.term.id) else { continue }
            used.insert(candidate.term.id)
            picked.append(Mention(term: candidate.term, range: candidate.range))
            reached = candidate.range.upperBound
        }
        return picked
    }

    /// Whole words only: "bet" must not light up inside "better", and "nit" must not light up
    /// inside "definite".
    private static func wholeWordRanges(of phrase: String, in text: String) -> [Range<String.Index>] {
        var found: [Range<String.Index>] = []
        var searchFrom = text.startIndex
        while searchFrom < text.endIndex,
              let range = text.range(of: phrase, options: [.caseInsensitive], range: searchFrom..<text.endIndex) {
            searchFrom = range.upperBound
            let before = range.lowerBound == text.startIndex ? nil : text[text.index(before: range.lowerBound)]
            let after = range.upperBound == text.endIndex ? nil : text[range.upperBound]
            let isBoundary = { (character: Character?) in
                guard let character else { return true }
                return !(character.isLetter || character.isNumber)
            }
            if isBoundary(before), isBoundary(after) { found.append(range) }
            if found.count >= 8 { break }
        }
        return found
    }

    // MARK: - The words that are not allowed in

    /// Jargon that has to be explained or kept out. Words on this list with no entry above are
    /// simply banned from anything the player reads: "range", "fold equity" and the rest are the
    /// vocabulary this app exists to do without. A test checks the coach's copy and every
    /// definition in here against it, so the ban holds by construction rather than by memory.
    public static let jargonWatchlist = [
        "range", "ranges", "orbit", "orbits", "initiative", "fold equity", "in position",
        "out of position", "polarised", "polarized", "shove", "shoves", "shoving", "shoved",
        "heads-up", "heads up", "equity", "ICM", "GTO", "villain", "blocker", "barrel",
        "overbet", "squeeze", "isolate", "float", "donk bet", "wet board", "dry board", "sharp",
        "sharper", "sharpest",
        // The position words. "position" itself has an entry and is deliberately never underlined
        // in prose, which meant "late-position" sailed through the sweep that took "range" and
        // "shove" out of the drills — `unexplained` matches whole phrases, and "in position" does
        // not match inside "late-position".
        "late position", "late-position", "early position", "early-position",
        // "Wide" and "deep" are the two that keep coming back, because they read as ordinary
        // English while carrying a poker meaning: "wide" here means "with too many hands" and
        // "deep" means "with a lot of chips", and nothing has taught the reader either one.
        "wide", "too wide", "deep",
    ]

    /// Which watched words appear in a piece of copy without an entry behind them. Empty is the
    /// only acceptable answer for anything the player reads.
    public static func unexplained(in text: String) -> [String] {
        jargonWatchlist.filter { word in
            look(up: word) == nil && !wholeWordRanges(of: word, in: text).isEmpty
        }
    }
}
