#!/usr/bin/env python3
# SPDX-FileCopyrightText: (C) Eric S. Raymond <esr@thyrsus.com>
# SPDX-License-Identifier: BSD-2-Clause
"""\
This is ski %s, designed by Mark Stevans, Python port by Eric S. Raymond.
You are hurtling down a ski slope in reverse, trying to evade the Yeti.
Available commands are:

 l = turn left            r = turn right
 j = jump                 h = hop
 t = teleport             Enter = keep skiing
 i = launch ICBM          d = summon Fire Demon

 ! = interpret line as shell command and execute.
 ? = print this help message.

"""
# This code runs under both Python 2 and Python 3. Preserve this property!
from __future__ import print_function

# pylint: disable=invalid-name,missing-function-docstring,no-else-return,redefined-outer-name,consider-using-f-string,consider-using-enumerate

# pylint: disable=multiple-imports
import time, random, curses, copy, sys, os

version = "6.15"

REP_SNOW = "."
REP_TREE = "Y"
REP_GROUND = ":"
REP_ICE = "#"
REP_PLAYER = "I"
REP_YETI = "A"
REP_ICBM = "*"
REP_DEMON = "D"

terrain_key = [
    "Terrain types:   ",
    REP_SNOW,
    " = snow      ",
    REP_TREE,
    " = tree  ",
    REP_GROUND,
    " = bare ground     ",
    REP_ICE,
    " = ice\n\
Creatures:       ",
    REP_PLAYER,
    " = player    ",
    REP_YETI,
    " = yeti  ",
    REP_ICBM,
    " = ICBM            ",
    REP_DEMON,
    " = fire demon",
]

# Length of line
LINE_LEN = 70

# Minimum distance from the player at which a new Yeti may appear.
MIN_YETI_APPEARANCE_DISTANCE = 3

# Constant multiplied into the first element of the cellular growth
# automaton probability array with each passing level.
LEVEL_MULTIPLIER = 1.01

# Absolute value of maximum horizontal player speed.
MAX_HORIZONTAL_PLAYER_SPEED = 5

ICBM_SPEED = 3  # Horizontal speed of an ICBM.
ICBM_RANGE = 2  # Horizontal Yeti lethality range of the ICBM.
DEMON_RANGE = 1  # Horizontal Yeti lethality range of the demon.
DEMON_SPEED = 1  # Horizontal maximum speed of the Fire Demon.

# Per-turn probabilities
PROB_YETI_MELT = 1.0  # Of Yeti spontaneously melting.
PROB_SKIS_MELT_YETI = 20.0  # Of melting Yeti by jumping over it.
PROB_BAD_SPELL = 10.0  # Of "Summon Fire Demon" spell going awry.
PROB_BAD_TELEPORT = 10.0  # Of a teleport going wrong.
PROB_BAD_ICBM = 30.0  # Of your ICBM exploding on launch.
PROB_SLIP_ON_ICE = 2.0  # Of slipping when on ice.
PROB_FALL_ON_GROUND = 10.0  # Of falling down on bare ground.
PROB_HIT_TREE = 25.0  # Of hitting tree, each turn in trees
PROB_BAD_LANDING = 3.0  # Of landing badly from jump or hop.

# Number of points awarded to the player for the successful completion
# of one jump.  For scoring purposes, a hop is considered to consist
# of exactly one half-jump.
POINTS_PER_JUMP = 20

# Number of points awarded to the player for each meter of horizontal
# or vertical motion during each turn.
POINTS_PER_METER = 1

# Number of points awarded to the player for each Yeti that melts
# during the course of the game, regardless of whether the player
# passively caused the Yeti to melt by luring him from a snowbank, or
# actively melted the Yeti using his skis, an ICBM, or with the
# assistance of the Fire Demon.
POINTS_PER_MELTED_YETI = 100

# Number of points docked from your score for each degree of injury.
POINTS_PER_INJURY_DEGREE = -40

