Buy tickets (200 coins each, max 100/draw); one winner is drawn daily at 21:00
Tallinn time weighted by ticket count and credited the whole pot. Coin-conserving
by design: each ticket's cost is deducted at purchase and the winner is minted
exactly the sum of all ticket spend - no shared pot record, so no cross-period
race. Ticket state lives per-user keyed by draw period (full scan only at draw
time and for the pot view).
- New lottery.py: TICKET_COST/MAX_TICKETS/DRAW_HOUR, pure period_for, and
do_buy_ticket / get_lottery_state / do_lottery_draw. New lottery_tickets +
lottery_period schema fields (period added to _TEXT_FIELDS).
- /lottery [kogus] command (view or buy) with full failure handling.
- Scheduled lottery_draw_daily loop in bot.py (both profiles; each draws its own
collection), announcing to the optional LOTTERY_CHANNEL_ID (config + .env).
14 tests: period boundary, buy/accumulate/reset/caps/guards, pot state, draw
payout with coin conservation, and the more-tickets-wins-more weighting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
15 one-time achievements over the lifetime stat counters (work/wealth/gambling/
crime/heists/fishing/streaks/prestige), each paying a modest one-time coin reward
when unlocked. Detection is lazy - opening /achievements claims any newly earned
(like quests roll on view) - so no per-command hook is needed, and rewards are a
bounded, one-time coin source.
- New achievements.py: ACHIEVEMENTS table, pure newly_earned/achievements_view,
and locked do_check_achievements. New achievements_earned schema field.
- /achievements command (own view claims; viewing others is read-only) with
progress bars, db_error handling, and an "unlocked" banner.
8 tests: threshold detection, one-time claim (no double pay), multi-unlock,
view progress capping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
A strategic counterpart to /rob: coins moved to the bank are safe from /rob and
/heist (which only touch liquid balance) but earn no Bot Farm interest and can't
be spent, gambled or given until withdrawn - the deliberate trade-off against
keeping coins liquid.
- New bank.py (do_deposit/do_withdraw), bank_balance schema field.
- /bank (view), /deposit, /withdraw commands ('all' supported), with db_error
handling; /balance shows the vault when non-zero.
- Coins leaderboard and /status money-supply now count net worth
(balance + bank_balance), so banking never hides you from the board or the
supply metric.
- Season reset wipes bank_balance too (no cross-season wealth hiding).
10 tests: deposit/withdraw math, guards, coin conservation, rob cannot touch the
vault, and net-worth accounting on the leaderboard + stats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
A pay-to-open box (1000 coins) with a weighted reward: coin tiers (usually a net
loss - the sink), a random 30-min earn/exp buff, or a rare jackpot. All rolling
lives in do_open_lootbox for testability; the command adds a short reveal.
- New core module lootbox.py; extracted consumables.grant_buff (reused by both
consumables and lootbox) to avoid duplicating the buff-stacking logic.
- New lootboxes_opened stat; pending schema syncs as a number automatically.
- Added /consumables, /lootbox, /vanity to the help embed (the first two were
previously missing from /help).
Tests cover charging, insufficient/banned, the coin and buff outcomes, the
net-vs-reward invariant, and that balance never goes negative.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
Blackjack and RPS PvP deduct a stake up front and held it only in an in-memory
View - a restart mid-hand lost the coins. Now the stake is escrowed on the user
record (new pending_wager JSON field) in the SAME commit as the deduction, and:
- do_blackjack_bet accumulates the escrow (covers double/split); do_blackjack_payout
clears it on settlement (incl. the 0-payout loss/timeout paths).
- do_rps_pvp_deposit records it; do_rps_pvp_payout/refund clear it, and a new
do_rps_pvp_forfeit clears the loser's marker (their stake went to the winner).
- reconcile_pending_wagers() runs on startup (on_ready) and refunds any stake left
escrowed by an interrupted game. It's idempotent and locks per user.
Schema: pending_wager auto-types as json via sync_pb_schema. Tests cover the full
escrow lifecycle, loser forfeit, and idempotent reconciliation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
Adds economy.get_economy_stats() (single-scan total coins, house balance,
player-held coins, player count) and a "Rahavaru" field to /status, so admins
can see whether the sinks are keeping pace with minted income. Also removed a
duplicate bot_admin_check import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
Three robustness/perf fixes from the review:
1. db_error UX: economy core returns {"ok": False, "reason": "db_error"} on a
PocketBase outage, but no handler expected it - deferred commands hung on
"thinking..." and others showed a misleading "you're broke". Added a shared
reply_db_error helper (commands/_replies.py) + S.ERR["db_error"], and wired a
db_error branch into every handler that can receive it (daily/work/beg/crime/
rob/give/buy/roulette/slots/blackjack/heist/fish/prestige/vanity/consumables/
request-funding). Also fixed a latent KeyError in vs-bot RPS that read
res["balance"] without checking res["ok"].
2. Deduped the item->cooldown mapping that was copied in do_daily/do_work/do_beg,
do_fish_start, _maybe_remind and _restore_reminders. Single source of truth:
store.ITEM_COOLDOWNS + effective_cooldown(cmd, items).
3. /leaderboard did six full-collection scans (one per tab). Added
get_all_leaderboards() which scans once and builds all six views in memory.
Tests: effective_cooldown cases, and get_all_leaderboards matches the individual
queries + scans the collection exactly once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink -
purchases burn coins (not credited to the house) and equip a badge shown on
/profile. New vanity_owned/vanity_active fields, schema sync, and tests.
Money-safety and robustness fixes from a codebase review:
- _parse_amount now rejects negative amounts. Every bet/give/request flows
through it, so a negative value can no longer mint coins on a loss/transfer
path that trusts the caller's sign (all call sites already guarded <= 0;
this closes the source).
- do_blackjack_payout no longer raises on a DB failure. The stake was already
deducted in do_blackjack_bet, so it now logs critical with the owed amount
(for admin reconciliation) and returns db_error; all payout call sites render
a clear "payout failed" notice instead of crashing the interaction.
- Instant "kohv" consumable now cancels the pending reminder DMs for the
cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate
reminders fire.
- Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it
debits the house) and dropped its ignored first arg.
- Added __all__ to vanity.py and consumables.py so `import *` no longer leaks
incidental imports into the economy namespace.
- Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES.
Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
The registration-log sheet only has in-game nicknames, so matching Discord
users to teams failed for ~10 of 40 teams. Fienta collects each competitor's
Discord username (+ sometimes user ID) and team name per ticket, giving a
reliable Discord-identity -> team mapping (validated: 201 usernames, 42 teams).
- core/fienta.py: token-auth client; fetch /events/{id}/tickets?attendees=true,
parse competitor/coach/substitute tickets into {username|id -> team} and
{team -> game}; exclude visitor/supporter/LAN/early-bird/waiting-list. No-op
when FIENTA_API_TOKEN/FIENTA_EVENT_ID unset.
- member_sync: resolve_team() tries Fienta (id, then username) then the sheet;
all_managed_team_names() and team_dividers() merge both sources.
- /teamsync + hourly task refresh Fienta alongside the sheet; enabled when
either source is configured.
- config + .env.example: FIENTA_API_TOKEN, FIENTA_EVENT_ID.
- tests: fienta parsing (game detection, inclusion rules, id/username mapping).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
Reuse the existing CS2/LoL divider roles as participant tags: sync_team_role
now also adds the divider role for the member's team's game, and strips any
other configured divider role (switched game / dropped out). Matched by ID
against config.TEAM_DIVIDERS, so only the divider roles are ever touched.
- TeamSyncResult gains divider_added / divider_removed; summary tallies both
- /teamsync report and log line surface divider_assigned / divider_removed
- tests: grants the game divider, swaps it on a game switch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
Section titles are stacked single-cell rows: the "TipiLAN 2026 CS2" title
sits above notice rows ("If a team withdraws..."), and parse_team_sections
kept only the LAST one, so the notice clobbered the title and resolve_divider
saw no game/year keywords -> None -> teams never positioned.
- parse_team_sections now accumulates all single-cell rows above a header, so
the game/year title survives alongside the notices.
- _refresh_teams_sync resolves the divider from "<tab name> <section title>",
so the game is taken reliably from the CS2/LoL worksheet name while the year
still comes from the title, keeping year-scoped TEAM_DIVIDER_*_2026 vars.
- regression test: title survives notice rows and still resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
Divider placement matched the divider role by its exact Discord name, so
renaming the role in Discord silently broke positioning. Switch the
TEAM_DIVIDER_<SUFFIX> config to hold a role ID; resolve the ID to the
role's current name in apply_team_role_positions and keep the existing
name-based ordering maths downstream unchanged.
- config._parse_team_dividers now parses values as ints (rejects non-ints)
- resolve_divider / _team_divider cache / get_team_dividers return IDs
- apply_team_role_positions resolves each ID via guild.get_role once
- .env.example documents IDs and ships the CS2/LoL divider role IDs
- resolve_divider tests updated to assert IDs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
The team-role feature was aimed at the wrong bot. Tournament participants
live in the economy/community guild, but team-role sync had been bolted onto
the dev bot's roster sync (sync_member), which bails out for anyone missing
from the internal member sheet - so it could never reach its actual audience.
Decouple it: keep the (roster-independent) team-sheet parsing, pull the team
wiring off the dev/member-sync path, and re-home it on the economy bot.
- core/member_sync: revert sync_member to add-only (drop team block +
SyncResult.roles_removed); add roster-independent sync_team_role and a
whole-guild sync_all_team_roles returning a reporting summary. Still only
ever touches role NAMES present in the team sheet.
- commands/economy_team_commands: new admin-only /teamsync command.
- bot.py: hourly team_sync_hourly task (economy-only, no-op unless
TEAM_SHEET_ID is set; first tick at boot covers startup load); register
/teamsync under the economy profile; drop the dev-side startup load.
- commands/dev_member_commands: /check no longer refreshes teams or reports
removed roles.
- strings + .env.example: TEAMSYNC_UI, CMD[teamsync], document TEAM_SHEET_ID.
- tests: retarget sync tests to sync_team_role; add sync_all_team_roles case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
Match members by Discord username against the lineup nicknames in the
separate registration spreadsheet (TEAM_SHEET_ID) and give each their
team's role. One team per person: switching teams removes the old team
role, and a team with no Discord role yet is auto-created. Only role
names present in the sheet are ever touched, so organisation/field/base
roles are never at risk; the feature is a no-op when TEAM_SHEET_ID is
unset.
The sheet is not a single table (merged rows, stacked CS2/LoL sections
with different layouts), so it is parsed via raw-row scanning rather than
get_all_records. Citizenship markers like "(EST)" are used only as
player delimiters and discarded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The economy had many coin faucets but almost no sinks: the /shop is
one-time ownership, and gambling/rob fines route to the house (which
players drain back via jackpots and heists), so they recirculate rather
than destroy coins. Result: steady inflation.
Add a consumables shop as a true recurring sink - buying destroys the
coins and grants a temporary boost, so there's always something to spend
on after gear is maxed:
- Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime
- XP jook (500) - 1h of 2x EXP
- Kohv (300) - instantly clears all cooldowns
Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned
on read; rebuying extends the timer. Effects hook where they belong:
earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in
levels.award_exp; kohv is self-contained. New /consumables command browses
the menu (with active buffs) or buys a boost. Covered by 9 tests.
Note: active_buffs is a new PocketBase field - run
scripts/sync_pb_schema.py before deploying or buffs won't persist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
strings/ is split into domain submodules whose names are re-exported from
strings/__init__.py. Adding a constant to a submodule and forgetting to
re-export it - or shadowing a name across two submodules - would only
surface as a runtime crash in a command. This test asserts every submodule
__all__ entry is reachable as strings.NAME, that no name is defined twice,
and that strings.__all__ matches the union of the submodules. Submodules
are auto-discovered, so a new one is covered without editing the test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-agent audit of the money-moving economy modules surfaced three
confirmed correctness bugs. All three are fixed here with regression tests.
heist (core/economy/heist.py):
- `house = await get_user(house.HOUSE_ID)` shadowed the imported `house`
module for the whole function, so `house.HOUSE_ID` raised UnboundLocalError
on every successful heist (win payout was entirely dead) and on the
fail-path compensation branch. Rename the local to `house_rec`.
- Un-shadowing exposed a latent mint: the pot was floored at 300 but the
house was debited only min(total, balance), so a poor house paid out more
than it lost. Cap the pot at the balance and debit exactly what is paid
(house debit == sum of payouts). No mint, no leak.
- Add the missing `_is_jailed` import (do_heist_check referenced it unimported).
blackjack (commands/economy_games_commands.py):
- Button callbacks had no reentrancy guard; discord.py dispatches each click
as its own task, so double-clicking Stand within the dealer-reveal window
paid out twice (mint), and double-clicking Double/Split deducted the extra
bet twice. Add a synchronous `_busy` guard (matching the existing RpsGame
idiom) on all four callbacks plus a `_resolved` idempotency flag on
settlement, so a game can only pay out once.
bail (core/economy/jail.py, commands/economy_extra_commands.py):
- do_bail only checked balance, never jail state; a double-click or a stale
BailView from a re-run /jailbreak charged bail twice, destroying coins (bail
is a pure sink). Make do_bail a no-op when the user is not jailed, and add a
UI reentrancy guard + "already free" message.
Tests: 47 passed (4 new regression tests covering heist coin-conservation and
bail idempotency).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>