From 090ca3939f699261a29e7d5289ef02a171b83769 Mon Sep 17 00:00:00 2001 From: Hila Cohen Date: Thu, 16 Jul 2026 10:04:14 +0300 Subject: [PATCH 1/2] engine change --- rose/engine/config.py | 8 +++++ rose/engine/logic.py | 31 ++++++++++++++++--- rose/engine/net.py | 4 ++- rose/engine/player.py | 6 ++++ rose/engine/score.py | 25 ++++++++++++++++ rose/engine/test_score.py | 63 +++++++++++++++++++++++++++++++++++++++ rose/engine/track.py | 19 ++++++++++++ 7 files changed, 151 insertions(+), 5 deletions(-) diff --git a/rose/engine/config.py b/rose/engine/config.py index 38e343d..7b0e2d0 100644 --- a/rose/engine/config.py +++ b/rose/engine/config.py @@ -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 diff --git a/rose/engine/logic.py b/rose/engine/logic.py index 9f92aba..8d598ff 100644 --- a/rose/engine/logic.py +++ b/rose/engine/logic.py @@ -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 @@ -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: @@ -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) diff --git a/rose/engine/net.py b/rose/engine/net.py index 64c6525..9dbc32e 100644 --- a/rose/engine/net.py +++ b/rose/engine/net.py @@ -61,7 +61,7 @@ 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() @@ -97,6 +97,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"), }, } diff --git a/rose/engine/player.py b/rose/engine/player.py index 208dc0e..b7a28b2 100644 --- a/rose/engine/player.py +++ b/rose/engine/player.py @@ -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): @@ -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 @@ -84,4 +88,6 @@ def state(self): "breaks": self.breaks, "jumps": self.jumps, "collisions": self.collisions, + "fuel": self.fuel, + "refuels": self.refuels, } diff --git a/rose/engine/score.py b/rose/engine/score.py index 1936432..476a379 100644 --- a/rose/engine/score.py +++ b/rose/engine/score.py @@ -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. @@ -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", diff --git a/rose/engine/test_score.py b/rose/engine/test_score.py index 680b7e3..a9ed48e 100644 --- a/rose/engine/test_score.py +++ b/rose/engine/test_score.py @@ -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. diff --git a/rose/engine/track.py b/rose/engine/track.py index eb791ef..988d708 100644 --- a/rose/engine/track.py +++ b/rose/engine/track.py @@ -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 @@ -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 From e06a7937dee61c255699412d8c7314d06894cb5d Mon Sep 17 00:00:00 2001 From: Hila Cohen Date: Thu, 16 Jul 2026 11:48:36 +0300 Subject: [PATCH 2/2] Fix broken tests from fuel feature The fuel feature added score, track and net handling that referenced obstacles.FUEL, but the constant was never defined, so the whole test suite failed to collect. Define FUEL in rose.common.obstacles. It is intentionally kept out of ALL: that tuple feeds get_random_obstacle(), and track.py already spawns fuel cans on a separate path with its own spawn chance and minimum-gap rule, so including it there would bypass that logic. Add the new fuel and refuels keys to the expected dict in test_player_state, which asserts on the full Player.state() output. Also wrap the send_post_request payload to satisfy black. Co-Authored-By: Claude Opus 4.8 (1M context) --- rose/common/obstacles.py | 3 +++ rose/engine/net.py | 5 ++++- rose/engine/test_player.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/rose/common/obstacles.py b/rose/common/obstacles.py index 3b84f9c..2b2e4e9 100644 --- a/rose/common/obstacles.py +++ b/rose/common/obstacles.py @@ -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) diff --git a/rose/engine/net.py b/rose/engine/net.py index 9dbc32e..c7f855c 100644 --- a/rose/engine/net.py +++ b/rose/engine/net.py @@ -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, "fuel": player.fuel}}, "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() diff --git a/rose/engine/test_player.py b/rose/engine/test_player.py index 3ddac3b..d6de130 100644 --- a/rose/engine/test_player.py +++ b/rose/engine/test_player.py @@ -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