MIL-008: smarter purchase rule #49

Merged
Tirsvad merged 5 commits from mil-008-smarter-rule into main 2026-10-28 16:43:41 +01:00
11 changed files with 1764 additions and 1495 deletions
Showing only changes of commit ecbe0d2671 - Show all commits
+1
View File
@@ -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)]
+24 -2
View File
@@ -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,
+6
View File
@@ -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"
+29
View File
@@ -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."""
+62 -1
View File
@@ -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]
+25
View File
@@ -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]
+32
View File
@@ -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]
+44
View File
@@ -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