#!/usr/bin/env python3
"""
Flappy Zerg — terminal edition.

Same game as flappy-zerg.pages.dev, but in your shell. Renders with curses,
deterministic seeds match the browser daily challenge.

Run:
    python3 flappy-zerg-term.py
    python3 flappy-zerg-term.py --seed 1234
    python3 flappy-zerg-term.py --daily

Or one-liner from anywhere:
    python3 -c "$(curl -fsSL https://flappy-zerg.pages.dev/play)"

Controls:
    SPACE / UP      flap
    R               restart
    D               daily challenge
    Q / Ctrl-C      quit
"""
import argparse, curses, locale, math, random, sys, time
from datetime import datetime

try:
    from zoneinfo import ZoneInfo
    PT = ZoneInfo("America/Los_Angeles")
except Exception:
    PT = None

# ---------------------------------------------------------------------------
# Game constants — tuned for ~80×24 terminals
# ---------------------------------------------------------------------------
BIRD_X_FRAC      = 0.28
GRAVITY          = 90.0    # rows/s²
FLAP_V           = -26.0   # rows/s
MAX_FALL         = 38.0
PIPE_WIDTH       = 5       # cols
PIPE_GAP         = 8       # rows (slightly more forgiving than browser)
PIPE_INTERVAL    = 1.55    # seconds
PIPE_SPEED_BASE  = 14.0    # cols/s
PIPE_SPEED_RAMP  = 0.2
PIPE_SPEED_MAX   = 26.0
POWERUP_CHANCE   = 0.35
GROUND_ROWS      = 1
HUD_ROWS         = 1
NIGHT_AT_SCORE   = 50

POWERUPS = {
    'Z': {'effect': 'bonus',      'desc': '+5'},
    'S': {'effect': 'shield',     'desc': 'shield'},
    'C': {'effect': 'slowmo',     'desc': 'slow'},
    'M': {'effect': 'magnet',     'desc': 'mag'},
    'A': {'effect': 'multiplier', 'desc': 'x2'},
}
POWERUP_KEYS = list(POWERUPS.keys())

