docs: add CLAUDE.md guidance for Claude Code

Architecture overview, dev/test commands, the dual-profile and re-export
patterns, the economy locking/PocketBase-schema footguns, and the test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
This commit is contained in:
Rene Arumetsa
2026-09-04 02:09:33 +03:00
parent 037628d24f
commit 7a28617657

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Discord bot for the TipiLAN community (`discord.py`). Two responsibilities: **member management** (syncs nicknames/roles from a Google Sheet, birthday announcements) and the **TipiCOIN economy** (a large game economy stored in PocketBase). User-facing text is **Estonian**; only `docs/PATCHNOTES.md` content is English.
See `README.md` for the full feature/gameplay spec and `docs/DEV_NOTES.md` for the developer reference (add-a-command / add-a-shop-item checklists, constants).
## Commands
```bash
# Run tests (pure logic + economy flows against an in-memory PocketBase fake)
python -m pytest tests/ -q
# Single file / single test
python -m pytest tests/test_economy_pure.py -q
python -m pytest tests/test_economy_flows.py::TestDaily::test_streak -q
# Run the bot (requires .env; PocketBase must already be running)
BOT_PROFILE=dev python bot.py # or BOT_PROFILE=economy
```
Deps: `pip install -r requirements-dev.txt` (dev = runtime + pytest). CI (`.gitea/workflows/deploy.yml`) runs `pytest tests/ -q` on push to `master`, then deploys to the host by restarting `tipibot_dev` (canary) and `tipibot_eco` systemd units. The bot runs on host `tipilan-bots:/root/tipibot`; the local checkout has no `.env`/live DB.
## Architecture
Three layers, deliberately decoupled:
- **`core/`** — domain logic, **no Discord objects**. `core/economy/` is the economy package.
- **`commands/`** — one Discord slash-command group per file. Each exposes `register_<group>_commands(tree, bot, ...)`.
- **`bot.py`** — thin wiring layer: Discord client, event handlers (`on_ready`, `on_member_join`), background tasks, shared helpers, and it calls every `register_*_commands(...)` on startup, passing shared helpers down (`coin`, `cd_ts`, `award_exp`, `maybe_remind`, `parse_amount`).
### Dual-profile design (dev vs economy)
`BOT_PROFILE` env var (`dev` | `economy`) selects **everything** at import time in `config.py`: which Discord token, guild ID, birthday channel, and **PocketBase collection** (`economy_users_dev` vs `economy_users_prod`) are used. Legacy non-suffixed env vars act as fallbacks. Logs and `data/` are also namespaced per profile (`logs/<profile>/`, `data/<profile>/`). `/check`, `/member`, `/birthdays` are **dev-profile only**. When adding config, follow the `_DEV` / `_ECONOMY` + legacy-fallback pattern.
### The re-export pattern (important — two places)
Both `strings/` and `core/economy/` are packages split into submodules but **re-exported flat** through their `__init__.py`, so callers use `import strings as S; S.NAME` and `from core import economy; economy.do_daily(...)` unchanged.
- **Edit the submodule**, not the `__init__`. Strings live in `strings/{common,commands,member,economy,admin,games,fishing}.py`; economy logic in `core/economy/{store,income,gambling,fishing,jail,heist,prestige,shop,levels,quests,leaderboards,house,admin}.py`.
- `tests/test_strings.py` guards that every submodule name is re-exported — a new string that isn't re-exported fails CI.
- Caveat (see `tests/conftest.py:93`): the re-exported `economy.house` mutable state is a snapshot; live house state is owned by `economy.house`. Patch the submodule, not the alias.
### Economy persistence & concurrency (`core/economy/store.py`)
- Every mutation is a **read-modify-write**: `get_user(id)` → mutate the `UserData` dict → `await _commit(id, user)`. Money/EXP changes must also call `_txn(...)` for the transaction log.
- **Per-user async locks** serialize commits. Public mutating functions are decorated with `@_locked_by(<arg positions of user ids>)`. Two hard rules to avoid deadlock/lost-writes:
1. A `@_locked_by` function must **never call another `@_locked_by` function**.
2. House balance changes go through `_credit_house` (an atomic PocketBase increment, no lock) so they're safe to call while holding user locks.
Multi-user functions (`do_give`, `do_rob`) list both positions and acquire locks in sorted order.
- **PocketBase silently drops writes to fields not in the collection schema** — this is the #1 footgun. Adding any new persisted field means adding it to `_default_user()` **and** running `python scripts/sync_pb_schema.py` (reconciles both dev + prod collections). `store.missing_schema_fields()` / `/status` surface drift.
### Command result convention
`core.economy` functions return a dict with a `"reason"` key describing the outcome (success, `"cooldown"`, `"jailed"`, `"banned"`, insufficient funds, ...). The command handler in `commands/` maps every possible `res["reason"]` to a string/embed. On success it calls `award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` and `maybe_remind(...)` where relevant.
## Adding an economy command or shop item
Follow the exact ordered checklists in `docs/DEV_NOTES.md` ("Adding a New Economy Command", "Adding a New Shop Item"). They enumerate every touchpoint across `core/economy/`, `strings/`, `commands/`, and `bot.py` (cooldowns, EXP rewards, help embed, reminders, item-modified cooldown branches). Missing a step generally means a silently broken feature rather than an error.
## Tests
No `pytest-asyncio`. Async tests wrap coroutines with the `run(coro)` helper from `tests/conftest.py` (which is `asyncio.run`). The `fake_pb` fixture monkeypatches `core.pb_client` with an in-memory `FakePocketBase` that mimics real PocketBase behaviour — including atomic `field+`/`field-` increments and **silently dropping writes to fields missing from the schema** (use `fake_pb_without_quest_fields` to simulate pre-migration schema drift). Prefer testing `core/` logic directly; there's no Discord in the test path.