"""Acesso ao SQL Server (banco Rosh).

Autenticacao do Windows (Trusted_Connection): nenhuma senha fica em arquivo.

Regra de ouro daqui: o banco NUNCA pode derrubar o jogo. Se o SQL Server estiver fora do ar,
todas as funcoes falham em silencio (avisando no log) e o servidor continua rodando com o
estado em memoria. Perder progresso e ruim; derrubar o jogador no meio de uma luta e pior.
"""
from __future__ import annotations

import hashlib
import hmac
import secrets
import struct
import datetime as _dt
import math as _math
import random as _random
import threading

import data_files

try:
    import pyodbc
except ImportError:      # pyodbc ausente: o servidor roda sem persistencia
    pyodbc = None

CONEXAO = ("DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost;"
           "DATABASE=Rosh;Trusted_Connection=yes;TrustServerCertificate=yes")

_lock = threading.Lock()
_con = None
_avisou_falha = False


def _conectar():
    """Devolve uma conexao viva, reconectando se preciso. None se nao der."""
    global _con, _avisou_falha
    if pyodbc is None:
        return None
    if _con is not None:
        try:
            _con.cursor().execute("SELECT 1").fetchone()
            return _con
        except Exception:
            try:
                _con.close()
            except Exception:
                pass
            _con = None
    try:
        _con = pyodbc.connect(CONEXAO, timeout=5, autocommit=True)
        _avisou_falha = False
        return _con
    except Exception as e:
        if not _avisou_falha:
            print(f"[db] sem banco ({str(e)[:120]}) -- seguindo sem persistencia")
            _avisou_falha = True
        return None


def disponivel() -> bool:
    with _lock:
        return _conectar() is not None


def garantir_conta(usuario: str, ip: str | None = None) -> int | None:
    """Cria a conta se nao existir e marca o login. Devolve o conta_id."""
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT conta_id FROM dbo.conta WHERE usuario = ?", usuario)
            linha = cur.fetchone()
            if linha is None:
                cur.execute(
                    "INSERT INTO dbo.conta (usuario, ultimo_login_em, ultimo_ip) "
                    "OUTPUT INSERTED.conta_id VALUES (?, SYSUTCDATETIME(), ?)", usuario, ip)
                return cur.fetchone()[0]
            cur.execute("UPDATE dbo.conta SET ultimo_login_em = SYSUTCDATETIME(), "
                        "ultimo_ip = ? WHERE conta_id = ?", ip, linha[0])
            return linha[0]
        except Exception as e:
            print(f"[db] garantir_conta falhou: {str(e)[:150]}")
            return None


def garantir_personagem(entity_id: int, nome: str, conta_id: int | None = None) -> bool:
    """Cria a linha do personagem se ainda nao existir."""
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("SELECT 1 FROM dbo.personagem WHERE entity_id = ?", entity_id)
            if cur.fetchone() is not None:
                return True
            if conta_id is None:
                cur.execute("SELECT TOP 1 conta_id FROM dbo.conta ORDER BY conta_id")
                linha = cur.fetchone()
                if linha is None:
                    cur.execute("INSERT INTO dbo.conta (usuario) OUTPUT INSERTED.conta_id "
                                "VALUES (?)", "sistema")
                    conta_id = cur.fetchone()[0]
                else:
                    conta_id = linha[0]
            cur.execute("INSERT INTO dbo.personagem (conta_id, entity_id, nome) VALUES (?, ?, ?)",
                        conta_id, entity_id, nome)
            print(f"[db] personagem {entity_id} ({nome}) criado")
            return True
        except Exception as e:
            print(f"[db] garantir_personagem falhou: {str(e)[:150]}")
            return False


# `pontos_skill` entrou em 29/08. Nao era guardado em lado nenhum: o valor vinha do molde do
# 02 05 e ficava cravado em 1, o mesmo para toda a gente. O jogador viu -- "to lv 6 era pra
# ter 5 pontos".
CAMPOS = ("nivel", "exp", "hp", "hp_max", "mp", "mp_max", "pontos_skill",
          "fletta_exp", "fletta_pontos",
          "campo43", "reputacao", "contador54")


def carregar_personagem(entity_id: int) -> dict | None:
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute(f"SELECT {', '.join(CAMPOS)} FROM dbo.personagem WHERE entity_id = ?",
                        entity_id)
            linha = cur.fetchone()
            if linha is None:
                return None
            return dict(zip(CAMPOS, (int(v) for v in linha)))
        except Exception as e:
            print(f"[db] carregar_personagem falhou: {str(e)[:150]}")
            return None


OFF_VALIDADE_BRUTO = 56
"""Offset da validade no registo de 147 bytes: LE u32, segundos Unix.

DECIFRADO em 29/08 comparando o registo de uma fantasia real do oficial com o que o
`dar_item` produzia. O oficial trazia 6a98ac21 e nos zero, e a conta fechou: era exactamente
7 dias (604.800 s) depois do campo [12:16], que e a data de aquisicao.

ZERO E LIDO PELO CLIENT COMO EXPIRADO. Foi assim que se percebeu: a fantasia que o servidor
dava ficava com zero aqui e o client mostrava-a na boneca da janela mas nao no personagem.
Pondo uma data futura, apareceu.
"""


# Os dois offsets da DURACAO dentro dos 156 bytes do itemBase. Sao campos SEPARADOS e
# nenhum item do catalogo tem os dois preenchidos -- medido nos 19806:
#
#     @120  40 itens    86400 / 43200 / 21600     as pecas de 6, 12 e 24 HORAS
#     @124  7636 itens  2592000 / 604800 / ...    as de 30, 7, 14, 3 dias
#
# Os nomes confirmam os dois lados ("O lobo Sagrado (6 horas)" tem 21600 em @120; "Terno
# para banquete" tem 604800 em @124). O importador so lia o @124, e por isso as "Asas de
# anjo (24 horas)" chegavam ao jogador com duracao zero -- nasciam sem validade e o client
# marcava-as "Expirou." de imediato.
#
# Nao se sabe o que distingue os dois campos; sabe-se que sao exclusivos, e por isso vale o
# que estiver preenchido.
OFF_DURACAO_A = 120
OFF_DURACAO_B = 124
# TEMPO RESTANTE, em SEGUNDOS, DOUBLE LE nos 147 bytes. So os itens de @120 o usam.
#
# E A TERCEIRA VEZ QUE UM DOUBLE DESALINHADO ME ENGANA neste projecto -- as outras duas
# foram no skillLevel e no skillData. Aqui li um float em [52] e vi 5,094 num campo que era
# a metade alta do double em [48]. O erro nao foi silencioso: escrevi 24,0 como float em
# [52], o que da o double 2^29 = 536.870.912 segundos, e o client mostrou "6213 dias 18 h
# 47 min" -- que e exactamente isso. A conta bate ao segundo, e foi ela que identificou o
# campo.
OFF_TEMPO_RESTANTE = 48


def duracao_do_item(item_id: int, so_horas: bool = False) -> int:
    """Duracao em segundos de um item temporario. Zero para os permanentes.

    Com `so_horas`, devolve apenas a duracao do campo @120 -- a familia que conta em horas e
    guarda o restante num float, em vez de uma data de validade.
    """
    ficha = data_files.indice("Item", "item_id").get(int(item_id)) or {}
    campos = ficha.get("campos")
    if isinstance(campos, str) and len(campos) >= (OFF_DURACAO_B + 4) * 2:
        try:
            b = bytes.fromhex(campos)
        except ValueError:
            return int(ficha.get("duracao_seg") or 0)
        for off in ((OFF_DURACAO_A,) if so_horas else (OFF_DURACAO_A, OFF_DURACAO_B)):
            v = struct.unpack_from("<I", b, off)[0]
            if 0 < v < 10 ** 9:
                return v
    return 0 if so_horas else int(ficha.get("duracao_seg") or 0)


def _tempo_agora() -> float:
    import time as _t
    return _t.time()


def segundos_restantes(item_id: int, bruto) -> float | None:
    """Quantos segundos faltam a um item temporario. None se for permanente.

    As duas familias contam de maneira diferente e por isso a conta e feita aqui, num sitio
    so -- ver duracao_do_item e OFF_TEMPO_RESTANTE:

        @120 (horas)  duracao - (agora - aquisicao)    o double desce em tempo real
        @124 (dias)   validade - agora                 data absoluta

    Negativo quer dizer expirado; quem chama decide o que fazer com isso.
    """
    b = bytes(bruto or b"")
    if len(b) < OFF_VALIDADE_BRUTO + 4:
        return None
    agora = int(_tempo_agora())
    horas = duracao_do_item(item_id, so_horas=True)
    if horas:
        aq = struct.unpack_from("<I", b, 12)[0]
        if not aq:
            return None
        return float(horas) - (agora - aq)
    val = struct.unpack_from("<I", b, OFF_VALIDADE_BRUTO)[0]
    if val:
        return float(val - agora)
    return None


def apagar_itens_expirados(entity_id: int) -> list:
    """Apaga os itens cuja validade ja passou. Devolve OS ITENS que saiu, nao a contagem.

    DEVOLVE OS ITENS de proposito: quem chama tem de avisar o client, um a um, com o 06 06
    de remocao. Enquanto isto devolvia so um numero, a linha saia do banco e o item ficava na
    tela ate ao relogin -- o mesmo defeito que o apagar a mao tinha.

    O jogador descreveu a regra: "mostra que ta expirando, quando expira, o item e deletado".

    SO APAGA QUEM TEM VALIDADE. Item com zero fica -- e verdade que o client mostra zero como
    expirado, mas quase todo o nosso inventario tem zero por o `dar_item` nunca ter preenchido
    o campo, e apagar por essa regra levaria a armadura e a arma do jogador com ele. Quando o
    dar_item passar a datar tudo, isto pode passar a tratar o zero como expirado.
    """
    import struct as _st
    import time as _t
    agora = int(_t.time())
    fora = []
    for it in inventario_do_personagem(entity_id) or ():
        b = bytes(it.get("bruto") or b"")
        if len(b) < OFF_VALIDADE_BRUTO + 4:
            continue
        # AS DUAS FAMILIAS, pela mesma conta. Antes so olhava a validade, entao um item de
        # horas nunca saia -- o jogador disse que no oficial "ja some dentro do jogo mesmo,
        # do mesmo jeito que quando deleto um item".
        resta = segundos_restantes(it.get("item_id"), b)
        if resta is not None and resta <= 0:
            fora.append((it, agora))
    if not fora:
        return []
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            for it, fim in fora:
                cur.execute("DELETE FROM dbo.personagem_item WHERE personagem_item_id = ?",
                            it.get("instancia"))
                print(f"[db] item {it.get('item_id')} expirou em {fim} -- apagado")
            con.commit()
            return [it for it, _f in fora]
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] apagar_itens_expirados falhou: {str(e)[:120]}")
            return []


def _requisitos_de_skill():
    """{skill_id: [(nivel_da_skill, nivel_do_personagem), ...]} ordenado, do SkillLod."""
    saida = {}
    for l in data_files.tabela("SkillLod"):
        try:
            s, nv, rq = int(l["skill_id"]), int(l["nivel"]), int(l["nivel_req"])
        except (KeyError, TypeError, ValueError):
            continue
        saida.setdefault(s, []).append((nv, rq))
    for s in saida:
        saida[s].sort()
    return saida


def buff_da_skill(skill_id: int, nivel: int):
    """(duracao_em_segundos, [(nome_interno, valor, unidade)]) de uma skill com duracao.

    A unidade e "abs" ou "%". Uma mesma skill pode dar as duas coisas no mesmo atributo.

    Devolve (0, []) quando a skill nao dura ou quando nenhum dos efeitos dela e de um tipo
    que o servidor sabe aplicar.

    O `tipo` do SkillBuff.ini e o mesmo switch dos bonus de item, portanto reaproveita-se o
    TIPO_DE_BONUS. Os que ficam de fora sao os mesmos de sempre -- PK, arena, e agora
    tambem os de CONTROLO (85, 86, 87: impossibilidade de mover, atacar e usar objectos),
    que precisam de mexer no movimento e no ataque e nao apenas somar um numero.
    """
    dur = 0.0
    efeitos = []
    for l in data_files.tabela("SkillBuff"):
        try:
            if int(l["skill_id"]) != int(skill_id) or int(l["nivel"]) != int(nivel):
                continue
            dur = max(dur, float(l["duracao"] or 0))
            tipo = int(l["tipo"] or 0)
            valor, pct = float(l["valor"] or 0), float(l["valor2"] or 0)
        except (KeyError, TypeError, ValueError):
            continue
        nome = TIPO_DE_BONUS.get(tipo)
        if not nome:
            continue
        # SAI A UNIDADE JUNTO, e por uma razao medida: 1400 das 3739 linhas de efeito com
        # tipo conhecido tem o numero na coluna da PERCENTAGEM e zero na do valor plano.
        # Ate 30/08 so se lia a plana, portanto essas 1400 valiam zero -- e nao havia
        # sequer um aviso no log. Um debuff de "-10% de velocidade de ataque" nao fazia nada.
        if valor:
            efeitos.append((nome, valor, "abs"))
        if pct:
            efeitos.append((nome, pct, "%"))
    return (dur, efeitos)


def guardar_buff(entity_id: int, skill_id: int, nivel: int, restante: float) -> None:
    """Grava um buff activo com os SEGUNDOS QUE FALTAM, nao com o instante em que acaba.

    O RELOGIO PARA ENQUANTO O JOGADOR ESTA FORA. A primeira versao guardava `acaba_em`, um
    instante Unix, e por isso o tempo de parede continuava a comer o buff com o jogador
    desligado -- quem saisse com dez minutos de buff e voltasse no dia seguinte nao tinha
    nada. O jogador disse como e: "a contagem nao para quando estou deslogado, tinha que
    pausar, e voltar da onde parou quando eu logasse".

    Guardar o que FALTA em vez de QUANDO ACABA e o que torna a pausa possivel: escreve-se ao
    lancar (a duracao cheia) e outra vez ao sair (o que sobrou) -- ver entity_sim.pausar_buffs.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            cur = con.cursor()
            cur.execute("UPDATE dbo.personagem_buff SET nivel = ?, restante_seg = ? "
                        "WHERE entity_id = ? AND skill_id = ?",
                        int(nivel), int(restante), int(entity_id), int(skill_id))
            if cur.rowcount == 0:
                cur.execute("INSERT INTO dbo.personagem_buff "
                            "(entity_id, skill_id, nivel, restante_seg) VALUES (?, ?, ?, ?)",
                            int(entity_id), int(skill_id), int(nivel), int(restante))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] guardar_buff falhou: {str(e)[:120]}")


def apagar_buff(entity_id: int, skill_id: int) -> None:
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            con.cursor().execute("DELETE FROM dbo.personagem_buff "
                                 "WHERE entity_id = ? AND skill_id = ?",
                                 int(entity_id), int(skill_id))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] apagar_buff falhou: {str(e)[:120]}")


def buffs_activos(entity_id: int) -> list:
    """[(skill_id, nivel, segundos_que_faltam)]. Limpa os que ja nao tem tempo.

    NAO SE COMPARA COM O RELOGIO: o que esta guardado e uma duracao que ficou parada desde
    que o jogador saiu, nao um prazo. Ver guardar_buff.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            cur.execute("DELETE FROM dbo.personagem_buff "
                        "WHERE entity_id = ? AND restante_seg <= 0", int(entity_id))
            cur.execute("SELECT skill_id, nivel, restante_seg FROM dbo.personagem_buff "
                        "WHERE entity_id = ?", int(entity_id))
            return [(int(a), int(b), int(c)) for a, b, c in cur.fetchall()]
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] buffs_activos falhou: {str(e)[:120]}")
            return []