# ---------------------------------------------------------------------------
# Mulberry32 RNG (bit-exact JS port — same daily seeds as the browser)
# ---------------------------------------------------------------------------
def make_rng(seed):
    s = [seed & 0xFFFFFFFF]
    def imul(a, b):
        return ((a & 0xFFFFFFFF) * (b & 0xFFFFFFFF)) & 0xFFFFFFFF
    def rng():
        s[0] = (s[0] + 0x6D2B79F5) & 0xFFFFFFFF
        t = s[0]
        t = imul(t ^ (t >> 15), t | 1)
        t = (t ^ (t + imul(t ^ (t >> 7), t | 61))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
    return rng

def daily_seed_today():
    now = datetime.now(PT) if PT else datetime.now()
    return int(now.strftime("%Y%m%d"))

def today_str():
    now = datetime.now(PT) if PT else datetime.now()
    return now.strftime("%Y-%m-%d")

# ---------------------------------------------------------------------------
# Color setup
# ---------------------------------------------------------------------------
COLOR_BIRD     = 1
COLOR_PIPE     = 2
COLOR_PIPE_CAP = 3
COLOR_GROUND   = 4
COLOR_SKYLINE  = 5
COLOR_HUD      = 6
COLOR_POWERUP  = 7
COLOR_DIM      = 8
COLOR_ACCENT   = 9
COLOR_DEAD     = 10

def init_colors(use_256):
    curses.start_color()
    curses.use_default_colors()
    if use_256 and curses.COLORS >= 256:
        # Approximate brand palette in 256-color
        cream     = 230
        charcoal  = 234
        orange    = 166
        green     = 71
        gray_lite = 245
        gray_dark = 240
        red       = 196
        curses.init_pair(COLOR_BIRD,     cream,    -1)
        curses.init_pair(COLOR_PIPE,     charcoal, -1)
        curses.init_pair(COLOR_PIPE_CAP, orange,   -1)
        curses.init_pair(COLOR_GROUND,   orange,   -1)
        curses.init_pair(COLOR_SKYLINE,  gray_dark,-1)
        curses.init_pair(COLOR_HUD,      cream,    -1)
        curses.init_pair(COLOR_POWERUP,  green,    -1)
        curses.init_pair(COLOR_DIM,      gray_lite,-1)
        curses.init_pair(COLOR_ACCENT,   orange,   -1)
        curses.init_pair(COLOR_DEAD,     red,      -1)
    else:
        # 8-color fallback
        curses.init_pair(COLOR_BIRD,     curses.COLOR_WHITE,  -1)
        curses.init_pair(COLOR_PIPE,     curses.COLOR_BLACK,  -1)
        curses.init_pair(COLOR_PIPE_CAP, curses.COLOR_YELLOW, -1)
        curses.init_pair(COLOR_GROUND,   curses.COLOR_YELLOW, -1)
        curses.init_pair(COLOR_SKYLINE,  curses.COLOR_BLACK,  -1)
        curses.init_pair(COLOR_HUD,      curses.COLOR_WHITE,  -1)
        curses.init_pair(COLOR_POWERUP,  curses.COLOR_GREEN,  -1)
        curses.init_pair(COLOR_DIM,      curses.COLOR_WHITE,  -1)
        curses.init_pair(COLOR_ACCENT,   curses.COLOR_YELLOW, -1)
        curses.init_pair(COLOR_DEAD,     curses.COLOR_RED,    -1)

def attr(pair_id, *flags):
    a = curses.color_pair(pair_id)
    for f in flags: a |= f
    return a

# ---------------------------------------------------------------------------
# Game
# ---------------------------------------------------------------------------
class Game:
    def __init__(self, stdscr):
        self.stdscr = stdscr
        self.term_resize()
        self.high_score = 0
        self.runs = 0
        self.reset()

    def term_resize(self):
        self.H, self.W = self.stdscr.getmaxyx()
        self.game_top  = HUD_ROWS
        self.game_bot  = self.H - GROUND_ROWS - 1   # last game-area row
        self.game_h    = self.game_bot - self.game_top + 1

    def reset(self, seed=None, daily=False):
        if seed is None:
            seed = random.randint(1, 2**31 - 1)
        self.seed     = seed
        self.daily    = daily
        self.rng      = make_rng(seed)
        self.bird_x   = BIRD_X_FRAC * self.W
        self.bird_y   = self.game_top + self.game_h * 0.45
        self.bird_vy  = 0.0
        self.pipes    = []         # list of [x, gap_y, scored, powerup]
        self.collected = []
        self.score    = 0
        self.multi    = 1
        self.multi_until = 0.0
        self.slowmo_until = 0.0
        self.shielded = False
        self.magnet   = 0
        self.god      = False
        self.elapsed  = 0.0
        self.pipe_timer = PIPE_INTERVAL
        self.phase    = 'menu'     # menu | playing | gameover
        self.flash_until = 0.0
        # Spawn first pipe near bird so first obstacle arrives quickly
        self._spawn_pipe()
        self.pipes[0][0] = max(self.W * 0.62, self.bird_x + 14)

    def start(self):
        self.runs += 1
        self.phase = 'playing'

    def flap(self):
        if self.phase == 'menu':
            self.start(); return
        if self.phase == 'gameover':
            return
        self.bird_vy = FLAP_V

    def _spawn_pipe(self):
        margin = 2
        min_gy = self.game_top + margin + PIPE_GAP / 2
        max_gy = self.game_bot - margin - PIPE_GAP / 2
        gap_y  = min_gy + self.rng() * max(1, max_gy - min_gy)
        powerup = POWERUP_KEYS[int(self.rng() * len(POWERUP_KEYS))] if self.rng() < POWERUP_CHANCE else None
        self.pipes.append([self.W + PIPE_WIDTH, gap_y, False, powerup])

    def _hit(self):
        if self.shielded:
            self.shielded = False
            self.flash_until = self.elapsed + 0.3
            self.bird_vy = FLAP_V * 0.8
            return
        self.flash_until = self.elapsed + 0.6
        self.phase = 'gameover'
        if self.score > self.high_score:
            self.high_score = self.score

    def _apply_powerup(self, key):
        self.collected.append(key)
        e = POWERUPS[key]['effect']
        if e == 'bonus':       self.score += 5
        elif e == 'shield':    self.shielded = True
        elif e == 'slowmo':    self.slowmo_until = self.elapsed + 3
        elif e == 'magnet':    self.magnet = 3
        elif e == 'multiplier': self.multi = 2; self.multi_until = self.elapsed + 5

    def update(self, dt):
        if self.phase != 'playing':
            return
        if self.elapsed < self.slowmo_until:
            dt *= 0.5
        self.elapsed += dt
        if self.elapsed >= self.multi_until and self.multi != 1:
            self.multi = 1

        # bird
        self.bird_vy = min(self.bird_vy + GRAVITY * dt, MAX_FALL)
        self.bird_y += self.bird_vy * dt

        # pipe spawn
        self.pipe_timer -= dt
        if self.pipe_timer <= 0:
            self._spawn_pipe()
            self.pipe_timer = PIPE_INTERVAL
        speed = min(PIPE_SPEED_MAX, PIPE_SPEED_BASE + self.score * PIPE_SPEED_RAMP)
        for p in self.pipes:
            p[0] -= speed * dt
        self.pipes = [p for p in self.pipes if p[0] + PIPE_WIDTH > -2]

        # collisions / scoring
        bx, by = self.bird_x, self.bird_y
        for pipe in self.pipes:
            px, gy, scored, pu = pipe
            # power-up pickup (with magnet pull)
            if pu is not None:
                cx, cy = px + PIPE_WIDTH / 2, gy
                d2 = (bx - cx) ** 2 + (by - cy) ** 2
                pick_r = 12 if self.magnet > 0 else 1.5
                if d2 < pick_r * pick_r:
                    pipe[3] = None
                    if self.magnet > 0 and d2 > 1.5 * 1.5:
                        self.magnet -= 1
                    self._apply_powerup(pu)
            # scoring
            if not scored and px + PIPE_WIDTH < bx:
                pipe[2] = True
                self.score += self.multi
            # collision
            if not self.god:
                if bx + 0.5 > px and bx - 0.5 < px + PIPE_WIDTH:
                    top = gy - PIPE_GAP / 2
                    bot = gy + PIPE_GAP / 2
                    if by < top or by > bot:
                        self._hit(); return

        # ground / ceiling
        if not self.god:
            if self.bird_y >= self.game_bot or self.bird_y <= self.game_top:
                self._hit(); return

    # -----------------------------------------------------------------------
    # Render
    # -----------------------------------------------------------------------
    def render(self):
        self.stdscr.erase()
        self._draw_skyline()
        self._draw_ground()
        for pipe in self.pipes:
            self._draw_pipe(pipe)
        self._draw_bird()
        self._draw_hud()
        if self.phase == 'menu':
            self._draw_menu()
        elif self.phase == 'gameover':
            self._draw_gameover()

    def _safe_addstr(self, y, x, s, a=0):
        if y < 0 or y >= self.H: return
        if x < 0:
            s = s[-x:]; x = 0
        max_w = self.W - x - 1
        if max_w <= 0: return
        try: self.stdscr.addstr(y, x, s[:max_w], a)
        except curses.error: pass

    def _draw_ground(self):
        ground_y = self.H - 1
        bar = '─' * (self.W - 1)
        self._safe_addstr(ground_y, 0, bar, attr(COLOR_GROUND, curses.A_BOLD))

    def _draw_skyline(self):
        # Single row of varied "buildings" just above ground
        sl_y = self.H - 2
        line = []
        scrollx = int(self.elapsed * 4) % self.W
        for x in range(self.W - 1):
            i = (x + scrollx) % 13
            if i in (0, 4, 5, 9):    line.append('▓')
            elif i in (1, 7, 12):    line.append('▒')
            elif i in (2, 6, 8, 11): line.append('░')
            else:                    line.append(' ')
        self._safe_addstr(sl_y, 0, ''.join(line), attr(COLOR_SKYLINE))

    def _draw_pipe(self, pipe):
        px, gy, _, pu = pipe
        col = int(round(px))
        if col + PIPE_WIDTH < 0 or col >= self.W: return
        top = int(round(gy - PIPE_GAP / 2))
        bot = int(round(gy + PIPE_GAP / 2))
        # bodies
        for r in range(self.game_top, top):
            self._safe_addstr(r, col, '█' * PIPE_WIDTH, attr(COLOR_PIPE, curses.A_BOLD))
        for r in range(bot + 1, self.game_bot + 1):
            self._safe_addstr(r, col, '█' * PIPE_WIDTH, attr(COLOR_PIPE, curses.A_BOLD))
        # caps
        if 0 <= top - 1 <= self.game_bot:
            self._safe_addstr(top, col, '▀' * PIPE_WIDTH, attr(COLOR_PIPE_CAP, curses.A_BOLD))
        if 0 <= bot <= self.game_bot:
            self._safe_addstr(bot, col, '▄' * PIPE_WIDTH, attr(COLOR_PIPE_CAP, curses.A_BOLD))
        # power-up
        if pu is not None:
            pcol = col + PIPE_WIDTH // 2
            prow = int(round(gy))
            self._safe_addstr(prow, pcol, pu, attr(COLOR_POWERUP, curses.A_BOLD | curses.A_REVERSE))

    def _draw_bird(self):
        if self.phase == 'menu': return
        col = int(round(self.bird_x))
        row = int(round(self.bird_y))
        glyph = 'Ze'
        # tilt indicator: up-arrow when rising fast, down-arrow when falling fast
        if self.bird_vy < -10:   glyph = 'Ze'
        elif self.bird_vy > 15:  glyph = 'Ze'
        a = attr(COLOR_BIRD, curses.A_BOLD)
        if self.god:
            # cycle color in god mode
            colors = [COLOR_PIPE_CAP, COLOR_POWERUP, COLOR_BIRD, COLOR_ACCENT]
            a = attr(colors[int(self.elapsed * 10) % len(colors)], curses.A_BOLD)
        if self.flash_until > self.elapsed:
            a = attr(COLOR_DEAD, curses.A_BOLD | curses.A_BLINK)
        self._safe_addstr(row, col, glyph, a)

    def _draw_hud(self):
        # left: tag (DAILY or AGENT), middle: score, right: status chips
        left = ''
        if self.daily: left = f' DAILY · {today_str()} '
        # Score centered
        mid = f' {self.score} '
        # right chips
        chips = []
        if self.shielded:                  chips.append('SHIELD')
        if self.elapsed < self.slowmo_until: chips.append('SLOW')
        if self.elapsed < self.multi_until: chips.append('×2')
        if self.magnet > 0:                chips.append(f'MAG{self.magnet}')
        if self.god:                       chips.append('GOD')
        right = ' '.join(chips)

        if left:
            self._safe_addstr(0, 0, left, attr(COLOR_ACCENT, curses.A_BOLD | curses.A_REVERSE))
        if self.phase == 'playing':
            mid_x = max(0, self.W // 2 - len(mid) // 2)
            self._safe_addstr(0, mid_x, mid, attr(COLOR_HUD, curses.A_BOLD))
        if right:
            rx = max(0, self.W - 2 - len(right))
            self._safe_addstr(0, rx, right, attr(COLOR_ACCENT, curses.A_BOLD))

    def _draw_menu(self):
        title    = 'FLAPPY ZERG'
        subtitle = 'built for mission-critical flapping'
        controls = 'SPACE flap   ·   D daily   ·   R restart   ·   Q quit'
        y = max(self.game_top + 1, self.game_h // 2 - 3)
        self._center(y,     title,    attr(COLOR_HUD,    curses.A_BOLD))
        self._center(y + 1, subtitle, attr(COLOR_ACCENT, curses.A_BOLD))
        self._center(y + 3, '[ press SPACE to start ]', attr(COLOR_HUD, curses.A_BOLD | curses.A_REVERSE))
        self._center(y + 5, controls, attr(COLOR_DIM))
        self._center(y + 7, 'flappy-zerg.pages.dev', attr(COLOR_DIM))

    def _draw_gameover(self):
        y = max(self.game_top + 1, self.game_h // 2 - 3)
        self._center(y,     'GAME OVER',  attr(COLOR_DEAD, curses.A_BOLD))
        self._center(y + 1, f'score {self.score}', attr(COLOR_HUD, curses.A_BOLD))
        meta = f'high {self.high_score}  ·  seed {self.seed}  ·  {len(self.collected)} power-ups'
        if self.daily: meta = f'DAILY {today_str()}  ·  ' + meta
        self._center(y + 3, meta, attr(COLOR_DIM))
        self._center(y + 5, '[ R restart   D daily   Q quit ]', attr(COLOR_HUD, curses.A_BOLD | curses.A_REVERSE))

    def _center(self, y, text, a=0):
        x = max(0, self.W // 2 - len(text) // 2)
        self._safe_addstr(y, x, text, a)

# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def run(stdscr, args):
    locale.setlocale(locale.LC_ALL, '')
    curses.curs_set(0)
    stdscr.nodelay(True)
    stdscr.timeout(0)
    init_colors(use_256=not args.no_256)
    g = Game(stdscr)

    # too-small guard
    H, W = stdscr.getmaxyx()
    if H < 12 or W < 50:
        stdscr.erase()
        try:
            stdscr.addstr(0, 0, "Terminal too small. Need at least 50×12.")
            stdscr.addstr(1, 0, f"Currently {W}×{H}. Resize and re-run.")
        except curses.error: pass
        stdscr.refresh()
        time.sleep(2.5)
        return

    if args.daily:
        g.reset(seed=daily_seed_today(), daily=True); g.start()
    elif args.seed is not None:
        g.reset(seed=args.seed); g.start()

    last  = time.monotonic()
    frame_dt = 1 / 30
    KONAMI = [curses.KEY_UP, curses.KEY_UP, curses.KEY_DOWN, curses.KEY_DOWN,
              curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_LEFT, curses.KEY_RIGHT,
              ord('b'), ord('a')]
    konami_i = 0
    while True:
        # read all pending keys this frame
        while True:
            ch = stdscr.getch()
            if ch == -1: break
            # Konami: arrow codes pass through; letters are lowercased to match
            cmp = (ch | 0x20) if 65 <= ch <= 90 else ch
            if cmp == KONAMI[konami_i]:
                konami_i += 1
                if konami_i == len(KONAMI):
                    g.god = True
                    g.flash_until = g.elapsed + 1.2
                    konami_i = 0
            else:
                konami_i = 1 if cmp == KONAMI[0] else 0

            if ch in (ord('q'), ord('Q')): return
            if ch == ord(' ') or ch == curses.KEY_UP:           g.flap()
            elif ch in (ord('r'), ord('R')):                     g.reset(); g.start()
            elif ch in (ord('d'), ord('D')):                     g.reset(seed=daily_seed_today(), daily=True); g.start()
            elif ch == curses.KEY_RESIZE:                        g.term_resize()

        now = time.monotonic()
        dt = min(0.1, now - last)
        last = now
        g.update(dt)
        g.render()
        stdscr.refresh()

        # frame pacing
        elapsed = time.monotonic() - now
        sleep = frame_dt - elapsed
        if sleep > 0: time.sleep(sleep)

def main():
    ap = argparse.ArgumentParser(description="Flappy Zerg — terminal edition")
    ap.add_argument("--seed",     type=int, help="start with a specific seed (skips menu)")
    ap.add_argument("--daily",    action="store_true", help="start today's daily challenge")
    ap.add_argument("--no-256",   action="store_true", help="force 8-color palette (use if 256-color looks wrong)")
    args = ap.parse_args()
    try:
        curses.wrapper(run, args)
    except KeyboardInterrupt:
        pass
    print("\nthanks for flapping. https://flappy-zerg.pages.dev")

if __name__ == "__main__":
    main()
