Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions rose/common/obstacles.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
BIKE = "bike" # NOQA
WATER = "water" # NOQA
BARRIER = "barrier" # NOQA
FUEL = "fuel" # NOQA

# FUEL is not included: it is spawned by the track on its own schedule, not
# by get_random_obstacle().
ALL = (NONE, CRACK, TRASH, PENGUIN, BIKE, WATER, BARRIER)


Expand Down
8 changes: 8 additions & 0 deletions rose/engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@
score_pickup = 10
score_jump = 5
score_brake = 4

# Fuel
starting_fuel = 40
max_fuel = 60
fuel_per_move = 2
fuel_can_refill = 20
fuel_spawn_chance = 0.25
fuel_min_gap = 3 # rows a fuel can must wait after the previous one, so they spread out
31 changes: 27 additions & 4 deletions rose/engine/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@
log = logging.getLogger("logic")


def _determine_winner(players):
"""Return the name of the highest-scoring player, or None if no players."""
if not players:
return None
return max(players, key=lambda p: p.score).name


async def initialize_game(state):
"""Reset game settings and return re-initialized track and players."""
state["reset"] = None
state["running"] = 0
state["timeleft"] = config.game_duration
state["gameover"] = False
state["winner"] = None
track = initialize_track(state["track_type"] != "same")
players = await initialize_players(state["drivers"])
return track, players
Expand Down Expand Up @@ -95,9 +104,11 @@ async def game_loop(state, active_websockets):
if state["reset"] == 1:
track, players = await initialize_game(state)

# Stop game if timeleft is zero
if state["timeleft"] < 1:
# Stop game if timeleft is zero -> natural end, winner decided by score
if state["timeleft"] < 1 and state["running"] == 1:
state["running"] = 0
state["gameover"] = True
state["winner"] = _determine_winner(players)