def guardar_buff_item(entity_id: int, item_id: int, pct: float, restante: int) -> None:
    """Grava um buff de ITEM (pocao) com os SEGUNDOS QUE FALTAM.

    Mesmo desenho do guardar_buff, e pela mesma razao: o jogador pediu que "qualquer tipo de
    buff que fica ali" volte no mesmo estado ao logar, como ja acontecia com as habilidades.
    Guardar o que FALTA -- e nao quando acaba -- e o que faz o relogio parar enquanto ele
    esta fora.

    O `pct` vai junto porque o buff de item nao tem skill de onde o reler: quem usou uma
    pocao de 60% guardou 60, e e esse numero que tem de voltar.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            cur = con.cursor()
            cur.execute("UPDATE dbo.personagem_buff_item SET pct = ?, restante_seg = ? "
                        "WHERE entity_id = ? AND item_id = ?",
                        float(pct), int(restante), int(entity_id), int(item_id))
            if cur.rowcount == 0:
                cur.execute("INSERT INTO dbo.personagem_buff_item "
                            "(entity_id, item_id, pct, restante_seg) VALUES (?, ?, ?, ?)",
                            int(entity_id), int(item_id), float(pct), int(restante))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] guardar_buff_item falhou: {str(e)[:120]}")


def apagar_buff_item(entity_id: int, item_id: int) -> None:
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            con.cursor().execute("DELETE FROM dbo.personagem_buff_item "
                                 "WHERE entity_id = ? AND item_id = ?",
                                 int(entity_id), int(item_id))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] apagar_buff_item falhou: {str(e)[:120]}")


def buffs_item_activos(entity_id: int) -> list:
    """[(item_id, pct, segundos_que_faltam)]. Limpa os que ja nao tem tempo."""
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            cur.execute("DELETE FROM dbo.personagem_buff_item "
                        "WHERE entity_id = ? AND restante_seg <= 0", int(entity_id))
            cur.execute("SELECT item_id, pct, restante_seg FROM dbo.personagem_buff_item "
                        "WHERE entity_id = ?", int(entity_id))
            return [(int(a), float(b), int(c)) for a, b, c in cur.fetchall()]
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] buffs_item_activos falhou: {str(e)[:120]}")
            return []


def periodo_da_skill(skill_id: int, nivel: int) -> float:
    """De quantos em quantos segundos o efeito volta a bater. Zero = continuo, sem tique.

    A Corrosao (11002) tem duracao 10 e periodo 2, e o tooltip do client confirma-o ao lado
    de "Saude -5": sao CINCO tiques de -5, nao um -5 solto. Os buffs tem periodo zero.
    """
    for linha in data_files.agrupar("SkillBuff", "skill_id").get(int(skill_id), ()):
        if int(linha.get("nivel") or 0) == int(nivel):
            try:
                return float(linha.get("periodo") or 0.0)
            except (TypeError, ValueError):
                return 0.0
    return 0.0


def bonus_das_passivas(entity_id: int) -> dict:
    """{nome_interno: (absoluto, percentual)} das skills PASSIVAS aprendidas.

    Passiva e a skill com duracao ZERO que mesmo assim tem efeito -- a cadeia e a mesma dos
    buffs, so nao tem prazo. O jogador mandou a dica da "Habilidade de evasao" (5003): "Tipo
    de habilidade: passiva", "Destreza +2,0", e a ficha dele mostra "Destreza 13 (+2)".

    Vale o nivel a que ELE tem a skill: 5003 da +2, +3, +4, +5, +6 do nivel 1 ao 5.

    Devolve na mesma forma do bonus_do_equipamento para poder somar-se-lhe sem conversao --
    e por isso o "(+2)" aparece na ficha sem mais nada ser mexido.
    """
    saida = {}
    tem = skills_do_personagem(entity_id)
    if not tem:
        return saida
    for l in data_files.tabela("SkillBuff"):
        try:
            s = int(l["skill_id"])
            if tem.get(s) != int(l["nivel"]) or float(l["duracao"] or 0) != 0.0:
                continue
            nome = TIPO_DE_BONUS.get(int(l["tipo"] or 0))
            valor = float(l["valor"] or 0)
            pct = float(l["valor2"] or 0)
        except (KeyError, TypeError, ValueError):
            continue
        if not nome:
            continue
        # A COLUNA DA PERCENTAGEM CONTAVA ZERO. Ate 30/08 lia-se so o `valor`, e as 1400
        # linhas de efeito que so tem `valor2` -- 37 por cento do total -- eram deitadas
        # fora em silencio: velocidade de ataque, velocidade de movimento, poder de ataque,
        # saude, proteccao. Uma passiva de "+5% de saude" nao somava nada e nao havia aviso.
        if valor or pct:
            a, pc = saida.get(nome, (0.0, 0.0))
            saida[nome] = (a + valor, pc + pct)
    return saida


def skills_do_personagem(entity_id: int) -> dict:
    """{skill_id: nivel} do que este personagem tem aprendido."""
    with _lock:
        con = _conectar()
        if con is None:
            return {}
        try:
            cur = con.cursor()
            cur.execute("SELECT skill_id, nivel FROM dbo.personagem_skill "
                        "WHERE entity_id = ?", int(entity_id))
            return {int(a): int(b) for a, b in cur.fetchall()}
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] skills_do_personagem falhou: {str(e)[:120]}")
            return {}


def skills_disponiveis(classe: int, nivel: int, tem: dict) -> dict:
    """{skill_id: nivel_maximo_permitido} -- o que o personagem PODE aprender ou evoluir.

    O NIVEL LIBERTA, O PONTO APRENDE. Foi o jogador que o disse -- "o nivel libera a skill,
    mas eu tenho que clicar pra aprender ou evoluir" -- e o fio confirma-o ao pormenor:

        62202,81  pontos 2 -> 1     gastou
        62204,17  pontos 1 -> 0     gastou outra vez
        62231,16  05 05 n=18 -> 20  duas skills novas

    Eu tinha lido as capturas ao contrario: via a lista crescer no mesmo instante em que o
    personagem subia de nivel e concluia que o nivel dava a skill. Dava a DISPONIBILIDADE; o
    jogador carregava no botao logo a seguir, e as duas coisas caiam no mesmo segundo.
    """
    req = _requisitos_de_skill()
    arv = [int(l["skill_id"]) for l in
           data_files.agrupar("SkillArvore", "classe").get(int(classe), ())]
    pre = _prerrequisitos()
    nivel = max(1, int(nivel or 1))
    saida = {}
    for s in arv:
        if any(tem.get(r, 0) < n for r, n in pre.get(s, ())):
            continue                      # falta a skill-mae
        alcancados = [nv for nv, rq in req.get(s, [(1, 1)]) if rq <= nivel]
        if alcancados:
            saida[s] = max(alcancados)
    return saida


def _prerrequisitos():
    """{skill: [(skill_mae, nivel_dela)]}, da calculadora oficial."""
    pre = {}
    for l in data_files.tabela("SkillArvoreWeb"):
        for cs, cn in (("req1", "req1nv"), ("req2", "req2nv")):
            try:
                s, r, n = int(l["skill"]), int(l[cs]), int(l[cn])
            except (KeyError, TypeError, ValueError):
                continue
            if r:
                pre.setdefault(s, []).append((r, max(1, n)))
    return pre


def _skill_raiz(classe: int) -> int:
    """A skill com que a classe comeca. `gt = 1` marca-a na calculadora oficial."""
    for l in data_files.tabela("SkillArvoreWeb"):
        try:
            if int(l["classe"]) == int(classe) and int(l["gt"]) == 1:
                return int(l["skill"])
        except (KeyError, TypeError, ValueError):
            continue
    arv = [int(l["skill_id"]) for l in
           data_files.agrupar("SkillArvore", "classe").get(int(classe), ())]
    return arv[0] if arv else 0


def sincronizar_skills(entity_id: int, classe: int, nivel: int) -> dict:
    """Garante a skill INICIAL da classe e devolve {skill_id: nivel} do que ele tem.

    NAO DA MAIS NADA. Cheguei a dar aqui tudo o que o nivel alcancava, e o jogador viu logo:
    "vc libero as skill sem clica pra liberar" e depois "ta como se eu tivesse usando 3 ponto
    de skill". O resto vem do 05 06, que e o clique.

    A skill inicial e a que tem `gt = 1` na calculadora oficial -- na captura da Arqueira o
    personagem entra com UMA skill de classe na lista, e e essa.
    """
    tem = skills_do_personagem(entity_id)
    raiz = _skill_raiz(classe)
    if raiz and raiz not in tem:
        with _lock:
            con = _conectar()
            if con is not None:
                try:
                    con.cursor().execute(
                        "INSERT INTO dbo.personagem_skill (entity_id, skill_id, nivel) "
                        "VALUES (?, ?, 1)", int(entity_id), raiz)
                    print("[db] skill inicial da classe %d dada a %d: %d"
                          % (classe, entity_id, raiz))
                    tem[raiz] = 1
                except Exception as e:                              # noqa: BLE001
                    print(f"[db] skill inicial falhou: {str(e)[:120]}")
    arv = {int(l["skill_id"]) for l in
           data_files.agrupar("SkillArvore", "classe").get(int(classe), ())}
    return {s: n for s, n in tem.items() if s in arv}


def aprender_skill(entity_id: int, classe: int, nivel: int, skill_id: int) -> tuple:
    """Gasta um ponto para aprender ou evoluir uma skill. Devolve (ok, motivo, pontos).

    Um ponto por nivel -- medido: cada 05 06 fez os pontos descerem exactamente 1 e a soma
    dos niveis do 05 05 subir exactamente 1.
    """
    tem = skills_do_personagem(entity_id)
    disp = skills_disponiveis(classe, nivel, tem)
    s = int(skill_id)
    atual = tem.get(s, 0)
    if s not in disp:
        return (False, "skill %d nao disponivel no nivel %d" % (s, nivel), None)
    if atual >= disp[s]:
        return (False, "skill %d ja esta no maximo que o nivel %d permite (%d)"
                % (s, nivel, disp[s]), None)
    with _lock:
        con = _conectar()
        if con is None:
            return (False, "sem banco", None)
        try:
            cur = con.cursor()
            cur.execute("SELECT pontos_skill FROM dbo.personagem WHERE entity_id = ?",
                        int(entity_id))
            r = cur.fetchone()
            pontos = int(r[0] or 0) if r else 0
            if pontos < 1:
                return (False, "sem pontos de habilidade", pontos)
            if atual:
                cur.execute("UPDATE dbo.personagem_skill SET nivel = ? "
                            "WHERE entity_id = ? AND skill_id = ?",
                            atual + 1, int(entity_id), s)
            else:
                cur.execute("INSERT INTO dbo.personagem_skill (entity_id, skill_id, nivel) "
                            "VALUES (?, ?, 1)", int(entity_id), s)
            cur.execute("UPDATE dbo.personagem SET pontos_skill = ? WHERE entity_id = ?",
                        pontos - 1, int(entity_id))
            print("[db] %d aprendeu a skill %d (nivel %d -> %d), pontos %d -> %d"
                  % (entity_id, s, atual, atual + 1, pontos, pontos - 1))
            return (True, None, pontos - 1)
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] aprender_skill falhou: {str(e)[:120]}")
            return (False, str(e)[:80], None)


def registar_entrada(entity_id: int) -> tuple:
    """Marca hoje na assiduidade do mes e devolve (consecutivas, [dias do mes]).

    E O QUE ALIMENTA A JANELA "Premios de entrada", pelo 0a 0a e pelo 0a 0b. O formato do
    pacote foi decifrado em 30/08 a partir de duas capturas do oficial:

        [6:10]  BE  relogio unix
        [10:14] BE  entradas CONSECUTIVAS
        [14:18] BE  entradas do MES
        [18:]       um byte por dia do mes em que se entrou

    Confere-se sozinho: o valor em [14:18] e sempre o comprimento da lista de dias. Numa
    captura deu (1, 2) com os dias 27 e 29; noutra (1, 1) com o dia 30.

    ISTO ESTAVA CRAVADO. Quando liguei o 0a 0a copiei os bytes de uma captura sem os
    perceber, e o servidor anunciava a toda a gente a assiduidade da personagem capturada --
    dois dias, 27 e 29. O jogador viu-os na janela e perguntou se era molde.

    As consecutivas contam-se para tras a partir de hoje, e param no primeiro buraco.
    """
    import time as _t
    hoje = _t.localtime()
    mes = hoje.tm_year * 100 + hoje.tm_mon
    dia = hoje.tm_mday
    with _lock:
        con = _conectar()
        if con is None:
            return (1, [dia])
        try:
            cur = con.cursor()
            cur.execute("SELECT entrada_mes, entrada_dias FROM dbo.personagem "
                        "WHERE entity_id = ?", int(entity_id))
            r = cur.fetchone()
            guardado_mes = int(r[0]) if r and r[0] else 0
            texto = str(r[1] or "") if r else ""
            dias = ([int(x) for x in texto.split(",") if x.strip().isdigit()]
                    if guardado_mes == mes else [])
            if dia not in dias:
                dias.append(dia)
                dias.sort()
                cur.execute("UPDATE dbo.personagem SET entrada_mes = ?, entrada_dias = ? "
                            "WHERE entity_id = ?", mes, ",".join(str(x) for x in dias),
                            int(entity_id))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] registar_entrada falhou: {str(e)[:120]}")
            return (1, [dia])
    seguidos = 0
    d = dia
    marcados = set(dias)
    while d in marcados:
        seguidos += 1
        d -= 1
    return (max(1, seguidos), dias)


def guardar_preferencia(entity_id: int, coluna: str, valor) -> bool:
    """Guarda uma preferencia do personagem (mostrar_fato, barra_skills).

    Sao coisas que o jogador escolhe na interface e que tem de sobreviver ao logout: ele
    reparou que "nao salva que deixei ativado o forma, se altero a barra de skill tbm, ela
    volta do jeito que ta". Ficam em colunas proprias e nao em CAMPOS, porque nao sao
    progresso -- nao mudam por jogar, so por ele mexer.
    """
    if coluna not in ("mostrar_fato", "barra_skills"):
        return False
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            con.cursor().execute(
                "UPDATE dbo.personagem SET %s = ? WHERE entity_id = ?" % coluna,
                valor, int(entity_id))
            con.commit()
            return True
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] guardar_preferencia({coluna}) falhou: {str(e)[:120]}")
            return False


def ler_preferencias(entity_id: int) -> dict:
    """{mostrar_fato, barra_skills} do personagem. Ver guardar_preferencia."""
    with _lock:
        con = _conectar()
        if con is None:
            return {}
        try:
            cur = con.cursor()
            cur.execute("SELECT mostrar_fato, barra_skills FROM dbo.personagem "
                        "WHERE entity_id = ?", int(entity_id))
            r = cur.fetchone()
            if not r:
                return {}
            return {"mostrar_fato": int(r[0] or 0),
                    "barra_skills": bytes(r[1]) if r[1] else None}
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] ler_preferencias falhou: {str(e)[:120]}")
            return {}


def salvar_progresso(entity_id: int, valores: dict) -> bool:
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute(
                # OS INTERROGATORIOS SAO CONTADOS A PARTIR DE CAMPOS, nao escritos a mao.
                #
                # Estavam dez fixos. Ao acrescentar `pontos_skill`, `fletta_exp` e
                # `fletta_pontos` a CAMPOS em 29/08, a chamada passou a mandar treze valores
                # para dez marcadores e TODA a gravacao passou a falhar -- em silencio, com
                # uma linha no log que ninguem estava a ler. O progresso deixou de ser
                # guardado e so se viu porque um teste imprimiu o erro.
                #
                # Contados assim, acrescentar um campo a CAMPOS e mexer no procedimento do
                # banco passa a ser o unico trabalho.
                "EXEC dbo.salvar_progresso " + ", ".join("?" * (len(CAMPOS) + 1)),
                entity_id, *(int(valores.get(c, 0)) for c in CAMPOS))
            return True
        except Exception as e:
            print(f"[db] salvar_progresso falhou: {str(e)[:150]}")
            return False


# ---------------------------------------------------------------------------
# Autenticacao
# ---------------------------------------------------------------------------
# PBKDF2-HMAC-SHA256, 200k iteracoes, salt de 16 bytes por conta. Cabe no schema
# (senha_hash CHAR(64) = 32 bytes em hex, senha_salt CHAR(32) = 16 bytes em hex).
# O client manda a senha em texto claro no CLoginReqPacket -- nao da pra evitar isso sem
# mexer no client, mas em DISCO nunca fica texto claro.

ITERACOES = 200_000

# DESLIGADO em 23/08, quando a pagina de cadastro (web_cadastro.py) entrou: com um caminho
# proprio de cadastro, deixar o login criar conta sozinho so serviria pra encher o banco de
# contas nascidas de erro de digitacao.
AUTO_REGISTRAR = False


def _derivar(senha: str, salt_hex: str) -> str:
    return hashlib.pbkdf2_hmac("sha256", senha.encode("utf-8"),
                               bytes.fromhex(salt_hex), ITERACOES).hex()


def definir_senha(usuario: str, senha: str) -> bool:
    """Define ou troca a senha de uma conta existente."""
    salt = secrets.token_bytes(16).hex()
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("UPDATE dbo.conta SET senha_hash = ?, senha_salt = ? WHERE usuario = ?",
                        _derivar(senha, salt), salt, usuario)
            return cur.rowcount > 0
        except Exception as e:
            print(f"[db] definir_senha falhou: {str(e)[:150]}")
            return False


def autenticar(usuario: str, senha: str, ip=None):
    """Devolve (resultado, conta_id) com resultado em:
        ok | senha_errada | bloqueada | desconhecida | sem_banco

    Sem banco = RECUSA. Fechar em falha e o unico comportamento honesto para autenticacao:
    deixar passar quando o banco cai transforma indisponibilidade em porta aberta. Isso e o
    OPOSTO da regra do resto do db.py, onde falhar em silencio e o certo -- la o risco e
    perder progresso, aqui o risco e deixar entrar quem nao devia.
    """
    if not usuario:
        return "desconhecida", None
    with _lock:
        con = _conectar()
        if con is None:
            return "sem_banco", None
        try:
            cur = con.cursor()
            cur.execute("SELECT conta_id, senha_hash, senha_salt, bloqueada "
                        "FROM dbo.conta WHERE usuario = ?", usuario)
            linha = cur.fetchone()

            if linha is None:
                if not AUTO_REGISTRAR:
                    return "desconhecida", None
                salt = secrets.token_bytes(16).hex()
                cur.execute(
                    "INSERT INTO dbo.conta (usuario, senha_hash, senha_salt, "
                    "ultimo_login_em, ultimo_ip) OUTPUT INSERTED.conta_id "
                    "VALUES (?, ?, ?, SYSUTCDATETIME(), ?)",
                    usuario, _derivar(senha, salt), salt, ip)
                conta_id = cur.fetchone()[0]
                print(f"[db] conta '{usuario}' criada no primeiro login (conta_id={conta_id})")
                return "ok", conta_id

            conta_id, hash_salvo, salt, bloqueada = linha
            if bloqueada:
                return "bloqueada", conta_id

            if not hash_salvo or not salt:
                # conta criada antes de existir autenticacao: adota a senha deste login
                novo_salt = secrets.token_bytes(16).hex()
                cur.execute("UPDATE dbo.conta SET senha_hash = ?, senha_salt = ? "
                            "WHERE conta_id = ?", _derivar(senha, novo_salt), novo_salt, conta_id)
                print(f"[db] conta '{usuario}' nao tinha senha; adotada a deste login")
            elif not hmac.compare_digest(_derivar(senha, salt), hash_salvo.strip()):
                return "senha_errada", conta_id

            cur.execute("UPDATE dbo.conta SET ultimo_login_em = SYSUTCDATETIME(), "
                        "ultimo_ip = ? WHERE conta_id = ?", ip, conta_id)
            return "ok", conta_id
        except Exception as e:
            print(f"[db] autenticar falhou: {str(e)[:150]}")
            return "sem_banco", None


def conta_existe(usuario: str) -> bool:
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("SELECT 1 FROM dbo.conta WHERE usuario = ?", usuario)
            return cur.fetchone() is not None
        except Exception as e:
            print(f"[db] conta_existe falhou: {str(e)[:150]}")
            return False


def criar_conta(usuario: str, senha: str, ip=None) -> bool:
    """Cria uma conta com senha. Usado pela pagina de cadastro."""
    salt = secrets.token_bytes(16).hex()
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("INSERT INTO dbo.conta (usuario, senha_hash, senha_salt, ultimo_ip) "
                        "VALUES (?, ?, ?, ?)", usuario, _derivar(senha, salt), salt, ip)
            return True
        except Exception as e:
            print(f"[db] criar_conta falhou: {str(e)[:150]}")
            return False


_cache_mapas: dict[int, tuple[str, bool]] = {}


def mapa_persiste(mapa_id: int | None) -> bool:
    """O jogador pode renascer aqui no proximo login?

    Falso em calabouco, instancia, campo de batalha e mina: o jogo oficial devolve o
    jogador pra cidade quando ele reloga, porque a instancia em que ele estava nao existe
    mais. Salvar a posicao la dentro deixaria o personagem preso num mapa que ninguem
    abriu -- e o tipo de bug que so aparece meses depois, quando ja tem gente jogando.

    A classificacao vem de Data/Map.ini, extraida do mapWarpTargetPos.lod: 219 mapas de
    campo (19xxx), 117 calabocos (29xxx), 26 instancias, 16 campos de batalha e 6 minas.

    Mapa desconhecido devolve True -- hoje o mundo e um mapa so, o inicial, que e campo
    aberto. Quando existirem zonas de verdade, esta funcao ja decide sozinha.
    """
    if mapa_id is None:
        return True
    linha = data_files.indice("Map", "mapa_id").get(int(mapa_id))
    if linha is None:
        return True
    return bool(linha.get("persiste"))


def ponto_de_retorno(mapa_id: int) -> tuple[int, float, float] | None:
    """(mapa de destino, x, y) pra onde mandar quem deslogou dentro de uma instancia."""
    linha = data_files.indice("Map", "mapa_id").get(int(mapa_id))
    if not linha:
        return None
    destino, x, y = linha.get("destino_id"), linha.get("x"), linha.get("y")
    if destino is None or x is None or y is None:
        return None
    return (int(destino), float(x), float(y))


def taxa(codigo: str, padrao: float = 1.0) -> float:
    """Multiplicador de evento, de server/Data/Common/Rate.scp.

    O oficial roda eventos que multiplicam EXP, Fleta, drop e encantamento, e isso nao
    aparece em campo nenhum do protocolo -- so no valor que chega. Foi o que quase
    corrompeu a medicao de 24/08: o servidor estava em 3x e a aranha de 24 vinha dando 72.

    Saiu do banco em 24/08. O data_files rele o arquivo quando o mtime muda, entao editar
    Rate.scp vale na hora, sem reiniciar -- mesmo comportamento do TTL de 10 s de antes,
    so que sem ida ao banco.
    """
    return data_files.taxa(codigo, padrao)


def salvar_posicao(entity_id: int, x: float, y: float, z: float,
                   mapa_id: int | None = None) -> bool:
    """Grava onde o personagem parou, pra ele renascer ali no proximo login.

    As colunas pos_x/y/z existiam desde sempre e eram so LIDAS, na montagem do 04 02 --
    nada nunca escrevia nelas. Por isso todo login devolvia o personagem ao ponto de
    nascimento da criacao, por mais longe que ele tivesse andado.

    Chamada na saida e de tempos em tempos enquanto joga; salvar a cada passo seria uma
    ida ao banco por movimento (foram 97 mil movimentos numa unica captura).
    """
    if not entity_id or x is None:
        return False
    if not mapa_persiste(mapa_id):
        return False           # instancia: o proximo login sai na cidade, nao aqui
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            con.cursor().execute(
                "UPDATE dbo.personagem SET pos_x = ?, pos_y = ?, pos_z = ?, "
                "atualizado_em = SYSUTCDATETIME() WHERE entity_id = ?",
                float(x), float(y), float(z), int(entity_id))
            return True
        except Exception as e:
            print(f"[db] salvar_posicao falhou: {str(e)[:150]}")
            return False


def spawns() -> list[dict] | None:
    """Os pontos de nascimento de monstro do mundo, de Data/NpcSpawn.ini.

    Devolve None se o arquivo NAO EXISTE (a o mundo usa o bando padrao) e [] se ele existe
    e esta vazio -- que quer dizer "mundo sem mob, de proposito". A distincao importa: com
    as duas coisas devolvendo lista vazia, esvaziar a lista pra medir no oficial fazia as
    12 aranhas de fallback voltarem.
    """
    if not data_files.existe("NpcSpawn"):
        return None
    nomes = data_files.indice("Npc", "npc_id")
    saida = []
    for r in data_files.tabela("NpcSpawn"):
        npc = r.get("npc_id")
        ficha = nomes.get(npc) or {}
        # A COLUNA `quantidade` PASSA A CONTAR. Estava a ser lida do ficheiro e ignorada aqui.
        #
        # O cabecalho do proprio NpcSpawn.ini ja o dizia -- "quantidade: quantos monstros
        # nascem no ponto (...) uma entrada de Aranha pequena traz 10, com raio 300" -- e o
        # codigo criava um por linha. Sao 518 linhas que declaram 1.223 monstros.
        #
        # O jogador deu por isso: "no oficial tem mais urso furioso que o nosso". Ele tem 12
        # linhas que somam 85 bichos, portanto tinhamos um setimo. E as quantidades declaradas
        # batem com o que se contou nas capturas do oficial (maximo de entidades distintas
        # numa sessao):
        #
        #     especie              declarado   visto no oficial   tinhamos
        #     Aranha pequena           181            140              57
        #     Filhote de urso feroz    105             55              26
        #     Javali-de-sabre           55             48              34
        #
        # O ID DOS EXTRAS TEM DE SER NOSSO, e isso foi medido antes de mexer. O `entidade_id`
        # do client e o indice da LINHA, nao do monstro: comparando os ids que o oficial usa
        # com o que cada esquema preve, numerar por linha acerta 63,9% dos ids em comum e
        # numerar por monstro acerta 0%. Ou seja o client conhece um por ponto -- esse fica
        # com o id dele -- e os outros sao criados pelo servidor, com id da faixa nossa
        # (o montar_bando ja o faz quando `entidade_id` vem vazio).
        try:
            quantos = int(float(r.get("quantidade") or 0)) or 1
        except (TypeError, ValueError):
            quantos = 1
        quantos = max(1, min(quantos, 40))     # trava de seguranca contra linha estragada
        try:
            raio_sp = float(r.get("raio") or 0.0)
        except (TypeError, ValueError):
            raio_sp = 0.0
        for _j in range(quantos):
            px, py = r.get("x"), r.get("y")
            if _j:
                # ESPALHA OS EXTRAS, com semente fixa: assim as posicoes nao mudam a cada
                # reinicio do servidor, que faria os bichos saltarem de sitio entre sessoes.
                rnd = _random.Random((int(r.get("spawn_id") or 0) << 8) | _j)
                for _tent in range(8):
                    ang = rnd.uniform(0.0, 6.283185)
                    d = (raio_sp or 60.0) * (rnd.random() ** 0.5)
                    cx = float(r.get("x") or 0.0) + _math.cos(ang) * d
                    cy = float(r.get("y") or 0.0) + _math.sin(ang) * d
                    try:
                        import pathfinding
                        if pathfinding.bloqueado_em(cx, cy):
                            continue
                    except Exception:                                  # noqa: BLE001
                        pass
                    px, py = cx, cy
                    break
            saida.append({"npc_id": npc, "x": px, "y": py, "z": r.get("z"),
                      # O HP DA ESPECIE, do Mob.ini. O NpcSpawn nao tem coluna de HP, entao
                      # `r.get("hp_max")` era sempre None e cada mob nascia com o valor de
                      # recurso do entity_sim -- HP_MAX = 100, igual para TODA a fauna.
                      #
                      # Ninguem lia a coluna hp_max do Mob.ini em lado nenhum: uma Aranha
                      # pequena de 50 e um Tirano de 336 tinham os mesmos 100. O jogador
                      # deu por isso em 29/08: "o hp dos mob do nosso server nao esta certo
                      # ainda".
                      "hp_max": r.get("hp_max") or _hp_da_especie(npc),
                      "nome": ficha.get("nome"),
                      "tipo": _tipo_de_spawn(r, ficha),
                      "entidade_id": r.get("entidade_id") if not _j else None,
                      # ate onde ele perambula a partir daqui. O client e explicito: 231 dos
                      # 472 pontos de mob trazem raio 10, quase metade. Estava a ser lido do
                      # ficheiro e deitado fora aqui.
                      "raio": r.get("raio")})
    return saida


# O catalogo Npc.ini separa NPC de monstro na coluna `tipo`, e a separacao e limpa: dos 30
# pontos que a captura marcou como npc, os 30 tem tipo 1 no catalogo, e dos 98 marcados como
# mob, nenhum tem. Sao 301 entradas com tipo 1 -- inclusive a "Pedra de Retorno".
TIPO_NPC_NO_CATALOGO = 1


def _tipo_de_spawn(linha, ficha):
    """'npc' ou 'mob'. O catalogo manda; a coluna do arquivo de spawn e o desempate.

    CORRIGIDO 25/08: esta funcao nao existia e o dicionario montado acima nao tinha a chave
    `tipo` nenhuma. O entity_sim fazia `sp.get("tipo") or "mob"`, entao TODO ponto de
    nascimento virava monstro -- inclusive os 30 NPCs. O codigo que trata NPC (andar devagar,
    nao morrer) ja existia e simplesmente nunca era alcancado: os instrutores iam correr pelo
    mapa a 30 de velocidade e dar EXP ao morrer.
    """
    if ficha.get("tipo") == TIPO_NPC_NO_CATALOGO:
        return "npc"
    if ficha.get("tipo") is not None:
        return "mob"
    return linha.get("tipo") or "mob"


def personagens_da_conta(usuario: str) -> list[dict]:
    """Personagens de uma conta, pela procedure. Lista vazia = tela de criacao."""
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            cur.execute("EXEC dbo.listar_personagens ?", usuario)
            return [_linha_para_dict(l) for l in cur.fetchall()]
        except Exception as e:
            print(f"[db] personagens_da_conta falhou: {str(e)[:150]}")
            return []


_skill_do_item = None


_skills_existentes = None


def skill_do_item(item_id: int, bruto=None) -> int:
    """A SKILL que este item invoca ao ser usado. Zero quando nao invoca nenhuma.

    ISTO E O QUE FALTAVA PARA O BUFF FUNCIONAR, achado em 31/08. O campo [14:18] do `05 09`
    nao e uma instancia de item, como eu tinha assumido -- e o ID DA SKILL. O oficial mandou
    30301 ao usar a "Pocao do vento", e 30301 e uma linha do SkillLod chamada exactamente
    "Pocao do vento", com tipo 27 (velocidade de movimento) no SkillEfeito.

    Nos mandavamos ali o numero da instancia no inventario. O client procurava a skill,
    nao achava nenhuma, e por isso nao desenhava o icone nem aplicava o efeito -- ficava
    preso na velocidade base mesmo com a ficha a dizer 165,88.

    A LIGACAO E PELO NOME, e nao por uma tabela de indices: o `itemInvokeSkill.lod` do
    client tem 26 registos e nenhum destes itens. Cruzando os nomes, 126 itens tem uma skill
    com o nome igual -- as pocoes todas, os aneis, os colares.

    Quando ha mais de uma skill com o nome (a "Pocao do vento" tem 30301 e 30332), fica a
    MENOR: e a que o oficial mandou no unico caso medido.
    """
    global _skill_do_item
    if _skill_do_item is None:
        _skill_do_item = {}
        por_nome = {}
        for r in data_files.tabela("SkillLod"):
            try:
                n = str(r.get("nome") or "").strip()
                if n:
                    sk = int(r["skill_id"])
                    if n not in por_nome or sk < por_nome[n]:
                        por_nome[n] = sk
            except (TypeError, ValueError, KeyError):
                pass
        for r in data_files.tabela("Item"):
            try:
                n = str(r.get("nome") or "").strip()
                if n and n in por_nome:
                    _skill_do_item[int(r["item_id"])] = por_nome[n]
            except (TypeError, ValueError, KeyError):
                pass
    sk = _skill_do_item.get(int(item_id or 0), 0)
    if sk:
        return sk

    # SEGUNDA VIA: A FANTASIA. Nenhuma peca de fantasia tem skill com o nome igual -- as
    # botas 999008088 e 100170488 davam zero aqui. A ligacao delas e outra, e e directa:
    # O CODIGO DA OPCAO DA PECA E O ID DA SKILL.
    #
    # A bota tem a opcao 60200, e existe SkillBuff 60200 com as DUAS linhas exactas da
    # peca -- tipo 27 codigo 42710 valor2 10 (velocidade +10%) e tipo 17 codigo 41727
    # valor 33 (proteccao fisica +33). Nao e parecido: e o mesmo par.
    #
    # ISTO NAO ESTA MEDIDO NO OFICIAL, e a diferenca em relacao as pocoes tem de ficar
    # dita: o oficial nunca manda `05 09` ao equipar fantasia, entao nao ha captura dele
    # com este numero para comparar. O que se sabe e que o campo e um skill_id (medido nas
    # pocoes) e que a peca tem uma skill com o codigo dela. Se o client ignorar, cai.
    global _skills_existentes
    if _skills_existentes is None:
        _skills_existentes = set()
        for r in data_files.tabela("SkillBuff"):
            try:
                _skills_existentes.add(int(r["skill_id"]))
            except (TypeError, ValueError, KeyError):
                pass
    efeito = _efeito_da_peca(int(item_id or 0), bruto)
    return efeito if efeito in _skills_existentes else 0


def buff_do_item(item_id: int, bruto=None) -> tuple:
    """(skill_id, duracao_em_segundos, percentual) do buff que este item invoca.

    (0, 0, 0.0) quando o item nao invoca skill nenhuma.

    PORQUE A DURACAO VEM DAQUI. Ate 31/08 o servidor mandava `60` cravado, com um
    `duracao_seg` da tabela Item como primeira escolha -- e esse campo e ZERO em todas as
    pocoes, entao valia sempre o 60. Passava despercebido porque a "Pocao do vento" dura
    mesmo 60 s: o numero errado batia com o caso que se estava a olhar.

    A "Pocao de velocidade de movimento" (item 54208, skill 58007) dura 300 s, e a propria
    caixa de texto do jogo promete "60% por 5 minutos". O jogador viu 1 minuto na tela.

    O SkillBuff traz os dois numeros na mesma linha -- `duracao` e `valor2` -- e e a mesma
    tabela de onde ja sai o id. Um item que promete cinco minutos e um servidor que da um e
    uma discordancia que so existia porque a duracao vinha de outro sitio que a skill.

    O `tipo` fica de fora de proposito: quem chama ja sabe que efeito quer (ver o ramo da
    pocao no world_server, que filtra por tipo 27 antes de chegar aqui).
    """
    sk = skill_do_item(item_id, bruto)
    if not sk:
        return (0, 0, 0.0)
    dur, pct = 0, 0.0
    for r in data_files.tabela("SkillBuff"):
        try:
            if int(r["skill_id"]) != sk:
                continue
            d = int(r.get("duracao") or 0)
            if d > dur:
                dur = d
            v = float(r.get("valor2") or 0)
            if abs(v) > abs(pct):
                pct = v
        except (TypeError, ValueError, KeyError):
            pass
    return (sk, dur, pct)


def personagem_por_entidade(entity_id: int) -> dict | None:
    """A linha do personagem, pela entidade. Mesma forma do personagens_da_conta.

    Existe para o reenvio periodico da ficha (ver entity_sim.rodar_fichas): o `mundo`
    guarda a entidade e nao a conta, e a procedure de listagem so aceita utilizador.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT c.usuario FROM dbo.personagem p "
                        "JOIN dbo.conta c ON c.conta_id = p.conta_id "
                        "WHERE p.entity_id = ?", int(entity_id))
            r = cur.fetchone()
            if not r:
                return None
            cur.execute("EXEC dbo.listar_personagens ?", r[0])
            for l in cur.fetchall():
                d = _linha_para_dict(l)
                if int(d.get("entity_id") or 0) == int(entity_id):
                    return d
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] personagem_por_entidade falhou: {str(e)[:150]}")
    return None