# The injury categories.
SLIGHT_INJURY = 0
MODERATE_INJURY = 3
SEVERE_INJURY = 6

# The randomness of injury degrees.
INJURY_RANDOMNESS = 6

colordict = {
    REP_SNOW: curses.COLOR_WHITE,
    REP_TREE: curses.COLOR_GREEN,
    REP_PLAYER: curses.A_STANDOUT,
    REP_GROUND: curses.COLOR_YELLOW,  # Brown on PC-compatibles
    REP_ICE: curses.COLOR_CYAN,
    REP_YETI: curses.COLOR_BLUE,
    REP_ICBM: curses.COLOR_MAGENTA,
    REP_DEMON: curses.COLOR_RED,
}


def percent(X):
    return (random.randint(0, 9999) / 100.0) <= (X)


def exists(X):
    return X is not None


# pylint: disable=too-many-instance-attributes
class SkiWorld:
    "The game state."

    def __init__(self):
        # Constants controlling the multiple cellular growth
        # automatons that are executed in parallel to generate
        # hazards.
        #
        # Each cellular growth automaton probability element tends to control a
        # different facet of hazard generation:
        #
        #    [0] appearance of new hazards in clear snow
        #    [1] hazards edge growth
        #    [2] hazards edge stability
        #    [3] appearance of holes in solid hazards (this allows the Yeti
        #        to work his way through forests)
        self.prob_tree = [0.0, 30.0, 70.0, 90.0]
        self.prob_ice = [0.0, 30.0, 70.0, 90.0]
        self.prob_ground = [0.0, 30.0, 70.0, 90.0]

        self.level_num = 0
        self.slope = [REP_SNOW] * LINE_LEN
        self.player_pos = self.teleport()
        self.yeti = None
        self.icbm_pos = None
        self.demon_pos = None

        # Randomize the appearance probabilities.
        self.prob_tree[0] = (random.randint(0, 99)) / 500.0 + 0.05
        self.prob_ice[0] = (random.randint(0, 99)) / 500.0 + 0.05
        self.prob_ground[0] = (random.randint(0, 99)) / 500.0 + 0.05
        self.prob_yeti_appearance = (random.randint(0, 99)) / 25.0 + 1.0

    def terrain(self):
        "What kind of terrain are we on?"
        return self.slope[self.player_pos]

    def nearby(self, pos, minval=1):
        "Is the specified position near enough to the player?"
        return exists(pos) and abs(pos - self.player_pos) <= minval

    def teleport(self):
        "Return a random location"
        return random.choice(list(range(len(self.slope))))

    # pylint: disable=too-many-branches
    def gen_next_slope(self):
        # Generates the slope of the next level, dependent upon
        # the characteristics of the current level, the probabilities, and the
        # position of the player.

        # Stash away old state so we don't step on it while generating new
        current_slope = copy.copy(self.slope)

        # Generate each character of the next level.
        for i in range(len(self.slope)):
            # Count the number of nearby trees, ice patches, and
            # ground patches on the current level.
            num_nearby_trees = 0
            num_nearby_ice = 0
            num_nearby_ground = 0

            if current_slope[i] == REP_TREE:
                num_nearby_trees += 1
            if current_slope[i] == REP_ICE:
                num_nearby_ice += 1
            if current_slope[i] == REP_GROUND:
                num_nearby_ground += 1

            if i > 0:
                if current_slope[i - 1] == REP_TREE:
                    num_nearby_trees += 1
                elif current_slope[i - 1] == REP_ICE:
                    num_nearby_ice += 1
                elif current_slope[i - 1] == REP_GROUND:
                    num_nearby_ground += 1

            if i < len(self.slope) - 1:
                if current_slope[i + 1] == REP_TREE:
                    num_nearby_trees += 1
                elif current_slope[i + 1] == REP_ICE:
                    num_nearby_ice += 1
                elif current_slope[i + 1] == REP_GROUND:
                    num_nearby_ground += 1

            # Generate this character of the next level based upon
            # the characteristics of the nearby characters on the
            # current level.
            if percent(self.prob_tree[num_nearby_trees]) and (
                (i != self.player_pos) or (num_nearby_trees > 0)
            ):
                self.slope[i] = REP_TREE
            elif percent(self.prob_ice[num_nearby_ice]) and (
                (i != self.player_pos) or (num_nearby_ice > 0)
            ):
                self.slope[i] = REP_ICE
            elif percent(self.prob_ground[num_nearby_ground]) and (
                (i != self.player_pos) or (num_nearby_ground > 0)
            ):
                self.slope[i] = REP_GROUND
            else:
                self.slope[i] = REP_SNOW

    def update_level(self):
        # Go to the next level, and move the player.
        self.level_num += 1

        # Figure out the new player position based on a modulo
        # addition.  Note that we must add the line length into the
        # expression before taking the modulus to make sure that we
        # are not taking the modulus of a negative integer.
        self.player_pos = (
            self.player_pos + player.player_speed + len(self.slope)
        ) % len(self.slope)

        # Generate the updated slope.
        self.gen_next_slope()

        # If there is no Yeti, one might be created.
        if not exists(self.yeti) and percent(self.prob_yeti_appearance):
            # Make sure that the Yeti does not appear too
            # close to the player.
            while True:
                self.yeti = self.teleport()
                if not self.nearby(self.yeti, MIN_YETI_APPEARANCE_DISTANCE):
                    break

        # Increase the initial appearance probabilities of all obstacles and
        # the Yeti.
        self.prob_tree[0] *= LEVEL_MULTIPLIER
        self.prob_ice[0] *= LEVEL_MULTIPLIER
        self.prob_ground[0] *= LEVEL_MULTIPLIER
        self.prob_yeti_appearance *= LEVEL_MULTIPLIER

    def manipulate_objects(self, player):
        # If there is a Yeti, the player's jet-powered skis may melt him,
        # or he may spontaneously melt. Otherwise move him towards the player.
        # If there is a	tree in the way, the Yeti is blocked.
        if exists(self.yeti):
            if (self.nearby(self.yeti) and percent(PROB_SKIS_MELT_YETI)) or percent(
                PROB_YETI_MELT
            ):
                self.yeti = None
                player.num_snomen_melted += 1

        if exists(self.yeti):
            if self.yeti < self.player_pos:
                if self.slope[self.yeti + 1] != REP_TREE:
                    self.yeti += 1
            else:
                if self.slope[self.yeti - 1] != REP_TREE:
                    self.yeti -= 1

        # If there is an ICBM, handle it.
        if exists(self.icbm_pos):
            # If there is a Yeti, move the ICBM towards him.  Else,
            # self-destruct the ICBM.
            if exists(self.yeti):
                if self.icbm_pos < self.yeti:
                    self.icbm_pos += ICBM_SPEED
                else:
                    self.icbm_pos -= ICBM_SPEED
            else:
                self.icbm_pos = None

        # If there is a fire demon on the level, handle it.
        if exists(self.demon_pos):
            # If there is a Yeti on the current level, move the demon
            # towards him.  Else, the demon might decide to leave.
            if exists(self.yeti):
                if self.demon_pos < self.yeti:
                    self.demon_pos += DEMON_SPEED
                else:
                    self.demon_pos -= DEMON_SPEED
            else:
                if percent(25.0):
                    self.demon_pos = None

        # If there is a Yeti and an ICBM on the slope, the Yeti
        # might get melted.
        if exists(self.yeti) and exists(self.icbm_pos):
            if abs(self.yeti - self.icbm_pos) <= ICBM_RANGE:
                self.icbm_pos = None
                self.yeti = None
                player.num_snomen_melted += 1

        # If there is a Yeti and a fire demon, he might get melted.
        if exists(self.yeti) and exists(self.demon_pos):
            if abs(self.yeti - self.demon_pos) <= 1:
                self.yeti = None
                player.num_snomen_melted += 1

    def __repr__(self):
        "Create a picture of the current level."
        picture = copy.copy(self.slope)
        picture[self.player_pos] = REP_PLAYER
        if exists(self.yeti):
            picture[self.yeti] = REP_YETI
        if exists(self.demon_pos):
            picture[self.demon_pos] = REP_DEMON
        if exists(self.icbm_pos):
            picture[self.icbm_pos] = REP_ICBM
        picture = colorize(picture)
        return "%4d %s" % (self.level_num, picture)


