"""
Simulacao de entidades do mundo (hop3).

Substitui o replay cego do stream gravado: em vez de reproduzir bytes de uma sessao alheia
(que referenciam IDs de entidade que nunca existiram na nossa sessao, e por isso pioravam a
situacao), aqui a gente GERA os pacotes com estado proprio.

Layout dos pacotes -- decodificado em 22/08 cruzando a captura real (6000 frames) com o mapa
de opcodes extraido do binario (ver docs/opcodes.md):

  CMoveAppearPacket (04 02), 260 bytes (NPC sem nome)
    [8:12]  entity_id  u32 LITTLE-endian
    [12:24] posicao    3 floats LE (x, y, z)
    resto: boilerplate -- confirmado CONSTANTE nos 682 NPCs da captura, copiado do template

  CMoveMovePacket (04 01), 42 bytes
    [2:6]   seq        u32 BIG-endian
    [6]     0x01       [7] flag
    [8:12]  entity_id  u32 BIG-endian   <- endianness diferente do Appear (protocolo inconsistente,
                                            mas confirmado empiricamente em 100% das amostras)
    [12:24] origem     3 floats LE
    [24:36] destino    3 floats LE
    [36:40] velocidade float LE
    [40][41] flags

  CMoveDisappearPacket (04 03), 11 bytes
    [2:6]   seq        u32 BE      [6] 0x01      [7:11] entity_id u32 BE
"""
from __future__ import annotations

import math
import os
import random
import struct
import threading

import data_files
import pathfinding
import db
import time

from login_packet import encode_frame

TEMPLATE_PATH = r"D:\RoshEmulator\captures\appear_template.bin"

# IDs proprios, bem longe da faixa 1169-1300 usada pelas entidades do stream gravado,
# pra nao colidir com o que o replay do enterworld ja spawnou.
BASE_ENTITY_ID = 9000
NUM_ENTITIES = 12

# regiao onde nosso personagem nasce -- confirmada pelo proprio client, que mandou
# CMoveRegenPacket (04 00) com X=8920 Y=6520 Z=627
SPAWN_X, SPAWN_Y, SPAWN_Z = 8920.0, 6520.0, 627.0
SPAWN_RAIO = 250.0

# TAMANHO DO PASSO, medido no oficial em 115 mil pacotes 04 01: 91 por cento deles tem
# distancia entre 20 e 29, e dentro dessa faixa o p10, a mediana e o p90 sao todos 23.33.
# Nao e uma faixa com media 23 -- e um valor CONSTANTE.
#
# Nos sorteavamos de 8 a 30 a cada ciclo, com intervalo fixo de 0,6 s. O mob andava 8 num
# ciclo e 30 no seguinte, no mesmo tempo: e isso que o jogador via como "anda rapido depois
# devagar". A animacao do client nao tinha como acompanhar, porque o deslocamento mudava de
# quase quatro vezes de um passo pro outro.
PASSO_MOB = 23.33

# Velocidade que vai no pacote. No oficial ela varia de 3,3 a 8,7 (p10 a p90) mas e ESTAVEL
# por monstro -- e caracteristica dele, nao sorteio de cada passo. Sorteamos uma vez, quando
# a entidade nasce, e ela fica.
VEL_MIN, VEL_MAX = 3.3, 8.7

# QUANTO O MONSTRO VIRA entre um passo e o seguinte. Medido em 114.915 pares consecutivos
# do oficial:
#
#     0 a 29 graus   85,6 %      <- segue reto
#    30 a 59          2,2 %
#    60 a 89          3,5 %
#    90 a 119         2,4 %
#   120 a 149         2,5 %
#   150 a 179         3,5 %
#
# A virada MEDIANA e exatamente 0: na maior parte dos passos ele nao muda de direcao.
#
# Nos sorteavamos um angulo novo a cada passo, o que da uma virada media de 90 graus e
# distribuicao uniforme -- ziguezague. Junto com o passo de tamanho variavel, era isso que
# fazia o movimento parecer quebrado.
# RITMO DE CAMINHADA: anda um trecho, PARA um ciclo, vira, anda de novo.
#
# Medido no oficial em 132.569 pacotes 04 01, separando por entidade:
#
#   trecho andando   p25 3 pacotes   p50 5   p75 9   p90 13     (~3 s na mediana)
#   trecho parado    p25 1 pacote    p50 1   p75 1   p90 1      SEMPRE um so
#   tempo parado     13 por cento do total
#
# E a virada acontece NA PARADA, nao durante a caminhada:
#
#   virada logo apos parar        mediana 113,9 graus   88 por cento acima de 30
#   virada entre passos andando   mediana   0,0 graus    3 por cento acima de 30
#
# Antes disto o mob andava sem parar nunca, com chance fixa de virar a cada passo. As duas
# coisas estavam erradas: ele nao parava, e virava no meio do caminho.
#
# A parada e um 04 01 com ORIGEM IGUAL AO DESTINO -- e assim que o oficial diz "parou". O
# byte [7] vai a 2 nesses pacotes (0 = andando), e em 100 por cento dos [7]=2 a origem e
# igual ao destino. Nao existe opcode proprio de parar.
# O trecho e a virada saem da DISTRIBUICAO medida, guardada em Data/Monsters/MobMovement.ini.
#
# Antes eram aproximacoes: uma geometrica com chance fixa de terminar o trecho, e uma virada
# sorteada entre 48 e 180 graus. As duas acertavam a MEDIANA e erravam a forma -- a virada,
# por exemplo, nunca ficava abaixo de 48 graus, enquanto no oficial 7,5 por cento delas ficam
# abaixo de 20. Amostrar da tabela reproduz a forma inteira, nao so o meio.
_dist_cache = {}


# DIAGNOSTICO (25/08): com isto ligado o mob anda em LINHA RETA, sem parar e sem virar.
# Serve pra separar duas causas do tranco que o jogador relata:
#   - se ele SUMIR, o problema esta no ritmo (parada/virada)
#   - se CONTINUAR, esta na mecanica do passo (o client sendo reposicionado a cada pacote)
# Voltar pra False depois de olhar.
DIAGNOSTICO_LINHA_RETA = False


# TRILHAS: trajetorias reais gravadas do oficial (Data/Monsters/MobTrack.ini).
#
# Substituem a simulacao de movimento. O historico de 25/08 explica por que: distancia de
# passo, velocidade, distribuicao de virada, tamanho de trecho e duracao de pausa foram todos
# medidos e casados com o oficial, e o monstro continuava girando em torno do proprio eixo na
# tela -- inclusive em modo diagnostico, andando em linha reta, o que provou que o defeito
# nao estava em nada disso.
#
# Olhando um passo real de perto apareceu o que faltava: o oficial ACELERA e DESACELERA. O
# ultimo passo antes de parar tem metade do avanco, o primeiro depois de retomar tem avanco
# zero, e a velocidade do pacote sobe junto (1,48 -> 4,62 -> 4,62). Eu ia de parado a 6,66
# num ciclo, com velocidade fixa.
#
# Dava pra modelar a rampa tambem. Mas ja errei cinco vezes tentando modelar este movimento,
# e cada erro so apareceu com o jogador olhando a tela. A trilha gravada nao precisa de
# teoria: ela E o que o client aceita, com o tempo entre pacotes, as rampas e os campos que
# o servidor oficial mandou.
_trilhas_por_especie = None


LADO_ALTURA = 25.0
RAIO_ALTURA = 8       # ate 200 unidades a procura de terreno medido
_alturas = None


ENTIDADES_REPLAY = set()
_replay = None


def _carregar_replay():
    """A sequencia de 04 01 REAL do oficial, para uma entidade a repetir tal e qual.

    E uma experiencia, nao uma funcionalidade. Ver tools/extrair_replay.py para o porque:
    quando todas as metricas batem certo e o olho continua a discordar, mede-se de outra
    maneira em vez de medir mais do mesmo.

    Nada aqui e recalculado -- posicao, z, campo do rumo e estado saem do ficheiro como
    vieram do fio. Se o servidor mexesse em qualquer um deles deixava de ser reproducao e o
    teste nao separava nada.
    """
    global _replay
    if _replay is not None:
        return _replay
    _replay = []
    for r in data_files.tabela("Replay"):
        try:
            _replay.append((int(r["ms"]), int(r["estado"]),
                            float(r["ox"]), float(r["oy"]), float(r["oz"]),
                            float(r["dx"]), float(r["dy"]), float(r["dz"]),
                            float(r["campo36"])))
        except (KeyError, TypeError, ValueError):
            continue
    _replay.sort()
    return _replay


def _carregar_alturas():
    """O mapa de alturas do terreno, medido nas capturas do oficial.

    PORQUE EXISTE. O nosso monstro guardava a altura do sitio onde nasceu e andava a essa
    altura para sempre: medido em 28/08, 20 das 20 entidades nossas com o Z ABSOLUTAMENTE
    fixo, contra 18 das 21 do oficial a variar (amplitude mediana 8,38, maxima 35,44). Ele
    deslizava por cima das encostas em vez de as subir.

    Isto escapou a um dia inteiro de medicoes porque todas elas eram sobre x e y -- passo,
    intervalo, rumo, rectidao, buracos. O terceiro float do pacote nunca foi olhado, e foi
    preciso pOr os 42 bytes lado a lado para dar por ele.

    Tabela gerada por tools/extrair_alturas.py a partir de 301 mil posicoes das capturas.
    """
    global _alturas
    if _alturas is not None:
        return _alturas
    _alturas = {}
    for r in data_files.tabela("Altura"):
        try:
            _alturas[(int(r["cx"]), int(r["cy"]))] = float(r["z"])
        except (KeyError, TypeError, ValueError):
            continue
    return _alturas


def altura_medida_em(x, y):
    """A altura interpolada, ou None quando NENHUM dos quatro cantos foi medido.

    O `altura_em` alarga em aneis quando falta medicao, o que e certo para POR o bicho a
    uma altura plausivel. Para DECIDIR se ele pode ir a um sitio nao serve: numa zona sem
    medicao ele devolve a altura de uma celula longe, e isso le-se como um precipicio que
    nao existe.

    Foi o que aconteceu com o Velocista do pantano: 296 -> 337 -> 545 em 18 unidades, em
    patamares planos. A coleira encolhia a trilha ao minimo por causa de terreno inventado.
    """
    mapa = _carregar_alturas()
    if not mapa:
        return None
    fx = x / LADO_ALTURA - 0.5
    fy = y / LADO_ALTURA - 0.5
    cx, cy = int(math.floor(fx)), int(math.floor(fy))
    tx, ty = fx - cx, fy - cy
    soma = peso = 0.0
    for (kx, ky), w in (((cx, cy), (1.0 - tx) * (1.0 - ty)),
                        ((cx + 1, cy), tx * (1.0 - ty)),
                        ((cx, cy + 1), (1.0 - tx) * ty),
                        ((cx + 1, cy + 1), tx * ty)):
        z = mapa.get((kx, ky))
        if z is not None and w > 0.0:
            soma += z * w
            peso += w
    return soma / peso if peso > 0.0 else None


def altura_em(x, y, actual=None):
    """A altura do chao em (x, y), INTERPOLADA entre as celulas medidas.

    INTERPOLAR NAO E REFINAMENTO, E A CORRECCAO. A primeira versao devolvia a altura da
    celula, e a celula tem 25 unidades enquanto o passo do passeio tem 6,66: tres ou quatro
    pacotes seguidos saiam com o MESMO z e depois havia um degrau na fronteira. Medido no
    nosso fio contra uma gravacao do oficial a correr ao lado, no mesmo instante:

        replay do oficial   dz = 0 em 30 por cento dos pacotes,  mediana 0,99
        nos, por celula     dz = 0 em 77 por cento dos pacotes,  mediana 0,00

    Ou seja, o oficial sobe e desce um bocadinho a cada pacote e nos ficamos parados e depois
    saltamos. Um degrau vertical e um solavanco, e era o que o jogador via como o monstro a
    chacoalhar -- inclusive a andar, nao so a perseguir.

    Foi a experiencia do replay que separou isto: com o mesmo client, no mesmo momento, o
    monstro que repetia os bytes do oficial andava liso e o do lado tremia. Sem ela eu estaria
    ainda a ler o descompilado do client.

    Bilinear sobre os quatro centros de celula a volta. Se faltar algum canto, usa os que
    houver; se nao houver nenhum, procura em aneis e por fim devolve `actual` -- nao se
    inventa terreno onde nao se mediu.
    """
    mapa = _carregar_alturas()
    if not mapa:
        return actual
    fx = x / LADO_ALTURA - 0.5
    fy = y / LADO_ALTURA - 0.5
    cx = int(math.floor(fx))
    cy = int(math.floor(fy))
    tx = fx - cx
    ty = fy - cy
    cantos = (((cx, cy), (1.0 - tx) * (1.0 - ty)),
              ((cx + 1, cy), tx * (1.0 - ty)),
              ((cx, cy + 1), (1.0 - tx) * ty),
              ((cx + 1, cy + 1), tx * ty))
    soma = peso = 0.0
    for chave, w in cantos:
        z = mapa.get(chave)
        if z is not None and w > 0.0:
            soma += z * w
            peso += w
    if peso > 0.0:
        return soma / peso
    # nenhum dos quatro cantos tem medicao: alarga em aneis
    for raio in range(1, RAIO_ALTURA + 1):
        perto = [mapa[(cx + i, cy + j)]
                 for i in range(-raio, raio + 1) for j in range(-raio, raio + 1)
                 if max(abs(i), abs(j)) == raio and (cx + i, cy + j) in mapa]
        if perto:
            return sum(perto) / len(perto)
    return actual


# Alcance minimo para uma trilha contar como PERAMBULACAO.
#
# Nem tudo o que foi gravado e um bicho a passear. Algumas capturas apanharam monstros que
# ficaram a marcar passo no mesmo palmo -- em combate, encurralados, ou so com a janela de
# captura curta. Essas trilhas existem, sao validas byte a byte, e nao servem para nada:
# quem calhar nelas fica preso.
#
# Medi o alcance das 55 trilhas (distancia maxima ao ponto de partida) e a separacao e limpa:
#
#     3,3  10,0  10,0  10,0  13,3  19,4   <- seis, e depois um vazio
#     60,6  61,7  65,4  74,5  75,2 ...    <- as outras 49, ate 245,2
#
# Nao ha nada entre 19,4 e 60,6, portanto o corte nao e afinacao minha: cai dentro de um
# vazio de mais do triplo. Fica a meio dele.
#
# NAO se usa a razao alcance/comprimento-do-caminho, que seria o criterio obvio: as ms vao
# de 0,008 a 0,059 e as boas de 0,031 a 0,372, e sobrepoem-se. Ja o alcance separa sozinho.
#
# Foi o jogador que deu por isto, na Fada: "ela fica presa num espaco muito pequeno". Das
# tres trilhas dela, duas tinham alcance 13,3 e 19,4 -- dois em tres bichos saiam parados.
# Medido no fio, a fada 233 passou 90 por cento do tempo a menos de 13,5 unidades de um
# ponto, exatamente o alcance da trilha que lhe calhou. A mesma coisa ja tinha sido notada
# na Aranha pequena (trilhas de alcance 3,3 e 10,0) e remendada por outro lado.
ALCANCE_MINIMO_DA_TRILHA = 40.0


def _alcance_de(passos):
    """Distancia maxima ao ponto de partida da trilha."""
    if not passos:
        return 0.0
    x0, y0 = passos[0][1], passos[0][2]
    return max(math.hypot(p[1] - x0, p[2] - y0) for p in passos)


def _carregar_trilhas():
    global _trilhas_por_especie
    if _trilhas_por_especie is not None:
        return _trilhas_por_especie
    _trilhas_por_especie = {}
    for r in data_files.tabela("MobTrack"):
        passos = []
        for campo in str(r.get("dados") or "").split():
            p = campo.split(":")
            if len(p) != 7:
                continue
            passos.append((int(p[0]), float(p[1]), float(p[2]),
                           float(p[3]), float(p[4]), float(p[5]), int(p[6])))
        passos = _aparar_buracos(passos)
        passos = _so_perambulacao(passos)
        if passos and _alcance_de(passos) >= ALCANCE_MINIMO_DA_TRILHA:
            _trilhas_por_especie.setdefault(int(r["npc_id"]), []).append(passos)
    return _trilhas_por_especie


# Pausa maxima que uma trilha pode conter. Acima disto e o mob a ter saido do campo de visao
# de quem gravou, nao uma pausa de verdade.
#
# Medido no oficial, 163 mil intervalos entre pacotes do mesmo mob:
#
#     mediana 0,61 s   p90 2,21   p99 6,79   p99,9 98,6   max 2266
#
# Ate ao p99 sao pausas reais. O salto do p99 para o p99,9 -- de 7 para 98 segundos -- e a
# assinatura de outra coisa: o bicho desapareceu de vista e reapareceu mais tarde.
#
# As nossas trilhas herdaram esses buracos e reproduziam-nos fielmente: o mob mandava
# "parado" e ficava 58 segundos em silencio. Na tela e uma aranha imovel que so volta a
# andar ao fim de um minuto, que foi o que o jogador viu.
BURACO_MAXIMO_MS = 7000
VELOCIDADE_DE_PASSEIO = 10.6   # u/s, medida no oficial (6,67 por passo a cada 0,613 s)


def _aparar_buracos(passos):
    """Encolhe esperas absurdas, MAS NUNCA ABAIXO DO QUE O BICHO CONSEGUE ANDAR.

    Nao remove o passo -- so encurta a espera ANTES dele, e desloca os seguintes para tras
    pelo mesmo tanto. As posicoes ficam intactas; muda so o relogio.

    O LIMITE PELA VELOCIDADE E O QUE FALTAVA, E ERA O DEFEITO DO ANDAR.
    ------------------------------------------------------------------
    Antes encurtava-se toda a espera para 7 s, fosse qual fosse a distancia. Uma pausa em que
    o monstro do oficial esteve 30 s quieto e depois se deslocou 168 unidades passava a 168
    unidades em 7 s -- 24 u/s, quando ele anda a 10,6. O client recebia o pacote, andava os
    23 declarados, chegava, ficava parado, e era teleportado para o ponto seguinte.

    Medido no fio em 28/08, e a assinatura era inconfundivel:

        buracos NOSSOS   sempre 7,00 s cravados, 11 a 24 u/s implicitos
        buracos DO OFICIAL  8 a 55 s, variados,   3 a  6 u/s implicitos

    No oficial, quando ele fica calado e porque esta MESMO parado. Era isto o "ele sempre
    chacoalhou" que o jogador descreveu desde o inicio e que nenhuma medicao de passo, de
    intervalo ou de rumo apanhava -- todas essas batiam certo. O defeito nao estava no
    movimento: estava a ser fabricado aqui, ao carregar os dados.

    Agora o encolhimento respeita a velocidade de passeio: uma espera so encurta ate ao ponto
    em que a distancia ainda e percorrivel a pe. Quem ficou parado muito tempo continua a
    ficar; o que se corta e so a espera a mais.
    """
    if len(passos) < 2:
        return passos
    saida = [passos[0]]
    atraso = 0
    for i in range(1, len(passos)):
        p = passos[i]
        ant = passos[i - 1]
        dt = p[0] - ant[0]
        if dt > BURACO_MAXIMO_MS:
            dist = math.hypot(p[1] - ant[1], p[2] - ant[2])
            minimo = int(dist / VELOCIDADE_DE_PASSEIO * 1000.0)   # o que andar a pe exige
            alvo = max(BURACO_MAXIMO_MS, minimo)
            if dt > alvo:
                atraso += dt - alvo
        saida.append((p[0] - atraso,) + tuple(p[1:]))
    return saida


def _so_perambulacao(passos):
    """Fica so com o maior trecho seguido SEM estado 1, re-zerado no inicio dele.

    Estado 1 e PERSEGUICAO -- descoberto em 25/08 comparando o Urso furioso lado a lado nos
    dois servidores, com os dois clients abertos. Tudo batia (passo 6,67 contra 6,65; dt 0,610
    contra 0,627; campo de velocidade 5,63 contra 5,56) menos a virada:

        giro p90    oficial  0,1 graus    nosso  94,3 graus
        acima de 90 oficial      5,5 %    nosso   10,1 %

    A explicacao estava nas proprias trilhas: as do urso tem 46 passos de estado 1, e sao de
    longe as piores da colecao (p90 de 19 graus, contra 1,2 do resto). O urso e agressivo --
    durante a captura ele perseguiu alguem, e o extrator gravou a perseguicao junto com a
    perambulacao. Perseguir e virar direto para o alvo; reproduzido como passeio, isso e o
    tranco que o jogador via.

    Cortar so o estado 1 nao bastaria: ficaria uma emenda entre o ponto onde ele saiu
    correndo e o ponto onde voltou a passear. Por isso e o maior TRECHO SEGUIDO, nao a soma
    dos pedacos.

    Medido: urso de p90 19,0 para 2,8 graus e de 4,5 para 3,1 por cento acima de 90; no
    conjunto todo, 8 por cento dos passos descartados e nenhuma trilha fica com menos de 20.
    """
    melhor, atual = [], []
    for p in passos:
        if p[6] == 1:
            if len(atual) > len(melhor):
                melhor = atual
            atual = []
        else:
            atual.append(p)
    if len(atual) > len(melhor):
        melhor = atual
    if not melhor:
        return []
    ms0, x0, y0 = melhor[0][0], melhor[0][1], melhor[0][2]
    return [(ms - ms0, x - x0, y - y0, dx, dy, v, st)
            for ms, x, y, dx, dy, v, st in melhor]


# QUANTOS MONSTROS SIMPLESMENTE NAO ANDAM.
#
# Estava 0,31, de uma medicao minha que nao se sustenta. O jogador derrubou o numero olhando
# a tela: "o mob fica mais parado que antes". Refazendo a conta em 251 entidades do oficial,
# cada uma pelo SPAN COMPLETO de observacao (span mediano 509 s, 132 pacotes por entidade):
#
#     abaixo de 0,05 u/s    1,6 %     eu tinha dito 30,8
#     abaixo de 0,50 u/s    8,4 %     eu tinha dito 43,6
#     abaixo de 2,00 u/s   35,1 %     eu tinha dito 53,4
#
# O erro de antes foi a janela: eu media cada entidade num recorte curto, e um monstro parado
# manda pouquissimo pacote -- entao ele caia fora do recorte por falta de amostra e o que
# sobrava distorcia a conta nos dois sentidos. Mesmo tipo de armadilha que ja tinha me feito
# escolher as trilhas mais longas de cada especie: um filtro que remove justamente a
# populacao que eu queria medir.
#
# 0,02 e o 1,6 por cento medido, arredondado. Continua existindo porque monstro parado nao
# gera pacote e portanto nunca junta os 60 passos que o extrator de trilhas exige -- a
# amostra de trilhas segue enviesada pros ativos, so que o desvio e MUITO menor do que eu
# achava.
FRACAO_SEDENTARIA = 0.02

# Fallback do raio de perambulacao, so para entidade sem ponto de spawn (o bando antigo de
# aranhas). Quem vem do NpcSpawn.ini traz o raio do proprio client -- ver Entidade.raio.
TETO_DERIVA_TRILHA = 250.0


# PERSEGUICAO. Tudo aqui saiu de uma captura de 25/08 com os DOIS clients abertos ao mesmo
# tempo -- um no oficial, um no nosso -- seguindo a entidade 816, um Urso furioso, por 8
# episodios seguidos.
#
# O byte [7] do 04 01 tem um terceiro valor que nos nunca emitimos:
#
#     0  passeando        passo  6,67   declarado 23,33
#     2  parado           passo  0
#     1  PERSEGUINDO      passo 21,33   declarado 18,67   velocidade 1,05
#
# Cuidado ao reconferir: estado 1 tambem aparece em JOGADOR (23 dos 31 episodios da captura
# eram outros jogadores a correr). So os 8 do npc 1000814 sao perseguicao de monstro.
ESTADO_PERSEGUINDO = 1

# Onde ele decidiu vir. E UMA medida so -- houve um unico inicio de agro limpo na captura, a
# 85,2 unidades. As outras 7 entradas em estado 1 foram RE-engates de quem ja estava com odio,
# de 56 a 114. Some-se que o urso ficou a 61 unidades por 10 s sem reagir antes de arrancar,
# o que sugere que o gatilho nao e so distancia -- pode ter verificacao periodica ou visao.
# Fica 85 por ser o que se mediu, com a ressalva de que o numero e fraco.
# ELE AVISA ANTES. O jogador mandou o print do oficial: um triangulo amarelo aparece sobre o
# monstro e fica uns 10 s antes de ele vir. O pacote e o 05 0b, 14 bytes:
#
#     05 0b | seq BE | entidade BE | valor BE
#
# Os dois ciclos da captura sao identicos ao decimo de segundo:
#
#     valor 1    aparece o alerta
#     valor 10   +8,0 s
#     valor 0    +2,7 s   -- e a perseguicao comeca no MESMO instante
#
# Isto explica o buraco que eu tinha visto e nao soube justificar: o urso ficou a 61 unidades
# por 10 s sem reagir. Nao estava indeciso, estava a avisar.
#
# Durante o aviso ele CONTINUA a passear normalmente (estado 0 e 2, passo 6,67) -- o alerta
# nao o congela.
ALERTA_VALOR_INICIO = 1
ALERTA_VALOR_MEIO = 10
ALERTA_VALOR_FIM = 0
ALERTA_ATE_MEIO = 8.0
ALERTA_ATE_CACAR = 10.7

# Distancia em que o alerta dispara. Duas amostras: 66,8 e 100,7.
#
# A imprecisao e minha, nao do jogo: a minha posicao so e conhecida nos meus proprios 04 01,
# a cada 0,64 s, e eu vinha a correr a ~70 u/s -- ate 45 unidades de erro por amostra. Na
# primeira o quadro anterior ainda me punha a 211. Fica 100, o maior valor limpo, porque
# errar pra menos faz o monstro so reagir quando o jogador ja esta em cima dele.
AGRO_RAIO = 100.0

# DESISTENCIA: e a distancia da propria ANCORA, nao a distancia ao jogador.
#
# Foi o que a captura mostrou e contraria o que eu tinha registrado antes. No ultimo episodio
# o urso perseguiu ate estar a 509 da ancora, e no passo seguinte -- ja a 517 -- virou. Dai em
# diante o rumo declarado apontava pra ancora e a distancia dela caiu 517 -> 97, enquanto a
# distancia ao JOGADOR so crescia (53 -> 515). Quem olha a distancia relativa le isso como
# "desistiu aos 340" e erra, que foi exatamente o meu erro anterior.
# Ate onde o mob se afasta da propria ancora antes de desistir.
#
# Passou por 512 -> 380 -> 600, e vale registar porque.
#
# O 380 saiu de 4 amostras da distancia da ancora no fim do estado 1 (mediana 346). Amostra
# fraca, e teve um efeito colateral que so apareceu em jogo: o mob nunca se afastava o
# suficiente para SAIR DO CAMPO DE VISAO do jogador, entao ficava selecionado no client
# mesmo depois de desistir e voltar ao posto.
#
# A medicao boa e outra: a distancia entre o MOB e o JOGADOR durante a perseguicao, 337
# amostras do oficial:
#
#     mediana 160   p75 465   p90 526   max 576
#
# Ou seja, o mob do oficial chega a 576 do jogador -- ACIMA do raio de visao de 550. E por
# isso que ele sai da vista e a selecao cai sozinha. Com o leash em 380 isso era impossivel.
#
# 600 permite que a caçada chegue a essa distancia. Nao e o mob a perseguir mais longe do
# que o oficial: e a poder afastar-se da propria ancora o mesmo que ele.
# A CORDA DO POSTE QUASE NAO MANDA. MEDIDO: o oficial persegue a 200-600 u do sitio onde
# nasceu, e larga em cima do proprio poste quando o jogador se afasta. Com o jogador a
# menos de 100 u mas o mob ja a 200 u de casa, 48 por cento dos passos ainda apontam ao
# jogador; com o mob em cima do poste e o jogador a mais de 200 u, zero. Quem manda e a
# distancia ao JOGADOR.
#
# 600 e o topo do que se mediu no oficial (ele persegue a 200-600 u do poste). Esteve
# 200, que cortava cacadas normais, e 900, que era largo de mais. Fica um valor largo
# so para um mob arrastado para o outro lado do
# mapa acabar por voltar; nao e para cortar cacadas normais, que era o que 200 fazia.
LEASH_DA_ANCORA = 800.0
"""Distancia maxima ao POSTE. Era 600, que e o triplo do que se ve.

Medido em 28/08: quando um monstro larga a caca, esta em mediana a 35 do sitio onde a
comecou, p75 64, p90 91. O valor fica em 200 e nao em 91 porque a medicao conta cada pausa
de rajada como um fim de caca e por isso puxa a distribuicao para baixo -- 200 e folgado o
suficiente para nao cortar cacas legitimas e ja e um terco do que estava."""

# TEMPO MAXIMO DE UMA CACADA. E este o limite que o oficial usa, nao a distancia.
#
# Medido em 417 perseguicoes do oficial (sequencias continuas de estado 1):
#
#     mediana 0,0 s    p75 0,1 s    p90 0,6 s    MAXIMO ABSOLUTO 6,8 s
#
# Nenhuma passa dos 7 segundos. A nossa chegou a 27 -- quatro vezes o maximo que existe no
# jogo -- porque o unico travao era a distancia da ancora, e a 21,33 por passo demora muito
# a la chegar. O jogador viu isso como "me perseguiu bem mais longe".
#
# Com o tempo limitado, a caçada acaba mesmo que o mob nao tenha chegado ao limite de
# distancia; e quando acaba, ele volta ao posto e sai do campo de visao, que e o que faz a
# selecao cair no client.
# PERSEGUICAO EM RAJADAS, medida em 28/08 sobre 2388 corridas e 1179 pausas de monstros que
# estavam mesmo a combater com o jogador.
#
#     rajada (a correr)   p25 0,52   mediana 1,02   p75 1,81   p90 2,40 s
#     pausa (entre elas)  p25 0,37   mediana 0,45   p75 2,96   p90 7,35 s
#
# O monstro NAO persegue de contínuo: corre cerca de um segundo, para, volta a correr. Foi o
# jogador que o viu primeiro ("da um arranque depois diminui a velocidade") e a medicao
# confirmou. E isto explica o numero que nunca fechava: a "velocidade de perseguicao" que se
# media (mediana 34, p90 59, picos acima de 100) nao e uma velocidade -- e a media entre
# correr e estar parado. Nenhum valor unico podia encaixar.
RAJADA_PERSEGUICAO = 1.0
PAUSA_PERSEGUICAO = 0.45
# Em TIQUES de perseguicao, que e a unidade em que o ciclo e mesmo executado.
#
# MEDIDO DENTRO DA RAJADA, que e o que corrigiu os numeros. A primeira medicao juntou as
# pausas na mesma amostra e deu 36,7 u/s e 1,02 s de rajada; separando as sequencias
# seguidas de estado 1 (5127 pacotes, 2426 rajadas) sai:
#
#     intervalo   p25 0,412   mediana 0,536   p75 0,595 s
#     avanco      p25 18,67   mediana 21,332  p75 21,333 u   (p50 = p75, cravado)
#     pacotes por rajada   mediana 3
#     velocidade  39,8 u/s
#
# Ou seja o passo e o intervalo ja estavam certos e o alvo e que estava contaminado. O que
# faltava era a contagem: tres pacotes por rajada, nao dois.
# PERSEGUICAO CONTINUA, por decisao do jogador em 28/08: "basta deixar uma perseguicao
# continua ate uma certa distancia, numa velocidade de uns 15% a mais que a velocidade
# dele". Com TIQUES_PAUSA = 0 a conta `n % (RAJADA + PAUSA) >= RAJADA` nunca da verdade e
# a pausa desaparece.
#
# NAO E O QUE O OFICIAL FAZ. Ele persegue em rajadas curtas (mediana 0,62 s) separadas
# por pausas (mediana 4,00 s), 99,3% delas a comecar e acabar em estado 2. Isso esta
# medido em 879 rajadas e fica aqui registado para quem quiser voltar a tentar copiar o
# original -- mas a rajada, com o mob mais lento que o jogador, dava uma perseguicao que
# nunca alcancava ninguem e que se via como movimento partido.
TIQUES_RAJADA = 3
TIQUES_PAUSA = 0

# A que distancia do JOGADOR o monstro larga a caca. Medido: p50 174, p75 296, p90 399 --
# a distribuicao e continua, nao ha um degrau. Ou seja nao existe uma fronteira nitida no
# jogo; 265 e a mediana da leitura mais filtrada e fica como valor de trabalho.
#
# Isto e diferente do LEASH_DA_ANCORA, que mede a distancia ao POSTE. Sao duas cordas: uma
# prende o monstro ao sitio dele, outra ao alvo. So tinhamos a primeira, e com o dobro do
# valor real.
# A UNICA REGUA QUE MANDA: distancia ao jogador. MEDIDO em 2.142 passos -- a fraccao de
# passos que ainda apontam ao jogador mantem-se em 74-81 por cento ate aos 175 u e cai a
# pique a partir dai: 57 por cento (175-200), 45 (200-225), 29 (225-250), 0 acima de 300.
# O passo mais distante alguma vez observado com o mob ainda a apontar foi 280,1 u.
#
# Nao esta cortado pelo alcance de transmissao: o mob so desaparece (04 03) a uma mediana
# de 480 u.
#
# Ha histerese -- ele agarra-se a uma distancia bem menor do que aquela a que larga: raio
# de aggro mediano 94,7 u, p95 173, contra este limiar de largar.
DESISTE_DO_JOGADOR = 200.0

# SEM LIMITE DE TEMPO. O oficial nao larga por relogio nenhum.
#
# MEDIDO em 28/08: com a caca a durar ha mais de 60 s e o jogador a menos de 175 u, 77
# por cento dos passos ainda apontam ao jogador (n=632). E ao contrario: com menos de
# 10 s de caca mas o jogador ja a 200 u ou mais, so 39 por cento (n=33). Nao ha timeout
# de aggro; ha distancia.
#
# ESTAVA 7,0 E ERA O DEFEITO PRINCIPAL. O jogador fugia, o mob seguia sete segundos e
# ia-se embora: "para de me perseguir nem eu sair de perto dele". Passei a tarde a
# afinar rumo, passo e aceleracao, e o que o mandava embora era um relogio -- que nem
# sequer entra no caminho do movimento, por isso nenhuma medicao de movimento o
# apanhava. O proprio comentario aqui ao lado ja falava do "defeito dos 7 s"; o valor e
# que nunca foi mudado.
TEMPO_MAX_CACADA = float("inf")

# MEDIDO: 18,67 por passo, 3.715 pacotes de estado 1 do oficial na sessao de 250 minutos.
#
# REVERTIDO em 27/08 depois de eu proprio o ter estragado. Tinha-o posto em 5,92 com base
# numa medida que juntava tudo: perseguicao com retorno, monstros com PETS (o "Miau ladrao",
# que anda com o dono e corre ao dobro), e medianas cruzadas -- dividir a mediana do passo
# pela mediana do intervalo nao da a mediana da velocidade quando a distribuicao e bimodal.
#
# A media saiu certa (39,5 contra 39,5) e por isso nao dei por nada nos numeros. Mas a media
# estar certa nao faz o movimento parecer certo: mandar "vai a 37 unidades a frente" e depois
# corrigir para 6 faz o client puxar o modelo para tras a cada pacote, e na tela isso e o
# monstro a tremer. Foi o que o jogador gravou em video.
#
# Licao, ja repetida neste projeto: antes de medir, separar as populacoes. Perseguir e voltar
# usam o MESMO estado 1 e valores diferentes; pet nao e monstro.
#
# 18,67 e nao 21,33: o passo REAL medido entre pacotes e igual a distancia DECLARADA neles.
# Faz sentido -- o servidor diz para onde vai e vai mesmo ate la. O 21,33 vinha de uma
# medicao antiga do avanco, feita antes de se separar a perseguicao do retorno.
#
#     avanco medido entre pacotes   18,67  (mediana de 3.715)
#     distancia declarada           18,67  (98 por cento deles)
#     intervalo                     0,484 s
#     velocidade                    38,6 u/s
# REVERTIDO EM 29/08 PARA O ESTADO DE 27/08, a pedido do jogador.
#
# O que se segue nao e uma medicao nova: e a perseguicao que existia antes do trabalho de
# tirar o chacoalho, e que ele diz que estava certa -- "quando adicionou os mobs pela
# primeira vez ele nao ataca nem revida, ai voce colocou os mob pra revidar, tava certo,
# depois colocou os mob agressivos, tava certo tambem, ai quando foi pra tirar chacoalho, que
# bagunçou tudo".
#
# A diferenca nao e a velocidade -- sao as duas ~39,5 u/s. E a GRANULARIDADE:
#
#              passo    intervalo   pacotes/s
#     27/08     5,92      0,15 s       6,7
#     28-29/08 21,33      0,537 s      1,9
#
# Os 21,33 a cada 0,537 s sao o que eu medi no fio do oficial, e e capaz de estar certo para
# ele. Mas o oficial manda pouco e o client dele resolve; nos mandamos o mesmo pouco e sai
# aos solavancos. Copiei o numero sem copiar aquilo que o faz funcionar, e estraguei o que ja
# estava bom. Enquanto nao souber o que o client faz com esses pacotes, mandar mais e melhor
# do que mandar o numero certo.
PASSO_PERSEGUICAO = 5.92       # 27/08: 5,33 * (0,15 / 0,135) -> 39,5 u/s
"""Avanco por pacote enquanto PERSEGUE, EM REGIME. Estado 1 do byte [7] do 04 01.

A perseguicao do oficial nao tem um passo so: tem uma SEQUENCIA fixa de tres, medida em
2.378 pares do ficheiro de 12,8 horas de 27/08:

    2,667  ->  18,667  ->  21,333  ->  21,333  ->  21,333  ...

    depois de  2,667 vem 18,667 em 98 por cento (n=469)
    depois de 18,667 vem 21,333 em 91 por cento (n=431)
    depois de 21,333 vem 21,333 em 90 por cento (n=456)

Os tres tem sentido geometrico exacto: 18,667 e chegar ao destino declarado (residuo abaixo
de 0,3 u em 100 por cento de 634 casos) e 21,333 e passar 2,667 alem dele (residuo de
2,667 em 100 por cento). O avanco esta quantizado em 2,66667 u -- 97,5 por cento dos passos
a menos de 0,05 quanta de um inteiro -- e o declarado sao sempre 7 quanta.

ESTEVE 18,67 AQUI DURANTE MEIA HORA, EM 28/08, E ERA ERRADO. Duas medicoes independentes
deram 18,67 e as duas estavam contaminadas pela MESMA causa: `sorted(lst)` sem chave em
tools/analise/metricas.py, que trocava a ordem dos pacotes que partilham carimbo de
milissegundo e punha 7,3 por cento dos passos a andar para tras. Duas medicoes que
concordam nao sao confirmacao quando passam pelo mesmo instrumento avariado.

A segunda contaminacao foi juntar o npc 11000020, que e outra populacao: 77,6 u/s contra
38,9 dos monstros normais, e declarada 37,333 em 100 por cento dos pacotes. Sozinho
explicava a cauda de 6/10/16/38/42 do histograma que me levou a falar em "passo
multi-modal".

Velocidade real, medida por caminho total a dividir por duracao -- imune a perda de pacotes
e a coalescencia do TCP: perseguicao 39,69 u/s, passeio 10,00 u/s. Exactamente quatro vezes.
"""
DECL_PERSEGUICAO = 37.33       # 27/08 -- ver a nota em PASSO_PERSEGUICAO
"""Para onde o pacote APONTA: a distancia entre a origem e o destino declarado, dentro do
mesmo 04 01.

MEDIDO: 23,33 em 89 a 100 por cento dos pacotes, em todas as trinta capturas. O client
interpola a caminho deste ponto -- por isso ele e muito maior que o avanco real de 6,67.
Mandar os dois iguais, como estava, era dizer ao client "vai ate onde ja estas", e e dai que
vinha o tremor que o jogador filmou em 28/08."""
# MEDIDO: 1,04 nos pacotes em que o monstro se APROXIMA do jogador. Quando ele se afasta --
# o retorno a ancora, que usa o mesmo estado 1 -- o campo vale 3,14. Medir os dois juntos da
# um numero que nao existe em nenhum dos casos.
VEL_PERSEGUICAO = 1.05

# A VOLTA E MAIS RAPIDA QUE A CACADA: passo 42,66, o dobro. Medido em 12 passos seguidos.
# MEDIDO EM 29/08. O 42,66 era um numero meu e nao existe no oficial.
#
# Varri 236.345 avancos entre pacotes consecutivos do mesmo monstro, em todas as capturas:
#
#     estado 0 (a andar)    n=207.614   massa em 4 e 6      avancos >= 40:     0  (0,00%)
#     estado 1 (a correr)   n=  7.618   massa em 18 e 22    avancos >= 40:    70  (0,92%)
#     estado 2 (parado)     n= 21.113   massa em 0          avancos >= 40:     0  (0,00%)
#
# Nao ha um unico avanco de 42,66 em 207 mil passos de caminhada, e a correr sao 0,9 por
# cento -- cauda, nao regime. O monstro do oficial nunca se desloca isso entre dois pacotes.
#
# O que isto valia AQUI: o regresso sai debaixo do relogio da perseguicao, que depois da
# reversao de 29/08 e 0,149 s. 42,66 / 0,149 da 286 u/s -- quatro vezes e meia a velocidade
# a que o jogador corre. Por isso o Urso furioso, que e agressivo e portanto persegue e
# desiste, parecia disparar de volta ao poste. Foi o que o jogador viu: "urso furioso ainda
# volta correndo pro ponto inicial".
#
# Passa a ser o passo da perseguicao, ou seja os mesmos 39,5 u/s -- o mais rapido que se
# mediu no oficial. De lambuja, a trava logo abaixo (`d_ancora <= PASSO_RETORNO` poe o mob
# na ancora de uma vez) deixa de ser um salto de 42,66 e passa a ser um de 5,92.
PASSO_RETORNO = PASSO_PERSEGUICAO
DECL_RETORNO = 37.33
VEL_RETORNO = 4.71

# Arranque: os dois primeiros passos sao menores. 2,67 depois 18,67, e so entao 21,33 cheio.
# ARRANQUE. Os dois primeiros passos de uma cacada sao mais curtos: o monstro acelera em vez
# de saltar para a velocidade de ponta. Medidos no oficial junto com o passo de 21,33 -- os
# 0,74 e 5,18 que aqui estiveram eram estes reescalados para o passo errado de 5,92.
# ATRASO ANTES DE COMECAR A PERSEGUIR, depois de levar uma pancada.
#
# O jogador tinha reparado nisto a olho -- "no oficial, o mob tem um daley antes que
# comecar a perseguir, no nosso e insta" -- e ficou medido em 243 puxadas limpas do oficial
# (mob a mais de 60 u, a passear ou parado, sem estado 1 nos segundos anteriores):
#
#     mediana 0,818 s   p25 0,363   p75 1,202   p90 1,275   maximo 4,992
#     abaixo de 0,1 s: 6 por cento -- praticamente nunca e instantaneo
#
# Metade dos casos passa de uma cadencia inteira do proprio mob (0,62 s), portanto nao e
# artefacto da grelha de pacotes. Sorteia-se em vez de fixar a mediana: com um valor fixo o
# bando todo arranca em unissono quando um golpe apanha varios.
#
# JA DESAPARECEU UMA VEZ, em 28/08, apagada por uma substituicao minha no bloco ao lado. O
# servidor subiu na mesma e so rebentou quando alguem atacou -- "quando ataquei o mob, deu
# disconnect" -- porque este caminho so e percorrido a partir do primeiro golpe. Uma
# constante que so e lida em combate nao e apanhada por nenhum arranque limpo.
ATRASO_AGGRO = (0.36, 1.28)

# O RUMO CONGELA DURANTE O TRECHO. Nao se re-aponta ao jogador a cada pacote.
#
# MEDIDO no oficial: o rumo declarado fica bit-a-bit inalterado entre pacotes seguidos em
# 95,3 por cento dos pares a perseguir (91,0 por cento em todo o estado 1). Dentro de um
# trecho o monstro anda EM CIMA da recta -- desvio lateral mediano de 0,001 u, p99 1,655 --
# ou seja os pacotes do meio nao sao decisoes, sao posicao a avancar. Quando ele muda de
# rumo, muda MUITO: mediana 50 graus, p25 18, p75 83. Nao existe correccao pequena.
#
# PRIMEIRA TENTATIVA, E ESTAVA ERRADA: uma zona morta de 15 graus por passo, com o rumo a
# ser reavaliado a cada pacote e so aplicado acima do limiar. Piorou. O mob acumulava
# desvio ate passar o limiar, corrigia de uma vez, e recomecava a acumular -- as viradas
# passaram de 5-10 graus para 15-40, e a fraccao de passos em linha recta caiu de 49 para
# 33 por cento. O jogador disse-o em duas palavras: "ta so piorando".
#
# O erro foi de modelo, nao de valor: um limiar continua a ser uma decisao por passo. O
# oficial decide UMA VEZ por trecho e depois nao decide mais.
# DESLIGADO em 28/08, depois de o jogador ver o resultado: "quando vou pra correr ele e
# tao rapido que passa de mim e volta pra traz de novo".
#
# O congelamento vinha da medicao certa (o oficial mantem o rumo em 95,3% dos pares) mas
# aplicada a um corpo que se move de outra maneira. Em _andar_ate o passo e limitado por
# `min(passo, distancia_ao_alvo)`, o que impede o mob de passar o jogador SE ele andar na
# direccao do jogador. Com o rumo congelado ele anda na direccao ANTIGA: o limite conta a
# distancia numa recta e o movimento faz-se noutra, ele passa ao lado, e o passo seguinte
# manda-o para tras. Quanto mais depressa, pior.
#
# Para voltar a tentar, o congelamento tem de vir com o limite a ser calculado SOBRE O
# RUMO CONGELADO (a projeccao), nao sobre a distancia em linha recta ao jogador.
# ================================ MODO ESPERTO ================================
#
# Um interruptor so, para dar para comparar com o comportamento de sempre. Com False, nada
# disto corre e o mob volta a ser exactamente o que era.
#
# NAO E COPIA DO OFICIAL. Tudo o resto no movimento foi medido no fio ou lido da tabela do
# client; isto sao decisoes de jogo, pedidas pelo jogador em 29/08 ("coloca inteligencia
# nele"). Fica separado de proposito para nao se confundir uma coisa com a outra: se um dia
# se quiser o comportamento do original, basta desligar.
IA_ESPERTA = True

# 1. LISTA DE ODIO. Persegue quem mais dano fez, nao quem bateu por ultimo. Com dois
#    jogadores nota-se: o mob larga quem lhe deu um toque e vai ao que o esta a matar.
IA_ODIO = True

# 2. INTERCECCAO. Aponta para onde o jogador VAI estar, e nao para onde esta. Sem isto o mob
#    persegue sempre o rasto e nunca corta caminho.
#    O quanto olha a frente: 0 desliga, 1,0 seria prever um segundo inteiro. Acima de 0,6 o
#    bicho comeca a cortar tanto que parece adivinhar, o que fica estranho.
IA_ANTECIPACAO = 0.45

# 3. FUGA. Abaixo desta fraccao de vida o mob foge do jogador em vez de o enfrentar. E a
#    unica das tres que se ve de imediato, e serve de teste ao proprio interruptor.
IA_FUGIR_ABAIXO_DE = 0.20

# SO A FADA FOGE. Foi o jogador que corrigiu, em 29/08: "unico mob que corre quando e atacado
# e a fada, outros mob luta ate morrer". Ja tinha havido um erro simetrico antes -- fiz o
# monstro docil fugir por uma lembranca de que a fada corria, e ele foi testar e disse que a
# Aranha pequena revida. Docil/agressivo diz quem COMECA a briga; nao diz quem foge dela.
# Sao duas propriedades diferentes e agora estao separadas.
#
# Ids tirados da tabela Mob: as quatro chamadas "Fada". A "Fada caida" (9990106) fica DE FORA
# de proposito -- o nome sugere outra coisa e nao ha nada medido sobre ela.
IA_FOGEM = {9000001, 11000031, 11000032, 11000033}
IA_VEL_FUGA = 1.25      # foge mais depressa do que persegue
#
# Historico do numero. Comecou em 0,20 e no teste das 09:12 nao disparou uma unica vez: o
# Javali-de-sabre tem 133 de vida e o jogador bate 80 e 84, portanto a vida foi
# 133 -> 124 -> 44 -> 0 e nunca passou pela faixa dos 26,6. Subiu-se a 0,50 so para o teste
# das 09:5x, onde disparou tres vezes (9%, 22%, 41%) e o jogador confirmou de imediato:
# "quando mob ta perto de morrer ele corre pra longe". Confirmado o mecanismo, voltou a 0,20.
# Se com 0,20 parecer que quase nunca acontece, o motivo e este: com o dano actual quase
# nenhum mob visita a faixa antes de morrer.

LADO_PONTO = 12.0    # quao perto basta chegar para dar um ponto do caminho por feito
CONGELA_RUMO_NA_RAJADA = False

# De quantos em quantos passos se volta a apontar ao jogador. A 0,247 s por passo, 4
# passos sao cerca de 1 s de recta -- proximo da duracao de trecho do oficial (mediana
# 0,62 s, p90 2,20) e o suficiente para o rumo ficar a zero graus na maioria dos pares,
# que e a forma que faltava. Subir isto da rectas mais longas e curvas mais bruscas;
# descer aproxima-se do tremor que tinhamos.
PASSOS_POR_RUMO = 4

# ---------------------------------------------------------------------------------------
# CONTROLADOR DE PERSEGUICAO
#
# Ate 28/08 a perseguicao era "aponta ao jogador e salta um passo fixo". Isso nunca podia
# ficar bem, e nao por os valores estarem errados: o modelo e que nao tem inercia. Um corpo
# sem inercia ou muda de rumo instantaneamente (e treme, porque corrige a cada pacote) ou
# guarda o rumo antigo (e passa ao lado do alvo). Passaram-se horas a afinar constantes
# entre esses dois defeitos, e o jogador resumiu-o: "nao da, tem como fazer tipo uma IA pra
# controlar os mob nessa parte?".
#
# Agora o mob tem estado proprio -- velocidade e rumo -- e a cada passo:
#
#   1. ANTECIPA para onde o jogador vai, em vez de apontar para onde ele esta
#   2. VIRA na direccao desejada, no maximo TAXA_DE_GIRO por segundo
#   3. ACELERA ou trava na direccao da velocidade desejada, no maximo ACEL por segundo
#   4. TRAVA A CHEGADA: a velocidade desejada desce dentro de RAIO_DE_TRAVAGEM e chega a
#      zero a distancia de golpe, portanto ele nao pode passar ao lado
#
# Os quatro defeitos que o jogador foi descrevendo saem todos do mesmo sitio: o tremor e a
# falta de limite de rotacao, o vaivem e a falta de travagem, a arrancada e a falta de
# aceleracao. Uma constante nao resolve nenhum; o estado resolve os tres.
# VELOCIDADE DE PERSEGUICAO DO MONSTRO. E dele, nao uma fraccao da do jogador.
#
# MEDIDA no oficial: 39,69 u/s, por caminho total a dividir pela duracao do trecho (imune a
# perda de pacotes e a coalescencia do TCP), n=146 trechos. Confere por outro caminho: 21,33
# de passo a esta velocidade da 0,538 s de intervalo, e a cadencia medida e 0,531-0,543.
#
# ESTEVE ESCRITA COMO `VELOCIDADE_DO_JOGADOR * FACTOR`, E ISSO ESTAVA MAL. O jogador
# apanhou-o em 28/08: "existe habilidade que aumenta minha velocidade de movimento, nao pode
# deixar minha velocidade fixa". Amarrar a velocidade do bicho a um 65 escrito a mao junta
# duas coisas que sao independentes -- e no dia em que a velocidade do jogador passar a
# variar (opcao de item tipo 27, "Velocidade de movimento", ou uma habilidade), o mob
# passaria a herdar um numero que ja nao descreve ninguem.
#
# No oficial o monstro corre sempre aos mesmos 39,7, ande o jogador depressa ou devagar; o
# que decide se ele te apanha e a TUA velocidade contra a dele, e e assim que um bonus de
# movimento passa a servir para alguma coisa. Se o mob acompanhasse a tua velocidade, o
# bonus nao teria efeito nenhum -- que e exactamente o que ele quis dizer.
#
# Historia das tentativas de 28/08, para nao se repetir a volta:
#     74,8 u/s  ->  "ele ta muito rapido na perseguisao"
#     68,3 u/s  ->  "nem consigo pegar distancia, ele fica colado em mim"
#     39,7 u/s  ->  o valor do oficial, e da para fugir a pe
#
# Referencia, para quem quiser calibrar: o jogador corre a 65 u/s, medido em 6.258 amostras
# dos pacotes que o proprio client envia, com as oito capturas do oficial entre 64,9 e 66,6.
# Ha um segundo modo em 90 u/s, que e provavelmente ja com bonus de velocidade -- mais uma
# razao para nao tratar a velocidade do jogador como uma constante.
VEL_MAX_PERSEGUICAO = 39.7
# VELOCIDADE CONSTANTE, por decisao do jogador em 28/08: "deixa a velocidade dele
# constante, ele acelera e desacelera".
#
# A aceleracao (105 u/s^2) e a travagem (55 u de raio) vinham da forma medida no oficial
# -- ele tem mesmo uma rampa de entrada, 2,67/18,67/21,33 -- mas no ecra o que se ve e o
# bicho a mudar de velocidade a toda a hora, e isso incomoda mais do que a fidelidade
# ajuda. Com infinito e zero, o controlador anda sempre a VEL_MAX.
#
# O QUE NAO SE PERDE: o limite de rotacao (TAXA_DE_GIRO) continua, portanto ele nao
# treme; e o avanco continua limitado pela distancia que falta ate a distancia de golpe,
# portanto ele para a seco quando chega em vez de passar ao lado. Velocidade constante
# nao quer dizer atravessar o jogador.
#
# Para voltar a rampa: 105 e 55 eram os valores.
ACEL_PERSEGUICAO = float("inf")
# O RUMO E ESCOLHIDO UMA VEZ POR SEGMENTO, nao a cada pacote.
#
# MEDIDO no oficial (captura de 28/08 as 21:35, n=28 passos de perseguicao): 93 por
# cento dos passos vao em linha recta -- rumo identico ao do pacote anterior. Nos
# davamos 27 por cento. Ele escolhe uma recta e percorre-a; nos re-apontavamos a cada
# pacote, e cada pacote levava uma correccaozinha, que e o tremor que se ve.
#
# O limite de rotacao sozinho nao chegava: 200 graus por segundo a 0,555 s por passo da
# 111 graus por passo, ou seja limite nenhum na pratica.
#
# PORQUE NAO E UMA ZONA MORTA. Ja se tentou (limiar de 15 graus, 28/08) e piorou: o mob
# acumulava desvio ate passar o limiar e corrigia de uma vez, o que trocou viradas de
# 5-10 graus por viradas de 15-40. Um limiar continua a ser uma decisao por passo.
#
# E PORQUE NAO PASSA AO LADO, que foi o defeito da primeira tentativa de congelar: o
# avanco e limitado pela PROJECCAO da distancia que falta sobre o rumo escolhido, e nao
# pela distancia em linha recta ao jogador. Se o alvo deixar de estar a frente, isso
# conta como fim de segmento e o rumo e escolhido de novo.
PASSOS_POR_SEGMENTO = 4          # ~2,2 s de recta, e depois reaponta
VIRAGEM_IMEDIATA = math.radians(50)   # desvio que obriga a reapontar sem esperar
TAXA_DE_GIRO = math.radians(200) # graus por segundo, dentro da viragem de segmento
RAIO_DE_TRAVAGEM = 0.0           # 0 = sem curva de travagem; para a seco no alcance
ANTECIPACAO = 0.40               # segundos de previsao da posicao do jogador

RAMPA_PERSEGUICAO = (0.74, 5.18)   # 27/08: 12% e 88% de PASSO_PERSEGUICAO
"""Avanco dos primeiros passos de uma cacada, antes do regime de 21,33.

MAIS SUAVE DO QUE O OFICIAL, DE PROPOSITO. O que se mediu la foram TRES valores --
2,667 -> 18,667 -> 21,333, encadeados em 98 e 91 por cento de 2.378 pares -- mas essa
rampa e abrupta a meio: salta de 2,67 para 18,67, sete vezes de uma vez. O jogador pediu
menos: "tem como abaixar a aceleracao inicial?".

Aqui vao quatro passos, todos multiplos do quantum de 2,66667 em que o oficial se move
(1, 2, 4 e 6 quanta, e depois os 8 do regime). Sao cerca de 2,1 s a acelerar em vez de
1,1 s.

PARA AFINAR: acrescentar valores alonga o arranque, tirar encurta-o. Manter multiplos
de 2,667 nao e superticao -- 97,5 por cento dos passos do oficial caem a menos de 0,05
quanta de um inteiro, e sair da grelha e uma diferenca que se mede.

Para voltar exactamente ao oficial: (2.67, 18.67).
"""
"""Os dois primeiros passos de uma cacada, antes do regime de 21,33.

MEDIDO no oficial em 2.378 pares do ficheiro de 12,8 horas, e a sequencia e encadeada, nao
sorteada:

    2,667  ->  18,667  em 98 por cento (n=469)
    18,667 ->  21,333  em 91 por cento (n=431)
    21,333 ->  21,333  em 90 por cento (n=456)

O 2,667 e um quantum, e 95,5 por cento dos passos desse tamanho sao o PRIMEIRO da rajada;
sai muito colado ao anterior (dt mediano 0,010 s) e com o mesmo rumo. Depois 18,667, que e
chegar ao destino declarado, e so entao o passo cheio.

ESTEVE VAZIA, E A RAZAO PARA A ESVAZIAR ERA INVALIDA. O argumento era uma metrica de
"arranque" que dava 1,18 no oficial e 0,56 em nos -- ou seja, ele comecaria mais depressa e
assentaria. Essa metrica foi calculada com o `sorted(lst)` avariado do metricas.py, que
trocava a ordem dos pacotes com carimbo igual; e uma metrica de SEQUENCIA, que e exactamente
o que esse defeito destroi. Com a ordem do fio, o que ha e uma rampa a subir.

O jogador chegou la primeiro, a olho, e duas vezes: "da um arranque depois diminui a
velocidade" e depois "oque ta bugando e velocidade de arrancada". Sem rampa o mob salta de
parado para 21,33 num pacote so.
"""
"""Primeiro passo mais curto. O 3,33 e o segundo valor mais comum do avanco (10 a 15 por
cento), e nao esta medido que apareca so no arranque -- aparece ao longo da caminhada toda.
Fica um passo curto a abrir porque e barato e plausivel; se uma medicao do INICIO de uma
cacada disser que nao ha rampa nenhuma, tira-se."""

# Sem isto, o mob emitiria passo de perseguicao a cada tick de 0,15 s e andaria 4x mais rapido
# que o oficial. Os dt medidos durante a perseguicao ficam entre 0,45 e 0,63.
# Medido no oficial: 0,479 s de mediana entre pacotes durante a perseguicao, contra 0,612
# a passear. O bicho manda MAIS pacotes quando corre -- faz sentido, precisa de corrigir o
# rumo com mais frequencia porque o alvo mexe-se.
#
# O VALOR TEM DE SER MULTIPLO DE TICK_MUNDO. A trava e amostrada pelo laco do mundo, que
# dorme 0,15 s: o passo so sai no primeiro tick em que ja passou o intervalo. Com 0,48 o
# primeiro tick que serve e o de 0,60 -- ou seja, pedir 0,48 entregava 0,60, um quarto mais
# lento que o oficial, e o monstro gastava um quarto mais de tempo a fechar a distancia.
# E a mesma armadilha de grelha que ja tinha estragado o andar dos monstros e a entrega do
# dano; e a terceira vez que ela aparece neste arquivo.
#
# Trocar o valor por um multiplo "exato" NAO resolve: 0,15 somado repetidamente nao da
# multiplos exatos em virgula flutuante, e com 0,45 o laco passou a alternar entre 0,450 e
# 0,600 conforme a deriva. Quem tem de mudar e a COMPARACAO -- ver `venceu` -- que aceita o
# tick a menos de meio tick do alvo. Assim o valor aqui volta a ser o que foi MEDIDO, e o
# codigo deixa de prometer um intervalo que a grelha nao consegue entregar.
#
# Residuo assumido: a grelha so oferece 0,45 e 0,60, e 0,45 fica 6 por cento acima da
# velocidade do oficial. Antes disto estava 25 por cento abaixo.
# MEDIDO: 0,484 s de mediana entre pacotes de estado 1, nos 3.715 do log de 250 minutos.
# Cheguei a por 0,15 aqui, de uma medida contaminada pelas mesmas duas coisas que
# estragaram o passo -- ver o comentario de PASSO_PERSEGUICAO.
# VELOCIDADE DO JOGADOR A CORRER, medida no nosso proprio fio em 28/08 a partir das
# linhas JOGADOR do espiao (uma posicao por segundo), so em trechos de dois ou mais
# segundos seguidos em movimento:
#
#     [ADM]-NoLosT   mediana 72,8   p75 91,6   max  91,6 u/s   (4 trechos)
#     jacarta        mediana 76,8   p75 96,0   max 115,3 u/s   (8 trechos)
#
# A amostragem de 1 Hz mede a CORDA e nao o caminho, portanto isto e um piso, nao um
# tecto: quem curva dentro do segundo aparece mais lento do que e. Se a perseguicao
# ficar curta, o numero a subir e este.

# O INTERVALO SAI DA VELOCIDADE, e nao ao contrario. O passo fica nos 21,33 medidos no
# oficial -- e o que mantem a relacao entre o avanco e a distancia declarada, que e o
# que o client interpola; mexer no passo em vez do intervalo estragava essa relacao e
# fazia o client chegar ao destino e parar entre pacotes.
#
# Estava 0,536 s, que com passo 21,33 dava 39,8 u/s -- pouco mais de METADE da
# velocidade do jogador. Um perseguidor mais lento que a presa nunca a alcanca: ele
# ficava para tras, a corda esticava, largava, e voltava. Era isso que se via como
# perseguicao partida, e nenhuma das constantes de rumo que se afinaram antes disto
# podia corrigir uma coisa que e de velocidade.
# O INTERVALO SAI DA VELOCIDADE, para o AVANCO POR PACOTE nao mudar.
#
# E a relacao entre o avanco e a distancia declarada que o client interpola. O oficial anda
# 21,33 e declara 18,67 -- razao 1,14 -- e o client fica sempre a caminho, nunca chega.
#
# COM O INTERVALO FIXO EM 0,536 ISTO PARTIA-SE ao subir a velocidade: a 74,8 u/s cada
# pacote avancava 74,8 x 0,536 = 40 unidades contra 18,67 declarados, razao 2,15. O client
# corria os 18,67, PARAVA, e era puxado os outros 21 quando o pacote seguinte chegava --
# ou seja, quanto mais depressa se punha o mob, mais aos solavancos ele ficava. O jogador
# viu-o logo: "ele ta muito rapido na perseguisao".
#
# Com o intervalo derivado, mudar a velocidade muda a CADENCIA dos pacotes e nunca o
# tamanho do salto. A 68,3 u/s da 0,312 s, ou seja cerca de 3 pacotes por segundo por mob
# a perseguir -- e mais trafego, mas so enquanto ha caca, e sao poucos de cada vez.
INTERVALO_PERSEGUICAO = PASSO_PERSEGUICAO / VEL_MAX_PERSEGUICAO
"""MEDIDO: mediana entre 0,606 e 0,620 s nas trinta capturas, sem uma unica excecao. Nao
muda com o monstro, com o nivel nem com estar a perseguir."""

# ELE NAO PERSEGUE EM LINHA CONTINUA. Os 8 episodios do oficial vem em rajadas curtas
# separadas por paradas:
#
#     rajadas (s) : 1,23  2,66  2,17  0,59  2,25  3,40
#     pausas  (s) : 6,2   3,05  0,4   3,0   3,0
#
# Sem isto o monstro vai sempre em frente na mesma direcao ate esticar a corda, que foi o que
# o jogador viu no nosso servidor. A rajada e o que faz parecer bicho e nao missil.
# RAJADA E PAUSA -- JA NAO SAO USADAS, mantidas so como registo do que se mediu mal.
#
# Vieram de contar sequencias de estado 1 em capturas antigas e tratar os intervalos entre
# elas como "descanso". Nao era: eram cacadas SEPARADAS, com perda de alvo pelo meio. Numa
# cacada real o oficial nao pausa -- 19 passos seguidos, medido na entidade 1168.
PERSEG_RAJADA_MIN, PERSEG_RAJADA_MAX = 1.2, 3.4
PERSEG_PAUSA_MIN, PERSEG_PAUSA_MAX = 2.5, 4.0
# O prefixo PERSEG_ nao e enfeite: mais abaixo neste mesmo arquivo existe um RAJADA_MAX = 6013,
# que e o tamanho do LOTE DE FRAMES da rede. Como ele vem depois, ganhava a atribuicao, e a
# minha rajada de perseguicao passava a durar 6013 segundos -- o monstro nunca pausava, ia
# sempre em frente ate esticar a corda. Foi o que o jogador viu: "anda quase na mesma direcao
# sempre em frente". Nenhum erro, nenhum aviso: os dois nomes eram validos.

_agressao = {}


def velocidade_de(npc_id, correndo):
    """Velocidade desta especie, em u/s. Do npcInfo.lod do CLIENT, nao medida no fio.

    E o unico numero do movimento que nao vem de medicao: vem da tabela que o proprio client
    usa para animar o bicho. Ver tools/extrair_velocidades.py para a formula (passeio =
    f1*f2, corrida = f1*f2*f2) e para o porque de se confiar nela em vez do medido.

    O servidor usava 10,6 e 39,7 para as 926 especies. A maioria e mesmo 10,81/39,35, mas ha
    seis grupos diferentes -- ate 13,03 a passear -- e essa diferenca ve-se: um bicho que o
    client anima a 13 e o servidor move a 10,6 desliza com as patas.
    """
    r = (data_files.indice("MobVelocidade", "mob_id") or {}).get(int(npc_id or 0))
    if r:
        try:
            v = float(r["corrida" if correndo else "passeio"])
            if v > 0.5:
                return v
        except (KeyError, TypeError, ValueError):
            pass
    return VEL_MAX_PERSEGUICAO if correndo else 10.81


def cadencia_de_golpe(npc_id):
    """Segundos entre golpes desta especie. Medida no oficial; ver tools/medir_alcance.py.

    Media global 2,98 s em 3.335 intervalos, e por especie de 2,39 (Sedento de sangue) a
    4,19 (Tirano sanguinario). O servidor tinha 4,21 para TODAS -- o topo da distribuicao, ou
    seja toda a fauna a bater ao ritmo do bicho mais lento do jogo.
    """
    r = (data_files.indice("MobAlcance", "mob_id") or {}).get(int(npc_id or 0))
    try:
        c = float(r.get("cadencia") or 0.0) if r else 0.0
    except (TypeError, ValueError):
        c = 0.0
    return c if c > 0.2 else CADENCIA_OMISSAO


def _reputacao_de(entity_id):
    """A moral do jogador. Zero quando nao se sabe -- e o lado que NAO faz atacar."""
    import mundo
    j = mundo.jogador(int(entity_id or 0))
    h = getattr(j, "heroi", None) if j is not None else None
    return int(getattr(h, "reputacao", 0) or 0)


_parada = {}


def especie_parada(npc_id):
    """True quando a especie nao passeia -- `anda = False` no Mob.ini.

    Existe para o GUARDA. Ele e tipo=2 na tabela do proprio client, ou seja criatura e nao
    NPC, portanto recebia trilha como qualquer monstro e passeava pela cidade. O jogador
    viu-o no oficial em 30/08: os guardas ficam nos postos deles.

    Nao se resolve pondo `tipo = npc` no poste, que e o que a primeira ideia sugeria: isso
    tambem desliga a agressao, e o guarda TEM de reagir a quem tem moral negativa.
    """
    npc_id = int(npc_id or 0)
    v = _parada.get(npc_id)
    if v is None:
        linha = (data_files.indice("Mob", "mob_id") or {}).get(npc_id) or {}
        # CUIDADO COM O `or`: o data_files ja converte "False" para o booleano False, e
        # `False or ""` da string vazia -- a leitura dizia sempre "anda". Ausente e
        # diferente de False, e so o segundo e que trava o passeio.
        bruto = linha.get("anda")
        if bruto is None:
            v = False
        elif isinstance(bruto, bool):
            v = not bruto
        else:
            v = str(bruto).strip().lower() in ("false", "0", "nao")
        _parada[npc_id] = v
    return v


def dados_de_agressao(npc_id):
    """(agressivo, alcance_do_golpe) da especie, do Mob.ini. Cacheado."""
    if not npc_id:
        return (False, 0.0)
    v = _agressao.get(npc_id)
    if v is None:
        linha = (data_files.indice("Mob", "mob_id") or {}).get(int(npc_id)) or {}
        # TRES ESTADOS, nao dois. O guarda da cidade e agressivo, mas nao a toda a gente:
        # o jogador viu-o em 30/08 -- "eles atacam sem aviso quem ta de moral negativa".
        # `agressivo = pk` diz exactamente isso, e le-se no ponto onde o alvo e escolhido.
        cru = str(linha.get("agressivo") or "").strip().lower()
        agr = "pk" if cru == "pk" else (cru in ("true", "1", "sim"))
        # O MEDIDO GANHA AO DA TABELA. O Mob.ini tem alcance_ataque preenchido em 5 de 926
        # especies e as outras 921 usavam 30 para tudo -- uma aranha pequena parava a 30 do
        # jogador e batia-lhe de la. Medido em 5.151 golpes do oficial (tools/medir_alcance),
        # a aranha ataca a 17,0 e o cao branco a 25,3; o tecto e 30 e o que varia e quanto
        # abaixo dele cada bicho fica. O jogador viu-o de imediato: "o mob quando ataca ele
        # fica longe de mim".
        medido = (data_files.indice("MobAlcance", "mob_id") or {}).get(int(npc_id))
        if medido and medido.get("alcance"):
            alc = float(medido["alcance"])
        else:
            alc = float(linha.get("alcance_ataque") or 30.0)
        v = _agressao[npc_id] = (agr, alc)
    return v


def _escala_da_trilha(trilha, raio):
    """Fator para a trilha caber num raio. 1.0 quando ja cabe -- nunca AMPLIA.

    NAO E CHAMADA POR NINGUEM, e e de proposito. Foi escrita para encolher a trilha ao raio
    do poste, na ideia de que esse raio era de perambulacao. A medicao de 30/08 desmentiu-a
    -- ver o comentario em `Entidade.raio`: o raio diz onde o bicho nasce, e a dispersao dele
    e a mesma em postes de raio 10 e de raio 300.

    Fica porque a mecanica de encolher serve, e e a mesma que o `_escala_por_altura` usa com
    um criterio que ESTA medido. Se alguem a for ligar ao raio outra vez, leia primeiro
    aquele comentario.
    """
    if not trilha or not raio:
        return 1.0
    alcance = max(math.hypot(p[1], p[2]) for p in trilha)
    if alcance <= raio:
        return 1.0
    return raio / alcance


BANDA_DE_ALTURA = 45.0
"""Quanto um monstro pode afastar-se, em Z, da altura do poste dele.

MEDIDO no oficial em 30/08, sobre 401.058 posicoes de 1546 monstros. Desvio de cada
posicao face a altura MEDIANA do proprio bicho:

    p50 2,4    p90 9,8    p95 13,5    p99 22,7    max 78,0

Os nossos, calculados poste a poste com o mapa de alturas e a trilha que lhes cabe:

    Velocista do pantano   449        Lagarto do pantano   421
    Aranha pequena         309        Urso coruja          285
    Javali louco           112        Urso furioso         112

O jogador viu o resultado: "ja tinha javali em cima dos morro onde player nao chegam".

PORQUE 45 E NAO 23. O numero do oficial (p99 = 22,7) e medido a partir da MEDIANA do
proprio monstro; o nosso centro e o poste, que nao tem de estar no meio da area dele.
45 e o dobro, com folga para esse descentramento, e continua a barrar tudo o que se viu
mal -- o menor dos casos acima e 112. Nao e o numero do oficial: e o numero do oficial
mais uma margem que eu escolhi, e fica dito.

O DECLIVE NAO SERVIA. Medi-o tambem, 326.974 passos: p50 2,6 graus, p90 20,1, p99 42,1,
maximo 81,8. O monstro do oficial sobe encostas ingremes a vontade -- o que ele nao faz e
ACABAR longe da altura de onde partiu.
"""


def _escala_por_altura(trilha, ax, ay, banda=BANDA_DE_ALTURA):
    """Fator que mantem a trilha dentro da banda de altura, a partir do poste (ax, ay).

    Encolhe, nunca amplia, como o `_escala_da_trilha`. Devolve 1.0 quando ja cabe, e nunca
    desce abaixo de 0,1 -- um bicho preso a andar 3 unidades chama mais a atencao do que um
    que sobe um bocado a mais.
    """
    z0 = altura_medida_em(ax, ay)
    if z0 is None or not trilha:
        return 1.0
    f = 1.0
    for _ in range(12):
        pior = 0.0
        for p in trilha:
            z = altura_medida_em(ax + p[1] * f, ay + p[2] * f)
            if z is not None:
                d = abs(z - z0)
                if d > pior:
                    pior = d
        if pior <= banda or f <= 0.1:
            return max(0.1, f)
        f *= 0.75
    return max(0.1, f)


def _trilha_escalada(trilha, f):
    """A mesma trilha, encolhida. Posicao e destino declarado andam juntos."""
    if f >= 0.999:
        return trilha
    return [[p[0], p[1] * f, p[2] * f, p[3] * f, p[4] * f] + list(p[5:]) for p in trilha]


_alcance_trilha = {}


def alcance_da_trilha(npc_id, omissao=150.0):
    """Ate que distancia do inicio da trilha esta especie ANDA, no oficial.

    Serve de coleira ao terreno: o percurso foi gravado de bichos reais, portanto tudo dentro
    dele e chao que a especie pisa mesmo. Medido nas dez especies com trilha: de 104 (Tirano
    sanguinario) a 245 (Fada).
    """
    v = _alcance_trilha.get(npc_id)
    if v is None:
        v = 0.0
        for tr in (_carregar_trilhas().get(int(npc_id or 0)) or []):
            if not tr:
                continue
            x0, y0 = tr[0][1], tr[0][2]
            for p in tr:
                d = math.hypot(p[1] - x0, p[2] - y0)
                if d > v:
                    v = d
        v = _alcance_trilha[npc_id] = v or omissao
    return v


def trilha_para(npc_id, rnd=random):
    """Uma trilha da especie, ou de outra parecida se ela nao tiver.

    Emprestar de outra especie e melhor que simular: o defeito que as trilhas vieram
    corrigir estava na simulacao, nao na especie. Um javali andando com a trilha de um urso
    anda como um urso -- e anda direito.

    `rnd` permite ao chamador fixar a escolha por ponto de spawn. Ver _rnd_do_poste.
    """
    todas = _carregar_trilhas()
    if not todas:
        return None
    minhas = todas.get(int(npc_id or 0))
    if not minhas:
        minhas = todas.get(_de_quem_emprestar(npc_id))
    if not minhas:
        return None
    return _sortear_trilha(minhas, rnd)


_emprestimo = {}


def _de_quem_emprestar(npc_id):
    """De que especie tirar a trilha, quando esta nao tem uma propria.

    ERA `sorted(todas)[0]` -- ou seja, SEMPRE a mesma especie, a de id mais baixo. Todos os
    monstros sem trilha do mundo andavam como um Urso furioso, que e dos mais rapidos
    (8,2 a 8,8 u/s contra 4,4 da Aranha).

    PRIMEIRO O MESMO NOME. Os ids do jogo andam aos pares, e o par e a MESMA criatura em
    dois niveis:

        1000814 Urso furioso nv14   /  1000815 Urso furioso nv15
        1001005 Filhote de urso  5  /  1001006 Filhote de urso  6
        1005002 Urso coruja      2  /  1005003 Urso coruja      3
        1006703 Cao da estepe    3  /  1006704 Cao da estepe    4

    Quatro dos cinco monstros sem trilha tem um irmao com o mesmo nome que TEM trilha. Nada
    anda mais parecido com um bicho do que ele proprio um nivel acima.

    DEPOIS O NIVEL MAIS PROXIMO, para os que nao tem irmao -- so os Zombies, no mundo de
    hoje. E o melhor criterio que os dados sustentam: a velocidade do npcInfo estaria certa,
    mas falta para 12 dos 18 monstros do mundo.
    """
    npc_id = int(npc_id or 0)
    if npc_id in _emprestimo:
        return _emprestimo[npc_id]
    todas = _carregar_trilhas()
    escolha = sorted(todas)[0] if todas else 0
    try:
        ficha = {}
        for r in data_files.tabela("Mob"):
            try:
                ficha[int(r["mob_id"])] = (r.get("nome") or "", int(r.get("nivel") or 0))
            except (ValueError, TypeError):
                pass
        nome, nivel = ficha.get(npc_id, ("", 0))
        irmaos = [i for i in todas if i != npc_id and ficha.get(i, ("", 0))[0] == nome and nome]
        if irmaos:
            escolha = min(irmaos, key=lambda i: abs(ficha[i][1] - nivel))
        elif nivel:
            comnivel = [i for i in todas if ficha.get(i)]
            if comnivel:
                escolha = min(comnivel, key=lambda i: abs(ficha[i][1] - nivel))
    except Exception as e:                                          # noqa: BLE001
        print("[sim] emprestimo de trilha caiu no padrao: %s" % str(e)[:100])
    _emprestimo[npc_id] = escolha
    return escolha


# Marcador de "este poste nao anda". Nao pode ser None nem [], que ja significam
# "sem trilha" e "sedentario decidido" noutros sitios.
SEDENTARIO = object()

_reparticao = None


def _repartir_trilhas():
    """Reparte as trilhas pelos postes de cada especie, em vez de sortear poste a poste.

    Sortear cada poste por si so acerta na distribuicao QUANDO HA MUITOS POSTES. A Fada tem
    tres no mundo inteiro, e o sorteio independente deu-lhe duas vezes a trilha de alcance
    245 e uma sedentaria -- nenhuma com o esvoacar apertado de alcance 13 ou 19, que sao
    dois tercos das fadas gravadas no oficial. Na tela isso le-se como a especie inteira a
    andar a deriva, e foi o que o jogador viu.

    A media global estava certa (90 contra 92,7 do oficial); o que estava errado era a
    variancia com amostra pequena. Repartir resolve na raiz: com tres postes e tres trilhas
    equiprovaveis, cada poste fica com uma delas. Com trinta postes a reparticao aproxima os
    mesmos pesos que o sorteio daria, so que sem o azar.

    O sedentarismo entra como mais uma fatia, com o peso FRACAO_SEDENTARIA -- assim uma
    especie de tres postes praticamente nunca tem um parado (2 por cento de 3 da 0,06), que
    e o correto, ao passo que o sorteio independente atirava a moeda tres vezes.
    """
    global _reparticao
    if _reparticao is not None:
        return _reparticao
    _reparticao = {}
    todas = _carregar_trilhas()
    if not todas:
        return _reparticao

    postes = {}
    try:
        for sp in db.spawns():
            npc = int(sp.get("npc_id") or 0)
            if npc:
                postes.setdefault(npc, []).append((float(sp["x"]), float(sp["y"])))
    except Exception:
        return _reparticao

    for npc, pts in postes.items():
        lista = todas.get(npc)
        if not lista:
            continue                      # especie sem trilha propria: segue o emprestimo
        # ordenar os postes torna a reparticao reproduzivel entre arranques
        pts = sorted(set(pts))
        n = len(pts)
        opcoes = list(lista) + [SEDENTARIO]
        pesos = [PESO_TRILHA_PARADA if _velocidade_da_trilha(t) < LIMIAR_TRILHA_PARADA
                 else 1.0 for t in lista]
        total_tr = sum(pesos) or 1.0
        # as trilhas dividem entre si a fatia que nao e sedentaria
        pesos = [w / total_tr * (1.0 - FRACAO_SEDENTARIA) for w in pesos]
        pesos.append(FRACAO_SEDENTARIA)

        # quota inteira + maiores restos, para a soma bater exatamente em n
        exatos = [w * n for w in pesos]
        quotas = [int(e) for e in exatos]
        sobra = n - sum(quotas)
        for i in sorted(range(len(exatos)), key=lambda i: exatos[i] - quotas[i],
                        reverse=True)[:sobra]:
            quotas[i] += 1

        fila = []
        for opcao, q in zip(opcoes, quotas):
            fila.extend([opcao] * q)
        random.Random(npc * 2654435761 & 0x7FFFFFFF).shuffle(fila)
        for pt, opcao in zip(pts, fila):
            _reparticao[(npc, round(pt[0], 2), round(pt[1], 2))] = opcao
    return _reparticao


def _trilha_do_poste(npc_id, x, y):
    """A trilha que cabe a este poste pela reparticao, ou None se ele nao consta dela."""
    return _repartir_trilhas().get((int(npc_id or 0), round(float(x), 2), round(float(y), 2)))


def _rnd_do_poste(ancora_x, ancora_y, npc_id):
    """Um sorteador estavel para o ponto de spawn -- o mesmo poste sorteia sempre igual.

    Ate aqui a trilha era sorteada a CADA nascimento. Para uma especie cujas trilhas se
    parecem uma com a outra isso nao se ve. Para a Fada ve-se muito: as tres trilhas dela
    tem alcance 13, 19 e 245 unidades, e a mesma fada ora ficava a esvoacar no sitio ora
    atravessava meio mapa, mudando de personalidade a cada morte ou a cada reinicio do
    servidor. O jogador reparou: "o comportamento das Fadas e diferente, ela anda a deriva".

    No oficial cada poste tem uma cara fixa -- a fada daquele sitio anda sempre do mesmo
    jeito. Semear pelas coordenadas do poste devolve isso sem mexer nos dados nem na
    distribuicao: continuam a ser as mesmas trilhas com os mesmos pesos, so que a escolha
    deixa de ser um dado lancado de novo a cada respawn.

    Postes diferentes continuam a divergir entre si, portanto as entidades nao entram em
    sincronia -- que era o defeito que o arranque em ponto aleatorio da trilha veio corrigir.
    """
    semente = (int(round(ancora_x * 100)) * 73856093
               ^ int(round(ancora_y * 100)) * 19349663
               ^ int(npc_id or 0) * 83492791)
    return random.Random(semente & 0x7FFFFFFF)


# Peso minimo de uma trilha quase parada. Ver _sortear_trilha.
PESO_TRILHA_PARADA = 0.1
LIMIAR_TRILHA_PARADA = 1.5      # unidades por segundo


def _velocidade_da_trilha(tr):
    """Distancia total percorrida a dividir pela duracao, em unidades por segundo."""
    dur = (tr[-1][0] - tr[0][0]) / 1000.0
    if dur <= 0:
        return 0.0
    total = 0.0
    for i in range(1, len(tr)):
        total += math.hypot(tr[i][1] - tr[i-1][1], tr[i][2] - tr[i-1][2])
    return total / dur


def _sortear_trilha(lista, rnd=random):
    """Sorteia uma trilha, dando MENOS peso as que quase nao andam.

    Sorteio uniforme sobre-representa os bichos parados. As 6 trilhas da Aranha pequena tem
    velocidades de 0,72 / 1,47 / 2,23 / 4,29 / 5,41 / 7,01 u/s -- duas delas praticamente
    paradas. Com peso igual, uma em cada tres aranhas ficava plantada no sitio, e o jogador
    reparou: "tem aranha que nao sai do lugar, nao sao todas".

    No oficial isso e raro. Medindo 65 aranhas individuais:

        p10 1,91   mediana 4,64   p90 5,58 u/s
        abaixo de 1,5 u/s: 2 de 65 = 3 por cento

    Nos tinhamos 33 por cento -- dez vezes mais. As duas trilhas lentas vieram de bichos
    atipicos e nao deviam pesar tanto quanto as outras.

    O peso baixo nao as elimina: uma aranha parada de vez em quando existe no oficial, so
    nao e um terco delas.
    """
    if not lista:
        return None
    if len(lista) == 1:
        return lista[0]
    pesos = [PESO_TRILHA_PARADA if _velocidade_da_trilha(tr) < LIMIAR_TRILHA_PARADA else 1.0
             for tr in lista]
    total = sum(pesos)
    if total <= 0:
        return rnd.choice(lista)
    alvo = rnd.random() * total
    acumulado = 0.0
    for tr, peso in zip(lista, pesos):
        acumulado += peso
        if alvo <= acumulado:
            return tr
    return lista[-1]


def _sortear(tipo):
    if DIAGNOSTICO_LINHA_RETA:
        if tipo == "trecho": return 9999
        if tipo == "pausa": return 0
        if tipo == "virada": return 0
    """Sorteia um valor da distribuicao `tipo` do MobMovement.ini."""
    acum = _dist_cache.get(tipo)
    if acum is None:
        linhas = [r for r in data_files.tabela("MobMovement") if r.get("tipo") == tipo]
        linhas.sort(key=lambda r: r.get("valor") or 0)
        acum, soma = [], 0.0
        for r in linhas:
            soma += float(r.get("percentual") or 0)
            acum.append((soma, int(r.get("valor") or 0)))
        _dist_cache[tipo] = acum
    if not acum:
        return None
    alvo = random.random() * acum[-1][0]
    for limite, valor in acum:
        if alvo <= limite:
            return valor
    return acum[-1][1]
TRECHO_MAX = 15                 # o p90 medido e 13; corta a cauda da geometrica
ESTADO_ANDANDO, ESTADO_PARADO = 0, 2
VIRADA_MIN_GRAUS = 48.0    # ajustado pra mediana bater com os 113,9 do oficial

# QUANTO O MONSTRO ANDA DE VERDADE, por segundo. Medido no oficial comparando a ORIGEM de um
# 04 01 com a origem do 04 01 seguinte da mesma entidade:
#
#   p10  5,03    p25  8,44    p50 10,29    p75 11,04    p90 12,19
#
# ISTO NAO E O PASSO_MOB. Levei tres tentativas pra entender a diferenca, e ela e o coracao
# do problema: o destino que vai no pacote fica a 23,33 de distancia SEMPRE, mas a entidade
# nunca chega la -- o proximo pacote chega 0,6 s depois e reposiciona a origem. O 23,33 e
# pra onde ele esta OLHANDO, nao onde ele vai estar.
#
# Nos moviamos a entidade os 23,33 inteiros a cada ciclo, ou seja 38,9 unidades por segundo,
# quase quatro vezes o real. O jogador viu como "mais rapido que antes" -- e estava certo,
# porque o passo fixo de 23,33 e maior que a mediana do sorteio de 8 a 30 que havia antes.
# O avanco medido no oficial: 6,67 por pacote (p10 = p50 = p90, constante) com intervalo de
# 0,61 s, ou seja 10,9 por segundo.
AVANCO_POR_SEGUNDO = 11.1

# A VELOCIDADE PRECISA CASAR COM O AVANCO, e este foi o erro que produziu o giro que o
# jogador filmou.
#
# O campo [36:40] diz ao client a que ritmo desenhar o deslocamento. Agrupando o avanco real
# do oficial pela velocidade do pacote:
#
#     velocidade 0 a 2  ->  5,45 unidades por segundo
#     velocidade 3 a 9  -> ~10,5 unidades por segundo   (satura)
#
# Ou seja, o client anda no ritmo que o pacote manda, ate um teto.
#
# Eu tinha passado a sortear a velocidade de 0 a 10 a cada pacote, imitando a variacao que o
# oficial mostra. So que o nosso AVANCO e fixo. Resultado: num ciclo o client desenhava a
# 5 unidades por segundo e no seguinte a 16, enquanto o servidor movia sempre 11 -- e a cada
# pacote a entidade era puxada pra frente ou pra tras. Puxada pra tras, o modelo se VIRA pra
# andar de costas durante a correcao, e volta. E o giro de 180 graus em meio segundo que
# aparece no video de 25/08.
#
# O valor sai da propria medicao: a velocidade mediana do oficial e 5,81 e o avanco dela e
# 10,7 por segundo, um fator de 1,84. Pra um avanco de 11,1 a velocidade coerente e 6,03.
#
# Fica quase constante de proposito. A variacao do oficial so nao o quebra porque LA o avanco
# acompanha; aqui, com avanco fixo, variar a velocidade e justamente o que estraga.
VELOCIDADE_COERENTE = 6.03
INTERVALO_MUNDO = 0.6

# DE QUANTO EM QUANTO O LACO ACORDA. Nao e o mesmo que o intervalo entre passos de um mob.
#
# Ate agora eram a mesma coisa: o laco dormia 0,6 s e cada mob emitia um passo por volta.
# Isso QUANTIZA o tempo -- um passo marcado pra 602 ms sai em 606, um marcado pra 2004 ms
# espera ate 2424, e quando varios passos vencem juntos so um sai por volta e o resto fica
# atrasado. As trilhas do oficial nao cabem nessa grade:
#
#     intervalo entre passos gravados:  p10 387 ms   mediana 615 ms   p90 1398 ms
#     5 por cento dos passos vem com 0 ms de diferenca do anterior
#
# O que o jogador ve quando um passo atrasa: o pacote declara um destino 23,3 unidades a
# frente, mas o mob so avanca 6,6 por passo. O client caminha rumo ao destino declarado e,
# se a proxima correcao demora, ele passa do ponto e e puxado de volta -- que na tela e o
# modelo indo pro lado e voltando. Era isto o "chacoalhando" que sobrou depois de acertar a
# origem do pacote.
#
# Acordando a cada 0,15 s o erro de agenda cai de ate 606 ms pra ate 150 ms, e uma rajada de
# passos proximos sai em rajada em vez de ser esticada. O passo de cada mob continua vindo da
# trilha; muda so a resolucao do relogio que a serve.
# 0,15 nao chegava para a PERSEGUICAO. Medido no oficial em 4.690 passos de corridas
# continuas de estado 1: ele manda um pacote a cada 0,135 s com um avanco de 5,33 unidades.
# Um laco que so acorda de 0,15 em 0,15 s nao consegue emitir a esse ritmo, e a compensacao
# era dar saltos de 21,33 de meio em meio segundo -- mesma velocidade media, movimento aos
# trancos. Era isto que o jogador via como "a velocidade dele a perseguir nao esta igual".
#
# O custo estava medido antes de mexer: o laco gastava 4 ms de mediana (p90 6,7) nos 150 ms
# que tinha. A 0,05 s passa a gastar os mesmos 4 ms em 50 -- cerca de 8 por cento do ciclo.
# O log "[mundo] trabalho por ciclo" continua a vigiar isso.
#
# De lambuja, 0,05 divide certo o INTERVALO_MUNDO (0,60) e aproxima muito melhor os tempos
# gravados nas trilhas, que sao irregulares de proposito.
TICK_MUNDO = 0.05

# Amostras do tempo gasto em cada ciclo. Ver o log "[mundo] trabalho por ciclo".
_trabalho_ciclo = []


def venceu(agora, ultimo, intervalo):
    """O intervalo ja passou, contando que so se pode responder de TICK_MUNDO em TICK_MUNDO.

    Comparar `agora - ultimo >= intervalo` num laco que acorda de 0,15 em 0,15 s nunca
    entrega o intervalo pedido: entrega o primeiro tick DEPOIS dele. Pedir 0,48 entregava
    0,60 -- um quarto mais lento -- e o monstro gastava um quarto mais de tempo a fechar a
    distancia ate ao jogador. A mesma armadilha ja tinha estragado o andar dos monstros e a
    entrega do dano; e a terceira vez que aparece neste arquivo, e por isso fica aqui uma
    funcao em vez de mais um remendo local.

    Meia janela de tolerancia faz o tick MAIS PROXIMO do alvo ganhar, em vez do seguinte:
    com intervalo 0,48 e tick 0,15, passa aos 0,45 (erro 0,03) e nao aos 0,60 (erro 0,12).
    """
    return agora - ultimo >= intervalo - TICK_MUNDO / 2.0

# De quantos em quantos ticks rodam as tarefas que nao precisam da resolucao fina. O campo de
# visao varre as 472 entidades por jogador; a 0,15 s isso seria quatro vezes mais trabalho
# sem nada em troca, porque ninguem anda 700 unidades em 0,15 s.
TICKS_POR_VISAO = 4

# UM passo por volta. Nao quatro.
#
# Isto valia 4 e servia pra recuperar atraso quando o laco acordava de 0,6 em 0,6 s. Com o
# tick a 0,15 s deixou de fazer falta e passou a fazer mal: gravando o nosso proprio fio,
# apareciam seis pacotes de movimento da mesma entidade com o MESMO carimbo de tempo,
#
#     8,08  x=8750,0  destino 8773,1   (para leste)
#     8,08  x=8754,1  destino 8730,8   (para OESTE)
#     8,08  x=8760,8  destino 8737,5
#     8,24  x=8753,3  destino 8776,4   (volta atras 7,5 e aponta a leste outra vez)
#
# O client recebe os quatro de uma vez e tenta desenhar os quatro. Na tela isso e o bicho a
# saltar para a frente e para tras dentro do mesmo instante -- o "chacoalhar" que voltou.
#
# O custo de tirar: o passo pode sair ate 150 ms atrasado em vez de recuperar na hora. Ja
# medido e aceitavel -- mediana 82 ms, p90 145 ms.
# Quantos passos ATRASADOS um mob pode emitir na mesma volta do laco.
#
# Estava em 1, e isso estica as pausas. A trilha tem tempos proprios (615 ms de mediana) e o
# laco corre a cada 0,15 s; quando varios passos vencem juntos -- depois de uma pausa da
# trilha, ou do regresso ao posto -- com o limite a 1 eles saem um por volta em vez de porem
# o ritmo em dia. Medido no fio: pausas de 13,5 s quando o maximo da trilha e 7,0.
#
# 4 chega para recuperar sem despejar a trilha toda de uma vez.
MAX_PASSOS_POR_TICK = 4


# QUAL monstro o client desenha. Achado em 24/08 procurando, nos 370 CMoveAppear da captura
# oficial, um offset cujo u32 casasse com algum id do npcInfo.lod: [70:74] casou em 365 deles
# (os 5 restantes sao os 04 02 de JOGADOR, que nao tem npc_id).
#
# Ate agora a gente mandava o template capturado sem tocar neste campo -- por isso todo mob
# do nosso mundo nascia Aranha pequena, fosse qual fosse a intencao.
OFF_NPC_ID = 70
OFF_HP_ATUAL, OFF_HP_MAX = 28, 32


# ESPIAO: id da entidade cujos pacotes de movimento sao gravados, ou 0 pra desligar.
# Serve pra comparar o que NOS mandamos com a trilha que o oficial mandou pro mesmo tipo de
# mob. Grava so uma entidade de proposito -- o arquivo fica legivel.
ESPIAO_ENTIDADE = 0        # -1 todas, um id so aquela, 0 desligado. Foi assim que se achou, em
                           # 25/08, a origem defasada no pacote de movimento -- comparar o
                           # que MANDAMOS com a trilha de origem, em vez de comparar
                           # estatistica agregada. Deixar pronto pro proximo caso.
CAMINHO_ESPIAO = os.path.join(os.path.dirname(TEMPLATE_PATH), "espiao_movimento.log")

# Escrita em lote: com ESPIAO_ENTIDADE = -1 sao centenas de linhas por segundo, e abrir o
# arquivo a cada pacote transformaria o espiao no gargalo do proprio laco que ele mede.
_espiao_buffer = []


def _espiao_descarregar():
    if not _espiao_buffer:
        return
    linhas = _espiao_buffer[:]
    del _espiao_buffer[:]
    try:
        with open(CAMINHO_ESPIAO, "a", encoding="utf-8") as fp:
            fp.write(chr(10).join(linhas) + chr(10))
    except OSError:
        pass

PASTA_MOLDE_NPC = os.path.join(os.path.dirname(TEMPLATE_PATH), "npc_appear")
_moldes_npc: dict[int, bytes | None] = {}


def molde_npc(npc_id):
    """O 04 02 que o servidor OFICIAL mandou para este npc_id, ou None.

    Por que existe: em 25/08 os 30 NPCs entraram no mundo montados a partir do molde de
    ARANHA com o npc_id trocado em [70:74], e o client desenhou aranha em todos. Comparado
    com o Domingo real do oficial, 22 dos 260 bytes diferiam.

    CORRECAO do mesmo dia: eu tinha escrito aqui que os bytes [36:40]/[40:44] eram o HP.
    Nao sao -- sao o MP. O HP esta em [28:32] (atual) e [32:36] (maximo), como o
    OFF_HP_ATUAL/OFF_HP_MAX deste modulo sempre disse. O que desfez o engano foi olhar uma
    entidade FERIDA: a de id 3 traz 4427 em [28:32] e 6272 em [32:36] -- atual menor que
    maximo, que so faz sentido pra HP. Nos campos 36/40 quase todo mob traz 100 redondo, e
    o Urso coruja traz 10; sao valores de MP, nao de vida.

    Descobrir um por um o que cada byte significa daria trabalho e erro. O molde real nao
    precisa disso: o que o oficial mandou e, por definicao, o que o client entende. Trocamos
    so o id da entidade e a posicao, e todo o resto fica como veio do servidor de verdade.

    NPC e mob sao a MESMA entidade no protocolo -- mesmo opcode 04 02, mesmos 260 bytes. O
    que separa os dois sao os valores dos campos, e e justamente por isso que o molde errado
    produz um resultado quase certo: aranha na posicao do instrutor.

    Devolve None pra quem nao tem molde, e ai vale o caminho antigo.
    """
    if npc_id in _moldes_npc:
        return _moldes_npc[npc_id]
    caminho = os.path.join(PASTA_MOLDE_NPC, "%s.bin" % npc_id)
    try:
        with open(caminho, "rb") as fp:
            _moldes_npc[npc_id] = fp.read()
    except OSError:
        _moldes_npc[npc_id] = None
    return _moldes_npc[npc_id]


# ORIENTACAO NO PACOTE DE APARICAO. Float em [24:28], na mesma convencao do 04 01.
#
# MEDIDO em 29/08: no oficial este campo toma 3.284 valores distintos em 4.966 aparicoes, e
# 100 por cento deles caem em [0, 2*pi]. E o rumo com que a entidade nasce no ecra.
#
# Nos mandavamos o que estivesse no MOLDE capturado -- 4 valores distintos em 41 aparicoes.
# Ou seja, a cada entrada no campo de visao diziamos ao client que o monstro estava virado
# para um lado fixo, e os pacotes de movimento a seguir diziam outra coisa. O client tem de
# reconciliar as duas, e isso repete-se a CADA reaparicao (que acontece sempre que o jogador
# se move o suficiente).
#
# Isto escapou tres dias porque nunca comparei o 04 02: media-se o movimento, e o defeito
# estava no pacote que MONTA a entidade, antes de o movimento comecar.
OFF_RUMO_APPEAR = 24


OFF_CORRIDA_APPEAR = 44
OFF_CAMINHADA_APPEAR = 48
"""Os dois floats de velocidade do `04 02`. MEDIDOS na Aranha pequena, que mostra os dois
regimes no mesmo bicho: [48]=10 e ela caminha a 10,5 (p50 de 684 movimentos); [44]=40 e ela
corre a 44,7 (p90 dos mesmos). Por isso 44 e corrida e 48 e caminhada."""

_vel_npc = None


def velocidade_do_npc(npc_id):
    """(corrida, caminhada) da especie, de Data/Monsters/MobVelocidade.ini. None se nao ha.

    None quer dizer "fica o que o molde trouxer" -- e nao "poe zero": zero e um valor REAL,
    que o oficial usa nos NPCs parados de cidade, e confundir os dois punha a andar quem tem
    de ficar quieto.
    """
    global _vel_npc
    if _vel_npc is None:
        _vel_npc = {}
        for linha in data_files.tabela("MobVelocidade"):
            try:
                _vel_npc[int(linha["npc_id"])] = (float(linha["corrida"]),
                                                  float(linha["caminhada"]))
            except (TypeError, ValueError, KeyError):
                pass
    return _vel_npc.get(int(npc_id or 0))


def build_appear(template: bytes, ent_id: int, x: float, y: float, z: float,
                 npc_id: int | None = None, hp_max: int | None = None) -> bytes:
    p = bytearray(template)
    struct.pack_into("<I", p, 8, ent_id)
    struct.pack_into("<3f", p, 12, x, y, z)
    if len(p) >= OFF_RUMO_APPEAR + 4:
        # o rumo que a entidade traz do movimento; sem historico, o do molde fica
        r = _ULTIMO_RUMO.get(ent_id)
        if r is not None:
            struct.pack_into("<f", p, OFF_RUMO_APPEAR, r % (2.0 * math.pi))
    if npc_id and len(p) >= OFF_NPC_ID + 4:
        struct.pack_into("<I", p, OFF_NPC_ID, npc_id & 0xFFFFFFFF)
    if hp_max:
        struct.pack_into("<I", p, OFF_HP_ATUAL, hp_max & 0xFFFFFFFF)
        struct.pack_into("<I", p, OFF_HP_MAX, hp_max & 0xFFFFFFFF)
        # A VELOCIDADE DA ESPECIE, de tabela medida e nao do molde.
        #
        # Ate 31/08 estes dois campos vinham do `npc_appear/N.bin`, e quem nao tinha molde
        # herdava (40, 10) do generico. Das 69 especies que nascem no mundo, 22 nao tinham
        # molde e 12 dessas estavam erradas -- a Gargula dourada corre a 90 e ia a 40, o
        # Frezzard caminha a 20 e ia a 10, e nove NPCs de cidade, que o oficial declara
        # parados a (0, 0), sairam daqui a andar.
        #
        # MESMO DEFEITO do campo 78 da ficha e da velocidade no 08 01, e mesma cura: ler de
        # uma tabela medida. Ver tools/extrair_velocidade_npc.py.
        _v = velocidade_do_npc(npc_id)
        if _v is not None:
            struct.pack_into("<f", p, OFF_CORRIDA_APPEAR, _v[0])
            struct.pack_into("<f", p, OFF_CAMINHADA_APPEAR, _v[1])
    registrar_appear(bytes(p))     # anota o HP maximo dessa entidade
    return encode_frame(bytes(p))


_ULTIMO_RUMO = {}


def _campo_de_rumo(ent_id, origem, destino, estado, herdado):
    """O que vai em [36:40] do 04 01. E o RUMO, nao a velocidade.

    DECIFRADO em 28/08 sobre 103.134 pacotes do oficial: o campo e exactamente

        rumo_do_jogo(destino - origem) + k * pi

    com residuo mediano de 5,21e-06 rad e 100 por cento abaixo de 0,001 rad. A mesma formula
    que o projecto ja usava no 05 00 desde 25/08; ninguem tinha ligado as duas.

    O k NAO faz parte do angulo, e uma marca de CONTINUIDADE:

        estado 0, mesmo rumo do pacote anterior   ->  k = 1   (100,0 por cento)
        estado 0, arranque (nao avancou nada)     ->  k = 0   (99,3 por cento)
        estado 1 (a correr)                       ->  k = 0   (o pi nunca e somado)
        estado 2 (parado)                         ->  guarda o rumo do trecho que terminou
                                                       (bate em 97,1 por cento de 14.758)

    E por causa do pi somado que o campo chega a 3*pi = 9,4248 no passeio e parecia nao ser
    um angulo -- foi isso que o manteve por decifrar tanto tempo.

    O QUE ESTAVA ERRADO. Escreviamos aqui uma CONSTANTE por modo (VEL_PERSEGUICAO = 1,05,
    VEL_RETORNO = 4,71, e a do passeio), portanto o client recebia sempre a mesma orientacao
    independentemente de para onde o bicho ia. Isso desenha o monstro virado para um lado
    fixo enquanto desliza para outro, e e o candidato obvio para o "ele me bate mas ele fica
    de costa pra mim" que o jogador descreveu.
    """
    if ent_id in ENTIDADES_REPLAY:
        # REPLAY: o campo vai como veio do fio. Recalcula-lo aqui destruiria o teste.
        return float(herdado)
    dx, dy = destino[0] - origem[0], destino[1] - origem[1]
    if math.hypot(dx, dy) <= 0.01:
        # parado: retem o rumo do trecho anterior; so cai no herdado se nunca andou
        return _ULTIMO_RUMO.get(ent_id, float(herdado))
    r = rumo_do_jogo(dx, dy)
    anterior = _ULTIMO_RUMO.get(ent_id)
    _ULTIMO_RUMO[ent_id] = r
    if estado == ESTADO_ANDANDO and anterior is not None:
        if abs((r - anterior + math.pi) % (2 * math.pi) - math.pi) < 0.0175:   # 1 grau
            return r + math.pi
    return r


def build_move(seq: int, ent_id: int, origem, destino, velocidade: float,
               estado: int = ESTADO_ANDANDO) -> bytes:
    """04 01. `estado` vai no byte [7]: 0 andando, 2 parado (origem igual ao destino)."""
    p = bytearray(42)
    p[0:2] = b"\x04\x01"
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[6] = 0x01
    p[7] = estado & 0xFF
    struct.pack_into(">I", p, 8, ent_id)
    struct.pack_into("<3f", p, 12, *origem)
    struct.pack_into("<3f", p, 24, *destino)
    struct.pack_into("<f", p, 36, _campo_de_rumo(ent_id, origem, destino, estado, velocidade))
    return encode_frame(bytes(p))


# O GOLPE DO MONSTRO CONTRA O JOGADOR, decodificado em 25/08 isolando os pacotes que citam
# ao mesmo tempo a entidade 816 (Urso furioso) e a 11055 (o personagem do jogador). Vem sempre
# aos pares, o 05 00 primeiro e o 05 02 uns 0,84 s depois:
#
#     05 00   o bote: quem, de onde, contra quem, onde esta o alvo
#     05 02   o resultado: o dano
#
# Cadencia medida entre botes do mesmo urso: 3,05 / 5,95 / 6,63 / 5,56 s.
#
# Dano do Urso furioso (nivel 14) contra o jogador, 5 amostras: 289, 291, 290, 289 e 556.
# Os quatro primeiros sao a faixa normal, o ultimo e quase o dobro -- golpe forte. O campo
# [28:32] veio 0, 0, 0, 1 e 2 e ainda nao sei o que e; nao acompanha o dano.
# CADENCIA. E FIXA, nao sorteada.
#
# Medida numa sessao em que o jogador deixou UM monstro (entidade 952) bater-lhe ate morrer:
# 19 golpes seguidos, intervalos
#
#     5,28  4,12  4,25  4,17  4,21  4,24  4,14  4,25  4,15  4,20
#     4,25  4,15  4,24  4,17  4,21  4,26  4,12  4,24
#
# Mediana 4,21, desvio 0,25 -- e o desvio quase todo vem do primeiro valor, 5,28. Sorteio
# nenhum: e um relogio.
#
# Eu tinha aqui uma faixa de 1,8 a 6,6 tirada de 32 amostras misturadas de varios monstros e
# varios alvos. Misturar atacantes destroi a cadencia, porque cada um tem o seu relogio e as
# interrupcoes de uns entram como intervalos curtos dos outros.
INTERVALO_GOLPE = 4.21
"""O relogio DAQUELE monstro. Ver `CADENCIA_OMISSAO` para o que se usa quando nao se sabe."""

CADENCIA_OMISSAO = 2.98
"""Cadencia de quem nao tem medicao propria. MEDIANA GLOBAL de 3.335 intervalos.

O 4,21 acima esteve aqui como omissao e e o valor MAIS LENTO do jogo: saiu de um unico
monstro (entidade 952, 19 golpes seguidos). Por especie a distribuicao vai de 2,39 (Sedento
de sangue) a 4,19 (Tirano sanguinario), com mediana 2,98.

Resultado: 12 das 18 especies do nosso mundo batiam ao ritmo do bicho mais lento que existe.
O jogador deu por isso em 30/08 -- "tem como diminuir o tempo de ataque nos mob".

Continua a nao ser por especie, e sabe-se: das 18 do mundo so o Tirano sanguinario tem
medicao propria (`cad_n` > 0 no MobAlcance.ini). As outras usam a mediana ate haver amostra.
"""

JITTER_GOLPE = 0.08

# Do bote ao dano, dos mesmos 19 golpes: min 0,677, mediana 0,780, max 0,837, desvio 0,043.
ATRASO_DANO_MIN, ATRASO_DANO_MAX = 0.70, 0.84
# DANO CRU DO MONSTRO, por nivel. Urso furioso, nivel 14, cinco golpes: 33, 35, 34, 33 e 44.
# Os quatro primeiros dao 2,41 por nivel; o de 44 veio com bandeira 2 -- golpe forte, uma vez
# em cinco, e vale 1,3x e nao o dobro.
#
# O jogador nao perde o dano cru: perde `dano - defesa`. Na captura a queda foi 20, 19, 18 e
# 29 contra dano 35, 34, 33 e 44 -- exatamente 15 a menos nos QUATRO. A reducao e subtracao
# fixa, e a daquele personagem era 15. Quanto vale a de cada um, ainda nao se sabe: os cinco
# golpes foram todos no mesmo alvo.
# Quanto cada nivel do monstro vale em dano. CORRIGIDO em 27/08 de 2,41 para 4,06.
#
# O 2,41 vinha de uma unica especie e deixava os monstros inofensivos assim que o jogador
# vestia alguma coisa: um Javali-de-sabre de nivel 7 fazia 16,9 de dano bruto, e a defesa de
# um set simples corta 15,9 -- sobrava 1. O jogador viu exatamente isso: "no nosso server o
# javali tira so 1 de mim".
#
# O 4,06 vem da unica medicao LIMPA que existe: Tirano sanguinario (nivel 17) no oficial,
# 27/08, com a defesa do jogador variada de proposito em tres patamares para extrapolar o
# dano a defesa zero -- deu 69,0, ou seja 4,06 por nivel.
#
# ISTO E UMA APROXIMACAO, e sabe-se que o e. O dano NAO e funcao so do nivel: na sessao de
# progressao, dois monstros de nivel 26 batiam muito diferente um do outro --
#
#     Cao branco        nivel 26   dano medio 41,3
#     Espadachim aguia  nivel 26   dano medio 66,8
#
# -- ou seja cada especie tem o seu numero. O client NAO ajuda aqui: varri os 234 bytes do
# registo do npcInfo.lod e nenhum campo correlaciona com o dano medido, tal como ja tinha
# acontecido com o HP maximo. Dano e HP sao dados de servidor; o client so precisa de nome,
# modelo, nivel e categoria. Para ter o valor certo por especie e preciso medir cada uma no
# fio, como se fez com o Tirano.
DANO_POR_NIVEL_DO_MOB = 4.06

# COMO CORRE CADA GOLPE. A bandeira vai no byte alto de [24:28] do 05 02.
#
# Medido em 40 golpes de monstro contra o jogador, na captura de 25/08:
#
#     bandeira 1   77,5 %   dano cheio
#     bandeira 2    7,5 %   125 % do normal  -- golpe forte
#     bandeira 4    5,0 %   dano 0           -- falhou
#     bandeira 5   10,0 %   53 % do normal   -- bloqueado
#
# Eu tinha 20 por cento de golpe forte e nada de falha nem de bloqueio, ou seja, o monstro
# acertava sempre. Os 20 por cento tambem estavam errados: sairam de 1 amostra em 5.
#
# AVISO: 40 amostras, e as bandeiras 2 e 4 tem 3 e 2 casos. Pior, sao todas contra o MESMO
# personagem. Estas taxas quase de certeza dependem da diferenca de nivel -- o caminho
# jogador->monstro ja modela isso em `taxas_do_golpe`, com falha a crescer 0,70 por nivel de
# desvantagem. Aqui nao ha nada disso ainda: um monstro nivel 70 falha os mesmos 5 por cento
# num personagem nivel 2 que num nivel 80. Falta captura com personagens de niveis diferentes.
# O prefixo MOB_ nao e enfeite: mais abaixo neste ficheiro ja existem CHANCE_FALHA e
# CHANCE_BLOQUEIO, do golpe do JOGADOR contra o monstro. Como vem depois, ganhavam a
# atribuicao, e a evasao que acabei de medir corria com os numeros do outro lado --
# 1,6 e 3,1 por cento em vez de 5 e 10. Segunda colisao de nomes do dia, mesma causa.
# Bandeiras do 05 02, medidas nos 108 golpes que a Feiticeira levou em 27/08:
#     1 normal 21,3%   2 critico 0,9%   4 dano zero 38,9%   5 reduzido 31,5%   6 dano zero 7,4%
# A 4 e a 6 dao as duas dano zero mas nao sao a mesma coisa: a 6 bate com a evasao da ficha
# (7) e a 4 e frequente demais para isso -- e o monstro a falhar, nao o jogador a esquivar.
BANDEIRA_NORMAL, BANDEIRA_FORTE, BANDEIRA_FALHA, BANDEIRA_BLOQUEIO = 1, 2, 4, 5
BANDEIRA_EVASAO = 6

# Como cada bandeira aparece no diario de caca. O jogador pediu para ver a falha e o bloqueio,
# que ate aqui saiam no log iguais a um golpe normal -- a linha so dizia "GOLPE em X".
NOME_DA_BANDEIRA = {
    BANDEIRA_NORMAL:   "acertou %(dano)d (bruto %(bruto)d, defesa cortou %(cortado)d)",
    BANDEIRA_FORTE:    "GOLPE FORTE %(dano)d (bruto %(bruto)d, defesa cortou %(cortado)d)",
    BANDEIRA_FALHA:    "FALHOU (o monstro errou)",
    BANDEIRA_BLOQUEIO: "BLOQUEADO, passou so %(dano)d (bruto %(bruto)d)",
    BANDEIRA_EVASAO:   "ESQUIVADO pelo jogador",
}
# COMO ACABA CADA GOLPE DE MONSTRO. Medido no oficial em 27/08, 206 golpes levados pela
# mesma Feiticeira, contando as bandeiras do 05 02:
#
#     bandeira 1  acertou cheio      23,3 %   dano medio 44,8
#     bandeira 2  golpe forte         0,5 %   dano medio 56,0   (1,25x o normal)
#     bandeira 4  o MONSTRO falhou   36,4 %   dano 0
#     bandeira 5  bloqueado          31,1 %   dano medio 26,5   (0,59x o normal)
#     bandeira 6  o JOGADOR esquivou  8,7 %   dano 0
#
# Os numeros antigos eram outra coisa: 5 % de falha, 10 % de bloqueio, 7,5 % de golpe forte,
# e nada para o monstro falhar. Davam 75 % de golpes cheios contra os 23 % do oficial -- o
# jogador levava tres vezes mais pancada do que deve.
#
# A bandeira 4 e a 6 dao as duas dano zero mas nao sao a mesma coisa, e chegaram a ficar
# trocadas aqui: a 6 e a esquiva do jogador e sai da ficha dele (destreza), a 4 e o monstro a
# errar e nao depende do jogador nenhum. Somar as duas numa so tirava a esquiva da ficha.
#
# O que ainda nao esta medido e como estas taxas mudam com o nivel do monstro e o do jogador.
# Tudo isto veio de uma Feiticeira de nivel 68 contra Tiranos sanguinarios de nivel 30.
# REMEDIDO EM 29/08, e a amostra antiga estava a extrapolar de um caso especial.
#
# Os numeros de cima (36,4% de falha, 31,1% de bloqueio) sairam de 206 golpes levados por UMA
# Feiticeira de nivel 68. Aplicavam-se depois a toda a gente, incluindo um personagem de
# nivel 4 -- e o jogador viu logo: "ainda nao bate quando ando". Na nossa sessao das 09:4x
# foram 8 golpes, 2 acertaram: 25% contra os 92% do oficial.
#
# Alarguei para 4.094 golpes de monstro em todas as capturas. Elas separam-se em dois regimes
# limpos, e a separacao NAO e a especie nem o nivel do monstro:
#
#     ficheiro                       n   normal   falha   bloq    esq
#     karos_20260827_0838         3680    81,2%    3,6%   6,5%   1,1%
#     karos_20260827_2108          108    21,3%   38,9%  31,5%   7,4%
#     karos_20260827_2125           98    25,5%   34,0%  29,8%  10,6%
#
# A prova de que nao e o monstro: o MESMO nivel 17 da 91,3% de golpes cheios na sessao das
# 08:38 e 21,3% na das 21:08. Mesmo bicho, resultado oposto. O que muda e o PERSONAGEM --
# as sessoes 21xx sao a Feiticeira de nivel 68 equipada, a das 08:38 e um personagem comum.
#
# Ou seja: falha e bloqueio sao propriedade da defesa de quem leva, nao do monstro que bate.
# Modelar isso a serio precisa de capturas com varios personagens, que nao existem. O que da
# para fazer com honestidade e usar como BASE o caso comum -- a sessao de 3.680 golpes -- em
# vez do caso extremo. Um personagem muito defensivo vai levar mais pancada do que deve ate
# alguem medir a curva; e o erro menor dos dois, e ao contrario do outro nao torna o jogo
# impossivel de jogar para quem esta a comecar.
#
# Os multiplicadores tambem sao destes 4.094: normal 29,5 de media (n=3126), forte 33,8
# (n=280) = 1,15x, bloqueado 20,7 (n=352) = 0,70x. Os antigos eram 1,25x e 0,59x.
MOB_CHANCE_GOLPE_FORTE = 0.074
MOB_CHANCE_FALHA = 0.036          # bandeira 4: o monstro erra
MOB_CHANCE_BLOQUEIO = 0.065
MOB_MULT_GOLPE_FORTE = 1.15
MOB_MULT_BLOQUEIO = 0.70

# NAO HA REDUCAO. O dano cru E a perda de HP.
#
# Eu tinha aqui 15, de quatro golpes em que a queda de HP deu sempre 15 a menos que o dano.
# Parecia solido: quatro vezes o mesmo numero nao e acaso.
#
# Nao era armadura. Alargando a amostra, a diferenca entre dano e queda so toma DOIS valores
# -- exatamente 0 ou exatamente 15, nunca 7, nunca 12. Reducao seria constante; presenca ou
# ausencia de um degrau fixo e outra coisa. O degrau e a REGENERACAO do jogador, +15 a cada
# 5,38 s, que as vezes cai entre o golpe e a leitura do HP e as vezes nao.
#
# Os golpes onde a diferenca e zero provam-no: dano 33 -> caiu 33, dano 21 -> caiu 21,
# dano 56 -> caiu 56, dano 58 -> caiu 58.
# A DEFESA DO ALVO CORTA O DANO, e o corte e SUBTRATIVO.
#
# Ate 27/08 a defesa nao entrava na conta em lado nenhum: tirar o set inteiro nao mudava uma
# unidade de dano, e o jogador reparou -- "quando to com set equipado sofro dano x, mas
# quando tiro o set, por lei o dano do mob tem que aumentar".
#
# MEDIDO no oficial em 27/08. Mesma sessao, mesmo personagem (Feiticeira nivel 68), tres
# Tiranos sanguinarios (npc 1005717) do principio ao fim, so ataque BASICO -- nenhuma skill
# foi usada, de proposito, porque o nosso servidor ainda nao as tem. As pecas foram tiradas
# UMA A UMA, o que deu varios pontos de defesa em vez de so dois:
#
#     defesa total    dano medio (golpe normal)
#          51                57,4
#          90                49,7
#         102                46,7
#
# A defesa TOTAL, e nao so a do equipamento: a ficha do personagem mostra "Protecao fisica
# 102 (+51)", onde o (+51) e a soma exata das quatro pecas (12+24+6+9) e os outros 51 sao a
# base do proprio personagem. Medir so pelo equipamento deslocava a curva inteira.
#
# Testando as duas formas contra estes tres pontos:
#     dano - c*defesa       c = 0,197 / 0,210 / 0,250   erro maximo 0,8 por cento
#     dano * (1 - def/K)    K = 288,8 / 324,6 / 341,7   K varia 17 por cento
# A subtracao ganha com folga.
#
# A MESMA forma explica o outro lado da luta. O ataque fisico base do personagem era 26,5
# (ficha: 37 (+10) e 49 (+23), com a Bengala-foice posta, que vale 10,60 a 23,25) e sem arma
# o dano no Tirano deu 17 CRAVADO em dez golpes -- perda de 9,5, que a 0,22 por ponto poe a
# defesa do Tirano em cerca de 43. Com a Bengala lanca isso preve 21,9 a 26,8 e mediu-se 22 a
# 28; com a Bengala-foice preve 27,6 a 40,3 e mediu-se 29 a 44.
#
#     dano = ataque - CORTE_POR_DEFESA * defesa_do_alvo
#
# O minimo de 1 existe para uma defesa enorme nao virar imunidade nem dano negativo. Cuidado
# ao mexer: dano ZERO e uma falha e tem de continuar zero, por isso o corte so se aplica a
# golpes que ja acertaram.
CORTE_POR_DEFESA = 0.22

# A defesa BASE do personagem -- a que existe sem equipamento nenhum -- agora vem do nivel,
# por `db.protecao_base`. Ficou um dia inteiro a zero por falta do segundo ponto: com uma so
# ficha nao ha curva. Chegou a ficha de nivel 1 e a reta fechou (21 no nivel 1, 51 no 68).

# REGENERACAO. 49 amostras, +15 de HP a cada 5,38 s (5,2 a 5,4), inclusive DURANTE o combate.
# O valor absoluto e de UM personagem, com 332 de HP maximo. Se escala com o maximo ou com o
# nivel, nao se sabe -- por isso fica o numero medido, e nao uma regra inventada.
ESPERA_FORA_DE_COMBATE = 36.0
"""RECURSO DE ULTIMO CASO. O sinal certo e a arma ser GUARDADA -- ver sair_de_combate.

Media de 25 amostras do oficial entre a ultima pancada e o guardar da arma, e o problema esta
na dispersao: 9,9 / 15,5 / 16,2 / 22,0 / 33,0 / 42,1 / 68,2 / 89,6 s. Uma media de qualquer
coisa que varia de 10 a 90 segundos nao e uma constante, e a media do sinal errado.

O SINAL CERTO tem dispersao nenhuma. Em 22 amostras, o primeiro tique cai entre 5,1 e 5,4 s
depois do 03 02 que guarda a arma -- ou seja UM INTERVALO exacto. Foi o jogador que o disse:
"o atraso e mesmo, pq saiu do modo de combat", e depois "no momento que guarda a arma que a
regeneracao comeca".

Esta constante fica so para o caso de o 03 02 nao chegar.

Historia: ate 28/08 nao haviamos espera nenhuma -- o servidor regenerava enquanto o mob
batia, coisa que o oficial nao faz, e foi tambem o jogador que reparou.

E foi tambem isto que fez a aura cor-de-rosa parecer avaria. Ela e o efeito da regeneracao:
no oficial so aparece neste minuto final e depois some, no nosso estava quase sempre ligada
porque a regeneracao nunca parava.
"""

REGEN_HP = 15
"""OBSOLETO -- ficou como ultimo recurso se a tabela nao carregar. Ver db.regen_hp.

Era fixo em 15 para todo o mundo, o que ao nivel 3 (223 de maximo) dava 6,7% da barra por
tique contra os 5,4% medidos, e ao nivel 68 (3807) dava 0,4% contra 1,8%. Certo em nivel
nenhum.
"""
REGEN_INTERVALO = 5.35
"""Segundos entre tiques. MEDIDO em onze capturas, seis personagens, niveis 2 a 68: as
medias por captura ficaram entre 5,32 e 5,41 s, sem relacao nenhuma com o nivel. O 5,38 que
estava aqui ja era quase isto -- de todas as constantes de regeneracao, era a unica certa."""

# RENASCER. Ate 25/08 o jogador que chegava a HP 0 ficava caido para sempre: o monstro
# matava, a barra zerava e nada mais acontecia -- nem volta a cidade, nem levantar.
#
# O QUE ESTA AQUI e so a metade que da pra fazer sem medir: repor HP e MP e por o personagem
# de pe onde caiu.
#
# O QUE FALTA e a volta a cidade. Precisa do pacote de teleporte, que ainda nao foi
# decodificado -- nenhuma das 14 capturas tem o proprio jogador a chegar a HP 0, entao nunca
# se viu o que o servidor manda a seguir. O caminho e o mesmo do /esc: morrer no oficial com
# o hook a gravar. Ver o bloco [07 01] do Opcode.ini, que tem o destino (a pedra de retorno
# da REGIAO) mas nao o pacote da resposta.
# HP e MP ao ressuscitar. MEDIDO na morte do jogador no oficial, 25/08: 66/332 de HP e
# 28/140 de MP -- 19,9 e 20,0 por cento. Vale pros dois.
FRACAO_AO_RENASCER = 0.20

# CARENCIA DEPOIS DE LEVANTAR. Tapa-buraco assumido, nao medido.
#
# Sem isto ha ciclo de morte: levanta-se com 20 por cento DE PE NO MESMO SITIO, o monstro
# agressivo que matou ainda esta a 30 unidades e mata outra vez. Aconteceu ao jogador as
# 21:13 -- caiu, levantou com 37/186, e voltou a cair 9 s depois.
#
# A solucao certa e a volta a cidade, que e o que o oficial faz e que falta medir. Enquanto
# nao houver o pacote, dez segundos sem levar dano e sem gerar odio dao tempo de sair dali.
SEGUNDOS_DE_CARENCIA = 10.0
_carencia = {}

# NAO EXISTE FUGA. Ficou registado porque eu implementei e estava errado: deduzi que o
# monstro docil, ao apanhar, correria em vez de revidar. O jogador testou no oficial -- a
# Aranha pequena ataca de volta. `agressivo` separa APENAS quem comeca a briga.


def rumo_do_jogo(dx, dy):
    """O angulo como o client o quer: (3*pi/2 - atan2(dy, dx)) mod 2*pi.

    Nao e nenhum dos atan2 diretos. Descoberto em 25/08 comparando o campo [28:32] do 05 00
    com a direcao real atacante->alvo, em 6 golpes de 3 monstros diferentes:

        dx      dy      campo    formula
        -8,8   -27,8    0,308    0,307
       -25,4   -12,9    1,102    1,101
         4,4   -29,1    6,134    6,133
        20,4    18,2    3,984    3,984
        15,1    24,5    3,692    3,694

    Zero aponta para SUL e o angulo cresce no sentido contrario ao do atan2. Nos mandavamos
    zero cravado -- ou seja, TODOS os monstros golpeavam virados a sul, independentemente de
    onde estivesse o alvo. Foi o que o jogador viu: "ele me bate mas fica de costas pra mim".
    """
    return (1.5 * math.pi - math.atan2(dy, dx)) % (2 * math.pi)


CAMINHO_MOLDE_GOLPE = os.path.join(os.path.dirname(TEMPLATE_PATH), "molde_golpe_05_00.bin")
CAMINHO_MOLDE_DANO_MOB = os.path.join(os.path.dirname(TEMPLATE_PATH), "molde_dano_05_02.bin")
_moldes_golpe = {}


def _molde(caminho, tamanho):
    """Um pacote REAL do oficial como base, em vez de um bytearray de zeros.

    Montar do zero significa mandar zero em tudo o que nao decodifiquei. Comparando 600
    pacotes do oficial com o que escreviamos, sobravam campos preenchidos la e vazios aqui:

        05 00   [11:15]  [32]        -- o [32] vem 0x01 em 412 de 600
        05 02   [11:15]  [21]  [28:32]

    Com o molde, o que nao percebo fica com o valor que o oficial mandou. E a mesma tecnica
    dos moldes de aparicao e das trilhas de movimento, que ja resolveram dois problemas hoje:
    quando o dado gravado existe, ele vale mais do que a minha teoria sobre ele.
    """
    m = _moldes_golpe.get(caminho)
    if m is None:
        try:
            m = open(caminho, "rb").read()
        except OSError:
            m = b""
        if len(m) != tamanho:
            m = bytes(tamanho)
        _moldes_golpe[caminho] = m
    return m


def build_golpe(seq: int, atacante: int, alvo: int, pos_atacante, pos_alvo,
                rot_atacante: float = 0.0, rot_alvo: float = 0.0) -> bytes:
    """05 00 -- o bote. O dano nao vai aqui, vem no 05 02 logo a seguir."""
    p = bytearray(_molde(CAMINHO_MOLDE_GOLPE, 55))
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[6] = 0x01
    struct.pack_into(">I", p, 7, atacante)
    struct.pack_into("<3f", p, 16, *pos_atacante)
    struct.pack_into("<f", p, 28, rot_atacante)
    struct.pack_into(">I", p, 33, alvo)
    struct.pack_into("<3f", p, 37, *pos_alvo)
    struct.pack_into("<f", p, 49, rot_alvo)
    p[53:55] = bytes([0, 0x24])
    return encode_frame(bytes(p))


def build_dano_no_jogador(seq: int, atacante: int, alvo: int, dano: int,
                          pos_alvo, rot_alvo: float = 0.0, bandeira: int = 1) -> bytes:
    """05 02 -- o dano. O alvo vai em LITTLE-endian aqui e em BIG no 05 00.

    Nao e descuido meu: e assim no fio. Vale como mais um caso da regra deste protocolo --
    conferir o endianismo campo a campo, porque ele muda dentro do mesmo par de pacotes.
    """
    p = bytearray(_molde(CAMINHO_MOLDE_DANO_MOB, 51))
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 7, atacante)
    struct.pack_into("<H", p, 22, alvo & 0xFFFF)
    # [24:28] e `bandeira << 8 | dano`, BE u32 -- o byte BAIXO e o dano.
    #
    # Eu tinha lido isto como "dano = 289" e escrito o numero inteiro em [26:28]. Dava o mesmo
    # byte a byte por acaso (289 = 0x0121, e 0x01 calhava de ser a bandeira de golpe normal),
    # entao a remontagem batia e eu tomei isso como prova. Nao era: o dano e 0x21 = 33.
    #
    # Quem desfez foi cruzar com o HP do jogador a cair na mesma captura -- 20, 19, 18 e 29,
    # nada perto de 289. Remontar prova o FORMATO, nao o significado dos campos.
    struct.pack_into(">I", p, 24, ((bandeira & 0xFF) << 8) | (dano & 0xFF))
    struct.pack_into("<3f", p, 35, *pos_alvo)
    struct.pack_into("<f", p, 47, rot_alvo)
    return encode_frame(bytes(p))


def build_alerta(seq: int, ent_id: int, valor: int) -> bytes:
    """05 0b -- o triangulo amarelo sobre o monstro que esta prestes a atacar."""
    p = bytearray(14)
    p[0:2] = bytes([5, 0x0B])
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, ent_id)
    struct.pack_into(">I", p, 10, valor & 0xFFFFFFFF)
    return encode_frame(bytes(p))


def build_disappear(seq: int, ent_id: int) -> bytes:
    p = bytearray(11)
    p[0:2] = b"\x04\x03"
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[6] = 0x01
    struct.pack_into(">I", p, 7, ent_id)
    return encode_frame(bytes(p))


# ============================================================================
# DROP -- os sacos no chao
# ============================================================================
#
# TUDO AQUI FOI MEDIDO em 28/08 sobre 2258 sacos de 9 capturas do oficial, menos UMA coisa,
# que esta marcada como nao medida (RAIO_DE_RECOLHA).
#
# OS DOIS PACOTES
#
#   06 04  saco APARECE, 187 bytes. Monta-se por MOLDE: dos 187 bytes so 6 campos estao
#          decifrados, e os outros sao constantes e zeros que nao vale a pena adivinhar.
#          E a mesma tecnica do golpe e do dano, e pela mesma razao: um byte esquecido nao
#          da erro, da um client que desenha errado sem dizer porque.
#
#              [  0:  2]  06 04
#              [  2:  6]  sequencia BE
#              [  6: 10]  DONO, id de jogador BE. 0xFFFFFFFF = livre para todos.
#              [ 10: 14]  id do saco BE
#              [ 28: 32]  item_id LE          258 de 258 batem no catalogo
#              [ 36: 40]  id de instancia LE  unico em cada saco
#              [ 42: 46]  quantidade LE
#              [173:185]  x, y, z, tres floats LE
#
#   06 03  saco SAI, 20 bytes. Curto o bastante para montar a mao:
#
#              [ 0: 2]  06 03
#              [ 2: 6]  sequencia BE
#              [ 6:10]  0xFFFFFFFF nas 202 amostras
#              [10:14]  id do saco BE -- casou 202 de 202 com o 06 04
#              [14:18]  item_id BE
#              [18:20]  0x005a nas 202
#
# O QUE O 06 03 SIGNIFICA -- e onde eu me enganei primeiro
#
# Escrevi que o [14:18] era "quem apanhou", a partir de duas amostras onde parecia um id de
# jogador. Nas 1607 sao todos ids do catalogo de itens: e o item_id, repetido.
#
# E o proprio pacote nao quer dizer "apanhado". Medi a distancia do jogador ao saco no
# instante de cada saida, com filtro para tirar os que expiraram e os de outros jogadores:
# a mediana deu 432,4 unidades. O nosso RAIO_VISAO, medido no oficial por outro caminho e
# muito antes disto, e 430,0. Sao a mesma coisa: o 06 03 e o saco a SAIR DO CAMPO DE VISAO,
# tal como o 04 03 faz com as entidades. Apanhar e so uma das razoes de sair.
#
# Por isso o saco entra no mesmo desenho que ja usamos para os monstros: aparece a quem
# esta perto, some para quem se afasta, e existe no mundo independentemente de quem o ve.
#
# O CARAT E UM ITEM, item_id 999999, com a quantidade em [42:46]. Nao ha pacote de dinheiro
# separado -- a classe CDropMoney do client e so o modelo 3D do saco de moedas.
CAMINHO_MOLDE_SACO = os.path.join(os.path.dirname(TEMPLATE_PATH), "molde_drop_06_04.bin")

ITEM_NADA = 9999999                # o "nao caiu nada" das tabelas de drop
ITEM_CARAT = 999999
SACO_LIVRE = 0xFFFFFFFF
SACO_RABO = 0x005A                 # os dois ultimos bytes do 06 03, constantes

OFF_SACO_DONO, OFF_SACO_ID = 6, 10
OFF_SACO_ITEM, OFF_SACO_INSTANCIA, OFF_SACO_QTD = 28, 36, 42
OFF_SACO_POS = 173

# MEDIDO: 298 dos 1607 sacos sairam entre 60 e 61 s de vida, um pico limpo que nao existe em
# mais nenhuma faixa. E o tempo que o saco fica no chao.
SEGUNDOS_SACO_NO_CHAO = 60.0

# NAO MEDIDO. E o unico numero desta seccao que nao saiu do oficial, e a razao esta acima: o
# 06 03 e saida de campo de visao, entao a captura nao distingue "apanhado" de "afastou-se" e
# a distancia nao mede o raio de recolha.
#
# O que a captura DELIMITA, mesmo sem medir:
#
#   TETO. Os sacos ficam no chao uma mediana de 36 s, e 298 deles chegam aos 60 e expiram --
#   com o jogador a lutar ali ao lado o tempo todo. Um raio grande nao pode estar certo: ele
#   aspirava tudo no ciclo a seguir a morte. Foi o ensaio que mostrou isto, com o 40 que eu
#   tinha posto: o saco nascia e sumia no mesmo ciclo, e o jogador nunca o via na tela.
#
#   CHAO. Tem de apanhar quem passa por cima. O passo de caminhada mede 21 a 42 unidades,
#   entao um raio muito abaixo disso faz o jogador saltar por cima do saco entre dois passos.
#
# 20 fica entre os dois, mais perto do chao do que do teto. E o primeiro numero a mexer se o
# saque parecer errado, e os dois sintomas veem-se a olho: grande demais e o saco sumir antes
# de aparecer, pequeno demais e ter de passar em cima ao milimetro.
#
# COMO SE RESOLVE de vez: andar por cima de um saco no oficial com a captura ligada e ver a
# que distancia o 06 03 sai. Uma passagem lenta e deliberada chega.
RAIO_DE_RECOLHA = 20.0

_PROXIMO_SACO = [0x00340000]       # a mesma gama do oficial (0x00343274..0x003453d4)
SACOS: dict[int, "Saco"] = {}

# O ponto onde o world_server liga a entrega no inventario. Fica como gancho, e nao como
# import, porque o world_server ja importa este modulo -- importar de volta era ciclo.
ENTREGAR_ITEM = [None]


class Saco:
    """Um item no chao. Vive no mundo, nao na tela de ninguem."""

    __slots__ = ("id", "item_id", "qtd", "x", "y", "z", "dono", "criado_em", "instancia",
                 "vistos")

    def __init__(self, item_id, qtd, x, y, z, dono=SACO_LIVRE):
        _PROXIMO_SACO[0] += 1
        self.id = _PROXIMO_SACO[0]
        self.item_id = int(item_id)
        self.qtd = int(qtd)
        self.x, self.y, self.z = float(x), float(y), float(z)
        self.dono = int(dono)
        self.criado_em = time.time()
        self.instancia = self.id
        # QUEM JA VE ESTE SACO. E a mesma memoria que o Jogador.visiveis guarda para as
        # entidades, e existe pela mesma razao: sem ela nao da para saber o que acabou de
        # entrar no campo de visao (precisa de 06 04) e o que acabou de sair (06 03).
        # Reenviar o 06 04 a cada ciclo desenhava o saco outra vez, de meio em meio segundo.
        self.vistos = set()


def build_saco_aparece(seq: int, saco: "Saco") -> bytes:
    p = bytearray(_molde(CAMINHO_MOLDE_SACO, 187))
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, OFF_SACO_DONO, saco.dono & 0xFFFFFFFF)
    struct.pack_into(">I", p, OFF_SACO_ID, saco.id)
    struct.pack_into("<I", p, OFF_SACO_ITEM, saco.item_id)
    struct.pack_into("<I", p, OFF_SACO_INSTANCIA, saco.instancia)
    struct.pack_into("<I", p, OFF_SACO_QTD, saco.qtd)
    struct.pack_into("<3f", p, OFF_SACO_POS, saco.x, saco.y, saco.z)
    return encode_frame(bytes(p))


def build_saco_sai(seq: int, saco_id: int, item_id: int, dono: int = 0) -> bytes:
    """O saco sai do chao. O [6:10] diz QUEM o apanhou -- e nao e detalhe.

        [6:10] = id do jogador   apanhado por ele
        [6:10] = ffffffff        sumiu sem dono (expirou, saiu de vista)

    Medido em 30/08 comparando o nosso fio com o do oficial: ele manda UM 06 03 depois da
    recolha, com o id do jogador. Nos mandavamos DOIS -- primeiro este com ffffffff, do
    _tirar_saco, e a seguir o eco do pedido com o id certo. O client lia o primeiro como
    "saco desapareceu" e ficava sem a quem atribuir a recolha: a mensagem saia
    "Voce comprou: Carat." sem o numero. Foi o jogador que reparou na falta do numero.
    """
    p = bytearray(20)
    p[0:2] = bytes([6, 3])
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, int(dono) if dono else SACO_LIVRE)
    struct.pack_into(">I", p, 10, saco_id)
    struct.pack_into(">I", p, 14, item_id)
    struct.pack_into(">H", p, 18, SACO_RABO)
    return encode_frame(bytes(p))


# ---------------------------------------------------------------- CARAT: quanto cai
#
# MEDIDO em 29/08 sobre 1383 sacos de Carat das capturas do oficial. A quantidade NAO e uma
# faixa por monstro, como o MobDrop.ini guarda para os outros itens -- e uma formula:
#
#     carat = (CARAT_POR_NIVEL * nivel_do_mob + CARAT_BASE) * penalidade(dif) * variacao
#
# A recta saiu de sete especies, do nivel 1 ao 26, e erra entre -18% e +14%. Os pontos:
#
#     nivel  1     7     13    14    17    22    26
#     carat  10,0  15,3  18,7  19,0  24,3  30,0  36,7
#
# ESTES VALORES JA ESTAO DIVIDIDOS POR 3. A captura grande apanhou um evento de Carat x3, e
# sem esse aviso do jogador a formula saia com o triplo -- a nota inteira, com a conta que o
# confirma ao decimal, esta no Data/Drop/CaratPenalty.ini.
CARAT_POR_NIVEL = 1.040
CARAT_BASE = 7.14

# A variacao a volta do valor central, dos extremos observados: o Espadachim aguia tem base
# 110 e cai entre 97 e 120; o Javali base 46, entre 41 e 51. Da +-11% nos dois.
CARAT_VARIACAO = 0.11

# Dito pelo jogador e coerente com a medicao: nenhum abate deu menos do que isto.
CARAT_MINIMO = 2


def penalidade_carat(diferenca: int) -> float:
    """Fraccao do Carat base, dado nivel_jogador - nivel_mob. Le Data/Drop/CaratPenalty.ini.

    Mesma forma do db.penalidade_nivel da EXP, e de proposito: sao a mesma especie de curva
    e vale que se leiam da mesma maneira. Interpola entre os pontos medidos.
    """
    linhas = sorted(
        ((r.get("diferenca"), r.get("percentual")) for r in data_files.tabela("CaratPenalty")
         if r.get("diferenca") is not None and r.get("percentual") is not None),
        key=lambda x: x[0])
    if not linhas:
        return 1.0
    if diferenca <= linhas[0][0]:
        return linhas[0][1] / 100.0
    for (d0, p0), (d1, p1) in zip(linhas, linhas[1:]):
        if d0 <= diferenca <= d1:
            if d1 == d0:
                return p0 / 100.0
            t = (diferenca - d0) / float(d1 - d0)
            return (p0 + t * (p1 - p0)) / 100.0
    return linhas[-1][1] / 100.0


def carat_do_abate(nivel_mob: int, nivel_jogador: int, rnd=random) -> int:
    """Quanto Carat este abate rende. A formula esta no cabecalho desta seccao."""
    base = CARAT_POR_NIVEL * max(1, int(nivel_mob)) + CARAT_BASE
    pena = penalidade_carat(int(nivel_jogador) - int(nivel_mob))
    v = base * pena * rnd.uniform(1.0 - CARAT_VARIACAO, 1.0 + CARAT_VARIACAO)
    return max(CARAT_MINIMO, int(round(v)))


def sortear_drop_missao(npc_id: int, quests_aceites, rnd=random) -> list:
    """Itens de missao que este monstro larga a QUEM TEM A MISSAO. Lista de (item, qtd).

    `quests_aceites` e o conjunto de quest_id que o jogador tem aceites. Sem missao nenhuma
    nao cai nada -- e nao e uma optimizacao, e a regra do jogo.

    ISTO FOI CONFIRMADO, e nao suposto. Nas capturas do oficial o Espadachim aguia morreu
    773 vezes e a Garra da aguia espadachim nunca caiu; o Cao branco 306 vezes sem largar
    Carne, Costela nem Pele. Nenhum dos 257 itens do QuestDrop.ini apareceu em 1091 abates,
    porque o jogador da captura nao tinha essas missoes.

    NAO PASSA PELO DropPermitido.ini. Aquele filtro e sobre a tabela de saque geral; um item
    de missao nao e saque, e concedido pela missao. Quem quiser desligar isto desliga a taxa
    `drop_missao` do Rate.ini, que existe so para isso.

    E NAO CAI NO CHAO. Nao ha saco nenhum: o item de missao vai direto para a mochila de quem
    matou, corrigido pelo jogador em 29/08. Por isso esta funcao so DECIDE, e quem entrega e
    o world_server -- o chao nunca chega a saber que este item existiu.
    """
    if not quests_aceites:
        return []
    fator = data_files.taxa("drop_missao", 1.0)
    if fator <= 0:
        return []
    saida = []
    for r in data_files.tabela("QuestDrop"):
        if r.get("mob_id") != npc_id:
            continue
        if r.get("quest_id") not in quests_aceites:
            continue
        chance = float(r.get("chance") or 0) / 100.0
        if chance <= 0:
            continue
        if rnd.random() < min(1.0, chance * fator):
            saida.append((int(r["item_id"]), 1))
    return saida


def itens_permitidos():
    """Os item_id que o filtro deixa cair, ou None quando nao ha filtro.

    Le Data/Common/DropPermitido.ini, que recarrega a quente -- gravar la vale no abate
    seguinte, sem reiniciar nada.

    LISTA VAZIA DEVOLVE None, e nao um conjunto vazio, e a diferenca e tudo:

        None            nao ha filtro, cai tudo o que o MobDrop.ini disser
        set() vazio     nao cai NADA

    Um filtro que ninguem escreveu nao devia bloquear nada, entao a lista vazia tem de ser
    o primeiro caso. E a mesma armadilha do `if trilha:` que ja custou caro neste ficheiro:
    lista vazia e um estado com significado proprio e nao se testa pela verdade do valor.
    """
    linhas = data_files.tabela("DropPermitido")
    if not linhas:
        return None
    permitidos = set()
    for r in linhas:
        item = r.get("item_id")
        if isinstance(item, int) and item > 0:
            permitidos.add(item)
    return permitidos or None


def sortear_drop(npc_id: int, rnd=random, nivel_jogador: int = 0) -> list:
    """O que este monstro larga nesta morte. Lista de (item_id, quantidade).

    Le Data/Drop/MobDrop.ini. Sem linhas para o monstro devolve lista vazia -- e o caso
    normal enquanto a tabela nao estiver medida, e nao um erro.

    CADA LINHA E SORTEADA A PARTE, e nao uma escolha unica entre todas.

    Escrevi primeiro a escolha unica, copiando o formato do rvrDrop, e estava errada para os
    monstros. O oficial larga 2258 sacos em 1091 mortes, 2,07 por abate: um Javali larga o
    Carat E a costela na mesma morte. Escolha unica nunca daria mais do que um saco, e o
    sintoma seria discreto -- drop a funcionar, so que a metade do ritmo, que ninguem nota
    sem contar.

    A medicao tambem e por linha e nao por grupo: `peso` e em quantas mortes aquele item
    apareceu e `abates` quantas mortes houve. A chance da linha e peso/abates -- o Carat do
    Javali saiu em 177 de 199, logo 89%. Somar os pesos e sortear um ponto misturaria coisas
    que foram medidas separadamente.

    O rvrDrop do jogo usa mesmo escolha unica com soma-como-denominador, e ali a linha do
    item 9999999 e que da a taxa de vazio. Sao dois modelos diferentes para duas tabelas
    diferentes; aqui vale o que a medicao dos monstros mede.

    LIGAR E DESLIGAR e a taxa `drop_chance` do Data/Common/Rate.ini, que recarrega a quente:

        drop_chance = 0.000    nada cai, e e como esta agora
        drop_chance = 1.000    o ritmo medido no oficial
        drop_chance = 2.000    o dobro

    Ela MULTIPLICA a chance de cada linha, nao substitui: manter as proporcoes entre itens e
    so mexer no ritmo global e o que uma taxa de evento tem de fazer.
    """
    chance = data_files.taxa("drop_chance", 1.0)
    if chance <= 0:
        return []
    permitidos = itens_permitidos()
    saida = []
    for r in data_files.tabela("MobDrop"):
        if r.get("mob_id") != npc_id:
            continue
        item = int(r.get("item_id") or 0)
        if item == ITEM_NADA or item <= 0:
            continue                      # linha de vazio: nao e um item, e o resto da conta
        if permitidos is not None and item not in permitidos:
            continue                      # fora do filtro do Data/Common/DropPermitido.ini
        peso = int(r.get("peso") or 0)
        abates = int(r.get("abates") or 0)
        if peso <= 0 or abates <= 0:
            continue
        if rnd.random() < min(1.0, chance * peso / float(abates)):
            # A QUANTIDADE E UMA FAIXA, e sorteia-se dentro dela. A tabela guardava so o
            # maximo visto e largava-o sempre: a Aranha dava 36 Carat de cada vez e o Cao
            # branco 120. Com o Carat a ser o dinheiro do jogo, isso multiplicava a economia
            # varias vezes sem nada na tela a denunciar.
            if item == ITEM_CARAT:
                # O CARAT TEM FORMULA e nao sai da faixa medida. A faixa do MobDrop mistura
                # niveis de jogador diferentes E um evento x3 -- para os outros itens isso
                # nao se notou, mas o Carat e o dinheiro do jogo. Ver carat_do_abate().
                nm = 1
                ficha = data_files.indice("Mob", "mob_id").get(npc_id)
                if ficha and ficha.get("nivel"):
                    nm = int(ficha["nivel"])
                # Sem nivel de jogador nao ha penalidade que calcular, e usa-se o do proprio
                # monstro (dif = 0). O que NAO se faz e cair na faixa do MobDrop: essa faixa
                # ja se sabe errada para o Carat, e um caminho de recurso que devolve um
                # valor sabidamente mau e pior do que nenhum -- passa despercebido.
                saida.append((item, carat_do_abate(nm, nivel_jogador or nm, rnd)))
                continue
            qmin = max(1, int(r.get("qtd_min") or 1))
            qmax = max(qmin, int(r.get("qtd_max") or qmin))
            saida.append((item, qmin if qmax == qmin else rnd.randint(qmin, qmax)))
    return saida


# ESPALHAR OS SACOS. No oficial os sacos da mesma morte nao ficam empilhados no mesmo ponto:
# medi 8634,0 / 8648,3 / 8626,8 em x e 6639,3 / 6619,8 / 6655,5 em y numa so morte -- uns 20
# a 30 de raio a volta do corpo. Empilhados, o de baixo fica invisivel.
ESPALHAR_SACO = 25.0


def largar(npc_id: int, x: float, y: float, z: float, dono: int = SACO_LIVRE,
           rnd=random, log=print, nivel_jogador: int = 0) -> list:
    """Sorteia e poe os sacos no chao. Devolve os que nasceram.

    O nivel_jogador so serve ao Carat, que tem formula com penalidade por diferenca de
    nivel. Fica com padrao 0 (= sem penalidade) para o teste e as ferramentas nao terem de
    inventar um nivel; quem chama a serio e o world_server, que sabe o do heroi.

    """
    novos = []
    for item_id, qtd in sortear_drop(npc_id, rnd, nivel_jogador):
        s = Saco(item_id, qtd,
                 x + rnd.uniform(-ESPALHAR_SACO, ESPALHAR_SACO),
                 y + rnd.uniform(-ESPALHAR_SACO, ESPALHAR_SACO),
                 z, dono)
        SACOS[s.id] = s
        novos.append(s)
    if novos:
        log("[drop] mob %s largou %d saco(s): %s"
            % (npc_id, len(novos), ", ".join("%dx%d" % (s.item_id, s.qtd) for s in novos)))
    return novos


def sacos_perto(x: float, y: float, raio=None) -> list:
    r = RAIO_VISAO if raio is None else raio
    return [s for s in SACOS.values() if math.hypot(s.x - x, s.y - y) <= r]


def limpar_sacos() -> None:
    """Esvazia o chao. Chamado ao reiniciar o mundo: sacos de uma geracao anterior nao tem
    quem os desenhe, e ficavam a ocupar memoria e a ser apanhaveis por fantasmas."""
    SACOS.clear()


def _tirar_saco(saco, seq_de, motivo, log, dono: int = 0):
    """Tira o saco do chao e avisa quem o via. Um so caminho para as tres saidas.

    `dono` e o jogador que o apanhou, quando foi apanhado -- vai no [6:10] do 06 03 e e o que
    faz o client escrever a mensagem de aquisicao. Zero nas outras duas saidas.
    """
    import mundo          # tardio, igual ao resto do ficheiro: mundo importa este de volta
    SACOS.pop(saco.id, None)
    if saco.vistos:
        frame = build_saco_sai(seq_de(), saco.id, saco.item_id, dono)
        for j in mundo.todos():
            if j.entity_id in saco.vistos:
                j.enviar(frame)
    saco.vistos.clear()
    if motivo:
        log("[drop] saco %d (%dx%d) %s" % (saco.id, saco.qtd, saco.item_id, motivo))


def apanhar_saco(saco_id: int, jogador, seq_de, log=print) -> bool:
    """Apanha o saco a PEDIDO do jogador (o 06 03 que o client manda).

    Medido no fio do oficial: o client manda 06 03 com o id do saco e o servidor responde
    com o 06 06 do item e depois com o seu proprio 06 03 a tirar o saco.

    A distancia continua a ser verificada, mas como VALIDACAO e nao como gatilho -- o
    pedido de um saco do outro lado do mapa nao passa.
    """
    saco = SACOS.get(int(saco_id))
    if saco is None:
        return False
    if saco.dono != SACO_LIVRE and saco.dono != jogador.entity_id:
        log("[drop] %s pediu o saco %d, que e de outro" % (jogador.nome, saco_id))
        return False
    # O LIMITE E O CAMPO DE VISAO, nao o raio de recolha.
    #
    # Os 20 unidades do RAIO_DE_RECOLHA vinham de quando a recolha era por PROXIMIDADE: era
    # o raio em que o saco vinha sozinho. Como validacao de um clique nao serve -- o jogador
    # clica no saco e a personagem caminha ate la, entao no instante do pedido ele esta longe.
    # Com os 20 a recolha parou de funcionar assim que se trocou o gatilho.
    #
    # O limite honesto e o que o client consegue mostrar: so se clica no que se ve. Fica em
    # RAIO_VISAO, e a distancia vai para o log para se saber o valor real do oficial quando
    # houver uma captura com as posicoes bem lidas.
    _d = math.hypot(saco.x - jogador.x, saco.y - jogador.y)
    if _d > RAIO_VISAO:
        log("[drop] %s pediu o saco %d a %.0f de distancia -- fora do campo de visao"
            % (jogador.nome, saco_id, _d))
        return False
    log("[drop] %s apanhou o saco %d a %.0f de distancia" % (jogador.nome, saco_id, _d))
    entregar = ENTREGAR_ITEM[0]
    if entregar is None or not entregar(jogador, saco.item_id, saco.qtd):
        return False               # mochila cheia, banco em baixo, ou entrega nao ligada
    _tirar_saco(saco, seq_de, "apanhado por %s" % jogador.nome, log, jogador.entity_id)
    return True


def rodar_sacos(seq_de, log=print) -> None:
    """Um ciclo do chao: campo de visao, tempo de vida e recolha.

    Chamada de dentro de rodar_mundo, no mesmo ritmo do resto. Nao tem estado proprio: o
    tempo de vida sai de saco.criado_em e o resto das posicoes que ja existem.

    A ORDEM IMPORTA, e as tres etapas estao pela ordem em que tem de acontecer:

      1. DESENHAR primeiro. Quem mata esta em cima do corpo, entao o saco que nasce ali ja
         nasce dentro do raio de recolha. Se a recolha viesse antes, o saco era apanhado no
         mesmo ciclo em que nasceu e o 06 04 nunca chegava a sair -- o item aparecia na
         mochila sem nada na tela a dizer de onde veio. Foi o que o ensaio mostrou.

      2. RECOLHER antes de expirar. Um saco que chega aos 60 s com o jogador em cima tem de
         ir para a mochila, e nao evaporar-lhe nas maos.

      3. EXPIRAR por ultimo.
    """
    import mundo          # tardio, igual ao resto do ficheiro: mundo importa este de volta
    agora = time.time()
    jogadores = mundo.todos()

    for saco in list(SACOS.values()):
        # --- 1. campo de visao: quem chegou perto ve, quem se afastou deixa de ver.
        # HISTERESE, a mesma das entidades: entra a RAIO_VISAO e so sai a RAIO_VISAO +
        # MARGEM. Sem ela um jogador parado na fronteira recebia aparece/some sem parar.
        for j in jogadores:
            d = math.hypot(saco.x - j.x, saco.y - j.y)
            via = j.entity_id in saco.vistos
            if not via and d <= RAIO_VISAO:
                saco.vistos.add(j.entity_id)
                j.enviar(build_saco_aparece(seq_de(), saco))
            elif via and d > RAIO_VISAO + MARGEM_SAIDA_VISAO:
                saco.vistos.discard(j.entity_id)
                j.enviar(build_saco_sai(seq_de(), saco.id, saco.item_id))

        # --- 2. A RECOLHA NAO E POR PROXIMIDADE. Quem apanha e o jogador, com um clique:
        # o client manda um 06 03 com o id do saco e o servidor responde. Ver apanhar_saco.
        #
        # Aqui pisava-se e o saco vinha sozinho. O jogador comparou com o oficial: "no nosso
        # nao aparece essa mao pra coletar o item, ta coletando o carat so de chegar perto".
        # No fio do oficial ve-se o saco a ficar no chao 9,3 s depois de nascer, com o
        # jogador em cima, ate ao 06 03 do client.

        # --- 3. passou do tempo?
        if agora - saco.criado_em >= SEGUNDOS_SACO_NO_CHAO:
            _tirar_saco(saco, seq_de, "expirou", log)


def descontar_defesa(jogador_id, dano):
    """Aplica a defesa equipada do jogador ao dano que ele ia levar.

    Fica FORA de `_tirar_hp_do_jogador` de proposito: o dano tem de ser cortado onde e
    DECIDIDO, antes de ir para o 05 02, senao o numero que flutua na tela diz uma coisa e a
    barra de vida perde outra.
    """
    if dano <= 0:
        return dano
    try:
        fisica, _magica = db.defesa_total(int(jogador_id))
    except Exception as e:                                          # noqa: BLE001
        print("[sim] defesa_total falhou: %s" % str(e)[:120])
        return dano
    base = 0.0
    try:
        ficha = db.carregar_personagem(int(jogador_id)) or {}
        if ficha:
            base = db.protecao_base(int(ficha.get("nivel") or 1))
    except Exception as e:                                          # noqa: BLE001
        print("[sim] protecao_base falhou: %s" % str(e)[:120])
    total = fisica + base
    if total <= 0:
        return dano
    return max(1, int(round(dano - CORTE_POR_DEFESA * total)))


class Entidade:
    """Um NPC que perambula em torno de um ponto de ancora."""

    def __init__(self, ent_id: int, npc_id: int | None = None, nome: str = "",
                 hp_max: int | None = None, ancora=None, tipo: str = "mob",
                 raio: float | None = None):
        self.id = ent_id
        self.npc_id = npc_id
        self.nome = nome
        self.hp_max_npc = hp_max
        # "mob" persegue, morre e renasce. "npc" e instrutor: fica no posto, nao morre e
        # existe pra conversar -- missao, criacao ou loja. A separacao vem da captura, pelo
        # HP: todo NPC parado do oficial vem com 1450 cravado, monstro nenhum tem esse valor.
        self.tipo = tipo
        if ancora is not None:
            self.ancora_x, self.ancora_y, z = ancora
            self.z = z
        else:
            ang = random.uniform(0, 2 * math.pi)
            raio = random.uniform(40, SPAWN_RAIO)
            self.ancora_x = SPAWN_X + math.cos(ang) * raio
            self.ancora_y = SPAWN_Y + math.sin(ang) * raio
            self.z = SPAWN_Z + random.uniform(-8, 8)
        self.x, self.y = self.ancora_x, self.ancora_y
        # RAIO DE ESPALHAMENTO, do client (coluna raio do NpcSpawn.ini) -- NAO de perambulacao.
        #
        # Este comentario dizia o contrario, e a hipotese foi MEDIDA e desmentida em 30/08.
        # Cruzando 1953 segmentos de monstro do oficial com o raio do poste mais proximo da
        # especie:
        #
        #     raio   dispersao do bicho (p50)   distancia ao poste (p50)
        #      10             68                        22
        #      30             68                        21
        #     200             55                        56
        #     300             64                       119
        #
        # A DISPERSAO NAO MEXE: um bicho de poste raio 10 vagueia tanto como um de raio 300.
        # O que acompanha o raio e a distancia a que ele SE INSTALA do poste. Ou seja o raio
        # diz onde o monstro nasce, nao ate onde anda depois.
        #
        # O `db.spawns()` ja o usa assim -- espalha os monstros extra de cada poste dentro
        # dele. Aqui o campo fica guardado e NAO e usado para encolher trilha nenhuma: fazer
        # isso poria 231 dos 472 postes a andar num circulo de 10 unidades, com metade do
        # mundo praticamente parado, a corrigir um defeito que nao existe.
        self.raio = float(raio) if raio else SPAWN_RAIO
        self.hp = hp_max or hp_max_de(ent_id)
        self.viva = True
        self.morta_em = 0.0

    def appear(self, template: bytes) -> bytes:
        # Molde REAL deste NPC, se a captura tiver um. Ver molde_npc().
        proprio = molde_npc(self.npc_id)
        if proprio is not None:
            return build_appear(proprio, self.id, self.x, self.y, self.z)
        return build_appear(template, self.id, self.x, self.y, self.z,
                            npc_id=self.npc_id, hp_max=self.hp_max_npc)

    @property
    def velocidade(self):
        """Velocidade do PROXIMO pacote. Sorteada a cada chamada, como no oficial.

        Ate 25/08 era sorteada UMA vez e guardada na entidade, porque eu tinha medido que
        ela era "estavel por monstro". Estava errado: a Aranha pequena aparece com 943
        valores distintos em 95.940 pacotes -- muda a cada envio.

        E o erro tinha consequencia visivel. O campo controla a ANIMACAO, nao o
        deslocamento (a correlacao entre ele e o avanco e -0,09). Um monstro que sorteasse
        3,3 no nascimento animava devagar PARA SEMPRE enquanto deslizava a 10,3 por segundo,
        e nada o corrigia. Sorteando a cada pacote, nenhum fica preso num valor ruim.

        A distribuicao esta em Data/Monsters/MobMovement.ini, tipo=velocidade.
        """
        return VELOCIDADE_COERENTE + random.uniform(-0.3, 0.3)

    def esquecer_caca(self):
        """Apaga tudo o que este monstro sabia sobre perseguir. Usado ao renascer.

        O respawn repunha HP, posicao e o livro de dano, mas nao isto. O bicho voltava com o
        `_modo` em "perseguindo" e o `_alvo_id` de quem o tinha matado -- e portanto nascia ja
        a correr para o jogador, sem o aviso de 10,7 s e sem sequer precisar de o ver. Foi o
        que o jogador descreveu: "depois do respawn ele aparece e ja vem me atacar".

        Tambem re-ancora a trilha, porque a entidade acabou de ser teleportada para a ancora e
        a trilha continuava contada a partir de onde ela morreu.
        """
        self._modo = None
        self._alvo_id = 0
        self._rampa = 0
        self._parado_pra_lutar = False
        self._regresso = None
        self._cacada_t0 = None
        self._alerta_fase = 1
        self._proximo_golpe = 0.0
        self._dano_pendente = None
        self._ultima_caca = 0.0
        self._religar_trilha()

    def _passo_de_perseguicao(self):
        """Alerta, persegue em rajadas, golpeia de perto, e volta pra ancora na corda.

        Quatro modos, e o mob so passeia quando esta em nenhum deles:

            alertando     ve o jogador e AVISA por 10,7 s -- continua a passear enquanto isso
            perseguindo   passo 21,33 rumo ao jogador, estado 1, em rajadas curtas
            golpeando     parado dentro do alcance, estado 2
            voltando      passo 42,66 rumo a ancora, estado 1

        A ordem das verificacoes importa. A corda e testada ANTES do alcance do golpe: um mob
        arrastado pra longe tem de largar o jogador mesmo estando colado nele, senao seguiria
        pelo mapa inteiro enquanto o jogador andasse de costas.
        """
        if self.tipo == "npc":
            return None
        agressivo, alcance = dados_de_agressao(self.npc_id)
        modo = getattr(self, "_modo", None)
        if not agressivo and modo is None:
            return None   # docil so age se for provocado, e ai o modo ja vem preenchido
        agora = time.time()
        # O DANO PENDENTE SAI AQUI, antes de qualquer trava de intervalo.
        #
        # Estava a ser libertado dentro de `_golpear`, que so corre de 0,6 em 0,6 s. O atraso
        # do golpe e 0,78, portanto caia sempre na volta seguinte: medido no nosso fio, o dano
        # chegava 1,25 a 1,45 s depois do bote em vez de 0,78. E a mesma armadilha de grelha
        # que ja tinha estragado o andar dos monstros, noutro sitio.
        pendente = getattr(self, "_dano_pendente", None)
        if pendente and agora >= pendente[0]:
            self._dano_pendente = None
            self._enfileirar("dano", pendente[1], pendente[2], pendente[3], pendente[4])
        alvo = self._jogador_mais_perto()
        # O GUARDA SO ATACA QUEM TEM MORAL NEGATIVA. Quem ja lhe bateu continua a ser
        # perseguido -- por isso o portao so vale enquanto o modo ainda nao comecou.
        if alvo and agressivo == "pk" and modo is None and _reputacao_de(alvo[0]) >= 0:
            return None
        dist = math.hypot(alvo[1] - self.x, alvo[2] - self.y) if alvo else 1e18
        d_ancora = math.hypot(self.x - self.ancora_x, self.y - self.ancora_y)

        # ---- aviso, antes de qualquer perseguicao ----
        if modo is None:
            if dist > AGRO_RAIO:
                return None
            self._modo = "alertando"
            self._alerta_t0 = agora
            self._alertar(ALERTA_VALOR_INICIO)
            anotar(self, "avistou a %.0f -- avisa por %.1f s" % (dist, ALERTA_ATE_CACAR))
            return None                      # continua a passear enquanto avisa
        if modo == "alertando":
            passado = agora - self._alerta_t0
            if dist > AGRO_RAIO:             # saiu de perto: cancela o aviso
                self._alertar(ALERTA_VALOR_FIM)
                self._modo = None
                anotar(self, "cancelou o aviso -- alvo saiu pra %.0f" % dist)
                return None
            if passado >= ALERTA_ATE_CACAR:
                self._alertar(ALERTA_VALOR_FIM)
                self._modo = "perseguindo"
                self._rampa = 0
            elif passado >= ALERTA_ATE_MEIO and getattr(self, "_alerta_fase", 1) == 1:
                self._alerta_fase = ALERTA_VALOR_MEIO
                self._alertar(ALERTA_VALOR_MEIO)
                return None
            else:
                return None

        # UM RELOGIO SO. O portao do intervalo vem PRIMEIRO, e a rajada decide-se depois.
        #
        # Na primeira tentativa (28/08) a rajada ficou antes do portao, e por isso a pausa
        # devolvia ESTADO_PARADO a cada tique do mundo -- vinte por segundo. A captura do
        # jogador mostrou 1457 pacotes parados contra 21 a correr, e a pausa media saiu em
        # 4,82 s onde eu queria 0,45. Eram dois temporizadores a decidir a mesma coisa e o
        # resultado nao era nenhum dos dois.
        # A ESPERA DO AGGRO vem antes do relogio do passo -- ver ATRASO_AGGRO.
        espera = getattr(self, "_espera_aggro", 0.0)
        if espera:
            if agora < espera:
                return ()          # ja tem odio, ainda nao arrancou
            self._espera_aggro = 0.0

        # O GOLPE NAO ESPERA PELO RELOGIO DO PASSO.
        #
        # O teste "estou em alcance?" estava DEPOIS do portao do movimento, portanto a decisao
        # de atacar so era tomada de 0,537 em 0,537 s -- presa a cadencia dos passos, que nao
        # tem nada a ver com a de bater. No oficial o ataque e imediato: medido em 191
        # engajamentos, o primeiro golpe sai a uma mediana de 0,00 s depois de o mob entrar no
        # alcance, e 87 por cento abaixo de 1 s. O jogador viu-o antes de eu o medir: "quando o
        # mob chega no raio de accao ele ataca automatico, nao tem delay".
        #
        # Aqui o golpe passa a ser avaliado a cada tique do mundo. Nao dispara em rajada: o
        # _golpear tem a cadencia propria da especie (2,4 a 4,2 s, medida).
        # ESTE PORTAO SO DECIDE O GOLPE. Nao mexe na rampa.
        #
        # Zerava `_rampa_caca` aqui, e isto corre a 20 Hz. Com o jogador por perto, o mob esta
        # dentro do alcance quase sempre, portanto a rampa era posta a zero vinte vezes por
        # segundo -- e no instante em que o jogador se afastava, o mob arrancava do primeiro
        # degrau: 2,67 unidades em 0,537 s, ou seja 5 u/s contra os 65 do jogador. Fica no
        # sitio. E o "notei que ele parou de me perseguir comigo perto dele", de 29/08.
        #
        # A rampa e a aceleracao de quem estava PARADO. Quem interrompeu a corrida por um
        # golpe nao esta parado, esta a segurar o passo. Por isso o zero passou a acontecer so
        # onde ha mesmo repouso: numa cacada nova (provocado_por) e ao sair da fuga.
        #
        # Piorou com o alcance a subir de 27 para 30: quanto maior o raio, mais tempo o mob
        # passa dentro dele e mais vezes a rampa era zerada.
        if alvo is not None and dist <= alcance and self._modo == "perseguindo":
            self._cacada_t0 = None
            self._golpear(alvo, agora)

        if not venceu(agora, getattr(self, "_ultima_caca", 0.0), INTERVALO_PERSEGUICAO):
            # Ja emitiu passo neste intervalo. Devolve tupla vazia em vez de None, porque
            # None deixaria a trilha assumir e o mob voltava a passear no meio da cacada.
            return ()
        self._ultima_caca = agora

        # RAJADAS, agora dentro do mesmo relogio: conta-se em TIQUES, nao em segundos.
        #
        # Medido no oficial em 2388 corridas e 1179 pausas: rajada mediana 1,02 s, pausa
        # 0,45 s. Com o tique de perseguicao a 0,543 s isso da, em numero de tiques, dois a
        # correr e um parado -- e contar em tiques e o que garante que o padrao sai mesmo,
        # em vez de depender de um segundo relogio cair no sitio certo.
        #
        # So FORA de alcance: dentro dele o monstro ja para para combater, e uma pausa
        # dentro de outra nao faz sentido.
        if alvo is not None and dist > alcance:
            n = getattr(self, "_tique_rajada", 0)
            self._tique_rajada = n + 1
            if n % (TIQUES_RAJADA + TIQUES_PAUSA) >= TIQUES_RAJADA:
                # FIM DO TRECHO: e aqui, e so aqui, que o rumo volta a ser decidido. E o que
                # produz a forma medida no oficial -- longas seguidas a zero graus e depois
                # uma virada grande -- em vez de uma correccao pequena a cada passo.
                self._rumo_caca = None
                aqui = (self.x, self.y, self.z)
                return aqui, aqui, VEL_PERSEGUICAO, ESTADO_PARADO
        else:
            self._tique_rajada = 0

        # ---- FUGA COM POUCA VIDA (modo esperto) ----
        #
        # Nao ha nada disto no oficial que eu tenha medido. E comportamento de jogo, pedido
        # em 29/08. Fica de proposito depois do portao do intervalo e antes do "voltando",
        # para usar o mesmo relogio de passo que todo o resto do movimento -- foi ter dois
        # relogios a decidir a mesma coisa que estragou a rajada da primeira vez.
        if (IA_ESPERTA and IA_FUGIR_ABAIXO_DE > 0.0 and alvo is not None
                and int(self.npc_id) in IA_FOGEM
                and self._modo in ("perseguindo", "fugindo")):
            teto = self.hp_max_npc or hp_max_de(self.id) or 1
            fraccao = float(self.hp) / float(teto)
            # Histerese: foge abaixo do limite, so volta a lutar acima do dobro dele. Sem
            # isto o bicho oscila entre fugir e atacar de tique a tique em volta do limite.
            if fraccao <= IA_FUGIR_ABAIXO_DE:
                if self._modo != "fugindo":
                    anotar(self, "FOGE: %d/%d de vida (%.0f%%)" % (self.hp, teto, fraccao * 100))
                    self._modo = "fugindo"
                # Corre para o lado oposto ao jogador, a partir da posicao dele.
                # A fuga tambem tem corda. Sem isto o bicho com pouca vida atravessa o mapa:
                # o teste de corda la em baixo nunca chega a correr, porque isto devolve antes.
                if d_ancora > LEASH_DA_ANCORA:
                    aqui = (self.x, self.y, self.z)
                    return aqui, aqui, VEL_PERSEGUICAO, ESTADO_PARADO
                # PARA ONDE FOGE -- e porque nao e so "para longe do jogador".
                #
                # Era: 400 unidades em linha recta no sentido oposto, sem perguntar nada ao
                # terreno. A fada fugia MORRO ACIMA, para onde o personagem nao sobe, e ficava
                # inalcancavel. Viu-se em 29/08.
                #
                # Tentei tirar um limite de declive do mapa de alturas e nao da: o declive que
                # o proprio jogador percorre tem p90 de 12 graus mas p99 de 38, portanto
                # qualquer corte seria inventado por mim.
                #
                # O que EXISTE medido e o territorio da especie. As trilhas foram gravadas de
                # bichos reais no oficial, logo tudo dentro delas e chao que a especie pisa --
                # de 104 unidades (Tirano sanguinario) a 245 (Fada). A fuga passa a acontecer
                # dentro desse raio a contar da ANCORA: afasta-se do jogador ate onde o bicho
                # anda, e nao por uma recta que pode sair do mundo jogavel.
                dx, dy = self.x - alvo[1], self.y - alvo[2]
                n = math.hypot(dx, dy) or 1.0
                raio = alcance_da_trilha(self.npc_id)
                fx = self.ancora_x + dx / n * raio
                fy = self.ancora_y + dy / n * raio
                # Se calhar em pedra, roda ate achar sitio livre. O mapa de colisao nao conhece
                # o morro, mas conhece parede -- e o que ele sabe, usa-se.
                for giro in (0.0, 0.6, -0.6, 1.2, -1.2, 2.0, -2.0):
                    a = math.atan2(dy, dx) + giro
                    cx = self.ancora_x + math.cos(a) * raio
                    cy = self.ancora_y + math.sin(a) * raio
                    try:
                        if not pathfinding.bloqueado_em(cx, cy):
                            fx, fy = cx, cy
                            break
                    except Exception:                                  # noqa: BLE001
                        break
                return self._andar_ate(fx, fy,
                                       PASSO_PERSEGUICAO * IA_VEL_FUGA,
                                       DECL_PERSEGUICAO * IA_VEL_FUGA,
                                       VEL_MAX_PERSEGUICAO * IA_VEL_FUGA)
            if self._modo == "fugindo" and fraccao > IA_FUGIR_ABAIXO_DE * 2.0:
                anotar(self, "recuperou (%.0f%%), volta a lutar" % (fraccao * 100))
                self._modo = "perseguindo"
                self._rampa_caca = 0
            elif self._modo == "fugindo":
                return ()          # ainda a recuperar, nem foge nem ataca

        if self._modo == "voltando":
            self._cacada_t0 = None
            # REARMA O "PAROU PRA LUTAR". Sem isto so o PRIMEIRO combate da vida do bicho
            # anunciava a paragem: a bandeira era ligada ao entrar em alcance e so voltava a
            # False em esquecer_caca, que corre apenas no respawn. Do segundo engajamento em
            # diante o client nunca recebia o quadro ESTADO_PARADO, e continuava a executar a
            # ultima ordem que tinha -- caminhar para um destino a frente. Na tela e o monstro
            # a virar costas e a afastar-se no instante do golpe, que foi exatamente o que o
            # jogador descreveu ("ele me bate mas ele fica de costa pra mim"). Aquilo tinha
            # sido corrigido para o primeiro combate; ficou por corrigir para todos os outros.
            self._parado_pra_lutar = False
            if d_ancora <= PASSO_RETORNO:
                self.x, self.y = self.ancora_x, self.ancora_y
                anotar(self, "chegou a ancora, volta a passear")
                self._modo = None
                self._alerta_fase = 1
                self._religar_trilha()
                return None
            return self._andar_ate(self.ancora_x, self.ancora_y, PASSO_RETORNO,
                                   DECL_RETORNO, VEL_RETORNO)

        cacando_desde = getattr(self, "_cacada_t0", None)
        if cacando_desde is None:
            self._cacada_t0 = cacando_desde = agora
        tempo_de_caca = agora - cacando_desde
        # LARGA TAMBEM POR DISTANCIA AO ALVO, e nao so ao poste.
        #
        # Sao duas cordas diferentes e so tinhamos uma. Medido em 28/08 em 583 desistencias:
        # o monstro larga com o jogador a p50 174, p75 296, p90 399 unidades. Sem isto ele
        # persegue-te ate a corda do poste esticar, que com os 600 antigos era praticamente
        # nunca.
        longe_do_alvo = alvo is not None and dist > DESISTE_DO_JOGADOR
        if (alvo is None or longe_do_alvo or d_ancora > LEASH_DA_ANCORA
                or tempo_de_caca > TEMPO_MAX_CACADA):
            if self._modo != "voltando":
                # O MOTIVO CERTO. A condicao tem tres pernas mas a mensagem so distinguia
                # duas, e toda desistencia por tempo era registada como corda esticada -- com
                # um valor de d_ancora muito abaixo dos 600, o que mandava quem lesse o diario
                # investigar a corda em vez do relogio. Foi isso que escondeu o defeito dos 7 s.
                anotar(self, "DESISTIU: %s" % (
                    "sem alvo" if alvo is None
                    else "alvo a %.0f, alem dos %.0f" % (dist, DESISTE_DO_JOGADOR)
                    if longe_do_alvo
                    else "corda esticada, %.0f da ancora" % d_ancora
                    if d_ancora > LEASH_DA_ANCORA
                    else "tempo esgotado, %.1f s de cacada" % tempo_de_caca))
            self._modo = "voltando"
            return self._andar_ate(self.ancora_x, self.ancora_y, PASSO_RETORNO,
                                   DECL_RETORNO, VEL_RETORNO)
        # ATACA NO MESMO TIQUE EM QUE CHEGA, e nao no seguinte.
        #
        # `dist` e a distancia ANTES de o mob se mexer. No tique em que ele finalmente entra
        # em alcance ainda estava fora, portanto so batia 0,537 s depois -- e com o jogador em
        # movimento, muitas vezes ja tinha saido do alcance nesse meio tempo. Ele so acertava
        # quando o jogador parava e esperava, que foi como o jogador o descreveu: "so ataca de
        # eu parar e depois de um tempinho".
        #
        # No oficial o primeiro golpe sai no INSTANTE da chegada: mediana 0,00 s entre entrar
        # em alcance e bater, 87 por cento abaixo de 1 s, em 191 casos.
        # O ATALHO SAIU DAQUI -- ele e que fazia o mob parar longe e bater no ar.
        #
        # Estava assim:
        #
        #     if dist > alcance and dist - alcance <= VEL_MAX_PERSEGUICAO * INTERVALO_PERSEGUICAO:
        #         dist = alcance
        #
        # Isto MENTE sobre a distancia em vez de andar. Com VEL_MAX 39,7 e o tique 0,537, um
        # passo vale 21,3 unidades: qualquer mob a menos de alcance+21,3 do jogador era
        # tratado como se ja la estivesse. Cai no ramo de baixo, declara ESTADO_PARADO, liga
        # o `_parado_pra_lutar` -- e a partir dai fica em silencio de movimento para sempre.
        # Nunca mais anda. Fica cravado onde estava e bate no ar ate um dos dois morrer.
        #
        # Esta no log de hoje, com o Javali-de-sabre 286, alcance medido 27,0:
        #
        #     GOLPE FORA DE ALCANCE: 32.9 > 27.0
        #     GOLPE FORA DE ALCANCE: 31.8 > 27.0
        #     GOLPE FORA DE ALCANCE: 30.5 > 27.0
        #
        # Tres golpes, nenhum de dentro do alcance, e a distancia mal se mexeu entre eles
        # porque o bicho tinha deixado de andar. E o "ele para antes de chegar em mim".
        #
        # O motivo original do atalho -- bater no tique da chegada e nao no seguinte --
        # deixou de existir quando o golpe saiu do relogio do passo (ver "O GOLPE NAO ESPERA
        # PELO RELOGIO DO PASSO", mais acima). Esse portao corre a cada tique do MUNDO, 20 Hz.
        # Sem o atalho, o mob anda ate ao alcance neste tique e bate 50 ms depois, nao 537.
        # O atalho ficou a resolver um problema que ja nao existia, e a criar outro.
        if dist <= alcance:
            self._rampa = 0
            # O RELOGIO DA CACADA NAO CORRE DENTRO DO COMBATE.
            #
            # TEMPO_MAX_CACADA foi medido no oficial como sequencias continuas de ESTADO 1 --
            # o monstro a correr atras. Combate nao e estado 1: o oficial fica em silencio de
            # movimento enquanto bate (ver o comentario do "parado pra lutar" logo abaixo, com
            # o atacante 952 a bater 14 s sem mandar um so pacote). Aplicar o teto dos 7 s ao
            # total corrida+porrada corta a luta a meio.
            #
            # Media na captura do oficial, sessoes de golpes seguidos entre o mesmo par:
            #
            #     mediana 12,1 s    p75 23,8 s    p90 36,8 s
            #     passam dos 7 s: 305 de 456 = 67 por cento
            #
            # E o jogador confirma o que a estatistica diz: "fica ate eu morrer ou ele morrer".
            # Nao ha desistencia por tempo dentro do alcance -- so a corda, que continua a ser
            # testada acima de proposito, para o monstro arrastado para longe largar o alvo.
            #
            # Zerar aqui faz o relogio contar so o tempo FORA de alcance. Se o jogador fugir,
            # a perseguicao recomeca do zero, que e o comportamento certo: e uma cacada nova.
            self._cacada_t0 = None
            self._golpear(alvo, agora)
            # UM unico "parado" ao entrar em combate, e depois silencio.
            #
            # Silencio total estava errado: o monstro vem a ANDAR quando o jogador o acerta, e
            # se deixamos de mandar movimento sem nunca dizer "para", o client continua a
            # executar a ultima ordem que tinha -- caminhar ate um destino 23,33 a frente. Na
            # tela isso e o bicho a virar costas e a afastar-se no instante do golpe, que foi
            # exatamente o que o jogador descreveu.
            #
            # Silencio DEPOIS do parado esta certo: na captura, o atacante 952 passou 14 s a
            # bater sem mandar um unico pacote de movimento. Ele ja estava parado antes da
            # luta comecar -- por isso nao precisava de anunciar nada.
            if not getattr(self, "_parado_pra_lutar", False):
                self._parado_pra_lutar = True
                aqui = (self.x, self.y, self.z)
                return aqui, aqui, VEL_PERSEGUICAO, ESTADO_PARADO
            return ()

        # ---- rajada / pausa ----
        # SEM PAUSA NO MEIO DA CACADA.
        #
        # Havia rajadas de 1,2 a 3,4 s separadas por pausas de 2,5 a 4,0 s. Como cada passo
        # leva 0,6 s, so cabiam 2 ou 3 passos por rajada -- e dois deles eram sempre a rampa
        # de arranque (2,67 e 18,67). O bicho passava a vida a acelerar e a parar, e nunca
        # alcancava ninguem. Foi o que o jogador viu como "perseguicao bugada".
        #
        # A captura do oficial mostra o contrario. Na perseguicao da entidade 1168 sao 19
        # passos SEGUIDOS, sem pausa nenhuma:
        #
        #     2,67  18,66  21,33  21,33  21,33  10,67  2,67  18,67  21,33  21,33 ...
        #
        # A rampa reaparece la dentro (aqueles 2,67 no meio), mas nao interrompe a corrida --
        # e o proprio jogo a reajustar o rumo quando o alvo se move, nao uma pausa.
        #
        # As pausas que eu tinha medido antes vinham de rajadas SEPARADAS por perda de alvo,
        # nao de descanso a meio de uma caçada.
        # A RAMPA E O PASSO FIXO SAIRAM DAQUI. O controlador tem aceleracao propria, portanto
        # a rampa 2,67/18,67/21,33 aparece sozinha como consequencia -- nao e preciso escreve
        # -la. Ver o bloco CONTROLADOR DE PERSEGUICAO.
        # REARMA O "PAROU PRA LUTAR" SEMPRE QUE VOLTA A ANDAR.
        #
        # A bandeira so era limpa em "voltando" e no respawn. Mas o mob sai e volta a entrar
        # em alcance dezenas de vezes numa luta so -- basta o jogador dar dois passos. Da
        # segunda entrada em diante o quadro ESTADO_PARADO nunca era mandado, e o client
        # continuava a executar a ultima ordem que tinha: andar ate um destino 18,67 a frente.
        # Na tela isso e o monstro a atravessar o jogador e so depois voltar. E o "ele passa
        # de mim e volta pra atacar", dito assim mesmo em 29/08.
        #
        # Aqui e o unico sitio por onde ele volta a MEXER-SE dentro da cacada, portanto e aqui
        # que a bandeira tem de cair.
        self._parado_pra_lutar = False
        # A CACADA DE 27/08, tal e qual: rampa de dois degraus e recta ate ao jogador.
        #
        # Sai daqui o _caca_dirigida com o caminho A*, o congelamento de rumo por trecho, a
        # rampa de quatro degraus e a paragem antecipada no alcance do golpe. Tudo isso foi
        # construido entre 28 e 29/08 para tirar o chacoalho, e o jogador diz que foi ai que
        # se estragou o que funcionava. O pathfinding fica no ficheiro e continua a servir a
        # fuga; so a perseguicao deixa de o usar.
        i = getattr(self, "_rampa", 0)
        passo = RAMPA_PERSEGUICAO[i] if i < len(RAMPA_PERSEGUICAO) else PASSO_PERSEGUICAO
        self._rampa = i + 1
        return self._andar_ate(alvo[1], alvo[2], passo, DECL_PERSEGUICAO, VEL_PERSEGUICAO,
                               estado=ESTADO_PERSEGUINDO)

    def _enfileirar(self, *item):
        """Poe um pacote na fila. O laco do mundo drena, numera e manda a quem esta perto.

        A entidade nao monta nem envia nada por conta propria: o numero de sequencia e do
        mundo e o filtro de visao tambem. Aqui so se diz o QUE aconteceu.
        """
        if not hasattr(self, "_saida"):
            self._saida = []
        self._saida.append(item)

    def _alertar(self, valor):
        self._enfileirar("alerta", valor)

    def provocado_por(self, jogador_id):
        """Levou dano deste jogador: larga o passeio e vai atras, sem aviso.

        Vale pra QUALQUER monstro, docil ou nao. Agressivo diz quem ataca sem ser atacado --
        revidar e outra coisa, e todo bicho revida. O aviso de 10,7 s tambem nao se aplica:
        ele serve pra anunciar um bote que o jogador ainda pode evitar, e quem ja bateu no
        monstro nao precisa de aviso nenhum.
        """
        if self.tipo == "npc" or not self.viva:
            return
        self._alvo_id = int(jogador_id)
        # LARGA O REGRESSO. Se estava a caminhar de volta ao inicio da trilha, esse destino
        # deixa de ter qualquer valor -- quem levou uma pancada vai atras de quem bateu, nao
        # continua o passeio interrompido.
        self._regresso = None
        self._cacada_t0 = None      # cacada nova, relogio do zero
        self._tique_rajada = 0
        self._rumo_caca = None      # cacada nova aponta de fresco, sem herdar rumo velho
        self._vel_mov = 0.0         # arranca parado: e daqui que sai a rampa de aceleracao
        self._rumo_mov = None
        self._alvo_antes = None
        self._passos_no_segmento = 0
        self._rampa_caca = 0        # cacada nova volta a arrancar devagar
        # RELOGIO DO GOLPE DO ZERO. Ficava com o valor da luta anterior, e o primeiro
        # golpe de um engajamento novo tinha de esperar o resto da cadencia -- ate 4 s
        # parado ao pe do jogador sem bater.
        self._proximo_golpe = 0.0
        self._caminho = None
        self._caminho_alvo = None
        # so arma a espera se ainda nao estava a perseguir: quem ja corre nao volta a hesitar
        if getattr(self, "_modo", None) not in ("perseguindo", "golpeando"):
            self._espera_aggro = time.time() + random.uniform(*ATRASO_AGGRO)
        # "voltando" ENTRA NA LISTA. O guarda existe para nao reiniciar a rampa de quem ja
        # persegue, mas apanhava tambem quem ia a caminho do poste: esse zerava _alvo_id e
        # _regresso logo acima e depois saia por aqui sem nunca voltar a "perseguindo",
        # portanto continuava a andar para casa imune a pancada ate la chegar.
        if getattr(self, "_modo", None) not in (None, "alertando", "voltando"):
            return
        # SEM EXCECAO PRA DOCIL. Cheguei a fazer o docil FUGIR em vez de revidar, por uma
        # lembranca de que a fada corria. O jogador foi testar: a Aranha pequena ataca de
        # volta. `agressivo` diz so quem comeca a briga -- terminada essa distincao, todo
        # monstro que apanha vai atras de quem bateu.
        self._modo = "perseguindo"
        self._rampa = 0
        # DIARIO DA PROVOCACAO. Nao existia, e sem ele nao se distingue "o bicho nem chegou a
        # reagir" de "reagiu e desistiu logo a seguir" -- que e a duvida de 30/08, o javali
        # que foge quando se lhe bate.
        import mundo as _m
        _j = _m.jogador(int(jogador_id))
        _d = -1.0
        if _j is not None and _j.x is not None:
            _p = _j.pos() if hasattr(_j, "pos") else (_j.x, _j.y, _j.z)
            _d = math.hypot(_p[0] - self.x, _p[1] - self.y)
        anotar(self, "PROVOCADO por %d a %.0f de distancia (%.0f do poste)"
               % (jogador_id, _d,
                  math.hypot(self.x - self.ancora_x, self.y - self.ancora_y)))

    def _golpear(self, alvo, agora):
        """Enfileira o par 05 00 / 05 02 quando a cadencia permite.

        O 05 02 nao sai junto: o oficial manda o bote e o dano 0,84 s depois. Quem monta os
        dois no mesmo instante perde a animacao -- o client usa esse intervalo pra levantar a
        pata antes de acertar.
        """
        if agora < getattr(self, "_proximo_golpe", 0.0):
            return   # o pendente ja e libertado no topo de _passo_de_perseguicao
        prox = cadencia_de_golpe(self.npc_id) + random.uniform(-JITTER_GOLPE, JITTER_GOLPE)
        self._proximo_golpe = agora + prox
        # DIAGNOSTICO TEMPORARIO do "ele vira para onde eu estava no inicio". Regista a
        # posicao do jogador que o mob esta a usar para se orientar. Se ela ficar parada
        # enquanto o jogador anda a volta, o defeito esta em quem escreve mundo.Jogador.x/y.
        aid, ax, ay = alvo
        # REGISTA A DISTANCIA QUE O SERVIDOR VE, no instante do golpe.
        #
        # As medicoes feitas a partir do espiao tem o erro da amostragem da posicao do
        # jogador (ate 16 unidades a 5 Hz), e com esse erro nao da para decidir se um golpe
        # a "33 unidades" foi mesmo fora de alcance. Aqui nao ha interpolacao nenhuma: e o
        # numero que a propria decisao usou.
        _d = math.hypot(ax - self.x, ay - self.y)
        _al = dados_de_agressao(self.npc_id)[1]
        if _d > _al + 1.0:
            anotar(self, "GOLPE FORA DE ALCANCE: %.1f > %.1f" % (_d, _al))
        self._enfileirar("golpe", aid, (ax, ay, self.z),
                         rumo_do_jogo(ax - self.x, ay - self.y))
        dano, bandeira = self.dano_do_mob(aid)
        bruto = dano
        dano = descontar_defesa(aid, dano)
        anotar(self, "GOLPE em %s: %s | rumo=%.0f graus (proximo em %.1f s)"
               % (aid, NOME_DA_BANDEIRA.get(bandeira, "bandeira %d" % bandeira)
                  % {"bruto": bruto, "dano": dano, "cortado": bruto - dano},
                  math.degrees(rumo_do_jogo(ax - self.x, ay - self.y)), prox))
        atraso = random.uniform(ATRASO_DANO_MIN, ATRASO_DANO_MAX)
        self._dano_pendente = (agora + atraso, aid, dano, (ax, ay, self.z), bandeira)

    def dano_do_mob(self, jogador_id=None):
        """Quanto este monstro tira do jogador.

        UMA especie medida: Urso furioso, nivel 14, cinco golpes -- 289, 291, 290, 289 e 556.
        Os quatro primeiros dao 20,7 por nivel; o ultimo e quase o dobro, golpe forte, e saiu
        uma vez em cinco.
        A escala por nivel e minha, nao medida, e nao ha nada aqui sobre a defesa do alvo --
        os cinco golpes foram todos no mesmo personagem. Precisa de captura com personagens de
        niveis diferentes pra saber o que muda.
        """
        nivel = 1
        linha = (data_files.indice("Mob", "mob_id") or {}).get(int(self.npc_id or 0))
        if linha and linha.get("nivel"):
            nivel = int(linha["nivel"])
        d = nivel * DANO_POR_NIVEL_DO_MOB * random.uniform(0.97, 1.03)

        # A EVASAO SAI DA FICHA DO JOGADOR, nao de uma constante.
        #
        # db.chance_evasao ja existia e ja estava medida -- destreza * 0,05 + 6,5 -- mas nunca
        # tinha sido chamada por ninguem. A ficha da Feiticeira de nivel 68 (Destreza 11) diz
        # "Chance de evasao 7", e a formula da 7,05; na captura de 27/08 a bandeira 6, que e a
        # de dano zero por esquiva, saiu em 7,4 por cento dos 108 golpes que ela levou. Tres
        # numeros independentes a bater: a ficha, a formula e o fio.
        #
        # A bandeira 4, tambem de dano zero, saiu em 38,9 por cento e essa NAO e do jogador --
        # e o monstro a falhar, e continua na constante ate ser medida. O mesmo para o
        # bloqueio: a ficha diz 9 e a formula da 9,1, mas a bandeira 5 apareceu em 31,5 por
        # cento, longe demais para eu ligar os dois sem perceber o que falta no meio.
        evasao = MOB_CHANCE_FALHA
        if jogador_id:
            try:
                ficha = db.carregar_personagem(int(jogador_id)) or {}
                if ficha:
                    evasao = max(0.0, db.chance_evasao(ficha) / 100.0)
            except Exception as e:                                  # noqa: BLE001
                print("[sim] chance_evasao falhou: %s" % str(e)[:120])

        sorte = random.random()
        if sorte < evasao:
            return 0, BANDEIRA_EVASAO
        if sorte < evasao + MOB_CHANCE_FALHA:
            return 0, BANDEIRA_FALHA
        if sorte < evasao + MOB_CHANCE_FALHA + MOB_CHANCE_BLOQUEIO:
            return max(1, int(round(d * MOB_MULT_BLOQUEIO))), BANDEIRA_BLOQUEIO
        if sorte < evasao + MOB_CHANCE_FALHA + MOB_CHANCE_BLOQUEIO + MOB_CHANCE_GOLPE_FORTE:
            return min(255, int(round(d * MOB_MULT_GOLPE_FORTE))), BANDEIRA_FORTE
        return max(1, min(255, int(round(d)))), BANDEIRA_NORMAL

    def _caca_dirigida(self, alvo_id, ax, ay, alcance, dt):
        """Um passo de perseguicao. Devolve (origem, destino, rumo, estado).

        E a maquina de estados que o jogador escreveu em 28/08, e nao mais do que isso:

            se distancia > raio maximo   -> RETORNANDO   (tratado por quem chama)
            se distancia <= alcance      -> ATACANDO     (idem)
            senao                        -> aponta a posicao ACTUAL do jogador e anda

        O QUE SE TIROU DAQUI, E PORQUE. Isto ja teve congelamento de rumo (4 pacotes na mesma
        direccao), limite de rotacao e antecipacao da posicao do jogador. As tres vinham de
        medicoes verdadeiras -- o oficial vai a direito em 93 por cento dos pares -- mas as
        tres produziam o mesmo defeito visivel: o bicho compromete-se com uma direccao, o
        jogador desvia-se, ele segue na direccao velha e depois corrige de repente. O jogador
        descreveu-o exactamente assim: "o mob do nada muda a direcao quando perseguindo e
        volta a perseguir de novo".

        A licao e que 93 por cento de rumo identico nao se copia congelando o rumo. No
        oficial isso e CONSEQUENCIA de haver um caminho calculado que ele segue; aqui era uma
        causa imposta, e imposta contra o alvo real. Quando houver A* a serio, a rectidao
        aparece sozinha.

        O que fica: velocidade constante e o avanco limitado pela distancia que falta ate ao
        alcance de golpe -- e isso que o impede de passar ao lado do jogador, defeito que ja
        se viu em 28/08 e que nao volta so por se apontar sempre ao alvo certo.
        """
        # SEGUE UM CAMINHO quando ha obstaculo pelo meio.
        #
        # Ate 29/08 o mob apontava sempre ao jogador. Perto dele, um passo do jogador muda o
        # rumo em dezenas de graus e o bicho gira -- "vira de um lado pro outro mas andando
        # em minha direcao". O oficial nao gira porque segue um caminho calculado; a rectidao
        # de 93 por cento que se mediu nele e consequencia disso.
        #
        # So se recalcula quando o alvo se afastou do sitio para onde o caminho apontava:
        # recalcular a cada pacote traria o giro de volta por outra porta.
        # INTERCECCAO: mira onde ele VAI estar. Ver IA_ANTECIPACAO.
        #
        # A previsao entra no RUMO e nao na PARAGEM. Sao duas coisas diferentes e juntar as
        # duas traria de volta exactamente o defeito de que o jogador se queixa: com o
        # jogador a vir na direccao do mob, o ponto previsto fica ATRAS do mob, a distancia
        # ao ponto previsto cresce, e o bicho continuaria a avancar depois de ja o ter
        # alcancado -- "ele passa de mim". Por isso guardo aqui a posicao verdadeira e e
        # essa, mais abaixo, que decide quando parar.
        ax_real, ay_real = ax, ay
        if IA_ESPERTA and IA_ANTECIPACAO > 0.0:
            ant = getattr(self, "_alvo_antes", None)
            if ant is not None and ant[0] == alvo_id:
                d_t = max(1e-3, dt)
                vx, vy = (ax - ant[1]) / d_t, (ay - ant[2]) / d_t
                if math.hypot(vx, vy) < 200.0:
                    ax = ax + vx * IA_ANTECIPACAO
                    ay = ay + vy * IA_ANTECIPACAO
            # GUARDA A REAL, nao a prevista. Guardar a prevista faria a velocidade estimada
            # no tique seguinte sair da diferenca entre duas previsoes, e o erro compunha-se
            # tique a tique ate o mob mirar fora do mapa.
            self._alvo_antes = (alvo_id, ax_real, ay_real)

        alvo_cam = (ax, ay)
        cam_ = getattr(self, "_caminho", None)
        base = getattr(self, "_caminho_alvo", None)
        if cam_ is None or base is None or math.hypot(ax - base[0], ay - base[1]) > 40.0:
            cam_ = pathfinding.caminho(self.x, self.y, ax, ay)
            self._caminho, self._caminho_alvo = cam_, (ax, ay)
        if cam_:
            # descarta os pontos ja alcancados e aponta ao primeiro que falta
            while cam_ and math.hypot(cam_[0][0] - self.x, cam_[0][1] - self.y) < LADO_PONTO:
                cam_.pop(0)
            if cam_:
                alvo_cam = cam_[0]
        ax_mov, ay_mov = alvo_cam

        dx, dy = ax_mov - self.x, ay_mov - self.y
        dist_alvo = math.hypot(ax_real - self.x, ay_real - self.y)
        dist = math.hypot(dx, dy)
        if dist < 0.01:
            aqui = (self.x, self.y, self.z)
            return aqui, aqui, getattr(self, "_rumo_mov", 0.0), ESTADO_PARADO

        rumo = math.atan2(dy, dx)
        self._rumo_mov = rumo

        # RAMPA DE ARRANQUE. O oficial nao sai de parado a velocidade de ponta: a entrada na
        # perseguicao e uma sequencia de tres passos, 2,667 -> 18,667 -> 21,333, encadeados em
        # 98 e 91 por cento de 2.378 pares medidos. Sem ela o mob salta de zero para o passo
        # inteiro num pacote so, e o jogador ve isso como um solavanco: "ainda da aquela
        # aceleracao no inicio da perseguicao".
        k = getattr(self, "_rampa_caca", 0)
        self._rampa_caca = k + 1
        vel_esp = velocidade_de(self.npc_id, True)
        tecto = RAMPA_PERSEGUICAO[k] if k < len(RAMPA_PERSEGUICAO) else vel_esp * dt

        # PARA A DISTANCIA DE GOLPE, nao em cima do jogador. Medido no oficial: 92 por cento
        # das perseguicoes acabam com ele a chegar ao pe do jogador, nao a passar por ele.
        # A paragem mede-se ao JOGADOR; o `dist` acima pode ser a um ponto do caminho.
        falta = max(0.0, min(dist, dist_alvo - alcance))
        avanco = min(tecto, falta)
        self.x += math.cos(rumo) * avanco
        self.y += math.sin(rumo) * avanco
        # SEGUE O TERRENO. Ver altura_em(): o mob andava a altura do sitio onde nasceu.
        self.z = altura_em(self.x, self.y, self.z)

        origem = (self.x, self.y, self.z)

        # CHEGADA: PARADO com origem igual ao destino, e nunca um passo curto.
        #
        # O client vai a caminho do ponto declarado, a frente. Se no tique da chegada nos
        # mandarmos um passo CURTO, a posicao nova pode ficar ATRAS de onde ele ja tinha
        # desenhado o monstro -- e o modelo salta para tras. O jogador viu-o assim: "avanca
        # na minha direcao, passa de mim, depois volta pra traz e me ataca", e continuou a
        # ve-lo mesmo depois de o raio declarado deixar de o ultrapassar.
        #
        # O oficial nao tem passos curtos: ou da o passo inteiro, ou manda estado 2 com a
        # origem igual ao destino. Quem chega, chega e para, e o client fica com um ponto
        # final certo em vez de uma ordem que sera desmentida meio segundo depois.
        if avanco <= 0.01 or falta <= tecto:
            return origem, origem, rumo, ESTADO_PARADO
        # o destino declarado e um raio de comprimento fixo sobre o rumo -- medido no
        # oficial: 18,67 em 81 por cento dos pacotes de perseguicao, nunca a posicao do alvo
        # O RAIO DECLARADO NUNCA APONTA PARA LA DO JOGADOR.
        #
        # O destino declarado sao 18,67 fixos sobre o rumo -- e o que o client interpola. Se o
        # mob parar a menos de 18,67 do jogador, esse ponto cai ATRAS dele: o client anda ate
        # la, ATRAVESSA o jogador, e e puxado de volta quando o pacote seguinte chega. O
        # jogador descreveu-o em 29/08: "avanca na minha direcao, passa de mim, depois volta
        # pra traz e me ataca".
        #
        # Aconteceu porque eu tinha posto o alcance de ataque no p25 dos golpes medidos (a
        # aranha ficou em 10,2) e 10,2 e menor que 18,67. Com o p90 os alcances sao todos
        # maiores e o problema desaparece sozinho -- mas depender de os numeros calharem bem
        # nao e uma correccao. Este travao vale para qualquer alcance futuro.
        # TRAVA NO ALCANCE DE ATAQUE, e nao na posicao do jogador.
        #
        # Estava a travar em `dist - 1`, ou seja o raio podia apontar para um ponto a 1 unidade
        # do jogador. Com o mob a 30 e o raio de 18,67, o destino declarado ficava a 11,3 do
        # jogador -- MAIS PERTO do que o alcance de ataque de 17. O client andava ate la e
        # ficava colado; depois o pacote da chegada dizia "estas a 17", que e atras, e o modelo
        # saltava para tras. O jogador descreveu as duas metades: "quando eu ando ele fica em
        # cima de mim, quando ele ataca teleporta pra traz".
        #
        # Travando no alcance, o ponto declarado nunca passa do sitio onde o mob vai mesmo
        # parar, e o client nao tem de ser desmentido.
        alvo_falta = max(0.0, math.hypot(ax - self.x, ay - self.y) - alcance)
        decl = min(DECL_PERSEGUICAO, alvo_falta) if alvo_falta > 0.0 else DECL_PERSEGUICAO
        destino = (self.x + math.cos(rumo) * decl,
                   self.y + math.sin(rumo) * decl, self.z)
        return origem, destino, rumo, ESTADO_PERSEGUINDO


    def _andar_ate(self, ax, ay, passo, declarada, vel, estado=ESTADO_PERSEGUINDO,
                   congelar=False, parar_a=0.0):
        """Anda `passo` na direcao de (ax, ay) e declara um destino a `declarada` a frente.

        Andar e declarar sao coisas diferentes, como no passeio: o oficial avanca 21,33 e
        declara 18,67. O destino declarado nao e onde ele vai parar, e para onde ele aponta.
        """
        dx, dy = ax - self.x, ay - self.y
        n = math.hypot(dx, dy)
        if n < 0.01:
            aqui = (self.x, self.y, self.z)
            return aqui, aqui, vel, ESTADO_PARADO
        rumo = math.atan2(dy, dx)
        if congelar:
            # UMA decisao por trecho -- ver CONGELA_RUMO_NA_RAJADA. O rumo guardado so e
            # deitado fora quando a rajada acaba (em _passo_de_perseguicao) ou quando uma
            # cacada nova comeca (em provocado_por).
            # SEM PAUSA, O RUMO TEM DE DESCONGELAR POR CONTAGEM. Enquanto havia pausa era
            # ela que apagava o rumo guardado; numa perseguicao continua isso nunca
            # aconteceria e o bicho corria em linha recta para sempre, ignorando o jogador.
            k = getattr(self, "_passos_no_rumo", 0)
            anterior = getattr(self, "_rumo_caca", None)
            if anterior is None or k >= PASSOS_POR_RUMO:
                self._rumo_caca, self._passos_no_rumo = rumo, 1
            else:
                rumo = anterior
                self._passos_no_rumo = k + 1
        ux, uy = math.cos(rumo), math.sin(rumo)
        # PARA A DISTANCIA DE GOLPE, nao em cima do alvo. Medido no oficial: o alcance do
        # golpe do mob e 27,33 u de mediana, e 92% das perseguicoes acabam com ele a chegar
        # ao pe do jogador -- nao a passar por ele. Sem esta trava o mob encosta-se, o passo
        # seguinte manda-o para o outro lado, e o resultado e o vaivem.
        avanco = min(passo, max(0.0, n - parar_a))
        self.x += ux * avanco
        self.y += uy * avanco
        self.z = altura_em(self.x, self.y, self.z)
        origem = (self.x, self.y, self.z)
        destino = (self.x + ux * declarada, self.y + uy * declarada, self.z)
        return origem, destino, vel, estado

    def _jogador_mais_perto(self):
        """(id, x, y) do alvo. Quem BATEU tem preferencia sobre quem esta perto."""
        import mundo
        alvo = getattr(self, "_alvo_id", 0)
        if IA_ESPERTA and IA_ODIO:
            # QUEM MAIS DOEU. O livro de dano ja existia para dividir a EXP; serve tambem
            # para escolher o alvo, que e o que uma lista de odio faz.
            livro = _DANO.get(int(self.id)) or {}
            if livro:
                pior = max(livro.items(), key=lambda kv: kv[1])[0]
                if pior and pior != alvo:
                    alvo = self._alvo_id = int(pior)
        if alvo:
            j = mundo.jogador(alvo)
            if j is not None and j.x is not None:
                # pos() interpola o segmento anunciado; j.x/j.y sao so a origem dele.
                p = j.pos() if hasattr(j, "pos") else (j.x, j.y, j.z)
                return (alvo, p[0], p[1])
            self._alvo_id = 0          # saiu do mundo
        melhor = None
        md = 1e18
        agora_c = time.time()
        for j in mundo.todos():
            if j.x is None or agora_c < _carencia.get(j.entity_id, 0.0):
                continue
            h = getattr(j, "heroi", None)
            if h is not None and h.hp <= 0:
                continue            # nao se persegue quem esta caido
            p = j.pos() if hasattr(j, "pos") else (j.x, j.y, j.z)
            d = math.hypot(p[0] - self.x, p[1] - self.y)
            if d < md:
                md, melhor = d, (j.entity_id, p[0], p[1])
        return melhor

    def _religar_trilha(self):
        """Retoma o passeio a partir de onde o mob esta agora.

        Sem re-basear, a trilha somaria as posicoes gravadas a uma ancora que o mob ja nao
        ocupa no mesmo instante da contagem, e ele daria um salto no primeiro passo.
        """
        if getattr(self, "_trilha", None):
            # NAO REENTRA NO PASSO ZERO. Reentrar no zero poe em sincronia todos os bichos da
            # mesma trilha que tenham voltado ao poste por volta do mesmo instante -- e eles
            # voltam juntos, porque foi o mesmo jogador que os provocou. O arranque em ponto
            # sorteado, que existe justamente para os dessincronizar, so valia ate a primeira
            # vez que cada um chegasse a ancora; dai em diante voltavam a andar em bloco.
            #
            # O ponto vem do sorteador do poste, portanto e estavel para este monstro e
            # diferente do dos vizinhos: mantem a dessincronizacao sem introduzir aleatorio
            # novo a cada regresso, que faria o bicho saltitar pela trilha.
            rnd = _rnd_do_poste(self.ancora_x, self.ancora_y, self.npc_id)
            self._trilha_i = rnd.randrange(len(self._trilha))
            self._trilha_t0 = time.time() - (self._trilha[self._trilha_i][0] / 1000.0)
            # o regresso de antes do combate ja nao faz sentido: a trilha recomeca do zero
            self._regresso = None
            # SEMPRE a ancora, nunca a posicao atual.
            #
            # A trilha guarda posicoes RELATIVAS ao seu inicio, e a base e o ponto onde elas
            # sao somadas. Basear na posicao atual faz a trilha inteira acompanhar o bicho
            # para onde quer que ele tenha ido -- e como a trava do raio compara com a
            # ANCORA, ficava a lutar contra uma base que fugia dela. Com a base fixa na
            # ancora, a trilha e sempre desenhada a volta do poste, e a trava so tem de
            # aparar os extremos.
            self._trilha_base = (self.ancora_x, self.ancora_y)

    def _passo_da_trilha(self):
        """O proximo passo da trajetoria gravada, ou None se nao houver trilha.

        Devolve None quando ainda nao chegou a hora do proximo passo -- a trilha tem tempos
        proprios (600, 630, 785 ms...) e o laco do mundo roda a cada 0,6 s. Respeitar o
        tempo gravado e parte do ponto: a irregularidade do oficial esta na trilha.
        """
        trilha = getattr(self, "_trilha", None)
        if trilha is None and especie_parada(self.npc_id):
            self._trilha = []      # guarda: fica no posto. Ver especie_parada.
            return None
        if trilha is None:
            # Sedentario: nao anda nunca, como quase um terco do mundo oficial.
            rnd = _rnd_do_poste(self.ancora_x, self.ancora_y, self.npc_id)
            trilha = _trilha_do_poste(self.npc_id, self.ancora_x, self.ancora_y)
            if trilha is SEDENTARIO:
                self._trilha = []
                return None
            if trilha is None:
                trilha = trilha_para(self.npc_id, rnd)
            # A COLEIRA DO TERRENO. A trilha foi gravada a volta de OUTRA ancora; posta
            # nesta pode dar para uma encosta. Encolhe-se ate caber na banda de altura
            # medida no oficial -- ver BANDA_DE_ALTURA. Calcula-se uma vez por entidade.
            if trilha:
                f = _escala_por_altura(trilha, self.ancora_x, self.ancora_y)
                if f < 0.999:
                    trilha = _trilha_escalada(trilha, f)
                    anotar(self, "trilha encolhida para %.2f -- terreno" % f)
            self._trilha = trilha
            # COMECA NUM PONTO ALEATORIO DA TRILHA, nao no passo zero.
            #
            # Ha muito mais bichos do que trilhas -- 34 Javali-de-sabre para 6 trilhas
            # gravadas, ~6 entidades por trilha. Se todos comecarem no passo 0 no mesmo
            # instante (e comecam: nascem todos juntos, no arranque do mundo ou na recarga),
            # ficam em sincronia PERFEITA e nunca dessincronizam, porque a trilha e
            # deterministica.
            #
            # Medido no fio: 8 dos 13 javalis a volta do jogador andavam em 3 grupos com
            # sequencia identica passo a passo -- 4 deles com 106 passos byte a byte iguais.
            # Na tela sao clones a mover-se como um so, e foi assim que o jogador descreveu:
            # "no original eles se movem aleatorio um do outro, nosso 2 ou 3 se move igual e
            # ao mesmo tempo".
            #
            # Deslocar o indice inicial resolve sem tocar nos dados: a mesma trilha, o mesmo
            # movimento gravado, so que cada entidade entra nela num sitio diferente.
            if trilha:
                # SEM ESCALA. O raio do client NAO e o limite de perambulacao.
                #
                # Cheguei a escalar a trilha para caber na coluna `raio` do NpcSpawn.ini,
                # convencido de que era a coleira do bicho. Medindo o alcance REAL no oficial
                # -- distancia maxima ao centro do percurso, em 828 aranhas e centenas de
                # outros -- o numero desmente-o por completo:
                #
                #     especie                 medido     raio declarado
                #     Aranha pequena            92,6         10     9x MAIOR
                #     Lagarto do pantano        77,3         10     8x maior
                #     Filhote de urso feroz     67,3        300     4x MENOR
                #     Urso furioso              54,9        300     5x menor
                #
                # Quem declara 10 anda dez vezes mais; quem declara 300 anda cinco vezes
                # menos. Escrevi aqui que nao era teto nem escala e que nao percebia o que
                # era. Percebo agora: sao DOIS REGIMES, e a media dos dois nao quer dizer
                # nada. Comparando o alcance MAXIMO das trilhas com o raio declarado, nas
                # dez especies que tem trilha gravada:
                #
                #     raio >= 150 (3 especies)   max/declarado = 0,47  0,65  0,82   sempre <1
                #     raio <= 30  (7 especies)   max/declarado = 4x a 21x           sempre >1
                #
                # Tres em tres dos que declaram valor grande ficam DENTRO dele; sete em sete
                # dos que declaram valor pequeno ignoram-no. O valor grande parece ser mesmo
                # um teto; o pequeno e outra coisa qualquer (dispersao do nascimento? raio
                # de grupo?) que nao tem a ver com perambular.
                #
                # Continuo a nao aplicar teto nenhum, e de proposito: as trilhas gravadas ja
                # respeitam o teto por construcao -- a Fada declara 300 e vai a 245 -- entao
                # aplica-lo nao mudaria nada nas especies medidas, e nas outras seria adivinhar.
                #
                # A trilha gravada ja tem o alcance certo por construcao: veio de bichos
                # reais do oficial. Mexer nela so estragava. O sintoma foi a Aranha pequena
                # parada no sitio: as trilhas dela tem alcance 3 e 10, e escalar para o raio
                # 10 deixava-a a andar 3 unidades.
                self._trilha_escala = 1.0
                self._trilha_i = rnd.randrange(len(trilha))
                # o relogio tem de recuar junto, senao o passo escolhido ja "venceu" ha muito
                # e ele despeja a trilha toda de uma vez tentando pos-se em dia
                self._trilha_t0 = time.time() - (trilha[self._trilha_i][0] / 1000.0)
            else:
                self._trilha_i = 0
                self._trilha_t0 = time.time()
            self._trilha_base = (self.ancora_x, self.ancora_y)
        if not trilha:
            return None
        # REGRESSO AO INICIO DA TRILHA, a andar.
        #
        # So acontece entre voltas, e so quando o fim da trilha ficou longe do inicio. Usa o
        # passo e a velocidade do passeio normal, portanto na tela e indistinguivel de o
        # bicho estar simplesmente a caminhar -- que e o que o oficial mostra.
        regresso = getattr(self, "_regresso", None)
        if regresso is not None:
            agora_r = time.time()
            if not venceu(agora_r, getattr(self, "_ultimo_regresso", 0.0), INTERVALO_MUNDO):
                return None
            self._ultimo_regresso = agora_r
            rx, ry = regresso
            dxr, dyr = rx - self.x, ry - self.y
            dist = math.hypot(dxr, dyr)
            if dist <= AVANCO_POR_SEGUNDO * INTERVALO_MUNDO:
                self.x, self.y = rx, ry
                self._regresso = None
                self._trilha_t0 = time.time()
                return None
            ux, uy = dxr / dist, dyr / dist
            self.x += ux * AVANCO_POR_SEGUNDO * INTERVALO_MUNDO
            self.y += uy * AVANCO_POR_SEGUNDO * INTERVALO_MUNDO
            origem = (self.x, self.y, self.z)
            destino = (self.x + ux * PASSO_MOB, self.y + uy * PASSO_MOB, self.z)
            return origem, destino, VELOCIDADE_COERENTE, ESTADO_ANDANDO
        i = self._trilha_i
        if i >= len(trilha):
            # FIM DE VOLTA: ENTRA NUMA TRILHA NOVA PELO PONTO MAIS PARECIDO COM ONDE ESTA.
            #
            # Antes recomecava sempre no passo 0 da mesma trilha. Como 44 das 49 trilhas nao
            # fecham -- o ultimo ponto fica a 52 unidades do primeiro de mediana, ate 189 --
            # cada volta acabava com uma caminhada em linha recta de volta ao inicio. Na tela
            # e o bicho a passear a serio e depois a marchar para o poste, que foi o que o
            # jogador descreveu: "andam longe e depois volta correndo pro ponto inicial".
            #
            # O truque e nao ter perna de regresso nenhuma. Escolhe-se uma trilha ao acaso
            # entre as da especie e entra-se nela pelo INDICE cujo deslocamento gravado mais
            # se parece com o deslocamento actual do mob em relacao a ancora. Medido nas 49
            # trilhas, o que sobra a andar depois disto:
            #
            #                        p50     p90     max
            #     como estava         52     107     189
            #     assim              2,5      13    46,3
            #
            # 47 das 49 ficam abaixo de um passo de passeio (23,33), ou seja sem regresso
            # visivel nenhum. O `_regresso` fica para os outros dois casos.
            #
            # A BASE CONTINUA NA ANCORA, e e isso que impede a deriva: por muitas voltas que
            # de, o deslocamento e sempre somado ao mesmo ponto fixo. Reancorar na posicao
            # actual seria mais simples e somava o desvio de cada volta para sempre.
            #
            # De lambuja resolve o outro pedido -- "deixa natural e aleatorio um do outro".
            # Dois bichos da mesma especie que comecem juntos separam-se na primeira volta,
            # porque cada um sorteia a trilha seguinte por sua conta.
            todas = _carregar_trilhas().get(int(self.npc_id or 0)) or [trilha]
            ox, oy = self.x - self.ancora_x, self.y - self.ancora_y
            # A melhor entrada de CADA trilha, e so depois o sorteio.
            #
            # Sortear a trilha primeiro e entrar nela pelo ponto menos mau custava caro:
            # simulando 200 voltas por especie, o resto a andar dava mediana ate 37 e maximo
            # 180 -- pior do que o problema que isto vem resolver. Medindo primeiro e
            # sorteando entre as que ficam perto, cai para mediana ate 16 e maximo 28, ou
            # seja pouco mais de um passo de passeio, em todas as dez especies.
            #
            # O sorteio entre as candidatas PROXIMAS e o que mantem os bichos diferentes uns
            # dos outros; se houvesse so a melhor, dois mobs no mesmo sitio seguiriam sempre
            # o mesmo percurso.
            cands = []
            for tt in todas:
                # Na propria trilha ignora-se a cauda: o ponto mais parecido com o fim e o
                # proprio fim, e o bicho ficava preso a repetir o ultimo passo.
                limite = int(len(tt) * 0.9) if tt is trilha else len(tt)
                bk, bd = 0, 1e18
                for k in range(max(1, limite)):
                    p = tt[k]
                    d = (p[1] - ox) ** 2 + (p[2] - oy) ** 2
                    if d < bd:
                        bd, bk = d, k
                cands.append((bd, tt, bk))
            perto = [c for c in cands if c[0] <= PASSO_MOB * PASSO_MOB]
            _d, nova, melhor = random.choice(perto) if perto else min(cands, key=lambda c: c[0])
            self._trilha = trilha = nova
            self._trilha_i = i = melhor
            self._trilha_base = (self.ancora_x, self.ancora_y)
            self._trilha_t0 = time.time() - (nova[melhor][0] / 1000.0)
            alvo_x = self.ancora_x + nova[melhor][1]
            alvo_y = self.ancora_y + nova[melhor][2]
            if math.hypot(alvo_x - self.x, alvo_y - self.y) > PASSO_MOB:
                self._regresso = (alvo_x, alvo_y)
                return None
        ms, x, y, dx, dy, vel, estado = trilha[i]
        # MEIO TICK DE TOLERANCIA, como em `venceu`. Sem ela cada passo saia no primeiro tick
        # DEPOIS do instante gravado, sempre atrasado e nunca adiantado: medido numa trilha
        # real da Aranha, 79 ms de atraso medio que se acumulam ao longo da volta inteira.
        # Com a tolerancia o atraso medio cai para 3 ms.
        #
        # O que ela NAO corrige e o jitter: os intervalos gravados (0,61 / 0,38 / 0,79 s) nao
        # sao representaveis em multiplos de 0,15, e a grelha arredonda-os para 0,60 / 0,45 /
        # 0,75. Isso so se resolve com um TICK_MUNDO mais fino -- ver a medida de trabalho
        # por ciclo que o laco agora regista.
        if (time.time() - self._trilha_t0) * 1000.0 < ms - TICK_MUNDO * 500.0:
            return None
        self._trilha_i = i + 1
        bx, by = self._trilha_base
        k = getattr(self, "_trilha_escala", 1.0)
        x, y, dx, dy = x * k, y * k, dx * k, dy * k
        # SEM TRAVA DE RAIO. Ver o comentario da escala acima: a coluna `raio` do client nao
        # corresponde ao alcance real medido no oficial, entao usa-la como cerca prendia os
        # bichos num circulo que nao existe no jogo.
        #
        # A trilha nao precisa de cerca: ela E o percurso de um bicho real, e ja se mantem
        # em volta do sitio dele. Quem trata da deriva de longo prazo e a base fixa na
        # ancora, logo abaixo.
        self.x, self.y = bx + x, by + y
        self.z = altura_em(self.x, self.y, self.z)
        # A ORIGEM DO PACOTE E A POSICAO DE AGORA, nao a anterior.
        #
        # Aqui estava o defeito que o jogador via como o mob chacoalhando, e que sobreviveu a
        # cinco tentativas de conserto porque eu procurava no comportamento e ele estava no
        # empacotamento. Eu montava:
        #
        #     origem  = posicao ANTERIOR
        #     destino = posicao NOVA + (dx, dy)
        #
        # As duas defasadas em um passo. A distancia declarada saia 30 em vez dos 23,33 da
        # trilha -- o avanco de 6,67 somado por engano -- e, pior, o client recebia "ande a
        # partir de um ponto 6,67 ATRAS de onde o bicho esta". A cada pacote ele era puxado
        # pra tras e voltava andando.
        #
        # Isso tambem quebrava o sinal de parada: com origem defasada, um passo de estado 2
        # (que na trilha tem destino igual a origem) saia com 4,1 de distancia declarada. No
        # oficial, 100 por cento dos estado 2 tem origem igual ao destino.
        #
        # Como achei: gravei o que o nosso servidor manda e comparei com a trilha de origem,
        # em vez de continuar ajustando estatistica. Foi o primeiro teste que olhou os dois
        # lados da mesma coisa.
        origem = (self.x, self.y, self.z)
        destino = (self.x + dx, self.y + dy, self.z)
        return origem, destino, vel, estado

    def _passo_de_replay(self):
        """Devolve o proximo passo gravado, quando a hora dele chegar. Repete em ciclo."""
        seq = _carregar_replay()
        if not seq:
            return None
        i = getattr(self, "_replay_i", 0)
        if i >= len(seq):
            i = 0
            self._replay_t0 = time.time()
        t0 = getattr(self, "_replay_t0", None)
        if t0 is None:
            t0 = self._replay_t0 = time.time()
        ms, est, ox, oy, oz, dx, dy, dz, f36 = seq[i]
        if (time.time() - t0) * 1000.0 < ms - TICK_MUNDO * 500.0:
            return ()
        self._replay_i = i + 1
        self.x, self.y, self.z = ox, oy, oz
        return (ox, oy, oz), (dx, dy, dz), f36, est


    def proximo_passo(self):
        """Escolhe um destino curto perto da ancora e devolve (origem, destino, velocidade).

        NPC NAO ANDA. Devolve None e o mundo nao emite passo nenhum pra ele.

        Ate 25/08 o NPC dava passos curtos (0 a 3). Foi corrigido olhando o servidor oficial:
        os NPCs ficam PARADOS no posto. E a lista de spawn deixa claro por que passo curto
        tambem estava errado -- entre os 30 NPCs ha uma "Caixa de correio" e uma "Pedra de
        Retorno", que sao objetos do cenario. Caixa de correio andando de um lado pro outro
        nao e movimentacao sutil, e defeito.

        De quebra sai trafego: 30 NPCs parados nao geram pacote de movimento nenhum, o que
        conta quando a preocupacao e aguentar 100 jogadores.
        """
        if self.id in ENTIDADES_REPLAY:
            # O BONECO DA EXPERIENCIA nao simula nada: repete o fio do oficial.
            return self._passo_de_replay() or None
        if self.tipo == "npc":
            return None
        # PERSEGUICAO ANTES DA TRILHA. Quem esta com odio nao passeia.
        caca = self._passo_de_perseguicao()
        if caca is not None:
            # A tupla vazia quer dizer "esta cacando, mas ainda nao venceu o intervalo".
            # Vira None aqui -- o que o laco entende como "sem pacote nesta volta" -- e nao
            # cai na trilha, porque a trilha so e consultada quando `caca` E None.
            return caca or None
        passo = self._passo_da_trilha()
        if passo is not None:
            return passo
        # QUEM TEM TRILHA PARA AQUI. Nunca cai no andarilho simulado abaixo.
        #
        # Era esta a origem do "chacoalhando" que sobreviveu a correcao da origem do pacote.
        # `_passo_da_trilha` devolve None em duas situacoes muito diferentes: "esta entidade
        # nao tem trilha" e "tem trilha, mas o proximo passo dela ainda nao venceu". O codigo
        # tratava as duas como a mesma coisa e seguia pro simulador, que escreve self.x/self.y
        # com um rumo sorteado dele. O passo seguinte da trilha sobrescrevia a posicao de
        # volta pra base + trilha, e o resultado no fio era A -> B -> A:
        #
        #     t=26,350  (7962,25 , 1879,01)   <- passo do simulador
        #     t=27,025  (7958,15 , 1873,76)   <- trilha puxa de volta, giro de 180 graus
        #
        # Medido na captura do client: 8,1 por cento dos passos com giro acima de 90 graus e
        # o percentil 99 em 180 exatos, contra 0,40 por cento acima de 170 nas trilhas de
        # origem. Ou seja, o tranco nao vinha do dado gravado -- era acrescentado aqui.
        #
        # Sobreviveu tanto tempo porque cada correcao anterior mexia na trilha, e a trilha
        # estava certa.
        # `is not None`, e nao so a verdade do valor: uma trilha VAZIA e um estado com
        # significado proprio.
        #
        #     []    mob sedentario -- FRACAO_SEDENTARIA sorteou que este fica parado
        #     None  nao existe trilha nenhuma no arquivo -- ai sim o simulado serve
        #     lista mob com trajetoria gravada
        #
        # Como `[]` e falso em Python, a primeira versao deste guarda mandava justamente os
        # 31 por cento de sedentarios pro andarilho simulado -- os unicos que deviam ficar
        # quietos eram os unicos andando por conta propria. Medido: zero por cento de mobs
        # parados do nosso lado, contra um quarto do oficial abaixo de 0,5 u/s.
        if getattr(self, "_trilha", None) is not None:
            return None
        # SEM TRILHA: cadencia propria de 0,6 s. O laco acorda a cada 0,15 s, e o avanco daqui
        # e calculado como AVANCO_POR_SEGUNDO * INTERVALO_MUNDO -- sem esta trava o mob
        # simulado andaria quatro vezes mais rapido que o proprio calculo diz.
        agora_sim = time.time()
        if not venceu(agora_sim, getattr(self, "_ultimo_sim", 0.0), INTERVALO_MUNDO):
            return None
        self._ultimo_sim = agora_sim
        origem = (self.x, self.y, self.z)
        # RITMO: anda um trecho, para, DESCANSA alguns segundos, vira, anda de novo.
        #
        # Durante a pausa nao sai pacote nenhum -- devolve None e o mundo pula esta entidade,
        # que e exatamente o que o oficial faz. Ficar mandando "parado" a cada ciclo seria
        # trafego a toa e nao e o que o fio mostra.
        pausa = getattr(self, "_pausa", 0)
        if pausa > 0:
            self._pausa = pausa - 1
            return None
        restam = getattr(self, "_restam", 0)
        if restam <= 0:
            # a parada. Vira agora e devolve um passo de tamanho ZERO -- e assim que o
            # oficial sinaliza "parou": origem igual ao destino, com estado 2.
            # A virada na parada NAO e um sorteio uniforme. Uniforme daria mediana de 90
            # graus e o oficial da 113,9 -- ele evita virar pouco, que faz sentido: virar 10
            # graus depois de parar nao teria por que exigir uma parada.
            #
            # Sorteando a magnitude entre VIRADA_MIN e 180 graus, com sinal aleatorio, a
            # mediana cai onde o oficial esta. O sinal importa: sem ele o mob viraria sempre
            # pro mesmo lado e andaria em circulo.
            anterior = getattr(self, "_ang", None)
            faixa = _sortear("virada")
            if anterior is None or faixa is None:
                self._ang = random.uniform(0, 2 * math.pi)
            else:
                # a tabela guarda a faixa de 20 graus; espalha dentro dela
                giro = math.radians(faixa + random.uniform(0, 20))
                self._ang = anterior + (giro if random.random() < 0.5 else -giro)
            _t = _sortear("trecho")
            self._restam = 5 if _t is None else _t
            # E FICA PARADO. O oficial nao manda pacote nenhum durante a pausa; o proximo
            # movimento so vem numa mediana de 4,2 SEGUNDOS -- sete ciclos, nao um.
            # `or` aqui engoliria um zero legitimo -- foi o que aconteceu no
            # diagnostico de 25/08, com pausa 0 virando 7.
            _p = _sortear("pausa")
            self._pausa = 7 if _p is None else _p
            parado = (self.x, self.y, self.z)
            return origem, parado, self.velocidade, ESTADO_PARADO
        self._restam = restam - 1
        ang = self._ang
        dist = PASSO_MOB
        # O DESTINO do pacote fica a `dist` (23,33), mas a entidade so AVANCA a fracao que
        # cabe no intervalo. Sao duas coisas diferentes -- ver AVANCO_POR_SEGUNDO.
        nx = self.x + math.cos(ang) * dist
        ny = self.y + math.sin(ang) * dist
        avanco = AVANCO_POR_SEGUNDO * INTERVALO_MUNDO
        px = self.x + math.cos(ang) * avanco
        py = self.y + math.sin(ang) * avanco
        # Longe demais da ancora: vira PRA ELA em vez de encolher o passo.
        #
        # Antes o ponto era puxado de volta multiplicando por 0,3, o que quebrava o passo
        # constante -- exatamente o defeito que este conjunto de mudancas veio corrigir. Um
        # mob no limite do raio dava passos minusculos e parecia travado. Virar mantem o
        # passo do mesmo tamanho e ainda o traz de volta.
        if math.hypot(px - self.ancora_x, py - self.ancora_y) > SPAWN_RAIO:
            ang = math.atan2(self.ancora_y - self.y, self.ancora_x - self.x)
            nx = self.x + math.cos(ang) * dist
            ny = self.y + math.sin(ang) * dist
            px = self.x + math.cos(ang) * avanco
            py = self.y + math.sin(ang) * avanco
        self._ang = ang
        # A entidade fica em (px, py) -- o avanco real. O pacote leva (nx, ny), que e o
        # ponto a 23,33 de distancia. Trocar os dois foi o defeito de 25/08.
        self.x, self.y = px, py
        destino = (nx, ny, self.z)
        return origem, destino, self.velocidade, ESTADO_ANDANDO


_proxima_faixa = [BASE_ENTITY_ID]
_faixa_lock = threading.Lock()


def reservar_faixa() -> int:
    """Faixa de ids de mob exclusiva desta conexao.

    Cada jogador tem hoje o proprio bando de aranhas. Sem isto os dois usariam os ids
    9000-9011 e o segundo a entrar chamaria `ENTIDADES.clear()`, apagando as do primeiro --
    que passaria a bater em entidades que o servidor nao conhece mais.

    PENDENTE: o certo num MMO e o mob ser do MUNDO, nao do jogador -- um bando so, que todos
    veem e que morre pra quem matar primeiro. Isso muda o `rodar` de "thread por conexao" pra
    "thread do mundo", e fica pro proximo passo.
    """
    with _faixa_lock:
        base = _proxima_faixa[0]
        _proxima_faixa[0] += NUM_ENTITIES
        return base


_mundo_thread = None
_mundo_lock = threading.Lock()

# GERACAO DO MUNDO. Serve pra encerrar a thread do laco sem matar o processo.
#
# Reiniciar o world_server fecha o socket, e o client fecha o jogo junto. Durante o ajuste do
# movimento dos monstros isso custava, a cada tentativa, fechar o jogo, rearmar o hook e
# entrar de novo -- e o hook so grava RECV se for injetado ANTES do jogo abrir, entao nao dava
# pra pular etapa.
#
# Com a geracao, `parar_mundo()` incrementa o contador, o laco percebe que a geracao dele
# mudou e retorna. O socket que escuta e as conexoes dos jogadores ficam de pe: quem esta no
# mundo continua no mundo.
_GERACAO = [0]


def parar_mundo():
    """Pede pra thread do mundo terminar. Devolve a thread, pra quem quiser esperar."""
    _GERACAO[0] += 1
    return _mundo_thread
_template_appear = None


def _template():
    global _template_appear
    if _template_appear is None:
        with open(TEMPLATE_PATH, "rb") as f:
            _template_appear = f.read()
    return _template_appear


# ATE ONDE O JOGADOR ENXERGA. Entidade mais longe que isto nao e mandada.
#
# MEDIDO, nao escolhido: nos 5 segundos seguintes ao 04 00 de entrada no mundo, o servidor
# oficial mandou 35 entidades e 9.273 bytes de aparicao. Testando raios contra o nosso mapa
# de Berneo, 700 devolve 35 entidades e 9.240 bytes -- as duas contas batem.
#
# Sem isto o mundo inteiro ia de uma vez: 518 entidades e 136.752 bytes, que sao 23 vezes a
# maior rajada que o oficial ja mandou (6.013 bytes). Era esse tamanho de rajada que causava
# a travada de um segundo ao entrar no mundo, corrigida em 25/08 -- e importar o spawn do
# client a traria de volta multiplicada.
#
# No movimento pesa ainda mais: 472 mobs andando a cada 0,6 s dao 3,5 MB/s com 100 jogadores.
# Com o filtro cai pra cerca de 0,25 MB/s.
# MEDIDO no oficial, 909 remocoes (04 03) e 1320 aparicoes (04 02):
#
#     manda 04 03 (remover) : mediana 439, p90 548, max 624
#     manda 04 02 (aparecer): mediana 448, p90 548, max 691
#
# Estava 700, numero meu. Um efeito colateral e que o mob que perseguia e desistia nunca
# saia da vista, entao continuava SELECIONADO no client mesmo depois de voltar ao posto --
# foi o que o jogador reparou. No oficial ele sai do campo de visao durante o regresso e a
# selecao cai sozinha.
#
# 550 fica perto do p90 dos dois lados. Nao ha histerese: aparecer e desaparecer tem
# praticamente a mesma distancia na medicao, entao um valor unico chega.
# RAIO DE VISAO. MEDIDO no oficial: o mob desaparece (04 03) a uma mediana de 480,7 u e
# p95 de 604 u, e 99,94 por cento das posicoes de mob recebidas estao abaixo de 600.
#
# ESTAVA 430, QUE E CURTO DE MAIS, e foi o que produziu o defeito que o jogador
# descreveu como "e como se ele tivesse resetando no meio da perseguicao". Com o raio
# curto e a margem estreita, os monstros vivem em cima da fronteira: saem da vista,
# voltam a entrar, e CADA REENTRADA e um 04 02, que manda o client recriar a entidade
# naquela posicao. Um reset, visto de dentro do jogo.
#
# Medido no nosso fio, 15 minutos: 51 aparicoes e 50 desaparicoes, emparelhadas. Numa
# janela de 13 s a volta de uma perseguicao mandavamos 11 aparicoes contra 1 do oficial.
#
# Isto nao aparecia em nenhuma metrica de movimento -- passo, intervalo, rumo, rectidao,
# altura, buracos -- porque nao E movimento. So apareceu ao listar TUDO o que o client
# recebe numa janela e comparar com o oficial, em vez de escolher que campo comparar.
RAIO_VISAO = 480.0

# Quanto mais longe e preciso ir para SAIR da vista, depois de ja se estar dentro. Ver a
# histerese em atualizar_visao.
#
# 120, para casar com o oficial: entra por volta dos 480 e sai por volta dos 604, o que
# da uma histerese de ~120. Esteve 30, com o argumento de que uma margem larga adia a
# saida do mob que volta ao posto. Adia mesmo, mas o preco era a oscilacao na fronteira
# -- e uma entidade a piscar custa muito mais do que ela ficar visivel uns segundos a
# mais, porque cada reentrada RECRIA o monstro no client.
MARGEM_SAIDA_VISAO = 120.0


def perto(e, x, y, raio=None):
    """A entidade esta no campo de visao de quem esta em (x, y)?

    Usa a posicao ATUAL da entidade, nao a ancora: um mob que perambulou pra longe deve
    sumir da tela, e um que se aproximou deve aparecer.
    """
    if x is None or y is None:
        return True          # sem posicao conhecida, nao filtra -- melhor mandar demais
    r = RAIO_VISAO if raio is None else raio
    return (e.x - x) ** 2 + (e.y - y) ** 2 <= r * r


def ids_visiveis(x, y) -> set:
    """Quais entidades estao no campo de visao de (x, y). So os ids."""
    return {e.id for e in ENTIDADES.values() if e.viva and perto(e, x, y)}


def atualizar_visao(template) -> None:
    """Manda 04 02 do que entrou no campo de visao e 04 03 do que saiu.

    Um por jogador, comparando o conjunto de agora com o que ele ja via. So o que MUDOU vai
    pro fio: parado no mesmo lugar, isto nao manda nada.

    Por que 04 03 pra quem sai: sem ele o client guarda a entidade pra sempre e a tela vai
    enchendo de mob que ficou pra tras -- e o custo volta, so que acumulado.
    """
    import mundo          # tardio, igual ao rodar_mundo: mundo importa entity_sim de volta
    # O CADAVER FICA NA LISTA. Nao se manda 04 03 por morrer -- so por sair de vista.
    #
    # Era `if e.viva`, portanto o morto caia fora do conjunto e no ciclo seguinte entrava em
    # `sairam`: o client recebia 04 03 e apagava a entidade. Como o 04 03 e o hp=0 chegam com
    # poucos milissegundos de diferenca, quem ganhava a corrida mudava de morte para morte --
    # as vezes dava tempo de o corpo se deitar, as vezes a entidade sumia antes. Foi assim que
    # o jogador o descreveu: "tem vez que aparece o char deitando, e tem vez que simplesmente
    # desaparece".
    #
    # O OFICIAL NAO MANDA 04 03 NA MORTE. Medido em 2.096 mortes de entidade:
    #
    #     seguidas de 04 03 (sumir)          44   (2%)
    #     seguidas de 04 02 (renascer)    1.973   (94%)
    #     nem um nem outro                   79   (4%)
    #
    # O corpo fica no sitio e a entidade e reaproveitada pelo 04 02 do renascimento. Os 44 sao
    # o caso normal de o jogador se afastar do cadaver e ele sair de vista -- que continua a
    # acontecer aqui, porque o morto so deixa de contar quando renasce.
    vivos = {e.id: e for e in ENTIDADES.values()
             if e.viva or (e.morta_em and time.time() - e.morta_em < SEGUNDOS_RESPAWN + 5.0)}
    for j in mundo.todos():
        if j.x is None:
            continue
        # HISTERESE. Entra a RAIO_VISAO, mas so sai a RAIO_VISAO + MARGEM.
        #
        # Com um limiar unico, um mob parado na fronteira oscila: a 549 entra, a 551 sai, a
        # 549 entra outra vez. Medido no fio: a entidade 335 deu REMOVE/APARECE tres vezes em
        # 30 s, com 0,9 a 4,7 s entre cada par. Cada APARECE reintroduz a entidade no client
        # e desfaz a desselecao -- foi por isso que o mob continuava selecionado depois de
        # desistir, mesmo com o servidor a mandar o 04 03 correto.
        #
        # A margem so precisa de ser maior que o que o mob anda entre dois ciclos de visao
        # (uns 21 por passo a perseguir). 80 da folga sem atrasar a saida de forma visivel.
        antes = j.visiveis
        agora = set()
        for i, e in vivos.items():
            limite = RAIO_VISAO + MARGEM_SAIDA_VISAO if i in antes else RAIO_VISAO
            if perto(e, j.x, j.y, limite):
                agora.add(i)
        entraram = agora - antes
        sairam = antes - agora
        if not entraram and not sairam:
            continue
        # Aparicao so para quem esta vivo: nao se anuncia um cadaver a quem acabou de chegar.
        # Quem renasce e anunciado pelo transmitir_todos do proprio renascimento.
        quadros = [vivos[i].appear(template) for i in entraram
                   if i in vivos and vivos[i].viva]
        for i in sairam:
            quadros.append(build_disappear(_seq_visao(), i))
        if quadros and j.enviar(renumerar(quadros, j)):
            j.visiveis = agora


def renumerar(quadros, jogador):
    """Numera os pacotes POR LIGACAO, no envio. Devolve os bytes prontos.

    O numero de sequencia esta em [2:6] do payload, big-endian; o payload comeca 4 bytes
    depois do inicio do quadro, portanto o campo vive em [6:10] do quadro.

    PORQUE ISTO EXISTE, e foi o unico campo em que o nosso fio difere do oficial.
    ---------------------------------------------------------------------------
    Numeravamos ao CONSTRUIR, e construimos para as 518 entidades do mundo. Logo a seguir o
    filtro de visao deita fora as que estao longe -- mas os numeros delas ja estavam gastos.
    O jogador recebia pacotes com buracos enormes na sequencia. Medido em 29/08, com o
    oficial e o nosso gravados na MESMA maquina com minutos de diferenca:

        oficial   22 numeros por segundo, +1 entre pacotes seguidos, +21 por entidade
        nosso    465 numeros por segundo, +22 entre pacotes seguidos, +294 por entidade

    Tudo o resto batia certo -- passo 6,67 contra 6,66, declarado 23,33 nos dois, intervalo
    0,615 contra 0,610, altura a variar dos dois lados. Duas noites a medir o movimento e o
    unico campo diferente era este.

    E explica a experiencia que me tinha deixado sem saidas: pos-se uma entidade a repetir,
    byte a byte, uma hora de gravacao do oficial, e ela chacoalhou na mesma. O replay copiava
    posicoes, tempos, z e rumo -- mas o seq era gerado por nos. Tinha mesmo de chacoalhar.
    """
    if not quadros:
        return b""
    n = getattr(jogador, "_seq_saida", 0)
    saida = []
    for q in quadros:
        n += 1
        b = bytearray(q)
        if len(b) >= 10:
            struct.pack_into(">I", b, 6, n & 0xFFFFFFFF)
        saida.append(bytes(b))
    jogador._seq_saida = n
    return b"".join(saida)


_seq_v = [700000]


def _seq_visao():
    _seq_v[0] += 1
    return _seq_v[0]


def aparicoes_atuais(x: float | None = None, y: float | None = None) -> bytes:
    """Os 04 02 dos mobs VIVOS que estao PERTO de (x, y), pra quem acabou de entrar.

    Sem posicao devolve tudo, que era o comportamento antigo -- serve pra quem chama sem
    saber onde o jogador esta, mas nao e o caminho normal.
    """
    try:
        template = _template()
    except OSError:
        return b""
    return b"".join(e.appear(template) for e in ENTIDADES.values()
                    if e.viva and perto(e, x, y))


# ESPIAO DO FIO. Grava TUDO o que sai para os jogadores, nao so o movimento.
#
# O espiao antigo so via os 04 01 do laco, porque estava dentro dele. Para comparar um golpe
# com o do oficial e preciso ver a sequencia inteira -- o oficial manda seis pacotes por
# pancada (05 00, 08 00 x2, 08 02, 08 01, 05 02, 08 06) e nos mandamos tres. E a sequencia,
# nao o conteudo de cada um, que faz a animacao.
#
# Enrola o `enviar` do Jogador porque ha mais de um caminho de envio: o laco do mundo manda
# por ali, e a thread da conexao manda direto pelo socket. Este apanha o primeiro, que e por
# onde vao os golpes de monstro.
ESPIAO_FIO = True

# Registo das mudancas de modo do monstro. Existe porque "o javali atacou-me durante um
# tempo e depois parou" nao tem diagnostico possivel sem ver o estado dele: pode ter esticado
# a corda, perdido o alvo, entrado em pausa de rajada, ou o golpe pode ter deixado de sair.
# Quatro causas diferentes, todas com o mesmo sintoma na tela.
DIARIO_DE_CACA = True
_diario = []


def anotar(ent, texto, log=print):
    if not DIARIO_DE_CACA:
        return
    log("[caca] %s %s (npc %s): %s" % (ent.id, ent.nome or "?", ent.npc_id, texto))
CAMINHO_ESPIAO_FIO = os.path.join(os.path.dirname(TEMPLATE_PATH), "espiao_fio.log")
_fio_buffer = []
_fio_ligado = [False]


def _ligar_espiao_do_fio(log=print):
    if not ESPIAO_FIO:
        return
    import mundo
    # A MARCA VIVE NA FUNCAO, nao num global deste modulo.
    #
    # `_fio_ligado` era um global daqui, e a recarga a quente reconstroi os globais -- entao
    # cada recarga envolvia o `enviar` por cima do que ja estava envolvido. Ao fim de tres
    # recargas cada pacote era gravado tres vezes, e o log mostrava o mesmo golpe em
    # triplicado com 0,00 s entre copias. Nao era o servidor a duplicar nada: era o
    # observador. `mundo` nao e recarregado, entao um atributo posto na funcao sobrevive.
    if getattr(mundo.Jogador.enviar, "_espiado", False):
        return
    original = mundo.Jogador.enviar

    def enviar_espiado(self, frame):
        try:
            for f in separar_frames(frame):
                _fio_buffer.append("%.3f %s %s" % (time.time(), self.nome or "?",
                                                   f[4:].hex(" ")))
        except Exception:                                          # noqa: BLE001
            pass
        return original(self, frame)

    enviar_espiado._espiado = True
    mundo.Jogador.enviar = enviar_espiado
    log("[espiao] a gravar tudo o que sai em %s" % CAMINHO_ESPIAO_FIO)


def _descarregar_fio():
    if not _fio_buffer:
        return
    linhas = _fio_buffer[:]
    del _fio_buffer[:]
    try:
        with open(CAMINHO_ESPIAO_FIO, "a", encoding="utf-8") as fp:
            fp.write(chr(10).join(linhas) + chr(10))
    except OSError:
        pass


_relogio_afinado = False


def afinar_relogio(log=print) -> bool:
    """Poe o processo do mundo em prioridade ALTA. Devolve True se conseguiu.

    O laco do mundo gasta 1 a 3 ms por ciclo e dorme os outros 47. Se esse sono for exacto o
    tick sai a 0,050 s; se derrapar, todos os intervalos medidos com cuidado -- passo de
    perseguicao, pausa, regeneracao -- saem esticados e irregulares.

    A CAUSA e disputa de CPU, nao a resolucao do relogio. Em 28/08 media-se um sleep de
    48,5 ms a errar 39 ms com a maquina cheia e 0,5 ms com ela livre; timeBeginPeriod(1) nao
    mudou nada. Nessa altura corriam nesta VPS de 8 nucleos os dois clientes do jogo
    (Axn.exe a 87 por cento dos 8 nucleos, AMo.exe a 19, fila do processador em 11). Depois
    disso o jogo passou a correr na maquina do jogador, e e essa a correccao de fundo; isto
    aqui e o seguro para quando alguem voltar a encher a VPS.

    QUANTO E QUE A PRIORIDADE ALTA MELHORA: NAO ESTA MEDIDO. Chegou a haver aqui uma tabela
    a dizer que Normal errava 144 ms e Alta 0,5 -- era falsa. A experiencia que a produziu
    chamava SetPriorityClass sem argtypes e as tres medicoes correram todas em Normal, sem
    ninguem dar por isso porque a funcao falha em silencio. Fica por medir de novo.

    O ARGTYPES NAO E DECORACAO. Em Python de 64 bits, GetCurrentProcess() devolve o
    pseudo-handle (HANDLE)-1; sem restype o ctypes trunca-o para 32 bits e a chamada devolve
    ERROR_INVALID_HANDLE (6). Foi assim durante tres arranques, com o log a dizer "nao
    consegui subir a prioridade" e o processo a ficar em Normal.

    Nao ha risco de esfomear a maquina: o mundo usa 3 a 6 por cento de um nucleo e passa o
    resto a dormir, que e quando os outros correm. Tempo-real e que seria perigoso.
    """
    global _relogio_afinado
    if _relogio_afinado:
        return True
    try:
        import ctypes
        k = ctypes.windll.kernel32
        k.GetCurrentProcess.restype = ctypes.c_void_p
        k.SetPriorityClass.argtypes = [ctypes.c_void_p, ctypes.c_uint]
        k.SetPriorityClass.restype = ctypes.c_int
        if k.SetPriorityClass(k.GetCurrentProcess(), 0x00000080):  # HIGH_PRIORITY_CLASS
            _relogio_afinado = True
            log("[mundo] prioridade alta")
            return True
        log("[mundo] SetPriorityClass falhou (erro %d) -- o processo fica em Normal"
            % ctypes.GetLastError())
    except Exception as e:                                            # noqa: BLE001
        log("[mundo] sem prioridade alta: %s" % str(e)[:80])
    return False


def iniciar_mundo(log=print) -> None:
    """Sobe a thread do MUNDO, uma so no processo inteiro.

    Ate 23/08 cada conexao tinha a propria thread e o proprio bando de aranhas, em faixas de
    id separadas: o jogador via o outro personagem andando, mas nao os mobs dele -- e portanto
    nao via o outro lutando. Agora o mob e do mundo: um bando so, que todos veem, e quem matar
    primeiro leva.
    """
    global _mundo_thread
    with _mundo_lock:
        if _mundo_thread is not None and _mundo_thread.is_alive():
            return
        _mundo_thread = threading.Thread(target=rodar_mundo, args=(log,), daemon=True)
        _mundo_thread.start()


def montar_bando(log=print) -> list["Entidade"]:
    """Os mobs do mundo: do banco quando ha spawn cadastrado, senao o bando antigo.

    O fallback existe porque Data/NpcSpawn.ini so se enche com uma captura no oficial -- nenhuma
    tabela do client guarda spawn de monstro. Enquanto ela estiver vazia, o mundo continua
    com as 12 aranhas de sempre em vez de ficar deserto.
    """
    linhas = db.spawns()
    if linhas is None:
        log(f"[mundo] Data/NpcSpawn.ini ausente -- {NUM_ENTITIES} mobs padrao "
            f"(ids {BASE_ENTITY_ID}-{BASE_ENTITY_ID + NUM_ENTITIES - 1})")
        return [Entidade(BASE_ENTITY_ID + i) for i in range(NUM_ENTITIES)]
    if not linhas:
        log("[mundo] Data/NpcSpawn.ini vazia -- mundo sem monstro nenhum, de proposito")
        return []

    bando = []
    for i, sp in enumerate(linhas):
        # ID DA ENTIDADE: o que o CLIENT ja conhece, quando a captura nos deu.
        #
        # O client desenha os NPCs a partir dos dados dele -- eles estavam no mundo mesmo
        # quando o nosso servidor mandava zero entidade. Um 04 02 com id proprio (era
        # BASE_ENTITY_ID + i) nao substitui o que ele desenhou: vira um SEGUNDO NPC em cima
        # do primeiro, que foi o que o jogador viu em 25/08.
        #
        # Os ids do oficial sao fixos: em todas as capturas, 28 dos 29 NPCs sempre com o
        # mesmo numero. Ver a coluna entidade_id do NpcSpawn.ini.
        ent = sp.get("entidade_id") or (BASE_ENTITY_ID + i)
        bando.append(Entidade(
            int(ent),
            npc_id=sp.get("npc_id"),
            nome=sp.get("nome") or "",
            hp_max=sp.get("hp_max") or None,
            ancora=(float(sp["x"]), float(sp["y"]), float(sp["z"])),
            tipo=sp.get("tipo") or "mob",
            raio=sp.get("raio")))
    especies = len({e.npc_id for e in bando})
    quantos_npc = sum(1 for e in bando if e.tipo == "npc")
    proprios = sum(1 for e in bando if e.id >= BASE_ENTITY_ID)
    log(f"[mundo] {len(bando) - quantos_npc} mobs e {quantos_npc} npcs, "
        f"{especies} especies | {len(bando) - proprios} com id do CLIENT, "
        f"{proprios} com id nosso")
    return bando


_caidos = {}


_ultima_regen = {}
_ultimo_combate = {}


def marcar_combate(jogador_id: int) -> None:
    """Carimba o instante em que o jogador levou ou deu pancada.

    A regeneracao so arranca ESPERA_FORA_DE_COMBATE segundos depois deste carimbo.
    """
    if jogador_id:
        _ultimo_combate[int(jogador_id)] = time.time()


# ---------------------------------------------------------------- BUFFS
#
# {entity_id: {skill_id: (acaba_em, nivel, [(nome, valor)])}}
#
# A CADEIA esta em db.buff_da_skill; aqui so se guarda o que esta ligado e quando acaba.
# Medido no fio do oficial em 30/08 (cabandobuf.log): o 05 09 com [11]=1 liga, o mesmo
# pacote com [11]=0 desliga, e entre os dois passaram 1199,99 s contra os 1200 da tabela.
BUFFS = {}
OP_BUFF = bytes([5, 9])
TAM_BUFF = 32


def build_buff(seq: int, entity_id: int, skill_id: int, nivel: int,
               duracao: float, ligar: bool, no_alvo: bool = False) -> bytes:
    """O 05 09, copiado do oficial.

        [2:6]   seq
        [7:11]  entidade BE
        [11]    1 liga, 0 desliga
        [16:18] skill BE u16
        [18]    nivel da skill
        [26:28] duracao em segundos BE u16

    Os bytes [12:14]=00 01 e [19]=03 vao como no oficial e nao estao percebidos. Com uma
    captura de uma skill so nao da para saber o que variam, e copia-los e melhor do que
    inventar-lhes um significado.
    """
    p = bytearray(TAM_BUFF)
    p[0:2] = OP_BUFF
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 7, int(entity_id))
    p[11] = 1 if ligar else 0
    p[13] = 1
    struct.pack_into(">H", p, 16, int(skill_id) & 0xFFFF)
    p[18] = max(1, int(nivel)) & 0xFF
    # DOIS BYTES SEPARAM UM DEBUFF DE UM BUFF, e sem eles o client nao desenha o icone por
    # baixo da vida do bicho. Medido em 30/08 comparando os dois pacotes do oficial:
    #
    #     buff em si proprio (Foco 5002):     [6] = 0   [19] = 3
    #     debuff no alvo    (Corrosao 11002): [6] = 1   [19] = 4
    #
    # Nos mandavamos sempre os do buff. O servidor aplicava o debuff certo -- os tiques
    # caiam no log -- e o jogador nao via nada.
    p[6] = 1 if no_alvo else 0
    p[19] = 4 if no_alvo else 3
    struct.pack_into(">H", p, 26, min(0xFFFF, int(duracao)))
    return encode_frame(bytes(p))


def aplicar_buff(jogador, skill_id: int, nivel: int, seq_de, log=print,
                 pedido: bytes | None = None) -> bool:
    """Liga um buff no proprio jogador. False quando a skill nao e de duracao.

    SO EM SI PROPRIO, e o porque importa: a unica captura que temos e de um buff que o
    jogador lanca sobre ele. Ha skills de duracao que sao DEBUFFS -- a 1002 poe a proteccao
    fisica do alvo a -10, e o jogador confirmou: "tem ataques que contem debuffs tambem".
    Essas exigem saber a que entidade o 05 09 vai, e isso nao esta medido. Ficam registadas
    no log e nao sao aplicadas, em vez de as pôr no alvo errado.
    """
    dur, efeitos = db.buff_da_skill(skill_id, nivel)
    if dur <= 0:
        return False
    if not efeitos:
        log("[buff] skill %d nivel %d dura %gs mas nenhum efeito e de tipo conhecido"
            % (skill_id, nivel, dur))
        return False
    if any(v < 0 for _n, v, _u in efeitos):
        # DEBUFF: nao vai no lancador, vai no ALVO. Trata-se noutro sitio -- ver
        # aplicar_debuff, chamado pelo ramo do 05 01 com o alvo do golpe.
        return False
    # RELANCAR UM BUFF JA ACTIVO DESLIGA-O PRIMEIRO. Medido no fio do oficial (30/08): a
    # renovacao sai como dois 05 09 no mesmo instante -- [11]=0 com o tempo que ainda
    # faltava, e logo a seguir [11]=1 com a duracao cheia. Mandar so o segundo deixaria o
    # client com dois relogios para o mesmo icone.
    # COM PERIODO NAO E BONUS DE FICHA, E TIQUE. A "Regeneracao de mana" (11004) tem
    # duracao 20, periodo 2 e "mana +10": sao cinco tiques de +10 mana, nao +10 de mana
    # MAXIMA durante 20 s. Estava a cair no BUFFS como bonus, e por isso o jogador via a
    # ficha mexer e a mana nao subir -- "quando eu uso nao aumenta os 10", 30/08.
    #
    # Vao para um sitio proprio e NAO para o BUFFS, para o bonus_dos_buffs continuar a
    # somar so o que e mesmo bonus. O anuncio (o icone e o relogio) e o mesmo dos outros.
    periodo = db.periodo_da_skill(skill_id, nivel)
    if periodo > 0:
        agora_p = time.time()
        act_p = PERIODICOS.setdefault(jogador.entity_id, {})
        if int(skill_id) in act_p:
            _f, _nv, _e, _p, _t, _pd = act_p[int(skill_id)]
            jogador.enviar(build_buff(seq_de(), jogador.entity_id, skill_id, _nv,
                                      max(0, _f - agora_p), False, no_alvo=True))
        # O PRIMEIRO TIQUE E NO LANCAMENTO, nao um periodo depois. Medido no oficial em
        # 30/08: a mana do jogador saltou de 114 para 124 no mesmo instante do 05 09 que
        # ligou a skill, e so depois foi a 134 (+2,15 s) e a 140 (travada no maximo).
        # O PEDIDO FICA GUARDADO porque o 05 02 se monta a partir dele -- e sem ele nao
        # havia como mostrar o numero nos tiques seguintes, so no lancamento.
        act_p[int(skill_id)] = (agora_p + dur, int(nivel), efeitos, periodo, agora_p,
                                pedido)
        jogador.enviar(build_buff(seq_de(), jogador.entity_id, skill_id, nivel, dur, True))
        log("[buff] %s ligou a skill %d nivel %d: %d tiques de %s a cada %gs"
            % (jogador.nome, skill_id, nivel, int(dur // periodo), efeitos, periodo))
        return True

    activos = BUFFS.setdefault(jogador.entity_id, {})
    if int(skill_id) in activos:
        _f, _nv, _e = activos[int(skill_id)]
        jogador.enviar(build_buff(seq_de(), jogador.entity_id, skill_id, _nv,
                                  max(0, _f - time.time()), False, no_alvo=True))
    fim = time.time() + dur
    activos[int(skill_id)] = (fim, int(nivel), efeitos)
    # GRAVA O QUE FALTA, nao o instante em que acaba: e assim que a contagem PARA enquanto
    # o jogador esta fora e recomeca de onde ficou. Ver db.guardar_buff e pausar_buffs.
    db.guardar_buff(jogador.entity_id, skill_id, nivel, dur)
    jogador.enviar(build_buff(seq_de(), jogador.entity_id, skill_id, nivel, dur, True))
    log("[buff] %s ligou a skill %d nivel %d por %gs: %s"
        % (jogador.nome, skill_id, nivel, dur, efeitos))
    return True


PERIODICOS = {}
"""{entity_id_do_JOGADOR: {skill_id: (acaba_em, nivel, efeitos, periodo, proximo_tique)}}.

Buffs de quem lanca que agem POR TIQUE em vez de mexerem numa estatistica: a regeneracao
de mana, as pocoes que curam ao longo do tempo, as auras que gastam mana do proprio.
Mesma forma do DEBUFFS de proposito -- e a mesma mecanica, muda so o sinal e quem recebe.

NAO SE GRAVA NO BANCO, ao contrario do BUFFS. Estes duram 20 s e o relogio deles nao
sobrevive a uma saida do mundo; guardar seria persistencia inventada. Se vier a aparecer
um periodico longo, e aqui que isso muda.
"""


def numero_do_periodico(skill_id: int, nivel: int) -> int:
    """O numero que o 05 02 mostra quando um periodico do proprio comeca. 0 se nao ha.

    SINAL TROCADO de proposito: o campo do 05 02 e DANO, portanto uma reposicao vai
    negativa. Medido na 11004 do oficial -- "mana +10" na tabela sai no fio como

        05 02 ... 2a fc 01 | ff ff ff ff | 0d 2c 00 00 | 01 | f6 ff ff ff
                   skill     sem alvo      o proprio    bandeira 1    -10

    e e o unico 05 02 dos 20 segundos: os tiques seguintes nao mostram numero nenhum,
    so a barra mexe pelo 08 01.
    """
    _dur, efeitos = db.buff_da_skill(skill_id, nivel)
    soma = sum(v for n, v, u in efeitos if n in ("saude", "mana") and u == "abs")
    return int(round(-soma))


INTERVALO_DA_FICHA = 3.14
"""De quanto em quanto tempo se reenvia a ficha (08 02).

MEDIDO no oficial, 546 fichas em 1322 s da captura de 30/08 22:49:

    intervalo entre fichas   p10 0,47   MEDIANA 3,14   p90 3,25   max 24,4 s

Nos mandavamos TRES na sessao inteira -- uma na entrada e uma a cada mudanca de
equipamento. Foi a ultima diferenca estrutural que sobrou na caca a velocidade de
movimento: o campo 82 do nosso 08 02 e igual ao dele (71,5 com a bota, 65,0 sem), o 462
tambem, e mesmo assim o client anda a 65.

NAO ESTA PROVADO que seja isto que destrava a velocidade. Esta provado que o oficial faz
assim e nos nao, e e a unica coisa que resta por igualar. Se depois disto o client
continuar a 65, o proximo sitio a olhar e o registo do ITEM equipado, nao a ficha.
"""

_ultima_ficha = {}

ENVIAR_FICHA = [None]
"""Preenchido pelo world_server no arranque. Este modulo nao sabe montar o 08 02."""


def rodar_fichas(enviar_ficha, log=print) -> None:
    """Reenvia a ficha a cada INTERVALO_DA_FICHA, como o oficial. Ver a constante.

    `enviar_ficha(jogador)` e do world_server, que e quem sabe montar o 08 02 -- este
    modulo nao conhece o personagem do banco.
    """
    import mundo
    agora = time.time()
    for j in mundo.todos():
        if getattr(j, "heroi", None) is None:
            continue
        if agora - _ultima_ficha.get(j.entity_id, 0.0) < INTERVALO_DA_FICHA:
            continue
        _ultima_ficha[j.entity_id] = agora
        try:
            enviar_ficha(j)
        except Exception as e:                                      # noqa: BLE001
            log("[sim] reenvio da ficha falhou: %s" % str(e)[:100])


def rodar_periodicos(seq_de, log=print) -> None:
    """Os tiques dos buffs de quem lanca: repoe vida/mana e manda as barras.

    Gemeo do rodar_debuffs, com duas diferencas que importam: o valor vem POSITIVO e o
    pacote vai para o proprio, portanto ha um `08 01` (a ficha) alem do `08 06` (a barra).
    """
    import mundo
    agora = time.time()
    for ent in list(PERIODICOS):
        activos = PERIODICOS.get(ent) or {}
        j = mundo.jogador_por_entidade(ent) if hasattr(mundo, "jogador_por_entidade") else None
        if j is None:
            j = next((x for x in mundo.todos() if x.entity_id == ent), None)
        for sk in list(activos):
            fim, nv, efeitos, periodo, proximo, pedido = activos[sk]
            if fim <= agora:
                activos.pop(sk, None)
                if j is not None:
                    j.enviar(build_buff(seq_de(), ent, sk, nv, 0, False))
                log("[periodico] a skill %d de %d acabou" % (sk, ent))
                continue
            if agora < proximo:
                continue
            activos[sk] = (fim, nv, efeitos, periodo, proximo + periodo, pedido)
            if j is None:
                continue
            h = getattr(j, "heroi", None)
            if h is None or h.hp <= 0:
                continue
            mudou = []
            for nome, valor, unidade in efeitos:
                if nome not in ("saude", "mana"):
                    continue
                teto = h.hp_max if nome == "saude" else getattr(h, "mp_max", 0)
                passo = int(round(teto * valor / 100.0)) if unidade == "%" else int(round(valor))
                if not passo:
                    continue
                if nome == "saude":
                    h.hp = max(0, min(h.hp_max, h.hp + passo))
                else:
                    h.mp = max(0, min(getattr(h, "mp_max", 0), getattr(h, "mp", 0) + passo))
                mudou.append("%s %+d" % (nome, passo))
            if not mudou:
                continue
            # SO A FICHA (08 01). O oficial NAO manda 08 06 nenhum ao proprio durante os
            # 20 s da 11004 -- conferido pacote a pacote na janela 60162..60182. A mana
            # viaja no 08 01, campo [23:27] LE. Mandar tambem o 08 06 seria inventar.
            quadro = build_hero_gage(seq_de(), h)
            if quadro:
                j.enviar(quadro)
            # O NUMERO EM TODOS OS TIQUES -- e isto AFASTA-SE do oficial de proposito.
            # Medido em 30/08: ele manda um unico 05 02, no lancamento, e nos 20 s
            # seguintes so a barra mexe. O jogador pediu a 30/08 que saisse sempre, para
            # se ver cada tique a acontecer. Fica registado que e escolha de jogo, nao
            # imitacao -- se um dia isto parecer estranho no ecra, e por aqui que se volta.
            if pedido:
                numero = numero_do_periodico(sk, nv)
                if numero:
                    quadro_n = build_dano_skill(seq_de(), pedido, numero, ent,
                                                GOLPE_NORMAL)
                    if quadro_n:
                        j.enviar(quadro_n)
            log("[periodico] tique da skill %d em %d: %s (fica %d/%d de mana)"
                % (sk, ent, ", ".join(mudou), getattr(h, "mp", 0), getattr(h, "mp_max", 0)))
        if not activos:
            PERIODICOS.pop(ent, None)


DEBUFFS = {}
"""{entity_id_do_ALVO: {skill_id: (acaba_em, nivel, [(nome, valor)])}}.

SEPARADO DOS BUFFS de proposito: a chave aqui e uma entidade que nao e jogador -- um mob --
e nada disto se grava no banco. Um mob morre e renasce limpo; guardar seria inventar
persistencia que o jogo nao tem.
"""


GOLPE_CONTINUO = 0x04
"""A bandeira do `05 02` que anuncia um efeito continuo.

MEDIDA em 30/08: ao lancar a Corrosao o oficial manda UM `05 02` com `dano=5` e esta
bandeira, e depois so `08 06` a cada tique. O numero que flutua na tela vem dai -- aparece
uma vez, no lancamento, e nao a cada tique.

Estava anotada como "falha" na tabela das bandeiras. Nao e: o golpe tirou vida na mesma.
"""


def aplicar_debuff(alvo_id: int, skill_id: int, nivel: int, seq_de, transmitir,
                   log=print) -> int:
    """Poe um debuff no ALVO e anuncia-o. Devolve quanto vale cada tique, ou 0.

    O ALVO E O DO GOLPE, e isso esta MEDIDO. Em 30/08 o jogador lancou a skill 11002 num
    javali no oficial e a captura mostra:

        05 09  entidade=952  LIGA     skill 11002  nv 1  dur 10 s
        05 09  entidade=952  DESLIGA  skill 11002  nv 1  dur 0
        05 09  entidade=957  LIGA     skill 11002  nv 1  dur 10 s

    A entidade dele era a 11277; as 952 e 957 sao dois javalis. Ou seja o mesmo `05 09` dos
    buffs, com a entidade do ALVO no lugar da do lancador -- era esta a duvida que mantinha
    os debuffs desligados, e nao havia captura nenhuma que a resolvesse ate agora.

    ANUNCIA-SE A TODOS os que estao por perto, nao so a quem lancou: o icone aparece por
    baixo da vida do bicho e quem esta ao lado tambem o ve.
    """
    dur, efeitos = db.buff_da_skill(skill_id, nivel)
    if dur <= 0 or not efeitos:
        return 0
    if not any(v < 0 for _n, v, _u in efeitos):
        return 0                          # e buff de quem lanca, nao debuff
    periodo = db.periodo_da_skill(skill_id, nivel)
    agora = time.time()
    activos = DEBUFFS.setdefault(int(alvo_id), {})
    if int(skill_id) in activos:
        _f, _nv, _e, _p, _t = activos[int(skill_id)]
        transmitir(build_buff(seq_de(), int(alvo_id), skill_id, _nv,
                              max(0, _f - agora), False))
    activos[int(skill_id)] = (agora + dur, int(nivel), efeitos, periodo,
                              agora + periodo if periodo > 0 else 0.0)
    transmitir(build_buff(seq_de(), int(alvo_id), skill_id, nivel, dur, True,
                          no_alvo=True))
    log("[debuff] skill %d nivel %d no alvo %d por %gs: %s"
        % (skill_id, nivel, alvo_id, dur, efeitos))
    # QUANTO VALE CADA TIQUE. Devolve-se para quem chamou poder anunciar o numero -- ver
    # GOLPE_CONTINUO. Zero quando o debuff nao tira vida (so abranda, por exemplo).
    return int(round(-sum(v for n, v, u in efeitos
                          if n == "saude" and u == "abs" and v < 0)))


def debuffs_do_alvo(alvo_id: int) -> dict:
    """{nome interno: soma} dos debuffs a correr no alvo. Vazio quando nao ha."""
    activos = DEBUFFS.get(int(alvo_id))
    if not activos:
        return {}
    agora = time.time()
    soma = {}
    for _s, (fim, _nv, efeitos, _p, _t) in activos.items():
        if fim <= agora:
            continue
        for nome, valor, unidade in efeitos:
            a, pc = soma.get(nome, (0.0, 0.0))
            soma[nome] = (a, pc + valor) if unidade == "%" else (a + valor, pc)
    return soma


def rodar_debuffs(seq_de, transmitir, log=print) -> None:
    """Bate os tiques e desliga os que passaram do tempo. Chamada do ciclo do mundo.

    O TIQUE E O QUE FAZ O DANO CONTINUO. A Corrosao tem duracao 10 e periodo 2 com
    "Saude -5": sao cinco tiques de -5, nao um -5 solto -- o tooltip do client mostra os
    dois campos lado a lado. Periodo zero quer dizer efeito continuo, sem tique, que e o
    caso dos buffs e de metade dos debuffs.
    """
    agora = time.time()
    for alvo in list(DEBUFFS):
        activos = DEBUFFS.get(alvo) or {}
        for sk in list(activos):
            fim, nv, efeitos, periodo, proximo = activos[sk]
            if fim <= agora:
                activos.pop(sk, None)
                transmitir(build_buff(seq_de(), alvo, sk, nv, 0, False, no_alvo=True))
                log("[debuff] a skill %d no alvo %d acabou" % (sk, alvo))
                continue
            if periodo <= 0 or agora < proximo:
                continue
            activos[sk] = (fim, nv, efeitos, periodo, proximo + periodo)
            golpe = int(round(-sum(v for n, v, u in efeitos
                                   if n == "saude" and u == "abs" and v < 0)))
            if golpe <= 0:
                continue
            # SEM ATACANTE no livro da EXP: quem lancou ja levou o credito no golpe que
            # aplicou o debuff, e contar outra vez daria a morte a quem so pos o veneno.
            hp, morreu = aplicar_dano(alvo, golpe)
            transmitir(build_hp(seq_de(), alvo, hp, continuo=True))
            log("[debuff] tique da skill %d no alvo %d: -%d (fica %d)"
                % (sk, alvo, golpe, hp))
        if not activos:
            DEBUFFS.pop(alvo, None)


def pausar_buffs(entity_id: int, log=print) -> int:
    """Congela a contagem dos buffs quando o jogador sai: grava o que FALTA a cada um.

    Sem isto o tempo de parede continuava a correr com ele desligado. Quem lancasse um buff
    de vinte minutos e voltasse no dia seguinte encontrava-o gasto.

    SE O SERVIDOR CAIR sem passar por aqui, fica gravado o que o `aplicar_buff` escreveu, que
    e a duracao cheia -- o jogador ganha o tempo que ja tinha corrido. Erra a favor dele, que
    e o lado certo para errar, e evita ter de escrever no banco a cada volta do ciclo.
    """
    activos = BUFFS.pop(int(entity_id), None)
    if not activos:
        return 0
    agora, n = time.time(), 0
    for skill_id, (fim, nivel, _e) in activos.items():
        resta = int(fim - agora)
        if resta <= 0:
            db.apagar_buff(int(entity_id), skill_id)
            continue
        db.guardar_buff(int(entity_id), skill_id, nivel, resta)
        n += 1
    if n:
        log("[buff] %d buff(s) do %d congelados na saida" % (n, entity_id))
    return n


def repor_buffs(jogador, seq_de, log=print) -> int:
    """Volta a ligar os buffs que estavam a contar quando o jogador saiu.

    Manda um 05 09 por cada, com o tempo QUE FALTA e nao com a duracao cheia -- e isso que
    faz o relogio da tela continuar de onde estava, em vez de recomecar.
    """
    n = 0
    for skill_id, nivel, resta in db.buffs_activos(jogador.entity_id):
        if resta <= 0:
            continue
        _dur, efeitos = db.buff_da_skill(skill_id, nivel)
        BUFFS.setdefault(jogador.entity_id, {})[int(skill_id)] = (
            time.time() + resta, int(nivel), efeitos)
        jogador.enviar(build_buff(seq_de(), jogador.entity_id, skill_id, nivel, resta, True))
        n += 1
    if n:
        log("[buff] %d buff(s) repostos em %s" % (n, jogador.nome))
    return n


# ============================ MONTARIA ============================
#
# MEDIDA no oficial em 30/08 (karos_20260830_1505), com o jogador a montar e a desmontar:
#
#     SEND 05 03  id=5 extra=1 + posicao     comeca a invocar
#        ... 2 s de canalizacao ...
#     SEND 05 04  id=5 + posicao             acaba
#     SEND 03 02  [10]=2                     MONTOU
#     RECV 03 02  [10]=2, entidade em [12:16]
#     RECV 03 16  (vazio), depois 08 02 e 08 01
#
#     SEND 03 02  [10]=0                     DESMONTOU (nao ha 05 03/05 04 a desmontar)
#
# O [10] do 03 02 NAO E UM BOOLEANO. Vale 0 (normal), 1 (arma sacada) e 2 (montado); ate
# 30/08 lia-se `bool(body[10])` e o 2 entrava como "arma sacada".
#
# O 05 03 e o 05 04 ja eram ecoados desde 29/08, portanto a canalizacao nao precisou de
# nada: o que faltava era so o modo 2.
#
# A VELOCIDADE VEM DO itemAttr, indice 8, e SUBSTITUI a base -- nao se soma. Medido: a
# Phoenix diz 90, o jogador tinha o fato cash que da +10% de velocidade, e o 08 02 passou de
# 71,5 (65 x 1,10) para 99,0 (90 x 1,10). Se somasse daria 171,5.
FORCAR_VELOCIDADE = 0.0
"""EXPERIENCIA, desligada. Zero desliga; qualquer outro valor forca a velocidade na ficha.

CORREU EM 30/08 E DEU RESPOSTA: com 200 na ficha, o client andou a 64,4 -- exactamente o
mesmo que com 65 e com 71,5. O campo 82 do 08 02 NAO move o personagem do proprio jogador;
e reflexo, nao causa.

Isso reenquadra o problema todo: no oficial a ficha e a velocidade real batem porque AMBAS
saem do mesmo item, nao porque uma mande na outra. Quem calcula a velocidade do proprio
personagem e o CLIENT, a partir do equipamento que conhece.

Fica aqui, desligada, porque a experiencia e barata de repetir e a conclusao e forte.
"""

VELOCIDADE_BASE = 65.0
"""Velocidade de um personagem a pe. Aparece em 2210 dos 2214 `08 02` oficiais."""

# O 462 NAO E VELOCIDADE. Tinha 65,0 igual ao 82 e por isso pareceu um segundo campo; nas
# capturas do oficial de 30/08 o 82 sobe para 71,5, 99,0, 103,68, 136,66 e 150,33 e o 462
# fica em 65,0 nos 546 quadros. Coincidencia, nao campo. Fica escrito para nao se tentar
# outra vez.

OFF_VELOCIDADE = 82
"""Onde a velocidade de movimento vive no 08 02.

ACHADO em 30/08 comparando a ficha antes e depois de montar: e o UNICO campo que muda. Os
valores que aparecem nas capturas oficiais fecham a conta todos -- 65 (base), 71,5 (65 x
1,10 do fato), 84,5 (65 x 1,30), 90 e 99 (90 x 1,10, montado).

Os dois candidatos que se tinham no 02 05, achados em 28/08, eram falsos: o [1100] vale 65
em todas as capturas e nunca muda, e o [1068] e a MANA. Ver o bloco no world_server.py.
"""

DURACAO_BUFF_DE_EQUIPAMENTO = 86400
"""Quanto tempo se declara num buff que vem de PECA EQUIPADA.

Nao ha valor medido para isto: as unicas amostras do oficial sao pocoes, que trazem a
duracao real (60 s e 20 s). Uma peca equipada nao expira enquanto estiver vestida.

ZERO NAO SERVE, e foi o primeiro palpite: mandou-se 0 em 31/08 e o client ignorou o buff --
a velocidade ficou na base nas tres fases do teste, com os pacotes a chegarem certos. Zero
num campo de duracao le-se como "ja acabou".

Um dia inteiro e escolha minha, nao medicao. Se aparecer captura do oficial com fantasia a
ser equipada e um 05 09 junto, e esse numero que manda.
"""


def build_buff_de_item(seq: int, entity_id: int, item_id: int,
                       ligar: bool, segundos: int = 0, instancia: int = 0) -> bytes:
    """O `05 09` que anuncia um bonus vindo de ITEM. Decifrado em 31/08.

    E ISTO QUE FAZ O CLIENT ANDAR MAIS DEPRESSA, e nao a ficha. Medido numa captura em que o
    jogador tomou duas pocoes de velocidade no oficial:

        67,28 na ficha -> anda 67,2   (n=235)
        97,55          -> anda 97,5   (n=97)
        156,08         -> anda 155,1  (n=58)

    O client segue o campo 82 do `08 02` ao decimo -- mas so depois de receber ESTE pacote.
    Passar 200 na ficha sem ele nao muda nada, e foi o que se mediu em 30/08 sem perceber
    porque. Toda mudanca de velocidade no oficial vem acompanhada de um 05 09, a ligar e a
    desligar.

    O FORMATO, tirado da diferenca entre o "liga" e o "desliga" do mesmo item -- entre os
    dois so mudam DOIS sitios:

        [11]     1 liga, 0 desliga
        [14:18]  identificador da instancia (BE)
        [20:24]  o ITEM, BE u32
        [24:28]  duracao em segundos, BE u32 -- zero quando nao expira

    Confirmado pelos numeros: a "Pocao do vento" (54601) diz 60 s e durou 60,9; o "Combo de
    movimentos" (54813) diz 20 s e durou 20,4.

    OS BONUS MULTIPLICAM, nao somam -- outra coisa que esta captura mostrou:

        67,28 x 1,45 = 97,56   (o +45% da Pocao do vento)
        97,55 x 1,60 = 156,08  (o +60% do Combo, por CIMA do anterior)

    Se somasse dava 67,28 x 2,05 = 137,9, que nao e o que se mediu.

    Os bytes [18:20] valem 01 5e nas duas amostras e nao sei o que sao; ficam como estao.
    """
    p = bytearray(32)
    p[0:2] = b"	"
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 7, int(entity_id) & 0xFFFFFFFF)
    p[11] = 1 if ligar else 0
    p[13] = 1
    struct.pack_into(">I", p, 14, int(instancia) & 0xFFFFFFFF)
    p[18], p[19] = 0x01, 0x5E
    struct.pack_into(">I", p, 20, int(item_id) & 0xFFFFFFFF)
    struct.pack_into(">I", p, 24, max(0, int(segundos)) & 0xFFFFFFFF)
    return encode_frame(bytes(p))


MONTADOS = {}
"""{entity_id: item_id da montaria}. Nao sobrevive ao logout, e o oficial tambem nao:
na captura das 14:46 o jogador fechou o jogo montado e voltou a pe."""


def montar(entity_id: int, item_id: int) -> None:
    MONTADOS[int(entity_id)] = int(item_id)


def desmontar(entity_id: int) -> None:
    MONTADOS.pop(int(entity_id), None)


def esta_montado(entity_id: int) -> bool:
    return int(entity_id) in MONTADOS


VELOCIDADE_ATAQUE_BASE = 0.864
"""Velocidade de ataque de MAOS VAZIAS. Ver BASE_VEL_ATAQUE."""

BASE_VEL_ATAQUE = {100: 0.864, 200: 0.864, 300: 0.872, 400: 0.868,
                   500: 0.870, 600: 0.866, 800: 0.860}
"""Base por classe, DE MAOS VAZIAS -- do bloco 2 dos moldes de ficha (offset 474).

QUEM MANDA E A ARMA. Equipada, a `vel_ataque` dela substitui isto por completo, e e ela que
explica os numeros que as fichas do oficial mostram: uma Paladina de nivel 1 marca 1,10
porque a Espada pequena declara vel_ataque 1,1 -- nao porque a classe 200 valha 1,1.

O BLOCO 1 NAO E A BASE, e cheguei a usa-lo. Ele tem 0,913 / 1,100 / 1,573 / 0,847 / 0,781 /
0,847 / 0,616 nas sete classes, e esses valores sao as ARMAS INICIAIS dos personagens que
foram capturados -- por isso variam tanto. As sete fichas de nivel 1 do oficial batem com
eles, mas batem porque nessas fichas a arma inicial estava equipada.

De maos vazias os sete ficam entre 0,860 e 0,872, que e o que este mapa guarda.

A CONFUSAO DUROU DUAS TROCAS, e vale dizer porque: os dois candidatos explicam as fichas do
oficial igualmente bem enquanto nao se reparar que nelas ha uma arma na mao. O que separa os
dois nao e a tabela -- e olhar o slot da arma antes de chamar um numero de "base"."""


_classe_de = {}


def base_de_ataque_da_classe(entity_id: int) -> float:
    """A base da classe deste personagem. Ver BASE_VEL_ATAQUE.

    A classe fica em cache: isto e chamado a cada 08 01 e nao vale uma ida ao banco de cada
    vez. A classe de um personagem nao muda -- so na promocao, que ainda nao existe aqui.
    """
    import db as _db
    ent = int(entity_id or 0)
    if ent not in _classe_de:
        cl = 0
        try:
            p = _db.personagem_por_entidade(ent) or {}
            cl = int(p.get("classe") or 0)
        except Exception as e:                                      # noqa: BLE001
            print("[sim] base_de_ataque_da_classe: %s" % str(e)[:90])
        _classe_de[ent] = cl
    return BASE_VEL_ATAQUE.get(_classe_de.get(ent, 0), VELOCIDADE_ATAQUE_BASE)


def velocidade_de_ataque(entity_id: int, base: float | None = None) -> float:
    """A que vai no campo 39 do 08 00 e do 08 01 -- e no 94 da ficha.

    ACHADO EM 31/08, e fecha o par com a velocidade de movimento.

    O campo 39 do `08 00` era o unico que ficara por decifrar: variava entre 0,91 e 1,13,
    nao acompanhava a velocidade de movimento, e eu tinha-o deixado cravado em 0,9954 com um
    comentario a dizer "nao se sabe o que e". Era A VELOCIDADE DE ATAQUE.

    Apareceu procurando o VALOR: nas 24 capturas do oficial em que o campo 94 da ficha muda,
    o mesmo numero aparece sempre em tres sitios --

        08 02 offset  94      a ficha (que o client mostra e ignora)
        08 00 offset  39
        08 01 offset  39      <- e por aqui que ele aplica

    O jogador tinha 10 opcoes de "+10% de velocidade de ataque" em tres pecas, a ficha
    declarava 4,40 contra a base 1,10, e o intervalo entre golpes nao mexeu: 1,210 s de
    mediana em catorze golpes. Mesmo sintoma da velocidade de movimento, mesma causa.

    A arma MANDA na base, como na ficha: quem tem arma usa a `vel_ataque` dela; quem nao
    tem fica na base do molde. As percentagens do equipamento entram por cima.
    """
    import db as _db
    if base is None:
        base = base_de_ataque_da_classe(entity_id)
        try:
            equipado = _db.equipamento_do_personagem(int(entity_id))
            arma = equipado.get(_db.SLOT_ARMA)
            if arma and arma.get("vel_ataque"):
                base = float(arma["vel_ataque"])
        except Exception as e:                                      # noqa: BLE001
            print("[sim] velocidade_de_ataque: arma: %s" % str(e)[:90])
    try:
        pct = _db.bonus_do_equipamento(entity_id).get("velocidade_ataque", (0.0, 0.0))[1]
    except Exception as e:                                          # noqa: BLE001
        print("[sim] velocidade_de_ataque: %s" % str(e)[:90])
        pct = 0.0
    return base * (1.0 + pct / 100.0)


def velocidade_de_movimento(entity_id: int) -> float:
    """Velocidade que vai no 08 02: a base (ou a da montaria) vezes as percentagens.

    A montaria SUBSTITUI a base; as percentagens do equipamento entram por cima das duas.
    Os buffs ficam de fora de proposito: o `buff_da_skill` devolve (nome, valor) sem dizer
    se o valor e plano ou percentagem, e para a velocidade essa diferenca muda tudo. Quando
    houver captura de um buff de velocidade, entra aqui.
    """
    import db as _db
    base = VELOCIDADE_BASE
    item = MONTADOS.get(int(entity_id))
    if item:
        v = _db.velocidade_da_montaria(item)
        if v:
            base = float(v)
    try:
        pct = _db.bonus_do_equipamento(entity_id).get("velocidade_movimento", (0.0, 0.0))[1]
    except Exception as e:                                          # noqa: BLE001
        print("[sim] velocidade_de_movimento: %s" % str(e)[:120])
        pct = 0.0
    v = base * (1.0 + pct / 100.0)
    # AS POCOES MULTIPLICAM, uma por uma, e nao se somam entre si nem com o equipamento.
    #
    # MEDIDO no oficial em 31/08, com duas pocoes tomadas em sequencia:
    #     67,28 x 1,45 = 97,56   (Pocao do vento, +45%)   -- medido 97,55
    #     97,55 x 1,60 = 156,08  (Combo, +60%, por CIMA)  -- medido 156,08
    # Somando dava 67,28 x 2,05 = 137,9, que nao e o que se viu. Era assim que estava.
    for _p in pcts_de_itens_activos(entity_id):
        v *= (1.0 + _p / 100.0)
    return v


ENVIAR_BUFF_DE_FANTASIA = False
"""Mandar `05 09` ao equipar uma peca de fantasia. MEDIDO E DESLIGADO em 31/08.

O QUE O TESTE MOSTROU. Com a skill certa no campo [14:18] (60188, 60192, 60196, 60200 para
as quatro pecas do Traje do vencedor) o client PASSOU a desenhar o icone e a contagem --
apareceram quatro icones a marcar 1439:32, que e a duracao de 24 h a correr para tras. Isso
nunca acontecia quando ali ia o numero da instancia, e prova que o campo e mesmo um skill_id
e que o client o resolve.

MAS NAO APLICA NADA. A velocidade do jogador antes e depois, medida no fio:

    16:22:15   p75 65,6      16:23:30   p75 65,8    (pocoes tomadas as 16:23:19 e 16:23:24)

O tecto de 65 nao se mexeu. Ler a skill e desenhar o icone e uma coisa; aplicar o efeito e
outra, e a segunda nao acontece por esta via.

DESLIGA-SE POR DUAS RAZOES, nao uma: nao funciona, E o oficial nao manda `05 09` ao equipar
fantasia -- entao os quatro icones eram lixo que so o nosso servidor produzia. O jogador
viu-os na tela e disse *"ficou la em cima o set, ta errado"*.

O QUE FICA. A descoberta do skill_id continua boa e continua ligada nas POCOES, onde o
oficial de facto manda o pacote e onde temos captura dele para comparar. O bonus da
fantasia continua a entrar na ficha por bonus_da_fantasia, como sempre entrou.

Nao apagar: o caminho esta decifrado e o interruptor documenta o que foi medido. Se algum
dia se achar o que falta para o client APLICAR, e voltar a True."""


BUFFS_DE_ITEM = {}
"""{entity_id: {item_id: (acaba_em, percentagem_de_velocidade)}}.

Pocoes e afins: bonus temporario que vem de USAR um item, nao de o vestir. Separado do
BUFFS (skills) porque a chave e o item e nao a skill, e do bonus_do_equipamento porque
expira sozinho.
"""


def pcts_de_itens_activos(entity_id: int) -> list:
    """As percentagens de velocidade dos itens usados que ainda contam."""
    activos = BUFFS_DE_ITEM.get(int(entity_id))
    if not activos:
        return []
    agora = time.time()
    return [p for _fim, p in
            ((v[0], v[1]) for v in activos.values()) if _fim > agora]


def usar_item_de_bonus(entity_id: int, item_id: int, pct: float, segundos: float) -> None:
    """Liga o buff de uma pocao e GRAVA-O logo, com a duracao cheia.

    Grava-se ja aqui, e nao so ao sair, pela mesma razao do aplicar_buff: se o servidor cair
    sem passar pelo pausar, fica no banco a duracao cheia -- o jogador ganha o tempo que ja
    tinha corrido, que e o lado certo para errar.
    """
    BUFFS_DE_ITEM.setdefault(int(entity_id), {})[int(item_id)] = (
        time.time() + float(segundos), float(pct))
    try:
        db.guardar_buff_item(int(entity_id), int(item_id), float(pct), int(segundos))
    except Exception as e:                                          # noqa: BLE001
        print("[sim] guardar_buff_item falhou: %s" % str(e)[:110])


def pausar_buffs_de_item(entity_id: int, log=print) -> int:
    """Congela os buffs de pocao quando o jogador sai. Gemeo do pausar_buffs.

    O jogador em 31/08: *"qualquer tipo de buff que fica ali, tem que ficar no banco de dados
    na hora de sair do jogo pra voltar o mesmo estado quando logar, igual habilidade de
    buff"*. As habilidades ja o faziam desde 27/08; as pocoes viviam so em memoria e
    morriam com a sessao.
    """
    activos = BUFFS_DE_ITEM.pop(int(entity_id), None)
    if not activos:
        return 0
    agora, n = time.time(), 0
    for item_id, (fim, pct) in activos.items():
        resta = int(fim - agora)
        if resta <= 0:
            db.apagar_buff_item(int(entity_id), int(item_id))
            continue
        db.guardar_buff_item(int(entity_id), int(item_id), float(pct), resta)
        n += 1
    if n:
        log("[pocao] %d buff(s) de item pausados em %d" % (n, entity_id))
    return n


def repor_buffs_de_item(jogador, seq_de, log=print) -> int:
    """Volta a ligar as pocoes que estavam a contar. Gemeo do repor_buffs.

    Manda o `05 09` com o tempo QUE FALTA, e com o ID DA SKILL do item -- ver
    db.skill_do_item: e ele que faz o client desenhar o icone e a contagem.
    """
    n = 0
    for item_id, pct, resta in db.buffs_item_activos(jogador.entity_id):
        if resta <= 0:
            continue
        BUFFS_DE_ITEM.setdefault(jogador.entity_id, {})[int(item_id)] = (
            time.time() + resta, float(pct))
        jogador.enviar(build_buff_de_item(
            seq_de(), jogador.entity_id, int(item_id), True, resta,
            db.skill_do_item(int(item_id))))
        n += 1
    if n:
        log("[pocao] %d buff(s) de item repostos em %s" % (n, jogador.nome))
    return n


def rodar_buffs_de_item(seq_de, enviar_ficha, log=print) -> None:
    """Apaga os que expiraram e manda a ficha nova. Chamada do ciclo do mundo."""
    import mundo
    agora = time.time()
    for ent in list(BUFFS_DE_ITEM):
        activos = BUFFS_DE_ITEM.get(ent) or {}
        acabou = [i for i, (fim, _p) in activos.items() if fim <= agora]
        for i in acabou:
            activos.pop(i, None)
            try:
                db.apagar_buff_item(int(ent), int(i))
            except Exception as e:                                  # noqa: BLE001
                log("[sim] apagar_buff_item falhou: %s" % str(e)[:90])
        if not acabou:
            continue
        j = next((x for x in mundo.todos() if x.entity_id == ent), None)
        if j is not None:
            for i in acabou:
                j.enviar(build_buff_de_item(seq_de(), ent, i, False, 0, 0))
            try:
                enviar_ficha(j)
            except Exception as e:                                  # noqa: BLE001
                log("[sim] ficha pos-pocao falhou: %s" % str(e)[:90])
        log("[pocao] acabou em %d: %s" % (ent, acabou))
        if not activos:
            BUFFS_DE_ITEM.pop(ent, None)


def bonus_dos_buffs(entity_id: int) -> dict:
    """{nome_interno: (absoluto, percentual)} dos buffs activos.

    MESMA FORMA do db.bonus_do_equipamento, para se poderem somar sem conversao. Passou a
    trazer a percentagem em 30/08: ate ai perdia-se, e com ela 1400 das 3739 linhas de
    efeito da tabela -- ver db.buff_da_skill.
    """
    activos = BUFFS.get(int(entity_id))
    if not activos:
        return {}
    agora = time.time()
    soma = {}
    for _s, (fim, _nv, efeitos) in activos.items():
        if fim <= agora:
            continue
        for nome, valor, unidade in efeitos:
            a, pc = soma.get(nome, (0.0, 0.0))
            soma[nome] = (a, pc + valor) if unidade == "%" else (a + valor, pc)
    return soma


def rodar_buffs(seq_de, log=print) -> None:
    """Desliga os que passaram do tempo. Chamada do ciclo do mundo."""
    import mundo
    agora = time.time()
    for j in mundo.todos():
        activos = BUFFS.get(j.entity_id)
        if not activos:
            continue
        for s in [s for s, (fim, _nv, _e) in activos.items() if fim <= agora]:
            _fim, nv, _e = activos.pop(s)
            db.apagar_buff(j.entity_id, s)
            j.enviar(build_buff(seq_de(), j.entity_id, s, nv, 0, False))
            log("[buff] a skill %d de %s acabou" % (s, j.nome))


def sair_de_combate(jogador_id):
    """A arma foi guardada: o modo de combate acabou e a regeneracao arranca.

    MEDIDO em 22 amostras do oficial: o primeiro tique cai entre 5,1 e 5,4 s depois do 03 02
    que guarda a arma, ou seja um intervalo certo. Por isso aqui nao se antecipa nada -- so
    se limpa o carimbo de combate e se acerta o relogio da regeneracao para que o proximo
    tique caia um intervalo a frente.
    """
    jid = int(jogador_id)
    _ultimo_combate.pop(jid, None)
    _ultima_regen[jid] = time.time()


def regenerar(seq_de, log=print):
    """Regenera HP e MP a cada REGEN_INTERVALO, em quem esta vivo.

    O MP FALTAVA POR COMPLETO, e o corte no topo escondia-o duas vezes: a condicao de saida
    era `h.hp >= h.hp_max`, portanto com a vida cheia o ciclo nem chegava ao resto. Mesmo
    depois de acrescentar o MP, sem mexer nesta linha ele so regeneraria com o HP a faltar.
    """
    import mundo
    agora = time.time()
    for j in mundo.todos():
        h = getattr(j, "heroi", None)
        if h is None or h.hp <= 0:
            continue
        falta_hp = h.hp < h.hp_max
        falta_mp = getattr(h, "mp", 0) < getattr(h, "mp_max", 0)
        if not (falta_hp or falta_mp):
            continue
        if agora - _ultimo_combate.get(j.entity_id, 0.0) < ESPERA_FORA_DE_COMBATE:
            continue                     # ainda em combate, ou acabou de sair dele
        if agora - _ultima_regen.get(j.entity_id, 0.0) < REGEN_INTERVALO:
            continue
        _ultima_regen[j.entity_id] = agora
        # "Regeneracao de HP" (tipo 30) e "do MP" (31) somam-se ao passo. Vem sempre em
        # valor absoluto nos dados -- 8 linhas cada, nenhuma em percentagem.
        extra = extra_mp = 0
        try:
            _b = db.bonus_do_equipamento(h.id)
            extra = int(_b.get("regen_hp", (0.0, 0.0))[0])
            extra_mp = int(_b.get("regen_mp", (0.0, 0.0))[0])
        except Exception as e:                                     # noqa: BLE001
            print(f"[sim] bonus de regeneracao falhou: {str(e)[:120]}")
        try:
            passo = db.regen_hp(h.hp_max)
        except Exception as e:                                     # noqa: BLE001
            print(f"[sim] db.regen_hp falhou: {str(e)[:120]}")
            passo = REGEN_HP
        if falta_hp:
            h.hp = min(h.hp_max, h.hp + passo + extra)
        # O MP CORRE NO MESMO TIQUE. Medido em 29/08 no personagem de nivel 68 do oficial:
        # +144 de MP em 64,4 s e outra vez +144 em 64,8 s, que a 5,35 s por tique da 12,0
        # exactos nas duas. Nao ha relogio proprio para o MP.
        if falta_mp:
            try:
                passo_mp = db.regen_mp(h.mp_max)
            except Exception as e:                                 # noqa: BLE001
                print(f"[sim] db.regen_mp falhou: {str(e)[:120]}")
                passo_mp = 3
            h.mp = min(h.mp_max, h.mp + passo_mp + extra_mp)
        quadro = build_hero_gage(seq_de(), h)
        if quadro:
            j.enviar(quadro)
        # E TAMBEM PELO 08 06, que e por onde o OFICIAL manda a regeneracao.
        #
        # Medido em 28/08: no oficial os tiques de regeneracao chegam pela barra de ENTIDADE
        # (08 06) com o id do proprio jogador, e nao pela barra do heroi. Nos mandavamos so o
        # 08 01, e o client -- que liga o efeito visual da regeneracao a este pacote -- ficava
        # sem o aviso de que ela tinha acabado.
        #
        # O sintoma foi o jogador a descrever com precisao: "quando eu ataco o mob a aura
        # desliga, mas depois que o hp enche ela nao desliga, so quando ataco de novo". A aura
        # ligava por um caminho e so desligava por outro.
        # TAMBEM QUANDO SO O MP SOBE. Esteve travado em `if falta_hp` por causa da aura da
        # regeneracao, que ficava acesa com a vida ja cheia. O corte estava no sitio errado:
        # o oficial MANDA o 08 06 com o HP parado e so o MP a mexer -- na entidade 11277,
        # quatro vezes seguidas com o HP cravado em 296 e o MP a subir 125 -> 128 -> 130.
        #
        # O que provavelmente causava a aura presa era o defeito do campo [19:23] (ver a
        # seccao 52): como la ia o HP maximo em vez do MP, um tique so de mana saia com os
        # dois numeros iguais ao anterior -- um pacote que nao anunciava mudanca nenhuma.
        # Isto ultimo e explicacao, nao medicao: se a aura voltar a prender-se, e aqui.
        if falta_hp or falta_mp:
            barra = build_hp(seq_de(), h.id, h.hp, mp=h.mp)
            if barra:
                j.enviar(barra)
                mundo.transmitir(j.entity_id, barra)


def marcar_caidos():
    """So solta o odio de quem esta a bater em alguem que caiu. NAO ressuscita.

    Cheguei a por aqui um renascer automatico ao fim de 5 s. Estava errado, e a captura da
    morte no oficial mostrou porque: quem conduz a ressurreicao e o CLIENT.

        t=0,00    08 01        HP 0/332 -- caiu
        (dialogo no client: "Voce retornara ao ponto de ressurreicao", 2 minutos)
        t=16,90   SEND 04 00   o client manda A POSICAO DO PONTO DE RESSURREICAO
        t=17,15   05 04        o servidor responde com a posicao onde ele CAIU
        t=17,15   08 01        HP 66/332 e MP 28/140 -- 20 por cento exatos nos dois

    E o mesmo desenho do /esc: a contagem e do client, o servidor so recebe o resultado. E
    o client ainda diz PARA ONDE vai, o que dispensa a tabela de pedras de retorno.
    """
    import mundo
    for j in mundo.todos():
        h = getattr(j, "heroi", None)
        if h is None:
            continue
        if h.hp > 0:
            _caidos.pop(j.entity_id, None)
            continue
        if j.entity_id in _caidos:
            continue
        _caidos[j.entity_id] = time.time()
        for e in ENTIDADES.values():
            if getattr(e, "_alvo_id", 0) == j.entity_id:
                e._alvo_id = 0
                e._modo = "voltando"


_seq_res = [800000]


def proxima_seq_ressurreicao():
    """Faixa propria de sequencia. A do mundo e da thread do laco, esta e das conexoes."""
    _seq_res[0] += 2
    return _seq_res[0]


def build_ressurreicao(seq: int, jogador_id: int, pos_da_queda) -> bytes:
    """05 04 -- a resposta ao 04 00 de quem morreu. Leva onde o corpo ficou."""
    p = bytearray(31)
    p[0:2] = bytes([5, 4])
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 7, jogador_id)
    p[11:15] = bytes([0xFF, 0xFF, 0xFF, 0xFF])
    struct.pack_into("<3f", p, 15, *pos_da_queda)
    return encode_frame(bytes(p))


def _tirar_hp_do_jogador(jogador_id, dano, seq, log=print):
    """Desconta o HP e manda o 08 01 SO pra quem levou.

    O 05 02 ja diz o dano e o client desenha o numero vermelho, mas quem manda na barra de
    vida e o 08 01 -- sem ele o numero aparece e a barra fica cheia.
    """
    import mundo
    j = mundo.jogador(jogador_id)
    if j is None or j.heroi is None:
        return
    if time.time() < _carencia.get(jogador_id, 0.0):
        return                      # acabou de levantar
    h = j.heroi
    # dano 0 e uma FALHA e tem de continuar 0 -- um max(1, ...) aqui transformava toda a
    # esquiva num arranhao de 1 ponto
    sofrido = max(0, int(dano))
    if sofrido == 0:
        return
    h.hp = max(0, h.hp - sofrido)
    marcar_combate(jogador_id)
    gravar_estado_com_folga(h, urgente=(h.hp == 0))
    quadro = build_hero_gage(seq, h)
    if quadro:
        j.enviar(quadro)
    if h.hp == 0:
        perdida = cobrar_exp_da_morte(h)
        log(f"[mundo] {j.nome} caiu -- morto por monstro"
            + (f", perdeu {perdida} de EXP" if perdida else ""))
        quadro = build_hero_gage(seq, h)
        if quadro:
            j.enviar(quadro)


# O BONECO DE REPLAY: ligado ou desligado. Ver tools/extrair_replay.py para o que ele
# serve. Com False, o monstro que estivesse marcado volta a comportar-se normalmente --
# nada mais muda, e a ferramenta e os dados ficam prontos para a proxima vez.
#
# NAO E CODIGO MORTO: foi esta experiencia que separou "o defeito esta nos nossos
# pacotes" de "o defeito esta na entrega", em 29/08, quando todas as metricas de
# movimento ja batiam certo e o jogador continuava a ver o bicho a chacoalhar.
REPLAY_LIGADO = True


def _armar_replay(entidades, log=print):
    """Marca o monstro mais proximo do inicio da gravacao para repetir o fio do oficial.

    NAO SE CRIA UMA ENTIDADE NOVA. Os ids dos monstros sao os que o CLIENT ja conhece; um id
    nosso faz nascer um segundo NPC por cima do primeiro, defeito que ja se viu em 25/08.
    Marca-se um que ja existe, e ele passa a repetir em vez de simular.
    """
    ENTIDADES_REPLAY.clear()
    if not REPLAY_LIGADO:
        return
    seq = _carregar_replay()
    if not seq or not entidades:
        return
    ax, ay = seq[0][2], seq[0][3]
    alvo = min(entidades, key=lambda e: math.hypot(e.x - ax, e.y - ay))
    d = math.hypot(alvo.x - ax, alvo.y - ay)
    ENTIDADES_REPLAY.add(alvo.id)
    alvo.x, alvo.y, alvo.z = seq[0][2], seq[0][3], seq[0][4]
    log("[replay] entidade %d (%s) repete %d pacotes do oficial em (%.0f, %.0f); "
        "estava a %.0f u dali" % (alvo.id, alvo.nome or "mob", len(seq), ax, ay, d))


def rodar_mundo(log=print):
    """Thread do mundo: spawna os mobs uma vez e transmite o movimento deles pra todo mundo.

    O client espera trafego constante de entidades depois de entrar -- sem isso ele derruba a
    conexao em ~14s (ver docs/findings.md). Por isso o laco continua rodando mesmo sem
    ninguem: quem entra recebe as aparicoes por `aparicoes_atuais()` e ja pega o fluxo.
    """
    import mundo
    try:
        template = _template()
    except OSError as e:
        log(f"[mundo] nao consegui ler o template de Appear: {e}")
        return

    afinar_relogio(log)
    # DIZ O QUE CARREGOU. Em 28/08 mudou-se PASSO_PERSEGUICAO, reiniciou-se o servidor e
    # anunciou-se a correccao ao jogador -- mas o processo tinha arrancado ANTES de o
    # ficheiro ser gravado e continuou com o valor velho. O ficheiro estava certo, o
    # servidor estava errado, e nada no arranque permitia notar a diferenca. Agora permite.
    log("[mundo] constantes do movimento: passeio %.2f/%.2f  perseguicao %.2f/%.2f @ %.3fs"
        % (PASSO_MOB, DECL_MOB if "DECL_MOB" in globals() else 23.33,
           PASSO_PERSEGUICAO, DECL_PERSEGUICAO, INTERVALO_PERSEGUICAO))
    log("[mundo] modo esperto: %s (odio %s, antecipacao %.2f s, foge abaixo de %.0f%%)"
        % ("LIGADO" if IA_ESPERTA else "desligado", "sim" if IA_ODIO else "nao",
           IA_ANTECIPACAO, IA_FUGIR_ABAIXO_DE * 100))
    _ligar_espiao_do_fio(log)
    entidades = montar_bando(log)
    _armar_replay(entidades, log)
    # SUBSTITUI, nao acumula.
    #
    # Era `ENTIDADES.update(...)`, que junta a geracao nova a velha em vez de a trocar. Numa
    # recarga a quente isso deixa as duas a coexistir: as entidades antigas continuam no
    # dicionario, continuam a ser percorridas pelo laco do mundo e continuam a andar -- so
    # que sem nenhuma das correcoes que motivaram a recarga.
    #
    # Foi o que fez parecer que quatro correcoes seguidas ao raio de perambulacao nao tinham
    # efeito nenhum: elas estavam certas, mas quem estava a mandar os pacotes eram os bichos
    # velhos, criados antes delas existirem.
    ENTIDADES.clear()
    ENTIDADES.update({e.id: e for e in entidades})
    # O CHAO tambem e da geracao velha. Os sacos guardam em `vistos` ids de jogadores e o
    # client ja nao tem nenhum deles desenhado depois da recarga: deixa-los ficar dava sacos
    # invisiveis mas apanhaveis, que e pior do que sacos nenhuns.
    limpar_sacos()
    for e in entidades:
        e.appear(template)                # registra o HP maximo
        if not e.hp_max_npc:
            e.hp = hp_max_de(e.id)

    seq = 5000
    minha_geracao = _GERACAO[0]
    while True:
        if _GERACAO[0] != minha_geracao:
            log("[mundo] geracao %d encerrada -- recarga pedida" % minha_geracao)
            return
        agora = time.time()
        lote = []
        for e in entidades:
            if e.tipo == "npc" and not e.viva:
                # NPC nao devia poder morrer. Se chegou aqui, algo o matou -- devolve na
                # hora em vez de deixar o instrutor sumir do mapa ate o respawn.
                e.viva = True
                e.hp = e.hp_max_npc or hp_max_de(e.id)
                e.morta_em = 0.0
                HP_EXTERNO[e.id] = e.hp
                esquecer_dano(e.id)
                e.esquecer_caca()      # vida nova, odio zerado tambem
                mundo.transmitir_todos(e.appear(template))
                log(f"[mundo] npc {e.id} ({e.nome or 'npc'}) voltou -- npc nao morre")
            if not e.viva:
                if e.morta_em and agora - e.morta_em >= respawn_de(e.npc_id):
                    e.viva = True
                    # volta pra ancora: renascer onde morreu ia empurrando o bando pra
                    # longe do ponto de spawn a cada ciclo.
                    if nasce_ao_acaso(e.npc_id):
                        # A ANCORA MUDA, nao so a posicao: e ela que a trilha e a corda
                        # usam. Sem isto a Fada nascia num sitio e passeava a volta do
                        # poste antigo, atravessando o mapa para la chegar.
                        nova = sortear_ancora(e.npc_id, (e.ancora_x, e.ancora_y))
                        e.ancora_x, e.ancora_y = nova
                        # NAO HA `ancora_z`: a Entidade so guarda x e y da ancora, e o z
                        # sai do mapa de alturas a cada passo. Escrever um atributo que nao
                        # existe passava na leitura e rebentava no primeiro renascimento.
                        e._trilha = None       # trilha nova, coleira de altura recalculada
                        log("[mundo] %s renasceu noutro sitio: (%.0f, %.0f)"
                            % (e.nome or e.npc_id, nova[0], nova[1]))
                    e.x, e.y = e.ancora_x, e.ancora_y
                    e.z = altura_em(e.x, e.y, e.z)
                    e.hp = e.hp_max_npc or hp_max_de(e.id)
                    e.morta_em = 0.0
                    HP_EXTERNO[e.id] = e.hp
                    esquecer_dano(e.id)      # vida nova, dano zerado
                    e.esquecer_caca()      # vida nova, odio zerado tambem
                    # o oficial manda um 08 06 com hp=0 imediatamente antes do 04 02
                    seq_r = _seq_visao()
                    mundo.transmitir_todos(build_hp(seq_r, e.id, 0))
                    mundo.transmitir_todos(e.appear(template))
                    log(f"[mundo] mob {e.id} ({e.nome or 'mob'}) renasceu")
                continue
            # O ALERTA sai pelo mesmo caminho do movimento -- entra no lote e o filtro de
            # visao la embaixo decide quem ve. Nao adianta avisar quem esta longe demais pra
            # ver o triangulo.
            fila = getattr(e, "_saida", None)
            if fila:
                for item in fila:
                    seq += 1
                    if item[0] == "alerta":
                        lote.append((e, build_alerta(seq, e.id, item[1])))
                    elif item[0] == "golpe":
                        lote.append((e, build_golpe(seq, e.id, item[1],
                                                    (e.x, e.y, e.z), item[2],
                                                    rot_atacante=item[3])))
                    elif item[0] == "dano":
                        _, alvo_id, dano, pos, bandeira = item
                        # rot_alvo: para onde o JOGADOR fica virado ao levar a pancada.
                        # A chamada nunca a passava, entao ia 0,0 -- que aponta para sul.
                        # O mesmo defeito do 05 02 do mob, do outro lado da luta.
                        lote.append((e, build_dano_no_jogador(
                            seq, e.id, alvo_id, dano, pos,
                            rot_alvo=rumo_do_jogo(e.x - pos[0], e.y - pos[1]),
                            bandeira=bandeira)))
                        seq += 1
                        _tirar_hp_do_jogador(alvo_id, dano, seq, log)
                del fila[:]
            # ATE MAX_PASSOS_POR_TICK passos por volta, e nao um so.
            #
            # Um por volta e o que deixava a rajada esticada: quando varios passos da trilha
            # vencem juntos -- e 5 por cento deles foram gravados com 0 ms de diferenca do
            # anterior -- sair um por tick transforma a rajada do oficial numa fila lenta, e
            # o atraso vai se acumulando pelo resto da trilha (chegava a 4,9 s medido).
            # Emitir os atrasados de uma vez e o que o oficial de fato fez.
            #
            # Nao dispara sozinho: cada chamada so devolve passo se a hora dele ja venceu, e
            # o caminho simulado tem trava propria de 0,6 s. Sem passo vencido, sai na
            # primeira volta.
            for _ in range(MAX_PASSOS_POR_TICK):
                passo = e.proximo_passo()
                if passo is None:  # NPC nao anda, ou nao venceu passo nenhum ainda
                    break
                origem, destino, vel, estado = passo
                seq += 1
                quadro = build_move(seq, e.id, origem, destino, vel, estado)
                lote.append((e, quadro))
                # GRAVA o que mandamos de UM mob, pra comparar com a trilha original dele.
                # E o teste que faltava: se o nosso pacote for igual ao do oficial e a tela
                # mostrar coisas diferentes, o problema esta em algo que ele manda e nos nao.
                # -1 grava TODAS as entidades; um id grava so aquela.
                #
                # O "todas" existe porque o hook do client morre junto com o AMo.exe, e sem
                # ele nao da pra medir o giro do lado de la. Do nosso lado a medida e a
                # mesma coisa: o giro sai da sequencia de posicoes de cada entidade, que e
                # exatamente o que vai no fio.
                if ESPIAO_ENTIDADE and (ESPIAO_ENTIDADE == -1 or e.id == ESPIAO_ENTIDADE):
                    _espiao_buffer.append("%.3f %s" % (time.time(), quadro[4:].hex(" ")))
        if lote and mundo.quantos():
            # POR JOGADOR, nao pra todos. Cada um recebe so o movimento do que esta no campo
            # de visao dele.
            #
            # Sem isto sao 472 mobs andando a cada 0,6 s mandados pra todo mundo: 36 KB por
            # segundo por jogador, 3,5 MB/s com cem. Com o filtro fica em torno de 0,25 MB/s,
            # porque cada um so ve umas 35 entidades.
            #
            # O custo aqui e uma varredura de 472 entidades por jogador a cada ciclo. Com 100
            # jogadores sao 47 mil comparacoes de distancia a cada 0,6 s, o que e barato
            # perto de 3,5 MB/s de rede -- mas e o proximo lugar a olhar se faltar folego, e
            # a saida seria dividir o mapa em setores em vez de comparar com todos.
            for j in mundo.todos():
                seus = [q for e, q in lote if perto(e, j.x, j.y)]
                if seus:
                    j.enviar(renumerar(seus, j))
        # CAMPO DE VISAO DINAMICO. Roda todo ciclo, ande o jogador ou nao -- o mob tambem
        # se aproxima sozinho.
        #
        # Isto faltava quando o filtro de distancia entrou, em 25/08: a aparicao era mandada
        # so na ENTRADA, entao o jogador via as 42 entidades ao redor do ponto de nascimento
        # e mais nada pelo resto da sessao, por mais que andasse. O mundo tem 518.
        if mundo.quantos():
            marcar_caidos()
            def _seq_regen():
                nonlocal seq
                seq += 1
                return seq
            regenerar(_seq_regen, log)
            # O CHAO. Fica dentro do mesmo `if`: sem ninguem no mundo nao ha quem apanhe
            # nem quem veja, e os sacos que houver expiram na mesma quando alguem entrar.
            rodar_sacos(_seq_regen, log)
            rodar_buffs(_seq_regen, log)      # desliga os que passaram do tempo
            # DEBUFFS: batem o tique de dano continuo e desligam ao fim do tempo.
            # Transmitem-se a TODOS, porque o icone aparece por baixo da vida do
            # bicho e quem esta ao lado tambem o ve.
            rodar_debuffs(_seq_regen, mundo.transmitir_todos, log)
            # PERIODICOS DE QUEM LANCA: a regeneracao de mana e afins. Separados dos
            # debuffs porque o pacote vai para o proprio e leva tambem a ficha.
            rodar_periodicos(_seq_regen, log)
            if ENVIAR_FICHA[0] is not None:
                rodar_buffs_de_item(_seq_regen, ENVIAR_FICHA[0], log)
            # A FICHA DE 3 EM 3 SEGUNDOS, como o oficial -- ver INTERVALO_DA_FICHA.
            if ENVIAR_FICHA[0] is not None:
                rodar_fichas(ENVIAR_FICHA[0], log)
        globals()["_tick"] = globals().get("_tick", 0) + 1
        if mundo.quantos() and globals()["_tick"] % TICKS_POR_VISAO == 0:
            atualizar_visao(template)
        # MEDE O CICLO. Se ele nao durar 0,6 s de verdade, o client anda mais do que a gente
        # avanca e e puxado de volta a cada pacote -- que aparece como o modelo girando.
        agora_t = time.time()
        anterior_t = globals().get("_ultimo_ciclo")
        globals()["_ultimo_ciclo"] = agora_t
        if anterior_t is not None and mundo.quantos():
            real = agora_t - anterior_t
            globals().setdefault("_ciclos", []).append(real)
            cs = globals()["_ciclos"]
            if len(cs) % 25 == 0:
                cs2 = sorted(cs[-100:])
                log("[mundo] tick real: mediana %.3f s, min %.3f, max %.3f (alvo %.3f)"
                    % (cs2[len(cs2)//2], cs2[0], cs2[-1], TICK_MUNDO))
        # DORME O QUE FALTA, nao o tick inteiro. O corpo do laco leva uns 38 ms com 518
        # entidades, e dormir 0,150 em cima disso dava 0,188 de periodo real -- 25 por cento
        # mais lento que o alvo, que e exatamente o tipo de erro de relogio que esta serie de
        # correcoes veio consertar.
        if ESPIAO_ENTIDADE:
            _espiao_descarregar()
        if ESPIAO_FIO:
            # ONDE ESTA O JOGADOR. Sem isto o espiao grava o mob a mexer-se mas nao da para
            # saber se ele PERSEGUE ou se ja esta colado a combater -- e as duas coisas dao
            # pacotes de estado 1 com passos parecidos. Em 28/08 mediram-se passos de 18,67
            # separados por 4,4 s e concluiu-se "a cadencia esta oito vezes lenta"; podia ser
            # apenas o monstro a dar um passo de cada vez que o jogador se afastava um pouco,
            # sem maneira de distinguir. Uma linha por segundo chega e nao pesa.
            # CINCO VEZES POR SEGUNDO, nao uma.
            #
            # A 1 Hz, com o jogador a 65 u/s, a posicao usada nas medicoes erra ate 65
            # unidades. Em 29/08 isso fez a distancia mob-jogador aparecer a saltar
            # 47 -> 91 -> 112 -> 33 em pacotes de meio segundo, e eu quase concluia que
            # o monstro teleportava. Era ruido do instrumento. A 5 Hz o erro cai para 13.
            if globals().get("_tick", 0) % 4 == 0:
                for _j in mundo.todos():
                    if _j.x is not None:
                        _p = _j.pos() if hasattr(_j, "pos") else (_j.x, _j.y, _j.z)
                        _fio_buffer.append("%.3f JOGADOR %s %.2f %.2f %.2f"
                                           % (time.time(), _j.nome or "?",
                                              _p[0], _p[1], _p[2] or 0.0))
            _descarregar_fio()
        trabalho = time.time() - agora
        _trabalho_ciclo.append(trabalho)
        if len(_trabalho_ciclo) >= 200:
            v = sorted(_trabalho_ciclo)
            del _trabalho_ciclo[:]
            if mundo.quantos():
                log("[mundo] trabalho por ciclo: mediana %.1f ms, p90 %.1f, max %.1f "
                    "(cabe em %.0f ms de tick)"
                    % (v[len(v)//2]*1000, v[9*len(v)//10]*1000, v[-1]*1000, TICK_MUNDO*1000))
        resta = TICK_MUNDO - trabalho
        if resta > 0:
            time.sleep(resta)


# ---------------------------------------------------------------------------
# Envio pausado do stream de entrada no mundo
# ---------------------------------------------------------------------------
# O servidor real entrega esses pacotes espalhados no tempo, um frame por vez.
# Nos despejavamos os 45744 bytes num sendall() so -- e ja foi provado nesse projeto
# (ver comentario no channel_server.py) que esse client e' sensivel a isso.
#
# O stream tambem tem duas fases distintas:
#   frames 0-63   (ate o byte 15507): setup do personagem, inclui o CMoveAppear do
#                                     PROPRIO personagem no frame 26 (ent_id 0x299e)
#   frames 64-499 (o resto):          trafego de entidades do mundo
# A fase 2 so faz sentido depois que o client anuncia a propria posicao (04 00).

SPLIT_OFFSET = 15507

def separar_frames(dados: bytes):
    out, p = [], 0
    while p + 4 <= len(dados):
        n = struct.unpack(">I", dados[p:p + 4])[0]
        if n == 0 or n > 200000 or p + 4 + n > len(dados):
            break
        out.append(dados[p:p + 4 + n])
        p += 4 + n
    return out


# HP maximo declarado por entidade. O client NAO usa o maximo que vai no 08 06 -- ele usa o
# que veio no CMoveAppearPacket (04 02) de cada entidade. As aranhas nascem com 50, o jogador
# com 186, dois NPCs grandes com 1450. Mandar HP numa escala diferente dessa faz a barra passar
# de 100%: era o sintoma de 23/08 -- 120%, depois 40%, depois morte, porque eu tirava 40 de um
# total de 100 e o client dividia por 50.
#
#   04 02: [8:12] entidade (LE u32)  [28:32] HP atual  [32:36] HP maximo  (todos LE u32)
HP_MAX_ENT: dict[int, int] = {}


def registrar_appear(payload: bytes) -> None:
    """Anota HP atual/maximo de uma entidade a partir do 04 02 que estamos mandando."""
    if len(payload) < 36 or payload[0:2] != bytes([4, 2]):
        return
    ent = struct.unpack_from("<I", payload, 8)[0]
    cur = struct.unpack_from("<I", payload, 28)[0]
    mx = struct.unpack_from("<I", payload, 32)[0]
    if mx:
        HP_MAX_ENT[ent] = mx
        HP_EXTERNO[ent] = min(cur, mx)


def hp_max_de(ent_id: int) -> int:
    return HP_MAX_ENT.get(ent_id, HP_MAX)


# Maior rajada que o servidor OFICIAL entrega de uma vez na entrada: 6013 bytes em 25
# frames, medido em traffic_0823_1819.log. Nao e numero escolhido -- e o teto observado.
RAJADA_MAX = 6013


def enviar_stream(conn, send_lock, dados: bytes) -> int:
    """Entrega o stream de entrada em blocos do tamanho que o client aguenta.

    Duas tentativas erradas antes desta, as duas medidas:

    1. Frame a frame com 4 ms de pausa, "imitando o servidor real". Nao imitava nada: o
       oficial nao goteja. Os 171 frames levavam 922 ms e o jogador via a tela travar por
       cerca de um segundo ao entrar.

    2. Tudo numa escrita so, 22982 bytes. Rapido -- 3 ms -- e QUEBRADO: o client recebeu,
       nao reclamou de nada e nunca mandou o 04 00, entao o personagem nao entrava no
       mundo. E o mesmo silencio de quando se corta frame do stream.

    O meio-termo sai da propria medicao: o oficial entrega no maximo 6013 bytes por rajada.
    Blocos desse tamanho dao quatro escritas em vez de 171, sem sleep nenhum -- rapido o
    bastante pra nao travar e pequeno o bastante pro client digerir.
    """
    fs = separar_frames(dados)
    for f in fs:
        registrar_appear(f[4:])
    blocos, atual, tam = [], [], 0
    for f in fs:
        if atual and tam + len(f) > RAJADA_MAX:
            blocos.append(b"".join(atual))
            atual, tam = [], 0
        atual.append(f)
        tam += len(f)
    if atual:
        blocos.append(b"".join(atual))
    for b in blocos:
        with send_lock:
            conn.sendall(b)
    return len(fs)


def enviar_pausado(conn, send_lock, dados: bytes, atraso: float = 0.0) -> int:
    """Nome antigo, mantido para nao quebrar chamador. Ver enviar_stream."""
    return enviar_stream(conn, send_lock, dados)


# ---------------------------------------------------------------------------
# Combate: HP das entidades, dano e morte
# ---------------------------------------------------------------------------
# CCharInfoGageCurHPMPPacket (08 06), 27 bytes -- decodificado da captura real:
#   [2:6]   seq        BE u32
#   [6]     0x01
#   [7:11]  entity_id  BE u32
#   [15:19] HP atual   BE u32
#   [19:23] HP maximo  BE u32
#   [23:27] MP         BE u32 (0xffffffff = nao aplicavel, usado pros mobs)
#
# CAttackStartPacket (05 00): alvo em [12:16] BE u32; atacante em [7:11].

# Reputacao ("moral"): sobe a cada mob abatido e fica negativa ao matar outro jogador (PK).
# Teto informado pelo jogador: 51200, que e 50 * 1024 -- cara de constante do jogo, nao de
# numero redondo inventado.
REPUTACAO_POR_ABATE = 8
REPUTACAO_MAXIMA = 51200

HP_MAX = 100
# GOLPE: bandeiras e dano, medidos no servidor OFICIAL em 23/08 (64 golpes de um Vagabundo
# nivel 1 num canal vazio, com arma e depois de maos vazias).
#
# O campo [24:28] do 05 02 NAO e o dano puro: e `bandeira << 8 | dano`.
#
#     0x01  golpe normal        82,8% dos golpes
#     0x02  CRITICO             9,4%   -- dano x1,2, o mesmo "Multiplicador de critico" da ficha
#     0x04  falha, dano 0       1,6%
#     0x05  bloqueio parcial    3,1%   -- dano reduzido (saiu 4 e 6 onde o normal era 11-14)
#     0x06  esquiva, dano 0     3,1%
#
# O x1,2 fecha nos dois casos medidos: 7 -> 8 de maos vazias, 13 -> 16 com a adaga.
#
# As TAXAS acima sao de 64 golpes, entao valem como ordem de grandeza e nao como numero final.
# A ficha promete critico 7,1% e bloqueio 9%; medimos 9,4% e 3,1%. Com mais amostra da pra
# amarrar isso nos campos da ficha ([170] critico, [70] bloqueio) em vez de constantes.
NOME_DO_GOLPE = {0x01: "normal", 0x02: "CRITICO", 0x04: "falha",
                 0x05: "bloqueio", 0x06: "esquiva"}
"""Para o log. `(0x02)` nao se le -- o jogador olhou o registo a procura do critico e
disse que nao estava la, estando: dois em nove golpes, com 12,1% de chance."""


GOLPE_NORMAL = 0x01
GOLPE_CRITICO = 0x02
GOLPE_FALHA = 0x04
GOLPE_BLOQUEIO = 0x05
GOLPE_ESQUIVA = 0x06

# Como o client DESENHA o numero, campo [31] do 05 02 -- ver build_dano_skill.
REALCE = {GOLPE_NORMAL: 1, GOLPE_CRITICO: 2, GOLPE_FALHA: 0,
          GOLPE_BLOQUEIO: 1, GOLPE_ESQUIVA: 0}

# Quanto o critico multiplica o dano. A ficha do jogo mostra isto como "Multiplicador de
# critico" e vale 1,2 nas duas fichas de Feiticeira capturadas em 27/08 -- a de nivel 1 e a
# de nivel 68 -- portanto nao sobe com o nivel. Confirmado tambem pelo fio, no unico teste
# limpo que existe: de maos vazias o dano e fixo, e saiu 17 no golpe normal e 20 no critico.
#
# NAO ENCONTREI DE ONDE UM ITEM O MUDARIA. As colunas de atributo do client (ItemOption:
# dano, defesa, velocidade, alcance, e cinco `bonus` que estao a zero em quase tudo) nao tem
# campo nenhum de critico, e nenhuma outra tabela importada tem. Ou nao e atributo de item
# neste jogo, ou vive numa tabela que ainda nao foi extraida.
#
# `multiplicador_critico` existe para o dia em que aparecer: a base fica aqui, o bonus vem do
# equipamento, e quem chamar nao precisa de saber a diferenca.
MULTIPLICADOR_CRITICO = 1.2
"""CONFIRMADO em 29/08: no oficial, golpes com bandeira 2 deram 94 e 97 sobre normais de
81-83, ou seja 1,13 a 1,20. O 1,2 que ja ca estava cai dentro. O jogador confirmou que a
bandeira 2 e mesmo o critico."""

# Quanto passa num golpe bloqueado (bandeira 5). Ver o comentario no dano_do_golpe.
FRACAO_DO_BLOQUEIO = 0.60


def multiplicador_critico(entity_id=None):
    """Multiplicador do golpe critico: a base do jogo mais o que o equipamento somar.

    Hoje devolve sempre a base, porque nenhuma peca declara este atributo nos dados que temos.
    Se um item passar a declarar `critico_mult` (ou o campo que se vier a descobrir), basta
    soma-lo aqui e o combate inteiro passa a respeita-lo sem mais nenhuma alteracao.
    """
    if not entity_id:
        return MULTIPLICADOR_CRITICO
    extra = 0.0
    try:
        for peca in db.equipamento_do_personagem(int(entity_id)).values():
            extra += float(peca.get("critico_mult") or 0.0)
        # E O DAS OPCOES, que a ficha ja aplicava e o combate ignorava.
        #
        # A Bengala do jogador tem uma opcao `multiplicador de critico -0,3` gravada na
        # instancia. A ficha mostrava 0,9 (1,2 - 0,3) e o combate continuava a bater com
        # 1,2 -- no log, golpe normal 174 e critico 207, que e 1,19. Ele viu o numero cair
        # ao equipar a arma e nada mudar na luta.
        #
        # A opcao e INVISIVEL no item, e por isso demorou a aparecer: -0,3 nao e um grau
        # inteiro do uniqueOptionType, entao `_escrever_opcoes_da_instancia` nao a escreve
        # e a caixa de texto da arma nao a menciona. Conta calada, como o comentario de la
        # avisa que pode acontecer.
        extra += float(db.bonus_do_equipamento(int(entity_id))
                       .get("multiplicador_critico", (0.0, 0.0))[0])
    except Exception as e:                                          # noqa: BLE001
        print("[sim] multiplicador_critico falhou: %s" % str(e)[:120])
    return MULTIPLICADOR_CRITICO + extra

# A chance de critico NAO e constante: e o campo da ficha, `Destreza * 0,452 + 2,08`, em
# porcentagem. O jogador confirmou a leitura -- quanto maior o numero, mais crita, e 100
# significaria critar quase sempre. Vagabundo 7,1% / Artilheiro 4,3%.
#
# As outras tres continuam constantes MEDIDAS (64 golpes, entao ordem de grandeza) porque
# dependem do MOB, nao do personagem: quem falha, bloqueia e esquiva do nosso golpe e ele.
# Quando `dbo.mob` tiver evasao e bloqueio, saem daqui.
# ESTAS TAXAS SAO AS DE NIVEL PAREADO. Foram medidas em 64 golpes numa Aranha pequena de
# nivel 1 com um personagem de nivel 1, entao valem para "mob do meu nivel" e so.
#
# Elas NAO valem de forma fixa: quanto mais o mob esta acima do jogador, mais o golpe falha
# e e bloqueado. Isso e descricao do jogador, que joga o oficial em nivel 80, e as nossas
# proprias capturas mostram o mesmo -- contando as bandeiras do 05 02 por alvo:
#
#     Aranha pequena          222 golpes    6% de falha+bloqueio+esquiva
#     Velocista do pantano   1700 golpes   12%
#     Bico de alumina         201 golpes   45%
#
# O Bico erra sete vezes mais que a aranha contra o mesmo personagem.
CHANCE_FALHA = 0.016
CHANCE_BLOQUEIO = 0.031
CHANCE_ESQUIVA = 0.031

# A CONTA, como ela sai dos dados:
#
#     ataque = base da classe + sorteio na faixa da arma
#     dano   = ataque - defesa do mob
#
# E a VARIACAO vem da arma, nao de um fator aleatorio nosso. A prova esta na captura: de maos
# vazias o oficial deu 7 CRAVADO, 18 golpes seguidos sem variar um ponto. Com a adaga
# (4,18 a 6,43) o dano espalhou por 10..14. Sem faixa de arma, sem variacao.
#
#     de maos vazias   ataque 10,95 -> 10   menos 3 de defesa = 7    (oficial: 7 sempre)
#     com a adaga      ataque 15..17        menos 3 de defesa = 12..14 (oficial: 10..14)
#
# O 3 de defesa nao aparece em pacote nenhum: e o que sobra da conta nos dois casos.
DANO_POR_GOLPE = 25      # nao usado no golpe; sobra como reserva de outros caminhos
DANO_SKILL = 12          # aranha tem 50 de HP -> ~5 golpes. Com 40 a barra pulava
                         # 50 -> 10 -> morte, dificil de ver se esta certa.

# Subtraida do ataque a cada golpe, IGUAL PRA TODO MONSTRO. Medida por diferenca no oficial
# em 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.
#
# Que ela e a mesma pra todo mob esta CONFIRMADO em 25/08. O jogador em nivel 3 bateu numa
# sequencia crescente no oficial e o dano normal saiu praticamente igual do bicho de nivel 1
# ao de nivel 14: 88, 87, 80, 82, 80, 92. O nivel do mob nao entra no dano.
# CORRIGIDO EM 29/08: a defesa do mob é PROPORCIONAL, nao uma subtracao fixa.
#
# O 3 fixo foi medido em 23/08 com ataques de 10 a 17, e nessa faixa qualquer das duas formas
# passa. So se separam com um ataque grande, e foi a skill que o deu: a Flecha de energia tem
# 110,37 na tabela do client e mediu-se 82 de dano no oficial.
#
#     caso                            ataque  medido   fixo -3   x0,751
#     maos vazias nv1, 23/08            10,0       7       7,0      7,5
#     adaga nv1, 23/08                  16,0      13      13,0     12,0
#     Flecha de energia nv4, 29/08     110,4      82     107,4     82,9
#
#     erro maximo:  fixo -3 -> 25,4      proporcional -> 1,0
#
# O modelo fixo erra 25 no unico ponto que os distingue. O proporcional passa nos tres.
#
# O que isto corrigiu em jogo: o jogador tirava 143 com a skill onde o oficial da 82.
#
# NAO CONFUNDIR com a defesa do JOGADOR, que continua subtractiva (CORTE_POR_DEFESA, mais
# acima). Essa foi medida por outro caminho -- tirando pecas do equipamento uma a uma -- e
# la a subtracao ganhou com folga. Sao dois lados diferentes da conta e nao ha razao para
# terem a mesma forma.
FRACAO_QUE_PASSA_A_DEFESA_DO_MOB = 0.751
DEFESA_DE_QUALQUER_MOB = 3   # OBSOLETO -- so fica para nao partir quem ainda o importe

# Quanto as taxas de falha/esquiva/bloqueio crescem por nivel que o mob tem ACIMA do jogador.
#
# MEDIDO em 25/08 na mesma sessao, contando as bandeiras do 05 02 por alvo -- possivel agora
# que sabemos o nivel de cada monstro (offset 68 do npcInfo.lod, achado no mesmo dia):
#
#     monstro              nivel   golpes   erro    k implicado
#     Javali-de-sabre        7        8      62%      1.74   <- ruidoso, n baixo
#     Javali louco           8        4      25%      0.44
#     Bico de alumina        9        3      33%      0.54
#     Urso furioso          14       19      68%      0.70   <- o de maior n
#
# Vale o do Urso furioso. Tres dos quatro pontos caem entre 0,44 e 0,70; o Javali-de-sabre
# destoa com 8 golpes e nao manda.
#
# O VALOR ANTERIOR ERA 0.06, e era chute meu -- dez vezes abaixo. Fica registrado porque
# mostra o tamanho do erro que "forma plausivel" produz: a curva subia com a diferenca,
# saturava no teto, parecia razoavel, e errava por ordem de grandeza. A forma estava certa e
# o numero nao, e so a medicao separou as duas coisas.
#
# Os n ainda sao pequenos (3 a 19 golpes por monstro). Mais captura aperta o numero; o que ja
# esta firme e a FORMA -- o erro sobe com a diferenca de nivel e o dano nao muda.
CRESCIMENTO_FALHA_POR_NIVEL = 0.70
TETO_FALHA_TOTAL = 0.75      # acima disso o mob viraria invulneravel na pratica


def npc_id_de(ent_id):
    """Que MONSTRO e a entidade `ent_id`, ou None se nao for uma das nossas.

    O mundo trabalha com id de entidade (370 pro Domingo) e o Mob.ini e indexado por
    npc_id (9300141). Sem esta traducao, procurar o nivel do alvo no Mob.ini nunca acha
    nada -- e o combate cairia calado nas taxas de nivel pareado pra todo mundo.
    """
    e = ENTIDADES.get(int(ent_id or 0))
    return e.npc_id if e is not None else None


# TAXAS DO GOLPE DO JOGADOR, por diferenca de nivel. REMEDIDAS em 29/08.
#
# Ate aqui havia tres constantes de nivel pareado (falha 1,6%, esquiva 3,1%, bloqueio 3,1%)
# e um fator unico que as fazia crescer todas juntas. As tres vinham de 64 golpes numa
# Aranha pequena, com o personagem ao nivel dela.
#
# Medi agora 5.943 golpes do jogador em todas as capturas, separados pela diferenca de nivel
# entre o mob e o personagem:
#
#     mob face ao jogador     n      normal    falha  bloqueio  esquiva
#     abaixo                144       86,8%     0,0%      0,7%     0,0%
#     parelho (0 a +2)       87       88,5%     0,0%      4,6%     0,0%
#     +3 a +8               149       69,1%     3,4%     15,4%     2,7%
#     +9 ou mais           5563       39,1%    16,9%     34,5%     3,7%
#
# Duas coisas que o modelo antigo nao acertava:
#
# 1. AO NIVEL, NAO SE FALHA NEM SE ESQUIVA. Sao 0,0% nas duas faixas de baixo, em 231 golpes.
#    O que existe e bloqueio, e pouco. Nos tinhamos 1,6% e 3,1% onde a medicao da zero.
# 2. AS TRES NAO CRESCEM JUNTAS. Do parelho para +9, o bloqueio multiplica por 7,5 e a
#    esquiva so por... nada, ela aparece do nada e fica em 3-4%. Um fator unico nao pode
#    reproduzir isto, e era isso que estava la.
#
# Passa a ser uma tabela interpolada, que e o que a medicao suporta. Fica grosseira nas
# faixas de cima -- 5563 dos 5943 golpes estao numa so -- mas cada linha tem numero em vez
# de extrapolacao.
TAXAS_POR_DIFERENCA = (
    # (diferenca, falha, esquiva, bloqueio)
    (-1, 0.000, 0.000, 0.007),
    (0,  0.000, 0.000, 0.046),
    (5,  0.034, 0.027, 0.154),
    (12, 0.169, 0.037, 0.345),
)


def taxas_do_golpe(nivel_jogador: int = 1, nivel_mob: int | None = None):
    """(falha, esquiva, bloqueio) pela diferenca de nivel, interpolando a tabela medida.

    Mob de nivel desconhecido nao sofre ajuste, e usa a linha do nivel pareado. Isso e
    deliberado: chutar o nivel faria o combate mudar sem ninguem perceber porque.
    """
    if not nivel_mob:
        d = 0
    else:
        d = int(nivel_mob) - int(nivel_jogador or 1)
    pontos = TAXAS_POR_DIFERENCA
    if d <= pontos[0][0]:
        return pontos[0][1:]
    if d >= pontos[-1][0]:
        return pontos[-1][1:]
    for (d0, f0, e0, b0), (d1, f1, e1, b1) in zip(pontos, pontos[1:]):
        if d0 <= d <= d1:
            k = (d - d0) / float(d1 - d0)
            return (f0 + (f1 - f0) * k, e0 + (e1 - e0) * k, b0 + (b1 - b0) * k)
    return pontos[-1][1:]


def dano_do_golpe(entity_id: int = 0, classe: int = 0,
                  personagem: dict | None = None,
                  alvo_id: int | None = None,
                  nivel_jogador: int = 1,
                  ataque_base: float = 0.0,
                  magico: bool = False) -> tuple[int, int]:
    """(bandeira, dano) de um golpe basico. A bandeira vai empacotada no 05 02.

    MEDIDO no oficial (23/08), Vagabundo nivel 1, 64 golpes num canal vazio:

        com a adaga     normal 10..14   critico 16
        de maos vazias  normal  7       critico  8

    O dano NAO e o numero cru da arma (a adaga e 4-6): e o ATAQUE FISICO do personagem, que a
    ficha mostra como "15 (+4)" -- a base da classe mais o dano da arma. De maos vazias sobra
    so a base, e ela deu 7 cravado.
    """
    base = 0.0
    faixa = None
    if entity_id:
        try:
            faixa = db.dano_da_arma(entity_id)
        except Exception as e:
            print(f"[sim] dano_da_arma falhou: {str(e)[:120]}")
    # O ATAQUE BASE VEM DO NIVEL, nao de uma constante por classe.
    #
    # `classe_base` dava o mesmo numero a um personagem de nivel 1 e a um de nivel 68 da
    # mesma classe. As duas fichas de Feiticeira de 27/08 mostram a base a subir de 7 para
    # 26,5 -- quase quatro vezes -- entao a constante estava certa so no nivel em que foi
    # medida. Ver db.atq_fisico_base.
    if nivel_jogador:
        base = db.atq_fisico_base(nivel_jogador)
    elif classe:
        try:
            b = db.classe_base(classe)
            base = float(b.get("atq_fisico_base") or 0.0)
        except Exception as e:
            print(f"[sim] classe_base falhou: {str(e)[:120]}")

    # "Forca de ataque +X%" do equipamento entra AQUI, sobre base+arma, e nao so sobre a
    # base como na ficha: o que sai daqui e o golpe inteiro, e e sobre ele que a percentagem
    # tem sentido. Sem opcao nenhuma o multiplicador e 1,0 e a conta fica como estava.
    mult = 1.0
    # E O ATAQUE ABSOLUTO, que faltava. A ficha escreve `(base + b_abs["atq_fisico"]) * mult`
    # (ver o bloco do ataque no world_server) e aqui usava-se so o `mult`. O "+15 de ataque
    # fisico" do torso da fantasia aparecia na tela e nao entrava em golpe nenhum -- o mesmo
    # buraco da proteccao em db.defesa_total e do critico logo abaixo.
    #
    # Entra DENTRO do parentesis, junto com a base e a arma, para a percentagem incidir sobre
    # ele tambem: e o que a ficha faz, e o golpe tem de dizer o mesmo que a tela promete.
    abs_atq = 0.0
    if entity_id:
        try:
            mult = db.multiplicadores_de_ataque(entity_id)[1 if magico else 0]
            # O ABSOLUTO DO TIPO CERTO. Um golpe magico soma "Poder magico. ataques"
            # e um fisico soma "Forca de Ataque Fisico" -- sao dois campos distintos na
            # ficha e no uniqueOptionType (tipos 8 e 6), e misturar os dois daria ao
            # bastao o bonus da espada.
            abs_atq = float(db.bonus_do_equipamento(entity_id)
                            .get("atq_magico" if magico else "atq_fisico",
                                 (0.0, 0.0))[0])
        except Exception as e:
            print(f"[sim] multiplicadores_de_ataque falhou: {str(e)[:120]}")

    if base and faixa:
        lo = int((base + abs_atq + faixa[0]) * mult)
        hi = int((base + abs_atq + faixa[1]) * mult)
    elif base:
        lo = hi = int((base + abs_atq) * mult)   # sem arma nao ha faixa: o dano fica fixo
    elif faixa:
        lo, hi = int((abs_atq + faixa[0]) * mult), int((abs_atq + faixa[1]) * mult)
    else:
        lo = hi = 10

    # DOIS sorteios independentes, e nao um so.
    #
    # O primeiro decide se o golpe ACERTA (falha / esquiva / bloqueio parcial), o segundo se
    # ele CRITA. Sao coisas diferentes: a primeira depende do mob se defender, a segunda do
    # personagem. Num sorteio unico as duas disputariam a mesma faixa, e uma chance de critico
    # de 100 ainda perderia golpes pra falha -- o jogador foi explicito de que 100 tem que
    # critar quase sempre.
    # DEFESA: a mesma para TODO monstro, de proposito.
    #
    # Ela estava sendo buscada com o mob_id 1 fixo, o que ja era defesa unica na pratica --
    # so que por acidente, e o proximo a mexer aqui trocaria o 1 por uma busca de verdade e
    # mudaria o combate sem querer. Agora e explicito.
    #
    # O jogador, que joga o oficial em nivel 80: bater num mob de nivel 30 e num de nivel 80
    # tira o MESMO dano. O que muda com o nivel do mob e o HP dele e a chance de errar, nao
    # o quanto cada golpe leva. Nossa medicao concorda -- ataque 10 dava dano 7 e ataque
    # 15-17 dava 12-14, menos 3 nos dois -- e a captura de 24/08 mostra o mesmo atacante
    # tirando 12-26 de um Bico de alumina e 15-20 de um Velocista, faixas sobrepostas.
    defesa_mob = DEFESA_DE_QUALQUER_MOB

    nivel_mob = None
    if alvo_id:
        try:
            ficha = data_files.indice("Mob", "mob_id").get(int(alvo_id))
            nivel_mob = (ficha or {}).get("nivel")
        except Exception as e:
            print(f"[sim] nivel do mob falhou: {str(e)[:120]}")
    p_falha, p_esquiva, p_bloqueio = taxas_do_golpe(nivel_jogador, nivel_mob)

    acerto = random.random()
    if acerto < p_falha:
        return GOLPE_FALHA, 0
    if acerto < p_falha + p_esquiva:
        return GOLPE_ESQUIVA, 0

    # A SKILL SUBSTITUI O ATAQUE DA ARMA, quando `ataque_base` vem preenchido.
    #
    # Entra aqui e nao no chamador para que a defesa do mob, a falha, a esquiva, o bloqueio
    # e o critico corram todos sobre o valor da skill -- que e como o oficial se comporta.
    # Medido em 29/08 as 14:14, o jogador de nivel 4 no Bico de alumina: ataque normal 24-26,
    # Flecha de energia 81-83, e a tabela do client da 110,37 para a skill. A diferenca de
    # 28 e a mesma nos dois casos, ou seja um corte de defesa unico -- o que so fecha se o
    # valor da skill entrar por aqui, no lugar do da arma.
    # A PERCENTAGEM CONTA AQUI TAMBEM, e nao contava.
    #
    # `lo` e `hi` ja levam o multiplicador, mas a skill entra por `ataque_base` e passava
    # ao lado deles -- entao NENHUM bonus de ataque chegava as skills. O jogador testou
    # com o Traje de danca completo (que da "poder de ataque +6%" no patamar de 4 pecas) e
    # sem uma peca, e mediu o mesmo dano:
    #
    #     4 pecas   magica 188 -> dano 142        3 pecas   magica 190 -> dano 143
    #
    # O bonus e do PERSONAGEM, nao da arma: subir 6% do ataque tem de subir o golpe todo,
    # venha ele da arma ou de uma habilidade. E o que a caixa de texto do conjunto promete.
    #
    # O ABSOLUTO ENTRA AQUI TAMBEM, agora que e o do tipo certo.
    #
    # Ficou de fora numa primeira versao, quando `abs_atq` era sempre o ataque FISICO:
    # somar o dano de um golpe de espada ao valor de uma habilidade magica nao tinha como
    # se justificar. Com o tipo separado a objeccao cai -- somar "Poder magico. ataques" a
    # uma magia e exactamente o que o nome do atributo diz.
    #
    # E o chamador ja faz o mesmo com a ARMA: o ramo da skill magica no world_server soma
    # `dano_da_arma(magico=True)` ao valor da habilidade antes de chegar aqui. A opcao do
    # item e a arma sao a mesma especie de bonus; tratá-las de maneira diferente e que
    # seria estranho.
    #
    # NAO ESTA MEDIDO NO OFICIAL: a captura de 29/08 que fixou este ramo era de um jogador
    # sem bonus nenhum, entao nao distingue as hipoteses. Se aparecer captura de alguem com
    # "+X" ou "+X%" a usar skill, e conferir aqui.
    bruto = ((ataque_base + abs_atq) * mult) if ataque_base > 0.0 else (
        random.randint(lo, hi) if hi > lo else max(1, hi))
    dano = max(1, int(round(bruto * FRACAO_QUE_PASSA_A_DEFESA_DO_MOB)))

    if acerto < p_falha + p_esquiva + p_bloqueio:
        # BLOQUEIO: passa 0,60 do normal. Remedido em 29/08 e o valor antigo estava a metade.
        #
        # O 1/3 vinha de "4 e 6 onde o normal era 11-14", em nivel 1: com numeros tao pequenos,
        # 4/12 e 7/12 estao a dois pontos de distancia e nao se distinguem. A skill deu a faixa
        # grande que faltava -- sobre golpes normais de 81-83, os bloqueados no oficial dao:
        #
        #     48 48 48 49 49 50   ->  0,58 a 0,62 do normal
        #
        # Seis amostras, todas na mesma casa. O 1/3 daria 27.
        #
        # E o mesmo padrao de hoje: o valor pequeno nao distinguia as hipoteses e o valor
        # grande distinguiu-as de imediato. Ja aconteceu com a defesa do mob (fixo contra
        # proporcional) na mesma captura.
        return GOLPE_BLOQUEIO, max(1, int(round(dano * FRACAO_DO_BLOQUEIO)))

    # a chance de critico sai da FICHA do personagem, em porcentagem, MAIS os buffs activos.
    #
    # E aqui que o buff tem de contar: a tela ja soma os +2 do "Foco" sozinha, a partir das
    # tabelas do client -- comparei os 25 pacotes 08 02 da captura e nenhum byte muda ao
    # ligar o buff. O que o client NAO faz por nos e o combate.
    critico = (db.chance_critico(personagem) / 100.0) if personagem else 0.07
    _cb = bonus_dos_buffs(entity_id).get("chance_critico", (0.0, 0.0))
    critico += (_cb[0] + _cb[1]) / 100.0
    # E O DO EQUIPAMENTO, que faltava. `db.chance_critico` so sabe da destreza e do nivel;
    # os buffs ja entravam pela linha acima; o que nunca entrava era o "+6 de critico" que
    # vem das opcoes e da fantasia -- estava na ficha e nao no calculo. Ver db.defesa_total,
    # que tinha o mesmo buraco na proteccao.
    #
    # Nao duplica com os buffs: `bonus_dos_buffs` le BUFFS (skills com prazo) e
    # `bonus_do_equipamento` le pecas, instancias, fantasia e passivas -- fontes disjuntas.
    try:
        _ce = db.bonus_do_equipamento(entity_id).get("chance_critico", (0.0, 0.0))
        critico += (_ce[0] + _ce[1]) / 100.0
    except Exception as _xc:                                        # noqa: BLE001
        print("[sim] critico do equipamento falhou: %s" % str(_xc)[:90])
    if random.random() < critico:
        return GOLPE_CRITICO, int(dano * multiplicador_critico(entity_id))
    return GOLPE_NORMAL, dano


# registro vivo das entidades (um client por vez, entao um dict de modulo basta)
ENTIDADES: dict[int, "Entidade"] = {}


MP_DE_MOB = 100
"""O que o oficial poe no campo da mana de um monstro. Constante nos 6 mobs medidos."""


def build_hp(seq: int, ent_id: int, hp: int, mp: int | None = None,
             atacante: int = 0xFFFFFFFF, continuo: bool = False) -> bytes:
    """CCharInfoGageCurHPMPPacket (08 06) -- e ESTE que move a barra de HP do mob.

    Layout confirmado no traffic.log real (frames 2971/2975/5757/5799):
        [7:11]  entidade (BE u32)
        [11:15] 1 quando a vida mudou por EFEITO CONTINUO, 0 no resto
        [15:19] HP atual (BE u32)
        [19:23] MP atual (BE u32)  -- NAO e o HP maximo, ver abaixo

    O [19:23] ESTEVE ERRADO ATE 30/08. Estava la a escrever o HP maximo, e passou porque
    para o heroi os dois numeros andam parecidos. O que desmente e o mob 952 do oficial:
    manda HP=128 com aquele campo em 100. Vida acima do maximo nao existe -- logo o campo
    nao e o maximo. E a entidade 11277 (o jogador) fecha o caso: enquanto o HP dela chegava
    a 296, o campo oscilava 116..130, que e mana a regenerar. Um maximo nao oscila.

    Nos 6 monstros medidos o oficial poe 100 fixo. O maximo verdadeiro nao viaja aqui: o
    client aprende-o no 04 02 da aparicao e guarda-o.
        [23:27] quem causou: id do atacante no golpe, ffffffff no sync ambiente

    O [11:15] FOI ACHADO EM 30/08 e explica um sintoma que parecia impossivel: o jogador
    dizia que a barra do bicho nao descia com o debuff, e nos mandavamos o pacote com a vida
    certa, que ate chegava ao client -- confirmado na captura. So que ia com zero ali, e o
    client trata isso como um sync de ambiente em vez de uma perda de vida para animar.

    Nos 13.952 `08 06` oficiais o campo vale 0 em 13.564 e 1 em 388 -- e os 388 sao
    exactamente os tiques de debuff.
    """
    if mp is None:
        mp = MP_DE_MOB
    p = bytearray(27)
    p[0:2] = b"\x08\x06"
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[6] = 0x01
    struct.pack_into(">I", p, 7, ent_id)
    if continuo:
        struct.pack_into(">I", p, 11, 1)
    struct.pack_into(">I", p, 15, max(0, hp))
    struct.pack_into(">I", p, 19, max(0, mp))
    struct.pack_into(">I", p, 23, atacante & 0xFFFFFFFF)
    return encode_frame(bytes(p))


def alvo_do_ataque(body: bytes) -> int | None:
    """Alvo de um CAttackStartPacket (05 00), 55 bytes.

    ERRO ANTIGO: eu lia [12:16], que na verdade e o id do TIPO de ataque (0x3fc no basico).
    Conferido no traffic.log real (frames 2915/2935/4857): o alvo e o BE u32 em [33:37]
    -- no golpe capturado, 00 00 05 11 = 1297, o mesmo id que reaparece depois no 08 06.
    """
    if len(body) < 37:
        return None
    return struct.unpack(">I", body[33:37])[0]


POS_ALVO: dict[int, bytes] = {}


def lembrar_pos_alvo(body: bytes) -> None:
    """Guarda a posicao do alvo vinda do 05 00 ([37:49], 3 floats LE).

    O 05 01 nao carrega essa posicao, mas o 05 02 do servidor real usa ela -- e o ponto
    onde o numero de dano flutua na tela (frame 2976 casa com o 05 00 do frame 2978).
    """
    if len(body) < 49:
        return
    alvo = alvo_do_ataque(body)
    if alvo:
        POS_ALVO[alvo] = bytes(body[37:49])


def atacante_do_pedido(body: bytes) -> int | None:
    """Quem bateu -- BE u32 em [7:11], igual em 05 00, 05 01 e 05 02."""
    if len(body) < 11:
        return None
    return struct.unpack(">I", body[7:11])[0]


# HP de entidades que NAO sao do nosso simulador -- o mundo tambem tem entidades vindas do
# replay do stream de entrada (ids na faixa ~1169-1300 e ~600), e o jogador pode atacar
# qualquer uma. Registramos HP sob demanda pra elas tambem.
HP_EXTERNO: dict[int, int] = {}


# LIVRO DE DANO: quanto cada atacante tirou de cada mob, para dividir a EXP no fim.
#
# A EXP nao e do golpe final -- e proporcional ao dano. Quem tirou 100 por cento do HP leva a
# EXP toda; quem tirou 10 por cento leva 10 por cento, e so entao os bonus ativos entram.
#
# Isso e o que permite um grupo cacar junto sem que o ultimo golpe decida tudo, e e diferente
# do que faziamos ate 25/08: EXP cheia pra quem desse o golpe mortal, e nada pros outros.
#
# Duas armadilhas, as duas tratadas em registrar_dano():
#
#   1. GOLPE EXCEDENTE. Mob com 30 de HP levando um golpe de 50 nao pode creditar 50 a quem
#      bateu, senao a soma passa de 100 por cento e as fracoes ficam erradas para todos. So
#      conta o dano que o mob tinha pra perder.
#   2. VAZAMENTO. Mob ferido de quem o jogador desistiu nunca morre, e a entrada ficaria no
#      dicionario pra sempre. O livro e limpo na morte E no renascimento.
_DANO: dict[int, dict[int, int]] = {}


def registrar_dano(ent_id: int, atacante: int, dano: int, hp_antes: int) -> None:
    """Anota o dano EFETIVO -- o que o mob tinha pra perder, nao o numero do golpe."""
    if not atacante or atacante == 0xFFFFFFFF:
        return
    efetivo = max(0, min(int(dano), int(hp_antes)))
    if efetivo <= 0:
        return
    livro = _DANO.setdefault(int(ent_id), {})
    livro[int(atacante)] = livro.get(int(atacante), 0) + efetivo


def participacao(ent_id: int, limpar: bool = True) -> dict[int, float]:
    """Fracao do dano de cada atacante, somando 1.0. Vazio se ninguem registrou dano.

    Por padrao ESVAZIA o livro do mob: a chamada acontece na morte, e deixar o registro
    ficaria contando o dano da vida anterior quando o mob renascesse.
    """
    livro = _DANO.pop(int(ent_id), None) if limpar else _DANO.get(int(ent_id))
    if not livro:
        return {}
    total = sum(livro.values())
    if total <= 0:
        return {}
    return {a: d / total for a, d in livro.items()}


def esquecer_dano(ent_id: int) -> None:
    """Apaga o livro de um mob. Chamado no renascimento."""
    _DANO.pop(int(ent_id), None)


def aplicar_dano(ent_id: int, dano: int = DANO_POR_GOLPE, atacante: int = 0):
    """Aplica dano em qualquer entidade do mundo. Devolve (hp_restante, morreu).

    Passando `atacante`, o dano tambem entra no livro que divide a EXP. Sem ele o dano
    acontece mas ninguem recebe credito -- que e o certo pra dano de ambiente.
    """
    e = ENTIDADES.get(ent_id)
    if e is not None:
        registrar_dano(ent_id, atacante, dano, e.hp)
        marcar_combate(atacante)          # bater tambem conta como combate
        e.hp = max(0, e.hp - dano)
        # REVIDA. Vale pra qualquer monstro, docil incluido: `agressivo` diz quem ataca sem
        # ser atacado, e isso e outra coisa. Uma aranha pequena nao vem atras de ninguem, mas
        # se levar uma pancada vai atras de quem bateu.
        if atacante and e.hp > 0:
            e.provocado_por(atacante)
        return e.hp, e.hp <= 0
    antes = HP_EXTERNO.get(ent_id, hp_max_de(ent_id))
    registrar_dano(ent_id, atacante, dano, antes)
    hp = max(0, antes - dano)
    HP_EXTERNO[ent_id] = hp
    return hp, hp <= 0


# ---------------------------------------------------------------------------
# Skills: CAttackSkill (05 01 do client) -> resultado do golpe (05 02 do servidor)
# ---------------------------------------------------------------------------
# Decodificado do traffic.log intercalado. O 05 02 e o UNICO pacote da familia 05 que o
# servidor manda por conta propria -- e o que carrega o resultado/dano do golpe.
#
#   05 01 (client, 38b): [7:11] atacante  [12:16] alvo  [16:28] pos (3 floats LE)
#                        [36:38] id da skill
#   05 02 (servidor, 51b): [2:6] seq  [7:11] atacante  [12:16] alvo
#                          [22:24] id da skill (ordem invertida em relacao ao client)
#
# ATENCAO -- LEITURA CONFLITANTE, de 25/08. Nos 05 02 em que o MONSTRO golpeia o JOGADOR, os
# campos nao estao onde esta nota diz:
#
#     [12:16] vem zerado
#     [22:26] LITTLE-endian e o ALVO (bateu com o entity_id do personagem, 11055)
#     [26:28] BIG-endian e o DANO (289, 291, 290, 289, 556)
#
# Isso foi confirmado remontando o pacote do oficial campo a campo: `build_dano_no_jogador`
# sai byte a byte igual ao capturado. Ou o mesmo opcode tem dois formatos conforme a direcao,
# ou uma das duas leituras esta errada -- e como a de cima nunca foi verificada por remontagem,
# a suspeita recai sobre ela. Nao mexi porque o caminho jogador->monstro funciona em jogo.
#                          [24:28] BANDEIRA e DANO: `bandeira << 8 | dano` (BE u32)
#                          [35:47] posicao do golpe
#
# Decodificado em 23/08 comparando 64 golpes do oficial com o HP do alvo caindo: os valores
# "263, 267, 1536" nao eram dano nenhum -- sao 0x0107, 0x010B, 0x0600. O byte baixo e o dano
# (e bate com a queda do HP do alvo), o alto e o tipo do golpe.

CAMINHO_TEMPLATE_DANO = r"D:\RoshEmulator\captures\dano_05_02.bin"
_template_dano: bytes | None = None


def build_dano_skill(seq: int, pedido: bytes, dano: int, alvo: int | None = None,
                     bandeira: int = GOLPE_NORMAL) -> bytes | None:
    """Monta o CAttackDamagePacket (05 02) a partir do 05 01 que o client mandou.

    Layout conferido no frame 2976 do traffic.log (o golpe que matou a entidade 1297):
        [2:6]   seq (BE u32)
        [7:11]  atacante (BE u32)
        [12:16] id da skill/ataque (BE u32)
        [22:24] alvo -- u16 LITTLE-endian (no capturado: 11 05 = 0x0511 = 1297)
        [24:28] DANO (BE u32)
        [35:47] posicao do golpe (3 floats LE)

    O 05 02 so desenha o numero de dano. Quem move a barra de HP e o 08 06 (build_hp).
    """
    global _template_dano
    if _template_dano is None:
        try:
            with open(CAMINHO_TEMPLATE_DANO, "rb") as f:
                _template_dano = f.read()
        except OSError:
            return None
    if len(pedido) < 38:
        return None
    p = bytearray(_template_dano)
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[7:11]  = pedido[7:11]      # atacante
    p[12:16] = pedido[12:16]     # id da skill / tipo de ataque
    if alvo is None:
        alvo = alvo_da_skill(pedido) or 0
    struct.pack_into("<H", p, 22, alvo & 0xFFFF)
    # LANCAMENTO SOBRE SI PROPRIO: dois campos que nunca escrevemos, e que ficavam com o
    # lixo do molde (`00 00 00 0b` e `01`). O oficial poe outra coisa, e foi por isto que a
    # "Regeneracao de mana" nao mostrava numero nenhum apesar de o resto do pacote bater.
    #
    #     oficial   [17:21] ff ff ff ff   [21] 00
    #     nos       [17:21] 00 00 00 0b   [21] 01
    #
    # NAO SEI O QUE [17:21] SIGNIFICA. Vale 13, 13 e 11 nas skills 11002/11003/11001 do
    # mesmo jogador e ffffffff aqui; tentei o custo de mana e nao bate (a 11001 custa 4 e o
    # campo diz 11). O que esta medido e o valor no caso de quem lanca sobre si -- e so
    # esse caso e que se escreve, para nao estragar o golpe normal, que ja funciona.
    if len(pedido) >= 11 and alvo == struct.unpack_from(">I", pedido, 7)[0]:
        struct.pack_into(">I", p, 17, 0xFFFFFFFF)
        p[21] = 0
    # O DANO SATURA EM 255, nao da a volta. O campo e mesmo de um byte -- em 14.490 `05 02`
    # do oficial, [24:26] e SEMPRE zero, [26] sao as bandeiras (1 a 6) e [27] o dano, com
    # maximo exactamente 255. Nao se alarga o campo por isso: o protocolo e assim.
    #
    # O QUE ESTAVA ERRADO era o `& 0xFF`: um golpe de 260 saia como 4, e o jogador veria um
    # numero ridiculo justamente nos golpes maiores. Saturar mostra 255, que e o que o
    # oficial mostra e o pior que o campo consegue dizer.
    # NEGATIVO OCUPA OS QUATRO BYTES. O campo [27:31] e um i32 LITTLE-endian: uma
    # reposicao de mana sai como -10, ou seja `f6 ff ff ff`, com a cauda preenchida.
    # Medido na 11004 do oficial em 30/08 -- e o unico sitio, nas capturas todas, em que
    # [28:31] nao e zero, o que so por si ja diz que o campo tem mesmo quatro bytes.
    #
    # O positivo continua a saturar em 255 enquanto nao houver medicao que o contrarie:
    # nunca se viu um golpe maior no fio, e alargar por dedução ja custou caro antes.
    struct.pack_into(">H", p, 24, 0)
    p[26] = bandeira & 0xFF
    struct.pack_into("<i", p, 27,
                     int(dano) if int(dano) < 0 else min(255, int(dano)))
    # [31] -- segundo campo do golpe, e o que muda a COR do numero que flutua na tela.
    # A bandeira em [26] diz o que aconteceu; este diz como DESENHAR. Nos mandavamos 1
    # fixo (herdado do template), entao o critico saia com a cor do golpe comum.
    #
    # Correlacao medida nos 221 golpes de 51 bytes da captura oficial:
    #     [26]=1 normal    -> [31] = 1 (103x) ou 0 (73x)
    #     [26]=2 CRITICO   -> [31] = 2 (10x)  ou 0 (2x)   <- so o critico usa o 2
    #     [26]=4 falha     -> [31] = 0 sempre
    #     [26]=5 bloqueio  -> [31] = 1 ou 0
    #     [26]=6 esquiva   -> [31] = 0 sempre
    # A REPOSICAO NAO E GOLPE: o oficial poe 0 aqui nos dois 05 02 da 11004, mesmo com a
    # bandeira 1. Fica dentro do que ja se tinha medido (bandeira 1 usa 1 ou 0).
    p[31] = 0 if int(dano) < 0 else REALCE.get(bandeira, 1)
    # posicao onde o numero de dano flutua: e a posicao do ALVO, que so chega no 05 00.
    # Sem o 05 00 correspondente, cai na posicao do atacante (melhor que nada).
    p[35:47] = POS_ALVO.get(alvo) or pedido[16:28]
    # A ROTACAO DO ALVO, em [47:51]. Sem escrever aqui, ficava o valor do MOLDE -- 1,6406 rad,
    # ou seja 94 graus -- e o servidor mandava o monstro virar-se sempre para o mesmo lado a
    # cada golpe recebido. Era isto que o jogador via: "quando eu ataco o mob fica salva a
    # minha localizacao, e toda vez que eu ataco ele vira pro lado que tava inicialmente".
    # Nao era a posicao dele que ficava salva -- era um angulo constante colado no molde.
    #
    # Que o campo e mesmo rotacao, medido nos 5.498 pacotes de 51 bytes do oficial:
    #     1.211 valores distintos, 100 por cento dentro de 0..2pi, so 1 por cento de zeros.
    # E o valor certo e o rumo do alvo para quem lhe bateu: e para la que ele se vira.
    if len(pedido) >= 32 and alvo == struct.unpack_from(">I", pedido, 7)[0]:
        # SOBRE SI PROPRIO nao ha "virar-se para quem bateu": o rumo que serve e o que ele
        # ja tinha, e vem no proprio pedido. Sem isto saia um angulo calculado contra a
        # posicao dele mesmo -- 0,63 rad onde o oficial mandou 5,92.
        p[47:51] = pedido[28:32]
    else:
        ent = ENTIDADES.get(alvo)
        if ent is not None and len(pedido) >= 28:
            try:
                jx, jy, _jz = struct.unpack_from("<3f", pedido, 16)
                struct.pack_into("<f", p, 47, rumo_do_jogo(jx - ent.x, jy - ent.y))
            except struct.error:
                pass
    return encode_frame(bytes(p))


_SKILL_CACHE = {}


def skill_do_pedido(pedido: bytes) -> int:
    """Qual skill o client usou, do 05 01: BE u32 em [12:16].

    NAO CONFUNDIR COM O ALVO, que e o [34:38] logo abaixo. Cheguei a ler a skill em [36:38]
    e saiam 246 valores distintos -- impossivel para um personagem. Eram a metade baixa do
    id do ALVO. O campo certo toma 26 valores em 5.665 pacotes do oficial, que e o numero
    certo de skills, e 9 deles casam com a tabela do client.
    """
    if len(pedido) < 16:
        return 0
    # OS DOIS PACOTES TEM LAYOUTS DIFERENTES, um byte de desfasamento. Medido nos dois na
    # captura de 29/08 as 17:28:
    #
    #     05 01   skill [12:16]   alvo [34:38]
    #     05 03   skill [11:15]   alvo [36:40]
    #
    # E o mesmo desfasamento que ja existia entre o 05 00 e o 05 01, e pela mesma razao: um
    # flag a mais num deles. Ler o 05 03 com os offsets do 05 01 dava 1280256 em vez de 5001.
    if pedido[0:2] == b"":
        return struct.unpack(">I", pedido[11:15])[0]
    return struct.unpack(">I", pedido[12:16])[0]


def skill_para(skill_id: int, nivel_jogador: int):
    """A linha da SkillLod.ini desta skill, no maior nivel que o personagem alcanca.

    NAO GUARDAMOS que skills o jogador aprendeu nem em que nivel -- isso e uma tabela de
    personagem que ainda nao existe.

    ATE 29/08 usava-se o maior nivel da skill que o nivel do personagem alcancasse, assumindo
    que ele tinha aprendido tudo o que podia. Estava errado e viu-se logo: a dica do client
    dizia 2 de mana e o servidor cobrava 3, porque escolhia o nivel 2 da Flecha de energia
    (nivel_req 3, mana 3) a um personagem de nivel 5 que so tinha a skill no nivel 1.

    Agora usa-se o NIVEL 1. Tambem e uma suposicao, mas erra para o lado seguro -- cobra
    menos mana e da menos dano do que o real, em vez de cobrar por uma skill que o jogador
    nao tem. E quando ele subir a skill, o que se ve e ela render de menos, que se percebe;
    ao contrario, o servidor cobrava por algo invisivel.

    PARA RESOLVER A SERIO e preciso guardar o nivel de cada skill por personagem, e isso
    implica saber quando ele gasta um ponto -- ha um pacote do client para isso que ainda nao
    foi identificado. Enquanto nao houver, isto e o menos errado.
    """
    chave = (int(skill_id or 0), int(nivel_jogador or 1))
    v = _SKILL_CACHE.get(chave)
    if v is not None:
        return v or None
    melhor = None
    for r in data_files.tabela("SkillLod"):
        try:
            if int(r.get("skill_id") or 0) != chave[0]:
                continue
            req = int(r.get("nivel_req") or 0)
        except (TypeError, ValueError):
            continue
        # `chave[1]` E O NIVEL DA SKILL, nao o do personagem -- mudou em 30/08, quando
        # passou a haver dbo.personagem_skill. Por isso o `nivel_req` deixou de servir de
        # filtro: ele diz que NIVEL DE PERSONAGEM e preciso para chegar a esta linha, e quem
        # ja tem a skill nesse nivel passou por esse requisito.
        #
        # Ate aqui escolhia-se o MENOR nivel disponivel, uma suposicao deliberada para errar
        # do lado seguro enquanto nao houvesse tabela por personagem. Agora ha.
        _ = req
        if int(r.get("nivel") or 0) == chave[1]:
            melhor = r
            break
        if melhor is None or int(r.get("nivel") or 0) < int(melhor.get("nivel") or 0):
            melhor = r
    _SKILL_CACHE[chave] = melhor or {}
    return melhor


def bonus_da_skill(skill_id: int, nivel_jogador: int):
    """(bonus_min, bonus_max, mana, recuperacao) da skill. Zeros quando nao ha linha.

    O `dano_min`/`dano_max` da tabela NAO e o dano total -- e o BONUS. A dica do client
    chama-lhe "Ataque fisico Bonus", e o fio confirma: o Golpe doloroso (7001) tem bonus
    0~0 na tabela e mediu-se 62 de dano no oficial, ou seja o golpe basico e mais nada.
    Somar isto ao dano da arma e o que reproduz os dois casos.
    """
    r = skill_para(skill_id, nivel_jogador)
    if not r:
        return (0.0, 0.0, 0.0, 0.0)
    def _f(k):
        try:
            return float(r.get(k) or 0.0)
        except (TypeError, ValueError):
            return 0.0
    # DOIS PARES DE DANO, um fisico e um magico, e cada skill so tem um preenchido.
    #
    # Ate 29/08 so se lia o par fisico, e por isso as skills da maga davam bonus zero -- o
    # jogador viu logo: "batendo eu tiro mais dano do que soltando skill". A Flecha de
    # energia tem 0~0 no par fisico e 110,37~113,67 no magico.
    dmin, dmax = _f("dano_min"), _f("dano_max")
    if dmax <= 0.0:
        dmin, dmax = _f("dano_mag_min"), _f("dano_mag_max")
    return (dmin, dmax, _f("mana"), _f("recuperacao"))


def skill_e_magica(skill_id: int, nivel_jogador: int) -> bool:
    """True quando a skill usa o par MAGICO da tabela.

    E o que decide de que lado do equipamento vem o ataque: a skill magica soma-se ao dano
    MAGICO das pecas, a fisica ao dano fisico. A propria db.dano_da_arma ja tinha os dois
    modos e dizia no docstring que "o magico e das skills"; faltava alguem usa-lo.

    COMO SE CHEGOU AQUI, em tres passos e dois erros meus:

    1. Somar o valor da tabela ao dano FISICO: 25 + 110,4 = 135 contra 82 medidos. Errado.
    2. SUBSTITUIR o ataque pelo valor da tabela: 110,4 x 0,751 = 83 contra 82. Batia, e por
       isso fiquei por aqui -- mas fazia a arma deixar de contar. O jogador foi testar:
       "o mesmo tanto que tira com arma +0 ta tirando com a +13".
    3. Somar ao dano MAGICO: (M + 110,4) x 0,751. Com o personagem de nivel 4 do oficial, de
       arma fraca, M e quase zero e da os mesmos 83 -- explica a medicao TAO bem como o
       passo 2, e ao contrario dele faz a arma contar. A Bengala da 8,37~13,32 a +0 e
       43,85~48,80 a +13, e essa diferenca tem de aparecer no golpe.

    O passo 2 nao estava errado por contradizer a medicao; estava errado por a medicao nao o
    distinguir do passo 3. Foi preciso o teste da arma para os separar.
    """
    r = skill_para(skill_id, nivel_jogador)
    if not r:
        return False
    try:
        return float(r.get("dano_mag_max") or 0.0) > 0.0
    except (TypeError, ValueError):
        return False


def alvo_da_skill(pedido: bytes) -> int | None:
    """Alvo de um CAttackDecidePacket (05 01), 38 bytes: BE u32 em [34:38].

    Um byte adiante do 05 00 porque o 05 01 tem um flag a mais em [33] e nao carrega a
    posicao do alvo no fim. Conferido nos frames 2958/4869 do traffic.log.
    """
    if len(pedido) < 38:
        return None
    if pedido[0:2] == b"":
        # Ver skill_do_pedido: o 05 03 traz tudo um byte a frente.
        return struct.unpack(">I", pedido[36:40])[0] if len(pedido) >= 40 else None
    return struct.unpack(">I", pedido[34:38])[0]


def hp_atual(ent_id: int) -> int:
    """HP atual de qualquer entidade (nossa ou vinda do replay)."""
    e = ENTIDADES.get(ent_id)
    if e is not None:
        return e.hp
    return HP_EXTERNO.get(ent_id, hp_max_de(ent_id))


# ---------------------------------------------------------------------------
# EXP e nivel: CCharInfoHeroGagePacket (08 01), 109 bytes
# ---------------------------------------------------------------------------
# Decodificado em 23/08 a partir de 19 amostras do traffic.log que contem um LEVEL UP ao vivo
# (HP maximo saltou de 186 pra 223 e o nivel de 1 pra 2), o que amarrou todos os campos:
#
#   [7:11]   jogador          BE u32
#   [15:19]  HP atual         LE u32      [19:23] HP maximo   LE u32
#   [23:27]  MP atual         LE u32      [27:31] MP maximo   LE u32
#
# ATENCAO ao HP e ao MP: sao LITTLE-endian tres bytes a frente, e nao big-endian em [12] e
# [16] como esteve escrito aqui ate 27/08. Os dois jeitos poem o byte baixo no mesmo sitio,
# entao dao o MESMO numero enquanto o valor couber em 255 -- e todas as capturas antigas eram
# de personagem novo. `build_hero_gage` ja estava certo desde 24/08; foi esta linha do
# cabecalho que ficou para tras e me fez ler mal a captura de progressao de 27/08, onde uma
# Feiticeira de nivel 3 com 432 de HP aparecia com 16.777.392.
#   [69:77]  EXP atual        LE u64   <- 64 bits, provado no AMo.exe (ver escrever_gage)
#   [77:85]  EXP necessaria   LE u64   <- endianness diferente do resto, e tambem 64 bits
#   [90:94]  nivel            BE u32
#
# Conferencia aritmetica na propria captura: EXP ia 24 -> 96 -> 168 (72 por aranha morta), o
# golpe seguinte levaria a 240, a necessaria era 216, e o pacote seguinte mostra nivel 2 com
# EXP 24 -- exatamente 240 - 216. Nao e inferencia solta, os numeros fecham.

CAMINHO_MOLDE_GAGE = r"D:\RoshEmulator\captures\hero_gage_08_01.bin"
_molde_gage: bytes | None = None

# CORRIGIDO 23/08: 24, nao 72. Eu tinha deduzido 72 da sequencia 24 -> 96 -> 168 no 08 01 da
# captura, mas esse pacote e PERIODICO, nao vem por abate -- cada salto de 72 que eu vi eram
# TRES aranhas. Observacao direta no servidor oficial: uma aranha vale 24.
#
# Isso ainda e uma constante para TODO mob, o que esta errado: cada monstro tem o seu valor.
# Vai virar coluna quando existir uma tabela de mobs.
EXP_POR_MORTE = 24
# MEDIDO: 15,01 e 15,03 s entre o hp=0 e o 04 02 do renascimento, em duas mortes da captura
# de 26/08. Estava 20, que era um numero meu.
#
# CONTINUA A SER O VALOR DE OMISSAO, mas deixou de valer para toda a fauna em 30/08: o tempo
# e POR ESPECIE e esta medido em Data/Monsters/MobRespawn.ini -- ver respawn_de(). Aqueles
# 15 s calharam ser os do Javali-de-sabre e do Filhote de urso, e nao os da aranha (7,0) nem
# os do cao branco (85,0).
SEGUNDOS_RESPAWN = 15.0

ESPALHAMENTO_FIAVEL = 2.0
"""p75-p25 acima disto e especie cujo renascimento nao e uma constante -- usa-se a omissao.

Sao cinco das dezoito medidas: Peekskin (197 s de espalhamento), Sedento de sangue (320),
Cao da estepe 1006703 (423) e os dois Estripadores (2081 e 4542). A mediana dessas nao
resume nada, e enfia-la no servidor seria pior do que os 15 s.
"""

_aleatorios = {}


def nasce_ao_acaso(npc_id):
    """True quando a especie nao volta ao mesmo poste -- `nasce_ao_acaso = True` no Mob.ini.

    E o caso da Fada. O jogador em 30/08: *"ela tem que nascer em lugar aleatorio nunca no
    mesmo"*. Nao esta medido -- nao ha uma unica morte de Fada nas 5.340 capturadas -- e
    portanto e conhecimento dele sobre o oficial, nao leitura do fio.
    """
    npc_id = int(npc_id or 0)
    v = _aleatorios.get(npc_id)
    if v is None:
        linha = (data_files.indice("Mob", "mob_id") or {}).get(npc_id) or {}
        bruto = linha.get("nasce_ao_acaso")
        if isinstance(bruto, bool):
            v = bruto
        else:
            v = str(bruto or "").strip().lower() in ("true", "1", "sim")
        _aleatorios[npc_id] = v
    return v


_postes_por_especie = None


def sortear_ancora(npc_id, omissao):
    """Um ponto novo na ZONA da especie: um dos postes dela, mais o raio dele.

    A ZONA E O CONJUNTO DOS POSTES, nao um circulo a volta de um. A Fada tem tres em Berneo,
    afastados uns dos outros -- (3696,3707), (8273,3517) e (2833,8117) -- e escolher sempre
    o mesmo poste faria dela um bicho de um sitio so, que e justamente o que se quer evitar.

    Devolve `omissao` quando nao ha postes ou quando as oito tentativas caem em sitio
    bloqueado: melhor voltar ao poste do que nascer dentro de pedra.
    """
    global _postes_por_especie
    if _postes_por_especie is None:
        _postes_por_especie = {}
        try:
            for sp in db.spawns():
                n = int(sp.get("npc_id") or 0)
                if n:
                    _postes_por_especie.setdefault(n, []).append(
                        (float(sp["x"]), float(sp["y"]), float(sp.get("raio") or 0.0)))
        except Exception as e:                                      # noqa: BLE001
            print("[sim] nao consegui ler os postes para o sorteio: %s" % str(e)[:100])
    lista = _postes_por_especie.get(int(npc_id or 0))
    if not lista:
        return omissao
    for _ in range(8):
        px, py, raio = random.choice(lista)
        ang = random.uniform(0.0, 2.0 * math.pi)
        d = (raio or 60.0) * (random.random() ** 0.5)
        cx, cy = px + math.cos(ang) * d, py + math.sin(ang) * d
        try:
            if pathfinding.bloqueado_em(cx, cy):
                continue
        except Exception:                                           # noqa: BLE001
            pass
        return (cx, cy)
    return omissao


_respawn = {}


def respawn_de(npc_id):
    """Segundos ate esta especie renascer. `SEGUNDOS_RESPAWN` quando nao ha linha fiavel."""
    npc_id = int(npc_id or 0)
    v = _respawn.get(npc_id)
    if v is None:
        # O Mob.ini GANHA A MEDICAO, e e o unico sitio onde isso acontece.
        #
        # Existe para o que a captura nao consegue alcancar. A Fada e o caso: ZERO
        # renascimentos em 5.340, porque o renascimento dela e de mais de uma hora e nenhuma
        # sessao gravada dura tanto. O numero vem do jogador, que joga o oficial -- e por
        # isso fica no ficheiro editavel a mao e nao na tabela gerada, que se reescreve.
        manual = (data_files.indice("Mob", "mob_id") or {}).get(npc_id) or {}
        try:
            m = float(manual.get("respawn") or 0.0)
        except (TypeError, ValueError):
            m = 0.0
        if m > 0.5:
            _respawn[npc_id] = m
            return m
        linha = (data_files.indice("MobRespawn", "mob_id") or {}).get(npc_id) or {}
        try:
            seg = float(linha.get("segundos") or 0.0)
            esp = float(linha.get("espalhamento") or 0.0)
        except (TypeError, ValueError):
            seg = esp = 0.0
        v = seg if (seg > 0.5 and esp <= ESPALHAMENTO_FIAVEL) else SEGUNDOS_RESPAWN
        _respawn[npc_id] = v
    return v

# VALIDADO NO FIO em 27/08. Uma sessao de 250 minutos do oficial, uma Feiticeira a subir 22
# niveis, 10.003 pacotes 08 01: a coluna `exp_para_subir` do Level.ini bate degrau a degrau
# com o campo [77:85] do pacote, com 100 por cento de concordancia em cada patamar --
# 48, 216, 520, 972, 1577, 2343, 3274, 4374, 8648, 7099, 10913, 13181, ...
#
# Inclusive a ANOMALIA do nono degrau, onde a exigencia DESCE (8648 e depois 7099). Eu ia
# trata-la como erro de leitura; o oficial faz mesmo assim. Nao mexer.
#
# O que segue e so o fallback de emergencia, para nao devolver zero se o Level.ini sumir.
# CORRIGIDO 23/08 com a ficha de um personagem novo (nivel 1, "Experiencia 0/48") capturada
# do servidor oficial. O campo [90:94] do 08 01 vale ZERO nesse personagem -- ou seja ele
# guarda NIVEL MENOS UM, nao o nivel. Isso desloca toda a tabela que eu tinha montado ontem:
# o "level up" da captura de ontem era 2 -> 3, nao 1 -> 2.
#
# Confirmacao independente: o HP maximo da Maga vai 150 (nv1, hoje) -> 186 (nv2) -> 223 (nv3),
# progressao coerente do MESMO personagem. Antes eu lia isso como dois personagens de nivel 1.
# Fallback, so pra nao devolver zero se o Data/Level.ini sumir. A fonte de verdade
# e o arquivo -- ver exp_necessaria().
EXP_NECESSARIA = {1: 48, 2: 216, 3: 520}

# Outros campos do 08 01 que tambem mudam no level up e que a gente NAO mexia. Descobertos
# comparando o nosso pacote de nivel 2 com o nivel 2 real da captura, byte a byte:
#
#   [43:45] LE u16   2 -> 3    sobe 1 a cada nivel (nao sei o que e; e o suspeito principal
#                              de ser o que faz o client entender "subiu de nivel" em vez de
#                              "perdeu experiencia")
#   [45:49] LE u32   +8 por abate            -- e a REPUTACAO, identificada depois
#   [54:58] BE u32   +12 por abate           -- NAO EXISTE, ver abaixo
#   [61:65] LE u32   375 -> 500 no level up  -- e a fletta_necessaria, ver abaixo
#
# OS DOIS ULTIMOS ERAM O MESMO CAMPO OUTRA VEZ (30/08). O "[54:58] +12 por abate" e a
# `fletta_exp`, que vive em [57:61] little-endian: um ">I" em 54 apanha os bytes 54..57 e o
# 57 e justamente o byte baixo dela. Por isso parecia um contador de um byte que dava a
# volta -- "chegou a 214, ganhou 46 e voltou pra 4" e 260 truncado.
#
# Medido no oficial (traffic_classe_guerreiro_vagabundo), abate a abate: so o byte 57 muda,
# 0c 18 24 30 3c 48, e os bytes 58, 59 e 60 ficam a zero. Um campo, nao dois.


# O `campo61_de` MORREU EM 30/08 -- ERA A `fletta_necessaria`.
#
# Ficou aqui meses como "campo [61:65] do 08 01, nao sabemos o que e", com uma curva
# 1.33^n escolhida por encaixar nos TRES unicos pontos que se tinham: 125, 375 e 500,
# nos niveis 1 a 3. Sao exactamente os tres primeiros valores de `fletta_necessaria`,
# que em 29/08 foi medida em 28 pontos e tem formula fechada.
#
# O mesmo campo foi portanto descoberto duas vezes, e o palpite estava a ser escrito por
# CIMA da formula medida -- as duas linhas iam para o offset 61 no `build_hero_gage`, e a
# ultima a escrever era o palpite. Batia ate ao nivel 3 e mentia do 4 para cima.
#
# A licao: quando aparece um "campo desconhecido" com poucos pontos medidos, vale a pena
# ver se algum campo JA CONHECIDO gera esses mesmos pontos, antes de lhe ajustar uma curva.


# QUANTO SE PERDE DE EXP AO MORRER: 2 por cento do total do nivel.
#
# MEDIDO no oficial em 27/08. Uma Feiticeira de nivel 48 morreu e o jogo escreveu na tela
# "Experiencia perdida: 144425". O 08 01 do fio confirma o mesmo numero -- a EXP passou de
# 1.460.350 para 1.315.925, uma queda de 144.425 exatos. E a EXP para subir do nivel 48 e
# 7.221.295:
#
#     7.221.295 * 0,02 = 144.425,9  ->  144.425 (truncado)
#
# Bate ao inteiro. E dois por cento do TOTAL DO NIVEL, nao do que o jogador tem acumulado --
# se fosse do acumulado dariam 29.207, dez por cento fora do que se mediu.
#
# A EXP nunca desce abaixo de zero: quem morre no principio de um nivel perde o que tem e
# fica a zero, nao desce de nivel. Nao esta medido que seja assim, mas descer de nivel teria
# de mexer em HP maximo, atributos e mais coisas -- se acontecer no oficial, aparece.
FRACAO_EXP_PERDIDA_NA_MORTE = 0.02

# ATENCAO: a conta depende de `exp_necessaria`, e o Data/Character/Level.ini que a alimenta
# NAO E DE CONFIANCA acima do nivel 13.
#
# Comparado com o fio em 27/08:
#     niveis  1..13   batem ao inteiro (48, 216, 520, 972, 1577, 2343, ... 15682)
#     nivel  14 em diante   desviam-se
#     nivel  48   Level.ini diz 154.960 e a ficha do oficial diz 7.221.295 -- 46 vezes menos
#
# Enquanto isso nao for corrigido, a EXP perdida na morte sai certa em personagem de nivel
# baixo, que e o que o nosso servidor tem, e proporcionalmente errada nos altos. O erro esta
# na TABELA, nao aqui: os 2 por cento estao medidos e fecham ao inteiro.


def cobrar_exp_da_morte(heroi) -> int:
    """Desconta a EXP da morte e devolve quanto se perdeu. Zero se nao havia nada a perder."""
    try:
        total = exp_necessaria(int(getattr(heroi, "nivel", 1) or 1))
    except Exception as e:                                          # noqa: BLE001
        print("[sim] exp_necessaria falhou: %s" % str(e)[:120])
        return 0
    if not total:
        return 0
    perdida = min(int(getattr(heroi, "exp", 0) or 0),
                  int(total * FRACAO_EXP_PERDIDA_NA_MORTE))
    if perdida > 0:
        heroi.exp = int(heroi.exp) - perdida
    return perdida


def exp_necessaria(nivel: int) -> int:
    """EXP para subir do nivel dado para o proximo. Vem de Data/Level.ini.

    ATE 25/08 esta funcao tinha tabela propria com tres niveis (48, 216, 520) e usava
    68n^2 - 36n + 16 pro resto. Isso criava DUAS fontes de verdade para o mesmo numero: o
    db.exp_para_subir lia o arquivo e esta aqui usava a formula -- e como e esta que decide
    a subida de nivel E escreve o limiar no 08 01 [77:85], o arquivo estava sendo ignorado
    no que importa.

    A formula esta errada por margem grande. Comparada com o que o servidor oficial manda:

        nivel  9    formula  5200    real  8648
        nivel 10    formula  6456    real  7099
        nivel 11    formula  7848    real 10913

    E ela nem podia acertar: foi ajustada em tres pontos, o que determina uma quadratica de
    forma unica mas nao prova nada. A curva real tem um degrau PRA BAIXO no nivel 10 --
    o nivel 9 cobra mais que o 10 -- e nenhuma quadratica faz isso.

    Os valores reais vem do campo [77:85] do proprio 08 01, que o servidor oficial manda em
    toda barra de heroi. Uma captura de personagem de nivel alto preenche a faixa inteira de
    uma vez; hoje temos 16 niveis assim.
    """
    linha = db.exp_para_subir(nivel)
    if linha:
        return linha
    # A formula NAO fica escrita aqui. Ela e um campo de Data/Formula.ini, como no Mu, e
    # este era o terceiro lugar onde o mesmo numero tinha uma definicao propria. Ver o
    # comentario de data_files.formula().
    pela_formula = data_files.formula("exp_para_subir", nivel=nivel)
    if pela_formula:
        return int(pela_formula)
    if nivel in EXP_NECESSARIA:
        return EXP_NECESSARIA[nivel]
    return 68 * nivel * nivel - 36 * nivel + 16


# ============================ FLETTA ============================
#
# A barra amarela da ficha. DECIFRADA em 29/08 na captura das 15:18, com o jogador a matar
# mobs ate ela transbordar. Vive no mesmo 08 01 da EXP normal:
#
#     [57:61]  LE u32   EXP de fletta atual
#     [61:65]  LE u32   EXP de fletta necessaria
#     [65:69]  LE u32   pontos de fletta
#
# Os numeros medidos, seguidos:
#
#     301 -> 384 -> 467 -> 550 -> 633 -> 716     (+83 por abate, cinco vezes seguidas)
#     necessaria: 625 e depois 750, subiu quando ele subiu de nivel
#     no transbordo: 716 + 83 = 799, menos 750 = 49, e os pontos foram de 0 a 3
#
# Tres confirmacoes independentes de que a leitura esta certa: o incremento e o mesmo nas
# cinco vezes, o resto do transbordo bate na conta, e 49/750 = 6,53% -- exactamente a
# percentagem que a ficha do jogador mostrava na imagem.
#
# Os 3 pontos de uma vez sao do evento de fletta que estava a correr; sem evento e 1, disse
# o jogador. Entra por db.taxa("fletta"), como a EXP e a reputacao.
#
# O QUE NAO ESTA MEDIDO: se os 83 mudam com a especie do mob ou com o nivel. As cinco
# amostras sao provavelmente do mesmo bicho. E a formula da necessaria -- 625 e 750 batem
# com 125 x (nivel+1) nos dois pontos que temos, e dois pontos nao fazem uma recta.
FLETTA_POR_ABATE = 83
"""Fletta por abate. MEDIDO em quatro abates seguidos, todos +83 sem variacao.

E UM VALOR DE UMA ESPECIE SO. Emparelhando cada salto com a morte que o causou, os quatro
sao do mesmo bicho -- Bico de alumina, nivel 9. Nao ha na captura um unico abate de outra
especie, portanto isto nao distingue "83 sempre" de "83 para esta especie".

O jogador suspeita que seja por especie, e as percentagens que ele viu apoiam-no: de 6,53%
para 12,67% sao 6,14 pontos, e um Bico de alumina numa barra de 750 vale 11,07. Pouco mais
de metade -- ou o bicho era outro, ou a barra mudou.

Para fechar: matar cinco da mesma especie e depois cinco de outra, com o gravador ligado."""

# A EXIGENCIA DA BARRA DE FLETTA, por nivel do personagem. FORMULA FECHADA:
#
#     necessaria(nivel) = 125 * (nivel + arredonda_para_cima((nivel - 1) / 4))
#
# Sai da sessao de progressao de 250 minutos (karos_20260827_0838), onde o mesmo personagem
# subiu 22 niveis: 28 valores da exigencia, e a formula bate nos 28.
#
# Os valores, todos multiplos de 125:
#
#     125  375  500  625  750  1000  1125  1250  1375  1625  1750  1875  2000  2250 ...
#
# e os saltos revelam o padrao de imediato -- +250, depois tres vezes +125, e repete:
#
#     250  125  125  125  250  125  125  125  250  125  125  125  250 ...
#
# COMO SE LIGOU CADA VALOR AO NIVEL: pelo campo [77:81] do mesmo pacote, que traz a EXP
# necessaria para subir. Esses numeros (48, 216, 520, 972, 1577, 2343, ...) sao os degraus do
# Level.ini, portanto identificam o nivel sem ambiguidade. Foi preciso porque o campo que eu
# tomava por nivel, o [90:94], e o dos pontos de habilidade.
#
# Isto responde a pergunta de quanto vale a barra la em cima sem ter de la chegar:
# nivel 50 da 7875, nivel 100 da 15625, nivel 120 da 18750.
def fletta_necessaria(nivel: int = 0) -> int:
    """Quanta EXP de fletta enche a barra, pelo nivel do personagem. Ver o bloco acima."""
    nv = max(1, int(nivel or 1))
    return 125 * (nv + -(-(nv - 1) // 4))


def fletta_da_especie(npc_id: int) -> int:
    """Fletta que esta especie da. Medida em Data/Monsters/MobFletta.ini.

    E valor por especie, e nao se deriva de nada: nivel, HP e EXP dao razoes diferentes nos
    dois bichos medidos. Ver o cabecalho do .ini.

    SEM PENALIZACAO DE NIVEL. Os 46 do Filhote de urso feroz repetiram-se 20 vezes seguidas
    com o jogador a passar de nivel 5 para 6. O `fator_nivel` do ganhar_exp deixou de ser
    aplicado aqui por causa disso -- estava la por descricao, saiu por medicao.
    """
    r = (data_files.indice("MobFletta", "mob_id") or {}).get(int(npc_id or 0))
    if r:
        try:
            v = int(r.get("fletta") or 0)
            if v:
                return v
        except (TypeError, ValueError):
            pass
    # QUEM NAO ESTA MEDIDO INTERPOLA PELO NIVEL, e nao leva o valor de uma especie so.
    #
    # Ate 30/08 o recurso era FLETTA_POR_ABATE = 83, que e o valor de um bicho de NIVEL 9.
    # Uma Aranha pequena de nivel 1 dava os mesmos 83, e o jogador deu por isso: "ta ganhando
    # muita exp de fletta quando matei aranha com personagem novo".
    #
    # A tabela medida da fletta/nivel entre 9,2 e 9,3 em quase todas as especies, com o
    # nivel 1 a destoar. Interpolar entre os pontos reais e o que nao inventa formula.
    try:
        nivel = int(((data_files.indice("Mob", "mob_id") or {})
                     .get(int(npc_id or 0)) or {}).get("nivel") or 0)
    except (TypeError, ValueError):
        nivel = 0
    if nivel > 0:
        pts = sorted((int(l["nivel"]), int(l["fletta"]))
                     for l in data_files.tabela("MobFletta") if l.get("nivel"))
        if pts:
            if nivel <= pts[0][0]:
                return pts[0][1]
            if nivel >= pts[-1][0]:
                # fora da faixa medida segue o declive, que e estavel: ~9,2 por nivel
                return int(round(pts[-1][1] + (nivel - pts[-1][0]) * 9.2))
            for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
                if x0 <= nivel <= x1:
                    return int(round(y0 + (y1 - y0) * (nivel - x0) / float(x1 - x0)))
    return FLETTA_POR_ABATE


# Pontos de habilidade ganhos por nivel. Nivel 6 da 5, ou seja um por nivel desde o 2.
PONTOS_POR_NIVEL = 1


class Heroi:
    """Estado do jogador: o que precisamos para o 08 01."""

    def __init__(self):
        self.id = 0
        self.nivel = 1
        self.exp = 0
        self.hp = self.hp_max = 186
        self.mp = self.mp_max = 100
        self.subiu_de_nivel = False
        self.campo43 = 1          # e o NIVEL: o oficial manda 1 num personagem nivel 1
        self.pontos_skill = 0     # [90:94] -- ver carregar_do_molde
        self.fletta_exp = 0       # [57:61] -- ver o bloco FLETTA
        self.fletta_pontos = 0    # [65:69]
        # REPUTACAO (a "moral" do personagem). Ficava anotada como "contador45, +8 por
        # abate, sem identificacao" ate o jogador dizer o que era: sobe matando mob, o
        # teto e ~52000, e fica NEGATIVA quando se mata outro jogador (vira PK).
        # Confirmado no fio: [45:49] LE u32 do 08 01. O personagem novo do oficial
        # manda 0 ali; a nossa Maga mandava 48, que sao os 6 abates dela.
        self.reputacao = 0        # +8 por abate
        # contador54 ERA A fletta_exp, vista por uma janela desalinhada -- ">I" em 54
        # apanha os bytes 54..57 e o 57 e o byte baixo da fletta. O "conta de um byte
        # que da a volta" (214 + 46 = 4) era 260 truncado. Nao se escreve nem se le
        # mais; fica so porque a coluna do banco esta em db.CAMPOS.
        self.contador54 = 0
        # QUANTO DO MAXIMO VEIO DO EQUIPAMENTO. `hp_max` e sempre o valor EFETIVO, porque e
        # ele que o combate, a regeneracao e o 08 01 usam; estes dois dizem quanto disso e
        # emprestado pelas pecas equipadas, para `salvar_estado` gravar a base e nao o total.
        #
        # Sem esta separacao seria a armadilha do login: gravar o total, ler o total,
        # somar o bonus outra vez, e o maximo subir um degrau por sessao. E o mesmo defeito
        # que a posicao de entrada teve em 27/08, com a altura da aparicao.
        self.bonus_hp_max = 0
        self.bonus_mp_max = 0

    def carregar_do_molde(self, payload: bytes) -> None:
        self.id = struct.unpack_from(">I", payload, 7)[0]
        self.hp = struct.unpack_from("<I", payload, 15)[0]
        self.hp_max = struct.unpack_from("<I", payload, 19)[0]
        self.mp = struct.unpack_from("<I", payload, 23)[0]
        self.mp_max = struct.unpack_from("<I", payload, 27)[0]
        self.exp = struct.unpack_from("<I", payload, 69)[0]   # ver build_hero_gage
        # [90:94] sao os PONTOS DE HABILIDADE, nao "nivel-1". O nivel vem de [43].
        # Os dois valores batiam por acaso nos niveis 1 e 2 (0 e 1), que era tudo o que as
        # capturas antigas tinham. Medido em 24/08 numa Arqueira ate o nivel 9:
        #     nivel 1->0  2->1  3->1  4->1  5->2  6->1  7->2  8->3  9->4
        # e a tela do jogo mostrava "Pontos de habilidade: 4" no nivel 9. O campo SOBE e
        # DESCE conforme se gasta em skill -- coisa que nivel-1 nunca faria.
        self.pontos_skill = struct.unpack_from(">I", payload, 90)[0]
        self.nivel = payload[43] or 1
        self.campo43 = struct.unpack_from("<H", payload, 43)[0]
        self.reputacao = struct.unpack_from("<I", payload, 45)[0]

    def aplicar_bonus_de_equipamento(self) -> tuple[int, int]:
        """Recalcula o maximo de HP/MP com as caracteristicas especiais do equipamento.

        Chamado na entrada e a cada equipar/desequipar. Tira o bonus antigo antes de por o
        novo, entao pode ser chamado quantas vezes for preciso sem acumular.

        SO MEXE NO MAXIMO. O HP atual fica onde esta, e so se corta o que passar do teto
        quando a peca sai. Nao ha medicao de qual das duas coisas o oficial faz -- se
        equipar as asas enche os 1004 na hora ou se a barra fica pelo meio -- e das duas a
        que nao inventa recurso e esta. Fica anotado para quando houver captura do momento
        de equipar: e olhar o 08 01 que vem logo a seguir e ver se o HP atual saltou.
        """
        import db as _db                      # tardio: db importa data_files, que importa daqui
        b = _db.bonus_do_equipamento(self.id)
        # A PERCENTAGEM CONTA, e nao contava.
        #
        # `bonus_do_equipamento` devolve (absoluto, percentagem) para cada caracteristica, e
        # aqui lia-se so o [0]. As Asas Da Luz dao "Saude +5%" -- a tabela trazia
        # ('saude': (0.0, 5.0)), o 5.0 era deitado fora e a barra do jogador nao mexia um
        # ponto. Ele viu na dica do item: "os atributos nao soma, por exemplo 5% de hp".
        #
        # A percentagem aplica-se sobre a BASE do personagem, que e o maximo actual menos o
        # bonus que ja la esta. Assim equipar e desequipar varias vezes nao acumula.
        base_hp = max(1, self.hp_max - self.bonus_hp_max)
        base_mp = max(0, self.mp_max - self.bonus_mp_max)
        abs_hp, pct_hp = b.get("saude", (0.0, 0.0))
        abs_mp, pct_mp = b.get("mana", (0.0, 0.0))
        novo_hp = int(round(abs_hp + base_hp * pct_hp / 100.0))
        novo_mp = int(round(abs_mp + base_mp * pct_mp / 100.0))
        self.hp_max = max(1, base_hp + novo_hp)
        self.mp_max = max(0, base_mp + novo_mp)
        self.bonus_hp_max, self.bonus_mp_max = novo_hp, novo_mp
        self.hp = max(0, min(self.hp, self.hp_max))
        self.mp = max(0, min(self.mp, self.mp_max))
        return novo_hp, novo_mp

    def ganhar_exp(self, qtd: int, fator_nivel: float = 1.0,
                   npc_morto: int = 0) -> bool:
        """Soma EXP e sobe de nivel se passar do limiar. Devolve True se subiu.

        `qtd` e a EXP BASE do mob. O multiplicador de evento entra aqui, vindo de
        dbo.taxa -- e assim que se liga um "evento 3x" sem tocar em codigo. A reputacao
        tem taxa propria porque o evento de EXP nao mexe nela: medida em +8 por abate com
        e sem evento, nas capturas de 23 e 24/08.
        """
        self.exp += int(round(qtd * db.taxa("exp")))
        self.reputacao = min(
            REPUTACAO_MAXIMA,
            self.reputacao + int(round(REPUTACAO_POR_ABATE * db.taxa("reputacao"))))
        # FLETTA: mesma mecanica da EXP, com taxa propria de evento.
        # A FLETTA TAMBEM SOFRE A PENALIZACAO POR DIFERENCA DE NIVEL. `fator_nivel` vem do
        # chamador, que e quem sabe que especie morreu -- e a mesma fraccao que ja se aplica
        # a EXP normal. Foi o jogador que o disse, em 29/08; nao esta medido no fio, e por
        # isso fica escrito de onde veio. Se um dia se medir e for outra curva, muda-se aqui
        # sem tocar em mais nada.
        # TECTO: os pontos de fletta nao passam do NIVEL do personagem, e com o tecto cheio
        # a barra deixa de encher -- so volta a andar depois de ele gastar pontos.
        #
        # Descrito pelo jogador em 29/08, e ele proprio com duvida ("eu acho"). NAO esta
        # medido no fio: para o medir era preciso apanha-lo com o tecto cheio a matar mobs e
        # ver a barra parada, e nenhuma captura tem isso. Fica assim porque e a unica
        # descricao que existe, e marcado para quem vier a medir saber que ainda nao esta.
        if int(getattr(self, "fletta_pontos", 0)) < int(self.nivel or 1):
            self.fletta_exp = int(getattr(self, "fletta_exp", 0)) + int(round(
                fletta_da_especie(npc_morto) * db.taxa("fletta")))
            while (self.fletta_exp >= fletta_necessaria(self.nivel)
                   and int(getattr(self, "fletta_pontos", 0)) < int(self.nivel or 1)):
                self.fletta_exp -= fletta_necessaria(self.nivel)
                self.fletta_pontos = int(getattr(self, "fletta_pontos", 0)) + 1
        subiu = False
        while self.exp >= exp_necessaria(self.nivel):
            self.exp -= exp_necessaria(self.nivel)
            self.nivel += 1
            # medido na Maga: HP 150 -> 186 -> 223 (+36, +37) e MP 90 -> 100 -> 110 (+10).
            # So temos essa classe; as outras vao ganhar o mesmo ate termos captura delas.
            self.hp_max += 36
            self.mp_max += 10
            self.hp, self.mp = self.hp_max, self.mp_max
            self.campo43 = self.nivel
            # UM PONTO DE HABILIDADE POR NIVEL. `ganhar_exp` subia nivel, HP e MP e nunca
            # tocava nisto -- o jogador chegou ao nivel 6 com o unico ponto que o molde do
            # 02 05 trazia. Nivel 6 tem de dar 5, ou seja um por nivel a partir do 2.
            self.pontos_skill = int(getattr(self, "pontos_skill", 0)) + PONTOS_POR_NIVEL
            subiu = True
        self.subiu_de_nivel = subiu
        salvar_estado(self)
        return subiu


# NAO existe mais um heroi global. Cada conexao tem o seu, criado por iniciar_heroi().
#
# Ate 23/08 isto era `HEROI = Heroi()`, um singleton de modulo -- que funcionou enquanto so
# havia um jogador por vez. Com dois conectados, os dois escreviam no MESMO objeto: nivel,
# EXP, HP e reputacao se misturavam, e cada um salvava o progresso por cima do outro.
# O multiplayer nao criou esse bug, so o tornou visivel.


def molde_gage() -> bytes | None:
    """So le o arquivo. NAO mexe no estado do HEROI -- ver iniciar_heroi()."""
    global _molde_gage
    if _molde_gage is None:
        try:
            with open(CAMINHO_MOLDE_GAGE, "rb") as f:
                _molde_gage = f.read()
        except OSError:
            return None
    return _molde_gage


# Persistencia: SQL Server (banco Rosh), via db.py. Antes disso era um JSON solto -- o banco
# entrou porque progressao de personagem precisa de algo que aguente varias contas, e nao de
# um arquivo unico. O db.py falha em silencio se o SQL estiver fora: perder progresso e ruim,
# derrubar o jogador no meio de uma luta e pior.
NOME_PADRAO = "Personagem"     # o protocolo capturado nao carrega o nome; trocar no banco


_ultimo_save = {}
FOLGA_ENTRE_SAVES = 3.0


def gravar_estado_com_folga(heroi, urgente=False):
    """Grava o heroi no banco, no maximo de FOLGA_ENTRE_SAVES em FOLGA_ENTRE_SAVES segundos.

    Ate 25/08 o unico gatilho de gravacao era GANHAR EXP. HP e MP nunca iam parar ao disco,
    entao quem saia com 20 por cento de vida voltava com a barra cheia -- o jogador reparou
    depois de morrer e relogar. Nao era o banco a falhar: era ninguem lhe estar a contar.

    A folga existe porque isto e chamado a cada golpe recebido. Sem ela seria uma escrita por
    pancada, e a defesa e subtracao fixa, entao um monstro rapido gera muitas. `urgente` passa
    por cima da folga: a morte tem de ficar gravada na hora, ou uma queda de ligacao logo a
    seguir devolvia o personagem vivo.
    """
    agora = time.time()
    if not urgente and agora - _ultimo_save.get(heroi.id, 0.0) < FOLGA_ENTRE_SAVES:
        return
    _ultimo_save[heroi.id] = agora
    salvar_estado(heroi)


def salvar_estado(heroi: "Heroi") -> None:
    """Grava o progresso do heroi no banco. Chamado a cada ganho de EXP.

    hp_max/mp_max vao SEM o bonus do equipamento: a coluna do banco e a base do personagem,
    e o bonus e recalculado a cada entrada a partir do que estiver equipado. Ver
    Heroi.aplicar_bonus_de_equipamento.
    """
    campos = {c: getattr(heroi, c) for c in db.CAMPOS}
    campos["hp_max"] = max(1, campos["hp_max"] - heroi.bonus_hp_max)
    campos["mp_max"] = max(0, campos["mp_max"] - heroi.bonus_mp_max)
    # o HP atual tambem nao pode ficar acima da base gravada, ou o proximo login entra com a
    # barra estourada antes de o equipamento ser lido
    campos["hp"] = min(campos["hp"], campos["hp_max"])
    campos["mp"] = min(campos["mp"], campos["mp_max"])
    db.salvar_progresso(heroi.id, campos)


def carregar_estado(heroi: "Heroi") -> bool:
    """Sobrepoe o progresso salvo por cima do molde. True se achou algo no banco."""
    # NAO cria personagem aqui. Isso existia quando o unico personagem era o da captura;
    # agora eles nascem pelo 02 02 e criar por engano aqui geraria linhas fantasma.
    dados = db.carregar_personagem(heroi.id)
    if not dados:
        return False
    for c, v in dados.items():
        setattr(heroi, c, v)
    return True


def iniciar_heroi(personagem: dict | None = None) -> "Heroi":
    """Zera o heroi no inicio da sessao, a partir do molde.

    Tem que ser chamado na ENTRADA no mundo, nunca preguicosamente dentro do build_hero_gage:
    antes era assim, e como o build vinha depois do ganhar_exp, a carga do molde sobrescrevia
    a EXP recem-ganha com a do personagem capturado (24). O primeiro abate valia 24 em vez de 72.

    A EXP comeca em 0, e nao nos 24 herdados da captura -- assim a barra e os degraus batem:
    72 -> 144 -> 216 = sobe de nivel com 0 sobrando, em vez de terminar num 24 solto.
    """
    heroi = Heroi()
    molde = molde_gage()
    if molde is not None:
        heroi.carregar_do_molde(molde)
    heroi.exp = 0
    if personagem:
        # dados do personagem que esta entrando, vindos do banco -- o molde e so a forma
        heroi.id = personagem["entity_id"]
        heroi.nivel = personagem.get("nivel", 1)
        heroi.hp = personagem.get("hp", heroi.hp)
        heroi.hp_max = personagem.get("hp_max", heroi.hp_max)
        heroi.mp = personagem.get("mp", heroi.mp)
        heroi.mp_max = personagem.get("mp_max", heroi.mp_max)
    if carregar_estado(heroi):
        print(f"[sim] progresso do heroi {heroi.id} recuperado do disco: "
              f"nivel {heroi.nivel}, EXP {heroi.exp}")
    bh, bm = heroi.aplicar_bonus_de_equipamento()
    if bh or bm:
        print(f"[sim] equipamento do heroi {heroi.id} da +{bh} HP e +{bm} MP "
              f"(maximo agora {heroi.hp_max}/{heroi.mp_max})")
    return heroi


RAZAO_VELOCIDADE_2 = 45.2 / 65.0
"""Entre os dois campos de velocidade. Constante em TODAS as amostras do oficial: 0,6954,
nas 28 do 08 00 e nas 23 fichas, do jogador parado ao jogador a 156."""

VEL_CAMPO39 = 0.9954
"""O terceiro float do 08 00. Nao se sabe o que e -- anda entre 0,91 e 1,13 e nao acompanha
a velocidade. Fica o valor que o JOGADOR da captura tinha nas amostras em que se mexia."""


def build_velocidade(seq: int, entity_id: int, velocidade: float,
                     hp: int = 0, hp_max: int = 0, mp: int = 0, mp_max: int = 0,
                     nivel: int = 1) -> bytes:
    """O `08 00`, que e QUEM MOVE O BONECO. 57 bytes.

    ACHADO EM 31/08, e e o fim de dois dias a bater na parede. A velocidade de movimento
    nao aplicava por mais que a ficha declarasse: mandava-se 165,88 no 08 02 e o personagem
    andava a 65. Testou-se e caiu, por esta ordem: o buff de item 05 09; o id de skill nesse
    buff; o par de campos 78/82 da ficha; os oito campos que so o oficial preenchia.

    O QUE ACHOU FOI MUDAR A PERGUNTA. Em vez de comparar a nossa ficha com a dele, listaram-
    se TODOS os pacotes que ele manda no instante em que a velocidade muda. As quatro
    mudancas da captura das 18:43 tem todas a mesma assinatura:

        05 09  ->  08 00  ->  08 00  ->  08 02  ->  08 01

    O `08 00` aparece nas quatro e nos nao o mandavamos: 28 dele contra 2 nossos. E dentro
    dele, nos bytes 31 e 35, estao as DUAS velocidades, iguaizinhas as da ficha:

        entidade 43133 (o jogador)   67,275 -> 97,549 -> 156,078   e acompanha o que ele anda
        entidades 71, 90206, 90208   65,000 sempre                 sao mobs, ficam na base

    A FICHA E O MOSTRADOR; ISTO E O MOTOR. E por isso que mexer no 08 02 nunca moveu nada --
    o numero mudava na tela e o client continuava a andar pelo valor que so chega aqui.

    Ao LIGAR um buff o oficial manda dois seguidos, ao DESLIGAR manda um. A razao disso nao
    esta medida; aqui manda-se um de cada vez.

    O que NAO esta decifrado: [10] (205 no jogador, 195 no mob), [39] (ver VEL_CAMPO39) e
    [46] (200 em toda a gente). Ficam com o valor observado.
    """
    p = bytearray(57)
    p[0:2] = bytes([8, 0])
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, int(entity_id) & 0xFFFFFFFF)
    p[10] = 205
    struct.pack_into("<I", p, 15, max(0, int(hp_max)))
    struct.pack_into("<I", p, 19, max(0, int(hp)))
    struct.pack_into("<I", p, 23, max(0, int(mp)))
    struct.pack_into("<I", p, 27, max(0, int(mp_max)))
    struct.pack_into("<f", p, 31, float(velocidade))
    struct.pack_into("<f", p, 35, float(velocidade) * RAZAO_VELOCIDADE_2)
    # O CAMPO 39 E A VELOCIDADE DE ATAQUE -- ver velocidade_de_ataque.
    struct.pack_into("<f", p, 39, float(velocidade_de_ataque(entity_id)))
    p[43] = max(0, min(255, int(nivel)))
    p[46] = 200
    return encode_frame(bytes(p))


def build_hero_gage(seq: int, heroi: "Heroi") -> bytes | None:
    """Monta o 08 01 com o estado do heroi DESTA conexao."""
    molde = molde_gage()
    if molde is None:
        return None
    p = bytearray(molde)
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 7, heroi.id)
    # HP e MP: u32 LITTLE-endian em [15],[19],[23],[27]. Nao big-endian em [12],[16],
    # [20],[24] -- os dois jeitos poem o byte baixo no mesmo lugar, entao davam o mesmo
    # resultado ate 255 e o erro passou despercebido. Medido na captura do oficial de
    # 24/08: uma Maga nivel 7 com 368 de HP maximo, que lido do jeito antigo virava
    # 16777328 (0x01000070). Ia quebrar aqui no nivel 4, quando o HP passa de 255.
    struct.pack_into("<I", p, 15, heroi.hp & 0xFFFFFFFF)
    struct.pack_into("<I", p, 19, heroi.hp_max & 0xFFFFFFFF)
    struct.pack_into("<I", p, 23, heroi.mp & 0xFFFFFFFF)
    struct.pack_into("<I", p, 27, heroi.mp_max & 0xFFFFFFFF)
    # A VELOCIDADE TAMBEM VIAJA AQUI, e ficava no 65 do molde.
    #
    # Achado em 31/08 varrendo TODOS os pacotes do oficial atras do numero 156,078 -- em vez
    # de adivinhar em qual deles a velocidade estaria. Ela aparece em QUATRO:
    #
    #     08 02  offsets 78/82     a ficha      (testada byte a byte: nao move o boneco)
    #     08 00  offsets 31/35     (testada: nao move)
    #     08 01  offsets 31/35     ESTE, e nunca lhe tocamos
    #     04 02  offsets 44/48     ao aparecer no mundo
    #
    # No oficial o 08 01 acompanha: 67,28 -> 97,55 -> 156,08, nas mesmas 28 amostras em que
    # o jogador andou a essas velocidades. No nosso saia 65,00 nos dez pacotes todos.
    #
    # E O MOLDE OUTRA VEZ, o mesmo defeito do campo 78 da ficha: o hero_gage_08_01.bin traz
    # os valores do personagem de onde foi capturado, escreve-se o HP e o MP por cima e o
    # resto fica a mentir. O jogador tinha perguntado, depois do caso anterior: "mais um dor
    # de cabeca por causa dos molde?".
    try:
        _v = velocidade_de_movimento(heroi.id)
        struct.pack_into("<f", p, 31, float(_v))
        struct.pack_into("<f", p, 35, float(_v) * RAZAO_VELOCIDADE_2)
        # A VELOCIDADE DE ATAQUE nao se escreve aqui: ja ha `escrever_velocidade`, que
        # corre mais abaixo e ganharia de qualquer coisa que se pusesse neste bloco.
    except Exception as _xv:                                        # noqa: BLE001
        print("[sim] velocidade no 08 01 falhou: %s" % str(_xv)[:90])
    # EXP: u32 LITTLE-endian em [69:73], nao big-endian em [66:70].
    #
    # As duas leituras dao o mesmo resultado em TODAS as capturas, porque nenhum
    # personagem capturado passou de 240 de EXP -- so o byte [69] era diferente de zero,
    # e ele e o byte baixo nos dois jeitos. O erro so apareceu quando um personagem nosso
    # passou de 255: com EXP 264 o client mostrou 8 (264 & 0xff) e anunciou "experiencia
    # perdida: 232", porque antes marcava 240.
    #
    # E o byte alto que sobrava caia em [68], que pertence ao FP (Fleta, u32 LE em
    # [65:69]) -- por isso a barra de FP saltou pra 16777216, que e 0x01000000: o valor 1
    # lido como byte mais alto de outro campo.
    # LARGURA: os dois sao u64 LE, nao u32 -- corrigido 25/08.
    #
    # Escrever u32 aqui funciona enquanto o valor cabe em 4.294.967.295, e por isso o erro
    # ficou invisivel: nas 6070 amostras de 08 01 que temos, [73:77] e [81:85] (os meios
    # altos dos dois campos) sao ZERO em todas. Nenhum personagem capturado chegou perto do
    # teto, entao u32 e u64 produzem bytes identicos.
    #
    # O que prova a largura e o proprio AMo.exe, nao a captura. A barra de EXP e montada com
    #     _sprintf(buf, "%I64d/%I64d", exp_lo, exp_hi, lim_lo, lim_hi)
    # e o percentual sai de uma divisao de 64 bits:
    #     (longlong)*(struct + 0x828) / (longlong)*(struct + 0x830)
    # A struct do personagem guarda EXP em 0x828/0x82c e limiar em 0x830/0x834 -- pares
    # baixo/alto. Ver ghidra/decomp/lote_004.c:9484 e :9531.
    #
    # Na pratica hoje isso nao muda byte nenhum, porque os valores sao pequenos. O que muda e
    # que passar de 4 bilhoes deixa de estourar: com "<I" o struct.pack_into levantaria
    # excecao, e com mascara o valor daria a volta em silencio -- que e o modo de falhar ruim.
    # FLETTA -- ver o bloco FLETTA. Antes estes tres campos iam com o que o molde trouxesse,
    # portanto a barra amarela do jogador nunca mexia por muito que ele matasse.
    struct.pack_into("<I", p, 57, max(0, getattr(heroi, "fletta_exp", 0)) & 0xFFFFFFFF)
    struct.pack_into("<I", p, 61, fletta_necessaria(heroi.nivel) & 0xFFFFFFFF)
    struct.pack_into("<I", p, 65, max(0, getattr(heroi, "fletta_pontos", 0)) & 0xFFFFFFFF)
    struct.pack_into("<Q", p, 69, heroi.exp & 0xFFFFFFFFFFFFFFFF)
    struct.pack_into("<Q", p, 77, exp_necessaria(heroi.nivel) & 0xFFFFFFFFFFFFFFFF)
    struct.pack_into(">I", p, 90, max(0, getattr(heroi, "pontos_skill", 0)))
    # [43] e o NIVEL. Eu mandava um contador proprio que comecava em 2, e o client lia isso
    # como "voce esta no nivel 2" -- daí o "Novo Nivel" ao entrar, seguido da volta pro 1
    # quando os outros pacotes diziam 1.
    struct.pack_into("<H", p, 43, max(1, heroi.nivel) & 0xFFFF)
    struct.pack_into("<I", p, 45, max(0, heroi.reputacao))
    # O ">I" EM 54 SAIU DAQUI (30/08). Escrevia 4 bytes, 54 a 57, e o 57 e o byte
    # BAIXO da fletta_exp que a linha de cima acabara de la por -- o pacote saía
    # sempre com o contador54 no lugar da fletta. Ver o bloco FLETTA.
    escrever_velocidade(p, heroi.id)
    return encode_frame(bytes(p))


OFF_VEL_GAGE = 39


def escrever_velocidade(p: bytearray, entity_id: int) -> None:
    """Velocidade de ataque, float LE em [39:43] do 08 01 E do 08 00.

    Descoberto em 23/08 comparando um Vagabundo recem-criado no oficial com o nosso. O oficial
    manda 0,872 (a base da classe) no primeiro gage, ANTES do inventario, e 1,573 (a velocidade
    da adaga) em todos os seguintes. A ficha le a velocidade DAQUI, nao do 08 02.

    O nosso mandava 0,866 nos tres -- e 0,866 e a base da MAGA, que veio junto no molde
    hero_gage_08_01.bin e nunca era reescrita. Por isso a ficha do Vagabundo mostrava 0,87.
    """
    if len(p) < OFF_VEL_GAGE + 4:
        return
    faixa = None
    if entity_id:
        try:
            equipado = db.equipamento_do_personagem(entity_id)
            arma = equipado.get(db.SLOT_ARMA)
            if arma and arma.get("vel_ataque"):
                faixa = float(arma["vel_ataque"])
        except Exception as e:
            print(f"[sim] velocidade do gage: {str(e)[:120]}")
    # AS PERCENTAGENS DO EQUIPAMENTO ENTRAM POR CIMA, e nao entravam.
    #
    # Ate 31/08 escrevia-se aqui a velocidade da ARMA e mais nada. O jogador pos dez opcoes
    # de "+10% de velocidade de ataque" em tres pecas -- +300% -- e mediu o intervalo entre
    # golpes: 1,210 s de mediana em catorze golpes, igual ao de antes. A ficha declarava 4,40
    # contra a base 1,10 e o client continuava a bater no mesmo ritmo, porque le DAQUI.
    #
    # A conta e a mesma do campo 94 da ficha: base (arma, ou a da classe) vezes 1+pct/100.
    # Ver velocidade_de_ataque, que e quem a faz.
    if entity_id:
        try:
            faixa = velocidade_de_ataque(entity_id, base=faixa)
        except Exception as e:                                      # noqa: BLE001
            print("[sim] percentagens da velocidade de ataque: %s" % str(e)[:100])
    if faixa:
        struct.pack_into("<f", p, OFF_VEL_GAGE, faixa)