MAX_DISPOSITIVOS = 0
"""Limite de maquinas por conta. ZERO = desligado, e por enquanto tem que ficar assim.

A ideia era o limite de 3 computadores do Karos original, usando hash1/hash2 do pacote de
login como impressao digital. Em 24/08 um teste do jogador refutou a premissa: o mesmo
hash1 aparece em cinco IPs diferentes, de maquinas e contas diferentes. Os campos sao
constantes do build do client, nao identificam computador.

Com isso o limite nao teria efeito nenhum -- ou pior, contaria todo mundo como a mesma
maquina. O codigo fica, porque a regra de negocio e desejada; falta so descobrir QUAL campo
identifica a instalacao. Quando aparecer, e so trocar a fonte e voltar este numero pra 3.
"""


def registrar_dispositivo(usuario: str, hash1: str, hash2: str) -> bool:
    """Anota a maquina e diz se ela pode entrar.

    hash1/hash2 sao dois MD5 que o CLoginReqPacket carrega -- a impressao digital do
    computador. Todas as capturas que temos vieram da mesma maquina, entao os valores eram
    iguais por construcao e nao davam pra confirmar a hipotese; o comportamento aqui assume
    que sao a impressao, que e o que o limite de 3 PCs do Karos original sugere.

    Devolve False quando a conta ja usa MAX_DISPOSITIVOS maquinas e esta e uma quarta.
    Maquina ja conhecida sempre passa, mesmo com a cota cheia.
    """
    if not usuario or not hash1 or not hash2:
        return True
    with _lock:
        con = _conectar()
        if con is None:
            # Sem banco nao da pra saber; deixa passar em vez de trancar todo mundo fora
            # por causa de uma queda de banco. A autenticacao ja recusou antes daqui.
            return True
        try:
            cur = con.cursor()
            cur.execute("SELECT conta_id FROM dbo.conta WHERE usuario = ?", usuario)
            linha = cur.fetchone()
            if not linha:
                return True
            conta_id = int(linha[0])
            cur.execute("SELECT COUNT(*) FROM dbo.conta_dispositivo "
                        "WHERE conta_id = ? AND hash1 = ? AND hash2 = ?",
                        conta_id, hash1, hash2)
            conhecida = cur.fetchone()[0] > 0
            if conhecida:
                cur.execute("UPDATE dbo.conta_dispositivo SET ultimo_uso = SYSUTCDATETIME() "
                            "WHERE conta_id = ? AND hash1 = ? AND hash2 = ?",
                            conta_id, hash1, hash2)
                return True
            cur.execute("SELECT COUNT(*) FROM dbo.conta_dispositivo WHERE conta_id = ?",
                        conta_id)
            quantas = cur.fetchone()[0]
            if MAX_DISPOSITIVOS and quantas >= MAX_DISPOSITIVOS:
                print(f"[db] conta '{usuario}' ja usa {quantas} maquinas "
                      f"(limite {MAX_DISPOSITIVOS}) -- RECUSANDO a nova {hash1[:12]}...")
                return False
            cur.execute("INSERT INTO dbo.conta_dispositivo "
                        "  (conta_id, hash1, hash2, ultimo_uso) "
                        "VALUES (?, ?, ?, SYSUTCDATETIME())", conta_id, hash1, hash2)
            print(f"[db] maquina nova registrada para '{usuario}': "
                  f"{quantas + 1} de {MAX_DISPOSITIVOS}")
            return True
        except Exception as e:
            print(f"[db] registrar_dispositivo falhou: {str(e)[:150]}")
            return True


def dispositivos_da_conta(usuario: str) -> list[dict]:
    """As maquinas que a conta ja usou, da mais recente pra mais antiga."""
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            cur.execute(
                "SELECT d.hash1, d.hash2, d.primeiro_uso, d.ultimo_uso "
                "FROM dbo.conta_dispositivo d JOIN dbo.conta c ON c.conta_id = d.conta_id "
                "WHERE c.usuario = ? ORDER BY d.ultimo_uso DESC", usuario)
            return [{"hash1": a, "hash2": b, "primeiro_uso": c2, "ultimo_uso": d2}
                    for a, b, c2, d2 in cur.fetchall()]
        except Exception as e:
            print(f"[db] dispositivos_da_conta falhou: {str(e)[:150]}")
            return []


def liberar_dispositivos(usuario: str, hash1: str | None = None) -> int:
    """Esquece maquinas da conta. Sem hash1, esquece todas. Devolve quantas sairam."""
    with _lock:
        con = _conectar()
        if con is None:
            return 0
        try:
            cur = con.cursor()
            if hash1:
                cur.execute(
                    "DELETE d FROM dbo.conta_dispositivo d "
                    "JOIN dbo.conta c ON c.conta_id = d.conta_id "
                    "WHERE c.usuario = ? AND d.hash1 = ?", usuario, hash1)
            else:
                cur.execute(
                    "DELETE d FROM dbo.conta_dispositivo d "
                    "JOIN dbo.conta c ON c.conta_id = d.conta_id "
                    "WHERE c.usuario = ?", usuario)
            return cur.rowcount
        except Exception as e:
            print(f"[db] liberar_dispositivos falhou: {str(e)[:150]}")
            return 0


# Primeiro entity_id que a sequencia de personagens usa. Bate com o `@primeiro_id INT =
# 20000` do procedimento dbo.criar_personagem.
#
# ESTAVA A FALTAR e ninguem dava por isso: o criar_personagem passava-o como argumento, o
# Python levantava NameError, o `except` devolvia None e o world_server traduzia QUALQUER
# falha para "nome em uso". O jogador tentava nome atras de nome e levava sempre a mesma
# recusa -- ver o aviso corrigido no world_server.
PRIMEIRO_ENTITY_ID = 20000


def criar_personagem(usuario: str, nome: str, classe: int, aparencia: int,
                     cabelo: int = 0, rosto: int = 0, cor=(0, 0, 0, 0)) -> dict | None:
    """Cria o personagem chamando dbo.criar_personagem. Devolve os dados ou None.

    A logica toda (checar conta, limite de slots, nome em uso, alocar entity_id, copiar os
    valores da classe) esta na procedure, numa transacao so. Antes isso era SQL solto aqui,
    com um SELECT MAX(entity_id)+1 seguido de INSERT em idas separadas -- duas criacoes ao
    mesmo tempo escolheriam o mesmo id e a segunda quebraria na chave unica.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute(
                "DECLARE @r NVARCHAR(32), @id INT; "
                "EXEC dbo.criar_personagem ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
                "@r OUTPUT, @id OUTPUT; SELECT @r, @id;",
                usuario, nome, classe, aparencia, cabelo, rosto,
                cor[0], cor[1], cor[2], cor[3], PRIMEIRO_ENTITY_ID)
            resultado, entity_id = cur.fetchone()
            if resultado != "ok":
                motivo = {"conta_inexistente": f"conta '{usuario}' nao existe",
                          "nome_em_uso": f"nome '{nome}' ja esta em uso",
                          "nome_reservado": f"nome '{nome}' pertenceu a um personagem "
                                            "apagado e segue reservado",
                          "classe_invalida": f"classe {classe} desconhecida",
                          "limite_de_personagens": "conta ja tem 4 personagens"}
                print(f"[db] criacao recusada: {motivo.get(resultado, resultado)}")
                return None
            cur.execute("EXEC dbo.listar_personagens ?", usuario)
            for linha in cur.fetchall():
                if int(linha[0]) == int(entity_id):
                    p = _linha_para_dict(linha)
                    print(f"[db] personagem '{nome}' criado: entity_id={p['entity_id']} "
                          f"classe={classe} HP={p['hp_max']} MP={p['mp_max']} "
                          f"atributos={p['forca']}/{p['destreza']}/{p['vitalidade']}/"
                          f"{p['inteligencia']}/{p['sabedoria']}")
                    return p
            return None
        except Exception as e:
            print(f"[db] criar_personagem falhou: {str(e)[:200]}")
            return None


_COLUNAS = ("entity_id", "nome", "nivel", "exp", "classe", "aparencia",
            "hp", "hp_max", "mp", "mp_max",
            "forca", "destreza", "vitalidade", "inteligencia", "sabedoria",
            "cabelo", "rosto", "cor_r", "cor_g", "cor_b", "cor_a",
            "pos_x", "pos_y", "pos_z")


def _linha_para_dict(linha) -> dict:
    d = {}
    for chave, valor in zip(_COLUNAS, linha):
        if chave == "nome":
            d[chave] = valor
        elif chave.startswith("pos_"):
            d[chave] = float(valor) if valor is not None else None
        else:
            d[chave] = int(valor)
    return d


def exp_para_subir(nivel: int) -> int | None:
    """EXP para sair deste nivel, de Data/Level.scp."""
    linha = data_files.indice("Level", "nivel").get(nivel)
    if not linha:
        return None
    v = linha.get("exp_para_subir")
    return int(v) if v is not None else None


def apagar_personagem(entity_id: int, usuario: str | None = None) -> bool:
    """Apaga pela procedure. Com `usuario`, so apaga se o personagem for daquela conta --
    sem isso qualquer um poderia mandar um 02 03 com id alheio."""
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("DECLARE @n INT; EXEC dbo.apagar_personagem ?, ?, @n OUTPUT; SELECT @n;",
                        entity_id, usuario)
            return int(cur.fetchone()[0]) > 0
        except Exception as e:
            print(f"[db] apagar_personagem falhou: {str(e)[:150]}")
            return False



def registrar_sessao(ip: str, usuario: str,
                     hash1: str | None = None, hash2: str | None = None) -> None:
    """Anota quem autenticou, de onde e de qual maquina.

    A impressao (hash1/hash2) e o que permite o hop3 reconhecer a conta quando o IP muda
    entre os saltos. Antes ela so aparecia no log e se perdia.
    """
    if not ip or not usuario:
        return
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            cur = con.cursor()
            cur.execute(
                "MERGE dbo.sessao AS a USING (SELECT ? AS ip, ? AS u, ? AS h1, ? AS h2) AS f "
                "ON a.ip = f.ip "
                "WHEN MATCHED THEN UPDATE SET usuario = f.u, atualizada_em = SYSUTCDATETIME(), "
                "  hash1 = f.h1, hash2 = f.h2 "
                "WHEN NOT MATCHED THEN INSERT (ip, usuario, hash1, hash2) "
                "  VALUES (f.ip, f.u, f.h1, f.h2);", ip, usuario, hash1, hash2)
        except Exception as e:
            print(f"[db] registrar_sessao falhou: {str(e)[:150]}")


def usuario_por_ip(ip: str, hash1: str | None = None,
                   hash2: str | None = None) -> str | None:
    """Quem esta por tras desta conexao de mundo.

    1. IP proprio, quando os dois saltos vem do mesmo endereco.
    2. Sessao unica recente -- so quando nao ha ambiguidade nenhuma.

    Os parametros hash1/hash2 continuam na assinatura mas NAO sao usados pra decidir. Em
    24/08 eu os usei como impressao de maquina e um teste do jogador refutou a hipotese: o
    mesmo hash1 (a755441959f0fb071dda530f086cc58c) aparece em cinco IPs diferentes, de
    maquinas e contas diferentes. E constante do build do client, nao identidade.

    Usa-los como chave era pior que nao usar: como todo mundo tem o mesmo valor, a consulta
    devolvia a sessao mais recente de QUALQUER jogador -- exatamente a invasao de conta que
    o passo 2 existe pra impedir.

    A solucao definitiva continua sendo o token: o client manda id='' e um `pw` de 5 chars
    na reconexao ao mundo. Esse campo e o candidato, e ainda nao foi decodificado.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            if ip:
                cur.execute("SELECT usuario FROM dbo.sessao WHERE ip = ? "
                            "AND DATEDIFF(minute, atualizada_em, SYSUTCDATETIME()) <= 30", ip)
                linha = cur.fetchone()
                if linha:
                    return linha[0]
            cur.execute("SELECT usuario, ip FROM dbo.sessao "
                        "WHERE DATEDIFF(minute, atualizada_em, SYSUTCDATETIME()) <= 5")
            recentes = cur.fetchall()
            if len(recentes) == 1:
                usuario, origem = recentes[0]
                print(f"[db] IP {ip} sem sessao propria; unica sessao recente e "
                      f"'{usuario}' de {origem} -- usando ela")
                return usuario
            # DUAS SESSOES DA MESMA CONTA NAO SAO AMBIGUIDADE. O que este passo evita e
            # entregar a conta de OUTRA pessoa; se todos os candidatos forem o mesmo
            # utilizador, nao ha nada para adivinhar.
            #
            # O jogador abriu o jogo na propria VPS enquanto tinha a sessao da maquina dele
            # aberta -- duas linhas, mesmo `mu6020`, uma de 127.0.0.1 e outra do IP de casa
            # -- e levou "RECUSADO: nao identifiquei a conta".
            if len({u for u, _o in recentes}) == 1:
                usuario = recentes[0][0]
                print(f"[db] IP {ip} sem sessao propria; as {len(recentes)} sessoes "
                      f"recentes sao todas de '{usuario}' -- usando ela")
                return usuario
            if len(recentes) > 1:
                print(f"[db] IP {ip} sem sessao propria e ha {len(recentes)} sessoes "
                      "recentes de contas DIFERENTES (%s): RECUSANDO"
                      % ", ".join(sorted({u for u, _o in recentes})))
            return None
        except Exception as e:
            print(f"[db] usuario_por_ip falhou: {str(e)[:150]}")
            return None


