From f9652d5f649988b155f08625cd7b197b7d2b4833 Mon Sep 17 00:00:00 2001 From: dmccoystephenson Date: Mon, 3 Aug 2026 09:06:47 +0000 Subject: [PATCH] Grey out menu options the game would refuse, with the reason An option the player had earned but couldn't use right now looked exactly like one they could: the only way to find out you were too tired to fish was to pick "Fish" and be told so afterwards. Every front-end now shows those options as unpickable and says why on the row itself. showOptions takes an optional {optionNumber: reason} mapping, converted once in BaseUserInterface into a list parallel to the options. The web front-end publishes it to the browser, which renders a greyed-out, disabled button carrying the reason (and ignores its number key); the console and pygame front-ends tag the same rows, skip them, and name the blocker instead of "Try again!". Marked so far: fishing without the energy for an hour, selling with an empty hold, gear that's unaffordable or already maxed out, drinking or buying a boat/property/home you can't pay for, repairing or upgrading a hull whose bill you can't cover, depositing with an empty purse, withdrawing from an empty account, and guessing a dice face before staking anything. The game's own post-choice guards are left untouched, so nothing depends on a front-end honouring this. Marking every option is treated as a bug and dropped rather than leaving a menu that accepts nothing. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + src/location/bank.py | 17 ++++- src/location/docks.py | 56 +++++++++++--- src/location/home.py | 11 ++- src/location/shop.py | 18 ++++- src/location/tavern.py | 25 ++++++- src/ui/baseUserInterface.py | 61 ++++++++++++++- src/ui/pygameUserInterface.py | 94 ++++++++++++++++++----- src/ui/userInterface.py | 28 +++++-- src/ui/webUserInterface.py | 12 ++- tests/location/test_bank.py | 86 +++++++++++++++++++-- tests/location/test_docks.py | 99 ++++++++++++++++++++++++- tests/location/test_home.py | 61 +++++++++++++++ tests/location/test_shop.py | 85 ++++++++++++++++++++- tests/location/test_tavern.py | 74 +++++++++++++++++- tests/ui/test_baseUserInterface.py | 68 ++++++++++++++++- tests/ui/test_pygameUserInterface.py | 107 +++++++++++++++++++++++++++ tests/ui/test_userInterface.py | 42 +++++++++++ tests/ui/test_webUserInterface.py | 46 ++++++++++++ tests/web/test_clientParity.py | 12 +++ web/client.css | 7 ++ web/client.js | 29 +++++++- 22 files changed, 971 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 75bd80f..8868ef8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Sell that basket and you're shown where you sleep; put in a few days and the vil Nothing is made harder by this; it's pacing, not difficulty. A save file from a player who already owns half the village opens with all of it available, exactly as before. +An option you've earned but can't use right now stays on the menu and says why, rather than being picked and refused: fishing with an empty energy bar, buying gear you can't afford or have already maxed out, withdrawing from an empty account, guessing a dice face before you've staked anything. In the browser those are greyed-out buttons carrying the reason; the console and pygame front-ends tag the same rows and skip over them. + ### Your Goal Build a fortune of **$10,000** in total wealth (cash on hand plus savings in the bank). You're told about it once you've got your first $1,000 to your name, after which your progress toward the goal is shown in the status header. Reaching it earns a one-time victory — after which you're free to keep fishing or retire from the Home menu. diff --git a/src/location/bank.py b/src/location/bank.py index 84d65c9..b449cd9 100644 --- a/src/location/bank.py +++ b/src/location/bank.py @@ -119,6 +119,13 @@ def run(self): # a fixed number. li = ["Make a Deposit", "Make a Withdrawal"] actions = ["deposit", "withdraw"] + # An empty purse or an empty account makes one side of the counter + # pointless; both are visible in the header, so say which one is out. + unavailable = {} + if not (self.player.operatorMode or self.player.money > 0): + unavailable[1] = "no money on you" + if self.player.moneyInBank <= 0: + unavailable[2] = "nothing in the bank" if progression.isUnlocked(self.stats, progression.TALK): li.append("Talk to %s" % self.npc.name) actions.append("talk") @@ -131,6 +138,7 @@ def run(self): input = self.userInterface.showOptions( "You're at the front of the line and the teller asks you what you want to do.", li, + unavailable, ) action = actions[int(input) - 1] @@ -188,6 +196,7 @@ def manageInvestments(self): while True: options = [] actions = [] + unavailable = {} owned = investments.ownedCounts(self.player) for typeId in range(1, len(investments.PROPERTY_TYPES) + 1): info = investments.typeInfo(typeId) @@ -196,6 +205,10 @@ def manageInvestments(self): % (info["name"], info["cost"], info["dailyIncome"]) ) actions.append(("buy", typeId)) + # The whole list is always shown, so the affordable rungs of the + # ladder read against the ones still out of reach. + if not self.player.canAfford(info["cost"]): + unavailable[len(options)] = "not enough money" if owned.get(typeId, 0) > 0: options.append( "Sell a %s (+$%d)" % (info["name"], info["resaleValue"]) @@ -205,7 +218,9 @@ def manageInvestments(self): actions.append(("back", None)) choice = int( - self.userInterface.showOptions(self._investmentsStatus(), options) + self.userInterface.showOptions( + self._investmentsStatus(), options, unavailable + ) ) action, typeId = actions[choice - 1] diff --git a/src/location/docks.py b/src/location/docks.py index 0d9260a..64949cb 100644 --- a/src/location/docks.py +++ b/src/location/docks.py @@ -23,6 +23,9 @@ REACTION_BASE_WINDOW = 2.0 ROD_WINDOW_STEP = 0.5 +# Energy spent per hour of fishing, and so the minimum needed to cast at all. +FISHING_ENERGY_COST = 10 + # @author Daniel McCoy Stephenson class Docks: @@ -103,6 +106,14 @@ def run(self): # a new one shows up. Quit stays last. li = ["Fish"] actions = ["fish"] + # Fishing is the one option here the player can be blocked from, and + # running out of energy is the commonest way a day ends - so say so on + # the option itself rather than only after they've picked it. + unavailable = {} + if not self.player.hasEnergy(FISHING_ENERGY_COST): + unavailable[len(li)] = ( + "needs %d energy - sleep at home" % FISHING_ENERGY_COST + ) for feature, label, action in ( (progression.SHOP, "Go to Shop", "shop"), (progression.HOME, "Go Home", "home"), @@ -142,13 +153,13 @@ def run(self): notices = boats.needsAttention(self.player) if notices: descriptor += "\n\nNeeds attention: " + "; ".join(notices) + "." - input = self.userInterface.showOptions(descriptor, li) + input = self.userInterface.showOptions(descriptor, li, unavailable) choice = int(input) action = actions[choice - 1] if action == "fish": - if self.player.hasEnergy(10): + if self.player.hasEnergy(FISHING_ENERGY_COST): self.fish() return LocationType.DOCKS else: @@ -372,9 +383,13 @@ def manageFleet(self): options = [] actions = [] + unavailable = {} + starter = business.tierInfo(1) options.append("Buy a %s ($%d)" % (starter["name"], starter["cost"])) actions.append("buy_boat") + if not self.player.canAfford(starter["cost"]): + unavailable[len(options)] = "not enough money" if self.player.boats: berths = boats.totalCrewBerths(self.player) @@ -408,7 +423,11 @@ def manageFleet(self): options.append("Back") actions.append("back") - choice = int(self.userInterface.showOptions(self._fleetStatus(), options)) + choice = int( + self.userInterface.showOptions( + self._fleetStatus(), options, unavailable + ) + ) action = actions[choice - 1] if action == "buy_boat": @@ -435,15 +454,20 @@ def manageFleet(self): self.currentPrompt.text = "What would you like to do?" return - def _pickBoat(self, prompt, candidates=None): + def _pickBoat(self, prompt, candidates=None, unavailable=None): """Shared boat chooser. Returns the boat, or None if the player backed - out - every fleet action needs one and they should all read the same.""" + out - every fleet action needs one and they should all read the same. + + unavailable maps a 1-based position in candidates to the reason that + boat can't be chosen (an unaffordable repair bill, say), so the ones + the player can't act on are greyed out rather than picked and refused. + "Back" is appended last and is never marked.""" candidates = self.player.boats if candidates is None else candidates if not candidates: return None options = [boats.describeBoat(boat) for boat in candidates] options.append("Back") - choice = int(self.userInterface.showOptions(prompt, options)) + choice = int(self.userInterface.showOptions(prompt, options, unavailable)) if choice == len(options): return None return candidates[choice - 1] @@ -556,7 +580,14 @@ def _assignCrew(self): def _repairBoat(self): damaged = [boat for boat in self.player.boats if boat["damage"] > 0] - boat = self._pickBoat("Which boat needs work?", damaged) + # Each hull has its own bill, so which ones are out of reach depends on + # the boat rather than on the menu as a whole. + unaffordable = { + index + 1: "repair costs $%d" % boats.repairCost(boat) + for index, boat in enumerate(damaged) + if not self.player.canAfford(boats.repairCost(boat)) + } + boat = self._pickBoat("Which boat needs work?", damaged, unaffordable) if boat is None: return cost = boats.repairCost(boat) @@ -579,7 +610,12 @@ def _upgradeBoat(self): for boat in self.player.boats if boat["tier"] < len(business.BOAT_TIERS) ] - boat = self._pickBoat("Which boat are you upgrading?", upgradable) + unaffordable = { + index + 1: "upgrade costs $%d" % business.tierInfo(boat["tier"] + 1)["cost"] + for index, boat in enumerate(upgradable) + if not self.player.canAfford(business.tierInfo(boat["tier"] + 1)["cost"]) + } + boat = self._pickBoat("Which boat are you upgrading?", upgradable, unaffordable) if boat is None: return nextInfo = business.tierInfo(boat["tier"] + 1) @@ -1117,10 +1153,10 @@ def fish(self): hours = random.randint(1, 10) # Check if player has enough energy for all hours - energy_needed = hours * 10 + energy_needed = hours * FISHING_ENERGY_COST if not self.player.hasEnergy(energy_needed): # Fish for as many hours as energy allows - hours = self.player.energy // 10 + hours = self.player.energy // FISHING_ENERGY_COST if hours == 0: self.currentPrompt.text = "You're too tired to fish! Go home and sleep." return diff --git a/src/location/home.py b/src/location/home.py index 83b11ac..edfee79 100644 --- a/src/location/home.py +++ b/src/location/home.py @@ -155,13 +155,22 @@ def manageHome(self): tier = housing.currentTier(self.player) options = [] actions = [] + unavailable = {} for targetTier, targetInfo, netCost in self._availableMoves(): options.append(self._moveLabel(tier, targetTier, targetInfo, netCost)) actions.append(("move", targetTier)) + # Moving down pays out and is always possible; only a move that + # costs money can be out of reach (see housing.moveHome). + if netCost > 0 and not self.player.canAfford(netCost): + unavailable[len(options)] = "not enough money" options.append("Back") actions.append(("back", None)) - choice = int(self.userInterface.showOptions(self._housingStatus(), options)) + choice = int( + self.userInterface.showOptions( + self._housingStatus(), options, unavailable + ) + ) action, targetTier = actions[choice - 1] if action == "move": diff --git a/src/location/shop.py b/src/location/shop.py index a00dd76..5c97ce2 100644 --- a/src/location/shop.py +++ b/src/location/shop.py @@ -207,12 +207,27 @@ def run(self): # actions are built as a pair rather than dispatched on a fixed number. li = ["Sell Fish"] actions = ["sell"] + # Every purchase here has a gate the player can be on the wrong side of + # (an empty hold, an empty purse, gear already maxed out), so each row + # is marked as it is added - len(li) is always the number just used. + unavailable = {} + if self.player.fishCount == 0: + unavailable[len(li)] = "you have no fish" if progression.isUnlocked(self.stats, progression.BAIT): li.append("Buy Better Bait ( $%.2f )" % self.player.priceForBait) actions.append("bait") + if self.player.fishMultiplier >= MAX_FISH_MULTIPLIER: + unavailable[len(li)] = "already the best bait" + elif not self.player.canAfford(self.player.priceForBait): + unavailable[len(li)] = "not enough money" if progression.isUnlocked(self.stats, progression.ROD): - li.append("Buy Better Rod ( $%.2f )" % rodUpgradeCost(self.player.rodLevel)) + rodCost = rodUpgradeCost(self.player.rodLevel) + li.append("Buy Better Rod ( $%.2f )" % rodCost) actions.append("rod") + if self.player.rodLevel >= MAX_ROD_LEVEL: + unavailable[len(li)] = "already the finest rod" + elif not self.player.canAfford(rodCost): + unavailable[len(li)] = "not enough money" if progression.isUnlocked(self.stats, progression.TALK): li.append("Talk to %s" % self.npc.name) actions.append("talk") @@ -222,6 +237,7 @@ def run(self): input = self.userInterface.showOptions( "The shopkeeper winks at you as you behold his collection of fishing poles.", li, + unavailable, ) action = actions[int(input) - 1] diff --git a/src/location/tavern.py b/src/location/tavern.py index 17878af..fffbff6 100644 --- a/src/location/tavern.py +++ b/src/location/tavern.py @@ -25,6 +25,10 @@ DRUNK_LOSS_CHANCE = 0.3 DRUNK_TIP_CHANCE = 0.3 +# What a night's drinking costs. Named so the menu label, the affordability +# check and the charge itself can't drift apart. +DRINK_COST = 10 + # @author Daniel McCoy Stephenson class Tavern: @@ -145,8 +149,11 @@ def run(self): # Options and actions are built as a pair rather than dispatched on a # fixed number, because talking to Old Tom is revealed separately from # the tavern itself (see src/progression). - li = ["Get drunk ( $10 )", "Gamble"] + li = ["Get drunk ( $%d )" % DRINK_COST, "Gamble"] actions = ["drink", "gamble"] + unavailable = {} + if not self.player.canAfford(DRINK_COST): + unavailable[1] = "not enough money" if progression.isUnlocked(self.stats, progression.TALK): li.append("Talk to %s" % self.npc.name) actions.append("talk") @@ -154,12 +161,14 @@ def run(self): actions.append("docks") input = self.userInterface.showOptions( - "You sit at the bar, watching the barkeep clean a mug with a dirty rag.", li + "You sit at the bar, watching the barkeep clean a mug with a dirty rag.", + li, + unavailable, ) action = actions[int(input) - 1] if action == "drink": - if self.player.canAfford(10): + if self.player.canAfford(DRINK_COST): self.getDrunk() return LocationType.HOME else: @@ -182,7 +191,7 @@ def run(self): return LocationType.DOCKS def getDrunk(self): - self.player.spendMoney(10) + self.player.spendMoney(DRINK_COST) self.userInterface.showBusy("You drink your way into the evening...", 3) @@ -222,12 +231,20 @@ def getDrunk(self): def gamble(self): while True: li = ["1", "2", "3", "4", "5", "6", "Change Bet", "Back"] + # Nothing rides on a face until money is on the table, so the six + # faces are greyed out until the player has set a bet - Change Bet + # and Back stay live, and they're the only way forward anyway. + unavailable = {} + if self.currentBet <= 0: + for face in range(1, 7): + unavailable[face] = "place a bet first" input = int( self.userInterface.showOptions( "Once you place your bet, the burly man in front of you will throw the dice. " "Guess the number right and he pays out %dx your bet." % DICE_WIN_MULTIPLIER, li, + unavailable, ) ) diff --git a/src/ui/baseUserInterface.py b/src/ui/baseUserInterface.py index 18cb1ed..ec25676 100644 --- a/src/ui/baseUserInterface.py +++ b/src/ui/baseUserInterface.py @@ -5,6 +5,23 @@ from world.timeService import TimeService +def unavailableSuffix(reason): + """How a text front-end tags a menu row the game would refuse. + + Shared by the console and pygame front-ends so the wording is identical in + both; the web front-end sends the bare reason to the browser instead, which + styles it rather than appending it to the label.""" + return "" if reason is None else " (unavailable: %s)" % reason + + +def unavailableMessage(reason): + """What to say when the player picks an option that can't be picked. + + Names the blocker and what to do about it rather than a bare "try again", + which would read as though they had mistyped.""" + return "You can't do that right now: %s." % reason + + # @author Daniel McCoy Stephenson class BaseUserInterface(ABC): """Abstract contract every front-end (text/console, pygame, web, ...) implements. @@ -63,10 +80,50 @@ def divider(self): pass @abstractmethod - def showOptions(self, descriptor, optionList): - """Show numbered options and return the chosen option's number as a string.""" + def showOptions(self, descriptor, optionList, unavailableOptions=None): + """Show numbered options and return the chosen option's number as a string. + + unavailableOptions is an optional {optionNumber: reason} mapping naming + the 1-based options the game would refuse right now, each with a short + reason ("needs 10 energy - sleep at home"). Every front-end must show + those options as unpickable, spell the reason out beside them, and + refuse to return their number - see unavailableReasons().""" pass + def unavailableReasons(self, optionList, unavailableOptions): + """One entry per option: the reason it can't be picked, or None. + + Call sites pass {optionNumber: reason} rather than a parallel list + because a menu is built by appending, so the row just added is always + len(optionList) and the numbers can't drift out of step as options + appear and disappear with the player's progress. Front-ends want the + parallel list, so the conversion happens once, here. + """ + reasons = [None] * len(optionList) + for number, reason in (unavailableOptions or {}).items(): + if not 1 <= number <= len(optionList): + raise ValueError( + "showOptions was told option %r is unavailable (%r), but " + "the menu only has %d option(s). The keys of " + "unavailableOptions are 1-based option numbers into the " + "list passed alongside it - a menu that appends its rows " + "should mark one with len(optionList) right after " + "appending it." % (number, reason, len(optionList)) + ) + reasons[number - 1] = reason + # Marking every option unavailable would leave the player facing a menu + # that accepts nothing, which no front-end can recover from. Fall back + # to a normal menu instead and let the game give its own refusal. + if optionList and all(reason is not None for reason in reasons): + return [None] * len(optionList) + return reasons + + def selectableNumbers(self, reasons): + """The 1-based option numbers a front-end may return, as strings.""" + return { + str(index + 1) for index, reason in enumerate(reasons) if reason is None + } + @abstractmethod def showDialogue(self, text): """Show a block of text and wait for the player to acknowledge it.""" diff --git a/src/ui/pygameUserInterface.py b/src/ui/pygameUserInterface.py index f4d62a8..083461d 100644 --- a/src/ui/pygameUserInterface.py +++ b/src/ui/pygameUserInterface.py @@ -1,7 +1,11 @@ import pygame import sys import time -from ui.baseUserInterface import BaseUserInterface +from ui.baseUserInterface import ( + BaseUserInterface, + unavailableMessage, + unavailableSuffix, +) from prompt.prompt import Prompt from player.player import Player from world.timeService import TimeService @@ -140,9 +144,50 @@ def divider(self): # This will be called during drawing, so we'll store it as a flag pass - def showOptions(self, descriptor, optionList): + def _selectableIndexes(self, reasons): + """Indexes the highlight is allowed to land on, in order. + + Options the game would refuse are drawn greyed out but skipped over by + the arrow keys, so holding DOWN never parks the cursor somewhere ENTER + does nothing.""" + return [index for index, reason in enumerate(reasons) if reason is None] + + def _initialSelection(self, reasons): + """Where the highlight opens: the first option the player can choose, + so a menu whose first row is greyed out doesn't start on it.""" + selectable = self._selectableIndexes(reasons) + return selectable[0] if selectable else 0 + + def _moveSelection(self, reasons, step): + """The next selectable option in the given direction, wrapping around.""" + selectable = self._selectableIndexes(reasons) + if not selectable: + return self.selected_option + if self.selected_option in selectable: + position = selectable.index(self.selected_option) + else: + # Nothing selectable is highlighted yet (an empty menu can't happen, + # but a stale highlight can) - step in from the nearest end. + position = -1 if step > 0 else 0 + return selectable[(position + step) % len(selectable)] + + def _optionRows(self, optionList, reasons): + """(text, isUnavailable) per option row, ready to draw. + + Split out from _draw_game_screen for the same reason as _statusLines: + the wording can be asserted without a real font.""" + return [ + ( + "[%d] %s%s" % (index + 1, option, unavailableSuffix(reason)), + reason is not None, + ) + for index, (option, reason) in enumerate(zip(optionList, reasons)) + ] + + def showOptions(self, descriptor, optionList, unavailableOptions=None): + reasons = self.unavailableReasons(optionList, unavailableOptions) self.current_options = optionList - self.selected_option = 0 + self.selected_option = self._initialSelection(reasons) self.waiting_for_input = True while self.waiting_for_input: @@ -155,24 +200,26 @@ def showOptions(self, descriptor, optionList): self._handle_resize(event.w, event.h) elif event.type == pygame.KEYDOWN: if event.key == pygame.K_UP: - self.selected_option = (self.selected_option - 1) % len( - optionList - ) + self.selected_option = self._moveSelection(reasons, -1) elif event.key == pygame.K_DOWN: - self.selected_option = (self.selected_option + 1) % len( - optionList - ) + self.selected_option = self._moveSelection(reasons, 1) elif event.key == pygame.K_RETURN or event.key == pygame.K_SPACE: - self.waiting_for_input = False - return str(self.selected_option + 1) + if reasons[self.selected_option] is None: + self.waiting_for_input = False + return str(self.selected_option + 1) elif event.key >= pygame.K_1 and event.key <= pygame.K_9: option_num = event.key - pygame.K_1 + 1 if option_num <= len(optionList): - self.waiting_for_input = False - return str(option_num) + reason = reasons[option_num - 1] + if reason is None: + self.waiting_for_input = False + return str(option_num) + # Say why instead of swallowing the keypress; the + # prompt is redrawn on the next frame. + self.currentPrompt.text = unavailableMessage(reason) # Draw the UI - self._draw_game_screen(descriptor, optionList) + self._draw_game_screen(descriptor, optionList, reasons) # Update display pygame.display.flip() @@ -261,8 +308,10 @@ def _gameScreenLayout(self, statusLineCount, promptLineCount, optionCount): "instructionsY": instructionsY, } - def _draw_game_screen(self, descriptor, optionList): + def _draw_game_screen(self, descriptor, optionList, reasons=None): """Draw the main game screen with responsive layout""" + if reasons is None: + reasons = [None] * len(optionList) # Clear screen self.screen.fill(self.BLACK) @@ -320,12 +369,19 @@ def _draw_game_screen(self, descriptor, optionList): highlight_margin = self.width * 0.02 # 2% margin for highlight y_offset = layout["optionsY"] - for i, option in enumerate(optionList): - color = self.LIGHT_BLUE if i == self.selected_option else self.WHITE - option_text = f"[{i + 1}] {option}" + for i, (option_text, unavailable) in enumerate( + self._optionRows(optionList, reasons) + ): + # Greyed out is how an unavailable option reads here, matching the + # web front-end's disabled buttons; the highlight never lands on + # one, so the selected colour can't apply to it. + if unavailable: + color = self.GRAY + else: + color = self.LIGHT_BLUE if i == self.selected_option else self.WHITE # Draw selection highlight with proportional sizing - if i == self.selected_option: + if i == self.selected_option and not unavailable: highlight_x = margin_x + highlight_margin highlight_width = self.width - 2 * (margin_x + highlight_margin) rect = pygame.Rect( diff --git a/src/ui/userInterface.py b/src/ui/userInterface.py index daf4e7c..b404e86 100644 --- a/src/ui/userInterface.py +++ b/src/ui/userInterface.py @@ -1,6 +1,10 @@ import sys import time -from ui.baseUserInterface import BaseUserInterface +from ui.baseUserInterface import ( + BaseUserInterface, + unavailableMessage, + unavailableSuffix, +) from prompt.prompt import Prompt from player.player import Player from world.timeService import TimeService @@ -30,7 +34,10 @@ def showOptions( self, descriptor, optionList, + unavailableOptions=None, ): + reasons = self.unavailableReasons(optionList, unavailableOptions) + selectable = self.selectableNumbers(reasons) while True: self.lotsOfSpace() self.divider() @@ -54,17 +61,24 @@ def showOptions( self.divider() self.n = 1 self.listOfN = [] - for option in optionList: - print(" [%d] %s" % (self.n, option)) + for option, reason in zip(optionList, reasons): + # An option the game would refuse is still listed - hiding it + # would leave the player wondering where it went - but it is + # marked with the reason instead of being selectable. + print(" [%d] %s%s" % (self.n, option, unavailableSuffix(reason))) self.listOfN.append("%d" % self.n) self.n += 1 choice = input("\n> ") - for i in self.listOfN: - if choice == i: - return choice + if choice in selectable: + return choice - self.currentPrompt.text = "Try again!" + if choice in self.listOfN: + # A listed option that can't be picked: say why rather than + # leaving "Try again!" to imply the player mistyped. + self.currentPrompt.text = unavailableMessage(reasons[int(choice) - 1]) + else: + self.currentPrompt.text = "Try again!" def showDialogue(self, text): self.lotsOfSpace() diff --git a/src/ui/webUserInterface.py b/src/ui/webUserInterface.py index 22fd73f..ca2d63e 100644 --- a/src/ui/webUserInterface.py +++ b/src/ui/webUserInterface.py @@ -246,22 +246,28 @@ def lotsOfSpace(self): def divider(self): pass - def showOptions(self, descriptor, optionList): + def showOptions(self, descriptor, optionList, unavailableOptions=None): + # "unavailable" is a list parallel to "options" - the reason each one + # can't be picked, or null. The browser greys those buttons out and + # shows the reason on the row; sending the reason as data rather than + # baked into the label is what lets it be styled apart from the option. + reasons = self.unavailableReasons(optionList, unavailableOptions) self._present( { "type": "options", "descriptor": descriptor, "prompt": self.currentPrompt.text, "options": list(optionList), + "unavailable": reasons, "header": self._header(), } ) - valid = {str(i + 1) for i in range(len(optionList))} + valid = self.selectableNumbers(reasons) while True: choice = str(self._awaitInput()) if choice in valid: return choice - # ignore anything that isn't a listed option and keep waiting + # ignore anything that isn't a selectable option and keep waiting def showDialogue(self, text): self._present({"type": "dialogue", "text": text}) diff --git a/tests/location/test_bank.py b/tests/location/test_bank.py index b6607bf..018dd5e 100644 --- a/tests/location/test_bank.py +++ b/tests/location/test_bank.py @@ -7,6 +7,7 @@ from src.ui.userInterface import UserInterface from src.world.timeService import TimeService from src.progression import progression +from src.investments import investments from unittest.mock import MagicMock @@ -361,9 +362,7 @@ def test_deposit_negative_amount_is_rejected(): bankInstance.userInterface.divider = MagicMock() bankInstance.player.money = 20 bankInstance.player.moneyInBank = 0 - bankInstance.userInterface.promptForNumber = MagicMock( - side_effect=[-100.0, 10.0] - ) + bankInstance.userInterface.promptForNumber = MagicMock(side_effect=[-100.0, 10.0]) # call bankInstance.deposit() @@ -383,9 +382,7 @@ def test_withdraw_negative_amount_is_rejected(): bankInstance.userInterface.divider = MagicMock() bankInstance.player.moneyInBank = 400 bankInstance.player.money = 20 - bankInstance.userInterface.promptForNumber = MagicMock( - side_effect=[-500.0, 10.0] - ) + bankInstance.userInterface.promptForNumber = MagicMock(side_effect=[-500.0, 10.0]) # call bankInstance.withdraw() @@ -514,3 +511,80 @@ def test_run_withdrawal_fires_from_its_position_with_a_short_menu(): # check assert nextLocation == LocationType.BANK bankInstance.withdraw.assert_called_once() + + +def markedOptions(locationInstance): + """{option label: reason} from the last showOptions call.""" + call = locationInstance.userInterface.showOptions.call_args + options = call[0][1] + reasons = call[0][2] if len(call[0]) > 2 else {} + return {options[number - 1]: reason for number, reason in (reasons or {}).items()} + + +def test_run_greys_out_the_side_of_the_counter_with_nothing_to_move(): + # prepare - money in hand and nothing saved yet: depositing works, + # withdrawing has nothing to withdraw + bankInstance = createBank() + bankInstance.player.money = 50 + bankInstance.player.moneyInBank = 0 + bankInstance.userInterface.showOptions = MagicMock(return_value="5") + + # call + bankInstance.run() + + # check + assert markedOptions(bankInstance) == {"Make a Withdrawal": "nothing in the bank"} + + # prepare - and the other way round + bankInstance.player.money = 0 + bankInstance.player.moneyInBank = 50 + + # call + bankInstance.run() + + # check + assert markedOptions(bankInstance) == {"Make a Deposit": "no money on you"} + + +def test_run_leaves_depositing_available_in_operator_mode(): + # prepare - operator mode can deposit with an empty purse (see Bank.run), + # so the option must not be greyed out + bankInstance = createBank() + bankInstance.player.money = 0 + bankInstance.player.moneyInBank = 50 + bankInstance.player.operatorMode = True + bankInstance.userInterface.showOptions = MagicMock(return_value="5") + + # call + bankInstance.run() + + # check + assert markedOptions(bankInstance) == {} + + +def test_manageInvestments_greys_out_properties_out_of_reach(): + # prepare - enough for the cheapest property and nothing more, so the + # ladder reads with the affordable rung against the ones still to come + bankInstance = createBank() + cheapest = investments.typeInfo(1)["cost"] + bankInstance.player.money = cheapest + + def chooseBack(descriptor, options, unavailableOptions=None): + return str(len(options)) # Back is always last + + bankInstance.userInterface.showOptions = MagicMock(side_effect=chooseBack) + + # call + bankInstance.manageInvestments() + + # check - the cheapest rung is live, everything dearer is marked, and Back + # is never marked + marked = markedOptions(bankInstance) + assert set(marked.values()) == {"not enough money"} + assert all(label.startswith("Buy a") for label in marked) + assert not any( + label.startswith("Buy a %s" % investments.typeInfo(1)["name"]) + for label in marked + ) + dearer = len(investments.PROPERTY_TYPES) - 1 + assert len(marked) == dearer diff --git a/tests/location/test_docks.py b/tests/location/test_docks.py index 29cbeaa..84898ca 100644 --- a/tests/location/test_docks.py +++ b/tests/location/test_docks.py @@ -38,7 +38,7 @@ def dockChooser(label): Which options are on the menu (and so what each one is numbered) depends on how much of the game the player has unlocked, so tests say what they mean.""" - def chooser(descriptor, optionList): + def chooser(descriptor, optionList, unavailableOptions=None): for index, option in enumerate(optionList, start=1): if option == label or option.startswith(label): return str(index) @@ -563,7 +563,7 @@ def fleetChooser(*wanted): these tests from breaking every time an option is added.""" remaining = list(wanted) - def choose(descriptor, options): + def choose(descriptor, options, unavailableOptions=None): if remaining: prefix = remaining.pop(0) for index, option in enumerate(options, start=1): @@ -1368,7 +1368,7 @@ def voyageChooser(*wanted, **kwargs): remaining = list(wanted) then = kwargs.get("then", "back") - def choose(descriptor, options): + def choose(descriptor, options, unavailableOptions=None): if remaining: prefix = remaining.pop(0) for index, option in enumerate(options, start=1): @@ -1989,3 +1989,96 @@ def test_sam_fishing_explanation_widens_with_rod_level(): # check assert "%.1f seconds" % expectedWindow in response assert "%.1f seconds" % docks.REACTION_BASE_WINDOW not in response + + +def unavailableLabels(showOptionsMock): + """{option label: reason} from the last showOptions call, so a test can say + which entry was greyed out without depending on its position.""" + call = showOptionsMock.call_args + options = call[0][1] + reasons = call[0][2] if len(call[0]) > 2 else {} + return {options[number - 1]: reason for number, reason in (reasons or {}).items()} + + +def test_run_greys_out_fishing_when_the_player_is_out_of_energy(): + # prepare - below the cost of a single hour, fishing is refused, so the + # option says so instead of the player finding out by picking it + docksInstance = createDocks() + docksInstance.player.energy = docks.FISHING_ENERGY_COST - 1 + docksInstance.userInterface.showOptions = MagicMock( + side_effect=dockChooser("Go Home") + ) + + # call + docksInstance.run() + + # check + marked = unavailableLabels(docksInstance.userInterface.showOptions) + assert marked == {"Fish": "needs 10 energy - sleep at home"} + + +def test_run_leaves_fishing_available_with_energy_to_spare(): + # prepare + docksInstance = createDocks() + docksInstance.player.energy = docks.FISHING_ENERGY_COST + docksInstance.userInterface.showOptions = MagicMock( + side_effect=dockChooser("Go Home") + ) + + # call + docksInstance.run() + + # check - nothing on the docks menu is greyed out + assert unavailableLabels(docksInstance.userInterface.showOptions) == {} + + +def test_run_leaves_fishing_available_in_operator_mode(): + # prepare - operator mode ignores energy (Player.hasEnergy), so the option + # must not be greyed out for an operator with an empty bar + docksInstance = createDocks() + docksInstance.player.energy = 0 + docksInstance.player.operatorMode = True + docksInstance.userInterface.showOptions = MagicMock( + side_effect=dockChooser("Go Home") + ) + + # call + docksInstance.run() + + # check + assert unavailableLabels(docksInstance.userInterface.showOptions) == {} + + +def test_manageFleet_greys_out_a_boat_the_player_cannot_afford(): + # prepare - the only fleet entry with no boats yet is buying one + docksInstance = createDocks() + docksInstance.player.money = business.tierInfo(1)["cost"] - 1 + docksInstance.userInterface.showOptions = MagicMock(side_effect=fleetChooser()) + + # call + docksInstance.manageFleet() + + # check + marked = unavailableLabels(docksInstance.userInterface.showOptions) + assert list(marked.values()) == ["not enough money"] + assert list(marked)[0].startswith("Buy a") + + +def test_repair_menu_greys_out_a_hull_the_player_cannot_pay_for(): + # prepare - a damaged boat and not enough money for her bill; the greying + # happens on the boat chooser, where the per-hull cost differs + docksInstance = createDocks() + boat = boats.addBoat(docksInstance.player, 1) + boat["damage"] = 3 + cost = boats.repairCost(boat) + docksInstance.player.money = cost - 1 + docksInstance.userInterface.showOptions = MagicMock(side_effect=fleetChooser()) + + # call + docksInstance._repairBoat() + + # check - the boat's own row carries the bill she can't cover; Back is not + # marked, so the player can always leave + call = docksInstance.userInterface.showOptions.call_args + assert call[0][2] == {1: "repair costs $%d" % cost} + assert call[0][1][-1] == "Back" diff --git a/tests/location/test_home.py b/tests/location/test_home.py index 620a7c8..ba18a04 100644 --- a/tests/location/test_home.py +++ b/tests/location/test_home.py @@ -489,3 +489,64 @@ def test_run_reveals_the_ledger_and_the_housing_ladder_as_they_unlock(): # check assert label in homeInstance.userInterface.showOptions.call_args[0][1] + + +def markedOptions(locationInstance): + """{option label: reason} from the last showOptions call.""" + call = locationInstance.userInterface.showOptions.call_args + options = call[0][1] + reasons = call[0][2] if len(call[0]) > 2 else {} + return {options[number - 1]: reason for number, reason in (reasons or {}).items()} + + +def chooseBack(descriptor, options, unavailableOptions=None): + return str(len(options)) # Back is always the last entry + + +def test_manageHome_greys_out_a_move_the_player_cannot_pay_for(): + # prepare - renting with nothing saved. The rung up has to be bought + # outright (renting has no resale value to put toward it), so it is out of + # reach; moving back down to Homeless is free and stays available. + homeInstance = createHome() + homeInstance.player.homeTier = 1 + homeInstance.player.money = 0 + homeInstance.userInterface.showOptions = MagicMock(side_effect=chooseBack) + + # call + homeInstance.manageHome() + + # check + marked = markedOptions(homeInstance) + assert set(marked.values()) == {"not enough money"} + assert all(label.startswith("Move to") for label in marked) + assert not any(label.startswith("Move down to") for label in marked) + + +def test_manageHome_leaves_a_move_down_available_when_broke(): + # prepare - moving down the ladder pays cash back rather than costing + # money (see housing.moveHome), so it is never greyed out + homeInstance = createHome() + homeInstance.player.homeTier = len(housing.HOUSING_TIERS) - 1 + homeInstance.player.money = 0 + homeInstance.userInterface.showOptions = MagicMock(side_effect=chooseBack) + + # call + homeInstance.manageHome() + + # check - only the top rung is occupied, so every offered move is downward + marked = markedOptions(homeInstance) + assert marked == {} + + +def test_manageHome_marks_nothing_when_every_move_is_affordable(): + # prepare - renting, with money for the rung above as well as the one below + homeInstance = createHome() + homeInstance.player.homeTier = 1 + homeInstance.player.money = 100000 + homeInstance.userInterface.showOptions = MagicMock(side_effect=chooseBack) + + # call + homeInstance.manageHome() + + # check + assert markedOptions(homeInstance) == {} diff --git a/tests/location/test_shop.py b/tests/location/test_shop.py index ca5827c..5244da1 100644 --- a/tests/location/test_shop.py +++ b/tests/location/test_shop.py @@ -562,9 +562,9 @@ def test_gilbert_fishing_explanation_widens_with_rod_level(): # prepare - a maxed-out rod widens the window well past the base 2.0s shopInstance = createShop() shopInstance.player.rodLevel = shop.MAX_ROD_LEVEL - expectedWindow = docks.REACTION_BASE_WINDOW + ( - shop.MAX_ROD_LEVEL - 1 - ) * docks.ROD_WINDOW_STEP + expectedWindow = ( + docks.REACTION_BASE_WINDOW + (shop.MAX_ROD_LEVEL - 1) * docks.ROD_WINDOW_STEP + ) # call response = shopInstance._howFishingWorksDialogue() @@ -572,3 +572,82 @@ def test_gilbert_fishing_explanation_widens_with_rod_level(): # check assert "%.1f seconds" % expectedWindow in response assert "%.1f seconds" % docks.REACTION_BASE_WINDOW not in response + + +def markedOptions(locationInstance): + """{option label: reason} from the last showOptions call, so a test names + the entry it means rather than a position that shifts with progression.""" + call = locationInstance.userInterface.showOptions.call_args + options = call[0][1] + reasons = call[0][2] if len(call[0]) > 2 else {} + return {options[number - 1]: reason for number, reason in (reasons or {}).items()} + + +def test_run_greys_out_selling_with_an_empty_hold(): + # prepare - a new player walks in with no fish; "Sell Fish" is refused, so + # the option says why instead of the player finding out by picking it + shopInstance = createShop() + shopInstance.player.money = 10000 # afford the gear, so only selling is marked + shopInstance.userInterface.showOptions = MagicMock(return_value="1") + shopInstance.sellFish = MagicMock() + + # call + shopInstance.run() + + # check + assert markedOptions(shopInstance) == {"Sell Fish": "you have no fish"} + + +def test_run_greys_out_gear_the_player_cannot_afford(): + # prepare - fish to sell but no money for either upgrade + shopInstance = createShop() + shopInstance.player.addFish("Cod", 1) + shopInstance.player.money = 0 + shopInstance.userInterface.showOptions = MagicMock(return_value="1") + shopInstance.sellFish = MagicMock() + + # call + shopInstance.run() + + # check + marked = markedOptions(shopInstance) + assert set(marked.values()) == {"not enough money"} + assert [label for label in marked if label.startswith("Buy Better Bait")] + assert [label for label in marked if label.startswith("Buy Better Rod")] + assert "Sell Fish" not in marked + + +def test_run_greys_out_gear_that_is_already_maxed_out(): + # prepare - all the money in the world, but nothing left to buy; "not + # enough money" would be the wrong reason here + shopInstance = createShop() + shopInstance.player.addFish("Cod", 1) + shopInstance.player.money = 100000 + shopInstance.player.fishMultiplier = shop.MAX_FISH_MULTIPLIER + shopInstance.player.rodLevel = shop.MAX_ROD_LEVEL + shopInstance.userInterface.showOptions = MagicMock(return_value="1") + shopInstance.sellFish = MagicMock() + + # call + shopInstance.run() + + # check + assert sorted(markedOptions(shopInstance).values()) == [ + "already the best bait", + "already the finest rod", + ] + + +def test_run_marks_nothing_when_everything_can_be_bought(): + # prepare + shopInstance = createShop() + shopInstance.player.addFish("Cod", 1) + shopInstance.player.money = 100000 + shopInstance.userInterface.showOptions = MagicMock(return_value="1") + shopInstance.sellFish = MagicMock() + + # call + shopInstance.run() + + # check + assert markedOptions(shopInstance) == {} diff --git a/tests/location/test_tavern.py b/tests/location/test_tavern.py index d089f3c..40588ec 100644 --- a/tests/location/test_tavern.py +++ b/tests/location/test_tavern.py @@ -425,7 +425,7 @@ def test_gamble_win_pays_multiple_of_bet(): textAfterWin = [] callCount = [0] - def showOptionsSideEffect(prompt, options): + def showOptionsSideEffect(prompt, options, unavailableOptions=None): callCount[0] += 1 if callCount[0] == 1: return "3" @@ -462,7 +462,7 @@ def test_gamble_loss_via_real_loop(): textAfterLoss = [] callCount = [0] - def showOptionsSideEffect(prompt, options): + def showOptionsSideEffect(prompt, options, unavailableOptions=None): callCount[0] += 1 if callCount[0] == 1: return "2" @@ -495,7 +495,7 @@ def test_gamble_no_bet_placed(): textAfterAttempt = [] callCount = [0] - def showOptionsSideEffect(prompt, options): + def showOptionsSideEffect(prompt, options, unavailableOptions=None): callCount[0] += 1 if callCount[0] == 1: return "1" @@ -605,3 +605,71 @@ def test_run_hides_old_tom_until_conversation_is_unlocked(): # check assert nextLocation == LocationType.TAVERN tavernInstance.talkToNPC.assert_called_once() + + +def markedOptions(locationInstance): + """{option label: reason} from the last showOptions call.""" + call = locationInstance.userInterface.showOptions.call_args + options = call[0][1] + reasons = call[0][2] if len(call[0]) > 2 else {} + return {options[number - 1]: reason for number, reason in (reasons or {}).items()} + + +def test_run_greys_out_drinking_when_it_cannot_be_paid_for(): + # prepare - a round costs DRINK_COST; short of it, the option says so + tavernInstance = createTavern() + tavernInstance.player.money = tavern.DRINK_COST - 1 + tavernInstance.userInterface.showOptions = MagicMock(return_value="4") + + # call + tavernInstance.run() + + # check + assert markedOptions(tavernInstance) == { + "Get drunk ( $%d )" % tavern.DRINK_COST: "not enough money" + } + + +def test_run_leaves_drinking_available_with_the_exact_price(): + # prepare + tavernInstance = createTavern() + tavernInstance.player.money = tavern.DRINK_COST + tavernInstance.userInterface.showOptions = MagicMock(return_value="4") + + # call + tavernInstance.run() + + # check + assert markedOptions(tavernInstance) == {} + + +def test_gamble_greys_out_the_dice_until_a_bet_is_placed(): + # prepare - guessing a face does nothing while nothing is staked, so the + # faces are greyed and Change Bet / Back are left live + tavernInstance = createTavern() + tavernInstance.player.money = 100 + tavernInstance.currentBet = 0 + tavernInstance.userInterface.showOptions = MagicMock(return_value="8") # Back + + # call + tavernInstance.gamble() + + # check + marked = markedOptions(tavernInstance) + assert marked == { + face: "place a bet first" for face in ["1", "2", "3", "4", "5", "6"] + } + + +def test_gamble_frees_the_dice_once_a_bet_is_on_the_table(): + # prepare + tavernInstance = createTavern() + tavernInstance.player.money = 100 + tavernInstance.currentBet = 25 + tavernInstance.userInterface.showOptions = MagicMock(return_value="8") # Back + + # call + tavernInstance.gamble() + + # check + assert markedOptions(tavernInstance) == {} diff --git a/tests/ui/test_baseUserInterface.py b/tests/ui/test_baseUserInterface.py index 264942b..3d7c85c 100644 --- a/tests/ui/test_baseUserInterface.py +++ b/tests/ui/test_baseUserInterface.py @@ -9,7 +9,11 @@ import pytest from unittest.mock import patch -from ui.baseUserInterface import BaseUserInterface +from ui.baseUserInterface import ( + BaseUserInterface, + unavailableMessage, + unavailableSuffix, +) from ui.userInterface import UserInterface from ui.consoleUserInterface import ConsoleUserInterface from player.player import Player @@ -54,7 +58,7 @@ def lotsOfSpace(self): def divider(self): pass - def showOptions(self, descriptor, optionList): + def showOptions(self, descriptor, optionList, unavailableOptions=None): return self.choices.pop(0) def showDialogue(self, text): @@ -199,3 +203,63 @@ def test_inherited_interactive_dialogue_reflects_unlocked_options(): # check - the newly available question resolves to its own response assert ui.shownDialogues == ["Tester: R2"] + + +def makeRecordingUI(choices=()): + prompt, timeService, player = makeArgs() + return RecordingUserInterface(prompt, timeService, player, choices=list(choices)) + + +def test_unavailableReasons_maps_option_numbers_onto_a_parallel_list(): + # check - call sites key by 1-based option number (what len(optionList) + # gives them as they append); front-ends want it lined up with the options + ui = makeRecordingUI() + reasons = ui.unavailableReasons(["Fish", "Go Home", "Quit"], {1: "too tired"}) + + assert reasons == ["too tired", None, None] + + +def test_unavailableReasons_defaults_to_everything_available(): + # check - a menu that passes nothing is unchanged in every front-end + ui = makeRecordingUI() + + assert ui.unavailableReasons(["A", "B"], None) == [None, None] + assert ui.unavailableReasons(["A", "B"], {}) == [None, None] + + +def test_unavailableReasons_rejects_a_number_outside_the_menu(): + # check - marking an option that isn't there means the call site's numbers + # have drifted from its option list, which would silently leave the wrong + # row (or no row) greyed out; say so instead + ui = makeRecordingUI() + with pytest.raises(ValueError) as raised: + ui.unavailableReasons(["Only"], {2: "nope"}) + + message = str(raised.value) + assert "1 option" in message + assert "len(optionList)" in message + + +def test_unavailableReasons_never_blocks_every_option(): + # check - a menu where nothing can be picked is one no front-end can get + # out of, so the marks are dropped and the game gives its own refusal + ui = makeRecordingUI() + reasons = ui.unavailableReasons(["A", "B"], {1: "no", 2: "also no"}) + + assert reasons == [None, None] + + +def test_selectableNumbers_excludes_the_unavailable_rows(): + # check - the set every front-end validates the player's answer against + ui = makeRecordingUI() + + assert ui.selectableNumbers(["too tired", None, None]) == {"2", "3"} + assert ui.selectableNumbers([None]) == {"1"} + + +def test_unavailable_wording_is_shared_by_the_text_front_ends(): + # check - console and pygame both tag rows through these helpers, so the + # phrasing can't drift between them + assert unavailableSuffix(None) == "" + assert unavailableSuffix("no fish") == " (unavailable: no fish)" + assert unavailableMessage("no fish") == "You can't do that right now: no fish." diff --git a/tests/ui/test_pygameUserInterface.py b/tests/ui/test_pygameUserInterface.py index 3bcc8b2..0c29a05 100644 --- a/tests/ui/test_pygameUserInterface.py +++ b/tests/ui/test_pygameUserInterface.py @@ -470,3 +470,110 @@ def test_drawBusy_wraps_a_message_wider_than_the_window(): assert ui.screen.blit.call_count > 1 finally: ui.cleanup() + + +def test_optionRows_greys_out_and_labels_an_unavailable_option(): + # check - the row carries the same "(unavailable: ...)" tag the console + # prints, and is flagged so the drawing code can grey it + ui = makeUI() + try: + rows = ui._optionRows(["Fish", "Go Home"], ["no energy", None]) + assert rows == [ + ("[1] Fish (unavailable: no energy)", True), + ("[2] Go Home", False), + ] + finally: + ui.cleanup() + + +def test_selection_skips_unavailable_options(): + # check - holding DOWN never parks the highlight somewhere ENTER does + # nothing; the cursor steps over the greyed-out rows and wraps + ui = makeUI() + try: + reasons = [None, "no money", None] + assert ui._selectableIndexes(reasons) == [0, 2] + + ui.selected_option = 0 + assert ui._moveSelection(reasons, 1) == 2 + ui.selected_option = 2 + assert ui._moveSelection(reasons, 1) == 0 + ui.selected_option = 0 + assert ui._moveSelection(reasons, -1) == 2 + finally: + ui.cleanup() + + +def test_selection_stays_put_when_nothing_is_selectable(): + # check - a defensive case (the base contract drops the marks rather than + # blocking a whole menu), but the navigation must not loop forever if it + # ever is handed one + ui = makeUI() + try: + ui.selected_option = 1 + assert ui._moveSelection(["a", "b"], 1) == 1 + finally: + ui.cleanup() + + +def test_initial_selection_starts_on_a_selectable_option(): + # check - the highlight opens on something the player can choose rather + # than on a greyed-out first row + ui = makeUI() + try: + assert ui._initialSelection(["no energy", None, None]) == 1 + assert ui._initialSelection([None, "no money"]) == 0 + # nothing selectable: fall back to the top rather than raising + assert ui._initialSelection(["a", "b"]) == 0 + finally: + ui.cleanup() + + +def test_showOptions_ignores_the_number_key_of_a_greyed_out_option(): + # the disabled option's key does nothing but say why; the next key picks + # an option that is actually available + ui = makeUI() + try: + with injected_events([keydown(key=pygame.K_1), keydown(key=pygame.K_2)]): + choice = ui.showOptions("Pick", ["Fish", "Go Home"], {1: "no energy"}) + assert choice == "2" + assert ui.currentPrompt.text == "You can't do that right now: no energy." + finally: + ui.cleanup() + + +def test_showOptions_enter_cannot_confirm_a_greyed_out_option(): + # the highlight never lands on a disabled row, so ENTER confirms the first + # option the player can actually choose + ui = makeUI() + try: + with injected_events([keydown(key=pygame.K_RETURN)]): + choice = ui.showOptions("Pick", ["Fish", "Go Home"], {1: "no energy"}) + assert choice == "2" + finally: + ui.cleanup() + + +def test_showOptions_arrows_step_over_greyed_out_options(): + # DOWN from the first available option skips the disabled middle row + ui = makeUI() + try: + with injected_events( + [keydown(key=pygame.K_DOWN), keydown(key=pygame.K_RETURN)] + ): + choice = ui.showOptions("Pick", ["A", "B", "C"], {2: "no money"}) + assert choice == "3" + finally: + ui.cleanup() + + +def test_draw_game_screen_draws_a_greyed_out_option(): + # the drawing path itself has to cope with the reason text and the grey + # colour, not just the row builder that feeds it + ui = makeUI() + try: + ui._draw_game_screen( + "The Docks", ["Cast a line", "Go home"], ["no energy", None] + ) + finally: + ui.cleanup() diff --git a/tests/ui/test_userInterface.py b/tests/ui/test_userInterface.py index ebde5e7..3fdff37 100644 --- a/tests/ui/test_userInterface.py +++ b/tests/ui/test_userInterface.py @@ -336,3 +336,45 @@ def test_showInteractiveDialogue_shows_only_unlocked_options(): printedText = printedOutput(printed) assert "Locked" in printedText assert "Test NPC: B" in printedText + + +def test_showOptions_tags_an_unavailable_row_and_refuses_it(): + # setup - "Fish" can't be picked; the player tries it anyway, then picks + # the option that is actually available + userInterfaceInstance = createUserInterface() + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + + # call + with patch.object(userInterface, "print", create=True) as printed: + userInterface.input = MagicMock(side_effect=["1", "2"]) + choice = userInterfaceInstance.showOptions( + "The docks", ["Fish", "Go Home"], {1: "needs 10 energy - sleep at home"} + ) + + # check - the row says why it can't be used, and the refusal names the + # reason rather than implying the player mistyped + assert choice == "2" + printedText = printedOutput(printed) + assert "[1] Fish (unavailable: needs 10 energy - sleep at home)" in printedText + assert ( + userInterfaceInstance.currentPrompt.text + == "You can't do that right now: needs 10 energy - sleep at home." + ) + + +def test_showOptions_still_rejects_a_number_that_is_not_on_the_menu(): + # setup - an unavailable row and a plain mistype are different mistakes and + # get different messages + userInterfaceInstance = createUserInterface() + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + + # call + with patch.object(userInterface, "print", create=True): + userInterface.input = MagicMock(side_effect=["9", "1"]) + choice = userInterfaceInstance.showOptions("The docks", ["Fish"], {}) + + # check + assert choice == "1" + assert userInterfaceInstance.currentPrompt.text == "Try again!" diff --git a/tests/ui/test_webUserInterface.py b/tests/ui/test_webUserInterface.py index cf1b6d8..0a370ba 100644 --- a/tests/ui/test_webUserInterface.py +++ b/tests/ui/test_webUserInterface.py @@ -262,3 +262,49 @@ def test_http_server_post_input_with_malformed_json_defaults_to_empty_value(): assert box["result"] == "" finally: ui.cleanup() + + +def test_showOptions_publishes_the_reason_each_option_is_unavailable(): + # check - the browser needs the reason as data (not baked into the label) + # so it can grey the button out and style the reason apart from the option + ui = makeWebUI() + thread, box = runInThread( + lambda: ui.showOptions("The docks", ["Fish", "Go Home"], {1: "no energy"}) + ) + waitForScreen(ui, "options") + + screen = ui.get_state()["screen"] + assert screen["options"] == ["Fish", "Go Home"] + assert screen["unavailable"] == ["no energy", None] + + ui.submit_input("2") + thread.join(timeout=2) + assert box["result"] == "2" + + +def test_showOptions_always_publishes_an_unavailable_entry_per_option(): + # check - one code path in the client: the list is the same length as the + # options even when everything is available + ui = makeWebUI() + thread, box = runInThread(lambda: ui.showOptions("Pick", ["Apple", "Banana"])) + waitForScreen(ui, "options") + + assert ui.get_state()["screen"]["unavailable"] == [None, None] + + ui.submit_input("1") + thread.join(timeout=2) + + +def test_showOptions_refuses_an_unavailable_choice(): + # check - the greyed-out button is disabled in the browser, but a response + # for it (a stale click, a hand-rolled POST) is ignored rather than acted on + ui = makeWebUI() + thread, box = runInThread( + lambda: ui.showOptions("The docks", ["Fish", "Go Home"], {1: "no energy"}) + ) + waitForScreen(ui, "options") + + ui.submit_input("1") # greyed out -> ignored + ui.submit_input("2") # available + thread.join(timeout=2) + assert box["result"] == "2" diff --git a/tests/web/test_clientParity.py b/tests/web/test_clientParity.py index 9235d3b..524af82 100644 --- a/tests/web/test_clientParity.py +++ b/tests/web/test_clientParity.py @@ -55,3 +55,15 @@ def test_a_missing_client_file_explains_where_it_should_be(): message = str(raised) assert "web/" in message assert "no-such-client.js" in message + + +def test_the_shared_client_consumes_the_unavailable_contract(): + # WebUserInterface.showOptions publishes a reason per option; a contract + # the server sends and the client ignores would render every option as + # pickable, which is exactly what the greying-out is for. Both browser + # front-ends load this one file, so checking it covers both. + client = readWebFile("client.js") + + assert "screen.unavailable" in client + assert "disabled = true" in client + assert "unavailable" in readWebFile("client.css") diff --git a/web/client.css b/web/client.css index 71f5c13..b0f9f4f 100644 --- a/web/client.css +++ b/web/client.css @@ -24,6 +24,13 @@ button.danger:hover { background: #63202c; } button:disabled { opacity: .45; cursor: not-allowed; } button:disabled:hover { background: #163345; } + /* A menu option the game would refuse right now. Greyed with its own muted + palette rather than the dimming above, because the reason printed on the + row has to stay readable - the point is that the player can see why. */ + button.unavailable { opacity: 1; background: #10202a; border-color: #24343d; + color: #7d94a3; } + button.unavailable:hover { background: #10202a; } + button .reason { color: #d8a866; font-style: italic; } button.action { width: auto; text-align: center; padding: .6rem 1.4rem; background: #1d5a7a; border-color: #2f7ba0; } button.action:hover { background: #246a90; } diff --git a/web/client.js b/web/client.js index 6289d30..556bf5d 100644 --- a/web/client.js +++ b/web/client.js @@ -29,6 +29,12 @@ window.FisheClient = (function () { return e; } + // The reason option i can't be picked, or null. Older screens (and any + // front-end that never marks anything unavailable) simply omit the list. + function unavailableReason(screen, i) { + return (screen.unavailable || [])[i] || null; + } + function renderNotice(text, className) { const a = app(); a.innerHTML = ""; @@ -80,11 +86,21 @@ window.FisheClient = (function () { if (screen.prompt) a.append(el("div", { className: "prompt", textContent: screen.prompt })); if (screen.type === "options") { screen.options.forEach((opt, i) => { + // An option the game would refuse right now is still listed, so the + // menu doesn't shuffle under the player, but it is greyed out and + // carries the reason - the button itself says why it can't be used. + const reason = unavailableReason(screen, i); const b = el("button", { - textContent: `[${i + 1}] ${opt}`, - className: /delete/i.test(opt) ? "danger" : "", + className: reason ? "unavailable" : (/delete/i.test(opt) ? "danger" : ""), }); - b.onclick = () => send(String(i + 1)); + b.append(`[${i + 1}] ${opt}`); + if (reason) { + b.append(el("span", { className: "reason", textContent: ` — ${reason}` })); + b.disabled = true; + b.title = reason; + } else { + b.onclick = () => send(String(i + 1)); + } a.append(b); }); } else if (screen.type === "dialogue") { @@ -130,7 +146,12 @@ window.FisheClient = (function () { if (s.type === "options") { if (e.key >= "1" && e.key <= "9") { const n = parseInt(e.key, 10); - if (n <= s.options.length) { e.preventDefault(); send(String(n)); } + // A greyed-out option is no more pickable by its number key than by + // its button; the game would ignore the response either way. + if (n <= s.options.length && !unavailableReason(s, n - 1)) { + e.preventDefault(); + send(String(n)); + } } } else if (s.type === "dialogue" || s.type === "timed") { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); send(""); }