Skip to content
Merged
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
48 changes: 34 additions & 14 deletions src/ophidian.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,18 @@ def printObituaryToConsole(self):
print("-----")

def notify(self, message):
"""Player-facing feedback: always printed to console (which is the
whole UI in text mode) and, in pygame mode, also queued on
self.uiBanner so it isn't invisible behind the graphical window."""
"""Player-facing feedback: printed to console and queued on
self.uiBanner, which both UI loops render from.

The queue is needed in text mode too: a bare print() is wiped by
TextRenderer.renderGrid()'s clearScreen() later in the very same
tick, so every notification was previously invisible there (see
issue #110). In text mode the console copy therefore only survives
in redirected/piped output - the banner is what the player sees.
Gameplay code only ever calls notify(); each renderer decides how
to show the queued message."""
print(message)
if not self.config.useTextUI:
self.uiBanner.push(message)
self.uiBanner.push(message)

def drawUiMessage(self):
if self.config.useTextUI:
Expand Down Expand Up @@ -620,15 +626,25 @@ def spawnFood(self):
else:
food = Food(self.config.blue, FOOD_TYPE_SPEED)

# get target location
targetLocation = -1
notFound = True
while notFound:
targetLocation = self.environment.getGrid().getRandomLocation()
if targetLocation.getNumEntities() == 0:
notFound = False

self.environment.addEntity(food)
# food must land on an empty location: moveEntity() checks the
# destination for a SnakePart before it ever looks for food, so food
# spawned underneath a segment isn't just hidden - it's a cell that
# kills the player instead of feeding them (see issue #109).
# Choosing from the set of empty locations, rather than redrawing
# random locations until one happens to be empty, also can't spin
# forever once the snake has filled the grid.
grid = self.environment.getGrid()
emptyLocations = [
location
for location in grid.getLocations().values()
if location.getNumEntities() == 0
]
if emptyLocations:
self.environment.addEntityToLocation(food, random.choice(emptyLocations))
else:
# every cell is occupied - there is no legal spot left, but the
# board should still have food on it for when one frees up
self.environment.addEntity(food)

def activateSpeedBoost(self):
"""Starts (or refreshes) a temporary tick-speed boost from speed food.
Expand Down Expand Up @@ -762,6 +778,10 @@ def runTextUI(self):
self.textRenderer.renderGrid(
self.environment, self.snakeParts, self.collision
)
# uiBanner.current() advances/expires the queue and must be
# called exactly once per frame, mirroring UiBanner.draw() in
# the pygame loop
self.textRenderer.renderMessage(self.uiBanner.current())
self.textRenderer.renderStats(
self.level, len(self.snakeParts), self.score, percentage
)
Expand Down
11 changes: 11 additions & 0 deletions src/textui/textrenderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ def renderGrid(self, environment, snakeParts, collision):

print("\nLegend: H=Head, S=Snake, F=Food, .=Empty")

def renderMessage(self, message):
"""Render the current player-facing notification, if any.