# ---------------------------------------------------------------------------
# Inventario
# ---------------------------------------------------------------------------
# CItemInvenListPacket (06 05), decodificado em 23/08 comparando os inventarios iniciais das
# 7 classes -- os tamanhos diferiam em multiplos exatos de 147:
#
#     cabecalho de 12 bytes, com a CONTAGEM de itens em [11]
#     cada item: 147 bytes
#         [0]    container  0 = equipado, 1 = mochila
#         [1]    slot
#         [2:4]  id do item (LE u16)
#
# Os outros ~140 bytes do registro (durabilidade, encantamento, opcoes) continuam sem
# identificacao, entao guardamos o registro cru junto. O pacote sai fiel enquanto isso, e cada
# campo que for decodificado migra do blob pra uma coluna.

def dar_itens_iniciais(entity_id: int, classe: int) -> int:
    """Copia o kit inicial da classe pro inventario do personagem.

    O kit vem de Data/ClassStartItem.ini e o destino e dbo.personagem_item, que e
    progresso. Era um INSERT-SELECT numa ida so ao banco; agora le do arquivo e insere.

    A coluna `bruto` e binaria e no arquivo esta em hexadecimal -- precisa voltar a bytes,
    senao o item entra no inventario com o blob errado e o client nao o desenha.
    """
    kit = [r for r in data_files.tabela("ClassStartItem")
           if r.get("classe_id") == int(classe)]
    if not kit:
        return 0
    with _lock:
        con = _conectar()
        if con is None:
            return 0
        try:
            cur = con.cursor()
            dados = []
            for r in kit:
                b = r.get("bruto")
                if isinstance(b, str):
                    b = bytes.fromhex(b)
                dados.append((entity_id, r.get("container"), r.get("slot"),
                              r.get("item_id"), r.get("quantidade"), b))
            cur.executemany(
                "INSERT INTO dbo.personagem_item "
                "  (entity_id, container, slot, item_id, quantidade, bruto) "
                "VALUES (?, ?, ?, ?, ?, ?)", dados)
            return len(dados)
        except Exception as e:
            print(f"[db] dar_itens_iniciais falhou: {str(e)[:150]}")
            return 0


def inventario_do_personagem(entity_id: int) -> list[dict]:
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            # o id da instancia vai junto: e por ele que registro_item acha as opcoes
            # escolhidas a mao, para as escrever no registo que o client desenha
            cur.execute("SELECT container, slot, item_id, quantidade, bruto, "
                        "personagem_item_id FROM dbo.personagem_item WHERE entity_id = ? "
                        "ORDER BY container, slot", entity_id)
            return [{"container": int(c), "slot": int(s), "item_id": int(i),
                     "quantidade": int(q), "bruto": bytes(b) if b else None,
                     "instancia": int(pid)}
                    for c, s, i, q, b, pid in cur.fetchall()]
        except Exception as e:
            print(f"[db] inventario_do_personagem falhou: {str(e)[:150]}")
            return []


# ---------------------------------------------------------------------------
# EXP: formula
# ---------------------------------------------------------------------------
# ganho = exp_base_do_mob * (1 + soma dos percentuais ativos / 100)
#
# ESCOLHA DELIBERADA: os bonus SOMAM, nao multiplicam. Com VIP 50% e platina 50%:
#     somando     -> base * 2,00
#     multiplicando -> base * 2,25
# A soma e mais previsivel pra quem balanceia e nao explode quando varios bonus coincidem.
# Trocar pra produto e mudar so a linha marcada.
#
# O exp_base vem de Data/Mob.ini. Ele NAO esta no client -- o npcInfo.lod so tem nome, modelo e
# escala -- entao ou e medido no servidor oficial ou e decisao de balanceamento. A coluna
# `medido` marca quais valores vieram de observacao real.

def modificadores_exp_ativos() -> list:
    """Bonus percentuais de EXP ligados, de Data/Common/ExpModifier.scp."""
    return [(r["codigo"], int(r.get("percentual") or 0))
            for r in data_files.tabela("ExpModifier") if r.get("ativo")]


def exp_do_mob(mob_id: int = 1) -> int:
    """EXP base do mob, de Data/Mob.scp. 24 e a aranha pequena, o fallback historico."""
    linha = data_files.indice("Mob", "mob_id").get(mob_id)
    if not linha:
        return 24
    v = linha.get("exp_base")
    return int(v) if v is not None else 24


def penalidade_nivel(diferenca: int) -> float:
    """Percentual da EXP em funcao de quanto o jogador esta acima do mob.

    A CURVA E PARCIAL. A coluna `medido` marca quais pontos vieram de observacao real no
    oficial; os demais sao interpolacao.

    CONFIRMADO em 27/08 por uma via nova -- 853 abates de uma sessao de progressao de 250
    minutos, com o jogador a subir 22 niveis a matar as mesmas especies, o que faz a
    diferenca variar sozinha. Normalizando a EXP de cada especie pelo melhor valor dela:

        diferenca -10 a +2   100 por cento, sem penalidade nenhuma
        diferenca +3          90 por cento   (a tabela ja tinha 87,5, medido de outra forma)
        diferenca -11 e -12   73 e 70 por cento  <- mob MUITO acima tambem rende menos

    O QUE FALTA E O MEIO DA CURVA, de +4 a +98, e e a parte que mais importa: o jogador
    relata que um Guerreiro de nivel 99 ganha 2 de EXP numa Aranha pequena, que rende 24 a
    quem esta ao nivel dela -- ou seja 8,3 por cento com diferenca de 98.

    A forma nao se resolve com o que ha. Uma exponencial encaixa nos dois pontos conhecidos
    de forma quase perfeita (dif 3 da r=0,875 e dif 5 da r=0,874) mas em dif 98 daria
    0,0002 por cento, muito abaixo dos 8,3 observados. Logo a curva SATURA num minimo, e
    onde comeca a saturar so se sabe medindo.

    COMO MEDIR TUDO DE UMA VEZ: um personagem de nivel alto a matar mobs de niveis variados.
    Cada especie da um ponto da curva, e uma sessao curta cobre a faixa inteira.
    """
    melhor = None
    for r in data_files.tabela("ExpPenalty"):
        d = r.get("diferenca")
        p = r.get("percentual")
        if d is None or p is None or d > diferenca:
            continue
        if melhor is None or d > melhor[0]:
            melhor = (d, p)
    # DEVOLVE FRACCIONARIO. Estava `int(...)`, e a tabela guarda 87,500 -- virava 87.
    #
    # O jogador apanhou isto com um caso concreto: nivel 4 a matar uma Aranha pequena de
    # nivel 1 da 21 de EXP no oficial. Sao 24 x 0,875 = 21,0 exactos. Com os 87 truncados
    # dava 24 x 0,87 = 20,88, e o int() do calcular_exp cortava outra vez para 20.
    #
    # Duas truncagens seguidas num numero pequeno. E a terceira vez que este erro aparece na
    # EXP -- ja tinha custado um sexto do total na divisao por grupo, e esta escrito la que
    # se arredonda e nao se trunca. Aqui ficou por corrigir.
    return float(melhor[1]) if melhor else 100.0


def _hp_da_especie(npc_id) -> int | None:
    """HP maximo da especie, do Mob.ini. None quando nao ha valor medido.

    So 20 das 926 especies o trazem. As outras ficam com None de proposito, para o
    entity_sim usar o valor de recurso e para se ver quantas faltam -- em vez de eu inventar
    um numero que depois passa por medido.

    O FIO NAO AJUDA AQUI, e vale a pena ficar escrito: o 08 06 do oficial manda a vida do
    monstro em PERCENTAGEM. O campo [19:23] (maximo) e 100 em todos os pacotes de todas as
    especies, e o [15:19] e quanto por cento resta. O HP absoluto do monstro nunca vai no
    fio -- e dado de servidor, como a EXP e o dano.
    """
    r = (data_files.indice("Mob", "mob_id") or {}).get(int(npc_id or 0))
    if not r:
        return None
    try:
        v = int(r.get("hp_max") or 0)
    except (TypeError, ValueError):
        return None
    return v or None


_irmaos_exp = {}


def _irmao_com_exp(mob_id: int, ficha):
    """EXP medida de outra especie com o MESMO NOME, ou None. Ver _exp_base_de."""
    if mob_id in _irmaos_exp:
        return _irmaos_exp[mob_id]
    saida = None
    try:
        nome = (ficha or {}).get("nome")
        if nome:
            medidas = data_files.indice("MobExp", "mob_id") or {}
            todos = data_files.indice("Mob", "mob_id") or {}
            for outro, linha in todos.items():
                if outro == mob_id or (linha or {}).get("nome") != nome:
                    continue
                m = medidas.get(int(outro))
                if m and m.get("exp"):
                    saida = int(m["exp"])
                    break
    except Exception as e:                                          # noqa: BLE001
        print("[db] _irmao_com_exp falhou: %s" % str(e)[:100])
    _irmaos_exp[mob_id] = saida
    return saida


def _exp_base_de(mob_id: int, ficha) -> int:
    """EXP que a especie da, antes de penalizacao e bonus. Tres fontes, por ordem de peso.

    1. MEDIDA no fio (Data/Monsters/MobExp.ini, ver tools/medir_exp.py). Doze especies, de
       24 a 610. Uma delas valida a medicao por fora: a Aranha pequena mede 24 e a coluna
       exp_base do Mob.ini, que veio de outra fonte, ja dizia 24.

    2. ESTIMADA pelo HP. Nas quatro especies que tem HP medido E EXP medida, a razao exp/hp
       fica em 0,69 / 0,72 / 0,75 -- e so a Aranha pequena foge (0,48), que e nivel 1 e cabe
       mal em qualquer regra. Sao TRES pontos: chega para nao deixar 900 especies com o
       mesmo numero, nao chega para eu chamar a isto uma lei. Fica marcado como estimativa.

    3. exp_base do Mob.ini, e por fim 24. Este 24 e o valor da Aranha pequena aplicado a
       toda a fauna, que e o que estava em vigor ate 29/08 -- um Guardiao Estripador, que
       mede 450, dava os mesmos 24 de uma aranha.
    """
    medido = (data_files.indice("MobExp", "mob_id") or {}).get(int(mob_id))
    if medido and medido.get("exp"):
        try:
            return int(medido["exp"])
        except (TypeError, ValueError):
            pass
    # 1b. O IRMAO DE MESMO NOME, se ele tiver medicao. Os ids do jogo andam aos pares e o
    # par e a MESMA criatura em dois niveis seguidos -- Urso furioso 14 e 15, Cao da estepe
    # 3 e 4. Nada se parece mais com um bicho do que ele proprio um nivel acima.
    #
    # VEM ANTES DA ESTIMATIVA PELO HP de proposito. O Bico de alumina nivel 10 saia com 144
    # pelo HP, quando o de nivel 9 tem 176 MEDIDOS -- ou seja a estimativa punha o bicho
    # mais forte a dar menos, que e impossivel.
    #
    # Usa-se o valor do irmao TAL E QUAL, sem escalar por nivel: a EXP nao e funcao do
    # nivel, e as medicoes provam-no -- tres especies de nivel 14 dao 174, 224 e 309.
    irmao = _irmao_com_exp(int(mob_id), ficha)
    if irmao:
        return irmao
    if ficha:
        try:
            hp = int(ficha.get("hp_max") or 0)
        except (TypeError, ValueError):
            hp = 0
        if hp > 0:
            return max(1, int(round(hp * EXP_POR_HP)))
        try:
            return int(ficha.get("exp_base") or 24)
        except (TypeError, ValueError):
            pass
    return 24


# Razao exp/hp das quatro especies com as duas coisas medidas: 0,48 (Aranha, nivel 1) /
# 0,75 / 0,72 / 0,69. Mediana 0,708. Tres pontos uteis -- ver _exp_base_de.
EXP_POR_HP = 0.708


def calcular_exp(mob_id: int = 1, nivel_jogador: int = 1, extras=()) -> int:
    """EXP de um abate.

        ganho = exp_base * penalidade(nivel_jogador - nivel_mob) * (1 + bonus / 100)

    A penalidade existe porque matar mob muito abaixo do teu nivel nao pode render EXP cheia --
    senao vira fazenda. `extras` sao percentuais que nao estao ligados globalmente: o VIP
    daquela conta, a platina que o jogador usou, o cabelo que ele veste.
    """
    ficha = data_files.indice("Mob", "mob_id").get(int(mob_id))
    nivel_mob = int(ficha.get("nivel") or 1) if ficha else 1
    base = _exp_base_de(int(mob_id), ficha)
    pena = penalidade_nivel(max(0, nivel_jogador - nivel_mob))
    soma = sum(p for _, p in modificadores_exp_ativos()) + sum(extras)
    # bonus SOMAM (escolha de balanceamento, ver comentario acima); a penalidade MULTIPLICA
    return max(0, int(round(base * (pena / 100.0) * (1 + soma / 100.0))))


# ---------------------------------------------------------------------------
# Atributos de item (Data/Item.ini + Data/ItemOption.ini)
# ---------------------------------------------------------------------------
# Decodificados do itemBase.lod / itemAttr.lod em 23/08. O client tem essas tabelas
# no disco -- o servidor NAO precisa mandar os numeros pro client, mas precisa deles
# pra calcular o combate. Ate agora o dano era a constante DANO_POR_GOLPE=25.
#
# Item.ini       classe_req, categoria, slot, nivel_req, empilha_max, precos, duracao
# ItemOption.ini uma linha por (item, refino 0..16): dano/defesa/velocidade/alcance
#
# CONTAINER 0 = equipado (o slot bate com Item.ini.slot), CONTAINER 1 = mochila.

CONTAINER_EQUIPADO = 0
SLOT_ARMA = 7

_cache_attr: dict[tuple[int, int], dict] = {}


def atributos_do_item(item_id: int, refino: int = 0) -> dict:
    """Numeros de combate de um item num dado nivel de refino. {} se nao houver.

    Sao 34336 linhas em Data/ItemOption.ini, indexadas por (item_id, refino) na primeira
    chamada e guardadas em memoria depois -- nao vale reprocessar a cada golpe.
    """
    linha = data_files.indice_composto("ItemOption", ("item_id", "refino")).get(
        (int(item_id), int(refino)))
    if not linha:
        return {}
    campos = ("dano_min", "dano_max", "dano_mag_min", "dano_mag_max",
              "vel_ataque", "alcance", "defesa", "defesa_mag")
    return {n: float(linha.get(n) or 0) for n in campos}


