"""WCW/nWo Revenge (N64) - the fight logic, as running code.

Every constant and every branch below was read out of the cartridge's own
MIPS code and then checked by executing that code in an interpreter.  The
ROM address each part comes from is named in the comment above it, so any
line here can be traced back to the machine code it models.

Units: health 0..255, spirit 0..100, limb condition 0..127.
Time is measured in ticks of the game's own logic loop.

    python3 revenge-pin.py        # runs the self-check at the bottom; exit 0 = every row
    bun test/oracle/oracle.test.js  # the tool's own gate (tools/aki-anvil): this self-check plus
                                    # the same rows replayed against the cartridge
"""

import sys
from dataclasses import dataclass, field

REFEREE_PERIOD = 30      # 0x800B8D8C calls tick(clock, 30)
COUNT_TO_WIN = 3         # measured (test/oracle/revenge_oracle.py chain): the count byte
TICKS_TO_WIN = REFEREE_PERIOD * COUNT_TO_WIN            # = 90


@dataclass
class Wrestler:
    """One 944-byte record at 0x800F9D10 + 944 * slot."""
    health: int = 255                    # +0x10
    max_health: int = 255                # +0x12, permanent, floor 128
    spirit: int = 50                     # +0x14
    limb_condition: list = field(default_factory=lambda: [100] * 5)   # +0x34C

    attack: list = field(default_factory=lambda: [3] * 5)      # bytes 5..9
    defence: list = field(default_factory=lambda: [3] * 5)     # bytes 10..14

    being_pinned: bool = False           # 0x00000400
    spirit_empty: bool = False           # 0x00020000
    spirit_full: bool = False            # 0x00040000
    special_active: bool = False         # 0x00080000
    marked: bool = False                 # 0x00000100, position-dependent

    holds: int = 0                       # 0x800FADD2 + 2 * slot
    escapes: int = 0                     # 0x800F9B80 + 2 * slot   <- the rule
    hold_timer: int = 0                  # +0x18


@dataclass
class Move:
    damage: int
    limb: int = 0
    limb_damage: int = 0
    spirit_gain: int = 0
    spirit_drain: int = 0


@dataclass
class Rules:
    """Two bits of the word at 0x8007F0A0, set by the match options screen."""
    pinfalls_enabled: bool = True        # bit 0x0020; off -> every pin lasts 5 ticks
    bit_0x0100: bool = True


def limb_condition(w: Wrestler, limb: int) -> int:
    if w.special_active:
        return 50                        # flat, whatever the real wear is
    return w.limb_condition[limb]


def life(w: Wrestler) -> int:
    """The pin reads MAXIMUM health while the special is up.  That single
    substitution is why a charged wrestler cannot be pinned."""
    return w.max_health if w.special_active else w.health


def spirit_apply(w: Wrestler, amount: int) -> None:
    if w.spirit_empty or w.spirit_full or w.special_active:
        return                           # frozen in all three states
    w.spirit += amount
    if w.spirit >= 100:
        w.spirit, w.spirit_full = 100, True
    elif w.spirit <= 0:
        w.spirit, w.spirit_empty = 0, True


def spirit_drift(w: Wrestler, opponent_marked: bool = False) -> None:
    if w.spirit_full or w.special_active or w.spirit_empty:
        return                           # each end has its own countdown
    if w.marked and not opponent_marked:
        w.spirit = max(1, w.spirit - 8)
    elif w.spirit > 50:
        w.spirit -= 1
    elif w.spirit == 50:
        pass                             # the resting point
    elif w.spirit >= 31:
        w.spirit += 2
    else:
        w.spirit += 3                    # the lower you are, the faster back


