Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79594de5a9 | ||
|
|
638730d95b | ||
|
|
14af49c9e8 | ||
|
|
314f8b4d77 | ||
|
|
2a1a205a05 | ||
|
|
f5e9e0b737 | ||
|
|
9a9ea53cc1 | ||
|
|
9592dd0ea0 |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
.claude
|
||||||
|
.agents
|
||||||
|
framework
|
||||||
|
docs
|
||||||
|
runs
|
||||||
|
tests
|
||||||
|
**/__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Publish the project's wiki pages to the git host's wiki.
|
||||||
|
#
|
||||||
|
# On a push to main that changes docs/wiki/, this workflow calls the reusable
|
||||||
|
# workflow of TirSystem/github-action, which copies the Markdown pages of
|
||||||
|
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
|
||||||
|
# what the folder holds: English and the PO language as a minimum).
|
||||||
|
#
|
||||||
|
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
|
||||||
|
# does not copy it publishes nothing.
|
||||||
|
#
|
||||||
|
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
|
||||||
|
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
|
||||||
|
# repository: a Gitea token with scope write:repository for a user that
|
||||||
|
# can write this repository (see guides/credentials.md of github-action).
|
||||||
|
# 2. Enable the wiki in the repository settings.
|
||||||
|
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
|
||||||
|
# A runner must be registered for the repository (or its organization).
|
||||||
|
#
|
||||||
|
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
|
||||||
|
# always equals the folder. Leave it "false" to keep hand-written pages.
|
||||||
|
|
||||||
|
name: Publish wiki
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- "docs/wiki/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
wiki:
|
||||||
|
uses: TirSystem/git-workflows/.gitea/workflows/publish-wiki-pages.yml@v1
|
||||||
|
with:
|
||||||
|
wiki_dir: docs/wiki
|
||||||
|
prune: "true"
|
||||||
|
secrets:
|
||||||
|
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
|
||||||
@@ -130,7 +130,7 @@ WARN_LOGFILE =
|
|||||||
#---------------------------------------------------------------------------
|
#---------------------------------------------------------------------------
|
||||||
# Configuration options related to the input files
|
# Configuration options related to the input files
|
||||||
#---------------------------------------------------------------------------
|
#---------------------------------------------------------------------------
|
||||||
INPUT = src README.md
|
INPUT = src
|
||||||
INPUT_ENCODING = UTF-8
|
INPUT_ENCODING = UTF-8
|
||||||
INPUT_FILE_ENCODING =
|
INPUT_FILE_ENCODING =
|
||||||
FILE_PATTERNS = *.py
|
FILE_PATTERNS = *.py
|
||||||
@@ -147,7 +147,7 @@ INPUT_FILTER =
|
|||||||
FILTER_PATTERNS =
|
FILTER_PATTERNS =
|
||||||
FILTER_SOURCE_FILES = NO
|
FILTER_SOURCE_FILES = NO
|
||||||
FILTER_SOURCE_PATTERNS =
|
FILTER_SOURCE_PATTERNS =
|
||||||
USE_MDFILE_AS_MAINPAGE = README.md
|
USE_MDFILE_AS_MAINPAGE =
|
||||||
IMPLICIT_DIR_DOCS = YES
|
IMPLICIT_DIR_DOCS = YES
|
||||||
FORTRAN_COMMENT_AFTER = 72
|
FORTRAN_COMMENT_AFTER = 72
|
||||||
#---------------------------------------------------------------------------
|
#---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,2 +1,173 @@
|
|||||||
# 048-the-coockie-factory
|
# 🚀 Cookie Clicker Bot
|
||||||
|
|
||||||
|
A Selenium bot that plays [Cookie Clicker](https://ozh.github.io/cookieclicker/) for five minutes: it clicks the big cookie, buys upgrades and reports its cookies per second (CPS). It is the day 48 project of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written to be read, run and compared by other course participants.
|
||||||
|
|
||||||
|
## 📚 Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#-overview)
|
||||||
|
- [Requirements](#-requirements)
|
||||||
|
- [Setup](#-setup)
|
||||||
|
- [Run](#-run)
|
||||||
|
- [Tests](#-tests)
|
||||||
|
- [License](#-license)
|
||||||
|
- [Links](#-links)
|
||||||
|
- [Appendix: Project documents](#-appendix-project-documents)
|
||||||
|
|
||||||
|
## 🧭 Overview
|
||||||
|
|
||||||
|
The bot follows the five steps of the lecture. Every step is one function with the assignment's name, and every step has a screenshot taken by the bot itself in a headless Chromium inside Docker.
|
||||||
|
|
||||||
|
| Step | Function | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `open_game`, `select_language` | Opens the game and picks English |
|
||||||
|
| 2 | `click_cookie` | Clicks the big cookie |
|
||||||
|
| 3 | `get_cookie_count` | Reads the number of cookies |
|
||||||
|
| 4 | `get_store_items`, `choose_upgrade`, `buy_upgrade` | Buys the most expensive upgrade it can afford |
|
||||||
|
| 5 | `run_bot`, `get_cps` | Plays for 300 seconds and reports the CPS |
|
||||||
|
|
||||||
|
### Lecture step screenshots
|
||||||
|
|
||||||
|
**Step 1: open the game and choose a language**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Step 2: click the big cookie**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Step 3: read the cookie count**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Step 4: buy the first upgrade**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Step 5: the five-minute result**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In the run that made these screenshots the bot reached **16.6 cookies per second** after 300 seconds. Your number will differ: it depends on the speed of your machine and the network.
|
||||||
|
|
||||||
|
### Parts of the repository
|
||||||
|
|
||||||
|
| Folder | Holds |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/cookie_clicker_bot/domain/` | Game rules without Selenium: number parsing and the purchase choice |
|
||||||
|
| `src/cookie_clicker_bot/application/` | The lecture step functions and the ports they need |
|
||||||
|
| `src/cookie_clicker_bot/infrastructure/` | The Selenium adapter and the clock |
|
||||||
|
| `src/cookie_clicker_bot/presentation/` | The command line entry points |
|
||||||
|
| `src/cookie_clicker_bot/constants.py` | All constants: address, element ids, timings |
|
||||||
|
| `tests/` | pytest tests that need no browser |
|
||||||
|
| `docker/` | The Docker image used for headless runs and screenshots |
|
||||||
|
| `docs/` | Project documents, screenshots and the wiki |
|
||||||
|
|
||||||
|
The only runtime dependency is `selenium`; a bot cannot drive a browser without it (see ADR-0001 in `docs/adr/`).
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- Python 3.13 or newer.
|
||||||
|
- Google Chrome (Selenium Manager downloads the matching driver). Not needed to run the tests.
|
||||||
|
- Docker, only to repeat the headless run and the screenshots.
|
||||||
|
- Doxygen, only to build the API documentation.
|
||||||
|
|
||||||
|
## 🛠️ Setup
|
||||||
|
|
||||||
|
Create a local virtual environment, upgrade `pip` and install the project with its development tools.
|
||||||
|
|
||||||
|
Windows (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux and macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the environment with `deactivate`. The `.venv` folder is ignored by git.
|
||||||
|
|
||||||
|
## ▶️ Run
|
||||||
|
|
||||||
|
With the virtual environment active, start the bot. A Chrome window opens and plays for five minutes, then the bot prints the CPS and closes the browser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m cookie_clicker_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m cookie_clicker_bot --headless
|
||||||
|
python -m cookie_clicker_bot --duration 60
|
||||||
|
```
|
||||||
|
|
||||||
|
To repeat the headless run and the screenshots without touching your desktop, use Docker. The screenshots and a `steps.json` summary land in `runs/` (the ones in `docs/images/` came from such a run):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cookie-clicker-bot -f docker/Dockerfile .
|
||||||
|
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the API documentation from the Doxygen comments, run `doxygen Doxyfile`; the result is in `docs/api/html/`.
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
The tests use fakes for the game and the clock, so they need neither Chrome nor the network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest
|
||||||
|
python -m ruff check src tests
|
||||||
|
python -m mypy src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- [Repository][repository]
|
||||||
|
- [Documentation][documentation]
|
||||||
|
- [Issue tracker][issues]
|
||||||
|
|
||||||
|
## 📎 Appendix: Project documents
|
||||||
|
|
||||||
|
The documents a newcomer needs, in reading order.
|
||||||
|
|
||||||
|
| Document | What it tells you |
|
||||||
|
| --- | --- |
|
||||||
|
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
|
||||||
|
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
|
||||||
|
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
|
||||||
|
| [Project Plan][PP-001] | The gateways and their schedule |
|
||||||
|
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
|
||||||
|
| [ADR-0001][ADR-0001] | Why Selenium is the one runtime dependency |
|
||||||
|
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
|
||||||
|
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
|
||||||
|
| [Framework README][framework-readme] | The framework this project uses |
|
||||||
|
| [Framework changelog][framework-changelog] | What changed in each framework release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory
|
||||||
|
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/wiki
|
||||||
|
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/issues
|
||||||
|
[agents]: ./AGENTS.md
|
||||||
|
[BC-001]: ./docs/business-case.md
|
||||||
|
[SA-001]: ./docs/stakeholder-analysis.md
|
||||||
|
[PP-001]: ./docs/project-plan.md
|
||||||
|
[milestones]: ./docs/milestones/
|
||||||
|
[ADR-0001]: ./docs/adr/adr-0001-selenium-runtime-dependency.md
|
||||||
|
[registry]: ./docs/artifact-registry.md
|
||||||
|
[TM-001]: ./docs/sqa/traceability-matrix.md
|
||||||
|
[framework-readme]: ./framework/README.md
|
||||||
|
[framework-changelog]: ./framework/CHANGELOG.md
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Headless Chromium for the Cookie Clicker bot. GUI runs never use the host desktop.
|
||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV CHROME_BIN=/usr/bin/chromium \
|
||||||
|
CHROMEDRIVER=/usr/bin/chromedriver \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml README.md LICENSE ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN python -m pip install --upgrade pip \
|
||||||
|
&& python -m pip install .
|
||||||
|
|
||||||
|
RUN useradd --create-home bot \
|
||||||
|
&& mkdir /runs \
|
||||||
|
&& chown bot /runs
|
||||||
|
USER bot
|
||||||
|
|
||||||
|
VOLUME ["/runs"]
|
||||||
|
ENTRYPOINT ["python", "-m", "cookie_clicker_bot.presentation.capture_steps"]
|
||||||
|
CMD ["--out", "/runs"]
|
||||||
@@ -14,10 +14,10 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| BC | Business Case | docs/business-case.md | 002 |
|
| BC | Business Case | docs/business-case.md | 002 |
|
||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 006 |
|
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 007 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
|
| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
INFO:__main__:Read 15 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 18 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:6 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 100 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:11 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 115 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:16 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 133 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:23 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 20 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 23 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 27 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 31 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:28 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 35 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 40 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:33 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 46 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 53 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:39 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 61 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:45 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 153 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:51 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 70 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:56 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 175 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:62 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 81 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:67 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 93 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:72 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 107 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:78 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 123 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:83 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 141 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:89 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 162 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:95 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:101 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 202 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:107 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 186 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:112 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:117 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 232 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:123 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 214 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:129 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:134 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 267 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:140 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:146 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 306 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:151 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:157 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 246 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:162 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:167 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 352 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:172 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:178 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 283 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:184 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:190 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 325 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:195 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:201 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 405 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:207 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:213 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 374 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:218 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:223 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:229 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 466 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:234 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:240 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 430 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:246 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:251 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:256 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 536 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:262 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:268 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:273 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 494 cookies
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:278 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:283 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:289 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 616 cookies
|
||||||
|
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:294 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:299 of 300 seconds played
|
||||||
|
INFO:cookie_clicker_bot.application.use_cases.run_bot:Run finished at 16.6 cookies per second
|
||||||
|
Result : 16.6
|
||||||
|
exit 0
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 663 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,72 @@
|
|||||||
|
# Purchase strategy improvement
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | MIL-006 |
|
||||||
|
| CrossReference | [BC-001] |
|
||||||
|
| Language | en |
|
||||||
|
| Domain | it |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version (reviewed in RC-014) | [638730d] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Decide whether the bot reaches a higher cookies-per-second (CPS) rate than the 16.6 of the first release by buying more reliably and more often. In the Docker run of MIL-004 about 40 percent of the purchase attempts failed: each failure came right after a successful purchase in the same store visit, because the game had not yet redrawn the store flags.
|
||||||
|
|
||||||
|
## Deliverable
|
||||||
|
|
||||||
|
The functions `buy_upgrade`, `get_store_items` and `run_bot` changed so that the bot waits for the store to redraw after a purchase, visits the store every second and also buys affordable upgrades; a Docker run of 300 seconds with its CPS, log and refreshed screenshot of step 5; the README and the wiki pages in English, Danish and Bahasa Malaysia updated with the new result and what it taught.
|
||||||
|
|
||||||
|
## Go / No-Go Criteria
|
||||||
|
|
||||||
|
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | In a 300 second Docker run, fewer than 5 percent of the purchase attempts log "did not raise the owned amount" | Met | Not met |
|
||||||
|
| 2 | The CPS of that run is higher than the 16.6 of the first release | Met | Not met |
|
||||||
|
| 3 | `python -m pytest`, `ruff check` and `mypy --strict` pass; `selenium` is still the only runtime dependency | Met | Not met |
|
||||||
|
| 4 | The source review record says Go | Met | Not met |
|
||||||
|
| 5 | The README and all three wiki languages show the same new CPS and screenshot | Met | Not met |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Depends on | Reason |
|
||||||
|
| --- | --- |
|
||||||
|
| MIL-005 | Needs the finished bot, the Docker run and the wiki it updates |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Business Case objective / KPI / user story | Reference |
|
||||||
|
| --- | --- |
|
||||||
|
| O1, O3, O4 in [BC-001] | Success criterion 2 (CPS reported) and the learning record of the wiki |
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Role | Stakeholder ID (SA) |
|
||||||
|
| --- | --- |
|
||||||
|
| Owner | S01 |
|
||||||
|
| Approving reviewer | S01 |
|
||||||
|
|
||||||
|
## Target Date
|
||||||
|
|
||||||
|
2026-11-03 - within the one-week limit of [BC-001]
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Wait for the store redraw after a purchase | After a successful purchase the store flags and prices are stale until the game redraws, so the next choice clicks a product that is no longer affordable. Wait until the price text of the bought product changes before the store is read again; the log of MIL-004 shows the failures follow a purchase. | No | |
|
||||||
|
| 2 | Visit the store every second | The store is visited every 5 seconds and the loop stops buying at the first failure, so cookies wait idle. Shorten `STORE_CHECK_INTERVAL_SECONDS` and keep buying while something is affordable; measure that clicks per second do not drop. | No | |
|
||||||
|
| 3 | Buy affordable upgrades | Upgrades such as the cursor and grandma boosts raise the CPS cheaply. Read the enabled upgrade crates in the `upgrades` area and click the cheapest first, behind the existing port with a fake in the tests. | No | |
|
||||||
|
| 4 | Measure the new strategy in Docker | Run the 300 second headless Docker run again, compare the CPS and the failure rate with the first release (16.6, about 40 percent) and refresh the screenshot of step 5. Environment: Docker container. | No | |
|
||||||
|
| 5 | Update the README and the wiki | Put the new CPS, the screenshot and what the improvement taught into the README and the English, Danish and Bahasa Malaysia wiki pages of step 4 and step 5. | No | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[BC-001]: ../business-case.md
|
||||||
|
[638730d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/638730d95ba24ba77c6448730d7999aa3e90d863
|
||||||
+125
-115
@@ -1,115 +1,125 @@
|
|||||||
# Project Plan
|
# Project Plan
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ID | PP-001 |
|
| ID | PP-001 |
|
||||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] |
|
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006] |
|
||||||
| Language | en |
|
| Language | en |
|
||||||
| Domain | it |
|
| Domain | it |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version (reviewed in RC-003) | [96a9a78] |
|
| 2026-10-28 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version (reviewed in RC-003) | [96a9a78] |
|
||||||
|
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added MIL-006 purchase strategy improvement after the user asked for it in chat (reviewed in RC-015) | [638730d] |
|
||||||
---
|
|
||||||
|
---
|
||||||
## Purpose
|
|
||||||
|
## Purpose
|
||||||
This plan schedules the five phases that deliver the Cookie Clicker bot inside the one-week limit of [BC-001].
|
|
||||||
|
This plan schedules the six phases that deliver the Cookie Clicker bot inside the one-week limit of [BC-001].
|
||||||
## Planning Assumptions
|
|
||||||
|
## Planning Assumptions
|
||||||
- Week 1 starts 2026-10-28; the plan ends by 2026-11-04, per the Business Case constraint.
|
|
||||||
- Phase length: one to three days, one branch and one pull request per phase.
|
- Week 1 starts 2026-10-28; the plan ends by 2026-11-04, per the Business Case constraint.
|
||||||
- S01 owns every phase; the AI acts as stand-in reviewer for S01 in autonomous mode, recorded in each review.
|
- Phase length: one to three days, one branch and one pull request per phase.
|
||||||
- The communication cadence of [SA-001] is one pull request per phase and a final summary.
|
- S01 owns every phase; the AI acts as stand-in reviewer for S01 in autonomous mode, recorded in each review.
|
||||||
|
- The communication cadence of [SA-001] is one pull request per phase and a final summary.
|
||||||
## Gateway Schedule
|
|
||||||
|
## Gateway Schedule
|
||||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||||
| Project setup and tooling | [MIL-001] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | pyproject, skeleton, .gitignore, Doxyfile, repository metadata | |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Bot core | [MIL-002] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | open_game, click_cookie, get_cookie_count with tests | |
|
| Project setup and tooling | [MIL-001] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | pyproject, skeleton, .gitignore, Doxyfile, repository metadata | |
|
||||||
| Upgrades and run | [MIL-003] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | strategy, run_bot, main | |
|
| Bot core | [MIL-002] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | open_game, click_cookie, get_cookie_count with tests | |
|
||||||
| Screenshots and README | [MIL-004] | 2026-10-31 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Docker run, screenshots, README | |
|
| Upgrades and run | [MIL-003] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | strategy, run_bot, main | |
|
||||||
| Wiki and release | [MIL-005] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | Wiki en, da, ms and final checks | |
|
| Screenshots and README | [MIL-004] | 2026-10-31 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Docker run, screenshots, README | |
|
||||||
|
| Wiki and release | [MIL-005] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | Wiki en, da, ms and final checks | |
|
||||||
```plantuml
|
| Purchase strategy improvement | [MIL-006] | 2026-10-28 to 2026-11-03 | 2026-11-03 | S01 | none (technical tasks) | Reliable store visits, upgrades bought, a higher CPS in Docker, README and wiki updated | |
|
||||||
@startgantt
|
|
||||||
Project starts 2026-10-28
|
```plantuml
|
||||||
[Project setup] starts 2026-10-28 and ends 2026-10-29
|
@startgantt
|
||||||
[Bot core] starts 2026-10-29 and ends 2026-10-30
|
Project starts 2026-10-28
|
||||||
[Upgrades and run] starts 2026-10-30 and ends 2026-10-31
|
[Project setup] starts 2026-10-28 and ends 2026-10-29
|
||||||
[Screenshots and README] starts 2026-10-31 and ends 2026-11-02
|
[Bot core] starts 2026-10-29 and ends 2026-10-30
|
||||||
[Wiki and release] starts 2026-11-02 and ends 2026-11-04
|
[Upgrades and run] starts 2026-10-30 and ends 2026-10-31
|
||||||
[MIL-001 Go/No-Go] happens 2026-10-29
|
[Screenshots and README] starts 2026-10-31 and ends 2026-11-02
|
||||||
[MIL-002 Go/No-Go] happens 2026-10-30
|
[Wiki and release] starts 2026-11-02 and ends 2026-11-04
|
||||||
[MIL-003 Go/No-Go] happens 2026-10-31
|
[MIL-001 Go/No-Go] happens 2026-10-29
|
||||||
[MIL-004 Go/No-Go] happens 2026-11-02
|
[MIL-002 Go/No-Go] happens 2026-10-30
|
||||||
[MIL-005 Go/No-Go] happens 2026-11-04
|
[MIL-003 Go/No-Go] happens 2026-10-31
|
||||||
@endgantt
|
[MIL-004 Go/No-Go] happens 2026-11-02
|
||||||
```
|
[Purchase strategy] starts 2026-10-28 and ends 2026-11-03
|
||||||
|
[MIL-005 Go/No-Go] happens 2026-11-04
|
||||||
## Scope Coverage
|
[MIL-006 Go/No-Go] happens 2026-11-03
|
||||||
|
@endgantt
|
||||||
| Business Case scope item | Gateway |
|
```
|
||||||
| --- | --- |
|
|
||||||
| Bot for the lecture steps | MIL-002, MIL-003 |
|
## Scope Coverage
|
||||||
| pyproject, .gitignore, .venv instructions, Doxyfile, constants.py | MIL-001, MIL-004 |
|
|
||||||
| pytest tests with a fake game | MIL-002, MIL-003 |
|
| Business Case scope item | Gateway |
|
||||||
| Headless Docker run and screenshots | MIL-004 |
|
| --- | --- |
|
||||||
| README | MIL-004 |
|
| Bot for the lecture steps | MIL-002, MIL-003 |
|
||||||
| Wiki in en, da and ms | MIL-005 |
|
| pyproject, .gitignore, .venv instructions, Doxyfile, constants.py | MIL-001, MIL-004 |
|
||||||
| Description and topics on the git host | MIL-001 |
|
| pytest tests with a fake game | MIL-002, MIL-003 |
|
||||||
|
| Headless Docker run and screenshots | MIL-004 |
|
||||||
## Dependencies
|
| README | MIL-004 |
|
||||||
|
| Wiki in en, da and ms | MIL-005 |
|
||||||
```
|
| Description and topics on the git host | MIL-001 |
|
||||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005
|
| Higher CPS by a more reliable purchase strategy (objective O1) | MIL-006 |
|
||||||
```
|
|
||||||
|
## Dependencies
|
||||||
A No-Go on a phase returns it to its owner for rework and moves every later gateway by the rework time; the end date 2026-11-04 moves only if S01 decides so and records the reason in Planning Assumptions. S01 is owner, reviewer and the only escalation point.
|
|
||||||
|
```
|
||||||
## Plan Risks
|
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006
|
||||||
|
```
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
| --- | --- | --- |
|
MIL-006 was added after the first release; it runs beside the end of the window and must finish by 2026-11-03, one day before the end date.
|
||||||
| The live game changes or is unreachable during MIL-004 | Screenshots and the CPS run slip | Run the capture early in MIL-004; stop and report if it stays unreachable |
|
|
||||||
| Translation takes longer than planned | MIL-005 slips | Danish and Bahasa Malaysia follow the English page by page, with a short page set |
|
A No-Go on a phase returns it to its owner for rework and moves every later gateway by the rework time; the end date 2026-11-04 moves only if S01 decides so and records the reason in Planning Assumptions. S01 is owner, reviewer and the only escalation point.
|
||||||
| Repository name differs from the one in the request | Metadata set on the wrong repository | Use the repository `origin` points to; reported in the plan summary |
|
|
||||||
| Token handling (Business Case risk: a token leaks) | Secret exposure | Tokens only read from `.env` by scripts outside the project, never printed |
|
## Plan Risks
|
||||||
| Live game changes (Business Case risk: page structure changes) | Bot breaks | Ids in `constants.py`; run the capture early in MIL-004 |
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
## Open Issues
|
| --- | --- | --- |
|
||||||
|
| The live game changes or is unreachable during MIL-004 | Screenshots and the CPS run slip | Run the capture early in MIL-004; stop and report if it stays unreachable |
|
||||||
- The request names `https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code` (underscores); `origin` is `Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory`. The plan applies description and topics to the `origin` repository.
|
| Translation takes longer than planned | MIL-005 slips | Danish and Bahasa Malaysia follow the English page by page, with a short page set |
|
||||||
- The Product Owner's earlier solution targeted the old Orteil page and used the extra package `continuous_threading`; this project reimplements it for the current game with one loop and no extra dependency.
|
| Repository name differs from the one in the request | Metadata set on the wrong repository | Use the repository `origin` points to; reported in the plan summary |
|
||||||
|
| Token handling (Business Case risk: a token leaks) | Secret exposure | Tokens only read from `.env` by scripts outside the project, never printed |
|
||||||
## Traceability Analysis
|
| Live game changes (Business Case risk: page structure changes) | Bot breaks | Ids in `constants.py`; run the capture early in MIL-004 |
|
||||||
|
| The new strategy does not raise the CPS | MIL-006 returns No-Go | Measure after each change; keep the first release as the fallback |
|
||||||
### Business Goal Alignment
|
|
||||||
|
## Open Issues
|
||||||
| Stakeholder | Concern | Business Case objective |
|
|
||||||
| --- | --- | --- |
|
- The request names `https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code` (underscores); `origin` is `Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory`. The plan applies description and topics to the `origin` repository.
|
||||||
| S01 | CN-001, CN-002, CN-007 | O1, O4 |
|
- The Product Owner's earlier solution targeted the old Orteil page and used the extra package `continuous_threading`; this project reimplements it for the current game with one loop and no extra dependency.
|
||||||
| S02 | CN-003, CN-004 | O2, O3, O4 |
|
|
||||||
| S03 | CN-005, CN-006 | O4, O5 |
|
## Traceability Analysis
|
||||||
|
|
||||||
## Sign-Off
|
### Business Goal Alignment
|
||||||
|
|
||||||
| Stakeholder | Decision | Date |
|
| Stakeholder | Concern | Business Case objective |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| S01 | Pending acceptance of this plan in chat | |
|
| S01 | CN-001, CN-002, CN-007 | O1, O4 |
|
||||||
|
| S02 | CN-003, CN-004 | O2, O3, O4 |
|
||||||
---
|
| S03 | CN-005, CN-006 | O4, O5 |
|
||||||
|
|
||||||
[BC-001]: ./business-case.md
|
## Sign-Off
|
||||||
[SA-001]: ./stakeholder-analysis.md
|
|
||||||
[MIL-001]: ./milestones/mil-001-project-setup.md
|
| Stakeholder | Decision | Date |
|
||||||
[MIL-002]: ./milestones/mil-002-bot-core-open-and-click.md
|
| --- | --- | --- |
|
||||||
[MIL-003]: ./milestones/mil-003-upgrades-and-five-minute-run.md
|
| S01 | Pending acceptance of this plan in chat | |
|
||||||
[MIL-004]: ./milestones/mil-004-screenshots-and-readme.md
|
|
||||||
[MIL-005]: ./milestones/mil-005-wiki-and-release.md
|
---
|
||||||
[96a9a78]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/96a9a78f010ce8ae3fce8d84602317d8b5808a13
|
|
||||||
|
[BC-001]: ./business-case.md
|
||||||
|
[SA-001]: ./stakeholder-analysis.md
|
||||||
|
[MIL-001]: ./milestones/mil-001-project-setup.md
|
||||||
|
[MIL-002]: ./milestones/mil-002-bot-core-open-and-click.md
|
||||||
|
[MIL-003]: ./milestones/mil-003-upgrades-and-five-minute-run.md
|
||||||
|
[MIL-004]: ./milestones/mil-004-screenshots-and-readme.md
|
||||||
|
[MIL-005]: ./milestones/mil-005-wiki-and-release.md
|
||||||
|
[MIL-006]: ./milestones/mil-006-purchase-strategy.md
|
||||||
|
[96a9a78]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/96a9a78f010ce8ae3fce8d84602317d8b5808a13
|
||||||
|
[638730d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/638730d95ba24ba77c6448730d7999aa3e90d863
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# SQA Review Record
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-013 |
|
||||||
|
| CrossReference | [MIL-004], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [9a9ea53] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: source of milestone MIL-004 (capture_steps, Chrome environment support, retry, owned-amount purchase check) and docker/Dockerfile ([MIL-004])
|
||||||
|
- Checklist used: [QC-PY-001]
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: n/a (technical type)
|
||||||
|
- Language reviewer: none
|
||||||
|
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing throughout. |
|
||||||
|
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (StepUnreachableError, build_chrome_options, OWNED_CHECK_ATTEMPTS). |
|
||||||
|
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean, no suppressions. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes on 35 files. |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | StepUnreachableError is raised and not swallowed; stale-element retries re-raise the last error after the limit. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins. |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is released in a finally block in cli.main and capture_steps.main. |
|
||||||
|
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Doxygen docstrings on all public items. |
|
||||||
|
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Progress goes through logging; print is used only for the program result; no secrets in output. |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 99 offline tests (fake game, fake clock, fake driver); the live behaviour was checked in Docker: two full 300 second runs reported 15.7 and 16.6 cookies per second, the last one is in the README. |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict clean; no Any. |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium remains the only runtime dependency; the Docker image installs only chromium for the browser. |
|
||||||
|
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Environment variable names, step file names and limits live in constants.py. |
|
||||||
|
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | selenium is imported only in infrastructure; presentation modules are composition roots. |
|
||||||
|
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Ports stay narrow; capture_steps uses SeleniumGame directly because screenshots are documentation tooling (decision recorded in the hand-over). |
|
||||||
|
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers under src/, unit, integration and e2e under tests/. |
|
||||||
|
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All names English, no spaces; Dockerfile is a fixed tool name. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go - All Mandatory criteria pass or are not applicable. GUI runs were done headless in a Docker container; no GUI was started on the host desktop. The Docker run showed that about 40 percent of the purchase attempts fail because the store flags are read just before the game redraws; the bot logs and retries at the next store check (noted for the wiki). Stand-in review, not independent.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| None | S01 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-004]: ../../milestones/mil-004-screenshots-and-readme.md
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
|
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SQA Review Record
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-014 |
|
||||||
|
| CrossReference | [MIL-006], [QC-MIL-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [638730d] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: MIL-006 ([MIL-006])
|
||||||
|
- Checklist used: [QC-MIL-001]
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: en / it (the artifact's Metadata rows)
|
||||||
|
- Language reviewer: S01 (confirmed criteria 3, 5 and 9 of QC-LANG-001 in chat on 2026-10-28)
|
||||||
|
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable names the changed functions, a Docker run with CPS and log, and the updated README and wiki. |
|
||||||
|
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Five checkable Go criteria (failure rate below 5 percent, CPS above 16.6, checks green, review Go, same result in all docs). |
|
||||||
|
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on MIL-005. |
|
||||||
|
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traces to O1, O3 and O4 of the Business Case. |
|
||||||
|
| 5 | Milestone owner and approving reviewer are identified | Pass | S01 owner and approving reviewer. |
|
||||||
|
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target 2026-11-03 is inside the window and equals the Project Plan decision date. |
|
||||||
|
|
||||||
|
## Language and Domain Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has Language en and Domain it. |
|
||||||
|
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | en is a BCP 47 code and it is in the registry domain list. |
|
||||||
|
| 3 | The content (prose and table cells) is written in the stated language | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). Content is English. |
|
||||||
|
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register: IT Executive English. |
|
||||||
|
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). Ordinary IT terms; no dictionary exists or is needed. |
|
||||||
|
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Headings, keys, IDs and statuses are English. |
|
||||||
|
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||||
|
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | No change of language or domain. |
|
||||||
|
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). S01 is competent in English and in IT. |
|
||||||
|
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out at first use. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go - All Mandatory criteria pass; the language criteria were confirmed by S01. Stand-in review, not independent.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| None | S01 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-006]: ../../milestones/mil-006-purchase-strategy.md
|
||||||
|
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||||
|
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||||
|
[638730d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/638730d95ba24ba77c6448730d7999aa3e90d863
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# SQA Review Record
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-015 |
|
||||||
|
| CrossReference | [PP-001], [QC-PP-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [638730d] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: PP-001 version 2 (adds MIL-006) ([PP-001])
|
||||||
|
- Checklist used: [QC-PP-001]
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: en / it (the artifact's Metadata rows)
|
||||||
|
- Language reviewer: S01 (confirmed criteria 3, 5 and 9 of QC-LANG-001 in chat on 2026-10-28)
|
||||||
|
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | The plan states its start date, end date and the constraint it must fit | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
| 2 | Every milestone or gateway is listed once with its document, decision date and owner | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
| 3 | Every decision date lies inside the plan's window and is consistent with the dates in the linked `MIL-*` document | Pass | Decision dates equal the Target Date of MIL-001 to MIL-006 and lie inside the window. |
|
||||||
|
| 4 | Every in-scope item of the Business Case is delivered at a named gateway (scope coverage) | Pass | Scope Coverage has a row for the purchase strategy improvement, mapped to MIL-006 and objective O1. |
|
||||||
|
| 5 | Dependencies between gateways are stated, acyclic and consistent with the schedule order | Pass | Chain MIL-001 to MIL-006 is acyclic; MIL-006 follows MIL-005. |
|
||||||
|
| 6 | The plan states what happens to later gateways and to the end date when a gateway is No-Go | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
| 7 | Owners, reviewers and escalation roles use Stakeholder Analysis IDs and match the governance RACI | N-A | No governance document is in scope of the Business Case; the only owner ID is S01, as in the MIL Ownership tables. |
|
||||||
|
| 8 | Planning assumptions are listed with their source, and a changed baseline is recorded with its reason | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
| 9 | Plan risks are listed with impact and a mitigation, and trace to Business Case risks where they come from there | Pass | Plan Risks list a new risk (CPS does not rise) with a mitigation; two risks trace to Business Case risks. |
|
||||||
|
| 10 | Open issues are listed, and none contradicts a statement elsewhere in the plan | Pass | Open Issues do not contradict the new row. |
|
||||||
|
| 11 | Each gateway's main deliverable is stated and matches the deliverable of its `MIL-*` document | Pass | The main deliverable of MIL-006 in the plan matches its Deliverable section. |
|
||||||
|
| 12 | Gateways that deliver user stories name them, and those that do not say why | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
| 13 | The schedule view (table, Gantt) and the dependency view agree with each other | Pass | Gantt bars, table and the chain agree. |
|
||||||
|
| 14 | Each gateway is linked to its issue-tracker milestone, or marked as not yet synced | Pass | Milestone column blank until the sync. |
|
||||||
|
| 15 | The plan is a singleton: one Project Plan per project, changes recorded in Version History | Pass | Unchanged from RC-003 and rechecked with MIL-006 included. |
|
||||||
|
|
||||||
|
## Language and Domain Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has Language en and Domain it. |
|
||||||
|
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | en is a BCP 47 code and it is in the registry domain list. |
|
||||||
|
| 3 | The content (prose and table cells) is written in the stated language | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). Content is English. |
|
||||||
|
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register: IT Executive English. |
|
||||||
|
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). Ordinary IT terms; no dictionary exists or is needed. |
|
||||||
|
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Headings, keys, IDs and statuses are English. |
|
||||||
|
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||||
|
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | No change of language or domain. |
|
||||||
|
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by S01 in chat on 2026-10-28 (named human reviewer). S01 is competent in English and in IT. |
|
||||||
|
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out at first use. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go - Full re-review of version 2; all Mandatory criteria pass or are not applicable; the language criteria were confirmed by S01. Stand-in review, not independent.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| None | S01 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[PP-001]: ../../project-plan.md
|
||||||
|
[QC-PP-001]: ../../../framework/qc/artifacts/qc-project-plan.md
|
||||||
|
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||||
|
[638730d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/638730d95ba24ba77c6448730d7999aa3e90d863
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-011 (MIL-002 source review) | [68c4391] |
|
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-013 (MIL-004 source review) | [9a9ea53] |
|
||||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-012 (MIL-003 source review) | [cb8e5ed] |
|
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added MIL-006, RC-014 and RC-015 | [638730d] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,12 +26,13 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| [BC-001] | BC | en | it | - | [SA-001], [PP-001] | [RC-001] |
|
| [BC-001] | BC | en | it | - | [SA-001], [PP-001] | [RC-001] |
|
||||||
| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] |
|
| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] |
|
||||||
| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] |
|
| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006] | [RC-003], [RC-015] |
|
||||||
| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] |
|
| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] |
|
||||||
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] |
|
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] |
|
||||||
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006], [RC-012] |
|
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006], [RC-012] |
|
||||||
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] |
|
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007], [RC-013] |
|
||||||
| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] |
|
| [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-008] |
|
||||||
|
| [MIL-006] | MIL | en | it | [MIL-005] | - | [RC-014] |
|
||||||
| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] |
|
| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] |
|
||||||
|
|
||||||
## Concern Coverage
|
## Concern Coverage
|
||||||
@@ -63,6 +64,7 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[MIL-003]: ../milestones/mil-003-upgrades-and-five-minute-run.md
|
[MIL-003]: ../milestones/mil-003-upgrades-and-five-minute-run.md
|
||||||
[MIL-004]: ../milestones/mil-004-screenshots-and-readme.md
|
[MIL-004]: ../milestones/mil-004-screenshots-and-readme.md
|
||||||
[MIL-005]: ../milestones/mil-005-wiki-and-release.md
|
[MIL-005]: ../milestones/mil-005-wiki-and-release.md
|
||||||
|
[MIL-006]: ../milestones/mil-006-purchase-strategy.md
|
||||||
[RC-001]: ./reviews/rc-001-business-case.md
|
[RC-001]: ./reviews/rc-001-business-case.md
|
||||||
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
||||||
[RC-003]: ./reviews/rc-003-project-plan.md
|
[RC-003]: ./reviews/rc-003-project-plan.md
|
||||||
@@ -74,6 +76,9 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md
|
[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md
|
||||||
[RC-010]: ./reviews/rc-010-mil-001-source.md
|
[RC-010]: ./reviews/rc-010-mil-001-source.md
|
||||||
[RC-011]: ./reviews/rc-011-mil-002-source.md
|
[RC-011]: ./reviews/rc-011-mil-002-source.md
|
||||||
[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4
|
|
||||||
[RC-012]: ./reviews/rc-012-mil-003-source.md
|
[RC-012]: ./reviews/rc-012-mil-003-source.md
|
||||||
[cb8e5ed]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c
|
[RC-013]: ./reviews/rc-013-mil-004-source.md
|
||||||
|
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
|
||||||
|
[RC-014]: ./reviews/rc-014-mil-006-purchase-strategy.md
|
||||||
|
[RC-015]: ./reviews/rc-015-pp-001-version-2.md
|
||||||
|
[638730d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/638730d95ba24ba77c6448730d7999aa3e90d863
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Appendiks
|
||||||
|
|
||||||
|
🌐 [English](Appendix-Documentation) · **Dansk** · [Bahasa Melayu](Appendix-Documentation-ms)
|
||||||
|
|
||||||
|
Projektets dokumenter i læserækkefølge. De ligger i repositoryet under `docs/`.
|
||||||
|
|
||||||
|
| Dokument | Hvad det fortæller dig |
|
||||||
|
| --- | --- |
|
||||||
|
| [Business Case](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/business-case.md) | Hvorfor projektet findes, dets afgrænsning og succeskriterier |
|
||||||
|
| [Stakeholder Analysis](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/stakeholder-analysis.md) | Hvem der er involveret; stakeholder-ID'erne |
|
||||||
|
| [Project Plan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/project-plan.md) | Gateways og deres tidsplan |
|
||||||
|
| [Milestones](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/milestones/) | Ét dokument pr. gateway med dets opgaver |
|
||||||
|
| [ADR-0001](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/adr/adr-0001-selenium-runtime-dependency.md) | Hvorfor Selenium er den ene runtime-afhængighed |
|
||||||
|
| [Traceability matrix](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/traceability-matrix.md) | Hvordan artefakterne hænger sammen, og hvornår hvert sidst blev gennemgået |
|
||||||
|
| [Review records](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/reviews/) | Gennemgangsposterne |
|
||||||
|
|
||||||
|
[README](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory) · [AGENTS.md](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/AGENTS.md)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Lampiran
|
||||||
|
|
||||||
|
🌐 [English](Appendix-Documentation) · [Dansk](Appendix-Documentation-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
Dokumen projek mengikut susunan bacaan. Ia berada dalam repositori di bawah `docs/`.
|
||||||
|
|
||||||
|
| Dokumen | Apa yang diberitahunya |
|
||||||
|
| --- | --- |
|
||||||
|
| [Business Case](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/business-case.md) | Mengapa projek wujud, skop dan kriteria kejayaannya |
|
||||||
|
| [Stakeholder Analysis](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/stakeholder-analysis.md) | Siapa yang terlibat; ID pihak berkepentingan |
|
||||||
|
| [Project Plan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/project-plan.md) | Gerbang dan jadualnya |
|
||||||
|
| [Milestones](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/milestones/) | Satu dokumen bagi setiap gerbang dengan tugasnya |
|
||||||
|
| [ADR-0001](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/adr/adr-0001-selenium-runtime-dependency.md) | Mengapa Selenium ialah satu-satunya kebergantungan masa jalan |
|
||||||
|
| [Traceability matrix](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/traceability-matrix.md) | Bagaimana artifak dipaut dan bila setiap satu disemak kali terakhir |
|
||||||
|
| [Review records](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/reviews/) | Rekod semakan |
|
||||||
|
|
||||||
|
[README](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory) · [AGENTS.md](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/AGENTS.md)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Appendix
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Appendix-Documentation-da) · [Bahasa Melayu](Appendix-Documentation-ms)
|
||||||
|
|
||||||
|
The project documents, in reading order. They live in the repository under `docs/`.
|
||||||
|
|
||||||
|
| Document | What it tells you |
|
||||||
|
| --- | --- |
|
||||||
|
| [Business Case](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/business-case.md) | Why the project exists, its scope and success criteria |
|
||||||
|
| [Stakeholder Analysis](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/stakeholder-analysis.md) | Who is involved; the stakeholder IDs |
|
||||||
|
| [Project Plan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/project-plan.md) | The gateways and their schedule |
|
||||||
|
| [Milestones](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/milestones/) | One document per gateway with its tasks |
|
||||||
|
| [ADR-0001](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/adr/adr-0001-selenium-runtime-dependency.md) | Why Selenium is the one runtime dependency |
|
||||||
|
| [Traceability matrix](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/traceability-matrix.md) | How the artifacts link and when each was last reviewed |
|
||||||
|
| [Review records](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/docs/sqa/reviews/) | The review records |
|
||||||
|
|
||||||
|
[README](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory) · [AGENTS.md](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/src/branch/main/AGENTS.md)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Beskrivelse
|
||||||
|
|
||||||
|
🌐 [English](Description) · **Dansk** · [Bahasa Melayu](Description-ms)
|
||||||
|
|
||||||
|
## Hvad projektet er
|
||||||
|
|
||||||
|
Botten åbner Cookie Clicker i Chrome, klikker på den store cookie så hurtigt spillet tillader, besøger butikken hvert femte sekund og køber det dyreste produkt, den har råd til. Efter 300 sekunder aflæser den cookies i sekundet og skriver `Result : <cps>`.
|
||||||
|
|
||||||
|
## Hvordan koden er lagdelt
|
||||||
|
|
||||||
|
Koden følger en lille Clean Architecture, så beslutningerne kan testes uden en browser.
|
||||||
|
|
||||||
|
| Lag | Indeholder | Kender Selenium? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `domain` | Taltolkning (`parse_game_number`) og købsvalget (`choose_upgrade`) | Nej |
|
||||||
|
| `application` | Funktionerne for forelæsningens trin og portene `GamePort` og `Clock` | Nej |
|
||||||
|
| `infrastructure` | `SeleniumGame` (adapteren) og `SystemClock` | Ja |
|
||||||
|
| `presentation` | `main` og skærmbilledoptagelsen, kompositionsrødderne | Gennem adapteren |
|
||||||
|
|
||||||
|
`constants.py` indeholder adressen, element-id'erne, tiderne og talordene, så en ændring i spillets side er en ændring i én fil.
|
||||||
|
|
||||||
|
## Den ene afhængighed
|
||||||
|
|
||||||
|
`selenium` er den eneste runtime-afhængighed. En bot kan ikke styre en browser uden, og opgaven bruger det. Begrundelsen og de afviste muligheder står i ADR-0001 (se [Appendiks](Appendix-Documentation-da)).
|
||||||
|
|
||||||
|
## Hvad testene beviser
|
||||||
|
|
||||||
|
De 99 tests bruger et falsk spil og et falsk ur. De kører på et sekund, kræver hverken Chrome eller netværk og dækker taltolkningen, strategien, løkken og kommandolinjen. Det rigtige spil er kun afprøvet i Docker (se [trin 5](Step-5-Five-Minute-Run-da)).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Huraian
|
||||||
|
|
||||||
|
🌐 [English](Description) · [Dansk](Description-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
## Apa itu projek ini
|
||||||
|
|
||||||
|
Bot membuka Cookie Clicker dalam Chrome, mengklik kuki besar secepat yang dibenarkan permainan, melawat kedai setiap lima saat dan membeli produk termahal yang mampu dibelinya. Selepas 300 saat ia membaca kuki sesaat dan mencetak `Result : <cps>`.
|
||||||
|
|
||||||
|
## Bagaimana kod disusun berlapis
|
||||||
|
|
||||||
|
Kod mengikut Clean Architecture yang kecil, supaya keputusan boleh diuji tanpa pelayar.
|
||||||
|
|
||||||
|
| Lapisan | Mengandungi | Mengenali Selenium? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `domain` | Pentafsiran nombor (`parse_game_number`) dan pilihan pembelian (`choose_upgrade`) | Tidak |
|
||||||
|
| `application` | Fungsi bagi langkah kuliah dan port `GamePort` dan `Clock` | Tidak |
|
||||||
|
| `infrastructure` | `SeleniumGame` (penyesuai) dan `SystemClock` | Ya |
|
||||||
|
| `presentation` | `main` dan penangkapan tangkapan skrin, akar komposisi | Melalui penyesuai |
|
||||||
|
|
||||||
|
`constants.py` menyimpan alamat, id elemen, masa dan perkataan nombor, jadi perubahan pada halaman permainan hanyalah perubahan dalam satu fail.
|
||||||
|
|
||||||
|
## Satu kebergantungan
|
||||||
|
|
||||||
|
`selenium` ialah satu-satunya kebergantungan masa jalan. Bot tidak boleh mengawal pelayar tanpanya, dan tugasan menggunakannya. Sebab dan pilihan yang ditolak ada dalam ADR-0001 (lihat [Lampiran](Appendix-Documentation-ms)).
|
||||||
|
|
||||||
|
## Apa yang dibuktikan oleh ujian
|
||||||
|
|
||||||
|
99 ujian menggunakan permainan palsu dan jam palsu. Ia berjalan dalam satu saat, tidak memerlukan Chrome atau rangkaian, dan merangkumi pentafsir, strategi, gelung dan baris arahan. Permainan sebenar hanya diuji dalam Docker (lihat [langkah 5](Step-5-Five-Minute-Run-ms)).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Description
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Description-da) · [Bahasa Melayu](Description-ms)
|
||||||
|
|
||||||
|
## What the project is
|
||||||
|
|
||||||
|
The bot opens Cookie Clicker in Chrome, clicks the big cookie as fast as the game allows, visits the store every five seconds and buys the most expensive product it can afford. After 300 seconds it reads the cookies per second and prints `Result : <cps>`.
|
||||||
|
|
||||||
|
## How the code is layered
|
||||||
|
|
||||||
|
The code follows a small Clean Architecture, so the decisions can be tested without a browser.
|
||||||
|
|
||||||
|
| Layer | Holds | Knows about Selenium? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `domain` | Number parsing (`parse_game_number`) and the purchase choice (`choose_upgrade`) | No |
|
||||||
|
| `application` | The lecture step functions and the ports `GamePort` and `Clock` | No |
|
||||||
|
| `infrastructure` | `SeleniumGame` (the adapter) and `SystemClock` | Yes |
|
||||||
|
| `presentation` | `main` and the screenshot capture, the composition roots | Through the adapter |
|
||||||
|
|
||||||
|
`constants.py` holds the address, the element ids, the timings and the number words, so a change in the game's page is a change in one file.
|
||||||
|
|
||||||
|
## The one dependency
|
||||||
|
|
||||||
|
`selenium` is the only runtime dependency. A bot cannot drive a browser without it, and the assignment uses it. The reasons and the options that were rejected are in ADR-0001 (see [Appendix](Appendix-Documentation)).
|
||||||
|
|
||||||
|
## What the tests prove
|
||||||
|
|
||||||
|
The 99 tests use a fake game and a fake clock. They run in a second, need no Chrome and no network, and cover the parser, the strategy, the loop and the command line. The real game was exercised only in Docker (see [step 5](Step-5-Five-Minute-Run)).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Cookie Clicker Bot
|
||||||
|
|
||||||
|
🌐 [English](Home) · **Dansk** · [Bahasa Melayu](Home-ms)
|
||||||
|
|
||||||
|
En Selenium-bot, der spiller [Cookie Clicker](https://ozh.github.io/cookieclicker/) i fem minutter: den klikker på den store cookie, køber opgraderinger og melder, hvor mange cookies i sekundet (CPS) den nåede. Det er projektet fra dag 48 i Udemys *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||||
|
|
||||||
|
Denne wiki gennemgår de fem trin fra forelæsningen. Hver side har et skærmbillede, som botten selv har taget i en headless browser i Docker, funktionen der udfører trinnet, og hvad trinnet lærte os.
|
||||||
|
|
||||||
|
| Side | Læs den, når du vil |
|
||||||
|
| --- | --- |
|
||||||
|
| [Beskrivelse](Description-da) | Se hvad projektet er, og hvordan koden er lagdelt |
|
||||||
|
| [Installation](Install-da) | Oprette et `.venv`, installere og køre botten og testene |
|
||||||
|
| [Trin 1: åbn spillet](Step-1-Open-The-Game-da) | Se hvordan botten starter spillet og vælger sprog |
|
||||||
|
| [Trin 2: klik på cookien](Step-2-Click-The-Cookie-da) | Se hvorfor botten skal bruge rigtige klik |
|
||||||
|
| [Trin 3: læs antallet](Step-3-Read-The-Count-da) | Se hvordan spillets tal bliver læst |
|
||||||
|
| [Trin 4: køb opgraderinger](Step-4-Buy-Upgrades-da) | Se hvordan botten vælger og køber produkter |
|
||||||
|
| [Trin 5: kørslen på fem minutter](Step-5-Five-Minute-Run-da) | Se løkken, resultatet og Docker-kørslen |
|
||||||
|
| [Appendiks](Appendix-Documentation-da) | Finde projektets dokumenter |
|
||||||
|
|
||||||
|
Resultatet af kørslen på skærmbillederne: **16,6 cookies i sekundet** efter 300 sekunder.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Bot Cookie Clicker
|
||||||
|
|
||||||
|
🌐 [English](Home) · [Dansk](Home-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
Bot Selenium yang bermain [Cookie Clicker](https://ozh.github.io/cookieclicker/) selama lima minit: ia mengklik kuki besar, membeli peningkatan dan melaporkan kuki sesaat (CPS) yang dicapai. Ini projek hari ke-48 dalam *100 Days of Code: The Complete Python Pro Bootcamp* di Udemy.
|
||||||
|
|
||||||
|
Wiki ini menerangkan lima langkah kuliah. Setiap halaman mempunyai tangkapan skrin yang diambil oleh bot sendiri dalam pelayar tanpa kepala (headless) di dalam Docker, fungsi yang melaksanakan langkah itu, dan apa yang dipelajari daripada langkah tersebut.
|
||||||
|
|
||||||
|
| Halaman | Baca apabila anda mahu |
|
||||||
|
| --- | --- |
|
||||||
|
| [Huraian](Description-ms) | Melihat apa itu projek ini dan bagaimana kod disusun berlapis |
|
||||||
|
| [Pemasangan](Install-ms) | Mencipta `.venv`, memasang, menjalankan bot dan ujian |
|
||||||
|
| [Langkah 1: buka permainan](Step-1-Open-The-Game-ms) | Melihat bagaimana bot memulakan permainan dan memilih bahasa |
|
||||||
|
| [Langkah 2: klik kuki](Step-2-Click-The-Cookie-ms) | Melihat sebab bot memerlukan klik sebenar |
|
||||||
|
| [Langkah 3: baca bilangan](Step-3-Read-The-Count-ms) | Melihat bagaimana nombor permainan dibaca |
|
||||||
|
| [Langkah 4: beli peningkatan](Step-4-Buy-Upgrades-ms) | Melihat bagaimana bot memilih dan membeli produk |
|
||||||
|
| [Langkah 5: larian lima minit](Step-5-Five-Minute-Run-ms) | Melihat gelung, hasil dan larian Docker |
|
||||||
|
| [Lampiran](Appendix-Documentation-ms) | Mencari dokumen projek |
|
||||||
|
|
||||||
|
Hasil larian dalam tangkapan skrin: **16.6 kuki sesaat** selepas 300 saat.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Cookie Clicker Bot
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Home-da) · [Bahasa Melayu](Home-ms)
|
||||||
|
|
||||||
|
A Selenium bot that plays [Cookie Clicker](https://ozh.github.io/cookieclicker/) for five minutes: it clicks the big cookie, buys upgrades and reports its cookies per second (CPS). It is the day 48 project of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||||
|
|
||||||
|
This wiki walks through the five lecture steps. Each page has a screenshot taken by the bot itself in a headless browser inside Docker, the function that does the step, and what the step taught.
|
||||||
|
|
||||||
|
| Page | Read it when you want to |
|
||||||
|
| --- | --- |
|
||||||
|
| [Description](Description) | See what the project is and how the code is layered |
|
||||||
|
| [Install](Install) | Create a `.venv`, install, run the bot and the tests |
|
||||||
|
| [Step 1: open the game](Step-1-Open-The-Game) | See how the bot starts the game and chooses a language |
|
||||||
|
| [Step 2: click the cookie](Step-2-Click-The-Cookie) | See why the bot needs real clicks |
|
||||||
|
| [Step 3: read the count](Step-3-Read-The-Count) | See how the game's numbers are parsed |
|
||||||
|
| [Step 4: buy upgrades](Step-4-Buy-Upgrades) | See how the bot chooses and buys products |
|
||||||
|
| [Step 5: the five-minute run](Step-5-Five-Minute-Run) | See the loop, the result and the Docker run |
|
||||||
|
| [Appendix](Appendix-Documentation) | Find the project documents |
|
||||||
|
|
||||||
|
The result of the run shown in the screenshots: **16.6 cookies per second** after 300 seconds.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
🌐 [English](Install) · **Dansk** · [Bahasa Melayu](Install-ms)
|
||||||
|
|
||||||
|
## Du skal bruge
|
||||||
|
|
||||||
|
- Python 3.13 eller nyere.
|
||||||
|
- Google Chrome (Selenium Manager henter driveren). Ikke nødvendig til testene.
|
||||||
|
- Docker, kun for at gentage den headless kørsel og skærmbillederne.
|
||||||
|
|
||||||
|
## Opret og brug et lokalt `.venv`
|
||||||
|
|
||||||
|
Første blok er til Windows PowerShell, anden til Linux og macOS. Forlad miljøet med `deactivate`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kør botten og testene
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m cookie_clicker_bot
|
||||||
|
python -m cookie_clicker_bot --headless --duration 60
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gentag skærmbillederne i Docker
|
||||||
|
|
||||||
|
Kørslen er headless, så den tager aldrig din mus eller dit tastatur. Skærmbillederne og `steps.json` havner i `runs/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cookie-clicker-bot -f docker/Dockerfile .
|
||||||
|
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Pemasangan
|
||||||
|
|
||||||
|
🌐 [English](Install) · [Dansk](Install-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
## Anda memerlukan
|
||||||
|
|
||||||
|
- Python 3.13 atau lebih baharu.
|
||||||
|
- Google Chrome (Selenium Manager memuat turun pemandu). Tidak diperlukan untuk ujian.
|
||||||
|
- Docker, hanya untuk mengulang larian tanpa kepala dan tangkapan skrin.
|
||||||
|
|
||||||
|
## Cipta dan guna `.venv` setempat
|
||||||
|
|
||||||
|
Blok pertama untuk Windows PowerShell, yang kedua untuk Linux dan macOS. Keluar daripada persekitaran dengan `deactivate`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jalankan bot dan ujian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m cookie_clicker_bot
|
||||||
|
python -m cookie_clicker_bot --headless --duration 60
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ulang tangkapan skrin dalam Docker
|
||||||
|
|
||||||
|
Larian ini tanpa kepala, jadi ia tidak mengambil tetikus atau papan kekunci anda. Tangkapan skrin dan `steps.json` disimpan dalam `runs/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cookie-clicker-bot -f docker/Dockerfile .
|
||||||
|
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Install
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Install-da) · [Bahasa Melayu](Install-ms)
|
||||||
|
|
||||||
|
## You need
|
||||||
|
|
||||||
|
- Python 3.13 or newer.
|
||||||
|
- Google Chrome (Selenium Manager downloads the driver). Not needed for the tests.
|
||||||
|
- Docker, only to repeat the headless run and the screenshots.
|
||||||
|
|
||||||
|
## Create and use a local `.venv`
|
||||||
|
|
||||||
|
The first block is for Windows PowerShell, the second for Linux and macOS. Leave the environment with `deactivate`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the bot and the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m cookie_clicker_bot
|
||||||
|
python -m cookie_clicker_bot --headless --duration 60
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repeat the screenshots in Docker
|
||||||
|
|
||||||
|
The run is headless, so it never takes your mouse or keyboard. The screenshots and `steps.json` land in `runs/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cookie-clicker-bot -f docker/Dockerfile .
|
||||||
|
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Trin 1: åbn spillet
|
||||||
|
|
||||||
|
🌐 [English](Step-1-Open-The-Game) · **Dansk** · [Bahasa Melayu](Step-1-Open-The-Game-ms)
|
||||||
|
|
||||||
|
Botten starter Chrome, indlæser spillet og venter, til sprogvalget er på siden. Derefter vælger den engelsk, så den store cookie kan bruges.
|
||||||
|
|
||||||
|
**Skærmbillede**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Funktion**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def open_game(game: GamePort) -> None:
|
||||||
|
game.open_page(GAME_URL)
|
||||||
|
game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
|
||||||
|
|
||||||
|
def select_language(game: GamePort) -> None:
|
||||||
|
game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
game.wait_for_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad vi fik ud af det
|
||||||
|
|
||||||
|
- **Vent på et element, sov ikke.** `WebDriverWait` vender tilbage, så snart elementet findes, så botten er hurtig, når siden er hurtig, og tålmodig, når den er langsom.
|
||||||
|
- **Selenium Manager** finder den rigtige Chrome-driver, så der ikke skal hentes noget manuelt.
|
||||||
|
- **En port holder Selenium ude af reglerne.** `open_game` taler kun med `GamePort`, så et falsk spil kan teste den uden browser.
|
||||||
|
- **Ét sted til sidens detaljer.** Adressen og element-id'erne står i `constants.py`.
|
||||||
|
|
||||||
|
Næste: [Trin 2: klik på cookien](Step-2-Click-The-Cookie-da)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Langkah 1: buka permainan
|
||||||
|
|
||||||
|
🌐 [English](Step-1-Open-The-Game) · [Dansk](Step-1-Open-The-Game-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
Bot memulakan Chrome, memuatkan permainan dan menunggu sehingga pilihan bahasa muncul pada halaman. Kemudian ia memilih bahasa Inggeris, supaya kuki besar boleh dimainkan.
|
||||||
|
|
||||||
|
**Tangkapan skrin**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Fungsi**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def open_game(game: GamePort) -> None:
|
||||||
|
game.open_page(GAME_URL)
|
||||||
|
game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
|
||||||
|
|
||||||
|
def select_language(game: GamePort) -> None:
|
||||||
|
game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
game.wait_for_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apa yang kami peroleh
|
||||||
|
|
||||||
|
- **Tunggu elemen, jangan tidur.** `WebDriverWait` kembali sebaik sahaja elemen wujud, jadi bot pantas apabila halaman pantas dan sabar apabila ia perlahan.
|
||||||
|
- **Selenium Manager** mencari pemandu Chrome yang sepadan, jadi tiada muat turun manual.
|
||||||
|
- **Port mengasingkan Selenium daripada peraturan.** `open_game` hanya bercakap dengan `GamePort`, jadi permainan palsu boleh mengujinya tanpa pelayar.
|
||||||
|
- **Satu tempat untuk butiran halaman.** Alamat dan id elemen berada dalam `constants.py`.
|
||||||
|
|
||||||
|
Seterusnya: [Langkah 2: klik kuki](Step-2-Click-The-Cookie-ms)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Step 1: open the game
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Step-1-Open-The-Game-da) · [Bahasa Melayu](Step-1-Open-The-Game-ms)
|
||||||
|
|
||||||
|
The bot starts Chrome, loads the game and waits until the language prompt is on the page. Then it picks English, which makes the big cookie playable.
|
||||||
|
|
||||||
|
**Screenshot**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def open_game(game: GamePort) -> None:
|
||||||
|
game.open_page(GAME_URL)
|
||||||
|
game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
|
||||||
|
|
||||||
|
def select_language(game: GamePort) -> None:
|
||||||
|
game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||||
|
game.wait_for_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we gained
|
||||||
|
|
||||||
|
- **Wait for an element, do not sleep.** `WebDriverWait` returns as soon as the element exists, so the bot is fast when the page is fast and patient when it is slow.
|
||||||
|
- **Selenium Manager** finds the matching Chrome driver, so there is no manual download.
|
||||||
|
- **A port keeps Selenium out of the rules.** `open_game` only talks to `GamePort`, so a fake game tests it without a browser.
|
||||||
|
- **One place for the page's details.** The address and the element ids are in `constants.py`.
|
||||||
|
|
||||||
|
Next: [Step 2: click the cookie](Step-2-Click-The-Cookie)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Trin 2: klik på cookien
|
||||||
|
|
||||||
|
🌐 [English](Step-2-Click-The-Cookie) · **Dansk** · [Bahasa Melayu](Step-2-Click-The-Cookie-ms)
|
||||||
|
|
||||||
|
`click_cookie` klikker et antal gange på den store cookie. Efter 15 klik viser skærmbilledet tælleren og de første cookies i bageriet.
|
||||||
|
|
||||||
|
**Skærmbillede**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Funktion**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def click_cookie(game: GamePort, times: int = 1) -> None:
|
||||||
|
if times < 0:
|
||||||
|
raise ValueError(f"times must not be negative, got {times}")
|
||||||
|
for _ in range(times):
|
||||||
|
game.click_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad vi fik ud af det
|
||||||
|
|
||||||
|
- **Et rigtigt klik, ikke et script-klik.** Spillet ignorerer syntetiske klik over cirka 3 i sekundet, men tager imod cirka 50 i sekundet fra et rigtigt klik, så `SeleniumGame` bruger altid `element.click()`.
|
||||||
|
- **Pas på inddata.** Et negativt antal klik giver `ValueError`, og nul klik er tilladt; begge dele er testet.
|
||||||
|
- **Prøv igen, når siden ændrer sig.** Spillet tegner siden om ofte, så et klik på et forældet element prøves igen et par gange.
|
||||||
|
|
||||||
|
Næste: [Trin 3: læs antallet](Step-3-Read-The-Count-da)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Langkah 2: klik kuki
|
||||||
|
|
||||||
|
🌐 [English](Step-2-Click-The-Cookie) · [Dansk](Step-2-Click-The-Cookie-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
`click_cookie` mengklik kuki besar beberapa kali. Selepas 15 klik, tangkapan skrin menunjukkan pembilang dan kuki pertama di kedai roti.
|
||||||
|
|
||||||
|
**Tangkapan skrin**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Fungsi**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def click_cookie(game: GamePort, times: int = 1) -> None:
|
||||||
|
if times < 0:
|
||||||
|
raise ValueError(f"times must not be negative, got {times}")
|
||||||
|
for _ in range(times):
|
||||||
|
game.click_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apa yang kami peroleh
|
||||||
|
|
||||||
|
- **Klik sebenar, bukan klik skrip.** Permainan mengabaikan klik sintetik melebihi kira-kira 3 sesaat tetapi menerima kira-kira 50 sesaat daripada klik sebenar, jadi `SeleniumGame` sentiasa menggunakan `element.click()`.
|
||||||
|
- **Kawal input.** Bilangan klik negatif menimbulkan `ValueError`, dan sifar klik dibenarkan; kedua-duanya diuji.
|
||||||
|
- **Cuba semula apabila halaman berubah.** Permainan melukis semula halamannya dengan kerap, jadi klik pada elemen yang usang dicuba semula beberapa kali.
|
||||||
|
|
||||||
|
Seterusnya: [Langkah 3: baca bilangan](Step-3-Read-The-Count-ms)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Step 2: click the cookie
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Step-2-Click-The-Cookie-da) · [Bahasa Melayu](Step-2-Click-The-Cookie-ms)
|
||||||
|
|
||||||
|
`click_cookie` clicks the big cookie a number of times. After 15 clicks the screenshot shows the counter and the first cookies in the bakery.
|
||||||
|
|
||||||
|
**Screenshot**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def click_cookie(game: GamePort, times: int = 1) -> None:
|
||||||
|
if times < 0:
|
||||||
|
raise ValueError(f"times must not be negative, got {times}")
|
||||||
|
for _ in range(times):
|
||||||
|
game.click_element(BIG_COOKIE_ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we gained
|
||||||
|
|
||||||
|
- **A real click, not a script click.** The game ignores synthetic clicks above about 3 per second but accepts about 50 per second from a real click, so `SeleniumGame` always uses `element.click()`.
|
||||||
|
- **Guard the input.** A negative number of clicks raises `ValueError`, and zero clicks is allowed; both are tested.
|
||||||
|
- **Retry when the page changes.** The game redraws its page often, so a click that hits a stale element is tried again a few times.
|
||||||
|
|
||||||
|
Next: [Step 3: read the count](Step-3-Read-The-Count)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Trin 3: læs antallet
|
||||||
|
|
||||||
|
🌐 [English](Step-3-Read-The-Count) · **Dansk** · [Bahasa Melayu](Step-3-Read-The-Count-ms)
|
||||||
|
|
||||||
|
`get_cookie_count` læser tællerens første linje og gør den til et heltal. Spillet skriver `12,345` og, ved store beløb, `1.234 million`.
|
||||||
|
|
||||||
|
**Skærmbillede**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Funktion**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_cookie_count(game: GamePort) -> int:
|
||||||
|
first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0]
|
||||||
|
return parse_game_number(first_line)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad vi fik ud af det
|
||||||
|
|
||||||
|
- **Læs spillets eget talformat.** Kommaer grupperer tusinder, og talord (`million`, `billion`, op til `decillion`) skalerer værdien.
|
||||||
|
- **Brug `Decimal`, ikke `float`.** En første version brugte `float`, og `3 decillion` blev forkert; `Decimal` er præcis. En fejlende test fandt det.
|
||||||
|
- **Fejl med en tydelig fejl.** Tekst uden et indledende tal giver `UnreadableNumberError` i stedet for en forkert værdi.
|
||||||
|
- **Test grænsetilfældene.** Tom tekst, ental og flertal (`1 cookie`), ukendte ord og afrunding er alle med i testene.
|
||||||
|
|
||||||
|
Næste: [Trin 4: køb opgraderinger](Step-4-Buy-Upgrades-da)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Langkah 3: baca bilangan
|
||||||
|
|
||||||
|
🌐 [English](Step-3-Read-The-Count) · [Dansk](Step-3-Read-The-Count-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
`get_cookie_count` membaca baris pertama pembilang dan menukarkannya kepada nombor bulat. Permainan menulis `12,345` dan, bagi jumlah besar, `1.234 million`.
|
||||||
|
|
||||||
|
**Tangkapan skrin**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Fungsi**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_cookie_count(game: GamePort) -> int:
|
||||||
|
first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0]
|
||||||
|
return parse_game_number(first_line)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apa yang kami peroleh
|
||||||
|
|
||||||
|
- **Tafsir format nombor permainan sendiri.** Koma mengumpulkan ribuan dan perkataan nombor (`million`, `billion`, hingga `decillion`) menskalakan nilai.
|
||||||
|
- **Guna `Decimal`, bukan `float`.** Versi pertama menggunakan `float` dan `3 decillion` menjadi salah; `Decimal` tepat. Ujian yang gagal menemuinya.
|
||||||
|
- **Gagal dengan ralat yang jelas.** Teks tanpa nombor di hadapan menimbulkan `UnreadableNumberError` dan bukannya nilai yang salah.
|
||||||
|
- **Uji kes tepi.** Teks kosong, tunggal dan jamak (`1 cookie`), perkataan tidak dikenali dan pembundaran semuanya ada dalam ujian.
|
||||||
|
|
||||||
|
Seterusnya: [Langkah 4: beli peningkatan](Step-4-Buy-Upgrades-ms)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Step 3: read the count
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Step-3-Read-The-Count-da) · [Bahasa Melayu](Step-3-Read-The-Count-ms)
|
||||||
|
|
||||||
|
`get_cookie_count` reads the first line of the counter and turns it into a whole number. The game writes `12,345` and, for big amounts, `1.234 million`.
|
||||||
|
|
||||||
|
**Screenshot**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_cookie_count(game: GamePort) -> int:
|
||||||
|
first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0]
|
||||||
|
return parse_game_number(first_line)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we gained
|
||||||
|
|
||||||
|
- **Parse the game's own number format.** Commas group thousands and number words (`million`, `billion`, up to `decillion`) scale the value.
|
||||||
|
- **Use `Decimal`, not `float`.** A first version used `float` and `3 decillion` came out wrong; `Decimal` is exact. A failing test found it.
|
||||||
|
- **Fail with a clear error.** Text without a leading number raises `UnreadableNumberError` instead of returning a wrong value.
|
||||||
|
- **Test the edge cases.** Empty text, a plural and a singular (`1 cookie`), unknown words and rounding are all in the tests.
|
||||||
|
|
||||||
|
Next: [Step 4: buy upgrades](Step-4-Buy-Upgrades)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Trin 4: køb opgraderinger
|
||||||
|
|
||||||
|
🌐 [English](Step-4-Buy-Upgrades) · **Dansk** · [Bahasa Melayu](Step-4-Buy-Upgrades-ms)
|
||||||
|
|
||||||
|
`get_store_items` læser butikkens 20 produkter, `choose_upgrade` vælger det dyreste, botten har råd til, og `buy_upgrade` klikker på det. Skærmbilledet viser den første markør, der er købt.
|
||||||
|
|
||||||
|
**Skærmbillede**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Funktion**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def choose_upgrade(items: Sequence[StoreItem]) -> StoreItem | None:
|
||||||
|
affordable = [item for item in items if item.is_affordable]
|
||||||
|
if not affordable:
|
||||||
|
return None
|
||||||
|
return max(affordable, key=lambda item: (item.price, item.index))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad vi fik ud af det
|
||||||
|
|
||||||
|
- **Hold beslutningen ren.** `choose_upgrade` tager almindelige `StoreItem`-værdier og returnerer én, så den testes uden noget spil (lige priser, intet overkommeligt, tom liste).
|
||||||
|
- **Bekræft resultatet, antag det ikke.** `buy_upgrade` tjekker, at det ejede antal (`productOwned<N>`) steg. At tjekke antallet af cookies først var forkert: antallet stiger, mens botten klikker.
|
||||||
|
- **Siden halter bagefter.** Den rigtige kørsel viste, at cirka 40 procent af købsforsøgene mislykkes, fordi butikkens markeringer læses lige før spillet tegner om. Botten logger det og prøver igen ved næste butiksbesøg.
|
||||||
|
- **Det rigtige spil finder det, falske ikke kan.** Genforsøget ved forældede elementer og tjekket af ejet antal kom begge fra kørsler i Docker.
|
||||||
|
|
||||||
|
Næste: [Trin 5: kørslen på fem minutter](Step-5-Five-Minute-Run-da)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Langkah 4: beli peningkatan
|
||||||
|
|
||||||
|
🌐 [English](Step-4-Buy-Upgrades) · [Dansk](Step-4-Buy-Upgrades-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
`get_store_items` membaca 20 produk kedai, `choose_upgrade` memilih yang termahal yang mampu dibeli bot, dan `buy_upgrade` mengkliknya. Tangkapan skrin menunjukkan kursor pertama yang dibeli.
|
||||||
|
|
||||||
|
**Tangkapan skrin**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Fungsi**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def choose_upgrade(items: Sequence[StoreItem]) -> StoreItem | None:
|
||||||
|
affordable = [item for item in items if item.is_affordable]
|
||||||
|
if not affordable:
|
||||||
|
return None
|
||||||
|
return max(affordable, key=lambda item: (item.price, item.index))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apa yang kami peroleh
|
||||||
|
|
||||||
|
- **Pastikan keputusan bersih.** `choose_upgrade` menerima nilai `StoreItem` biasa dan memulangkan satu, jadi ia diuji tanpa permainan langsung (harga sama, tiada yang mampu dibeli, senarai kosong).
|
||||||
|
- **Sahkan hasilnya, jangan andaikan.** `buy_upgrade` menyemak bahawa jumlah dimiliki (`productOwned<N>`) bertambah. Menyemak bilangan kuki dahulu adalah salah: bilangan terus meningkat semasa bot mengklik.
|
||||||
|
- **Halaman ketinggalan.** Larian sebenar menunjukkan kira-kira 40 peratus percubaan pembelian gagal kerana bendera kedai dibaca sebelum permainan melukis semula. Bot mencatatkannya dan mencuba lagi pada lawatan kedai seterusnya.
|
||||||
|
- **Permainan sebenar menemui apa yang tidak dapat ditemui oleh yang palsu.** Percubaan semula elemen usang dan semakan jumlah dimiliki kedua-duanya datang daripada larian dalam Docker.
|
||||||
|
|
||||||
|
Seterusnya: [Langkah 5: larian lima minit](Step-5-Five-Minute-Run-ms)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Step 4: buy upgrades
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Step-4-Buy-Upgrades-da) · [Bahasa Melayu](Step-4-Buy-Upgrades-ms)
|
||||||
|
|
||||||
|
`get_store_items` reads the 20 store products, `choose_upgrade` picks the most expensive one the bot can afford, and `buy_upgrade` clicks it. The screenshot shows the first cursor bought.
|
||||||
|
|
||||||
|
**Screenshot**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def choose_upgrade(items: Sequence[StoreItem]) -> StoreItem | None:
|
||||||
|
affordable = [item for item in items if item.is_affordable]
|
||||||
|
if not affordable:
|
||||||
|
return None
|
||||||
|
return max(affordable, key=lambda item: (item.price, item.index))
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we gained
|
||||||
|
|
||||||
|
- **Keep the decision pure.** `choose_upgrade` takes plain `StoreItem` values and returns one, so it is tested with no game at all (ties, none affordable, empty list).
|
||||||
|
- **Confirm the result, do not assume it.** `buy_upgrade` checks that the owned amount (`productOwned<N>`) went up. Checking the cookie count first was wrong: the count keeps rising while the bot clicks.
|
||||||
|
- **The page lags behind.** The live run showed that about 40 percent of the purchase attempts fail because the store flags are read just before the game redraws. The bot logs it and tries again at the next store check.
|
||||||
|
- **The real game finds what fakes cannot.** The stale-element retry and the owned-amount check both came from running in Docker.
|
||||||
|
|
||||||
|
Next: [Step 5: the five-minute run](Step-5-Five-Minute-Run)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Trin 5: kørslen på fem minutter
|
||||||
|
|
||||||
|
🌐 [English](Step-5-Five-Minute-Run) · **Dansk** · [Bahasa Melayu](Step-5-Five-Minute-Run-ms)
|
||||||
|
|
||||||
|
`run_bot` spiller i 300 sekunder i én løkke: en byge på 20 klik, og hvert 5. sekund et besøg i butikken. Til sidst læser `get_cps` cookies i sekundet. Kørslen på skærmbilledet nåede **16,6 cookies i sekundet** med 26 markører og 14 bedstemødre.
|
||||||
|
|
||||||
|
**Skærmbillede**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Funktion**
|
||||||
|
|
||||||
|
```python
|
||||||
|
while now - start < duration_seconds:
|
||||||
|
click_cookie(game, CLICKS_PER_BURST)
|
||||||
|
now = clock.monotonic()
|
||||||
|
if now >= next_store_check:
|
||||||
|
_buy_while_affordable(game)
|
||||||
|
next_store_check = now + STORE_CHECK_INTERVAL_SECONDS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad vi fik ud af det
|
||||||
|
|
||||||
|
- **Én løkke, ingen tråde.** Den tidligere løsning brugte to tråde og en ekstra pakke. Én løkke er lettere at læse, kræver ingen ekstra afhængighed og er let at teste.
|
||||||
|
- **Injicér uret.** `run_bot` får et `Clock`, så et falsk ur tester en kørsel på 300 sekunder på millisekunder.
|
||||||
|
- **Kør GUI'en i Docker.** Headless Chromium i en container tager aldrig din mus eller dit tastatur, og skærmbillederne og loggen kommer ud som filer.
|
||||||
|
- **Videre derfra.** Køb efter pris pr. cookie i sekundet, køb opgraderingerne og klik på den gyldne cookie: hver er en ny funktion bag de samme porte.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Langkah 5: larian lima minit
|
||||||
|
|
||||||
|
🌐 [English](Step-5-Five-Minute-Run) · [Dansk](Step-5-Five-Minute-Run-da) · **Bahasa Melayu**
|
||||||
|
|
||||||
|
`run_bot` bermain selama 300 saat dalam satu gelung: satu rentetan 20 klik, kemudian setiap 5 saat satu lawatan ke kedai. Pada akhirnya `get_cps` membaca kuki sesaat. Larian dalam tangkapan skrin mencapai **16.6 kuki sesaat** dengan 26 kursor dan 14 nenek.
|
||||||
|
|
||||||
|
**Tangkapan skrin**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Fungsi**
|
||||||
|
|
||||||
|
```python
|
||||||
|
while now - start < duration_seconds:
|
||||||
|
click_cookie(game, CLICKS_PER_BURST)
|
||||||
|
now = clock.monotonic()
|
||||||
|
if now >= next_store_check:
|
||||||
|
_buy_while_affordable(game)
|
||||||
|
next_store_check = now + STORE_CHECK_INTERVAL_SECONDS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apa yang kami peroleh
|
||||||
|
|
||||||
|
- **Satu gelung, tiada thread.** Penyelesaian terdahulu menggunakan dua thread dan satu pakej tambahan. Satu gelung lebih mudah dibaca, tidak memerlukan kebergantungan tambahan dan mudah diuji.
|
||||||
|
- **Suntik jam.** `run_bot` menerima `Clock`, jadi jam palsu menguji larian 300 saat dalam milisaat.
|
||||||
|
- **Jalankan GUI dalam Docker.** Chromium tanpa kepala dalam bekas tidak pernah mengambil tetikus atau papan kekunci anda, dan tangkapan skrin serta log keluar sebagai fail.
|
||||||
|
- **Langkah seterusnya.** Beli mengikut kos setiap kuki sesaat, beli peningkatan dan klik kuki emas: setiap satu ialah fungsi baharu di belakang port yang sama.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Step 5: the five-minute run
|
||||||
|
|
||||||
|
🌐 **English** · [Dansk](Step-5-Five-Minute-Run-da) · [Bahasa Melayu](Step-5-Five-Minute-Run-ms)
|
||||||
|
|
||||||
|
`run_bot` plays for 300 seconds in one loop: a burst of 20 clicks, then every 5 seconds a visit to the store. At the end `get_cps` reads the cookies per second. The run in the screenshot reached **16.6 cookies per second** with 26 cursors and 14 grandmas.
|
||||||
|
|
||||||
|
**Screenshot**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
```python
|
||||||
|
while now - start < duration_seconds:
|
||||||
|
click_cookie(game, CLICKS_PER_BURST)
|
||||||
|
now = clock.monotonic()
|
||||||
|
if now >= next_store_check:
|
||||||
|
_buy_while_affordable(game)
|
||||||
|
next_store_check = now + STORE_CHECK_INTERVAL_SECONDS
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we gained
|
||||||
|
|
||||||
|
- **One loop, no threads.** The earlier solution used two threads and an extra package. One loop is easier to read, needs no extra dependency and is easy to test.
|
||||||
|
- **Inject the clock.** `run_bot` receives a `Clock`, so a fake clock tests a 300 second run in milliseconds.
|
||||||
|
- **Run the GUI in Docker.** Headless Chromium in a container never takes your mouse or keyboard, and the screenshots and the log come out as files.
|
||||||
|
- **Where to go next.** Buy by cost per cookie per second, buy the upgrade crates, and click the golden cookie: each is a new function behind the same ports.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
**Cookie Clicker Bot**
|
||||||
|
|
||||||
|
- [Cookie Clicker Bot](Home)
|
||||||
|
- [Description](Description)
|
||||||
|
- [Install](Install)
|
||||||
|
- [Step 1: open the game](Step-1-Open-The-Game)
|
||||||
|
- [Step 2: click the cookie](Step-2-Click-The-Cookie)
|
||||||
|
- [Step 3: read the count](Step-3-Read-The-Count)
|
||||||
|
- [Step 4: buy upgrades](Step-4-Buy-Upgrades)
|
||||||
|
- [Step 5: the five-minute run](Step-5-Five-Minute-Run)
|
||||||
|
- [Appendix](Appendix-Documentation)
|
||||||
|
|
||||||
|
**Languages**
|
||||||
|
|
||||||
|
- English: [Home](Home)
|
||||||
|
- Dansk: [Hjem](Home-da)
|
||||||
|
- Bahasa Melayu: [Utama](Home-ms)
|
||||||
@@ -29,7 +29,7 @@ def _buy_while_affordable(game: GamePort) -> int:
|
|||||||
while (item := choose_upgrade(get_store_items(game))) is not None:
|
while (item := choose_upgrade(get_store_items(game))) is not None:
|
||||||
if not buy_upgrade(game, item):
|
if not buy_upgrade(game, item):
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Purchase of product %d did not lower the cookies", item.index
|
"Purchase of product %d did not raise the owned amount", item.index
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
bought += 1
|
bought += 1
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||||
from cookie_clicker_bot.application.use_cases.bot_core import get_cookie_count
|
|
||||||
from cookie_clicker_bot.constants import (
|
from cookie_clicker_bot.constants import (
|
||||||
CLASS_ATTRIBUTE,
|
CLASS_ATTRIBUTE,
|
||||||
CPS_ID,
|
CPS_ID,
|
||||||
CPS_SEPARATOR,
|
CPS_SEPARATOR,
|
||||||
ENABLED_CLASS,
|
ENABLED_CLASS,
|
||||||
|
OWNED_CHECK_ATTEMPTS,
|
||||||
PRODUCT_COUNT,
|
PRODUCT_COUNT,
|
||||||
PRODUCT_ID_PREFIX,
|
PRODUCT_ID_PREFIX,
|
||||||
|
PRODUCT_OWNED_ID_PREFIX,
|
||||||
PRODUCT_PRICE_ID_PREFIX,
|
PRODUCT_PRICE_ID_PREFIX,
|
||||||
UNLOCKED_CLASS,
|
UNLOCKED_CLASS,
|
||||||
)
|
)
|
||||||
@@ -41,15 +42,28 @@ def get_store_items(game: GamePort) -> list[StoreItem]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _read_owned(game: GamePort, index: int) -> int:
|
||||||
|
"""@brief Read how many of a product is owned; the game shows none as empty."""
|
||||||
|
text = game.read_text(PRODUCT_OWNED_ID_PREFIX + str(index)).strip()
|
||||||
|
return parse_game_number(text) if text else 0
|
||||||
|
|
||||||
|
|
||||||
def buy_upgrade(game: GamePort, item: StoreItem) -> bool:
|
def buy_upgrade(game: GamePort, item: StoreItem) -> bool:
|
||||||
"""@brief Click a product and check that the purchase went through.
|
"""@brief Click a product and check that the purchase went through.
|
||||||
|
|
||||||
|
The game redraws the page a moment after the click, so the owned amount is
|
||||||
|
read again, up to OWNED_CHECK_ATTEMPTS times, until it has grown.
|
||||||
|
|
||||||
@param game The game page to operate.
|
@param game The game page to operate.
|
||||||
@param item The product to buy.
|
@param item The product to buy.
|
||||||
@return True when the cookie count dropped after the click.
|
@return True when the owned amount of the product grew after the click.
|
||||||
|
@throws UnreadableNumberError When the owned amount shows no number.
|
||||||
"""
|
"""
|
||||||
before = get_cookie_count(game)
|
before = _read_owned(game, item.index)
|
||||||
game.click_element(PRODUCT_ID_PREFIX + str(item.index))
|
game.click_element(PRODUCT_ID_PREFIX + str(item.index))
|
||||||
return get_cookie_count(game) < before
|
return any(
|
||||||
|
_read_owned(game, item.index) > before for _ in range(OWNED_CHECK_ATTEMPTS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_cps(game: GamePort) -> Decimal:
|
def get_cps(game: GamePort) -> Decimal:
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ PRODUCT_ID_PREFIX: str = "product"
|
|||||||
## Element id prefix of the price of a store product; the product index follows.
|
## Element id prefix of the price of a store product; the product index follows.
|
||||||
PRODUCT_PRICE_ID_PREFIX: str = "productPrice"
|
PRODUCT_PRICE_ID_PREFIX: str = "productPrice"
|
||||||
|
|
||||||
|
## Element id prefix of the owned amount of a store product; the product index follows.
|
||||||
|
PRODUCT_OWNED_ID_PREFIX: str = "productOwned"
|
||||||
|
|
||||||
|
## Reads of the owned amount after a purchase click, as the game redraws late.
|
||||||
|
OWNED_CHECK_ATTEMPTS: int = 20
|
||||||
|
|
||||||
|
## Times an action on the page is repeated after a stale or blocked element.
|
||||||
|
CLICK_RETRIES: int = 3
|
||||||
|
|
||||||
## Attribute that holds the CSS classes of an element.
|
## Attribute that holds the CSS classes of an element.
|
||||||
CLASS_ATTRIBUTE: str = "class"
|
CLASS_ATTRIBUTE: str = "class"
|
||||||
|
|
||||||
@@ -56,6 +65,36 @@ CPS_SEPARATOR: str = ":"
|
|||||||
## Label printed before the cookies per second at the end of the run.
|
## Label printed before the cookies per second at the end of the run.
|
||||||
RESULT_LABEL: str = "Result"
|
RESULT_LABEL: str = "Result"
|
||||||
|
|
||||||
|
## Environment variable that names the Chrome binary to start.
|
||||||
|
CHROME_BIN_ENV: str = "CHROME_BIN"
|
||||||
|
|
||||||
|
## Environment variable that names the chromedriver executable to use.
|
||||||
|
CHROMEDRIVER_ENV: str = "CHROMEDRIVER"
|
||||||
|
|
||||||
|
## Screenshot file name of lecture step 1, the game opened at the language prompt.
|
||||||
|
SCREENSHOT_OPEN_GAME: str = "step-1-open-game.png"
|
||||||
|
|
||||||
|
## Screenshot file name of lecture step 2, after the first clicks on the cookie.
|
||||||
|
SCREENSHOT_CLICK_COOKIE: str = "step-2-click-cookie.png"
|
||||||
|
|
||||||
|
## Screenshot file name of lecture step 3, after the cookie count was read.
|
||||||
|
SCREENSHOT_READ_COUNT: str = "step-3-read-count.png"
|
||||||
|
|
||||||
|
## Screenshot file name of lecture step 4, after an upgrade was bought.
|
||||||
|
SCREENSHOT_BUY_UPGRADE: str = "step-4-buy-upgrade.png"
|
||||||
|
|
||||||
|
## Screenshot file name of lecture step 5, after the full run.
|
||||||
|
SCREENSHOT_RESULT: str = "step-5-result.png"
|
||||||
|
|
||||||
|
## File name of the record of what each capture step read and did.
|
||||||
|
STEPS_RECORD_FILE: str = "steps.json"
|
||||||
|
|
||||||
|
## Clicks on the big cookie in lecture step 2 of the capture.
|
||||||
|
CAPTURE_CLICK_COUNT: int = 15
|
||||||
|
|
||||||
|
## Most bursts of clicks the capture makes while it waits for an affordable product.
|
||||||
|
CAPTURE_MAX_BURSTS: int = 200
|
||||||
|
|
||||||
## Number words the game prints for large amounts, with their value.
|
## Number words the game prints for large amounts, with their value.
|
||||||
NUMBER_SCALES: dict[str, int] = {
|
NUMBER_SCALES: dict[str, int] = {
|
||||||
"thousand": 10**3,
|
"thousand": 10**3,
|
||||||
|
|||||||
@@ -2,13 +2,27 @@
|
|||||||
@brief Selenium adapter for the GamePort, and the factory for the Chrome driver.
|
@brief Selenium adapter for the GamePort, and the factory for the Chrome driver.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
|
from selenium.common.exceptions import (
|
||||||
|
ElementClickInterceptedException,
|
||||||
|
StaleElementReferenceException,
|
||||||
|
)
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.remote.webdriver import WebDriver
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
from selenium.webdriver.support import expected_conditions
|
from selenium.webdriver.support import expected_conditions
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
from cookie_clicker_bot.constants import ELEMENT_TIMEOUT_SECONDS
|
from cookie_clicker_bot.constants import (
|
||||||
|
CHROME_BIN_ENV,
|
||||||
|
CHROMEDRIVER_ENV,
|
||||||
|
CLICK_RETRIES,
|
||||||
|
ELEMENT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
_HEADLESS_ARGUMENTS: tuple[str, ...] = (
|
_HEADLESS_ARGUMENTS: tuple[str, ...] = (
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
@@ -17,6 +31,28 @@ _HEADLESS_ARGUMENTS: tuple[str, ...] = (
|
|||||||
"--window-size=1280,1024",
|
"--window-size=1280,1024",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_RETRYABLE_ERRORS = (StaleElementReferenceException, ElementClickInterceptedException)
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_on_page_change[T](action: Callable[[], T]) -> T:
|
||||||
|
"""@brief Run the action again when the game re-renders the page under it.
|
||||||
|
|
||||||
|
The action finds its element itself, so each attempt works on a fresh one.
|
||||||
|
|
||||||
|
@param action Finds the element and acts on it.
|
||||||
|
@return What the action returns.
|
||||||
|
@throws StaleElementReferenceException When every attempt found a stale element.
|
||||||
|
@throws ElementClickInterceptedException When every attempt was blocked.
|
||||||
|
"""
|
||||||
|
attempts_left = CLICK_RETRIES
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return action()
|
||||||
|
except _RETRYABLE_ERRORS:
|
||||||
|
if attempts_left == 0:
|
||||||
|
raise
|
||||||
|
attempts_left -= 1
|
||||||
|
|
||||||
|
|
||||||
class SeleniumGame:
|
class SeleniumGame:
|
||||||
"""@brief Operates the game page through a Selenium web driver."""
|
"""@brief Operates the game page through a Selenium web driver."""
|
||||||
@@ -38,8 +74,10 @@ class SeleniumGame:
|
|||||||
@param element_id The HTML id of the element.
|
@param element_id The HTML id of the element.
|
||||||
@throws selenium.common.exceptions.TimeoutException When it never appears.
|
@throws selenium.common.exceptions.TimeoutException When it never appears.
|
||||||
"""
|
"""
|
||||||
WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
_retry_on_page_change(
|
||||||
expected_conditions.presence_of_element_located((By.ID, element_id))
|
lambda: WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||||
|
expected_conditions.presence_of_element_located((By.ID, element_id))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def click_element(self, element_id: str) -> None:
|
def click_element(self, element_id: str) -> None:
|
||||||
@@ -51,7 +89,12 @@ class SeleniumGame:
|
|||||||
@param element_id The HTML id of the element.
|
@param element_id The HTML id of the element.
|
||||||
@throws selenium.common.exceptions.TimeoutException When it never
|
@throws selenium.common.exceptions.TimeoutException When it never
|
||||||
becomes clickable.
|
becomes clickable.
|
||||||
|
@throws StaleElementReferenceException When the element stays stale
|
||||||
|
after CLICK_RETRIES retries.
|
||||||
"""
|
"""
|
||||||
|
_retry_on_page_change(lambda: self._click_once(element_id))
|
||||||
|
|
||||||
|
def _click_once(self, element_id: str) -> None:
|
||||||
element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||||
expected_conditions.element_to_be_clickable((By.ID, element_id))
|
expected_conditions.element_to_be_clickable((By.ID, element_id))
|
||||||
)
|
)
|
||||||
@@ -62,7 +105,9 @@ class SeleniumGame:
|
|||||||
@param element_id The HTML id of the element.
|
@param element_id The HTML id of the element.
|
||||||
@return The text as shown on the page.
|
@return The text as shown on the page.
|
||||||
"""
|
"""
|
||||||
return self._driver.find_element(By.ID, element_id).text
|
return _retry_on_page_change(
|
||||||
|
lambda: self._driver.find_element(By.ID, element_id).text
|
||||||
|
)
|
||||||
|
|
||||||
def read_attribute(self, element_id: str, attribute: str) -> str:
|
def read_attribute(self, element_id: str, attribute: str) -> str:
|
||||||
"""@brief Return an attribute of the element.
|
"""@brief Return an attribute of the element.
|
||||||
@@ -70,21 +115,61 @@ class SeleniumGame:
|
|||||||
@param attribute The attribute name, for example "class".
|
@param attribute The attribute name, for example "class".
|
||||||
@return The value, or an empty string when the attribute is absent.
|
@return The value, or an empty string when the attribute is absent.
|
||||||
"""
|
"""
|
||||||
value = self._driver.find_element(By.ID, element_id).get_attribute(attribute)
|
value = _retry_on_page_change(
|
||||||
|
lambda: self._driver.find_element(By.ID, element_id).get_attribute(
|
||||||
|
attribute
|
||||||
|
)
|
||||||
|
)
|
||||||
return value or ""
|
return value or ""
|
||||||
|
|
||||||
|
def save_screenshot(self, path: Path) -> None:
|
||||||
|
"""@brief Save a PNG of the page, creating the parent folder when needed.
|
||||||
|
@param path The file to write.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._driver.save_screenshot(str(path))
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""@brief Quit the browser and end the driver session."""
|
"""@brief Quit the browser and end the driver session."""
|
||||||
self._driver.quit()
|
self._driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_chrome_options(
|
||||||
|
headless: bool, env: Mapping[str, str]
|
||||||
|
) -> webdriver.ChromeOptions:
|
||||||
|
"""@brief Build the Chrome options.
|
||||||
|
@param headless True to run without a window, for a container or CI.
|
||||||
|
@param env The environment; CHROME_BIN names the Chrome binary when set.
|
||||||
|
@return The options for the Chrome driver.
|
||||||
|
"""
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
if headless:
|
||||||
|
for argument in _HEADLESS_ARGUMENTS:
|
||||||
|
options.add_argument(argument)
|
||||||
|
chrome_binary = env.get(CHROME_BIN_ENV)
|
||||||
|
if chrome_binary:
|
||||||
|
options.binary_location = chrome_binary
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def build_chrome_service(env: Mapping[str, str]) -> Service | None:
|
||||||
|
"""@brief Build the chromedriver service when the environment names one.
|
||||||
|
@param env The environment; CHROMEDRIVER names the executable when set.
|
||||||
|
@return The service, or None to let Selenium Manager find the driver.
|
||||||
|
"""
|
||||||
|
driver_path = env.get(CHROMEDRIVER_ENV)
|
||||||
|
if not driver_path:
|
||||||
|
return None
|
||||||
|
return Service(executable_path=driver_path)
|
||||||
|
|
||||||
|
|
||||||
def create_chrome_driver(headless: bool = False) -> WebDriver:
|
def create_chrome_driver(headless: bool = False) -> WebDriver:
|
||||||
"""@brief Build a Chrome web driver.
|
"""@brief Build a Chrome web driver.
|
||||||
@param headless True to run without a window, for a container or CI.
|
@param headless True to run without a window, for a container or CI.
|
||||||
@return The started Chrome driver.
|
@return The started Chrome driver.
|
||||||
"""
|
"""
|
||||||
options = webdriver.ChromeOptions()
|
options = build_chrome_options(headless, os.environ)
|
||||||
if headless:
|
service = build_chrome_service(os.environ)
|
||||||
for argument in _HEADLESS_ARGUMENTS:
|
if service is None:
|
||||||
options.add_argument(argument)
|
return webdriver.Chrome(options=options)
|
||||||
return webdriver.Chrome(options=options)
|
return webdriver.Chrome(options=options, service=service)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""@file
|
||||||
|
@brief Entry point that plays the five lecture steps and saves a screenshot after each.
|
||||||
|
|
||||||
|
Run it as `python -m cookie_clicker_bot.presentation.capture_steps --out DIR`.
|
||||||
|
It is a documentation tool, so it uses the Selenium adapter directly and always
|
||||||
|
runs headless.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
|
|
||||||
|
from cookie_clicker_bot.application.use_cases.bot_core import (
|
||||||
|
click_cookie,
|
||||||
|
get_cookie_count,
|
||||||
|
open_game,
|
||||||
|
select_language,
|
||||||
|
)
|
||||||
|
from cookie_clicker_bot.application.use_cases.run_bot import run_bot
|
||||||
|
from cookie_clicker_bot.application.use_cases.store import (
|
||||||
|
buy_upgrade,
|
||||||
|
get_store_items,
|
||||||
|
)
|
||||||
|
from cookie_clicker_bot.constants import (
|
||||||
|
CAPTURE_CLICK_COUNT,
|
||||||
|
CAPTURE_MAX_BURSTS,
|
||||||
|
CLICKS_PER_BURST,
|
||||||
|
RESULT_LABEL,
|
||||||
|
RUN_DURATION_SECONDS,
|
||||||
|
SCREENSHOT_BUY_UPGRADE,
|
||||||
|
SCREENSHOT_CLICK_COOKIE,
|
||||||
|
SCREENSHOT_OPEN_GAME,
|
||||||
|
SCREENSHOT_READ_COUNT,
|
||||||
|
SCREENSHOT_RESULT,
|
||||||
|
STEPS_RECORD_FILE,
|
||||||
|
)
|
||||||
|
from cookie_clicker_bot.domain.store_item import StoreItem, choose_upgrade
|
||||||
|
from cookie_clicker_bot.infrastructure.selenium_game import (
|
||||||
|
SeleniumGame,
|
||||||
|
create_chrome_driver,
|
||||||
|
)
|
||||||
|
from cookie_clicker_bot.infrastructure.system_clock import SystemClock
|
||||||
|
|
||||||
|
DriverFactory = Callable[[bool], WebDriver]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StepUnreachableError(RuntimeError):
|
||||||
|
"""@brief Raised when a lecture step cannot be reached or does not succeed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="capture_steps",
|
||||||
|
description="Play the five lecture steps and save a screenshot after each.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out", type=Path, required=True, help="folder for the screenshots"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--duration",
|
||||||
|
type=float,
|
||||||
|
default=RUN_DURATION_SECONDS,
|
||||||
|
help="seconds of the final run (default: %(default)s)",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def _click_until_affordable(game: SeleniumGame) -> StoreItem:
|
||||||
|
"""@brief Click in bursts until a product can be bought, within a fixed bound."""
|
||||||
|
for _ in range(CAPTURE_MAX_BURSTS):
|
||||||
|
item = choose_upgrade(get_store_items(game))
|
||||||
|
if item is not None:
|
||||||
|
return item
|
||||||
|
click_cookie(game, CLICKS_PER_BURST)
|
||||||
|
raise StepUnreachableError(
|
||||||
|
f"no product became affordable within {CAPTURE_MAX_BURSTS} bursts of clicks"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _buy_first_product(game: SeleniumGame) -> StoreItem:
|
||||||
|
item = _click_until_affordable(game)
|
||||||
|
if not buy_upgrade(game, item):
|
||||||
|
raise StepUnreachableError(
|
||||||
|
f"buying product {item.index} did not raise the owned amount"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _write_record(
|
||||||
|
path: Path, count: int, item: StoreItem, cps: str, duration: float
|
||||||
|
) -> None:
|
||||||
|
record = {
|
||||||
|
"cookie_count": count,
|
||||||
|
"bought": {"index": item.index, "price": item.price},
|
||||||
|
"cps": cps,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(game: SeleniumGame, out: Path, duration: float) -> str:
|
||||||
|
"""@brief Play the five steps, saving a screenshot after each; return the cps."""
|
||||||
|
open_game(game)
|
||||||
|
game.save_screenshot(out / SCREENSHOT_OPEN_GAME)
|
||||||
|
|
||||||
|
select_language(game)
|
||||||
|
click_cookie(game, CAPTURE_CLICK_COUNT)
|
||||||
|
game.save_screenshot(out / SCREENSHOT_CLICK_COOKIE)
|
||||||
|
|
||||||
|
count = get_cookie_count(game)
|
||||||
|
_logger.info("Read %d cookies", count)
|
||||||
|
game.save_screenshot(out / SCREENSHOT_READ_COUNT)
|
||||||
|
|
||||||
|
item = _buy_first_product(game)
|
||||||
|
game.save_screenshot(out / SCREENSHOT_BUY_UPGRADE)
|
||||||
|
|
||||||
|
cps = str(run_bot(game, SystemClock(), duration))
|
||||||
|
game.save_screenshot(out / SCREENSHOT_RESULT)
|
||||||
|
|
||||||
|
_write_record(out / STEPS_RECORD_FILE, count, item, cps, duration)
|
||||||
|
return cps
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Sequence[str] | None = None,
|
||||||
|
driver_factory: DriverFactory = create_chrome_driver,
|
||||||
|
) -> int:
|
||||||
|
"""@brief Capture one screenshot per lecture step and print the result.
|
||||||
|
@param argv The command line arguments, or None to read them from the process.
|
||||||
|
@param driver_factory Builds the web driver; replaced in tests so no browser starts.
|
||||||
|
@return The process exit code, zero on success.
|
||||||
|
@throws StepUnreachableError When a step cannot be reached.
|
||||||
|
"""
|
||||||
|
arguments = _parse_arguments(argv)
|
||||||
|
game = SeleniumGame(driver_factory(True))
|
||||||
|
try:
|
||||||
|
cps = _capture(game, arguments.out, arguments.duration)
|
||||||
|
print(f"{RESULT_LABEL} : {cps}")
|
||||||
|
finally:
|
||||||
|
game.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,14 +4,24 @@
|
|||||||
No browser and no network: the fake driver records what the adapter asks for.
|
No browser and no network: the fake driver records what the adapter asks for.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from selenium.common.exceptions import NoSuchElementException
|
from selenium.common.exceptions import (
|
||||||
|
ElementClickInterceptedException,
|
||||||
|
NoSuchElementException,
|
||||||
|
StaleElementReferenceException,
|
||||||
|
)
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.remote.webdriver import WebDriver
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
|
|
||||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
from cookie_clicker_bot.constants import CHROME_BIN_ENV, CHROMEDRIVER_ENV, CLICK_RETRIES
|
||||||
|
from cookie_clicker_bot.infrastructure.selenium_game import (
|
||||||
|
SeleniumGame,
|
||||||
|
build_chrome_options,
|
||||||
|
build_chrome_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeElement:
|
class FakeElement:
|
||||||
@@ -23,6 +33,7 @@ class FakeElement:
|
|||||||
self.text = text
|
self.text = text
|
||||||
self.attributes = attributes or {}
|
self.attributes = attributes or {}
|
||||||
self.clicks = 0
|
self.clicks = 0
|
||||||
|
self.failures: list[Exception] = []
|
||||||
|
|
||||||
def get_attribute(self, name: str) -> str | None:
|
def get_attribute(self, name: str) -> str | None:
|
||||||
return self.attributes.get(name)
|
return self.attributes.get(name)
|
||||||
@@ -34,6 +45,8 @@ class FakeElement:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def click(self) -> None:
|
def click(self) -> None:
|
||||||
|
if self.failures:
|
||||||
|
raise self.failures.pop(0)
|
||||||
self.clicks += 1
|
self.clicks += 1
|
||||||
|
|
||||||
|
|
||||||
@@ -45,16 +58,24 @@ class FakeDriver:
|
|||||||
self.visited: list[str] = []
|
self.visited: list[str] = []
|
||||||
self.lookups: list[tuple[str, str]] = []
|
self.lookups: list[tuple[str, str]] = []
|
||||||
self.quit_calls = 0
|
self.quit_calls = 0
|
||||||
|
self.screenshots: list[str] = []
|
||||||
|
self.lookup_failures: list[Exception] = []
|
||||||
|
|
||||||
def get(self, url: str) -> None:
|
def get(self, url: str) -> None:
|
||||||
self.visited.append(url)
|
self.visited.append(url)
|
||||||
|
|
||||||
def find_element(self, by: str, value: str) -> FakeElement:
|
def find_element(self, by: str, value: str) -> FakeElement:
|
||||||
self.lookups.append((by, value))
|
self.lookups.append((by, value))
|
||||||
|
if self.lookup_failures:
|
||||||
|
raise self.lookup_failures.pop(0)
|
||||||
if value not in self.elements:
|
if value not in self.elements:
|
||||||
raise NoSuchElementException(value)
|
raise NoSuchElementException(value)
|
||||||
return self.elements[value]
|
return self.elements[value]
|
||||||
|
|
||||||
|
def save_screenshot(self, filename: str) -> bool:
|
||||||
|
self.screenshots.append(filename)
|
||||||
|
return True
|
||||||
|
|
||||||
def quit(self) -> None:
|
def quit(self) -> None:
|
||||||
self.quit_calls += 1
|
self.quit_calls += 1
|
||||||
|
|
||||||
@@ -118,6 +139,91 @@ def test_read_attribute_returns_empty_string_when_absent() -> None:
|
|||||||
assert make_game(driver).read_attribute("product0", "class") == ""
|
assert make_game(driver).read_attribute("product0", "class") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_click_element_retries_after_a_stale_element() -> None:
|
||||||
|
element = FakeElement()
|
||||||
|
element.failures = [StaleElementReferenceException()] * CLICK_RETRIES
|
||||||
|
driver = FakeDriver({"bigCookie": element})
|
||||||
|
|
||||||
|
make_game(driver).click_element("bigCookie")
|
||||||
|
|
||||||
|
assert element.clicks == 1
|
||||||
|
assert len(driver.lookups) == CLICK_RETRIES + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_click_element_retries_after_an_intercepted_click() -> None:
|
||||||
|
element = FakeElement()
|
||||||
|
element.failures = [ElementClickInterceptedException()]
|
||||||
|
|
||||||
|
make_game(FakeDriver({"bigCookie": element})).click_element("bigCookie")
|
||||||
|
|
||||||
|
assert element.clicks == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_click_element_raises_after_the_retry_limit() -> None:
|
||||||
|
element = FakeElement()
|
||||||
|
element.failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
|
||||||
|
driver = FakeDriver({"bigCookie": element})
|
||||||
|
|
||||||
|
with pytest.raises(StaleElementReferenceException):
|
||||||
|
make_game(driver).click_element("bigCookie")
|
||||||
|
|
||||||
|
assert element.clicks == 0
|
||||||
|
assert len(driver.lookups) == CLICK_RETRIES + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_text_retries_after_a_stale_element() -> None:
|
||||||
|
driver = FakeDriver({"cookies": FakeElement("5")})
|
||||||
|
driver.lookup_failures = [StaleElementReferenceException()] * CLICK_RETRIES
|
||||||
|
|
||||||
|
assert make_game(driver).read_text("cookies") == "5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_attribute_raises_after_the_retry_limit() -> None:
|
||||||
|
driver = FakeDriver({"product0": FakeElement()})
|
||||||
|
driver.lookup_failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
|
||||||
|
|
||||||
|
with pytest.raises(StaleElementReferenceException):
|
||||||
|
make_game(driver).read_attribute("product0", "class")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_screenshot_creates_the_folder_and_writes_the_file(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
driver = FakeDriver({})
|
||||||
|
target = tmp_path / "nested" / "shot.png"
|
||||||
|
|
||||||
|
make_game(driver).save_screenshot(target)
|
||||||
|
|
||||||
|
assert driver.screenshots == [str(target)]
|
||||||
|
assert target.parent.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chrome_options_use_the_binary_from_the_environment() -> None:
|
||||||
|
options = build_chrome_options(True, {CHROME_BIN_ENV: "/usr/bin/chromium"})
|
||||||
|
|
||||||
|
assert options.binary_location == "/usr/bin/chromium"
|
||||||
|
assert "--headless=new" in options.arguments
|
||||||
|
assert "--no-sandbox" in options.arguments
|
||||||
|
|
||||||
|
|
||||||
|
def test_chrome_options_leave_the_binary_alone_without_the_variable() -> None:
|
||||||
|
options = build_chrome_options(False, {})
|
||||||
|
|
||||||
|
assert options.binary_location == ""
|
||||||
|
assert options.arguments == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_chrome_service_uses_the_driver_from_the_environment() -> None:
|
||||||
|
service = build_chrome_service({CHROMEDRIVER_ENV: "/usr/bin/chromedriver"})
|
||||||
|
|
||||||
|
assert service is not None
|
||||||
|
assert service.path == "/usr/bin/chromedriver"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chrome_service_is_absent_without_the_variable() -> None:
|
||||||
|
assert build_chrome_service({}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_close_quits_the_driver() -> None:
|
def test_close_quits_the_driver() -> None:
|
||||||
driver = FakeDriver({})
|
driver = FakeDriver({})
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ class FakeGame:
|
|||||||
texts: dict[str, str] | None = None,
|
texts: dict[str, str] | None = None,
|
||||||
attributes: dict[tuple[str, str], str] | None = None,
|
attributes: dict[tuple[str, str], str] | None = None,
|
||||||
on_click: Callable[[str], None] | None = None,
|
on_click: Callable[[str], None] | None = None,
|
||||||
|
text_sequences: dict[str, list[str]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.texts: dict[str, str] = texts or {}
|
self.texts: dict[str, str] = texts or {}
|
||||||
self.attributes: dict[tuple[str, str], str] = attributes or {}
|
self.attributes: dict[tuple[str, str], str] = attributes or {}
|
||||||
self.on_click = on_click
|
self.on_click = on_click
|
||||||
|
## Texts returned one per read, before the plain text applies.
|
||||||
|
self.text_sequences: dict[str, list[str]] = text_sequences or {}
|
||||||
|
self.reads: dict[str, int] = {}
|
||||||
self.opened: list[str] = []
|
self.opened: list[str] = []
|
||||||
self.waited: list[str] = []
|
self.waited: list[str] = []
|
||||||
self.clicked: list[str] = []
|
self.clicked: list[str] = []
|
||||||
@@ -34,6 +38,10 @@ class FakeGame:
|
|||||||
self.on_click(element_id)
|
self.on_click(element_id)
|
||||||
|
|
||||||
def read_text(self, element_id: str) -> str:
|
def read_text(self, element_id: str) -> str:
|
||||||
|
self.reads[element_id] = self.reads.get(element_id, 0) + 1
|
||||||
|
queued = self.text_sequences.get(element_id)
|
||||||
|
if queued:
|
||||||
|
return queued.pop(0)
|
||||||
return self.texts[element_id]
|
return self.texts[element_id]
|
||||||
|
|
||||||
def read_attribute(self, element_id: str, attribute: str) -> str:
|
def read_attribute(self, element_id: str, attribute: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""@file
|
||||||
|
@brief Tests of the screenshot capture entry point; no browser is started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
|
|
||||||
|
from cookie_clicker_bot.application.ports.clock import Clock
|
||||||
|
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||||
|
from cookie_clicker_bot.constants import (
|
||||||
|
CAPTURE_CLICK_COUNT,
|
||||||
|
CAPTURE_MAX_BURSTS,
|
||||||
|
CLICKS_PER_BURST,
|
||||||
|
RUN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
from cookie_clicker_bot.domain.store_item import StoreItem
|
||||||
|
from cookie_clicker_bot.presentation import capture_steps
|
||||||
|
|
||||||
|
STEP_FILES = [
|
||||||
|
"step-1-open-game.png",
|
||||||
|
"step-2-click-cookie.png",
|
||||||
|
"step-3-read-count.png",
|
||||||
|
"step-4-buy-upgrade.png",
|
||||||
|
"step-5-result.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDriver:
|
||||||
|
def __init__(self, events: list[str]) -> None:
|
||||||
|
self.events = events
|
||||||
|
self.quit_calls = 0
|
||||||
|
|
||||||
|
def save_screenshot(self, filename: str) -> bool:
|
||||||
|
Path(filename).write_bytes(b"png")
|
||||||
|
self.events.append(f"shot:{Path(filename).name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
self.quit_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
class Script:
|
||||||
|
"""Stands in for the use cases and the driver factory and records the order."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events: list[str] = []
|
||||||
|
self.driver = FakeDriver(self.events)
|
||||||
|
self.headless: list[bool] = []
|
||||||
|
self.durations: list[float] = []
|
||||||
|
self.clicks: list[int] = []
|
||||||
|
self.affordable_after_bursts = 2
|
||||||
|
self.store_reads = 0
|
||||||
|
self.purchase_works = True
|
||||||
|
|
||||||
|
def driver_factory(self, headless: bool) -> WebDriver:
|
||||||
|
self.headless.append(headless)
|
||||||
|
return cast(WebDriver, self.driver)
|
||||||
|
|
||||||
|
def open_game(self, game: GamePort) -> None:
|
||||||
|
self.events.append("open")
|
||||||
|
|
||||||
|
def select_language(self, game: GamePort) -> None:
|
||||||
|
self.events.append("language")
|
||||||
|
|
||||||
|
def click_cookie(self, game: GamePort, times: int = 1) -> None:
|
||||||
|
self.clicks.append(times)
|
||||||
|
self.events.append(f"click:{times}")
|
||||||
|
|
||||||
|
def get_cookie_count(self, game: GamePort) -> int:
|
||||||
|
self.events.append("count")
|
||||||
|
return 42
|
||||||
|
|
||||||
|
def get_store_items(self, game: GamePort) -> list[StoreItem]:
|
||||||
|
self.store_reads += 1
|
||||||
|
affordable = len(self.clicks) - 1 >= self.affordable_after_bursts
|
||||||
|
return [StoreItem(3, 100, affordable)]
|
||||||
|
|
||||||
|
def buy_upgrade(self, game: GamePort, item: StoreItem) -> bool:
|
||||||
|
self.events.append(f"buy:{item.index}")
|
||||||
|
return self.purchase_works
|
||||||
|
|
||||||
|
def run_bot(self, game: GamePort, clock: Clock, duration: float) -> Decimal:
|
||||||
|
self.events.append("run")
|
||||||
|
self.durations.append(duration)
|
||||||
|
return Decimal("12.5")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def script(monkeypatch: pytest.MonkeyPatch) -> Script:
|
||||||
|
s = Script()
|
||||||
|
for name in (
|
||||||
|
"open_game",
|
||||||
|
"select_language",
|
||||||
|
"click_cookie",
|
||||||
|
"get_cookie_count",
|
||||||
|
"get_store_items",
|
||||||
|
"buy_upgrade",
|
||||||
|
"run_bot",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(capture_steps, name, getattr(s, name))
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_saves_one_screenshot_per_step_in_order(
|
||||||
|
script: Script, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
assert capture_steps.main(["--out", str(tmp_path)], script.driver_factory) == 0
|
||||||
|
shots = [e for e in script.events if e.startswith("shot:")]
|
||||||
|
assert shots == ["shot:" + name for name in STEP_FILES]
|
||||||
|
assert all((tmp_path / name).read_bytes() == b"png" for name in STEP_FILES)
|
||||||
|
assert script.events[:5] == [
|
||||||
|
"open",
|
||||||
|
"shot:step-1-open-game.png",
|
||||||
|
"language",
|
||||||
|
f"click:{CAPTURE_CLICK_COUNT}",
|
||||||
|
"shot:step-2-click-cookie.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_runs_headless_closes_and_prints_the_result(
|
||||||
|
script: Script, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||||
|
assert script.headless == [True]
|
||||||
|
assert script.driver.quit_calls == 1
|
||||||
|
assert capsys.readouterr().out == "Result : 12.5\n"
|
||||||
|
assert script.durations == [RUN_DURATION_SECONDS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_writes_the_steps_record(script: Script, tmp_path: Path) -> None:
|
||||||
|
capture_steps.main(
|
||||||
|
["--out", str(tmp_path), "--duration", "30"], script.driver_factory
|
||||||
|
)
|
||||||
|
record = json.loads((tmp_path / "steps.json").read_text(encoding="utf-8"))
|
||||||
|
assert record == {
|
||||||
|
"cookie_count": 42,
|
||||||
|
"bought": {"index": 3, "price": 100},
|
||||||
|
"cps": "12.5",
|
||||||
|
"duration_seconds": 30.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_creates_the_output_folder(script: Script, tmp_path: Path) -> None:
|
||||||
|
out = tmp_path / "nested" / "shots"
|
||||||
|
capture_steps.main(["--out", str(out)], script.driver_factory)
|
||||||
|
assert (out / STEP_FILES[0]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_clicks_in_bursts_until_a_product_is_affordable(
|
||||||
|
script: Script, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||||
|
assert script.clicks == [CAPTURE_CLICK_COUNT] + [CLICKS_PER_BURST] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_raises_when_nothing_becomes_affordable(
|
||||||
|
script: Script, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
script.affordable_after_bursts = CAPTURE_MAX_BURSTS + 10
|
||||||
|
with pytest.raises(capture_steps.StepUnreachableError, match="affordable"):
|
||||||
|
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||||
|
assert script.store_reads == CAPTURE_MAX_BURSTS
|
||||||
|
assert script.driver.quit_calls == 1
|
||||||
|
assert not (tmp_path / "step-4-buy-upgrade.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_raises_when_the_purchase_does_not_go_through(
|
||||||
|
script: Script, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
script.purchase_works = False
|
||||||
|
with pytest.raises(capture_steps.StepUnreachableError, match="owned amount"):
|
||||||
|
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||||
|
assert script.driver.quit_calls == 1
|
||||||
|
assert not (tmp_path / "steps.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_requires_the_out_argument(script: Script) -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
capture_steps.main([], script.driver_factory)
|
||||||
@@ -14,6 +14,7 @@ from cookie_clicker_bot.constants import (
|
|||||||
COOKIE_COUNTER_ID,
|
COOKIE_COUNTER_ID,
|
||||||
CPS_ID,
|
CPS_ID,
|
||||||
PRODUCT_ID_PREFIX,
|
PRODUCT_ID_PREFIX,
|
||||||
|
PRODUCT_OWNED_ID_PREFIX,
|
||||||
PRODUCT_PRICE_ID_PREFIX,
|
PRODUCT_PRICE_ID_PREFIX,
|
||||||
RUN_DURATION_SECONDS,
|
RUN_DURATION_SECONDS,
|
||||||
STORE_CHECK_INTERVAL_SECONDS,
|
STORE_CHECK_INTERVAL_SECONDS,
|
||||||
@@ -31,6 +32,7 @@ def make_game(cookies: int = 100, price: int = 15) -> FakeGame:
|
|||||||
COOKIE_COUNTER_ID: f"{cookies} cookies",
|
COOKIE_COUNTER_ID: f"{cookies} cookies",
|
||||||
f"{PRODUCT_PRICE_ID_PREFIX}0": str(price),
|
f"{PRODUCT_PRICE_ID_PREFIX}0": str(price),
|
||||||
CPS_ID: "per second: 4.5",
|
CPS_ID: "per second: 4.5",
|
||||||
|
f"{PRODUCT_OWNED_ID_PREFIX}0": "",
|
||||||
},
|
},
|
||||||
attributes={(PRODUCT, CLASS_ATTRIBUTE): "product unlocked enabled"},
|
attributes={(PRODUCT, CLASS_ATTRIBUTE): "product unlocked enabled"},
|
||||||
)
|
)
|
||||||
@@ -38,6 +40,7 @@ def make_game(cookies: int = 100, price: int = 15) -> FakeGame:
|
|||||||
def spend(element_id: str) -> None:
|
def spend(element_id: str) -> None:
|
||||||
if element_id == PRODUCT:
|
if element_id == PRODUCT:
|
||||||
game.texts[COOKIE_COUNTER_ID] = f"{cookies - price} cookies"
|
game.texts[COOKIE_COUNTER_ID] = f"{cookies - price} cookies"
|
||||||
|
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
|
||||||
game.attributes[(PRODUCT, CLASS_ATTRIBUTE)] = "product unlocked disabled"
|
game.attributes[(PRODUCT, CLASS_ATTRIBUTE)] = "product unlocked disabled"
|
||||||
|
|
||||||
game.on_click = spend
|
game.on_click = spend
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from cookie_clicker_bot.constants import (
|
|||||||
CLASS_ATTRIBUTE,
|
CLASS_ATTRIBUTE,
|
||||||
COOKIE_COUNTER_ID,
|
COOKIE_COUNTER_ID,
|
||||||
CPS_ID,
|
CPS_ID,
|
||||||
|
OWNED_CHECK_ATTEMPTS,
|
||||||
PRODUCT_ID_PREFIX,
|
PRODUCT_ID_PREFIX,
|
||||||
|
PRODUCT_OWNED_ID_PREFIX,
|
||||||
PRODUCT_PRICE_ID_PREFIX,
|
PRODUCT_PRICE_ID_PREFIX,
|
||||||
)
|
)
|
||||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||||
@@ -65,23 +67,77 @@ def test_get_store_items_propagates_an_unreadable_price() -> None:
|
|||||||
get_store_items(game)
|
get_store_items(game)
|
||||||
|
|
||||||
|
|
||||||
def test_buy_upgrade_returns_true_when_the_cookies_drop() -> None:
|
def test_buy_upgrade_returns_true_when_the_owned_amount_grows() -> None:
|
||||||
game = FakeGame(texts={COOKIE_COUNTER_ID: "100 cookies"})
|
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
|
||||||
|
|
||||||
def spend(element_id: str) -> None:
|
def buy(element_id: str) -> None:
|
||||||
game.texts[COOKIE_COUNTER_ID] = "85 cookies"
|
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}3"] = "3"
|
||||||
|
|
||||||
game.on_click = spend
|
game.on_click = buy
|
||||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||||
|
|
||||||
|
|
||||||
def test_buy_upgrade_returns_false_when_the_cookies_do_not_drop() -> None:
|
def test_buy_upgrade_treats_an_empty_owned_text_as_zero() -> None:
|
||||||
game = FakeGame(texts={COOKIE_COUNTER_ID: "100 cookies"})
|
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}0": ""})
|
||||||
|
|
||||||
|
def buy(element_id: str) -> None:
|
||||||
|
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
|
||||||
|
|
||||||
|
game.on_click = buy
|
||||||
|
assert buy_upgrade(game, StoreItem(0, 15, True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_returns_false_when_the_owned_amount_is_unchanged() -> None:
|
||||||
|
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
|
||||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_game(after: list[str]) -> FakeGame:
|
||||||
|
"""Owned text "2" before the click, then the given texts, one per read."""
|
||||||
|
owned = f"{PRODUCT_OWNED_ID_PREFIX}3"
|
||||||
|
return FakeGame(texts={owned: "2"}, text_sequences={owned: ["2", *after]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_waits_for_a_late_redraw() -> None:
|
||||||
|
game = _owned_game(["2", "3"])
|
||||||
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||||
|
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 3
|
||||||
|
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_gives_up_after_the_attempt_limit() -> None:
|
||||||
|
game = _owned_game([])
|
||||||
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||||
|
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 1 + OWNED_CHECK_ATTEMPTS
|
||||||
|
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_accepts_a_change_on_the_last_attempt() -> None:
|
||||||
|
game = _owned_game(["2"] * (OWNED_CHECK_ATTEMPTS - 1) + ["3"])
|
||||||
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_rejects_a_change_one_attempt_too_late() -> None:
|
||||||
|
game = _owned_game(["2"] * OWNED_CHECK_ATTEMPTS + ["3"])
|
||||||
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_ignores_the_cookie_count() -> None:
|
||||||
|
game = FakeGame(
|
||||||
|
texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "", COOKIE_COUNTER_ID: "100 cookies"}
|
||||||
|
)
|
||||||
|
game.on_click = lambda _id: game.texts.update({COOKIE_COUNTER_ID: "90 cookies"})
|
||||||
|
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_upgrade_propagates_an_unreadable_owned_amount() -> None:
|
||||||
|
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "???"})
|
||||||
|
with pytest.raises(UnreadableNumberError):
|
||||||
|
buy_upgrade(game, StoreItem(3, 15, True))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("text", "expected"),
|
("text", "expected"),
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user