def equipamento_do_personagem(entity_id: int) -> dict[int, dict]:
    """slot -> {item_id, nome, refino, e os atributos}. So o container 0.

    O que esta equipado e PROGRESSO e continua vindo do banco; o nome e os numeros de
    combate sao CONTEUDO e vem de Data/. Era um JOIN; agora sao duas leituras, uma de
    cada lado.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return {}
        try:
            cur = con.cursor()
            cur.execute(
                # o id da instancia vem junto: e por ele que se acham as opcoes
                # escolhidas a mao em dbo.personagem_item_bonus
                "SELECT slot, item_id, bruto, personagem_item_id FROM dbo.personagem_item "
                "WHERE entity_id = ? AND container = ?",
                entity_id, CONTAINER_EQUIPADO)
            linhas = cur.fetchall()
        except Exception as e:
            print(f"[db] equipamento_do_personagem falhou: {str(e)[:150]}")
            return {}
    catalogo = data_files.indice("Item", "item_id")
    equipado = {}
    for slot, item_id, bruto, instancia in linhas:
        ficha = catalogo.get(int(item_id))
        if ficha is None:
            # o JOIN antigo simplesmente sumia com a linha; aqui isso vira aviso, porque
            # item equipado que nao esta no catalogo e sintoma, nao detalhe.
            print(f"[db] item {item_id} equipado por {entity_id} nao esta em Data/Item.ini")
            continue
        # REFINO em bruto[7]. Antes ficava cravado em 0 aqui, entao uma arma +13 batia
        # como +0 -- o registro ate carregava o refino, mas o servidor nunca olhava.
        ref = int(bruto[OFF_REFINO_BRUTO]) if bruto and len(bruto) > OFF_REFINO_BRUTO else 0
        if not 0 <= ref <= REFINO_MAX:
            ref = 0
        d = dict(atributos_do_item(int(item_id), ref))
        if not d and ref:
            # nem todo refino existe pra todo item (o itemAttr pula alguns niveis)
            d = dict(atributos_do_item(int(item_id), 0))
        d.update(item_id=int(item_id), nome=str(ficha.get("nome")), refino=ref,
                 instancia=int(instancia),
                 # o registo cru vai junto: a fantasia guarda nele qual das duas opcoes
                 # a instancia sorteou -- ver OFF_EFEITO_FANTASIA_BRUTO
                 bruto=bytes(bruto) if bruto else b"")
        equipado[int(slot)] = d
    return equipado


# Tipos de caracteristica especial que o combate e a ficha ja sabem usar. A chave e a
# coluna `tipo` do ItemBonus.ini, que e o caso do switch FUN_006076cb do AMo.exe; o valor e
# o nome interno que world_server.bonus_de_opcoes devolve. Ver
# tools/importar_bonus_de_item.py e docs/opcoes-de-item.md.
#
# O que nao esta aqui e lido do ficheiro na mesma, contado em `ignorados`, e nao aplicado --
# sao 90 e tal tipos que dependem de sistemas que ainda nao existem (PK, arena, guerra de
# coalizao, animal de estimacao). Ficam de fora explicitamente, e nao por esquecimento.
TIPO_DE_BONUS = {
    1: "forca",           81: "forca",
    2: "vitalidade",     100: "vitalidade",
    3: "destreza",
    4: "sabedoria",      102: "sabedoria",
    5: "inteligencia",   101: "inteligencia",
    6: "atq_fisico",      73: "atq_fisico",
    8: "atq_magico",      74: "atq_magico",
    10: "classificacao_ataque",
    # "Forca de ataque": sobe os DOIS ataques, o fisico e o magico, e vem sempre em
    # percentagem -- 318 linhas do ItemBonus.ini, todas com unidade %, o terceiro tipo mais
    # usado do jogo. Faltava na lista e o jogador deu por isso.
    71: "poder_de_ataque",
    11: "chance_critico",
    12: "multiplicador_critico",
    17: "protecao_fisica",
    19: "protecao_magica",
    21: "chance_evasao",
    22: "chance_bloqueio",
    28: "saude",          46: "saude",
    29: "mana",           47: "mana",
    108: "poder_de_habilidade",
    # Os tres seguintes entraram em 28/08, na auditoria: apareciam nos dados e o servidor
    # ja tinha onde os por, so faltava a ligacao.
    13: "velocidade_ataque",
    # 14 "Velocidade de invocacao". FALTAVA, e o jogador viu: pos tres opcoes de +10% numa
    # luva e a ficha continuou a marcar 100. Sem linha aqui o tipo cai fora do
    # bonus_do_equipamento e nao chega a lado nenhum.
    #
    # E o par do 13 por classe: *"Maga nao tem velocidade de att e sim de invocacao"*.
    14: "velocidade_invocacao",
    # IDENTIFICADOS EM 31/08 pelo nome das skills que so tem esse efeito -- o metodo esta
    # em baixo. Ficam nomeados para entrarem no `bonus_do_equipamento`, MAS ainda nao tem
    # destino: falta saber em que campo da ficha cada um se escreve, e sem isso o valor
    # soma no dicionario e nao chega a lado nenhum.
    #
    # O metodo que os achou: procurar skills cujo UNICO efeito e o tipo em causa, e ler o
    # nome delas. A skill 20301 chama-se "Rapto de mana + 10" e tem tipo 33 valor 10; a
    # 20601 chama-se "Reduzir Dano Critico +2%" e tem tipo 98 valor2 2. O nome da skill diz
    # o que o tipo e, quando ela nao faz mais nada.
    #
    # NAO SE USOU a coluna `descricao` do uniqueOptionType para isto: ela sai de
    # `grupo % 100`, que este mesmo dia provou estar errada (ver catalogo_de_opcoes).
    33: "rapto_de_mana",           # "Rapto de mana + 10", valor 10
    97: "reduz_poder_creta",       # "Reduzir o poder de Creta + 2%", valor 0,02
    98: "reduz_dano_critico",      # "Reduzir Dano Critico +2%", valor2 2
    105: "absorcao_de_mana",       # "Absorcao de Mana + 1%", valor2 1
    # 27 "Velocidade de movimento". NAO ESTA LIGADO: sabe-se que o atributo existe e ate
    # quanto vale (a Phoenix diz "+90" na descricao), mas nao se sabe em que offset da
    # ficha vive -- os dois candidatos de 28/08 cairam em 30/08, ver o bloco no
    # world_server.py. Fica nomeado para a fantasia e os buffs o poderem somar.
    27: "velocidade_movimento",                         # 57 linhas, sempre %
    30: "regen_hp", 31: "regen_mp",                  # 8 linhas cada, sempre absoluto
    59: "experiencia", 60: "experiencia", 62: "experiencia",   # 109 linhas, sempre %
}


# ---------------------------------------------------------------- FANTASIA (cash)
#
# A cadeia inteira (ver docs/achados_0829.md, seccoes 13 e 43):
#
#     item      ItemPackage        -> as DUAS opcoes do grau da peca
#     instancia bruto[76:80]       -> qual das duas ela sorteou
#     efeito    FantasiaEfeito.ini -> (atributo, plano, percentual)
#     conjunto  FantasiaConjunto   -> patamares de 3 e 4 pecas no FantasiaSet
#
# NAO HA MECANISMO PROPRIO DE FANTASIA. O codigo de cada ranhura e uma chave do skillData,
# a mesma tabela de onde saem os valores dos buffs e das passivas, e o `tipo` e o mesmo
# switch dos bonus de item. A tabela ja chega aqui resolvida, com o atributo por nome.
#
# ATE 30/08 LIA-SE O VALOR DOS DOIS ULTIMOS DIGITOS DO CODIGO. Batia nas quatro primeiras
# medicoes por coincidencia e falhava no resto -- ver o cabecalho de
# tools/extrair_efeitos_fantasia.py. Nada aqui deve voltar a decodificar codigos.
OFF_EFEITO_FANTASIA_BRUTO = 76      # u32 LE dentro dos 147 bytes


def _opcoes_do_efeito(efeito: int):
    """[(nome, valor, unidade)] das ranhuras de um efeito de fantasia.

    O plano e a percentagem sao DOIS CAMPOS do mesmo registo e ambos podem valer: sai uma
    entrada por cada um que nao seja zero. O 42710 so tem percentagem (velocidade de
    movimento +10%) e o 41727 so tem plano (proteccao fisica +33).
    """
    saida = []
    for linha in data_files.agrupar("FantasiaEfeito", "efeito").get(int(efeito), ()):
        nome = str(linha.get("atributo") or "")
        if not nome or nome == "?":
            continue
        plano = float(linha.get("plano") or 0)
        pct = float(linha.get("pct") or 0)
        if plano:
            saida.append((nome, plano, "abs"))
        if pct:
            saida.append((nome, pct, "%"))
    return saida


def efeito_de_fantasia(item_id: int) -> bool:
    """True se este item e uma peca de fantasia com efeitos proprios."""
    return bool(data_files.agrupar("ItemPackage", "item_id").get(int(item_id), ()))


def efeito_da_peca(item_id: int, bruto) -> int:
    """Publico: ver _efeito_da_peca. O world_server usa-o para sanear o registo que envia."""
    return _efeito_da_peca(item_id, bruto)


def _efeito_da_peca(item_id: int, bruto) -> int:
    """Qual das duas opcoes do item vale, lida da INSTANCIA.

    O ItemPackage lista sempre DUAS por item -- sao as duas opcoes do grau, e a peca sorteia
    UMA. A escolha esta em bruto[76:80].

    VALIDA-SE CONTRA AS DUAS, e nao e zelo a mais: o `dar_item` monta a peca copiando o
    registo de OUTRO item como molde, e o molde traz o efeito dele. Nas pecas do jogador
    apareciam tres itens diferentes todos com 60189, que e o efeito do TORSO -- correcto
    para o torso e lixo herdado nas outras duas. Sem esta validacao a mao e o pe ganhavam
    ataque magico de torso.
    """
    possiveis = [int(l.get("efeito")) for l in
                 data_files.agrupar("ItemPackage", "item_id").get(int(item_id), ())]
    if not possiveis:
        return 0
    if bruto and len(bruto) >= OFF_EFEITO_FANTASIA_BRUTO + 4:
        e = struct.unpack_from("<I", bruto, OFF_EFEITO_FANTASIA_BRUTO)[0]
        if e in possiveis:
            return e
    return possiveis[0]


def bonus_da_fantasia(entity_id: int) -> dict:
    """{nome interno: (absoluto, percentual)} das pecas de fantasia e do conjunto."""
    soma = {}

    def somar(nome, valor, unidade):
        if not valor:
            return
        a, pc = soma.get(nome, (0.0, 0.0))
        soma[nome] = (a, pc + valor) if unidade == "%" else (a + valor, pc)

    pertence = data_files.indice("FantasiaConjunto", "item_id")
    contagem = {}
    for peca in equipamento_do_personagem(entity_id).values():
        item_id = int(peca.get("item_id") or 0)
        if item_id not in pertence:
            continue                                  # nao e fantasia
        efeito = _efeito_da_peca(item_id, peca.get("bruto"))
        for nome, valor, unidade in _opcoes_do_efeito(efeito):
            somar(nome, valor, unidade)
        cj = int(pertence[item_id].get("conjunto") or 0)
        contagem[cj] = contagem.get(cj, 0) + 1

    # BONUS DE CONJUNTO. Dois patamares e so dois -- 3 e 4 pecas -- e sao CUMULATIVOS: com
    # quatro pecas vestidas entram os dois, foi o que as cinco fichas de 29/08 mostraram.
    patamares = data_files.agrupar("FantasiaSet", "conjunto")
    for cj, n in contagem.items():
        for linha in patamares.get(cj, ()):
            if n >= int(linha.get("pecas") or 99):
                for nome, valor, unidade in _opcoes_do_efeito(int(linha.get("efeito"))):
                    somar(nome, valor, unidade)
    return soma


SLOT_MONTARIA = 6


def velocidade_da_montaria(item_id: int) -> float:
    """Velocidade que a montaria da, ou 0 se nao se sabe.

    ZERO NAO E "nao ha montaria", e "o numero nao esta em lado nenhum" -- ver o cabecalho de
    tools/extrair_montarias.py. Das 100 montarias so 21 anunciam a velocidade, e so no texto
    da descricao. Quem devolve zero monta na mesma, a velocidade base.
    """
    linha = data_files.indice("Montaria", "item_id").get(int(item_id))
    return float(linha.get("velocidade") or 0) if linha else 0.0


def montaria_equipada(entity_id: int) -> int:
    """item_id do que esta na ranhura da montaria, ou 0."""
    peca = equipamento_do_personagem(entity_id).get(SLOT_MONTARIA)
    return int(peca.get("item_id") or 0) if peca else 0


# ---------------------------------------------------------------- BARRA DE ATALHOS
#
# O client guarda o que se arrasta para a barra mandando um `06 0b` por ranhura (26 bytes):
#
#     [6:10]   entidade, BE
#     [10:14]  ranhura, BE
#     [14:18]  o que la esta (skill ou accao), LE
#     [18:22]  vale 9 em todas as amostras -- nossas e do oficial
#     [22:26]  varia (0, 4, 5, 7) e nao esta percebido
#
# Nos ignoravamo-lo, e o `05 0c` que mandamos a entrada ia do molde -- por isso a barra
# voltava sempre ao mesmo. O jogador viu: "a barra de skill em baixa deve ta puxando do
# mold pq eu altero, quando relogo volta assim".
#
# GUARDAM-SE OS QUATRO CAMPOS, mesmo os dois que nao se percebem. Custa dois inteiros e
# evita ter de pedir outra captura quando se perceber para que servem.


def guardar_atalho(entity_id: int, ranhura: int, valor: int,
                   campo18: int = 0, campo22: int = 0) -> None:
    """Grava uma ranhura da barra. Valor zero apaga -- e assim que o client tira um atalho."""
    with _lock:
        con = _conectar()
        if con is None:
            return
        try:
            cur = con.cursor()
            if not valor:
                cur.execute("DELETE FROM dbo.personagem_atalho "
                            "WHERE entity_id = ? AND ranhura = ?",
                            int(entity_id), int(ranhura))
                return
            # ARRASTAR E MOVER, NAO COPIAR. O client manda so o DESTINO -- nunca avisa que a
            # ranhura de origem ficou vazia. Sem esta limpeza o atalho aparecia nos dois
            # sitios ao voltar a entrar, que foi o que o jogador viu em 30/08: "reloguei e
            # ficou o que mudei de lugar e aparece outro onde tava antes".
            #
            # Procurou-se um pacote de limpeza e nao existe: no minuto em que ele moveu
            # quatro atalhos so chegaram os quatro `06 0b` do destino. Logo o servidor
            # oficial faz esta mesma remocao, ou teria o mesmo defeito.
            #
            # SE UM DIA O JOGO DEIXAR pôr a mesma skill em duas ranhuras, e aqui que parte --
            # e o sinal sera o atalho antigo a desaparecer quando se quer os dois.
            cur.execute("DELETE FROM dbo.personagem_atalho "
                        "WHERE entity_id = ? AND valor = ? AND ranhura <> ?",
                        int(entity_id), int(valor), int(ranhura))
            cur.execute("UPDATE dbo.personagem_atalho SET valor = ?, campo18 = ?, campo22 = ? "
                        "WHERE entity_id = ? AND ranhura = ?",
                        int(valor), int(campo18), int(campo22),
                        int(entity_id), int(ranhura))
            if cur.rowcount == 0:
                cur.execute("INSERT INTO dbo.personagem_atalho "
                            "(entity_id, ranhura, valor, campo18, campo22) "
                            "VALUES (?, ?, ?, ?, ?)",
                            int(entity_id), int(ranhura), int(valor),
                            int(campo18), int(campo22))
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] guardar_atalho falhou: {str(e)[:120]}")


def atalhos_do_personagem(entity_id: int) -> dict:
    """{ranhura: (valor, campo18, campo22)}."""
    with _lock:
        con = _conectar()
        if con is None:
            return {}
        try:
            cur = con.cursor()
            cur.execute("SELECT ranhura, valor, campo18, campo22 "
                        "FROM dbo.personagem_atalho WHERE entity_id = ?", int(entity_id))
            return {int(a): (int(b), int(c), int(d)) for a, b, c, d in cur.fetchall()}
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] atalhos_do_personagem falhou: {str(e)[:120]}")
            return {}


def bonus_do_equipamento(entity_id: int) -> dict:
    """Soma as "Caracteristicas especiais" de tudo o que o personagem tem equipado.

    Devolve {nome_interno: (absoluto, percentual)}. Um mesmo tipo pode chegar pelas duas
    vias -- as Asas de anjo dao Saude +1004 absoluto, outro item pode dar Saude +5% -- e por
    isso as duas somas andam separadas ate a hora de aplicar.

    A fonte e Data/Items/ItemBonus.ini, com chave (item_id, refino): a mesma peca com refino
    diferente tem valores diferentes, e o refino ja vem no dicionario do equipamento.
    """
    if not entity_id:
        return {}
    tabela = data_files.agrupar_composto("ItemBonus", ("item_id", "refino"))
    if not tabela:
        return {}
    soma = {}

    def somar(tipo, valor, unidade):
        nome = TIPO_DE_BONUS.get(tipo)
        if nome is None or not valor:
            return
        abs_, pct = soma.get(nome, (0.0, 0.0))
        if str(unidade or "") == "%":
            soma[nome] = (abs_, pct + float(valor))
        else:
            soma[nome] = (abs_ + float(valor), pct)

    pecas = equipamento_do_personagem(entity_id)
    for peca in pecas.values():
        # as FIXAS do item, iguais em toda copia dele
        for linha in tabela.get((peca.get("item_id"), peca.get("refino")), ()):
            somar(linha.get("tipo"), linha.get("valor"), linha.get("unidade"))
    # e as desta INSTANCIA, escolhidas a mao ou (um dia) sorteadas no drop
    for inst, linhas in bonus_das_instancias(
            [p["instancia"] for p in pecas.values() if p.get("instancia")]).items():
        for tipo, valor, unidade in linhas:
            somar(tipo, valor, unidade)
    # E A FANTASIA, que vem por outra cadeia inteira (ItemPackage -> efeito -> codigo) e
    # nao pelo ItemBonus. Somada aqui de proposito: quem chama `bonus_do_equipamento` fica
    # com tudo, e nada a jusante precisou de mudar.
    for nome, (a, pc) in bonus_da_fantasia(entity_id).items():
        va, vp = soma.get(nome, (0.0, 0.0))
        soma[nome] = (va + a, vp + pc)
    # E AS PASSIVAS. Nao sao equipamento, mas entram pela mesma porta de proposito: quem
    # chama isto ja poe o resultado na ficha e no combate, e uma passiva de Destreza +2 tem
    # de contar nos dois sitios exactamente como um item que desse Destreza +2.
    for nome, (a, pc) in bonus_das_passivas(entity_id).items():
        va, vp = soma.get(nome, (0.0, 0.0))
        soma[nome] = (va + a, vp + pc)
    return soma


_grupos_de_classe = None


def item_da_classe(item_id: int, classe: int) -> int:
    """O equivalente desta peca para a classe dada. O proprio item quando nao ha troca.

    PORQUE EXISTE. O conteudo das caixas foi MEDIDO abrindo uma no oficial, e a personagem
    que a abriu era de uma classe so. O jogador em 30/08: *"a arqueira do nosso server,
    abrir caixa de iniciante, e veio set de vagabundo"*. A tabela CaixaConteudo guarda o
    que se viu -- e o que se viu foi o terno da classe 300.

    O CATALOGO DO CLIENT TEM A RESPOSTA. Cada peca traz `classe_cod`, e as versoes das sete
    classes existem todas, em blocos regulares de 48 itens:

        classe 100  100170446    classe 400  100170518    classe 800  100170590
        classe 200  100170470    classe 500  100170542
        classe 300  100170494    classe 600  100170566

    NAO SE FAZ ARITMETICA COM OS IDS. O passo de 24 entre blocos e verdadeiro nestes, mas
    seria uma regra tirada de uma familia de itens so. Agrupa-se por (nome, slot, nivel
    exigido) e troca-se pela peca que ocupa a MESMA POSICAO no grupo da classe pedida --
    isso vale mesmo que os ids nao sejam contiguos noutra familia.

    `classe_cod` zero quer dizer peca de toda a gente (as pocoes, as caixas intermedias) e
    essas passam intactas.
    """
    global _grupos_de_classe
    item_id, classe = int(item_id or 0), int(classe or 0)
    if not item_id or not classe:
        return item_id
    idx = data_files.indice("Item", "item_id") or {}
    linha = idx.get(item_id)
    if not linha:
        return item_id
    try:
        minha = int(linha.get("classe_cod") or 0)
    except (TypeError, ValueError):
        return item_id
    if minha == 0 or minha == classe:
        return item_id

    if _grupos_de_classe is None:
        _grupos_de_classe = {}
        for r in data_files.tabela("Item"):
            try:
                cc = int(r.get("classe_cod") or 0)
                if not cc:
                    continue
                chave = (str(r.get("nome") or ""), r.get("slot"), r.get("nivel_req"))
                _grupos_de_classe.setdefault(chave, {}).setdefault(cc, []).append(
                    int(r["item_id"]))
            except (TypeError, ValueError, KeyError):
                continue
        for g in _grupos_de_classe.values():
            for lista in g.values():
                lista.sort()

    chave = (str(linha.get("nome") or ""), linha.get("slot"), linha.get("nivel_req"))
    grupo = _grupos_de_classe.get(chave) or {}
    origem, destino = grupo.get(minha) or [], grupo.get(classe) or []
    if item_id not in origem or not destino:
        return item_id
    pos = origem.index(item_id)
    if pos >= len(destino):
        return item_id
    return destino[pos]


def dar_item(entity_id: int, item_id: int, quantidade: int = 1, refino: int = 0,
             opcoes: list | None = None, container: int | None = None,
             slot: int | None = None, dias: int = 0, efeito: int = 0) -> dict:
    """Poe um item na mochila (ou noutro container) e devolve o que aconteceu.

    Saiu de tools/dar_item.py para aqui em 28/08, quando a ferramenta web passou a precisar
    do mesmo caminho: duas copias da montagem do registo de 147 bytes seriam duas copias
    para corrigir de cada vez que um campo novo fosse decifrado.

    Devolve {"ok": bool, "erro": str|None, "instancia": int, "slot": int}.
    """
    saida = {"ok": False, "erro": None, "instancia": 0, "slot": None}
    cont = CONTAINER_MOCHILA if container is None else int(container)
    with _lock:
        con = _conectar()
        if con is None:
            saida["erro"] = "sem banco"
            return saida
        try:
            cur = con.cursor()
            cur.execute("SELECT 1 FROM dbo.personagem WHERE entity_id = ?", int(entity_id))
            if cur.fetchone() is None:
                saida["erro"] = "personagem %d nao existe" % entity_id
                return saida
            _ficha = data_files.indice("Item", "item_id").get(int(item_id))
            if _ficha is None:
                saida["erro"] = "item %d nao esta em Data/Item.ini" % item_id
                return saida

            # EMPILHAR EM VEZ DE ABRIR PILHA NOVA. O jogador acabou com quatro montes de
            # Carat -- 51, 15, 15 e 16 -- porque cada dar_item criava uma linha nova. Um
            # item empilhavel junta-se ao monte que ja la esta e so muda a quantidade, que
            # e o que o jogo faz.
            #
            # `empilha_max` vem do catalogo: 1 nas pecas, 100 nas pocoes, e um numero enorme
            # no Carat. Nao se ultrapassa o limite -- quando o monte enche, abre-se outro.
            _max = int(_ficha.get("empilha_max") or 1)
            if _max > 1 and slot is None:
                cur.execute(
                    "SELECT TOP 1 personagem_item_id, slot, quantidade, bruto "
                    "FROM dbo.personagem_item WHERE entity_id = ? AND container = ? "
                    "AND item_id = ? AND quantidade < ? ORDER BY slot",
                    int(entity_id), cont, int(item_id), _max)
                _linha = cur.fetchone()
                if _linha is not None:
                    _pid, _slot, _qtd, _bruto = _linha
                    _novo = min(_max, int(_qtd) + int(quantidade))
                    _b = bytearray(bytes(_bruto) if _bruto else bytes(147))
                    if len(_b) >= 20:
                        struct.pack_into("<I", _b, 16, _novo & 0xFFFFFFFF)
                    cur.execute("UPDATE dbo.personagem_item SET quantidade = ?, bruto = ? "
                                "WHERE personagem_item_id = ?", _novo, bytes(_b), int(_pid))
                    con.commit()
                    saida.update(ok=True, instancia=int(_pid), slot=int(_slot),
                                 empilhado=True)
                    return saida

            # MOLDE: um item que ALGUEM ja tenha. Os 147 bytes tem campos que ainda nao
            # estao todos decifrados, e herda-los de um registo real e mais seguro do que
            # inventar zeros. O de tools/dar_item.py exigia que fosse do PROPRIO
            # personagem, e por isso falhava num personagem recem-criado sem nada.
            cur.execute("SELECT TOP 1 bruto FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND bruto IS NOT NULL "
                        "ORDER BY personagem_item_id DESC", int(entity_id))
            molde = cur.fetchone()
            if molde is None:
                cur.execute("SELECT TOP 1 bruto FROM dbo.personagem_item "
                            "WHERE bruto IS NOT NULL ORDER BY personagem_item_id DESC")
                molde = cur.fetchone()
            r = bytearray(bytes(molde[0])) if molde else bytearray(147)
            if len(r) != 147:
                r = bytearray(bytes(r[:147]).ljust(147, bytes(1)))

            if slot is None:
                cur.execute("SELECT slot FROM dbo.personagem_item "
                            "WHERE entity_id = ? AND container = ?", int(entity_id), cont)
                ocupados = {int(l[0]) for l in cur.fetchall()}
                slot = next((s for s in range(SLOTS_MOCHILA) if s not in ocupados), None)
                if slot is None:
                    saida["erro"] = "container %d cheio" % cont
                    return saida

            r[0] = cont & 0xFF
            r[1] = int(slot) & 0xFF
            struct.pack_into("<I", r, 2, int(item_id))
            r[6] = 0                                        # nao equipado
            r[OFF_REFINO_BRUTO] = max(0, min(REFINO_MAX, int(refino))) & 0xFF
            # serial proprio, para nao repetir o do molde
            struct.pack_into("<H", r, 10,
                             (struct.unpack_from("<H", r, 10)[0] + 0x40) & 0xFFFF)
            struct.pack_into("<I", r, 16, int(quantidade) & 0xFFFFFFFF)
            # DATA DE AQUISICAO E VALIDADE. Sem estes dois o item nasce com o que estivesse
            # no molde copiado -- e o molde e de outra peca, com a data dela.
            #
            # ZERO NA VALIDADE E LIDO PELO CLIENT COMO EXPIRADO. Ver OFF_VALIDADE_BRUTO: uma
            # fantasia dada por aqui aparecia na boneca da janela e nao no personagem, e so
            # apareceu depois de eu lhe pôr uma data futura a mao no banco. Era este campo.
            #
            # `dias=0` deixa a validade a ZERO de proposito, que e o que uma peca permanente
            # traz -- a armadura e a arma do jogador tem zero aqui e funcionam. Quem quiser
            # um item temporario passa os dias.
            import time as _tm
            _agora = int(_tm.time())
            struct.pack_into("<I", r, 12, _agora)
            # ITENS DE HORAS NAO USAM VALIDADE: usam TEMPO RESTANTE, num float.
            #
            # Os 40 itens com a duracao em @120 (6, 12 e 24 horas) contam de outra maneira.
            # Medido no fio do oficial em 29/08, no "Ferro (24 horas)" (item 48143), seis
            # registos ao longo de 84 segundos:
            #
            #     [56:60] validade   ZERO nos seis
            #     [48:56] double     2433 -> 2363 -> 2343, a descer 1 por segundo
            #
            # Sao SEGUNDOS que faltam, em tempo real. Por isso um item destes com a validade
            # preenchida e o double a zero aparece "Expirou.": o client olha para o double,
            # nao para a validade.
            # A VALIDADE, PARA OS OUTROS, vem do catalogo e nao do `dias`. Um item
            # temporario que nasca com validade zero e lido como EXPIRADO. O `dias` continua
            # a valer, para dar um item por um prazo diferente do de fabrica.
            _dur_h = duracao_do_item(item_id, so_horas=True)
            if _dur_h and not dias:
                struct.pack_into("<d", r, OFF_TEMPO_RESTANTE, float(_dur_h))
                struct.pack_into("<I", r, OFF_VALIDADE_BRUTO, 0)
            else:
                struct.pack_into("<d", r, OFF_TEMPO_RESTANTE, 0.0)
                _dur = int(dias) * 86400 if dias else duracao_do_item(item_id)
                struct.pack_into("<I", r, OFF_VALIDADE_BRUTO,
                                 (_agora + _dur) if _dur else 0)

            # EFEITO DA FANTASIA. O molde traz o efeito da peca de onde foi copiado, e um
            # efeito de TORSO numa luva da ataque magico a quem nao devia. Aqui a peca
            # sorteia uma das duas opcoes que o ItemPackage lhe da -- que e o que o jogo faz
            # -- e as pecas que nao sao fantasia levam zero, como as do jogador ja tem.
            # NAO chamar isto `opcoes`: e o nome do parametro da funcao, e sombra-lo
            # entregava esta lista ao definir_bonus_da_instancia la em baixo.
            efeitos_possiveis = [int(l.get("efeito")) for l in
                                 data_files.agrupar("ItemPackage", "item_id")
                                 .get(int(item_id), ())]
            # A ESCOLHA DO JOGADOR MANDA, quando ela vem. A caixa que da a peca abre uma
            # janela "Selecione Opcoes" com as duas opcoes do ItemPackage, e o `06 02` traz
            # a escolhida nos bytes [17:21] -- medido no oficial em 30/08, onde as quatro
            # caixas finais mandaram eb1c, eb20, eb24 e eb28, ou seja 60188, 60192, 60196 e
            # 60200. Nas caixas anteriores da cadeia esse campo traz a propria entidade,
            # porque ali nao ha nada a escolher.
            #
            # VALIDA-SE contra as opcoes do item em vez de se confiar no numero: assim o
            # campo que traz a entidade cai fora sozinho, sem precisar de o distinguir.
            _esc = int(efeito or 0)
            if _esc and _esc not in efeitos_possiveis:
                _esc = 0
            struct.pack_into("<I", r, OFF_EFEITO_FANTASIA_BRUTO,
                             _esc or (_random.choice(efeitos_possiveis)
                                      if efeitos_possiveis else 0))

            cur.execute("INSERT INTO dbo.personagem_item "
                        "(entity_id, container, slot, item_id, quantidade, bruto) "
                        "OUTPUT INSERTED.personagem_item_id VALUES (?,?,?,?,?,?)",
                        int(entity_id), cont, int(slot), int(item_id),
                        int(quantidade), bytes(r))
            saida["instancia"] = int(cur.fetchone()[0])
            con.commit()
            saida["slot"] = int(slot)
            saida["ok"] = True
        except Exception as e:
            saida["erro"] = str(e)[:200]
            return saida
    if opcoes:
        definir_bonus_da_instancia(saida["instancia"], opcoes)
    return saida


def apagar_item(personagem_item_id: int) -> bool:
    """Tira um item do inventario, e com ele as opcoes que estavam presas a instancia."""
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("DELETE FROM dbo.personagem_item_bonus WHERE personagem_item_id = ?",
                        int(personagem_item_id))
            cur.execute("DELETE FROM dbo.personagem_item WHERE personagem_item_id = ?",
                        int(personagem_item_id))
            n = cur.rowcount
            con.commit()
            return n > 0
        except Exception as e:
            print(f"[db] apagar_item falhou: {str(e)[:150]}")
            return False


# AS OPCOES DENTRO DO REGISTO DE ITEM, para o client as DESENHAR na caixa de texto.
#
# Medido em 28/08 no 06 06 do oficial que transporta a "[+7 A poderosa arma osso de dragao]"
# (item 21210). A caixa de texto dela declara quatro caracteristicas especiais, e o fim do
# registo traz quatro u16 que sao INDICES do uniqueOptionType -- e batem uma a uma, com o
# valor incluido, porque o GRAU da tabela e o proprio numero que aparece no ecra:
#
#     registo[125] = 31   grupo 408 grau  5  ->  Poder magico. ataques   +5,00
#     registo[127] =  8   grupo 402 grau  3  ->  Vitalidade              +3,00
#     registo[129] = 26   grupo 406 grau  5  ->  Forca de Ataque Fisico  +5,00
#     registo[135] = 38   grupo 410 grau  2  ->  Classificacao de ataque +2,00
#
# QUATRO EM QUATRO, com o valor certo. Era isto que faltava para as opcoes de instancia
# aparecerem escritas no item em vez de so contarem em silencio.
#
# O QUE ESTA MEDIDO E O QUE NAO ESTA. A codificacao (u16, indice do uniqueOptionType) e a
# regiao estao medidas. O LAYOUT das ranhuras nao: so ha um item com opcoes em todas as
# capturas, e nele o 131 e o 133 ficam vazios enquanto o 135 esta cheio. Pode ser que a
# ranhura tenha significado proprio, ou pode ser so como aquele item calhou. Aqui escreve-se
# em ranhuras seguidas a partir da primeira; se o client mostrar tudo, o palpite serve.
OFF_OPCOES_NO_REGISTO = 125
N_OPCOES_NO_REGISTO = 10
"""DEZ ranhuras, de 125 a 143, e sao DUAS METADES. Medido no jogo em 31/08.