The text-mode counterpart of the pygame UiBanner: it receives an
already-resolved string, so this renderer stays independent of the
graphical UI package.
"""
if not message:
return
print(f"\n>>> {message}")

def renderStats(self, level, snakeLength, score, percentage):
"""Render game statistics"""
print(f"\nLevel: {level}")
Expand Down
78 changes: 78 additions & 0 deletions tests/test_ophidian_food_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from food.food import Food, FOOD_TYPE_GROWTH, FOOD_TYPE_SPEED
from ophidian import Ophidian
from snake.snakePart import SnakePart


def _makeGame(monkeypatch, tmp_path):
Expand Down Expand Up @@ -101,6 +102,83 @@ def test_activating_speed_boost_twice_refreshes_timer_without_compounding(
assert game.speedBoostEndTime >= firstEndTime


def _foodLocations(game):
grid = game.environment.getGrid()
return [
grid.getLocation(locationId)
for locationId in grid.getLocations()
if any(
isinstance(entity, Food)
for entity in grid.getLocation(locationId).getEntities().values()
)
]


def test_spawn_food_lands_on_the_only_empty_location(tmp_path, monkeypatch):
# regression test: spawnFood() searched for an empty location and then
# discarded it, handing placement to Environment.addEntity()'s
# independent random draw - so food routinely landed under a snake
# part, on a cell that kills the player instead of feeding them
# (see issue #109)
game = _makeGame(monkeypatch, tmp_path)
_clearFoodFromGrid(game)
grid = game.environment.getGrid()

headLocation = game.getLocation(game.selectedSnakePart)
emptyLocation = None
for locationId in grid.getLocations():
location = grid.getLocation(locationId)
if location is headLocation:
continue
if emptyLocation is None:
emptyLocation = location
continue
game.environment.addEntityToLocation(SnakePart((0, 0, 0)), location)

game.spawnFood()

assert _foodLocations(game) == [emptyLocation]


def test_spawn_food_never_lands_on_an_occupied_location(tmp_path, monkeypatch):
game = _makeGame(monkeypatch, tmp_path)
grid = game.environment.getGrid()

# leave a handful of empty cells so placement is still random, but
# heavily weighted toward collisions if occupancy were ignored
emptyBudget = 3
for locationId in grid.getLocations():
location = grid.getLocation(locationId)
if location.getNumEntities() > 0:
continue
if emptyBudget > 0:
emptyBudget -= 1
continue
game.environment.addEntityToLocation(SnakePart((0, 0, 0)), location)

for _ in range(20):
_clearFoodFromGrid(game)
game.spawnFood()
for location in _foodLocations(game):
assert location.getNumEntities() == 1


def test_spawn_food_still_places_food_when_the_grid_is_full(tmp_path, monkeypatch):
# a completely full grid has no legal cell left; spawnFood() must fall
# back rather than hang searching for one
game = _makeGame(monkeypatch, tmp_path)
_clearFoodFromGrid(game)
grid = game.environment.getGrid()
for locationId in grid.getLocations():
location = grid.getLocation(locationId)
if location.getNumEntities() == 0:
game.environment.addEntityToLocation(SnakePart((0, 0, 0)), location)

game.spawnFood()

assert len(_foodLocations(game)) == 1


def test_spawn_food_can_produce_both_growth_and_speed_types(tmp_path, monkeypatch):
game = _makeGame(monkeypatch, tmp_path)

Expand Down
44 changes: 41 additions & 3 deletions tests/test_ophidian_ui_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,49 @@ def test_cycle_selected_cosmetic_wraps_and_updates_color_each_time(tmp_path, mon
assert game.selectedSnakePart.getColor() != SKINS_BY_ID["frost"]["color"]


def test_notify_is_console_only_in_text_ui_mode(tmp_path, monkeypatch):
def test_notify_queues_the_message_in_text_ui_mode(tmp_path, monkeypatch):
# regression test: notify() used to only print in text mode, and
# TextRenderer.renderGrid()'s clearScreen() wipes that print later in
# the same tick - so speed boosts, second_wind saves, unlocks and biome
# arrivals were all invisible to text-UI players (see issue #110)
game = _makeGame(monkeypatch, tmp_path)
game.uiBanner.queue.clear()

game.notify("hello")

# text UI has no pygame banner queue to populate
assert game.uiBanner.queue == []
assert list(game.uiBanner.queue) == ["hello"]


def test_text_ui_loop_renders_the_queued_message(tmp_path, monkeypatch):
# the loop itself must hand the banner's current message to the
# renderer - queuing it in notify() alone would still leave text-UI
# players with nothing on screen
game = _makeGame(monkeypatch, tmp_path)
game.uiBanner.queue.clear()
game.uiBanner.expiresAt = None

rendered = []
monkeypatch.setattr(TextRenderer, "renderGrid", lambda self, *args: None)
monkeypatch.setattr(TextRenderer, "renderStats", lambda self, *args: None)
monkeypatch.setattr(TextRenderer, "renderHud", lambda self, *args: None)
monkeypatch.setattr(TextRenderer, "renderControls", lambda self: None)
monkeypatch.setattr(TextRenderer, "getKeyPress", lambda self, timeout=0: None)
monkeypatch.setattr(
TextRenderer, "renderMessage", lambda self, message: rendered.append(message)
)
monkeypatch.setattr(Ophidian, "quitApplication", lambda self: None)
# one iteration only, and no real sleep between ticks
game.config.limitTickSpeed = False
monkeypatch.setattr(
Ophidian,
"moveEntity",
lambda self, entity, direction: setattr(self, "running", False),
)

game.notify("Speed boost!")
game.runTextUI()

assert rendered == ["Speed boost!"]


def test_notify_queues_messages_instead_of_clobbering(tmp_path, monkeypatch):
Expand All @@ -51,6 +87,8 @@ def test_notify_queues_messages_instead_of_clobbering(tmp_path, monkeypatch):
# drawn - a single-slot uiMessage would silently lose the first message
game = _makeGame(monkeypatch, tmp_path)
game.config.useTextUI = False # pretend pygame mode without a real display
# __init__ already queued a "<name> enters <biome>" notify()
game.uiBanner.queue.clear()

game.notify("The ophidian ascends! (Ascension 1)")
game.notify("Medusa enters The Sunken Grove.")
Expand Down
13 changes: 13 additions & 0 deletions tests/textui/test_textrenderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ def test_render_stats_shows_level_length_score_and_progress(renderer, capsys):
assert "█" * 15 + "░" * 15 in out


def test_render_message_prints_the_current_notification(renderer, capsys):
renderer.renderMessage("Speed boost!")

assert "Speed boost!" in capsys.readouterr().out


def test_render_message_prints_nothing_when_there_is_no_message(renderer, capsys):
renderer.renderMessage(None)
renderer.renderMessage("")

assert capsys.readouterr().out == ""


def test_render_hud_shows_currency_and_active_upgrades(renderer, capsys):
renderer.renderHud(currency=42, activeUpgradeLabels=["Head Start", "Extra Life"])

Expand Down