def hit(attacker: Wrestler, defender: Wrestler, move: Move,
        level_scale: float = 1.0) -> int:
    cond = limb_condition(attacker, move.limb)
    part1 = (cond + 50) * move.damage // 100
    part2 = (attacker.attack[move.limb] - defender.defence[move.limb]) * move.damage // 10
    part3 = (attacker.spirit - defender.spirit) // 20

    total = max(0, part1) + max(0, part2) + max(0, part3)
    if attacker.special_active:
        total = int(total * 1.2)
    total = int(total * level_scale)

    defender.health = max(0, defender.health - total)          # never negative
    defender.max_health = max(128, defender.max_health - total // 4)
    defender.limb_condition[move.limb] = max(0, defender.limb_condition[move.limb] - move.limb_damage)
    spirit_apply(attacker, move.spirit_gain)
    spirit_apply(defender, move.spirit_drain)
    return total


def hold_time(victim: Wrestler, attacker: Wrestler, rules: Rules = Rules(),
              move_cover_roll: bool | None = None) -> int:
    """move_cover_roll models the one dice roll inside the pin (0x800CA8C8):
    when the cover is the end of a move (wrestler state 0x3F) and the
    attacker's special is active, the game rolls 500 per mille; True means
    that roll succeeded, False that it failed, None that the cover was a
    plain one (state 0x39) and no roll happened."""
    if not rules.pinfalls_enabled:       # 0x800CA830
        return 5                         # the referee never reaches one
    if victim.marked and not rules.bit_0x0100:
        return 30                        # exactly a one count, always

    victim.holds += 1                    # 0x800CA89C: counted after the two

    hold = (110
            + attacker.spirit // 4
            + 3 * victim.holds
            - life(victim)
            - victim.spirit // 2)
    hold = max(0, hold)

    if hold >= 90 and victim.spirit >= 90:
        hold = 87

    if move_cover_roll and attacker.special_active:
        hold = 30000

    if victim.escapes < 2:
        spirit_gap = attacker.spirit - victim.spirit
        exception = spirit_gap >= 70 and life(victim) == 0
        if not exception and hold >= 88:
            hold = 87                    # three ticks short of ninety

    return max(15, hold)                 # 0x800CA9F8


def resolve_pin(victim: Wrestler, attacker: Wrestler, mash: int = 0,
                rules: Rules = Rules(), move_cover_roll: bool | None = None) -> dict:
    """mash is 0, 1 or 2 extra ticks per turn (buttons A, B, L, R); the
    controller routine never takes more than 2 extra per tick."""
    timer = hold_time(victim, attacker, rules, move_cover_roll)
    start = timer
    tick = 0
    while timer >= 0:
        tick += 1
        timer -= (1 + mash)
    count = min(COUNT_TO_WIN, tick // REFEREE_PERIOD)
    if count >= COUNT_TO_WIN:
        return dict(hold=start, tick=tick, count=count, result="PINFALL")
    victim.escapes += 1                  # only a clean kick-out counts
    return dict(hold=start, tick=tick, count=count, result="kick-out")


if __name__ == "__main__":
    def fresh(hp, spirit, escapes=0, special=False, max_health=400):
        return Wrestler(health=hp, max_health=max_health, spirit=spirit,
                        escapes=escapes, holds=escapes, special_active=special)

    EXPECTED = [
        ((255, 20, 50, 0), (15, 0)), ((150, 20, 50, 0), (15, 0)),
        ((100, 20, 50, 0), (15, 0)), ((60, 20, 50, 0), (55, 1)),
        ((30, 20, 50, 0), (85, 2)),  ((0, 20, 50, 0), (87, 2)),
        ((0, 20, 50, 1), (87, 2)),   ((0, 20, 50, 2), (121, 3)),
        ((0, 20, 50, 3), (124, 3)),
        ((0, 0, 69, 0), (87, 2)),    ((0, 0, 70, 0), (130, 3)),
        ((0, 20, 90, 0), (125, 3)),
        ((0, 98, 100, 2), (87, 2)),   # capped: 95 would have been a three count
        ((0, 90, 64, 2), (87, 2)),    # capped exactly at the threshold 90
        ((0, 90, 60, 2), (89, 3)),    # one below it: not capped
        ((0, 89, 64, 2), (91, 3)),    # spirit one below it: not capped
    ]
    bad = 0
    for (hp, vs, a_s, esc), (hold, count) in EXPECTED:
        r = resolve_pin(fresh(hp, vs, esc), fresh(255, a_s))
        ok = r["hold"] == hold and r["count"] == count
        bad += 0 if ok else 1
        print(f"  hp={hp:3d} spirit={vs:3d} atk={a_s:3d} escapes={esc}  "
              f"-> hold {r['hold']:3d} count {r['count']}  {r['result']:8s}"
              f"{'' if ok else f'   MISMATCH, expected {hold}/{count}'}")

    for esc, want in ((2, 30000), (0, 87)):
        r = resolve_pin(fresh(0, 50, escapes=esc), fresh(255, 50, special=True), move_cover_roll=True)
        ok = r["hold"] == want
        bad += 0 if ok else 1
        print(f"  special pin, escapes={esc} -> hold {r['hold']:5d} {r['result']:8s}"
              f"{'' if ok else f'   MISMATCH, expected {want}'}")
    r = resolve_pin(fresh(255, 50, escapes=2, special=True), fresh(255, 50, special=True), move_cover_roll=True)
    ok = r["hold"] == 30000
    bad += 0 if ok else 1
    print(f"  special pin against an active special -> hold {r['hold']:5d} {r['result']:8s}{'' if ok else '   MISMATCH'}")

    for hp in (255, 100, 0):
        r = resolve_pin(fresh(hp, 20, escapes=5, special=True), fresh(255, 100))
        ok = r["result"] == "kick-out" and r["count"] == 0
        bad += 0 if ok else 1
        print(f"  special active, hp={hp:3d} -> hold {r['hold']:3d} "
              f"count {r['count']}  {r['result']}{'' if ok else '   MISMATCH'}")

    print("\nall checks passed" if bad == 0 else f"\n{bad} MISMATCHES")
    sys.exit(1 if bad else 0)