Escreveram-se onze opcoes diferentes numa bota, uma por ranhura possivel de 125 a 145, e o
client desenhou DEZ -- a do 145 nao apareceu. E desenhou-as em duas cores:

    [125] Forca +1                 branca         [127] Vitalidade +2            branca      |  as CINCO PRIMEIRAS sao as opcoes
    [129] Destreza +3              branca      |  normais do item
    [131] Sabedoria +5             branca      |
    [133] Inteligencia +10         branca     /
    [135] Chance de Critico +3     AMARELA        [137] Proteccao fisica +20     AMARELA     |  as CINCO SEGUINTES sao as do
    [139] Magico. proteccao +12    AMARELA     |  REFINO -- a bota estava a +12
    [141] Forca de Ataque Fisico +5 AMARELA    |
    [143] Classificacao de ataque +4 AMARELA  /
    [145] (escrita e ignorada)

O JOGADOR JA TINHA DITO: *"pode ter mais que uma amarela, vai de acordo com o
refinamento"*. Nao e uma cor que muda -- sao ranhuras diferentes que se enchem, e quantas
delas dependem do refino.

ISTO EXPLICA A ANOMALIA DE 28/08. O item do oficial tinha opcoes "em 125, 127, 129 e 135",
com dois buracos no meio, e ninguem percebia porque. Com as duas metades a leitura e obvia:
tres opcoes normais e UMA de refino. Nao havia buraco nenhum -- havia duas listas.

O numero foi 6 ate 31/08 (palpite), passou a 7 quando os 2.446 registos do oficial mostraram
a ranhura 137, e fixou-se em 10 com este teste."""


_opcoes_do_client = None


def catalogo_de_opcoes() -> dict:
    """{indice: (tipo, valor, unidade)} do uniqueOptionType, resolvido pelo skillData.

    E O CATALOGO DO QUE O CLIENT SABE DESENHAR num item, e a unica fonte fiavel do que cada
    indice significa.

    NAO SE DECODIFICA O CODIGO, e ja custou duas vezes. Ate 31/08 tirava-se o atributo de
    `grupo % 100` e o valor dos dois ultimos digitos do codigo. As duas contas estao erradas,
    e o jogador viu na tela: pusemos o indice 145 e o indice 24 numa bota e o client escreveu

        indice 145 (codigo 70404)   "Magico. proteccao +12,00"   nos diziamos Sabedoria +4
        indice  24 (codigo 40505)   "Sabedoria +5,00"            nos diziamos Inteligencia +5

    O skillData da os dois certos: 70404 e tipo 19 valor 12, e 40505 e tipo 4 valor 5. A
    familia 704 anda de tres em tres (3, 6, 9, 12 ... 30), entao o "grau" nunca foi o valor.

    E A MESMA ARMADILHA que o extrator de fantasia ja documentava -- ver o cabecalho de
    tools/extrair_efeitos_fantasia.py, que diz textualmente "nada aqui deve voltar a
    decodificar codigos". Voltou, noutro ficheiro.

    A unidade sai de onde o skillData poe o numero: `valor` e plano, `valor2` e percentagem.
    """
    global _opcoes_do_client
    if _opcoes_do_client is None:
        # A COLUNA `codigo` DO uniqueOptionType E UM SKILL_ID, e nao um codigo de efeito.
        #
        # Custou a aparecer porque os dois numeros vivem no mesmo espaco e quase sempre
        # coincidem: a skill 42710 tem o codigo 42710. O indice 81 e a excepcao que denuncia
        # -- ele tem 41303, e existem AS DUAS COISAS:
        #
        #     o CODIGO 41303 e "velocidade de ataque +15%"   <- era o que liamos
        #     a SKILL  41303 tem codigo 41712, "proteccao fisica +30"  <- e o que o client usa
        #
        # O jogador escreveu esse indice numa bota e o client escreveu "Proteccao fisica
        # +30,00". Repetiu-se numa luva, para ver se a leitura dependia do slot: deu igual.
        # E a leitura por skill_id que bate com a tela.
        por_skill = {}
        for r in data_files.tabela("SkillBuff"):
            try:
                sk = int(r.get("skill_id") or 0)
                if not sk or sk in por_skill:
                    continue
                tipo = int(r.get("tipo") or 0)
                plano = float(r.get("valor") or 0)
                pct = float(r.get("valor2") or 0)
                if plano:
                    por_skill[sk] = (tipo, plano, "abs")
                elif pct:
                    por_skill[sk] = (tipo, pct, "%")
            except (TypeError, ValueError):
                pass
        _opcoes_do_client = {}
        for r in data_files.tabela("UniqueOptionType"):
            try:
                idx, sk = int(r["indice"]), int(r.get("codigo") or 0)
            except (TypeError, ValueError, KeyError):
                continue
            achado = por_skill.get(sk)
            if achado:
                _opcoes_do_client[idx] = achado
    return _opcoes_do_client


def opcao_do_indice(indice: int):
    """(tipo, valor, unidade) que o client desenha para este indice. None se nao souber."""
    return catalogo_de_opcoes().get(int(indice or 0))


def indice_de_opcao(tipo: int, valor: float, unidade: str = "abs") -> int | None:
    """Indice do uniqueOptionType que representa (tipo, valor), ou None se nao existir.

    Procura no catalogo resolvido pelo skillData -- ver catalogo_de_opcoes. Devolver None e
    de proposito: uma opcao sem representacao continua a contar no combate e fica calada na
    caixa de texto, que e melhor do que a caixa anunciar um numero que o servidor nao aplica.
    """
    alvo = (int(tipo), float(valor), "%" if str(unidade) == "%" else "abs")
    for idx, (t, v, u) in sorted(catalogo_de_opcoes().items()):
        if t == alvo[0] and abs(v - alvo[1]) < 1e-6 and u == alvo[2]:
            return idx
    return None


def regen_mp(mp_max: int) -> int:
    """Quanto MP a regeneracao devolve por tique, de Data/Character/RegenMp.ini.

    Mesmo desenho do regen_hp e pela mesma razao: interpola-se entre os pontos medidos e
    fora da faixa devolve-se a ponta, sem extrapolar. Aqui a prudencia pesa ainda mais --
    sao TRES pontos contra os oito do HP.
    """
    linhas = sorted(((int(r["mp_max"]), int(r["passo"]))
                     for r in data_files.tabela("RegenMp") if r.get("mp_max")),
                    key=lambda x: x[0])
    if not linhas:
        return 3
    if mp_max <= linhas[0][0]:
        return linhas[0][1]
    if mp_max >= linhas[-1][0]:
        return linhas[-1][1]
    for (x0, y0), (x1, y1) in zip(linhas, linhas[1:]):
        if x0 <= mp_max <= x1:
            if x1 == x0:
                return y0
            return int(round(y0 + (y1 - y0) * (mp_max - x0) / float(x1 - x0)))
    return linhas[-1][1]


def regen_hp(hp_max: int) -> int:
    """Quanto HP a regeneracao devolve por tique, interpolado de Data/Character/Regen.ini.

    Medido no oficial em 28/08, oito pontos de seis personagens entre os niveis 2 e 68. NAO
    e proporcional ao maximo: vai de 5,9% do maximo ao nivel 2 para 1,5% ao nivel 28, o que
    quer dizer que o passo cresce muito mais devagar do que a barra. Interpola-se por hp_max
    porque e a coluna com mais pontos; fora da faixa medida devolve-se a ponta, sem
    extrapolar -- extrapolar uma curva de oito pontos era o mesmo erro que ja se fez hoje
    duas vezes.
    """
    linhas = sorted(((int(r["hp_max"]), int(r["passo"]))
                     for r in data_files.tabela("Regen") if r.get("hp_max")),
                    key=lambda x: x[0])
    if not linhas:
        return 12
    if hp_max <= linhas[0][0]:
        return linhas[0][1]
    if hp_max >= linhas[-1][0]:
        return linhas[-1][1]
    for (x0, y0), (x1, y1) in zip(linhas, linhas[1:]):
        if x0 <= hp_max <= x1:
            if x1 == x0:
                return y0
            return int(round(y0 + (y1 - y0) * (hp_max - x0) / (x1 - x0)))
    return linhas[-1][1]


def bonus_de_exp_do_equipamento(entity_id: int) -> float:
    """Percentual de EXP que o equipamento da. Entra como `extras` em calcular_exp.

    Soma com os modificadores globais em vez de multiplicar, que e a regra ja escolhida para
    os bonus de EXP -- ver o comentario grande sobre `ganho = base * (1 + soma/100)`.
    """
    return bonus_do_equipamento(entity_id).get("experiencia", (0.0, 0.0))[1]


def multiplicadores_de_ataque(entity_id: int) -> tuple[float, float]:
    """(multiplicador fisico, multiplicador magico) das opcoes em percentagem.

    "Forca de ataque" (tipo 71) sobe os dois; "Forca de Ataque Fisico" e "Poder magico.
    ataques" sobem um so, quando vem em percentagem em vez de valor fixo. Devolve 1.0 quando
    nao ha nada -- multiplicar por 1 e mais barato de ler no sitio de uso do que um `if`.
    """
    b = bonus_do_equipamento(entity_id)
    comum = b.get("poder_de_ataque", (0.0, 0.0))[1]
    fis = comum + b.get("atq_fisico", (0.0, 0.0))[1]
    mag = comum + b.get("atq_magico", (0.0, 0.0))[1]
    return 1.0 + fis / 100.0, 1.0 + mag / 100.0


def bonus_das_instancias(ids: list) -> dict:
    """{personagem_item_id: [(tipo, valor, unidade), ...]} para varias instancias de uma vez.

    Uma consulta so em vez de uma por peca: o equipamento tem ate 32 ranhuras e isto e
    chamado a cada montagem de ficha.
    """
    if not ids:
        return {}
    with _lock:
        con = _conectar()
        if con is None:
            return {}
        try:
            marcas = ",".join("?" * len(ids))
            cur = con.cursor()
            cur.execute("SELECT personagem_item_id, tipo, valor, unidade "
                        "FROM dbo.personagem_item_bonus "
                        "WHERE personagem_item_id IN (%s) ORDER BY ordem" % marcas,
                        *[int(i) for i in ids])
            saida = {}
            for inst, tipo, valor, unidade in cur.fetchall():
                saida.setdefault(int(inst), []).append((int(tipo), float(valor), str(unidade)))
            return saida
        except Exception as e:
            print(f"[db] bonus_das_instancias falhou: {str(e)[:150]}")
            return {}


def definir_bonus_da_instancia(personagem_item_id: int, opcoes: list) -> bool:
    """Troca as opcoes de UMA instancia de item. `opcoes` e [(tipo, valor, unidade), ...].

    Substitui tudo o que la estava: lista vazia limpa. E assim de proposito -- a ferramenta
    de administracao manda sempre o conjunto completo, e uma escrita incremental deixaria
    sobras de uma edicao anterior sem ninguem dar por isso.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            cur.execute("DELETE FROM dbo.personagem_item_bonus WHERE personagem_item_id = ?",
                        int(personagem_item_id))
            for ordem, (tipo, valor, unidade) in enumerate(opcoes):
                cur.execute("INSERT INTO dbo.personagem_item_bonus "
                            "(personagem_item_id, ordem, tipo, valor, unidade) "
                            "VALUES (?,?,?,?,?)",
                            int(personagem_item_id), ordem, int(tipo), float(valor),
                            "%" if str(unidade) == "%" else "abs")
            con.commit()
            return True
        except Exception as e:
            print(f"[db] definir_bonus_da_instancia falhou: {str(e)[:150]}")
            return False


def dano_da_arma(entity_id: int, magico: bool = False) -> tuple[int, int] | None:
    """(dano_min, dano_max) somando TODO o equipamento. None se nada der dano.

    O nome ficou de quando so a arma contava; hoje soma toda a peca equipada que declare
    dano -- ver o comentario da soma, mais abaixo. Nao renomeei para nao mexer nas chamadas.

    ATENCAO: o golpe BASICO usa o dano FISICO, mesmo em cajado. Ate 23/08 esta funcao pegava
    `max(fisico, magico)` e a Maga batia 8-13 no basico -- o dano magico da Bengala -- em vez
    dos 4-9 fisicos dela. A propria ficha do jogo separa os dois campos, ATQ Fisico e ATQ
    Magico, e o basico e o fisico. O magico e das skills, e sai por `magico=True`.
    """
    # SOMA TUDO O QUE ESTA EQUIPADO, e nao so o slot da arma.
    #
    # Media no oficial em 27/08, mesma Feiticeira, sempre no mesmo Tirano sanguinario, sempre
    # ataque basico:
    #
    #     sem nada equipado          17 cravado
    #     so as "Asas do Caos"       42 a 51
    #     asas mais Bengala-foice    59 a 80
    #
    # As asas ocupam o slot proprio delas e valem 13,0 a 30,0 de dano na tabela. Lendo so o
    # SLOT_ARMA, nada disso entrava: para nos, equipar as asas nao mudava um ponto de dano --
    # que foi o que o jogador descreveu, "quando equipo uma asa ou arma mais forte tem que
    # aumentar o dano que causo tbm".
    #
    # A soma e do min com o min e do max com o max: cada peca alarga a faixa nas duas pontas.
    lo = hi = 0.0
    chave_min = "dano_mag_min" if magico else "dano_min"
    chave_max = "dano_mag_max" if magico else "dano_max"
    for peca in equipamento_do_personagem(entity_id).values():
        lo += float(peca.get(chave_min) or 0.0)
        hi += float(peca.get(chave_max) or 0.0)
    if hi <= 0:
        return None
    # devolve o valor CHEIO. Arredondar aqui estreitava a faixa do golpe: a adaga e
    # 4,18-6,43 e virava 4-6, o que somado a base dava 14-16 em vez de 15,13-17,38.
    return float(lo), float(hi)