class SkiPlayer:
    "The player in all his glory."
    injuries = (
        "However, you escaped injury!",
        "But you weren't hurt at all!",
        "But you only got a few scratches.",
        "You received some cuts and bruises.",
        "You wind up with a concussion and some contusions.",
        "You now have a broken rib.",
        "Your left arm has been fractured.",
        "You suffered a broken ankle.",
        "You have a broken arm and a broken leg.",
        "You have four broken limbs and a cut!",
        "You broke every bone in your body!",
        "I'm sorry to tell you that you have been killed....",
    )

    def __init__(self):
        self.jump_count = -1
        self.num_snomen_melted = 0
        self.num_jumps_attempted = 0.0
        self.player_speed = 0
        self.meters_travelled = 0

    def __accident(self, msg, severity):
        # "__accident" is called when the player gets into an __accident,
        # which ends the game.  "msg" is the description of the
        # __accident type, and "severity" is the severity.  This
        # function should never return.
        # Compute the degree of the player's injuries.
        degree = severity + random.randint(0, INJURY_RANDOMNESS - 1)

        # Print a message indicating the termination of the game.
        print("!\n\n%s  %s\n" % (msg, SkiPlayer.injuries[degree]))

        # Print the statistics of the game.
        print(
            "You skiied %d meters with %d jumps and melted %d %s."
            % (
                self.meters_travelled,
                self.num_jumps_attempted,
                self.num_snomen_melted,
                ("Yeti", "Yetis")[self.num_snomen_melted != 1],
            )
        )

        # Initially calculate the player's score based upon the number of
        # meters travelled.
        score = self.meters_travelled * POINTS_PER_METER

        # Add bonus points for the number of jumps completed.
        score += self.num_jumps_attempted * POINTS_PER_JUMP

        # Add bonus points for each Yeti that melted during the course of
        # the game.
        score += self.num_snomen_melted * POINTS_PER_MELTED_YETI

        # Subtract a penalty for the degree of injury experienced by the
        # player.
        score += degree * POINTS_PER_INJURY_DEGREE

        # Negative scores are just too silly.
        score = max(score, 0)

        # Print the player's score.
        print("Your score for this run is %ld." % score)

        # Exit the game with a code indicating successful completion.
        sys.exit(0)

    def check_obstacles(self, world):
        # If we are just landing after a jump, we might fall down.
        if (self.jump_count == 0) and percent(PROB_BAD_LANDING):
            self.__accident("Whoops!  A bad landing!", SLIGHT_INJURY)

        # If there is a tree in our position, we might hit it.
        if (world.terrain() == REP_TREE) and percent(PROB_HIT_TREE):
            self.__accident("Oh no!  You hit a tree!", SEVERE_INJURY)

        # If there is bare ground under us, we might fall down.
        if (world.terrain() == REP_GROUND) and percent(PROB_FALL_ON_GROUND):
            self.__accident("You fell on the ground!", MODERATE_INJURY)

        # If we are on ice, we might slip.
        if (world.terrain() == REP_ICE) and percent(PROB_SLIP_ON_ICE):
            self.__accident("Oops!  You slipped on the ice!", SLIGHT_INJURY)

        # If there is a Yeti next to us, he may grab us.
        if world.nearby(world.yeti):
            self.__accident("Yikes!  The Yeti's got you!", MODERATE_INJURY)

    def update_player(self):
        "Update state of player for current move."
        self.meters_travelled += abs(self.player_speed) + 1
        # If the player was jumping, decrement the jump count.
        if player.jump_count >= 0:
            player.jump_count -= 1

    # pylint: disable=too-many-return-statements,too-many-branches
    def do_command(self, world, cmdline):
        # Print a prompt, and read a command.  Return True to advance game.
        cmd = cmdline[0].upper()
        if cmd == "?":
            print((__doc__ % version) + terrain_key)
            return False
        elif cmd == "!":
            os.system(cmdline[1:])
            return False
        elif cmd == "R":  # Move right
            if (world.terrain() != REP_ICE) and (
                self.player_speed < MAX_HORIZONTAL_PLAYER_SPEED
            ):
                self.player_speed += 1
            return True
        elif cmd == "L":  # Move left
            if (world.terrain() != REP_ICE) and (
                self.player_speed > -MAX_HORIZONTAL_PLAYER_SPEED
            ):
                self.player_speed -= 1
            return True
        elif cmd == "J":  # Jump
            self.jump_count = random.randint(0, 5) + 4
            self.num_jumps_attempted += 1.0
            return True
        elif cmd == "H":  # Do a hop
            self.jump_count = random.randint(0, 2) + 2
            self.num_jumps_attempted += 0.5
            return True
        elif cmd == "T":  # Attempt teleportation
            if percent(PROB_BAD_TELEPORT):
                self.__accident("You materialized 25 feet in the air!", SLIGHT_INJURY)
            world.player_pos = world.teleport()
            return True
        elif cmd == "I":  # Launch backpack ICBM
            if percent(PROB_BAD_ICBM):
                self.__accident("Nuclear blast in your backpack!", SEVERE_INJURY)
            world.icbm_pos = world.player_pos
            return True
        elif cmd == "D":  # Incant spell for fire demon
            if percent(PROB_BAD_SPELL):
                self.__accident("A bad spell -- the demon grabs you!", MODERATE_INJURY)
            world.demon_pos = world.teleport()
            return True
        else:
            # Any other command just advances
            return True