# Check if the game is currently running and there's time left to play
if state["running"] == 1:
Expand Down Expand Up @@ -133,14 +144,26 @@ async def game_step(state, players, track, active_websockets):
"""

try:
# Cars that are out of fuel stop completely; the rest keep driving.
movable = [p for p in players if (p.fuel is None or p.fuel > 0)]

# Fetch players actions using an asynchronous HTTP session
await net.fetch_drivers_actions(players, track.matrix())
if movable:
await net.fetch_drivers_actions(movable, track.matrix())

# Update track
track.update()

# Process the actions of the players
score.process(players, track)
if movable:
score.process(movable, track)

# If every car has run dry, the round can't continue -- end it now
# by score instead of idling until the clock runs out.
if players and not movable and state["running"] == 1:
state["running"] = 0
state["gameover"] = True
state["winner"] = _determine_winner(players)

# Send data to all WebSocket connections
await net.update_websockets(True, state, players, track, active_websockets)
Expand Down
7 changes: 6 additions & 1 deletion rose/engine/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ async def fetch_driver_action(session, player, track_matrix):


async def send_post_request(session, player, track_matrix):
data = {"info": {"car": {"x": player.x, "y": player.y}}, "track": track_matrix}
data = {
"info": {"car": {"x": player.x, "y": player.y, "fuel": player.fuel}},
"track": track_matrix,
}

async with session.post(player.URL, data=json.dumps(data).encode()) as response:
return await response.json()
Expand Down Expand Up @@ -97,6 +100,8 @@ async def update_websockets(started, state, players, track, active_websockets):
"timeleft": state["timeleft"],
"players": [player.state() for player in players],
"track": track.state(),
"gameover": state.get("gameover", False),
"winner": state.get("winner"),
},
}

Expand Down
6 changes: 6 additions & 0 deletions rose/engine/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ def __init__(self, name, car, lane):
self.breaks = None
self.jumps = None
self.collisions = None
self.fuel = None
self.refuels = None
self.reset()

def reset(self):
Expand All @@ -53,6 +55,8 @@ def reset(self):
self.breaks = 0
self.collisions = 0
self.jumps = 0
self.fuel = config.starting_fuel
self.refuels = 0

def __cmp__(self, other):
x = self.score
Expand Down Expand Up @@ -84,4 +88,6 @@ def state(self):
"breaks": self.breaks,
"jumps": self.jumps,
"collisions": self.collisions,
"fuel": self.fuel,
"refuels": self.refuels,
}
25 changes: 25 additions & 0 deletions rose/engine/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ def process(players, track):

log.debug("player %s missed %s", player.name, obstacle)

elif obstacle == obstacles.FUEL:
if player.action == actions.PICKUP:
# Move forward and collect the can, refilling the tank (capped).
track.clear(player.x, player.y)
player.fuel = min(
config.max_fuel, player.fuel + config.fuel_can_refill
)
player.score += config.score_move_forward
player.refuels += 1

log.debug(
"player %s refueled: fuel=%d, got %d points",
player.name,
player.fuel,
config.score_move_forward,
)
else:
# Move forward leaving the can on the track (no refuel).
player.score += config.score_move_forward

log.debug("player %s missed %s", player.name, obstacle)

# Here we can end the game when player gets out of
# the track bounds. For now, just keep the player at the same
# location.
Expand Down Expand Up @@ -190,6 +212,9 @@ def process(players, track):

positions.add((player.x, player.y))

# Burn fuel for this move, never below zero.
player.fuel = max(0, player.fuel - config.fuel_per_move)

log.info(
"process_actions: name=%s lane=%d pos=%d,%d score=%d "
"response_time=%0.6f",
Expand Down
2 changes: 2 additions & 0 deletions rose/engine/test_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def test_player_state():
"breaks": 0,
"jumps": 0,
"collisions": 0,
"fuel": config.starting_fuel,
"refuels": 0,
}

assert player1.state() == expected_state
63 changes: 63 additions & 0 deletions rose/engine/test_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,69 @@ def test_back(self):
self.assert_remove_obstacle()


class TestFuel(SinglePlayerTest):
"""
Handling fuel cans and tank depletion.
"""

obstacle = obstacles.FUEL

def test_pickup_refuels_and_moves_forward(self):
self.player.fuel = 10
self.player.action = actions.PICKUP
self.process()
assert self.player.x == self.x
assert self.player.y == self.y
assert self.player.score == self.score + config.score_move_forward
assert self.player.fuel == 10 + config.fuel_can_refill - config.fuel_per_move
assert self.player.refuels == 1
self.assert_remove_obstacle()

def test_no_pickup_does_not_refuel(self):
# Landing on a can without PICKUP is a normal clear cell: move forward,
# no refuel, no penalty, and the can stays for another player.
self.player.fuel = 10
self.player.action = actions.NONE
self.process()
assert self.player.x == self.x
assert self.player.y == self.y
assert self.player.score == self.score + config.score_move_forward
assert self.player.fuel == 10 - config.fuel_per_move
assert self.player.refuels == 0
self.assert_keep_obstacle()

def test_pickup_is_capped_at_max_fuel(self):
self.player.fuel = config.max_fuel
self.player.action = actions.PICKUP
self.process()
assert self.player.fuel == config.max_fuel - config.fuel_per_move

def test_fuel_decreases_each_turn(self):
self.track.set(self.x, self.y, obstacles.NONE)
self.player.fuel = config.starting_fuel
self.player.action = actions.NONE
self.process()
assert self.player.fuel == config.starting_fuel - config.fuel_per_move

def test_fuel_never_goes_below_zero(self):
self.track.set(self.x, self.y, obstacles.NONE)
self.player.fuel = 0
self.player.action = actions.NONE
self.process()
assert self.player.fuel == 0

def test_empty_tank_scores_a_plain_move(self):
# A car that runs out of fuel is stopped upstream in logic.game_step
# (it is filtered out before score.process is ever called for it).
# score.process itself no longer special-cases an empty tank -- if it
# ever runs with fuel=0 it still scores a plain, unhalved move.
self.track.set(self.x, self.y, obstacles.NONE)
self.player.fuel = 0
self.player.action = actions.NONE
self.process()
assert self.player.score == self.score + config.score_move_forward


class TestCollisions(object):
"""
Handling case where two players try to move to the same cell.
Expand Down
19 changes: 19 additions & 0 deletions rose/engine/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Track(object):
def __init__(self, is_track_random=False):
self._matrix = None
self.is_track_random = is_track_random
self._rows_since_fuel = 0
self.reset()

# Game state interface
Expand Down Expand Up @@ -79,4 +80,22 @@ def _generate_row(self):
for lane in range(config.max_players):
row[cell + lane * config.cells_per_player] = obstacle

# Occasionally add a fuel can, using the same fair placement as
# above so both lanes get an equal chance to refuel. A minimum gap
# since the last can keeps them spread out instead of clustering.
self._rows_since_fuel += 1
if (
self._rows_since_fuel >= config.fuel_min_gap
and random.random() < config.fuel_spawn_chance
):
self._rows_since_fuel = 0
if self.is_track_random:
for lane in range(config.max_players):
cell = random.choice(range(0, config.cells_per_player))
row[cell + lane * config.cells_per_player] = obstacles.FUEL
else:
cell = random.choice(range(0, config.cells_per_player))
for lane in range(config.max_players):
row[cell + lane * config.cells_per_player] = obstacles.FUEL

return row