ITEM_PROTECTOR = 0
"""O item que serve de protector. ZERO = ainda nao se sabe qual e.

A tabela ItemEnchant diz QUANTOS a tentativa consome (a coluna `custo`), nao QUAL. O jogador
descreveu o efeito -- *"com protector nunca quebra"* -- e a categoria 25 da tabela lista doze
itens ("Proteccao de fletta", "Asas do Caos"), mas esses sao os que PODEM SER refinados com
custo zero, nao os que se gastam.

Enquanto for zero, `refinar` aceita o parametro `protegido` e confia em quem chama: e o
painel que decide. Quando se souber o item, poe-se aqui e o consumo passa a ser automatico.
"""

CATEGORIA_PROTECCAO = 25
"""A categoria das proteccoes na tabela de encantamento. Custo zero em todos os niveis."""

_taxas_refino = None


def taxas_de_refino(nivel: int, categoria: int, bloco: int = 0):
    """(sucesso, quebra, custo) para subir um item de `nivel` para `nivel`+1.

    As percentagens sao FATIAS DO TOTAL, e nao condicionais: o que sobra de sucesso+quebra e
    falha simples, que nao estraga nada. Medido -- nenhum dos 90 registos passa de 100.

    O `custo` e quantos protectores a tentativa consome. Cresce com o nivel e a ARMA CUSTA
    MAIS do que a defesa: no +18 sao 45 contra 38. Do +1 ao +3 e zero, porque nesses niveis
    nao ha risco nenhum.

    Devolve None quando nao ha linha -- e nesse caso nao se refina, em vez de se inventar
    uma taxa.
    """
    global _taxas_refino
    if _taxas_refino is None:
        _taxas_refino = {}
        for linha in data_files.tabela("ItemEnchant"):
            try:
                chave = (int(linha["bloco"]), int(linha["nivel"]), int(linha["categoria"]))
                if chave in _taxas_refino:
                    continue
                _taxas_refino[chave] = (float(linha["sucesso"]), float(linha["quebra"]),
                                        int(linha.get("custo") or 0))
            except (TypeError, ValueError, KeyError):
                pass
    return _taxas_refino.get((int(bloco), int(nivel), int(categoria)))


def refinar(entity_id: int, instancia: int, protegido: bool = False,
            bloco: int = 0, sorteio=None) -> dict:
    """Tenta subir o refino de UMA peca. Devolve o que aconteceu.

        {"ok", "erro", "resultado", "de", "para", "custo", "sucesso", "quebra"}

    `resultado` e "sucesso", "falha" ou "quebrou".

    PROTEGIDO NAO E UMA TAXA, E UM INTERRUPTOR. O jogador: *"com protector nunca quebra, mas
    quando falha sem os protector, corre risco do item quebrar e ele some, nao e toda vez, as
    vezes so falha mesmo"*. Nenhum dos dois blocos da tabela tem quebra zero, entao a
    proteccao nao esta nos dados -- e uma decisao de codigo, e e esta linha.

    `sorteio` existe para os testes: passando uma funcao que devolve um numero fixo, o
    resultado deixa de depender do acaso.

    O QUE ESTA POR LIGAR: o pedido do jogo. Nao se sabe que opcode o client manda ao refinar
    -- ha candidatos por tratar (06 03, 06 0c, 06 0e, 06 1e) e escolher um por palpite era
    convidar o erro. Ate la isto chama-se do painel de administracao.
    """
    saida = {"ok": False, "erro": None, "resultado": None, "de": 0, "para": 0,
             "custo": 0, "sucesso": 0.0, "quebra": 0.0}
    it = None
    for x in inventario_do_personagem(entity_id):
        if int(x.get("instancia") or 0) == int(instancia):
            it = x
            break
    if it is None:
        saida["erro"] = "instancia %s nao e deste personagem" % instancia
        return saida

    bruto = bytearray(it.get("bruto") or b"")
    if len(bruto) <= OFF_REFINO_BRUTO:
        saida["erro"] = "o item nao tem registo"
        return saida
    nivel = int(bruto[OFF_REFINO_BRUTO])
    saida["de"] = nivel
    if nivel >= REFINO_MAX:
        saida["erro"] = "ja esta no maximo (+%d)" % REFINO_MAX
        return saida

    ficha = data_files.indice("Item", "item_id").get(int(it["item_id"])) or {}
    try:
        categoria = int(ficha.get("categoria") or 0)
    except (TypeError, ValueError):
        categoria = 0
    taxas = taxas_de_refino(nivel + 1, categoria, bloco)
    if taxas is None:
        saida["erro"] = ("nao ha taxa para +%d na categoria %d" % (nivel + 1, categoria))
        return saida
    sucesso, quebra, custo = taxas
    saida.update({"custo": custo, "sucesso": sucesso, "quebra": quebra, "para": nivel + 1})

    r = (sorteio or random.random)() * 100.0
    if r < sucesso:
        bruto[OFF_REFINO_BRUTO] = nivel + 1
        saida["resultado"] = "sucesso"
    elif r < sucesso + quebra and not protegido:
        saida["resultado"] = "quebrou"
    else:
        saida["resultado"] = "falha"

    with _lock:
        con = _conectar()
        if con is None:
            saida["erro"] = "sem banco"
            return saida
        try:
            cur = con.cursor()
            if saida["resultado"] == "quebrou":
                cur.execute("DELETE FROM dbo.personagem_item_bonus "
                            "WHERE personagem_item_id = ?", int(instancia))
                cur.execute("DELETE FROM dbo.personagem_item "
                            "WHERE personagem_item_id = ?", int(instancia))
            elif saida["resultado"] == "sucesso":
                cur.execute("UPDATE dbo.personagem_item SET bruto = ? "
                            "WHERE personagem_item_id = ?", bytes(bruto), int(instancia))
            con.commit()
            saida["ok"] = True
        except Exception as e:                                      # noqa: BLE001
            saida["erro"] = str(e)[:150]
            print("[db] refinar falhou: %s" % saida["erro"])
    return saida


def defesa_total(entity_id: int) -> tuple[int, int]:
    """(defesa fisica, defesa magica) somando tudo que esta equipado.

    INCLUI AS CARACTERISTICAS ESPECIAIS, e ate 31/08 nao incluia. Somava-se so o campo
    `defesa` de cada peca -- o numero do catalogo -- e ficava de fora tudo o que vem por
    `bonus_do_equipamento`: as opcoes do ItemBonus, as da instancia, as passivas e a
    FANTASIA.

    O buraco media-se assim, no personagem SinFear com o Traje do vencedor vestido:

        a ficha mostrava     proteccao fisica  51 + 148 = 199
        o combate usava      defesa_total()          51

    Os +148 das quatro pecas apareciam na tela e o mob batia como se nao existissem. O
    jogador perguntou se os atributos da fantasia eram *"so visual no nosso"* -- para a
    proteccao, eram.

    SOMA-SE O ABSOLUTO E SO O ABSOLUTO, de proposito: e exactamente o que o world_server
    escreve na ficha (ver o bloco PROTECCAO E CRITICO la, que usa `bonus_de_opcoes`, os
    absolutos de `bonus_do_equipamento`). Se aqui entrasse tambem a percentagem, o combate
    passaria a usar um numero que a ficha nao mostra, e a divergencia so trocaria de lado.
    A percentagem fica por ligar NOS DOIS sitios ao mesmo tempo, quando houver medicao.
    """
    fis = mag = 0.0
    for d in equipamento_do_personagem(entity_id).values():
        fis += d.get("defesa", 0.0)
        mag += d.get("defesa_mag", 0.0)
    try:
        b = bonus_do_equipamento(entity_id)
        # O ABSOLUTO SOMA, O PERCENTUAL MULTIPLICA -- a mesma conta da ficha (ver o bloco
        # PROTECCAO E CRITICO no world_server). O percentual entrou em 31/08 com as 104
        # opcoes do client do Arena, que sao quase todas percentuais; sem isto a ficha
        # mostrava 329 de proteccao e o combate usava 305.
        fis = (fis + float(b.get("protecao_fisica", (0.0, 0.0))[0])) * (
            1.0 + float(b.get("protecao_fisica", (0.0, 0.0))[1]) / 100.0)
        mag = (mag + float(b.get("protecao_magica", (0.0, 0.0))[0])) * (
            1.0 + float(b.get("protecao_magica", (0.0, 0.0))[1]) / 100.0)
    except Exception as e:                                          # noqa: BLE001
        print(f"[db] defesa_total: bonus falhou: {str(e)[:120]}")
    return int(round(fis)), int(round(mag))


_cache_classe: dict[int, dict] = {}


def classe_base(classe_id: int) -> dict:
    """Bases da classe: HP/MP iniciais, as cinco caracteristicas, ataque fisico base e se e
    classe magica. Vem de Data/Class.ini, que bate com as sete fichas do oficial.

    ATENCAO: so as 7 classes BASE tem linha. As 48 promovidas que a ClassEvolution.ini
    referencia -- 110, 111, 820... -- ainda nao existem aqui, e por isso a promocao nao tem
    de onde tirar os status novos.
    """
    linha = data_files.indice("Class", "classe_id").get(int(classe_id))
    if not linha:
        return {}
    campos = ("hp_base", "mp_base", "forca", "destreza", "vitalidade", "inteligencia",
              "sabedoria", "atq_fisico_base", "magica")
    return {n: linha.get(n) for n in campos}


# ---------------------------------------------------------------------------
# Equipar / desequipar
# ---------------------------------------------------------------------------
# Pacotes decodificados do binario em 23/08 (RTTI do AMo.exe):
#     06 00  CItemWearPacket    equipar     (18 bytes)
#     06 01  CItemPutoffPacket  desequipar  (17 bytes)
#
# Layout, IGUAL nos dois sentidos -- o pedido do client e a confirmacao do servidor tem a
# mesma forma, o servidor so devolve o mesmo pacote quando aceita:
#     [6:10]  entity_id      BE u32
#     [10]    0
#     [11]    slot de equipamento
#     [12]    o mesmo slot de novo
#     [13:17] id do item     BE u32   <- quatro bytes, ver a nota do build_inventario
#     [17]    0              (so no 06 00)
#
# Nem o pedido nem a confirmacao dizem PRA ONDE o item vai na mochila: quem escolhe e o
# servidor. Por isso desequipar procura o primeiro slot livre do container 1.

CONTAINER_MOCHILA = 1
SLOTS_MOCHILA = 60
# Marcador temporario usado no meio de uma troca (tira um item do caminho antes de por o
# outro). NAO pode ser -1: a coluna slot e tinyint, e o -1 estourava com "arithmetic overflow"
# -- a troca falhava calada e o item ficava onde estava.
# REFINO: byte 7 dos 147 do registro de item. HIPOTESE do binario, nao medida no fio --
# nenhum item das capturas era refinado. O handler da lista de inventario (FUN_00657e08) le
# `_Src[6]` (slot) e `_Src[7]` como campos separados, e joga o segundo em +0xf3 do objeto
# item -- o mesmo +0xf3 que o handler do equipar enche com o byte 17 do pacote 06 00.
OFF_REFINO_BRUTO = 7
REFINO_MAX = 18
"""Refino maximo. Passou de 16 para 18 em 31/08, com o ItemEnchant do client do Arena.

O 16 NUNCA FOI VERDADE: a tabela do client de 2019 (ItemEnchant.lod) so tem niveis de 1 a
13 -- 65 registos, cinco por nivel. Pedir +14 ou +16 dava um numero que o client nao sabia
desenhar. A do Arena tem 90 registos, de 1 a 18, e e essa que la esta agora.

O BRILHO VEM DAQUI: e do proprio nivel de refino no byte 7 do registo do item que o client
desenha o efeito -- nao ha campo separado. Trazer a tabela traz os brilhos dos niveis 14 a
18 junto."""

SLOT_TEMPORARIO = 255


def _mover_item(cur, entity_id: int, item_id: int, de_container: int,
                para_container: int, para_slot: int, de_slot: int | None = None) -> bool:
    """Move UMA linha. `de_slot` desambigua quando ha copias do mesmo item.

    Ate 24/08 o UPDATE nao tinha clausula de slot: com duas copias do mesmo item_id no
    mesmo container, ele movia AS DUAS pro mesmo destino de uma vez, calado. Apareceu
    dando um segundo Arco grande (13001) pra quem ja tinha um -- desequipar o de cima
    mexia no de baixo, e o dano vinha do item errado.

    O TOP 1 e a rede de seguranca: mesmo sem `de_slot` (chamador antigo), no maximo uma
    linha se mexe.
    """
    if de_slot is None:
        cur.execute(
            "UPDATE dbo.personagem_item SET container = ?, slot = ? WHERE personagem_item_id = "
            "(SELECT TOP 1 personagem_item_id FROM dbo.personagem_item "
            " WHERE entity_id = ? AND item_id = ? AND container = ? ORDER BY slot)",
            para_container, para_slot, entity_id, item_id, de_container)
    else:
        cur.execute(
            "UPDATE dbo.personagem_item SET container = ?, slot = ? WHERE personagem_item_id = "
            "(SELECT TOP 1 personagem_item_id FROM dbo.personagem_item "
            " WHERE entity_id = ? AND item_id = ? AND container = ? AND slot = ?)",
            para_container, para_slot, entity_id, item_id, de_container, int(de_slot))
    return cur.rowcount > 0


def primeiro_slot_livre(entity_id: int, container: int = CONTAINER_MOCHILA) -> int | None:
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT slot FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND container = ?", entity_id, container)
            ocupados = {int(r[0]) for r in cur.fetchall()}
        except Exception as e:
            print(f"[db] primeiro_slot_livre falhou: {str(e)[:150]}")
            return None
    for s in range(SLOTS_MOCHILA):
        if s not in ocupados:
            return s
    return None


def pode_equipar(personagem: dict, item_id: int) -> tuple[bool, str]:
    """(pode, motivo). Confere slot, classe e nivel minimo contra Data/Item.ini."""
    linha = data_files.indice("Item", "item_id").get(int(item_id))
    if linha is None:
        return False, f"item {item_id} nao existe no catalogo"
    nome = linha.get("nome")
    slot = linha.get("slot")
    classe_req = linha.get("classe_req")
    nivel_req = linha.get("nivel_req")
    if not slot:
        return False, f"'{nome}' nao e equipavel"
    if classe_req and int(classe_req) != int(personagem.get("classe", 0)):
        return False, f"'{nome}' e da classe {classe_req}"
    if nivel_req and int(nivel_req) > int(personagem.get("nivel", 1)):
        return False, f"'{nome}' pede nivel {nivel_req}"
    return True, ""


def slot_do_item(item_id: int) -> int | None:
    """Slot de equipamento proprio do item, do catalogo. None se nao for equipavel.

    O `[11]` do pedido do client e o slot de ORIGEM, nao o destino: ao desequipar vem o slot
    do equipamento, mas ao equipar vem o slot da MOCHILA onde o item estava. Por isso o
    destino tem que sair daqui, e nao do pacote -- se sair do pacote, a Adaga tenta ir pro
    slot 3 (luvas) e o pedido morre.
    """
    linha = data_files.indice("Item", "item_id").get(int(item_id))
    if not linha:
        return None
    v = linha.get("slot")
    return int(v) if v else None


def equipar(entity_id: int, item_id: int, slot: int | None = None,
            de_slot: int | None = None) -> dict | None:
    """Move o item da mochila pro slot de equipamento DELE. Se o slot ja estiver ocupado, o
    antigo vai pra mochila.

    Devolve {'slot': n, 'trocado': item_id ou None, 'trocado_instancia': id ou None}.

    O `trocado_instancia` entrou em 28/08: `trocado` sozinho e o item_id, e com duas copias
    do mesmo item -- duas Bengalas, uma +0 vestida e uma +13 na mochila -- quem chama nao
    consegue dizer QUAL delas foi deslocada. O resultado era a peca deslocada nunca receber o
    pacote de "fui para a mochila" e sumir da tela ate ao proximo login.
    """
    destino = slot_do_item(item_id) if slot is None else int(slot)
    if not destino:
        return None
    antigo_id = antiga_instancia = None
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT item_id, personagem_item_id FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND container = ? AND slot = ?",
                        entity_id, CONTAINER_EQUIPADO, destino)
            antigo = cur.fetchone()
            antigo_id = int(antigo[0]) if antigo else None
            antiga_instancia = int(antigo[1]) if antigo else None
            if antigo_id is not None:
                cur.execute("UPDATE dbo.personagem_item SET container = ?, slot = ? "
                            "WHERE entity_id = ? AND container = ? AND slot = ?",
                            CONTAINER_MOCHILA, SLOT_TEMPORARIO, entity_id,
                            CONTAINER_EQUIPADO, destino)
            if not _mover_item(cur, entity_id, int(item_id), CONTAINER_MOCHILA,
                               CONTAINER_EQUIPADO, destino, de_slot=de_slot):
                if antigo_id is not None:   # desfaz: o antigo volta pro slot
                    cur.execute("UPDATE dbo.personagem_item SET container = ?, slot = ? "
                                "WHERE entity_id = ? AND container = ? AND slot = ?",
                                CONTAINER_EQUIPADO, destino, entity_id, CONTAINER_MOCHILA,
                                SLOT_TEMPORARIO)
                return None
        except Exception as e:
            print(f"[db] equipar falhou: {str(e)[:150]}")
            return None
    if antigo_id is not None:
        livre = primeiro_slot_livre(entity_id)
        with _lock:
            con = _conectar()
            if con is not None:
                try:
                    con.cursor().execute(
                        "UPDATE dbo.personagem_item SET slot = ? "
                        "WHERE entity_id = ? AND container = ? AND slot = ?",
                        livre if livre is not None else 0, entity_id, CONTAINER_MOCHILA,
                        SLOT_TEMPORARIO)
                except Exception as e:
                    print(f"[db] equipar (recolocar o antigo) falhou: {str(e)[:150]}")
    return {"slot": destino, "trocado": antigo_id,
            "trocado_instancia": antiga_instancia}


def desequipar(entity_id: int, item_id: int, slot: int) -> int | None:
    """Move o item do slot de equipamento pro primeiro slot livre da mochila.
    Devolve o slot da mochila, ou None se nao deu."""
    livre = primeiro_slot_livre(entity_id)
    if livre is None:
        return None
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            if not _mover_item(cur, entity_id, int(item_id), CONTAINER_EQUIPADO,
                               CONTAINER_MOCHILA, livre):
                return None
        except Exception as e:
            print(f"[db] desequipar falhou: {str(e)[:150]}")
            return None
    return livre