def colorize(picture):
    "Colorize special characters in a display list."
    for (i, c) in enumerate(picture):
        if i == 0 or (c != picture[i - 1][-1]):
            if c in colordict:
                picture[i] = colordict[c].decode("ascii") + c
            else:
                picture[i] = reset + c
    picture += reset
    return "".join(picture)


# Main sequence
if __name__ == "__main__":
    try:
        # pylint: disable=redefined-builtin
        input = raw_input
    except NameError:
        pass

    # Initialize the random number generator.
    random.seed(time.time())

    # Arrange for color to be available
    curses.setupterm()
    color = curses.tigetstr("setaf")
    for (ch, idx) in list(colordict.items()):
        if color:
            colordict[ch] = curses.tparm(color, idx)
        else:
            colordict[ch] = b""
    reset = (curses.tigetstr("sgr0") or b"").decode("ascii")
    terrain_key = colorize(terrain_key)

    print("SKI!  Version %s.  Type ? for help." % version)
    world = SkiWorld()
    player = SkiPlayer()

    # Perform the game loop until the game is over.
    try:
        repeat = 1
        while True:
            sys.stdout.write(repr(world))
            # If we are jumping, just finish the line.  Otherwise, check for
            # obstacles, and do a command.
            if player.jump_count >= 0:
                sys.stdout.write("\n")
            else:
                player.check_obstacles(world)
                if repeat:
                    repeat -= 1
                if repeat:
                    sys.stdout.write("\n")
                else:
                    cmd = input("? ")
                    if cmd == "":
                        cmd = "\n"
                    elif cmd[0] in "0123456789":
                        if cmd[-1] in "0123456789":
                            repeat = int(cmd)
                            cmd = "\n"
                        else:
                            repeat = int(cmd[:-1])
                            cmd = cmd[-1]
                if not player.do_command(world, cmd):
                    continue
            world.manipulate_objects(player)
            world.update_level()
            player.update_player()
    except (KeyboardInterrupt, EOFError):
        sys.stdout.write(reset)
        print("\nBye!")

# end
