MIL-008: smarter purchase rule #49
@@ -102,6 +102,7 @@ The bot plays for 5 minutes, then prints one line on standard output, for exampl
|
|||||||
| `--duration SECONDS` | How long to play | 300 |
|
| `--duration SECONDS` | How long to play | 300 |
|
||||||
| `--interval SECONDS` | Time between upgrade checks (greater than 0) | 5 |
|
| `--interval SECONDS` | Time between upgrade checks (greater than 0) | 5 |
|
||||||
| `--buy-all` | Buy every affordable upgrade, most expensive first, instead of one | off |
|
| `--buy-all` | Buy every affordable upgrade, most expensive first, instead of one | off |
|
||||||
|
| `--best-value` | Buy the affordable upgrade with the lowest price per cookie per second (not together with `--buy-all`) | off |
|
||||||
| `--headless` | Run Chromium without a window | off (on in Docker) |
|
| `--headless` | Run Chromium without a window | off (on in Docker) |
|
||||||
| `--screenshot PATH` | Save a screenshot when the run ends | none |
|
| `--screenshot PATH` | Save a screenshot when the run ends | none |
|
||||||
| `--screenshot-start PATH` | Save a screenshot before the first click | none |
|
| `--screenshot-start PATH` | Save a screenshot before the first click | none |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
|||||||
from cookie_clicker_bot.domain.upgrades import (
|
from cookie_clicker_bot.domain.upgrades import (
|
||||||
Upgrade,
|
Upgrade,
|
||||||
choose_affordable_upgrades,
|
choose_affordable_upgrades,
|
||||||
|
choose_best_value,
|
||||||
choose_most_expensive_affordable,
|
choose_most_expensive_affordable,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,16 +49,35 @@ def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def buy_best_value_upgrade(game: GamePort) -> Upgrade | None:
|
||||||
|
"""@brief Buy the affordable upgrade with the lowest price per cookie per second.
|
||||||
|
|
||||||
|
@param game The game to play.
|
||||||
|
@return The upgrade that was bought, or None when nothing was affordable or
|
||||||
|
the click did not reach the product.
|
||||||
|
"""
|
||||||
|
chosen = choose_best_value(game.upgrades(), game.cookie_count())
|
||||||
|
if chosen is not None and game.buy(chosen):
|
||||||
|
return chosen
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]:
|
def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]:
|
||||||
"""@brief Do one purchase round with the given strategy (lecture step 5).
|
"""@brief Do one purchase round with the given strategy (lecture step 5).
|
||||||
|
|
||||||
@param game The game to play.
|
@param game The game to play.
|
||||||
@param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys
|
@param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys
|
||||||
every upgrade the cookies pay for.
|
every upgrade the cookies pay for, BEST_VALUE buys the one with the lowest
|
||||||
|
price per cookie per second.
|
||||||
@return The upgrades really bought, in order; a click that missed is left out.
|
@return The upgrades really bought, in order; a click that missed is left out.
|
||||||
"""
|
"""
|
||||||
if strategy is BuyStrategy.BUY_ONE:
|
if strategy is not BuyStrategy.BUY_ALL:
|
||||||
bought = buy_most_expensive_upgrade(game)
|
buy_one = (
|
||||||
|
buy_best_value_upgrade
|
||||||
|
if strategy is BuyStrategy.BEST_VALUE
|
||||||
|
else buy_most_expensive_upgrade
|
||||||
|
)
|
||||||
|
bought = buy_one(game)
|
||||||
return [] if bought is None else [bought]
|
return [] if bought is None else [bought]
|
||||||
plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count())
|
plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count())
|
||||||
return [upgrade for upgrade in plan if game.buy(upgrade)]
|
return [upgrade for upgrade in plan if game.buy(upgrade)]
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
|
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
|
||||||
help="seconds between upgrade checks, greater than 0 (default: %(default)s)",
|
help="seconds between upgrade checks, greater than 0 (default: %(default)s)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
choice = parser.add_mutually_exclusive_group()
|
||||||
|
choice.add_argument(
|
||||||
"--buy-all",
|
"--buy-all",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=(
|
||||||
@@ -77,6 +78,14 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
"(default: buy only the most expensive affordable one)"
|
"(default: buy only the most expensive affordable one)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
choice.add_argument(
|
||||||
|
"--best-value",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"at each check buy the affordable upgrade with the lowest price per "
|
||||||
|
"cookie per second"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--headless",
|
"--headless",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -95,6 +104,19 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def choose_strategy(args: argparse.Namespace) -> BuyStrategy:
|
||||||
|
"""@brief Turn the purchase options into a strategy.
|
||||||
|
|
||||||
|
@param args The parsed options.
|
||||||
|
@return BUY_ALL, BEST_VALUE or, by default, BUY_ONE.
|
||||||
|
"""
|
||||||
|
if args.buy_all:
|
||||||
|
return BuyStrategy.BUY_ALL
|
||||||
|
if args.best_value:
|
||||||
|
return BuyStrategy.BEST_VALUE
|
||||||
|
return BuyStrategy.BUY_ONE
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
game_factory: Callable[[bool], GamePort] = create_game,
|
game_factory: Callable[[bool], GamePort] = create_game,
|
||||||
@@ -113,7 +135,7 @@ def main(
|
|||||||
game.dismiss_language()
|
game.dismiss_language()
|
||||||
if args.screenshot_start is not None:
|
if args.screenshot_start is not None:
|
||||||
game.save_screenshot(args.screenshot_start)
|
game.save_screenshot(args.screenshot_start)
|
||||||
strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE
|
strategy = choose_strategy(args)
|
||||||
logger.info(
|
logger.info(
|
||||||
"checking upgrades every %s seconds, strategy %s",
|
"checking upgrades every %s seconds, strategy %s",
|
||||||
args.interval,
|
args.interval,
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ WINDOW_SIZE: Final[str] = "1366,900"
|
|||||||
## Script that clicks an element from inside the page, past anything that covers it.
|
## Script that clicks an element from inside the page, past anything that covers it.
|
||||||
JS_CLICK_SCRIPT: Final[str] = "arguments[0].click();"
|
JS_CLICK_SCRIPT: Final[str] = "arguments[0].click();"
|
||||||
|
|
||||||
|
## Script that reads the cookies per second of one unit of each product, by index.
|
||||||
|
JS_PRODUCTION_SCRIPT: Final[str] = "return Game.ObjectsById.map(o => o.storedCps);"
|
||||||
|
|
||||||
|
## Prefix of a product element id; the rest is the index into the script's list.
|
||||||
|
PRODUCT_ID_PREFIX: Final[str] = "product"
|
||||||
|
|
||||||
## Chromium arguments needed to run headless inside a container.
|
## Chromium arguments needed to run headless inside a container.
|
||||||
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ class BuyStrategy(Enum):
|
|||||||
BUY_ONE = "buy-one"
|
BUY_ONE = "buy-one"
|
||||||
## Buy every upgrade the cookies pay for, most expensive first.
|
## Buy every upgrade the cookies pay for, most expensive first.
|
||||||
BUY_ALL = "buy-all"
|
BUY_ALL = "buy-all"
|
||||||
|
## Buy the affordable upgrade with the lowest price per cookie per second.
|
||||||
|
BEST_VALUE = "best-value"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class Upgrade:
|
|||||||
name: str
|
name: str
|
||||||
## Price in cookies.
|
## Price in cookies.
|
||||||
price: int
|
price: int
|
||||||
|
## Cookies per second one unit makes; None when the page did not tell.
|
||||||
|
cookies_per_second: float | None = None
|
||||||
|
|
||||||
|
|
||||||
def choose_affordable_upgrades(
|
def choose_affordable_upgrades(
|
||||||
@@ -39,6 +41,33 @@ def choose_affordable_upgrades(
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def choose_best_value(upgrades: Sequence[Upgrade], cookies: int) -> Upgrade | None:
|
||||||
|
"""@brief Pick the affordable upgrade that pays for itself fastest.
|
||||||
|
|
||||||
|
The value of an upgrade is its price divided by the cookies per second it makes,
|
||||||
|
so the lowest ratio wins. Upgrades with unknown or zero production are skipped;
|
||||||
|
when none of the affordable ones has a known production the rule falls back to
|
||||||
|
the most expensive affordable upgrade.
|
||||||
|
|
||||||
|
@param upgrades The visible upgrades, in store order.
|
||||||
|
@param cookies The cookies the player has.
|
||||||
|
@return The chosen upgrade (the first on a tie), or None when none is affordable.
|
||||||
|
"""
|
||||||
|
producing = [
|
||||||
|
upgrade
|
||||||
|
for upgrade in upgrades
|
||||||
|
if upgrade.price <= cookies
|
||||||
|
and upgrade.cookies_per_second is not None
|
||||||
|
and upgrade.cookies_per_second > 0
|
||||||
|
]
|
||||||
|
if not producing:
|
||||||
|
return choose_most_expensive_affordable(upgrades, cookies)
|
||||||
|
return min(
|
||||||
|
producing,
|
||||||
|
key=lambda upgrade: upgrade.price / (upgrade.cookies_per_second or 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def choose_most_expensive_affordable(
|
def choose_most_expensive_affordable(
|
||||||
upgrades: Sequence[Upgrade], cookies: int
|
upgrades: Sequence[Upgrade], cookies: int
|
||||||
) -> Upgrade | None:
|
) -> Upgrade | None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, Protocol, TypeVar
|
|||||||
|
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
ElementClickInterceptedException,
|
ElementClickInterceptedException,
|
||||||
|
JavascriptException,
|
||||||
NoSuchElementException,
|
NoSuchElementException,
|
||||||
StaleElementReferenceException,
|
StaleElementReferenceException,
|
||||||
)
|
)
|
||||||
@@ -149,7 +150,19 @@ class SeleniumGame:
|
|||||||
products = self._driver.find_elements(
|
products = self._driver.find_elements(
|
||||||
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
|
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
|
||||||
)
|
)
|
||||||
return [self._read_upgrade(product) for product in products]
|
production = self._read_production()
|
||||||
|
return [self._read_upgrade(product, production) for product in products]
|
||||||
|
|
||||||
|
def _read_production(self) -> list[float]:
|
||||||
|
"""@brief Read the cookies per second of one unit of every product.
|
||||||
|
|
||||||
|
@return The numbers by product index; empty when the page does not tell.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
production = self._driver.execute_script(constants.JS_PRODUCTION_SCRIPT)
|
||||||
|
except JavascriptException:
|
||||||
|
return []
|
||||||
|
return production if isinstance(production, list) else []
|
||||||
|
|
||||||
def _read_again_if_stale(self, read: Callable[[], T]) -> T:
|
def _read_again_if_stale(self, read: Callable[[], T]) -> T:
|
||||||
"""@brief Run a read; repeat it while the page re-renders under it.
|
"""@brief Run a read; repeat it while the page re-renders under it.
|
||||||
@@ -195,12 +208,26 @@ class SeleniumGame:
|
|||||||
self._driver.quit()
|
self._driver.quit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_upgrade(product: ElementLike) -> Upgrade:
|
def _read_upgrade(product: ElementLike, production: Sequence[float]) -> Upgrade:
|
||||||
"""@brief Read the id, name and price of one product element."""
|
"""@brief Read the id, name, price and production of one product element."""
|
||||||
name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR)
|
name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR)
|
||||||
price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR)
|
price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR)
|
||||||
key = product.get_attribute(constants.ID_ATTRIBUTE) or ""
|
key = product.get_attribute(constants.ID_ATTRIBUTE) or ""
|
||||||
return Upgrade(key=key, name=name.text.strip(), price=parse_amount(price.text))
|
return Upgrade(
|
||||||
|
key=key,
|
||||||
|
name=name.text.strip(),
|
||||||
|
price=parse_amount(price.text),
|
||||||
|
cookies_per_second=SeleniumGame._production_of(key, production),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _production_of(key: str, production: Sequence[float]) -> float | None:
|
||||||
|
"""@brief Look up the production of the product with this element id."""
|
||||||
|
index = key.removeprefix(constants.PRODUCT_ID_PREFIX)
|
||||||
|
if not index.isdigit() or int(index) >= len(production):
|
||||||
|
return None
|
||||||
|
value = production[int(index)]
|
||||||
|
return float(value) if isinstance(value, int | float) else None
|
||||||
|
|
||||||
def _visible(self, element_id: str) -> ElementLike | None:
|
def _visible(self, element_id: str) -> ElementLike | None:
|
||||||
"""@brief Return the element with this id when it exists and is displayed."""
|
"""@brief Return the element with this id when it exists and is displayed."""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
ElementClickInterceptedException,
|
ElementClickInterceptedException,
|
||||||
|
JavascriptException,
|
||||||
NoSuchElementException,
|
NoSuchElementException,
|
||||||
StaleElementReferenceException,
|
StaleElementReferenceException,
|
||||||
)
|
)
|
||||||
@@ -63,8 +64,13 @@ class FakeDriver:
|
|||||||
self.screenshots: list[str] = []
|
self.screenshots: list[str] = []
|
||||||
self.quit_called = False
|
self.quit_called = False
|
||||||
self.scripts: list[tuple[str, FakeElement]] = []
|
self.scripts: list[tuple[str, FakeElement]] = []
|
||||||
|
self.production: Any = []
|
||||||
|
|
||||||
def execute_script(self, script: str, *args: Any) -> None: # noqa: ANN401
|
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
|
||||||
|
if script == constants.JS_PRODUCTION_SCRIPT:
|
||||||
|
if isinstance(self.production, Exception):
|
||||||
|
raise self.production
|
||||||
|
return self.production
|
||||||
self.scripts.append((script, args[0]))
|
self.scripts.append((script, args[0]))
|
||||||
|
|
||||||
def get(self, url: str) -> None:
|
def get(self, url: str) -> None:
|
||||||
@@ -427,3 +433,58 @@ def test_close_quits_the_driver() -> None:
|
|||||||
make_game(driver).close()
|
make_game(driver).close()
|
||||||
|
|
||||||
assert driver.quit_called
|
assert driver.quit_called
|
||||||
|
|
||||||
|
|
||||||
|
def _driver_with_two_products() -> FakeDriver:
|
||||||
|
driver = FakeDriver()
|
||||||
|
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
|
||||||
|
make_product("product0", "Cursor", "15"),
|
||||||
|
make_product("product1", "Grandma", "100"),
|
||||||
|
]
|
||||||
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_reads_the_production_of_each_product() -> None:
|
||||||
|
driver = _driver_with_two_products()
|
||||||
|
driver.production = [0.1, 1]
|
||||||
|
|
||||||
|
assert make_game(driver).upgrades() == [
|
||||||
|
Upgrade("product0", "Cursor", 15, 0.1),
|
||||||
|
Upgrade("product1", "Grandma", 100, 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_leaves_the_production_unknown_when_the_script_fails() -> None:
|
||||||
|
driver = _driver_with_two_products()
|
||||||
|
driver.production = JavascriptException("Game is not defined")
|
||||||
|
|
||||||
|
upgrades = make_game(driver).upgrades()
|
||||||
|
|
||||||
|
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_leaves_the_production_unknown_for_a_product_the_list_lacks() -> None:
|
||||||
|
driver = _driver_with_two_products()
|
||||||
|
driver.production = [0.1]
|
||||||
|
|
||||||
|
upgrades = make_game(driver).upgrades()
|
||||||
|
|
||||||
|
assert [upgrade.cookies_per_second for upgrade in upgrades] == [0.1, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_leaves_a_production_that_is_not_a_number_unknown() -> None:
|
||||||
|
driver = _driver_with_two_products()
|
||||||
|
driver.production = [{}, 1]
|
||||||
|
|
||||||
|
upgrades = make_game(driver).upgrades()
|
||||||
|
|
||||||
|
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_leaves_the_production_unknown_for_an_unexpected_answer() -> None:
|
||||||
|
driver = _driver_with_two_products()
|
||||||
|
driver.production = None
|
||||||
|
|
||||||
|
upgrades = make_game(driver).upgrades()
|
||||||
|
|
||||||
|
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None]
|
||||||
|
|||||||
@@ -179,6 +179,31 @@ def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None:
|
|||||||
assert parse_args(["--buy-all"]).buy_all is True
|
assert parse_args(["--buy-all"]).buy_all is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_best_value_is_off_by_default() -> None:
|
||||||
|
assert parse_args([]).best_value is False
|
||||||
|
assert parse_args(["--best-value"]).best_value is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_rejects_buy_all_together_with_best_value(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
parse_args(["--buy-all", "--best-value"])
|
||||||
|
|
||||||
|
assert exit_info.value.code == 2
|
||||||
|
assert "not allowed with" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_logs_the_best_value_strategy(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level("INFO")
|
||||||
|
|
||||||
|
main(["--duration", "0", "--best-value"], lambda _h: RecordingGame())
|
||||||
|
|
||||||
|
assert "strategy best-value" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"])
|
@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"])
|
||||||
def test_parse_args_rejects_an_interval_that_is_not_positive(
|
def test_parse_args_rejects_an_interval_that_is_not_positive(
|
||||||
value: str, capsys: pytest.CaptureFixture[str]
|
value: str, capsys: pytest.CaptureFixture[str]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from cookie_clicker_bot.application.game_loop import (
|
from cookie_clicker_bot.application.game_loop import (
|
||||||
PlayResult,
|
PlayResult,
|
||||||
|
buy_best_value_upgrade,
|
||||||
buy_most_expensive_upgrade,
|
buy_most_expensive_upgrade,
|
||||||
buy_upgrades,
|
buy_upgrades,
|
||||||
click_and_buy,
|
click_and_buy,
|
||||||
@@ -14,6 +15,9 @@ from cookie_clicker_bot.application.game_loop import (
|
|||||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||||
|
|
||||||
|
VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1)
|
||||||
|
VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0)
|
||||||
|
|
||||||
CURSOR = Upgrade("product0", "Cursor", 15)
|
CURSOR = Upgrade("product0", "Cursor", 15)
|
||||||
GRANDMA = Upgrade("product1", "Grandma", 100)
|
GRANDMA = Upgrade("product1", "Grandma", 100)
|
||||||
|
|
||||||
@@ -248,3 +252,31 @@ def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() ->
|
|||||||
|
|
||||||
assert game.buy_times == [11.0, 21.0]
|
assert game.buy_times == [11.0, 21.0]
|
||||||
assert result.purchases == ("Grandma", "Grandma")
|
assert result.purchases == ("Grandma", "Grandma")
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_best_value_upgrade_buys_the_cheapest_per_cookie_per_second() -> None:
|
||||||
|
game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||||
|
|
||||||
|
bought = buy_best_value_upgrade(game)
|
||||||
|
|
||||||
|
assert bought == VALUE_GRANDMA
|
||||||
|
assert game.bought == [VALUE_GRANDMA]
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_best_value_upgrade_buys_nothing_when_too_poor() -> None:
|
||||||
|
game = FakeGame(cookies=10, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||||
|
|
||||||
|
assert buy_best_value_upgrade(game) is None
|
||||||
|
assert game.bought == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_best_value_upgrade_leaves_out_a_click_that_missed() -> None:
|
||||||
|
game = FakeGame(cookies=500, upgrades=[VALUE_GRANDMA], buy_succeeds=False)
|
||||||
|
|
||||||
|
assert buy_best_value_upgrade(game) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrades_with_best_value_buys_one_upgrade() -> None:
|
||||||
|
game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||||
|
|
||||||
|
assert buy_upgrades(game, BuyStrategy.BEST_VALUE) == [VALUE_GRANDMA]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
from cookie_clicker_bot.domain.upgrades import (
|
from cookie_clicker_bot.domain.upgrades import (
|
||||||
Upgrade,
|
Upgrade,
|
||||||
choose_affordable_upgrades,
|
choose_affordable_upgrades,
|
||||||
|
choose_best_value,
|
||||||
choose_most_expensive_affordable,
|
choose_most_expensive_affordable,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,3 +69,46 @@ def test_plan_does_not_change_the_given_list() -> None:
|
|||||||
choose_affordable_upgrades(upgrades, 1000)
|
choose_affordable_upgrades(upgrades, 1000)
|
||||||
|
|
||||||
assert upgrades == [CURSOR, GRANDMA]
|
assert upgrades == [CURSOR, GRANDMA]
|
||||||
|
|
||||||
|
|
||||||
|
VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1)
|
||||||
|
VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0)
|
||||||
|
VALUE_FARM = Upgrade("product2", "Farm", 1100, 8.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_picks_the_lowest_price_per_cookie_per_second() -> None:
|
||||||
|
upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM]
|
||||||
|
|
||||||
|
assert choose_best_value(upgrades, 2000) == VALUE_GRANDMA
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_ignores_what_the_cookies_cannot_pay() -> None:
|
||||||
|
upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM]
|
||||||
|
|
||||||
|
assert choose_best_value(upgrades, 50) == VALUE_CURSOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_returns_none_when_nothing_is_affordable() -> None:
|
||||||
|
assert choose_best_value([VALUE_CURSOR, VALUE_GRANDMA], 10) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_returns_none_without_upgrades() -> None:
|
||||||
|
assert choose_best_value([], 1000) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_takes_the_first_on_a_tie() -> None:
|
||||||
|
twin = Upgrade("product9", "Twin", 100, 1.0)
|
||||||
|
|
||||||
|
assert choose_best_value([VALUE_GRANDMA, twin], 500) == VALUE_GRANDMA
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_skips_an_upgrade_with_zero_production() -> None:
|
||||||
|
idle = Upgrade("product9", "Idle", 10, 0.0)
|
||||||
|
|
||||||
|
assert choose_best_value([idle, VALUE_GRANDMA], 500) == VALUE_GRANDMA
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_value_falls_back_to_the_most_expensive_when_production_is_unknown() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
|
assert choose_best_value([CURSOR, GRANDMA, FARM], 1200) == FARM
|
||||||
|
|||||||
Reference in New Issue
Block a user