def mover_item(entity_id: int, item_id: int, para_container: int, para_slot: int) -> dict | None:
    """Move um item para outra posicao. Se o destino estiver ocupado, TROCA os dois.

    Pedido do client (06 08, CItemInvenSwapPacket):
        [10] container de origem   [11] slot de origem
        [12:16] id do item BE      [16] container de destino   [17] slot de destino

    Devolve {'antes': (cont, slot), 'trocado': dict do outro item ou None}, ou None se falhar.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT container, slot FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND item_id = ?", entity_id, int(item_id))
            origem = cur.fetchone()
            if origem is None:
                return None
            de_container, de_slot = int(origem[0]), int(origem[1])
            if (de_container, de_slot) == (int(para_container), int(para_slot)):
                return None
            cur.execute("SELECT item_id FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND container = ? AND slot = ?",
                        entity_id, int(para_container), int(para_slot))
            ocupante = cur.fetchone()
            outro_id = int(ocupante[0]) if ocupante else None
            if outro_id is not None:
                # troca: o ocupante vai pra origem, passando por um slot temporario
                cur.execute("UPDATE dbo.personagem_item SET slot = ? "
                            "WHERE entity_id = ? AND container = ? AND slot = ?",
                            SLOT_TEMPORARIO, entity_id, int(para_container), int(para_slot))
            cur.execute("UPDATE dbo.personagem_item SET container = ?, slot = ? "
                        "WHERE entity_id = ? AND item_id = ?",
                        int(para_container), int(para_slot), entity_id, int(item_id))
            if outro_id is not None:
                cur.execute("UPDATE dbo.personagem_item SET container = ?, slot = ? "
                            "WHERE entity_id = ? AND item_id = ?",
                            de_container, de_slot, entity_id, outro_id)
        except Exception as e:
            print(f"[db] mover_item falhou: {str(e)[:150]}")
            return None
    _cache_attr.clear()
    return {"antes": (de_container, de_slot),
            "trocado": outro_id,
            "trocado_para": (de_container, de_slot) if outro_id else None}


def defesa_do_mob(mob_id: int = 1) -> int:
    """Defesa fisica do mob, subtraida do ataque a cada golpe.

    Medida por diferenca no oficial (23/08): de maos vazias o ataque do Vagabundo nivel 1 e 10
    e o dano saiu 7 cravado; com a adaga o ataque e 15-17 e o dano saiu 12-14. Menos 3 nos
    dois casos. Nao e um numero que apareca em pacote nenhum -- e o que sobra da conta.
    """
    ficha = data_files.indice("Mob", "mob_id").get(int(mob_id))
    if not ficha:
        return 0
    return int(ficha.get("defesa") or 0)


# ---------------------------------------------------------------------------
# Campos da ficha que o COMBATE tambem usa
# ---------------------------------------------------------------------------
# Sao as mesmas formulas do 08 02 (ver CAMPOS_DERIVADOS no world_server), levantadas contra as
# sete classes do oficial com erro zero. Ficam aqui porque o combate precisa delas sem montar
# pacote nenhum.
#
# A chance de critico e uma PORCENTAGEM: o jogador confirmou que quanto maior o numero da
# ficha, mais o personagem crita, e que 100 significaria critar quase sempre. Entao ela nao
# pode ser constante -- o Vagabundo tem 7,1 (Des 11) e o Artilheiro 4,3 (Des 5).


def atq_fisico_base(nivel: int) -> float:
    """ATQ Fisico do personagem SEM equipamento nenhum, pelo nivel.

    Medido em duas fichas de Feiticeira, 27/08, subtraindo o (+X) que a ficha mostra entre
    parentesis -- que e exatamente a soma do que esta vestido:

        nivel  1   ficha 11 (+4) e 16 (+9)    ->  base 7 e 7
        nivel 68   ficha 37 (+10) e 49 (+23)  ->  base 27 e 26

    MIN E MAX SAO O MESMO NUMERO. O ataque base nao tem faixa: a variacao toda vem do
    equipamento. E por isso que, sem arma nem asas, o dano no Tirano sanguinario saiu 17
    cravado em dez golpes seguidos -- nao havia nada a sortear.

    Dois pontos definem a reta mas nao provam que ela e reta. Se aparecer uma ficha de nivel
    intermedio que caia fora, e porque a curva tem outra forma.
    """
    return 6.71 + 0.291 * max(1, int(nivel or 1))


def protecao_base(nivel: int) -> float:
    """Protecao fisica do personagem SEM equipamento, pelo nivel. Mesmas duas fichas:

        nivel  1   ficha  56 (+35)  ->  base 21
        nivel 68   ficha 102 (+51)  ->  base 51

    O (+35) e o (+51) batem certo com a soma das pecas na tabela ItemOption, o que valida a
    leitura dos dois lados.
    """
    return 20.55 + 0.448 * max(1, int(nivel or 1))


def chance_critico(personagem: dict) -> float:
    """Chance de critico em PORCENTAGEM, do jeito que a ficha mostra.

    Destreza * 0,452 + 2,08 + 0,0604 * (nivel - 1).

    A parte da destreza vinha das fichas de Vagabundo (Des 11 -> 7,1), Arqueira (Des 10 ->
    6,6) e Artilheiro (Des 5 -> 4,3), todas de personagem novo. O termo do NIVEL faltava e so
    apareceu com duas fichas da MESMA classe em niveis diferentes, em 27/08:

        Feiticeira nivel  1, Destreza  9 -> ficha 6,1    so a destreza da 6,15   bate
        Feiticeira nivel 68, Destreza 11 -> ficha 11,1   so a destreza da 7,05   falha por 4

    Quatro pontos de diferenca em 67 niveis sao 0,0604 por nivel, e com esse termo as duas
    fichas fecham. As tres fichas antigas nao denunciavam nada porque eram todas de nivel 1,
    onde o termo vale zero.
    """
    return (personagem.get("destreza", 0) * 0.452 + 2.08
            + 0.0604 * max(0, int(personagem.get("nivel", 1)) - 1))


def chance_bloqueio(personagem: dict) -> float:
    """Chance de bloqueio em porcentagem. Destreza * 0,1 + 8. A ficha TRUNCA, nao arredonda.

    Conferido nas duas fichas de Feiticeira de 27/08: Destreza 9 da 8,9 e a ficha mostra 8;
    Destreza 11 da 9,1 e mostra 9. Arredondar poria 9 no primeiro caso e daria erro.

    ATENCAO: quem bloqueia e quem APANHA. Isto vale pro personagem defendendo, nao atacando.
    Pro golpe do jogador no mob, o que importa e o bloqueio do MOB, que ainda nao temos.
    """
    return personagem.get("destreza", 0) * 0.1 + 8.0


def chance_evasao(personagem: dict) -> float:
    """Chance de evasao em porcentagem. Destreza * 0,05 + 6,5. Mesma ressalva do bloqueio.

    Tambem truncada na ficha: Destreza 9 da 6,95 e mostra 6; Destreza 11 da 7,05 e mostra 7.
    E a captura de 27/08 confirma pelo fio -- a bandeira 6, de dano zero por esquiva, saiu em
    7,4 por cento dos 108 golpes que a Feiticeira de Destreza 11 levou.
    """
    return personagem.get("destreza", 0) * 0.05 + 6.5

# ---------------------------------------------------------------------------
# Missoes: a definicao vem do arquivo, o progresso vem do banco
# ---------------------------------------------------------------------------
# A missao em si esta em Data/Quests/Quest.ini -- 730 delas, com doador, alvo, quantidade e
# recompensa. Nada disso muda enquanto o servidor roda, entao nao tem por que estar no banco.
#
# O que vai pro banco e SO o que o jogador fez, em dbo.personagem_missao. E o mesmo criterio
# do resto do projeto: arquivo pra definicao, banco pra progresso.

MISSAO_ACEITA, MISSAO_CONCLUIDA = 0, 1

# Como a missao se repete. Vem da coluna `repeticao` do Quest.ini; enquanto ela nao estiver
# preenchida, TUDO e tratado como unica.
#
# O padrao nao e neutro, e escolhido: marcar de menos deixa o jogador sem poder refazer uma
# diaria, o que e chato e reversivel; marcar de mais deixa ele reentregar uma missao unica e
# farmar a recompensa, o que corrompe o progresso. Erra-se pro lado barato.
REPET_UNICA, REPET_DIARIA, REPET_LIVRE = "unica", "diaria", "repetivel"


def _ficha_missao(quest_id):
    return data_files.indice("Quest", "quest_id").get(int(quest_id))


def ciclo_da_missao(quest_id, entity_id=None, hoje=None) -> str:
    """A que rodada pertence uma aceitacao. E o que separa os tres tipos de repeticao.

        unica       vazio          uma linha pra sempre; o indice unico barra a segunda
        diaria      AAAA-MM-DD     uma por dia
        repetivel   1, 2, 3        contador; cada entrega abre a proxima

    Isso poe a regra de repeticao inteira dentro do indice unico do banco, em vez de
    espalhada em if pelo codigo. Aceitar duas vezes no mesmo ciclo nao e "verificado": e
    impossivel, o banco recusa.
    """
    ficha = _ficha_missao(quest_id) or {}
    repet = str(repeticao_da_missao(ficha)).strip().lower()
    if repet == REPET_DIARIA:
        return (hoje or _dt.datetime.now(_dt.timezone.utc)).strftime("%Y-%m-%d")
    if repet == REPET_LIVRE:
        return str(_vezes_concluida(entity_id, quest_id) + 1)
    return ""


def repeticao_da_missao(ficha) -> str:
    """unica, diaria ou repetivel. Tres fontes, nesta ordem:

        1. a coluna `repeticao` da propria missao, no Quest.ini
        2. a `repeticao` da CATEGORIA dela, no QuestCategory.ini
        3. unica

    A ordem importa: a categoria e um palpite bom para o conjunto, e a linha da missao e
    onde se corrige a excecao sem mexer nas outras 61.

    O ultimo degrau nao e neutro. Tratar como unica o que era diaria so impede refazer, e o
    jogador reclama e a gente conserta. Tratar como repetivel o que era unica deixa
    reentregar a mesma missao e farmar a recompensa, e ai o estrago ja esta no banco de
    todo mundo quando alguem perceber.
    """
    if not ficha:
        return REPET_UNICA
    proprio = ficha.get("repeticao")
    if proprio:
        return str(proprio)
    cat = ficha.get("categoria")
    if cat is not None:
        linha = data_files.indice("QuestCategory", "categoria").get(int(cat))
        if linha and linha.get("repeticao"):
            return str(linha["repeticao"])
    return REPET_UNICA


def _vezes_concluida(entity_id, quest_id) -> int:
    if not entity_id:
        return 0
    con = _conectar()
    if con is None:
        return 0
    try:
        cur = con.cursor()
        cur.execute("SELECT COUNT(*) FROM dbo.personagem_missao "
                    "WHERE entity_id = ? AND quest_id = ? AND estado = ?",
                    int(entity_id), int(quest_id), MISSAO_CONCLUIDA)
        return int(cur.fetchone()[0])
    except Exception as e:
        print("[db] _vezes_concluida falhou: " + str(e)[:150])
        return 0


def missoes_do_personagem(entity_id: int) -> list:
    """Tudo que este personagem aceitou, em aberto e concluido."""
    with _lock:
        con = _conectar()
        if con is None:
            return []
        try:
            cur = con.cursor()
            cur.execute("SELECT quest_id, estado, progresso, ciclo FROM dbo.personagem_missao "
                        "WHERE entity_id = ? ORDER BY personagem_missao_id", int(entity_id))
            return [{"quest_id": r[0], "estado": r[1], "progresso": r[2], "ciclo": r[3]}
                    for r in cur.fetchall()]
        except Exception as e:
            print("[db] missoes_do_personagem falhou: " + str(e)[:150])
            return []


def pode_aceitar(entity_id: int, quest_id: int):
    """(pode, motivo). Confere existencia, nivel, pre-requisito e repeticao."""
    ficha = _ficha_missao(quest_id)
    if not ficha:
        return False, "missao " + str(quest_id) + " nao existe no Quest.ini"
    pre = ficha.get("pre_requisito")
    if pre:
        feitas = set(m["quest_id"] for m in missoes_do_personagem(entity_id)
                     if m["estado"] == MISSAO_CONCLUIDA)
        if int(pre) not in feitas:
            return False, "falta concluir a missao " + str(pre) + " antes"
    ciclo = ciclo_da_missao(quest_id, entity_id)
    for m in missoes_do_personagem(entity_id):
        if m["quest_id"] == int(quest_id) and m["ciclo"] == ciclo:
            estado = "ja concluida" if m["estado"] == MISSAO_CONCLUIDA else "ja aceita"
            return False, estado + " neste ciclo (" + repr(ciclo) + ")"
    return True, ""


def aceitar_missao(entity_id: int, quest_id: int):
    """Registra a aceitacao. O indice unico do banco e a trava final contra duplicata."""
    pode, motivo = pode_aceitar(entity_id, quest_id)
    if not pode:
        return False, motivo
    with _lock:
        con = _conectar()
        if con is None:
            return False, "sem banco"
        try:
            con.cursor().execute(
                "INSERT INTO dbo.personagem_missao "
                "(entity_id, quest_id, estado, progresso, ciclo) VALUES (?, ?, ?, 0, ?)",
                int(entity_id), int(quest_id), MISSAO_ACEITA,
                ciclo_da_missao(quest_id, entity_id))
            return True, ""
        except Exception as e:
            # violacao do indice unico cai aqui: dois cliques rapidos no mesmo NPC
            return False, "nao gravou: " + str(e)[:120]


def avancar_missao(entity_id: int, quest_id: int, quanto: int = 1):
    """Soma progresso. Devolve (progresso, completou_o_objetivo)."""
    ficha = _ficha_missao(quest_id) or {}
    alvo = int(ficha.get("quantidade") or 1)
    with _lock:
        con = _conectar()
        if con is None:
            return 0, False
        try:
            cur = con.cursor()
            cur.execute("UPDATE dbo.personagem_missao SET progresso = progresso + ? "
                        "WHERE entity_id = ? AND quest_id = ? AND estado = ?",
                        int(quanto), int(entity_id), int(quest_id), MISSAO_ACEITA)
            cur.execute("SELECT TOP 1 progresso FROM dbo.personagem_missao "
                        "WHERE entity_id = ? AND quest_id = ? AND estado = ? "
                        "ORDER BY personagem_missao_id DESC",
                        int(entity_id), int(quest_id), MISSAO_ACEITA)
            linha = cur.fetchone()
            atual = int(linha[0]) if linha else 0
            return atual, atual >= alvo
        except Exception as e:
            print("[db] avancar_missao falhou: " + str(e)[:150])
            return 0, False


def concluir_missao(entity_id: int, quest_id: int):
    """Marca como concluida e devolve (ok, recompensa).

    A recompensa NAO e paga aqui: esta funcao so diz o que e devido. Quem credita EXP e item
    e o world_server, que e quem sabe mandar os pacotes -- misturar as duas coisas faria o
    banco depender do protocolo.
    """
    ficha = _ficha_missao(quest_id)
    if not ficha:
        return False, {}
    with _lock:
        con = _conectar()
        if con is None:
            return False, {}
        try:
            cur = con.cursor()
            cur.execute("UPDATE dbo.personagem_missao SET estado = ?, "
                        "concluida_em = SYSUTCDATETIME() "
                        "WHERE entity_id = ? AND quest_id = ? AND estado = ?",
                        MISSAO_CONCLUIDA, int(entity_id), int(quest_id), MISSAO_ACEITA)
            if cur.rowcount == 0:
                return False, {}
        except Exception as e:
            print("[db] concluir_missao falhou: " + str(e)[:150])
            return False, {}
    itens = [r for r in data_files.tabela("QuestReward")
             if int(r.get("quest_id") or 0) == int(quest_id)]
    return True, {"exp": int(ficha.get("exp_paga") or 0), "itens": itens}

def item_da_skill(skill_id: int):
    """(item_id, quantidade) que a skill consome ao ser lancada. None se nao consome nada.

    Vem de Data/Skills/SkillItem.ini, extraido do skillBase.lod do client -- ver
    tools/extrair_skill_item.py. O client ja mostrava isto na dica ("Objeto utilizado :
    Bala magica (1)") e o servidor ignorava-o: a skill lancava-se sem gastar municao.
    """
    linha = data_files.indice("SkillItem", "skill_id").get(int(skill_id))
    if not linha:
        return None
    try:
        iid, qtd = int(linha["item_id"]), int(linha["quantidade"])
    except (KeyError, TypeError, ValueError):
        return None
    return (iid, qtd) if iid and qtd > 0 else None


def gastar_do_inventario(entity_id: int, item_id: int, quantos: int):
    """Tira `quantos` de um item da mochila. Devolve o registo mexido ou None se nao chega.

    Devolve (instancia, container, slot, quantidade_que_ficou) para quem chama poder mandar
    ao client o `06 06` com a quantidade nova -- sem esse pacote o client continua a mostrar
    o que tinha, como acontecia com as pocoes antes de 30/08.

    Escolhe a PILHA MAIS PEQUENA que chegue, para nao deixar restos espalhados.
    """
    with _lock:
        con = _conectar()
        if con is None:
            return None
        try:
            cur = con.cursor()
            cur.execute("SELECT personagem_item_id, container, slot, quantidade "
                        "FROM dbo.personagem_item "
                        "WHERE entity_id = ? AND item_id = ? AND quantidade >= ? "
                        "ORDER BY quantidade ASC",
                        int(entity_id), int(item_id), int(quantos))
            linha = cur.fetchone()
            if linha is None:
                return None
            inst, cont, slot, qtd = int(linha[0]), int(linha[1]), int(linha[2]), int(linha[3])
            resta = qtd - int(quantos)
            if resta > 0:
                cur.execute("UPDATE dbo.personagem_item SET quantidade = ? "
                            "WHERE personagem_item_id = ?", resta, inst)
            else:
                cur.execute("DELETE FROM dbo.personagem_item "
                            "WHERE personagem_item_id = ?", inst)
            return (inst, cont, slot, resta)
        except Exception as e:                                      # noqa: BLE001
            print(f"[db] gastar_do_inventario falhou: {str(e)[:150]}")
            return None

# A barra de atalhos de um personagem novo. O jogador mostrou como e no oficial em 01/09:
# a skill da classe no primeiro botao -- "so muda a skill, que e so 1 de cada classe mesmo".
ATALHO_SKILL = 9        # campo18 = 9 nas linhas de skill dos personagens que ja existem
RANHURA_SKILL = 0


def dar_atalhos_iniciais(entity_id: int, classe: int) -> int:
    """Poe a skill da classe no primeiro botao da barra. Devolve quantos atalhos criou.

    Sem isto a barra de um personagem novo vinha VAZIA: os atalhos so existiam depois de o
    jogador arrastar cada um a mao. No oficial ja vem com a skill posta.

    A skill e a mesma que `dar_skills_da_classe` atribui -- a raiz da arvore, `gt = 1` na
    calculadora oficial. Se por alguma razao nao houver raiz, nao se inventa nada.

    So escreve se a ranhura estiver livre: assim pode correr a cada entrada sem apagar o que
    o jogador tenha posto la depois.
    """
    raiz = _skill_raiz(classe)
    if not raiz:
        return 0
    if atalhos_do_personagem(entity_id):
        return 0
    guardar_atalho(entity_id, RANHURA_SKILL, raiz, campo18=ATALHO_SKILL, campo22=0)
    print("[db] barra inicial de %d: skill %d na ranhura %d"
          % (entity_id, raiz, RANHURA_SKILL))
    return 1


# ---------------------------------------------------------------------------
# Segunda senha (PIN de 6 digitos)
# ---------------------------------------------------------------------------
# Tabela propria em vez de colunas novas em dbo.conta: isto e uma funcionalidade que se
# pode querer desligar, e uma tabela a parte apaga-se sem tocar no login.
#
# O PIN chega em TEXTO CLARO no 02 0b, offset 20, terminado em NUL -- medido em 02/09
# com o log de [2asenha], depois de a janela 30039 passar a abrir no client. Nao da para
# evitar isso sem mexer no client. Em DISCO fica derivado, como a senha da conta.
#
# NAO se fecha em falha aqui, ao contrario de `autenticar`. Se o banco cair, quem ja tem
# PIN fica sem poder entrar -- e isso e uma indisponibilidade que o jogador nao entende,
# num campo que e a SEGUNDA barreira e nao a primeira. A primeira ja recusou sem banco.

def _tabela_pin(cur) -> None:
    cur.execute("IF OBJECT_ID('dbo.segunda_senha', 'U') IS NULL "
                "CREATE TABLE dbo.segunda_senha ("
                "  conta_id INT PRIMARY KEY,"
                "  pin_hash CHAR(64) NOT NULL,"
                "  pin_salt CHAR(32) NOT NULL,"
                "  criado   DATETIME NOT NULL DEFAULT GETDATE())")


def _conta_id_de(cur, usuario: str):
    cur.execute("SELECT conta_id FROM dbo.conta WHERE usuario = ?", usuario)
    linha = cur.fetchone()
    return linha[0] if linha else None


def tem_segunda_senha(usuario: str) -> bool:
    """A conta ja cadastrou um PIN?"""
    if not usuario:
        return False
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            _tabela_pin(cur)
            cid = _conta_id_de(cur, usuario)
            if cid is None:
                return False
            cur.execute("SELECT 1 FROM dbo.segunda_senha WHERE conta_id = ?", cid)
            return cur.fetchone() is not None
        except Exception as e:                                     # noqa: BLE001
            print(f"[db] tem_segunda_senha falhou: {str(e)[:150]}")
            return False


def definir_segunda_senha(usuario: str, pin: str) -> bool:
    """Cadastra ou troca o PIN da conta."""
    if not usuario or not pin:
        return False
    salt = secrets.token_bytes(16).hex()
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            _tabela_pin(cur)
            cid = _conta_id_de(cur, usuario)
            if cid is None:
                return False
            cur.execute("DELETE FROM dbo.segunda_senha WHERE conta_id = ?", cid)
            cur.execute("INSERT INTO dbo.segunda_senha (conta_id, pin_hash, pin_salt) "
                        "VALUES (?, ?, ?)", cid, _derivar(pin, salt), salt)
            print(f"[db] segunda senha cadastrada para '{usuario}'")
            return True
        except Exception as e:                                     # noqa: BLE001
            print(f"[db] definir_segunda_senha falhou: {str(e)[:150]}")
            return False


def conferir_segunda_senha(usuario: str, pin: str) -> bool:
    """O PIN bate com o que esta cadastrado?"""
    if not usuario or not pin:
        return False
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            _tabela_pin(cur)
            cid = _conta_id_de(cur, usuario)
            if cid is None:
                return False
            cur.execute("SELECT pin_hash, pin_salt FROM dbo.segunda_senha WHERE conta_id = ?",
                        cid)
            linha = cur.fetchone()
            if not linha:
                return False
            return hmac.compare_digest(_derivar(pin, linha[1].strip()), linha[0].strip())
        except Exception as e:                                     # noqa: BLE001
            print(f"[db] conferir_segunda_senha falhou: {str(e)[:150]}")
            return False


def apagar_segunda_senha(usuario: str) -> bool:
    """Tira o PIN da conta. Serve ao 02 0a e ao administrador."""
    if not usuario:
        return False
    with _lock:
        con = _conectar()
        if con is None:
            return False
        try:
            cur = con.cursor()
            _tabela_pin(cur)
            cid = _conta_id_de(cur, usuario)
            if cid is None:
                return False
            cur.execute("DELETE FROM dbo.segunda_senha WHERE conta_id = ?", cid)
            print(f"[db] segunda senha removida de '{usuario}'")
            return cur.rowcount > 0
        except Exception as e:                                     # noqa: BLE001
            print(f"[db] apagar_segunda_senha falhou: {str(e)[:150]}")
            return False
